Conceptos básicos de IEEE 754
Primero repasemos los conceptos básicos de los números IEEE 754 organizados.
Nos centraremos en la precisión simple (32 bits), pero todo se puede generalizar inmediatamente a otras precisiones.
El formato es:
- 1 bit: signo
- 8 bits: exponente
- 23 bits: fracción
O si te gustan las fotos:
Fuente .
El signo es simple: 0 es positivo y 1 es negativo, final de la historia.
El exponente tiene 8 bits de longitud, por lo que varía de 0 a 255.
El exponente se llama sesgado porque tiene un desplazamiento de -127
, por ejemplo:
0 == special case: zero or subnormal, explained below
1 == 2 ^ -126
...
125 == 2 ^ -2
126 == 2 ^ -1
127 == 2 ^ 0
128 == 2 ^ 1
129 == 2 ^ 2
...
254 == 2 ^ 127
255 == special case: infinity and NaN
La convención de bits líder
(Lo que sigue es una narración hipotética ficticia, no basada en ninguna investigación histórica real).
Al diseñar IEEE 754, los ingenieros notaron que todos los números, excepto 0.0
, tienen un uno 1
en binario como primer dígito. P.ej:
25.0 == (binary) 11001 == 1.1001 * 2^4
0.625 == (binary) 0.101 == 1.01 * 2^-1
ambos comienzan con esa 1.
parte molesta .
Por lo tanto, sería un desperdicio dejar que ese dígito ocupe un bit de precisión en casi todos los números.
Por esta razón, crearon la "convención de bits inicial":
siempre asume que el número comienza con uno
Pero entonces, ¿cómo lidiar con eso 0.0
? Bueno, decidieron crear una excepción:
- si el exponente es 0
- y la fracción es 0
- entonces el número representa más o menos
0.0
para que los bytes 00 00 00 00
también representen 0.0
, lo que se ve bien.
Si solo consideráramos estas reglas, entonces el número más pequeño distinto de cero que se puede representar sería:
que se parece a esto en una fracción hexadecimal debido a la convención de bits inicial:
1.000002 * 2 ^ (-127)
donde .000002
es 22 ceros con un 1
al final.
No podemos tomar fraction = 0
, de lo contrario ese número sería 0.0
.
Pero luego los ingenieros, que también tenían un agudo sentido estético, pensaron: ¿no es eso feo? ¿Que saltemos de directo 0.0
a algo que ni siquiera es una potencia propia de 2? ¿No podríamos representar números aún más pequeños de alguna manera? (De acuerdo, era un poco más preocupante que "feo": en realidad, la gente estaba obteniendo malos resultados en sus cálculos, consulte "Cómo los subnormales mejoran los cálculos" a continuación).
Números subnormales
Los ingenieros se rascaron la cabeza un rato y volvieron, como de costumbre, con otra buena idea. ¿Qué pasa si creamos una nueva regla?
Si el exponente es 0, entonces:
- el bit inicial se convierte en 0
- el exponente se fija en -126 (no -127 como si no tuviéramos esta excepción)
Estos números se denominan números subnormales (o números desnormales que es sinónimo).
Esta regla implica inmediatamente que el número tal que:
es todavía 0.0
, lo cual es un poco elegante, ya que significa una regla menos de la que realizar un seguimiento.
¡Así 0.0
que en realidad es un número subnormal según nuestra definición!
Entonces, con esta nueva regla, el número no anormal más pequeño es:
- exponente: 1 (0 sería subnormal)
- fracción: 0
que representa:
1.0 * 2 ^ (-126)
Entonces, el número subnormal más grande es:
- exponente: 0
- fracción: 0x7FFFFF (23 bits 1)
que es igual a:
0.FFFFFE * 2 ^ (-126)
donde .FFFFFE
está una vez más 23 bits uno a la derecha del punto.
Esto está bastante cerca del número no subnormal más pequeño, lo que suena cuerdo.
Y el número subnormal distinto de cero más pequeño es:
que es igual a:
0.000002 * 2 ^ (-126)
que también se parece bastante a 0.0
!
Incapaces de encontrar una forma sensata de representar números más pequeños que eso, los ingenieros estaban felices y volvieron a ver imágenes de gatos en línea, o lo que sea que hicieran en los 70.
Como puede ver, los números subnormales hacen un intercambio entre precisión y longitud de representación.
Como ejemplo más extremo, el subnormal distinto de cero más pequeño:
0.000002 * 2 ^ (-126)
tiene esencialmente una precisión de un solo bit en lugar de 32 bits. Por ejemplo, si lo dividimos entre dos:
0.000002 * 2 ^ (-126) / 2
en realidad llegamos 0.0
exactamente!
Visualización
Siempre es una buena idea tener una intuición geométrica sobre lo que aprendemos, así que aquí va.
Si trazamos números de coma flotante IEEE 754 en una línea para cada exponente dado, se verá así:
+---+-------+---------------+-------------------------------+
exponent |126| 127 | 128 | 129 |
+---+-------+---------------+-------------------------------+
| | | | |
v v v v v
-------------------------------------------------------------
floats ***** * * * * * * * * * * * *
-------------------------------------------------------------
^ ^ ^ ^ ^
| | | | |
0.5 1.0 2.0 4.0 8.0
De eso podemos ver que:
- para cada exponente, no hay superposición entre los números representados
- para cada exponente, tenemos el mismo número 2 ^ 32 de números (aquí representado por 4
*
)
- dentro de cada exponente, los puntos están igualmente espaciados
- exponentes más grandes cubren rangos más grandes, pero con puntos más dispersos
Ahora, bajemos eso hasta el exponente 0.
Sin subnormales, hipotéticamente se vería así:
+---+---+-------+---------------+-------------------------------+
exponent | ? | 0 | 1 | 2 | 3 |
+---+---+-------+---------------+-------------------------------+
| | | | | |
v v v v v v
-----------------------------------------------------------------
floats * **** * * * * * * * * * * * *
-----------------------------------------------------------------
^ ^ ^ ^ ^ ^
| | | | | |
0 | 2^-126 2^-125 2^-124 2^-123
|
2^-127
Con subnormales, se ve así:
+-------+-------+---------------+-------------------------------+
exponent | 0 | 1 | 2 | 3 |
+-------+-------+---------------+-------------------------------+
| | | | |
v v v v v
-----------------------------------------------------------------
floats * * * * * * * * * * * * * * * * *
-----------------------------------------------------------------
^ ^ ^ ^ ^ ^
| | | | | |
0 | 2^-126 2^-125 2^-124 2^-123
|
2^-127
Al comparar los dos gráficos, vemos que:
subnormales duplican la longitud del rango del exponente 0
, de [2^-127, 2^-126)
a[0, 2^-126)
El espacio entre flotadores en rango subnormal es el mismo que para [0, 2^-126)
.
el rango [2^-127, 2^-126)
tiene la mitad del número de puntos que tendría sin subnormales.
La mitad de esos puntos se destina a llenar la otra mitad del rango.
el rango [0, 2^-127)
tiene algunos puntos con subnormales, pero ninguno sin ellos.
Esta falta de puntos [0, 2^-127)
no es muy elegante y es la principal razón por la que existen los subnormales.
dado que los puntos están igualmente espaciados:
- el rango
[2^-128, 2^-127)
tiene la mitad de puntos que [2^-127, 2^-126)
- [2^-129, 2^-128)
tiene la mitad de puntos que[2^-128, 2^-127)
- y así
Esto es lo que queremos decir cuando decimos que los subnormales son un compromiso entre tamaño y precisión.
Ejemplo de C ejecutable
Ahora juguemos con un código real para verificar nuestra teoría.
En casi todas las máquinas actuales y de escritorio, C float
representa números de punto flotante IEEE 754 de precisión simple.
Este es en particular el caso de mi computadora portátil Ubuntu 18.04 amd64 Lenovo P51.
Con esa suposición, todas las afirmaciones pasan por el siguiente programa:
subnormal.c
#if __STDC_VERSION__ < 201112L
#error C11 required
#endif
#ifndef __STDC_IEC_559__
#error IEEE 754 not implemented
#endif
#include <assert.h>
#include <float.h> /* FLT_HAS_SUBNORM */
#include <inttypes.h>
#include <math.h> /* isnormal */
#include <stdlib.h>
#include <stdio.h>
#if FLT_HAS_SUBNORM != 1
#error float does not have subnormal numbers
#endif
typedef struct {
uint32_t sign, exponent, fraction;
} Float32;
Float32 float32_from_float(float f) {
uint32_t bytes;
Float32 float32;
bytes = *(uint32_t*)&f;
float32.fraction = bytes & 0x007FFFFF;
bytes >>= 23;
float32.exponent = bytes & 0x000000FF;
bytes >>= 8;
float32.sign = bytes & 0x000000001;
bytes >>= 1;
return float32;
}
float float_from_bytes(
uint32_t sign,
uint32_t exponent,
uint32_t fraction
) {
uint32_t bytes;
bytes = 0;
bytes |= sign;
bytes <<= 8;
bytes |= exponent;
bytes <<= 23;
bytes |= fraction;
return *(float*)&bytes;
}
int float32_equal(
float f,
uint32_t sign,
uint32_t exponent,
uint32_t fraction
) {
Float32 float32;
float32 = float32_from_float(f);
return
(float32.sign == sign) &&
(float32.exponent == exponent) &&
(float32.fraction == fraction)
;
}
void float32_print(float f) {
Float32 float32 = float32_from_float(f);
printf(
"%" PRIu32 " %" PRIu32 " %" PRIu32 "\n",
float32.sign, float32.exponent, float32.fraction
);
}
int main(void) {
assert(float32_equal(0.5f, 0, 126, 0));
assert(float32_equal(1.0f, 0, 127, 0));
assert(float32_equal(2.0f, 0, 128, 0));
assert(isnormal(0.5f));
assert(isnormal(1.0f));
assert(isnormal(2.0f));
assert(0.5f == 0x1.0p-1f);
assert(1.0f == 0x1.0p0f);
assert(2.0f == 0x1.0p1f);
assert(float32_equal(-0.5f, 1, 126, 0));
assert(float32_equal(-1.0f, 1, 127, 0));
assert(float32_equal(-2.0f, 1, 128, 0));
assert(isnormal(-0.5f));
assert(isnormal(-1.0f));
assert(isnormal(-2.0f));
assert(float32_equal( 0.0f, 0, 0, 0));
assert(float32_equal(-0.0f, 1, 0, 0));
assert(!isnormal( 0.0f));
assert(!isnormal(-0.0f));
assert(0.0f == -0.0f);
assert(FLT_MIN == 0x1.0p-126f);
assert(float32_equal(FLT_MIN, 0, 1, 0));
assert(isnormal(FLT_MIN));
float largest_subnormal = float_from_bytes(0, 0, 0x7FFFFF);
assert(largest_subnormal == 0x0.FFFFFEp-126f);
assert(largest_subnormal < FLT_MIN);
assert(!isnormal(largest_subnormal));
float smallest_subnormal = float_from_bytes(0, 0, 1);
assert(smallest_subnormal == 0x0.000002p-126f);
assert(0.0f < smallest_subnormal);
assert(!isnormal(smallest_subnormal));
return EXIT_SUCCESS;
}
GitHub aguas arriba .
Compilar y ejecutar con:
gcc -ggdb3 -O0 -std=c11 -Wall -Wextra -Wpedantic -Werror -o subnormal.out subnormal.c
./subnormal.out
C ++
Además de exponer todas las API de C, C ++ también expone algunas funciones extranormales relacionadas que no están tan fácilmente disponibles en C en <limits>
, por ejemplo:
denorm_min
: Devuelve el valor subnormal positivo mínimo del tipo T
En C ++, toda la API está diseñada para cada tipo de punto flotante, y es mucho mejor.
Implementaciones
x86_64 y ARMv8 implementan IEEE 754 directamente en el hardware, al que se traduce el código C.
Los subnormales parecen ser menos rápidos que los normales en ciertas implementaciones: ¿Por qué cambiar 0.1f a 0 ralentiza el rendimiento en 10 veces? Esto se menciona en el manual de ARM, consulte la sección "Detalles de ARMv8" de esta respuesta.
Detalles ARMv8
ARM Architecture Reference Manual ARMv8 DDI 0487C. Un manual A1.5.4 "Flush-to-zero" describe un modo configurable donde los subnormales se redondean a cero para mejorar el rendimiento:
El rendimiento del procesamiento de punto flotante se puede reducir cuando se realizan cálculos con números desnormalizados y excepciones de subdesbordamiento. En muchos algoritmos, este rendimiento se puede recuperar, sin afectar significativamente la precisión del resultado final, reemplazando los operandos desnormalizados y los resultados intermedios con ceros. Para permitir esta optimización, las implementaciones de punto flotante ARM permiten utilizar un modo Flush-to-zero para diferentes formatos de punto flotante de la siguiente manera:
Para AArch64:
Si FPCR.FZ==1
, entonces el modo Flush-to-Zero se usa para todas las entradas y salidas de precisión simple y precisión doble de todas las instrucciones.
Si FPCR.FZ16==1
, entonces el modo Flush-to-Zero se usa para todas las entradas y salidas de Half-Precision de instrucciones de punto flotante, excepto: —Conversiones entre números de Half-Precision y Single-Precision. — Conversiones entre Half-Precision y Double-Precision números.
A1.5.2 "Normas y terminología de coma flotante" La Tabla A1-3 "Terminología de coma flotante" confirma que los subnormales y los desnormales son sinónimos:
This manual IEEE 754-2008
------------------------- -------------
[...]
Denormal, or denormalized Subnormal
C5.2.7 "FPCR, registro de control de punto flotante" describe cómo ARMv8 puede generar excepciones o establecer bits de bandera cuando la entrada de una operación de punto flotante es subnormal:
FPCR.IDE, bit [15] Entrada Habilitación de excepción de excepción de coma flotante denormal. Los posibles valores son:
0b0 Manejo de excepciones no envuelto seleccionado. Si se produce la excepción de punto flotante, el bit FPSR.IDC se establece en 1.
0b1 Manejo de excepciones atrapadas seleccionado. Si ocurre la excepción de punto flotante, el PE no actualiza el bit FPSR.IDC. El software de manejo de capturas puede decidir si establecer el bit FPSR.IDC en 1.
D12.2.88 "MVFR1_EL1, AArch32 Media and VFP Feature Register 1" muestra que el soporte desnormal es completamente opcional, de hecho, y ofrece un poco para detectar si hay soporte:
FPFtZ, bits [3: 0]
Enjuague a modo cero. Indica si la implementación de punto flotante proporciona soporte solo para el modo de operación Flush-to-Zero. Los valores definidos son:
Los demás valores están reservados.
En ARMv8-A, los valores permitidos son 0b0000 y 0b0001.
Esto sugiere que cuando no se implementan los subnormales, las implementaciones simplemente vuelven a cero.
Infinito y NaN
¿Curioso? He escrito algunas cosas en:
Cómo los subnormales mejoran los cálculos
TODO: comprender mejor con más precisión cómo ese salto empeora los resultados del cálculo / cómo los subnormales mejoran los resultados del cálculo.
Historia real
Una entrevista con el anciano de Floating-Point por Charles Severance . (1998) es una breve descripción histórica del mundo real en forma de una entrevista con William Kahan sugerida por John Coleman en los comentarios.