Qual é a diferença entre NaN silencioso e NaN de sinalização?

96

Eu li sobre ponto flutuante e entendo que NaN pode resultar de operações. Mas não consigo entender o que são conceitos exatamente. Qual a diferença entre eles?

Qual pode ser produzido durante a programação C ++? Como programador, eu poderia escrever um programa que causasse um sNaN?

JalalJaberi
fonte

Respostas:

68

Quando uma operação resulta em um NaN silencioso, não há indicação de que algo está incomum até que o programa verifique o resultado e veja um NaN. Ou seja, a computação continua sem qualquer sinal da unidade de ponto flutuante (FPU) ou biblioteca se o ponto flutuante for implementado no software. Um NaN de sinalização produzirá um sinal, geralmente na forma de exceção da FPU. Se a exceção é lançada depende do estado da FPU.

C ++ 11 adiciona alguns controles de linguagem sobre o ambiente de ponto flutuante e fornece maneiras padronizadas de criar e testar NaNs . No entanto, o fato de os controles serem implementados não é bem padronizado e as exceções de ponto flutuante geralmente não são capturadas da mesma maneira que as exceções C ++ padrão.

Em sistemas POSIX / Unix, as exceções de ponto flutuante são normalmente capturadas usando um manipulador para SIGFPE .

wrdieter
fonte
34
Somando a isso: Geralmente, a finalidade de um NaN de sinalização (sNaN) é para depuração. Por exemplo, objetos de ponto flutuante podem ser inicializados para sNaN. Então, se o programa falhar em um deles um valor antes de usá-lo, uma exceção ocorrerá quando o programa usar o sNaN em uma operação aritmética. Um programa não produzirá um sNaN inadvertidamente; nenhuma operação normal produz sNaNs. Eles só são criados especificamente com o propósito de ter uma sinalização NaN, não como resultado de qualquer aritmética.
Eric Postpischil
18
Em contraste, os NaNs são para uma programação mais normal. Eles podem ser produzidos por operações normais quando não há resultado numérico (por exemplo, tirar a raiz quadrada de um número negativo quando o resultado deve ser real). Seu objetivo é geralmente permitir que a aritmética prossiga de forma um tanto normal. Por exemplo, você pode ter uma grande variedade de números, alguns dos quais representam casos especiais que não podem ser tratados normalmente. Você pode chamar uma função complicada para processar este array, e ela pode operar no array com a aritmética usual, ignorando os NaNs. Após o término, você separaria os casos especiais para mais trabalho.
Eric Postpischil
@wrdieter Obrigado, então apenas uma grande diferença está gerando exceção ou não.
JalalJaberi
@EricPostpischil Obrigado por sua atenção à segunda pergunta.
JalalJaberi
@JalalJaberi sim, a exceção é a principal diferença
wrdieter
34

Qual a aparência dos qNaNs e sNaNs experimentalmente?

Vamos primeiro aprender como identificar se temos um sNaN ou um qNaN.

Usarei C ++ nesta resposta em vez de C porque oferece o conveniente std::numeric_limits::quiet_NaNe std::numeric_limits::signaling_NaNque não pude encontrar em C convenientemente.

No entanto, não consegui encontrar uma função para classificar se um NaN é sNaN ou qNaN, então vamos apenas imprimir os bytes brutos 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));
}

Compile e execute:

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

saída na minha máquina x86_64:

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

Também podemos executar o programa em aarch64 com o modo de usuário 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

e isso produz exatamente a mesma saída, sugerindo que vários archs implementam de perto o IEEE 754.

Neste ponto, se você não estiver familiarizado com a estrutura dos números de ponto flutuante IEEE 754, dê uma olhada em: O que é um número de ponto flutuante subnormal?

Em binário, alguns dos valores acima são:

     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

A partir desta experiência, observamos que:

  • qNaN e sNaN parecem ser diferenciados apenas pelo bit 22: 1 significa silencioso e 0 significa sinalização

  • infinitos também são bastante semelhantes com expoente == 0xFF, mas eles têm fração == 0.

    Por esta razão, os NaNs devem definir o bit 21 para 1, caso contrário não seria possível distinguir sNaN de infinito positivo!

  • nanf() produz vários NaNs diferentes, portanto, deve haver várias codificações possíveis:

    7fc00000
    7fc00001
    7fc00002
    

    Como nan0é o mesmo que std::numeric_limits<float>::quiet_NaN(), deduzimos que são todos NaNs silenciosos diferentes.

    O projeto padrão C11 N1570 confirma que nanf()gera NaNs silenciosos, porque nanfencaminha para strtode 7.22.1.3 "As funções strtod, strtof e strtold" diz:

    Uma sequência de caracteres NAN ou NAN (n-char-sequence opt) é interpretada como um NaN silencioso, se suportado no tipo de retorno, senão como uma parte da sequência de assunto que não tem a forma esperada; o significado da sequência n-char é definido pela implementação. 293)

Veja também:

Qual a aparência dos qNaNs e sNaNs nos manuais?

IEEE 754 2008 recomenda que (TODO obrigatório ou opcional?):

  • qualquer coisa com expoente == 0xFF e fração! = 0 é um NaN
  • e que o bit de fração mais alto diferencia qNaN de sNaN

mas não parece dizer qual bit é preferido para diferenciar o infinito de NaN.

6.2.1 "Codificações NaN em formatos binários" diz:

Esta subseção especifica ainda mais as codificações de NaNs como cadeias de bits quando são o resultado de operações. Quando codificados, todos os NaNs possuem um bit de sinal e um padrão de bits necessários para identificar a codificação como um NaN e que determina seu tipo (sNaN vs. qNaN). Os bits restantes, que estão no campo de significando à direita, codificam a carga útil, que pode ser uma informação de diagnóstico (veja acima). 34

Todas as cadeias binárias de bits NaN têm todos os bits do campo expoente polarizado E definido como 1 (consulte 3.4). Uma string de bits NaN silenciosa deve ser codificada com o primeiro bit (d1) do campo de significando final T sendo 1. Uma string de bits NaN de sinalização deve ser codificada com o primeiro bit do campo de significando final sendo 0. Se o primeiro bit do O campo de significando final é 0, algum outro bit do campo de significando final deve ser diferente de zero para distinguir o NaN do infinito. Na codificação preferida que acabamos de descrever, uma sinalização NaN deve ser silenciada, definindo d1 para 1, deixando os bits restantes de T inalterados. Para formatos binários, a carga útil é codificada nos p-2 bits menos significativos do campo de significando à direita

O Manual do Intel 64 e IA-32 Arquiteturas Software Developer - Volume 1 arquitetura básica - 253665-056US setembro 2015 4.8.3.4 "NANS" confirma que x86 segue IEEE 754, distinguindo NaN e Snan pelo mais alto bit fração:

A arquitetura IA-32 define duas classes de NaNs: NaNs silenciosos (QNaNs) e NaNs de sinalização (SNaNs). Um QNaN é um NaN com o bit de fração mais significativo definido e um SNaN é um NaN com o bit de fração mais significativo limpo.

e também o ARM Architecture Reference Manual - ARMv8, para o perfil de arquitetura ARMv8-A - DDI 0487C.a A1.4.3 "Formato de ponto flutuante de precisão única":

fraction != 0: O valor é um NaN e é um NaN silencioso ou um NaN de sinalização. Os dois tipos de NaN são distinguidos por seu bit de fração mais significativo, bit [22]:

  • bit[22] == 0: O NaN é um NaN de sinalização. O bit de sinal pode assumir qualquer valor e os bits de fração restantes podem assumir qualquer valor, exceto zeros.
  • bit[22] == 1: O NaN é um NaN silencioso. O bit de sinal e os bits de fração restantes podem assumir qualquer valor.

Como qNanS e sNaNs são gerados?

Uma das principais diferenças entre qNaNs e sNaNs é que:

  • qNaN é gerado por operações aritméticas internas regulares (software ou hardware) com valores estranhos
  • sNaN nunca é gerado por operações integradas, ele só pode ser explicitamente adicionado por programadores, por exemplo, com std::numeric_limits::signaling_NaN

Não consegui encontrar citações IEEE 754 ou C11 claras para isso, mas também não consigo encontrar nenhuma operação integrada que gere sNaNs ;-)

O manual da Intel afirma claramente este princípio em 4.8.3.4 "NaNs":

SNaNs são normalmente usados ​​para interceptar ou chamar um manipulador de exceção. Devem ser inseridos por software; ou seja, o processador nunca gera um SNaN como resultado de uma operação de ponto flutuante.

Isso pode ser visto em nosso exemplo em que:

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

produz exatamente os mesmos bits que std::numeric_limits<float>::quiet_NaN().

Ambas as operações são compiladas em uma única instrução de montagem x86 que gera o qNaN diretamente no hardware (TODO confirma com GDB).

O que os qNaNs e os sNaNs fazem de maneira diferente?

Agora que sabemos como são os qNaNs e sNaNs e como manipulá-los, estamos finalmente prontos para tentar fazer com que os sNaNs façam o que querem e explodam alguns programas!

Então, sem mais delongas:

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, execute e obtenha o status de saída:

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

Resultado:

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

Observe que esse comportamento só acontece com o -O0GCC 8.2: com -O3, o GCC pré-calcula e otimiza todas as nossas operações sNaN! Não tenho certeza se existe uma maneira padrão compatível de evitar isso.

Portanto, deduzimos deste exemplo que:

  • snan + 1.0causa FE_INVALID, mas qnan + 1.0não

  • O Linux só gera um sinal se estiver habilitado com feenableexept.

    Esta é uma extensão glibc, não consegui encontrar nenhuma maneira de fazer isso em nenhum padrão.

Quando o sinal acontece, é porque o próprio hardware da CPU levanta uma exceção, que o kernel do Linux manipula e informa a aplicação por meio do sinal.

O resultado é que o bash imprime Floating point exception (core dumped), e o status de saída é 136, que corresponde ao sinal 136 - 128 == 8, que de acordo com:

man 7 signal

é SIGFPE.

Observe que SIGFPEé o mesmo sinal que obteremos se tentarmos dividir um número inteiro por 0:

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

embora para inteiros:

  • dividir qualquer coisa por zero aumenta o sinal, uma vez que não há representação infinita em inteiros
  • o sinal acontece por padrão, sem a necessidade de feenableexcept

Como manusear o SIGFPE?

Se você apenas criar um manipulador que retorna normalmente, isso leva a um loop infinito, porque depois que o manipulador retorna, a divisão acontece novamente! Isso pode ser verificado com GDB.

A única maneira é usar setjmpe longjmppular para outro lugar, como mostrado em: C manipular o sinal SIGFPE e continuar a execução

Quais são algumas das aplicações do mundo real de sNaNs?

Sinceramente, ainda não entendi um caso de uso superútil para sNaNs, isso foi perguntado em: Utilidade da sinalização de NaN?

sNaNs parecem particularmente inúteis porque podemos detectar as operações inválidas iniciais ( 0.0f/0.0f) que geram qNaNs com feenableexcept: parece que snanapenas levanta erros para mais operações que qnannão levantam para, por exemplo ( qnan + 1.0f).

Por exemplo:

main.c

#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

então:

./main.out

dá:

Floating point exception (core dumped)

e:

./main.out  1

dá:

f1 -nan
f2 -nan

Veja também: Como rastrear um NaN em C ++

Quais são os sinalizadores e como são manipulados?

Tudo é implementado no hardware da CPU.

Os sinalizadores residem em algum registro, assim como o bit que diz se uma exceção / sinal deve ser levantada.

Esses registros são acessíveis a partir do userland da maioria dos archs.

Esta parte do código glibc 2.29 é realmente muito fácil de entender!

Por exemplo, fetestexcepté implementado para x86_86 em 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)

portanto, vemos imediatamente que as instruções de uso stmxcsrsignificam "Store MXCSR Register State".

E feenableexcepté implementado em 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;
}

O que o padrão C diz sobre qNaN vs sNaN?

O rascunho do padrão C11 N1570 diz explicitamente que o padrão não diferencia entre eles em F.2.1 "Infinidades, zeros assinados e NaNs":

1 Esta especificação não define o comportamento da sinalização de NaNs. Geralmente usa o termo NaN para denotar NaNs silenciosos. As macros NAN e INFINITY e as funções nan <math.h>fornecem designações para NaNs IEC 60559 e infinitos.

Testado em Ubuntu 18.10, GCC 8.2. Upstreams do GitHub:

Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
fonte
en.wikipedia.org/wiki/IEEE_754#Interchange_formats aponta que o IEEE-754 apenas sugere que 0 para sinalizar NaNs é uma boa escolha de implementação, para permitir silenciar um NaN sem correr o risco de torná-lo infinito (significando = 0). Aparentemente, isso não é padronizado, embora seja o que o x86 faz. (E o fato de que é o MSB do significando que determina qNaN vs. sNaN é padronizado). en.wikipedia.org/wiki/Single-precision_floating-point_format diz que x86 e ARM são iguais, mas PA-RISC fez a escolha oposta.
Peter Cordes
@PeterCordes sim, não tenho certeza do que "deveria" == "deve" ou "é preferido" no IEEE 754 20at "Uma string de bits NaN de sinalização deve ser codificada com o primeiro bit do campo de significando final sendo 0".
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
re: mas não parece especificar qual bit deve ser usado para diferenciar infinito de NaN. Você escreveu isso como se esperasse que houvesse alguma parte específica que o padrão recomenda definir para distinguir sNaN do infinito. IDK por que você esperaria que houvesse tal bit; qualquer escolha diferente de zero está bem. Basta escolher algo que posteriormente identifique de onde veio o sNaN. IDK, soa como uma frase estranha, e minha primeira impressão ao ler foi que você estava dizendo que a página da web não descreve o que distingue inf de NaN na codificação (um significando totalmente zero).
Peter Cordes
Antes de 2008, o IEEE 754 dizia qual é o bit de sinalização / silencioso (bit 22), mas não qual valor especificava o quê. A maioria dos processadores convergiu para 1 = silencioso, então isso se tornou parte do padrão na edição de 2008. Ele diz "deveria" em vez de "deve" para evitar que as implementações mais antigas que fizeram a mesma escolha não estejam em conformidade. Em geral, "deveria" em um padrão significa "deve, a menos que você tenha razões muito convincentes (e de preferência bem documentadas) para não cumprir".
John Cowan