Оптимизация CLR JIT нарушает причинность?

Я писал поучительный пример для коллеги, чтобы показать ему, почему тестирование поплавков на равенство часто является плохой идеей. В примере, в котором я участвовал, добавлялось .1 десять раз и сравнивалось с 1.0 (тот, который я показал в моем вводном числовом классе). Я с удивлением обнаружил, что два результата были равны (code + output).

float @float = 0.0f;
for(int @int = 0; @int < 10; @int += 1)
{
    @float += 0.1f;
}
Console.WriteLine(@float == 1.0f);

Некоторое расследование показало, что на этот результат нельзя положиться (подобно равенству поплавка). Наиболее удивительным было то, что добавление кода после другого кода может изменить результат вычисления (code + output). Обратите внимание, что этот пример имеет точно такой же код и IL, при этом добавляется еще одна строка С#.

float @float = 0.0f;
for(int @int = 0; @int < 10; @int += 1)
{
    @float += 0.1f;
}
Console.WriteLine(@float == 1.0f);
Console.WriteLine(@float.ToString("G9"));

Я знаю, что я не должен использовать равенство в поплавках и, следовательно, не должен заботиться об этом слишком сильно, но я обнаружил, что это довольно удивительно, так же как и обо всех, кого я показал. Выполнение работы после выполнения вычисления изменяет значение предыдущего вычисления? Я не думаю, что модель вычислительных людей обычно имеет в виду.

Я не полностью зациклен, кажется безопасным предположить, что в "равном" случае, который меняет результат вычисления, существует какая-то оптимизация (построение в режиме отладки предотвращает "равный" случай). По-видимому, оптимизация прекращается, когда CLR обнаруживает, что позже ей нужно будет поместить float.

Я искал немного, но не мог найти причину такого поведения. Может ли кто-нибудь понять меня?

Ответ 1

Это побочный эффект работы оптимизатора JIT. Он работает больше, если для генерации меньше кода. Цикл в вашем исходном фрагменте компилируется следующим образом:

                @float += 0.1f;
0000000f  fld         dword ptr ds:[0025156Ch]          ; push(intermediate), st0 = 0.1
00000015  faddp       st(1),st                          ; st0 = st0 + st1
            for (int @int = 0; @int < 10; @int += 1) {
00000017  inc         eax  
00000018  cmp         eax,0Ah 
0000001b  jl          0000000F 

Когда вы добавляете дополнительный оператор Console.WriteLine(), он компилирует его следующим образом:

                @float += 0.1f;
00000011  fld         dword ptr ds:[00961594h]          ; st0 = 0.1
00000017  fadd        dword ptr [ebp-8]                 ; st0 = st0 + @float
0000001a  fstp        dword ptr [ebp-8]                 ; @float = st0
            for (int @int = 0; @int < 10; @int += 1) {
0000001d  inc         eax  
0000001e  cmp         eax,0Ah 
00000021  jl          00000011 

Обратите внимание на разницу по адресу 15 по адресу 17 + 1a, первый цикл сохраняет промежуточный результат в FPU. Второй цикл сохраняет его обратно в локальную переменную @float. Пока он остается внутри FPU, результат рассчитывается с полной точностью. Сохраняя его обратно, он обрезает промежуточный результат обратно на поплавок, теряя при этом много бит точности.

Пока неприятно, я не считаю, что это ошибка. Компилятор x64 JIT ведет себя по-другому. Вы можете сделать свое дело на сайте connect.microsoft.com

Ответ 2

FYI, спецификация С# отмечает, что это поведение является законным и распространенным. См. Эти вопросы для получения более подробной информации и близких сценариев:

Ответ 3

Запустили ли это на процессоре Intel?

Одна теория состоит в том, что JIT разрешал @float аккумулироваться полностью в регистре с плавающей запятой, что было бы полной точностью 80 бит. Таким образом, расчет может быть достаточно точным.

Вторая версия кода полностью не вписывалась в регистры и поэтому @float пришлось "проливать" на память, что заставляет 80-битное значение округлять до одной точности, давая ожидаемые результаты от одинарной точности арифметика.

Но это просто очень случайное предположение. Нужно было бы проверить фактический машинный код, сгенерированный компилятором JIT (отладка с открытием разбора открыта).

Edit:

Хм... Я тестировал ваш код локально (Intel Core 2, Windows 7 x64, 64-bit CLR), и я всегда получал "ожидаемую" ошибку округления. Как в конфигурации выпуска, так и отладки.

Ниже представлена ​​демонстрация Visual Studio для первого фрагмента кода на моей машине:

xorps       xmm0,xmm0 
movss       dword ptr [rsp+20h],xmm0 
        for (int @int = 0; @int < 10; @int += 1)
mov         dword ptr [rsp+24h],0 
jmp         0000000000000061 
        {
            @float += 0.1f;
movss       xmm0,dword ptr [000000A0h] 
addss       xmm0,dword ptr [rsp+20h] 
movss       dword ptr [rsp+20h],xmm0 // <-- @float gets stored in memory
        for (int @int = 0; @int < 10; @int += 1)
mov         eax,dword ptr [rsp+24h] 
add         eax,1 
mov         dword ptr [rsp+24h],eax 
cmp         dword ptr [rsp+24h],0Ah 
jl          0000000000000042 
        }
        Console.WriteLine(@float == 1.0f);
etc.

Существуют различия между компилятором x64 и x86 JIT, но у меня нет доступа к 32-битной машине.

Ответ 4

Моя теория о том, что без строки ToString компилятор способен статически оптимизировать функцию до одного значения и что она каким-то образом компенсирует ошибку с плавающей запятой. Но когда добавляется строка ToString, оптимизатор должен обрабатывать float по-разному, потому что это требуется вызовом метода. Это просто предположение.