Flotación/doble precisión en modos de depuración/liberación
-
01-07-2019 - |
Pregunta
¿Las operaciones de punto flotante de C#/.NET difieren en precisión entre el modo de depuración y el modo de lanzamiento?
Solución
De hecho, pueden ser diferentes.Según la especificación CLR ECMA:
Las ubicaciones de almacenamiento para números de punto flotante (estadísticas, elementos de matriz y campos de clases) son de tamaño fijo.Los tamaños de almacenamiento compatibles son Float32 y Float64.En cualquier otro lugar (en la pila de evaluación, como argumentos, como tipos de devolución y como variables locales) se representan números de punto flotante utilizando un tipo de punto flotante interno.En cada caso, el tipo nominal de la variable o expresión es R4 o R8, pero su valor puede representarse internamente con un rango y/o precisión adicionales.El tamaño de la representación interna del punto flotante depende de la implementación, puede variar y tendrá precisión al menos tan grande como la de la variable o expresión que se representa.Se realiza una conversión amplia implícita a la representación interna de Float32 o Float64 cuando esos tipos se cargan desde el almacenamiento.La representación interna es típicamente el tamaño nativo para el hardware, o según sea necesario para la implementación eficiente de una operación.
Lo que esto significa básicamente es que la siguiente comparación puede ser igual o no:
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?).
}
}
}
Llevar el mensaje a casa:nunca verifique la igualdad de los valores flotantes.
Otros consejos
Esta es una pregunta interesante, así que experimenté un poco.Usé este código:
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");
}
}
utilizando DevStudio 2005 y .Net 2.Compilé como depuración y lanzamiento y examiné el resultado del compilador:
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
}
Lo que muestra lo anterior es que el código de punto flotante es el mismo tanto para la depuración como para el lanzamiento, el compilador elige la coherencia en lugar de la optimización.Aunque el programa produce un resultado incorrecto (a * a no es menor que b), es el mismo independientemente del modo de depuración/liberación.
Ahora, la FPU Intel IA32 tiene ocho registros de punto flotante, se podría pensar que el compilador usaría los registros para almacenar valores al optimizar en lugar de escribir en la memoria, mejorando así el rendimiento, algo como:
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
pero esto se ejecutaría de manera diferente a la versión de depuración (en este caso, mostraría el resultado correcto).Sin embargo, cambiar el comportamiento del programa según las opciones de compilación es algo muy malo.
El código FPU es un área en la que la elaboración manual del código puede superar significativamente al compilador, pero es necesario comprender cómo funciona la FPU.
De hecho, pueden diferir si el modo de depuración usa la FPU x87 y el modo de lanzamiento usa SSE para operaciones flotantes.
En respuesta a la solicitud anterior de Frank Krueger (en los comentarios) de una demostración de una diferencia:
Compile este código en GCC sin optimizaciones y -mfpmath = 387 (no tengo ninguna razón para pensar que no funcionaría en otros compiladores, pero no lo he probado). Luego compile sin optimizaciones y -msse -mfpmath = Sse.
La salida será diferente.
#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;
}
Aquí hay un ejemplo simple donde los resultados no solo difieren entre el modo de depuración y el modo de lanzamiento, sino que la forma en que lo hacen depende de si se usa x86 o x84 como plataforma:
Single f1 = 0.00000000002f;
Single f2 = 1 / f1;
Double d = f2;
Console.WriteLine(d);
Esto escribe los siguientes resultados:
Debug Release
x86 49999998976 50000000199,7901
x64 49999998976 49999998976
Un vistazo rápido al desensamblado (Depurar -> Windows -> Desensamblado en Visual Studio) brinda algunas pistas sobre lo que está sucediendo aquí.Para el 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
En particular, vemos que un montón de elementos aparentemente redundantes de "almacenar el valor del registro de punto flotante en la memoria, luego cargarlo inmediatamente de nuevo desde la memoria al registro de punto flotante" se han optimizado en el modo de lanzamiento.Sin embargo, las dos instrucciones
fstp dword ptr [ebp-44h]
fld dword ptr [ebp-44h]
son suficientes para cambiar el valor en el registro x87 de +5.0000000199790138e+0010 a +4.9999998976000000e+0010, como se puede verificar siguiendo el desmontaje e investigando los valores de los registros relevantes (Depurar -> Windows -> Registros, luego a la derecha haga clic y marque "Punto flotante").
La historia para x64 es tremendamente diferente.Seguimos viendo la misma optimización eliminando algunas instrucciones, pero esta vez, todo depende de SSE con sus registros de 128 bits y su conjunto de instrucciones dedicado:
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
Aquí, debido a que la unidad SSE evita el uso interno de una precisión mayor que la precisión simple (mientras que la unidad x87 lo hace), terminamos con el resultado de "precisión simple" del caso x86 independientemente de las optimizaciones.De hecho, uno encuentra (después de habilitar los registros SSE en la descripción general de Registros de Visual Studio) que después vdivss
, XMM0 contiene 0000000000000000-00000000513A43B7 que es exactamente el 49999998976 de antes.
Ambas discrepancias me afectaron en la práctica.Además de ilustrar que nunca se debe comparar la igualdad de puntos flotantes, el ejemplo también muestra que todavía hay espacio para la depuración de ensambladores en un lenguaje de alto nivel como C#, en el momento en que aparecen los puntos flotantes.