¿Cuál es la diferencia entre NaN silencioso y NaN de señalización?

98

He leído sobre el punto flotante y entiendo que NaN podría resultar de operaciones. Pero no puedo entender qué son estos conceptos exactamente. ¿Cuál es la diferencia entre ellos?

¿Cuál se puede producir durante la programación en C ++? Como programador, ¿podría escribir un programa que provoque un sNaN?

JalalJaberi
fuente

Respuestas:

68

Cuando una operación da como resultado un NaN silencioso, no hay indicios de que algo sea inusual hasta que el programa verifica el resultado y ve un NaN. Es decir, el cálculo continúa sin ninguna señal de la unidad de punto flotante (FPU) o biblioteca si el punto flotante está implementado en el software. Un NaN de señalización producirá una señal, generalmente en forma de excepción de la FPU. Si se lanza la excepción depende del estado de la FPU.

C ++ 11 agrega algunos controles de lenguaje sobre el entorno de punto flotante y proporciona formas estandarizadas de crear y probar NaN . Sin embargo, si los controles se implementan no está bien estandarizado y las excepciones de punto flotante no se detectan de la misma manera que las excepciones estándar de C ++.

En los sistemas POSIX / Unix, las excepciones de punto flotante generalmente se detectan usando un controlador para SIGFPE .

Wrdieter
fuente
34
Agregando a esto: generalmente, el propósito de una señalización NaN (sNaN) es para depurar. Por ejemplo, los objetos de punto flotante pueden inicializarse en sNaN. Entonces, si el programa falla en uno de ellos un valor antes de usarlo, ocurrirá una excepción cuando el programa use el sNaN en una operación aritmética. Un programa no producirá un sNaN inadvertidamente; ninguna operación normal produce sNaN. Solo se crean específicamente con el propósito de tener un NaN de señalización, no como resultado de ninguna aritmética.
Eric Postpischil
18
Por el contrario, los NaN son para una programación más normal. Pueden producirse mediante operaciones normales cuando no hay un resultado numérico (por ejemplo, sacar la raíz cuadrada de un número negativo cuando el resultado debe ser real). Su propósito es, en general, permitir que la aritmética proceda con cierta normalidad. Por ejemplo, es posible que tenga una gran variedad de números, algunos de los cuales representan casos especiales que no se pueden manejar normalmente. Puede llamar a una función complicada para procesar esta matriz, y podría operar en la matriz con la aritmética habitual, ignorando los NaN. Una vez que termine, separaría los casos especiales para trabajar más.
Eric Postpischil
@wrdieter Gracias, entonces solo una gran diferencia genera una excepción o no.
JalalJaberi
@EricPostpischil Gracias por su atención a la segunda pregunta.
JalalJaberi
@JalalJaberi sí, la excepción es la principal diferencia
wrdieter
36

¿Cómo se ven qNaN y sNaN experimentalmente?

Primero aprendamos a identificar si tenemos un sNaN o un qNaN.

Usaré C ++ en esta respuesta en lugar de C porque ofrece lo conveniente std::numeric_limits::quiet_NaNy std::numeric_limits::signaling_NaNque no pude encontrar en C convenientemente.

Sin embargo, no pude encontrar una función para clasificar si un NaN es sNaN o qNaN, así que imprimamos los bytes sin formato de NaN:

main.cpp

#include <cassert>
#include <cstring>
#include <cmath> // nanf, isnan
#include <iostream>
#include <limits> // std::numeric_limits

#pragma STDC FENV_ACCESS ON

void print_float(float f) {
    std::uint32_t i;
    std::memcpy(&i, &f, sizeof f);
    std::cout << std::hex << i << std::endl;
}

int main() {
    static_assert(std::numeric_limits<float>::has_quiet_NaN, "");
    static_assert(std::numeric_limits<float>::has_signaling_NaN, "");
    static_assert(std::numeric_limits<float>::has_infinity, "");

    // Generate them.
    float qnan = std::numeric_limits<float>::quiet_NaN();
    float snan = std::numeric_limits<float>::signaling_NaN();
    float inf = std::numeric_limits<float>::infinity();
    float nan0 = std::nanf("0");
    float nan1 = std::nanf("1");
    float nan2 = std::nanf("2");
    float div_0_0 = 0.0f / 0.0f;
    float sqrt_negative = std::sqrt(-1.0f);

    // Print their bytes.
    std::cout << "qnan "; print_float(qnan);
    std::cout << "snan "; print_float(snan);
    std::cout << " inf "; print_float(inf);
    std::cout << "-inf "; print_float(-inf);
    std::cout << "nan0 "; print_float(nan0);
    std::cout << "nan1 "; print_float(nan1);
    std::cout << "nan2 "; print_float(nan2);
    std::cout << " 0/0 "; print_float(div_0_0);
    std::cout << "sqrt "; print_float(sqrt_negative);

    // Assert if they are NaN or not.
    assert(std::isnan(qnan));
    assert(std::isnan(snan));
    assert(!std::isnan(inf));
    assert(!std::isnan(-inf));
    assert(std::isnan(nan0));
    assert(std::isnan(nan1));
    assert(std::isnan(nan2));
    assert(std::isnan(div_0_0));
    assert(std::isnan(sqrt_negative));
}

Compila y ejecuta:

g++ -ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic -o main.out main.cpp
./main.out

salida en mi máquina x86_64:

qnan 7fc00000
snan 7fa00000
 inf 7f800000
-inf ff800000
nan0 7fc00000
nan1 7fc00001
nan2 7fc00002
 0/0 ffc00000
sqrt ffc00000

También podemos ejecutar el programa en aarch64 con el modo de usuario QEMU:

aarch64-linux-gnu-g++ -ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic -o main.out main.cpp
qemu-aarch64 -L /usr/aarch64-linux-gnu/ main.out

y eso produce exactamente el mismo resultado, lo que sugiere que varios arcos implementan de cerca IEEE 754.

En este punto, si no está familiarizado con la estructura de los números de punto flotante IEEE 754, eche un vistazo a: ¿Qué es un número de punto flotante subnormal?

En binario, algunos de los valores anteriores son:

     31
     |
     | 30    23 22                    0
     | |      | |                     |
-----+-+------+-+---------------------+
qnan 0 11111111 10000000000000000000000
snan 0 11111111 01000000000000000000000
 inf 0 11111111 00000000000000000000000
-inf 1 11111111 00000000000000000000000
-----+-+------+-+---------------------+
     | |      | |                     |
     | +------+ +---------------------+
     |    |               |
     |    v               v
     | exponent        fraction
     |
     v
     sign

De este experimento observamos que:

  • qNaN y sNaN parecen diferenciarse solo por el bit 22: 1 significa silencioso y 0 significa señalización

  • los infinitos también son bastante similares con exponente == 0xFF, pero tienen fracción == 0.

    Por esta razón, los NaN deben establecer el bit 21 en 1; de lo contrario, no sería posible distinguir sNaN del infinito positivo.

  • nanf() produce varios NaN diferentes, por lo que debe haber varias codificaciones posibles:

    7fc00000
    7fc00001
    7fc00002
    

    Dado que nan0es lo mismo que std::numeric_limits<float>::quiet_NaN(), deducimos que todos son NaN silenciosos diferentes.

    El borrador estándar C11 N1570 confirma que nanf()genera NaN silenciosos, porque nanfreenvía strtoday 7.22.1.3 "Las funciones strtod, strtof y strtold" dice:

    Una secuencia de caracteres NAN o NAN (n-char-sequence opt) se interpreta como un NaN silencioso, si se admite en el tipo de retorno, o como una parte de la secuencia del sujeto que no tiene la forma esperada; el significado de la secuencia n-char está definido por la implementación. 293)

Ver también:

¿Qué aspecto tienen los qNaN y sNaN en los manuales?

IEEE 754 2008 recomienda que (TODO obligatorio u opcional?):

  • cualquier cosa con exponente == 0xFF y fracción! = 0 es un NaN
  • y que el bit de la fracción más alta diferencia qNaN de sNaN

pero no parece decir qué bit se prefiere para diferenciar el infinito de NaN.

6.2.1 "Codificaciones NaN en formatos binarios" dice:

Esta subcláusula especifica además las codificaciones de NaN como cadenas de bits cuando son el resultado de operaciones. Cuando se codifican, todos los NaN tienen un bit de signo y un patrón de bits necesarios para identificar la codificación como un NaN y que determina su tipo (sNaN frente a qNaN). Los bits restantes, que se encuentran en el campo de significación final, codifican la carga útil, que podría ser información de diagnóstico (ver arriba). 34

Todas las cadenas de bits binarias NaN tienen todos los bits del campo de exponente polarizado E puestos a 1 (ver 3.4). Una cadena de bits NaN silenciosa debe codificarse con el primer bit (d1) del campo de significación final T siendo 1. Una cadena de bits de señalización NaN debe codificarse con el primer bit del campo de significación final como 0. Si el primer bit del El campo de significación final es 0, algún otro bit del campo de significación final debe ser distinto de cero para distinguir el NaN del infinito. En la codificación preferida que se acaba de describir, una señalización NaN se silenciará poniendo d1 en 1, dejando los bits restantes de T sin cambios. Para formatos binarios, la carga útil se codifica en los p − 2 bits menos significativos del campo de significación final

El Manual del desarrollador de software de arquitecturas Intel 64 e IA-32 - Volumen 1 Arquitectura básica - 253665-056ES Septiembre de 2015 4.8.3.4 "NaNs" confirma que x86 sigue IEEE 754 al distinguir NaN y sNaN por el bit de fracción más alto:

La arquitectura IA-32 define dos clases de NaN: NaN silenciosos (QNaN) y NaN de señalización (SNaN). Un QNaN es un NaN con el bit de fracción más significativo establecido, un SNaN es un NaN con el bit de fracción más significativo libre.

y también lo hace el Manual de referencia de arquitectura ARM - ARMv8, para el perfil de arquitectura ARMv8-A - DDI 0487C.a A1.4.3 "Formato de punto flotante de precisión simple":

fraction != 0: El valor es un NaN y es un NaN silencioso o un NaN de señalización. Los dos tipos de NaN se distinguen por su bit de fracción más significativa, el bit [22]:

  • bit[22] == 0: El NaN es un NaN de señalización. El bit de signo puede tomar cualquier valor y los bits de fracción restantes pueden tomar cualquier valor excepto todos los ceros.
  • bit[22] == 1: El NaN es un NaN silencioso. El bit de signo y los bits de fracción restantes pueden tomar cualquier valor.

¿Cómo se generan qNanS y sNaNs?

Una diferencia importante entre qNaN y sNaN es que:

  • qNaN se genera mediante operaciones aritméticas incorporadas (software o hardware) regulares con valores extraños
  • sNaN nunca se genera mediante operaciones integradas, solo los programadores pueden agregarlo explícitamente, por ejemplo, con std::numeric_limits::signaling_NaN

No pude encontrar citas claras de IEEE 754 o C11 para eso, pero tampoco puedo encontrar ninguna operación incorporada que genere sNaN ;-)

Sin embargo, el manual de Intel establece claramente este principio en 4.8.3.4 "NaN":

Los SNaN se utilizan normalmente para capturar o invocar un controlador de excepciones. Deben insertarse mediante software; es decir, el procesador nunca genera un SNaN como resultado de una operación de punto flotante.

Esto se puede ver en nuestro ejemplo donde ambos:

float div_0_0 = 0.0f / 0.0f;
float sqrt_negative = std::sqrt(-1.0f);

producir exactamente los mismos bits que std::numeric_limits<float>::quiet_NaN().

Ambas operaciones se compilan en una sola instrucción de ensamblaje x86 que genera el qNaN directamente en el hardware (TODO confirmar con GDB).

¿Qué hacen los qNaN y los sNaN de manera diferente?

Ahora que sabemos cómo son los qNaN y sNaN, y cómo manipularlos, ¡finalmente estamos listos para intentar hacer que los sNaN hagan lo suyo y explotar algunos programas!

Así que sin más preámbulos:

blow_up.cpp

#include <cassert>
#include <cfenv>
#include <cmath> // isnan
#include <iostream>
#include <limits> // std::numeric_limits
#include <unistd.h>

#pragma STDC FENV_ACCESS ON

int main() {
    float snan = std::numeric_limits<float>::signaling_NaN();
    float qnan = std::numeric_limits<float>::quiet_NaN();
    float f;

    // No exceptions.
    assert(std::fetestexcept(FE_ALL_EXCEPT) == 0);

    // Still no exceptions because qNaN.
    f = qnan + 1.0f;
    assert(std::isnan(f));
    if (std::fetestexcept(FE_ALL_EXCEPT) == FE_INVALID)
        std::cout << "FE_ALL_EXCEPT qnan + 1.0f" << std::endl;

    // Now we can get an exception because sNaN, but signals are disabled.
    f = snan + 1.0f;
    assert(std::isnan(f));
    if (std::fetestexcept(FE_ALL_EXCEPT) == FE_INVALID)
        std::cout << "FE_ALL_EXCEPT snan + 1.0f" << std::endl;
    feclearexcept(FE_ALL_EXCEPT);

    // And now we enable signals and blow up with SIGFPE! >:-)
    feenableexcept(FE_INVALID);
    f = qnan + 1.0f;
    std::cout << "feenableexcept qnan + 1.0f" << std::endl;
    f = snan + 1.0f;
    std::cout << "feenableexcept snan + 1.0f" << std::endl;
}

Compile, ejecute y obtenga el estado de salida:

g++ -ggdb3 -O0 -Wall -Wextra -pthread -std=c++11 -pedantic-errors -o blow_up.out blow_up.cpp -lm -lrt
./blow_up.out
echo $?

Salida:

FE_ALL_EXCEPT snan + 1.0f
feenableexcept qnan + 1.0f
Floating point exception (core dumped)
136

Tenga en cuenta que este comportamiento solo ocurre con -O0GCC 8.2: con -O3, GCC precalcula y optimiza todas nuestras operaciones sNaN. No estoy seguro de si existe una forma estándar de prevenir eso.

Entonces deducimos de este ejemplo que:

  • snan + 1.0causa FE_INVALID, pero qnan + 1.0no

  • Linux solo genera una señal si está habilitado con feenableexept.

    Esta es una extensión glibc, no pude encontrar ninguna forma de hacerlo en ningún estándar.

Cuando ocurre la señal, es porque el propio hardware de la CPU genera una excepción, que el kernel de Linux manejó e informó a la aplicación a través de la señal.

El resultado es que bash imprime Floating point exception (core dumped), y el estado de salida es 136, que corresponde a la señal 136 - 128 == 8, que de acuerdo con:

man 7 signal

es SIGFPE.

Tenga en cuenta que SIGFPEes la misma señal que obtenemos si intentamos dividir un número entero entre 0:

int main() {
    int i = 1 / 0;
}

aunque para enteros:

  • dividir cualquier cosa por cero aumenta la señal, ya que no hay representación infinita en números enteros
  • la señal ocurre por defecto, sin necesidad de feenableexcept

¿Cómo manejar el SIGFPE?

Si solo crea un controlador que regresa normalmente, conduce a un bucle infinito, porque después de que el controlador regresa, ¡la división vuelve a ocurrir! Esto se puede verificar con GDB.

La única forma es usar setjmpy longjmpsaltar a otro lugar como se muestra en: C maneja la señal SIGFPE y continúa la ejecución

¿Cuáles son algunas de las aplicaciones del mundo real de sNaN?

Honestamente, todavía no he entendido un caso de uso súper útil para sNaN, esto se ha preguntado en: ¿ Utilidad de la señalización de NaN?

Los sNaN se sienten particularmente inútiles porque podemos detectar las operaciones no válidas iniciales ( 0.0f/0.0f) que generan qNaN con feenableexcept: parece que snansolo genera errores para más operaciones que qnanno generan , por ejemplo, ( qnan + 1.0f).

P.ej:

C Principal

#define _GNU_SOURCE
#include <fenv.h>
#include <stdio.h>

int main(int argc, char **argv) {
    (void)argv;
    float f0 = 0.0;

    if (argc == 1) {
        feenableexcept(FE_INVALID);
    }
    float f1 = 0.0 / f0;
    printf("f1 %f\n", f1);

    feenableexcept(FE_INVALID);
    float f2 = f1 + 1.0;
    printf("f2 %f\n", f2);
}

compilar:

gcc -ggdb3 -O0 -std=c99 -Wall -Wextra -pedantic -o main.out main.c -lm

luego:

./main.out

da:

Floating point exception (core dumped)

y:

./main.out  1

da:

f1 -nan
f2 -nan

Vea también: Cómo rastrear un NaN en C ++

¿Qué son las banderas de señales y cómo se manipulan?

Todo está implementado en el hardware de la CPU.

Las banderas viven en algún registro, al igual que el bit que dice si se debe generar una excepción / señal.

Esos registros son accesibles desde la tierra del usuario desde la mayoría de los archivos.

¡Esta parte del código glibc 2.29 es realmente muy fácil de entender!

Por ejemplo, fetestexceptse implementa para x86_86 en sysdeps / x86_64 / fpu / ftestexcept.c :

#include <fenv.h>

int
fetestexcept (int excepts)
{
  int temp;
  unsigned int mxscr;

  /* Get current exceptions.  */
  __asm__ ("fnstsw %0\n"
       "stmxcsr %1" : "=m" (*&temp), "=m" (*&mxscr));

  return (temp | mxscr) & excepts & FE_ALL_EXCEPT;
}
libm_hidden_def (fetestexcept)

por lo que inmediatamente vemos que las instrucciones de uso son stmxcsrlas siglas de "Store MXCSR Register State".

Y feenableexceptse implementa en sysdeps / x86_64 / fpu / feenablxcpt.c :

#include <fenv.h>

int
feenableexcept (int excepts)
{
  unsigned short int new_exc, old_exc;
  unsigned int new;

  excepts &= FE_ALL_EXCEPT;

  /* Get the current control word of the x87 FPU.  */
  __asm__ ("fstcw %0" : "=m" (*&new_exc));

  old_exc = (~new_exc) & FE_ALL_EXCEPT;

  new_exc &= ~excepts;
  __asm__ ("fldcw %0" : : "m" (*&new_exc));

  /* And now the same for the SSE MXCSR register.  */
  __asm__ ("stmxcsr %0" : "=m" (*&new));

  /* The SSE exception masks are shifted by 7 bits.  */
  new &= ~(excepts << 7);
  __asm__ ("ldmxcsr %0" : : "m" (*&new));

  return old_exc;
}

¿Qué dice el estándar C sobre qNaN vs sNaN?

El borrador del estándar C11 N1570 dice explícitamente que el estándar no diferencia entre ellos en F.2.1 "Infinitos, ceros con signo y NaN":

1 Esta especificación no define el comportamiento de la señalización de NaN. Generalmente usa el término NaN para denotar NaN silenciosos. Las macros NAN e INFINITY y las funciones nan <math.h>proporcionan designaciones para IEC 60559 NaN e infinitos.

Probado en Ubuntu 18.10, GCC 8.2. GitHub upstream:

Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
fuente
en.wikipedia.org/wiki/IEEE_754#Interchange_formats señala que IEEE-754 simplemente sugiere que 0 para señalizar NaN es una buena opción de implementación, para permitir silenciar un NaN sin correr el riesgo de convertirlo en infinito (significando = 0). Aparentemente eso no está estandarizado, aunque es lo que hace x86. (Y el hecho de que sea el MSB del significando lo que determina qNaN vs sNaN está estandarizado). en.wikipedia.org/wiki/Single-precision_floating-point_format dice que x86 y ARM son lo mismo, pero PA-RISC tomó la decisión opuesta.
Peter Cordes
@PeterCordes sí, no estoy seguro de qué "debería" == "debe" o "se prefiere" en IEEE 754 20at "Una cadena de bits NaN de señalización debe codificarse con el primer bit del campo de significación final siendo 0".
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
re: pero no parece especificar qué bit debe usarse para diferenciar infinito de NaN. Escribiste eso como esperabas que hubiera algún bit específico que el estándar recomienda configurar para distinguir sNaN del infinito. IDK por qué esperarías que hubiera algo así; cualquier opción distinta de cero está bien. Simplemente elija algo que luego identifique de dónde vino el sNaN. IDK, suena como una fraseología extraña, y mi primera impresión al leerlo fue que estabas diciendo que la página web no describía lo que distingue a inf de NaN en la codificación (un significado todo cero).
Peter Cordes
Antes de 2008, IEEE 754 decía cuál es el bit de señalización / silencio (bit 22) pero no qué valor especificaba qué. La mayoría de los procesadores habían convergido en 1 = silencioso, por lo que se convirtió en parte del estándar en la edición de 2008. Dice "debería" en lugar de "debe" para evitar realizar implementaciones más antiguas que hicieron que la misma elección no fuera conforme. En general, "debería" en un estándar significa "debe, a menos que tenga razones muy convincentes (y preferiblemente bien documentadas) para no cumplir".
John Cowan