Оптимизация CLR JIT нарушает причинно-следственную связь? - PullRequest
14 голосов
/ 09 февраля 2010

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

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

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

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 обнаруживает, что в дальнейшем ей потребуется блокировать число с плавающей запятой.

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

Ответы [ 4 ]

18 голосов
/ 09 февраля 2010

Это побочный эффект работы оптимизатора 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 + 1а первый цикл сохраняет промежуточный результат в FPU.Второй цикл сохраняет его обратно в локальную переменную @float.Пока он остается внутри FPU, результат вычисляется с полной точностью.Сохранение его обратно, однако, усекает промежуточный результат до плавающего значения, теряя много битов точности в процессе.

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

6 голосов
/ 27 февраля 2010

К вашему сведению, спецификация C # отмечает, что такое поведение является законным и распространенным. Посмотрите эти вопросы для получения более подробной информации и схожих сценариев:

4 голосов
/ 09 февраля 2010

Вы запускали это на процессоре Intel?

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

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

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

Edit:

Хм ... Я тестировал ваш код локально (Intel Core 2, Windows 7 x64, 64-битный 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.

* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *.

2 голосов
/ 09 февраля 2010

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

...