Precisione mobile/doppia nelle modalità debug/rilascio
-
01-07-2019 - |
Domanda
Le operazioni in virgola mobile C#/.NET differiscono in termini di precisione tra la modalità di debug e la modalità di rilascio?
Soluzione
Possono infatti essere diversi.Secondo la specifica CLR ECMA:
Le posizioni di archiviazione per numeri a virgola mobile (statica, elementi di array e campi delle classi) sono di dimensioni fisse.Le dimensioni di archiviazione supportate sono float32 e float64.Ovunque altro (nello stack di valutazione, come argomenti, come tipi di restituzione e come variabili locali) i numeri a punto mobile sono rappresentati usando un tipo di punto mobile interno.In ogni istanza, il tipo nominale della variabile o dell'espressione è R4 o R8, ma il suo valore può essere rappresentato internamente con intervallo aggiuntivo e/o precisione.La dimensione della rappresentazione del punto mobile interno è dipendente dall'implementazione, può variare e deve avere precisione almeno tanto quanto quella della variabile o dell'espressione rappresentata.Una conversione implicita di ampliamento alla rappresentazione interna da Float32 o Float64 viene eseguita quando tali tipi vengono caricati dalla memoria.La rappresentazione interna è in genere la dimensione nativa per l'hardware o come richiesto per un'implementazione efficiente di un'operazione.
Ciò significa sostanzialmente che il seguente confronto può o meno essere uguale:
class Foo
{
double _v = ...;
void Bar()
{
double v = _v;
if( v == _v )
{
// Code may or may not execute here.
// _v is 64-bit.
// v could be either 64-bit (debug) or 80-bit (release) or something else (future?).
}
}
}
Messaggio da portare a casa:non controllare mai l'uguaglianza dei valori mobili.
Altri suggerimenti
Questa è una domanda interessante, quindi ho fatto un po' di sperimentazione.Ho usato questo codice:
static void Main (string [] args)
{
float
a = float.MaxValue / 3.0f,
b = a * a;
if (a * a < b)
{
Console.WriteLine ("Less");
}
else
{
Console.WriteLine ("GreaterEqual");
}
}
utilizzando DevStudio 2005 e .Net 2.Ho compilato sia come debug che come rilascio ed ho esaminato l'output del compilatore:
Release Debug
static void Main (string [] args) static void Main (string [] args)
{ {
00000000 push ebp
00000001 mov ebp,esp
00000003 push edi
00000004 push esi
00000005 push ebx
00000006 sub esp,3Ch
00000009 xor eax,eax
0000000b mov dword ptr [ebp-10h],eax
0000000e xor eax,eax
00000010 mov dword ptr [ebp-1Ch],eax
00000013 mov dword ptr [ebp-3Ch],ecx
00000016 cmp dword ptr ds:[00A2853Ch],0
0000001d je 00000024
0000001f call 793B716F
00000024 fldz
00000026 fstp dword ptr [ebp-40h]
00000029 fldz
0000002b fstp dword ptr [ebp-44h]
0000002e xor esi,esi
00000030 nop
float float
a = float.MaxValue / 3.0f, a = float.MaxValue / 3.0f,
00000000 sub esp,0Ch 00000031 mov dword ptr [ebp-40h],7EAAAAAAh
00000003 mov dword ptr [esp],ecx
00000006 cmp dword ptr ds:[00A2853Ch],0
0000000d je 00000014
0000000f call 793B716F
00000014 fldz
00000016 fstp dword ptr [esp+4]
0000001a fldz
0000001c fstp dword ptr [esp+8]
00000020 mov dword ptr [esp+4],7EAAAAAAh
b = a * a; b = a * a;
00000028 fld dword ptr [esp+4] 00000038 fld dword ptr [ebp-40h]
0000002c fmul st,st(0) 0000003b fmul st,st(0)
0000002e fstp dword ptr [esp+8] 0000003d fstp dword ptr [ebp-44h]
if (a * a < b) if (a * a < b)
00000032 fld dword ptr [esp+4] 00000040 fld dword ptr [ebp-40h]
00000036 fmul st,st(0) 00000043 fmul st,st(0)
00000038 fld dword ptr [esp+8] 00000045 fld dword ptr [ebp-44h]
0000003c fcomip st,st(1) 00000048 fcomip st,st(1)
0000003e fstp st(0) 0000004a fstp st(0)
00000040 jp 00000054 0000004c jp 00000052
00000042 jbe 00000054 0000004e ja 00000056
00000050 jmp 00000052
00000052 xor eax,eax
00000054 jmp 0000005B
00000056 mov eax,1
0000005b test eax,eax
0000005d sete al
00000060 movzx eax,al
00000063 mov esi,eax
00000065 test esi,esi
00000067 jne 0000007A
{ {
Console.WriteLine ("Less"); 00000069 nop
00000044 mov ecx,dword ptr ds:[0239307Ch] Console.WriteLine ("Less");
0000004a call 78678B7C 0000006a mov ecx,dword ptr ds:[0239307Ch]
0000004f nop 00000070 call 78678B7C
00000050 add esp,0Ch 00000075 nop
00000053 ret }
} 00000076 nop
else 00000077 nop
{ 00000078 jmp 00000088
Console.WriteLine ("GreaterEqual"); else
00000054 mov ecx,dword ptr ds:[02393080h] {
0000005a call 78678B7C 0000007a nop
} Console.WriteLine ("GreaterEqual");
} 0000007b mov ecx,dword ptr ds:[02393080h]
00000081 call 78678B7C
00000086 nop
}
Ciò che mostra quanto sopra è che il codice in virgola mobile è lo stesso sia per il debug che per il rilascio, il compilatore sceglie la coerenza rispetto all'ottimizzazione.Sebbene il programma produca il risultato sbagliato (a * a non è inferiore a b) è lo stesso indipendentemente dalla modalità debug/rilascio.
Ora, la FPU Intel IA32 ha otto registri in virgola mobile, si potrebbe pensare che il compilatore utilizzi i registri per memorizzare valori durante l'ottimizzazione anziché scrivere in memoria, migliorando così le prestazioni, qualcosa sulla falsariga di:
fld dword ptr [a] ; precomputed value stored in ram == float.MaxValue / 3.0f
fmul st,st(0) ; b = a * a
; no store to ram, keep b in FPU
fld dword ptr [a]
fmul st,st(0)
fcomi st,st(0) ; a*a compared to b
ma questo verrebbe eseguito in modo diverso rispetto alla versione di debug (in questo caso, visualizzerebbe il risultato corretto).Tuttavia, modificare il comportamento del programma in base alle opzioni di compilazione è una cosa molto negativa.
Il codice FPU è un'area in cui la creazione manuale del codice può superare in modo significativo le prestazioni del compilatore, ma è necessario capire come funziona la FPU.
In effetti, potrebbero differire se la modalità di debug utilizza la FPU x87 e la modalità di rilascio utilizza SSE per le operazioni float.
In risposta alla richiesta di Frank Krueger sopra (nei commenti) per una dimostrazione di una differenza:
Compila questo codice in GCC senza ottimizzazioni e -mfpmath = 387 (non ho motivo di pensare che non funzionerebbe su altri compilatori, ma non l'ho provato.) Quindi compilalo senza ottimizzazioni e -msse -mfpmath = SSE.
L'output sarà diverso.
#include <stdio.h>
int main()
{
float e = 0.000000001;
float f[3] = {33810340466158.90625,276553805316035.1875,10413022032824338432.0};
f[0] = pow(f[0],2-e); f[1] = pow(f[1],2+e); f[2] = pow(f[2],-2-e);
printf("%s\n",f);
return 0;
}
Ecco un semplice esempio in cui i risultati non solo differiscono tra la modalità di debug e quella di rilascio, ma il modo in cui lo fanno dipende dall'utilizzo di x86 o x84 come piattaforma:
Single f1 = 0.00000000002f;
Single f2 = 1 / f1;
Double d = f2;
Console.WriteLine(d);
Questo scrive i seguenti risultati:
Debug Release
x86 49999998976 50000000199,7901
x64 49999998976 49999998976
Un rapido sguardo al disassemblaggio (Debug -> Windows -> Disassembly in Visual Studio) fornisce alcuni suggerimenti su cosa sta succedendo qui.Per il caso x86:
Debug Release
mov dword ptr [ebp-40h],2DAFEBFFh | mov dword ptr [ebp-4],2DAFEBFFh
fld dword ptr [ebp-40h] | fld dword ptr [ebp-4]
fld1 | fld1
fdivrp st(1),st | fdivrp st(1),st
fstp dword ptr [ebp-44h] |
fld dword ptr [ebp-44h] |
fstp qword ptr [ebp-4Ch] |
fld qword ptr [ebp-4Ch] |
sub esp,8 | sub esp,8
fstp qword ptr [esp] | fstp qword ptr [esp]
call 6B9783BC | call 6B9783BC
In particolare, vediamo che un gruppo apparentemente ridondante di "memorizza il valore dal registro a virgola mobile in memoria, quindi caricalo immediatamente dalla memoria nel registro a virgola mobile" è stato ottimizzato nella modalità di rilascio.Tuttavia, le due istruzioni
fstp dword ptr [ebp-44h]
fld dword ptr [ebp-44h]
sono sufficienti per modificare il valore nel registro x87 da +5.0000000199790138e+0010 a +4.9999998976000000e+0010 come si può verificare procedendo nel disassemblaggio ed esaminando i valori dei relativi registri (Debug -> Windows -> Registri, quindi a destra fare clic e selezionare "Virgola mobile").
La storia per x64 è molto diversa.Vediamo ancora la stessa ottimizzazione rimuovendo alcune istruzioni, ma questa volta tutto si basa su SSE con i suoi registri a 128 bit e un set di istruzioni dedicato:
Debug Release
vmovss xmm0,dword ptr [7FF7D0E104F8h] | vmovss xmm0,dword ptr [7FF7D0E304C8h]
vmovss dword ptr [rbp+34h],xmm0 | vmovss dword ptr [rbp-4],xmm0
vmovss xmm0,dword ptr [7FF7D0E104FCh] | vmovss xmm0,dword ptr [7FF7D0E304CCh]
vdivss xmm0,xmm0,dword ptr [rbp+34h] | vdivss xmm0,xmm0,dword ptr [rbp-4]
vmovss dword ptr [rbp+30h],xmm0 |
vcvtss2sd xmm0,xmm0,dword ptr [rbp+30h] | vcvtss2sd xmm0,xmm0,xmm0
vmovsd qword ptr [rbp+28h],xmm0 |
vmovsd xmm0,qword ptr [rbp+28h] |
call 00007FF81C9343F0 | call 00007FF81C9343F0
Qui, poiché l'unità SSE evita di utilizzare internamente una precisione più elevata rispetto alla precisione singola (mentre l'unità x87 lo fa), ci ritroviamo con il risultato "precisione singola" del caso x86 indipendentemente dalle ottimizzazioni.In effetti, si trova (dopo aver abilitato i registri SSE nella panoramica dei registri di Visual Studio) che after vdivss
, XMM0 contiene 0000000000000000-00000000513A43B7 che è esattamente il 49999998976 di prima.
Entrambe le discrepanze mi hanno ferito nella pratica.Oltre a illustrare che non si dovrebbe mai confrontare l'uguaglianza dei punti mobili, l'esempio mostra anche che c'è ancora spazio per il debugging dell'assembly in un linguaggio di alto livello come C#, nel momento in cui vengono visualizzati i punti mobili.