2010-02-08 10 views
14

Stavo scrivendo un esempio istruttivo per un collega per mostrargli perché testare i galleggianti per l'uguaglianza è spesso una cattiva idea. L'esempio che ho seguito è stato aggiungere .1 dieci volte e confrontandolo con 1.0 (quello che ho mostrato nella mia classe numerica introduttiva). Sono stato sorpreso di scoprire che i due risultati erano uguali a (code + output).CLR Le ottimizzazioni JIT violano la causalità?

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

Alcune indagini hanno dimostrato che questo risultato non può essere invocato (come l'uguaglianza del float). Quello che ho trovato più sorprendente è che aggiungendo il codice dopo l'altro codice potrebbe cambiare il risultato del calcolo (code + output). Si noti che questo esempio ha esattamente lo stesso codice e IL, con un'ultima riga di C# aggiunta.

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")); 

So che non dovrei usare l'uguaglianza su carri e, quindi, non dovrebbe preoccuparsi troppo di questo, ma ho trovato ad essere abbastanza sorprendente, come hanno fatto quasi tutti ho dimostrato che questo. Fare roba dopo il hai eseguito un calcolo cambia il valore del calcolo precedente? Non penso che questo sia il modello di calcolo che le persone hanno di solito nelle loro menti.

Non sono completamente perplesso, sembra sicuro assumere che ci sia un qualche tipo di ottimizzazione che si verifica nel caso "uguale" che modifica il risultato del calcolo (la costruzione in modalità debug impedisce il caso "uguale"). Apparentemente, l'ottimizzazione viene abbandonata quando il CLR scopre che in seguito dovrà boxare il float.

Ho cercato un po 'ma non ho trovato un motivo per questo comportamento. Qualcuno può identificarmi?

risposta

18

Questo è un effetto collaterale del modo in cui funziona l'ottimizzatore JIT. Funziona di più se c'è meno codice da generare. Il ciclo nel tuo snippet originale viene compilato a questo:

   @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 

quando si aggiunge la dichiarazione in più Console.WriteLine(), si compila a questo:

   @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 

nota la differenza all'indirizzo 15 vs indirizzo 17 + 1a, il primo loop mantiene il risultato intermedio nella FPU. Il secondo ciclo lo riporta alla variabile locale @float. Mentre rimane all'interno della FPU, il risultato viene calcolato con precisione completa. La sua memorizzazione tuttavia tronca il risultato intermedio su un float, perdendo molti bit di precisione nel processo.

Mentre sgradevole, non credo che questo sia un bug. Il compilatore JIT x64 si comporta in modo diverso ancora. È possibile creare il proprio caso su connect.microsoft.com

+0

Buona risposta. Non lo chiamerei neanche un bug. Più di un effetto collaterale insolito (e sicuramente inaspettato). – Gobiner

4

Avete eseguito questo su un processore Intel?

Una teoria è che il JIT consentiva di accumulare @float interamente in un registro a virgola mobile, che sarebbe la precisione completa di 80 bit. In questo modo il calcolo può essere abbastanza accurato.

La seconda versione del codice non rientrava nei registri interamente e così @float doveva essere "rovesciato" alla memoria, che determina il valore 80 bit da arrotondato a singola precisione, dando i risultati attesi dalla singola precisione aritmetica .

Ma questa è solo un'ipotesi molto casuale. Si dovrebbe esaminare il codice macchina effettivo generato dal compilatore JIT (debug con la vista di disassemblaggio aperta).

Edit:

Hm ... Ho testato il codice a livello locale (Intel Core 2, Windows 7 x64, 64-bit CLR) e ho sempre avuto l'errore di arrotondamento "previsto". Sia nella configurazione di rilascio che di debug.

che segue è lo smontaggio display di Visual Studio per il primo frammento di codice sulla mia macchina:

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. 

Ci sono differenze tra il 64 e il compilatore JIT x86, ma non hanno accesso ai una macchina a 32 bit.

+0

Io sicuramente ho * * corsa su un processore Intel. Non so se il dotnetpad gira su un processore Intel, comunque. – Gobiner

2

La mia teoria è che, senza la riga ToString, il compilatore è in grado di ottimizzare staticamente la funzione in un singolo valore e di compensare in qualche modo l'errore in virgola mobile.Ma quando viene aggiunta la riga ToString, l'ottimizzatore deve trattare il float in modo diverso perché è richiesto dalla chiamata al metodo. Questa è solo una supposizione.