SSE и iostream: неправильный вывод для типов с плавающей запятой
test.cpp:
#include <iostream>
using namespace std;
int main()
{
double pi = 3.14;
cout << "pi:"<< pi << endl;
}
При компиляции на cygwin 64-bit с g++ -mno-sse test.cpp
, вывод:
пи:0
Тем не менее, он работает правильно, если скомпилирован с g++ test.cpp
,
У меня GCC версии 5.4.0.
1 ответ
Да, я повторяю это. Ну, в основном. Я на самом деле не получаю вывод 0, но какой-то другой вывод мусора. Таким образом, я могу воспроизвести недопустимое поведение, и я точно определил причину.
Вы можете увидеть код, который GCC 5.4.0 генерирует с -m64 -mno-sse
флаги здесь в проводнике компилятора Голдболта. В частности, это инструкции, которые нас интересуют:
// double pi = 3.14;
fld QWORD PTR .LC0[rip]
fstp QWORD PTR [rbp-8]
// std::cout << "pi:";
mov esi, OFFSET FLAT:.LC1
mov edi, OFFSET FLAT:std::cout
call std::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)
// std::cout << pi;
sub rsp, 8
push QWORD PTR [rbp-8]
mov rdi, rax
call std::basic_ostream<char, std::char_traits<char> >::operator<<(double)
add rsp, 16
Что тут происходит? Ну, во-первых, нам нужно понять, что -mno-sse
флаг означает. Это препятствует тому, чтобы компилятор генерировал любой код, который использует инструкции SSE (так же как любые более поздние расширения набора инструкций). Следовательно, это означает, что все операции с плавающей запятой должны выполняться с использованием устаревшего x87 FPU. Это прекрасно работает и хорошо поддерживается на 32-битных сборках, но это бессмысленно на 64-битных сборках. Спецификация AMD64 требует как минимум поддержки SSE2, поэтому можно предположить, что все 64-битные процессоры x86 будут поддерживать как SSE, так и SSE2. Это предположение вошло в ABI: все операции с плавающей точкой на x86-64 выполняются с использованием инструкций SSE2, а значения с плавающей точкой передаются в регистрах XMM. Следовательно, выполнение операций с плавающей запятой, но запрещение компилятору инструкций SSE/SSE2 ставит генератор кода в невозможное положение и приводит к неизбежному отказу.
Как именно это терпит неудачу? Давайте пройдемся по коду выше. Это неоптимизировано (так как вы не прошли флаг оптимизации, по умолчанию -O0
), что делает его немного трудным для чтения, но терпите меня.
В первом блоке он использует инструкции x87 FPU для загрузки значения с плавающей запятой двойной точности (3.14) из памяти (оно сохраняется как константа в двоичном виде) в регистр в верхней части стека x87 FPU. Затем он выталкивает это значение из стека и сохраняет его в памяти (программный стек). Это просто занятая работа, выполненная в неоптимизированном коде, и вы можете просто проигнорировать это. В результате вы получаете значение с плавающей точкой в памяти rbp-8
(смещение 8 байт от базового указателя).
Следующий блок инструкций может быть полностью проигнорирован. Они просто выводят строку "pi:".
Третий блок инструкций должен выводить значение с плавающей точкой. Сначала в стеке выделяется 8 байт. Затем значение с плавающей точкой, которое мы ранее сохранили в памяти, помещается в стек.
Все идет нормально. Именно так вы обычно передаете параметр с плавающей запятой в функцию, то есть в 32-разрядной сборке, следуя 32-разрядному ABI, где вы использовали инструкции x87. В 64-битной сборке, после 64-битного ABI, параметры с плавающей запятой должны передаваться в регистрах XMM, и именно здесь operator<<(double)
Функция ожидает получить свой параметр. Но вы сказали компилятору, что он не может генерировать код SSE, поэтому он не может использовать регистры XMM. Его руки связаны. Он не может правильно вызвать библиотечную функцию, которая следует за ABI, потому что ваши конкретные параметры нарушают ABI.
Отсюда все вниз. Компилятор копирует содержимое rax
зарегистрироваться в rdi
зарегистрироваться, а затем вызывает operator<<(double)
функция. Эта функция пытается записать значение с плавающей точкой, переданное в XMM0
зарегистрируйтесь в stdout, но этот регистр содержит мусор (в вашем случае он, кажется, содержит 0, но его фактическое содержимое формально не определено), поэтому этот мусор записывается в stdout вместо значения с плавающей запятой, которое вы ожидали увидеть.
Теперь, когда мы понимаем проблему, каковы решения?
- Если вы не хотите использовать инструкции SSE, принудительно скомпилируйте 32-битный двоичный файл, используя
-m32
флаг. Это безопасно сочетается с-mno-sse
, - Если вам нужен 64-битный двоичный файл, не передавайте
-mno-sse
флаг, потому что это нарушение 64-битного ABI, который предполагает поддержку SSE2 как минимум.
(Хотя я игнорирую это здесь, технически целесообразно передать -mno-sse
флаг вместе с -m64
флаг. Действительно, это явно поддерживается GCC, потому что он используется для компиляции кода ядра Linux, где состояние регистров XMM не сохраняется между вызовами. Это работает только потому, что код ядра не выполняет операций с плавающей запятой. -mno-sse
Параметр switch используется только для предотвращения использования компилятором инструкций SSE как части расширенной оптимизации, которая не имеет ничего общего с операциями с плавающей запятой.)