Resultado de ponto flutuante diferente com otimização habilitada - bug do compilador?


109

O código a seguir funciona no Visual Studio 2008 com e sem otimização. Mas só funciona no g ++ sem otimização (O0).

#include <cstdlib>
#include <iostream>
#include <cmath>

double round(double v, double digit)
{
    double pow = std::pow(10.0, digit);
    double t = v * pow;
    //std::cout << "t:" << t << std::endl;
    double r = std::floor(t + 0.5);
    //std::cout << "r:" << r << std::endl;
    return r / pow;
}

int main(int argc, char *argv[])
{
    std::cout << round(4.45, 1) << std::endl;
    std::cout << round(4.55, 1) << std::endl;
}

A saída deve ser:

4.5
4.6

Mas g ++ com otimização ( O1- O3) produzirá:

4.5
4.5

Se eu adicionar a volatilepalavra - chave antes de t, funciona, então pode haver algum tipo de bug de otimização?

Teste em g ++ 4.1.2 e 4.4.4.

Aqui está o resultado em ideone: http://ideone.com/Rz937

E a opção que testo no g ++ é simples:

g++ -O2 round.cpp

O resultado mais interessante, mesmo eu habilitando a /fp:fastopção no Visual Studio 2008, o resultado ainda está correto.

Mais perguntas:

Eu estava me perguntando, devo sempre ativar essa -ffloat-storeopção?

Porque a versão g ++ que testei é fornecida com CentOS / Red Hat Linux 5 e CentOS / Redhat 6 .

Compilei muitos de meus programas nessas plataformas e estou preocupado que isso cause bugs inesperados em meus programas. Parece um pouco difícil investigar todo o meu código C ++ e bibliotecas usadas se eles têm esses problemas. Alguma sugestão?

Alguém está interessado em saber /fp:fastpor que o Visual Studio 2008 ainda funciona? Parece que o Visual Studio 2008 é mais confiável nesse problema do que o g ++?


51
Para todos os novos usuários do SO: ASSIM é como você faz uma pergunta. +1
dez

1
FWIW, estou obtendo a saída correta com g ++ 4.5.0 usando MinGW.
Steve Blackwell,

2
ideone usa 4.3.4 ideone.com/b8VXg
Daniel A. White

5
Você deve ter em mente que é improvável que sua rotina funcione de forma confiável com todos os tipos de saída. Em contraste com o arredondamento de um duplo para um inteiro, isso é vulnerável ao fato de que nem todos os números reais podem ser representados, então você deve esperar obter mais bugs como este.
Jakub Wieczorek

2
Para aqueles que não conseguem reproduzir o bug: não descomente as instruções de depuração comentadas, elas afetam o resultado.
n. 'pronomes' m.

Respostas:


91

Os processadores Intel x86 usam a precisão estendida de 80 bits internamente, ao passo que doublenormalmente é de 64 bits. Diferentes níveis de otimização afetam a frequência com que os valores de ponto flutuante da CPU são salvos na memória e, portanto, arredondados da precisão de 80 bits para a precisão de 64 bits.

Use a -ffloat-storeopção gcc para obter os mesmos resultados de ponto flutuante com diferentes níveis de otimização.

Como alternativa, use o long doubletipo, que normalmente tem 80 bits de largura no gcc para evitar o arredondamento da precisão de 80 bits para 64 bits.

man gcc diz tudo:

   -ffloat-store
       Do not store floating point variables in registers, and inhibit
       other options that might change whether a floating point value is
       taken from a register or memory.

       This option prevents undesirable excess precision on machines such
       as the 68000 where the floating registers (of the 68881) keep more
       precision than a "double" is supposed to have.  Similarly for the
       x86 architecture.  For most programs, the excess precision does
       only good, but a few programs rely on the precise definition of
       IEEE floating point.  Use -ffloat-store for such programs, after
       modifying them to store all pertinent intermediate computations
       into variables.

Em compilações x86_64, os compiladores usam registros SSE para floate doublepor padrão, de forma que nenhuma precisão estendida seja usada e esse problema não ocorra.

gcca opção do compilador-mfpmath controla isso.


20
Acho que esta é a resposta. A constante 4.55 é convertida em 4.54999999999999 que é a representação binária mais próxima em 64 bits; multiplique por 10 e arredonde novamente para 64 bits e você terá 45,5. Se você pular a etapa de arredondamento mantendo-o em um registro de 80 bits, terá 45,4999999999999.
Mark Ransom,

Obrigado, eu ainda não conheço essa opção. Mas eu estava me perguntando, devo sempre ativar a opção -ffloat-store? Como a versão g ++ que testei é fornecida com CentOS / Redhat 5 e CentOS / Redhat 6. Compilei muitos dos meus programas nessas plataformas, estou preocupado se isso irá causar erros inesperados em meus programas.
Bear

5
@Bear, a instrução de depuração provavelmente faz com que o valor seja liberado de um registro para a memória.
Mark Ransom,

2
@Bear, normalmente seu aplicativo deve se beneficiar da precisão estendida, a menos que opere em valores extremamente pequenos ou enormes quando é esperado que um float de 64 bits seja insuficiente ou excessivo e produza inf. Não existe uma boa regra prática, os testes de unidade podem fornecer uma resposta definitiva.
Maxim Egorushkin,

2
@bear Como regra geral, se você precisa de resultados perfeitamente previsíveis e / ou exatamente o que um humano obteria fazendo as somas no papel, você deve evitar o ponto flutuante. -ffloat-store remove uma fonte de imprevisibilidade, mas não é uma solução mágica.
plugwash de

10

A saída deve ser: 4.5 4.6 Essa é a saída que seria se você tivesse precisão infinita ou se estivesse trabalhando com um dispositivo que usasse uma representação de ponto flutuante baseada em decimal em vez de binária. Mas você não é. A maioria dos computadores usa o padrão de ponto flutuante IEEE binário.

Como Maxim Yegorushkin já observou em sua resposta, parte do problema é que internamente seu computador está usando uma representação de ponto flutuante de 80 bits. No entanto, isso é apenas parte do problema. A base do problema é que qualquer número na forma n.nn5 não tem uma representação binária flutuante exata. Esses casos extremos são sempre números inexatos.

Se você realmente deseja que seu arredondamento seja capaz de contornar de forma confiável esses casos extremos, você precisa de um algoritmo de arredondamento que aborde o fato de que n.n5, n.nn5 ou n.nnn5, etc. (mas não n.5) é sempre inexato. Encontre a caixa de canto que determina se algum valor de entrada é arredondado para cima ou para baixo e retorna o valor arredondado para cima ou para baixo com base em uma comparação com esta caixa de canto. E você precisa tomar cuidado para que um compilador otimizado não coloque aquele caso de canto encontrado em um registro de precisão estendido.

Consulte Como o Excel arredonda números flutuantes com êxito, embora sejam imprecisos? para tal algoritmo.

Ou você pode simplesmente conviver com o fato de que os casos mais difíceis às vezes terminam erroneamente.


6

Compiladores diferentes têm configurações de otimização diferentes. Algumas dessas configurações de otimização mais rápidas não mantêm regras estritas de ponto flutuante de acordo com IEEE 754 . Visual Studio tem uma configuração específica, /fp:strict, /fp:precise, /fp:fast, em que /fp:fastviola o padrão sobre o que pode ser feito. Você pode descobrir que esse sinalizador é o que controla a otimização em tais configurações. Você também pode encontrar uma configuração semelhante no GCC que muda o comportamento.

Se esse for o caso, a única coisa diferente entre os compiladores é que o GCC procuraria o comportamento de ponto flutuante mais rápido por padrão em otimizações mais altas, enquanto o Visual Studio não altera o comportamento de ponto flutuante com níveis de otimização mais altos. Portanto, pode não ser necessariamente um bug real, mas o comportamento intencional de uma opção que você não sabia que estava ativando.


4
Há uma -ffast-mathchave para o GCC que, e não é ativada por nenhum dos -Oníveis de otimização desde a citação: "pode ​​resultar em saída incorreta para programas que dependem de uma implementação exata de regras / especificações IEEE ou ISO para funções matemáticas."
Mat,

@Mat: Eu tentei -ffast-mathe algumas outras coisas no meu g++ 4.4.3e ainda não consigo reproduzir o problema.
NPE

Legal: com -ffast-matheu obtenho 4.5em ambos os casos níveis de otimização maiores que 0.
Kerrek SB

(Correção: recebo 4.5com -O1e -O2, mas não com -O0e -O3no GCC 4.4.3, mas com -O1,2,3no GCC 4.6.1.)
Kerrek SB

4

Para aqueles que não conseguem reproduzir o bug: não descomente as instruções de depuração comentadas, elas afetam o resultado.

Isso significa que o problema está relacionado às instruções de depuração. E parece que há um erro de arredondamento causado pelo carregamento dos valores nos registradores durante as instruções de saída, e é por isso que outros descobriram que você pode corrigir isso com-ffloat-store

Mais perguntas:

Eu estava me perguntando, devo sempre ativar a -ffloat-storeopção?

Para ser leviano, deve haver uma razão que alguns programadores não ligar -ffloat-store, caso contrário, a opção não existiria (do mesmo modo, deve haver uma razão que alguns programadores não ligar -ffloat-store). Eu não recomendaria sempre ligá-lo ou desligá-lo sempre. Ativá-lo impede algumas otimizações, mas desativá-lo permite o tipo de comportamento que você está obtendo.

Mas, geralmente, há alguma incompatibilidade entre números de ponto flutuante binário (como o computador usa) e números de ponto flutuante decimal (que as pessoas estão familiarizadas), e essa incompatibilidade pode causar um comportamento semelhante ao que você está obtendo (para ser claro, o comportamento que você está recebendo não é causado por essa incompatibilidade, mas um comportamento semelhante pode ser). O fato é que, como você já tem alguma imprecisão ao lidar com ponto flutuante, não posso dizer que -ffloat-storeisso torne isso melhor ou pior.

Em vez disso, você pode querer procurar outras soluções para o problema que está tentando resolver (infelizmente, Koenig não aponta para o papel real, e eu realmente não consigo encontrar um lugar "canônico" óbvio para ele, então eu terá que enviar você para o Google ).


Se você não estiver arredondando para fins de saída, provavelmente examinaria std::modf()(in cmath) e std::numeric_limits<double>::epsilon()(in limits). Pensando na round()função original , acredito que seria mais limpo substituir a chamada de std::floor(d + .5)por uma chamada para esta função:

// this still has the same problems as the original rounding function
int round_up(double d)
{
    // return value will be coerced to int, and truncated as expected
    // you can then assign the int to a double, if desired
    return d + 0.5;
}

Acho que isso sugere a seguinte melhoria:

// this won't work for negative d ...
// this may still round some numbers up when they should be rounded down
int round_up(double d)
{
    double floor;
    d = std::modf(d, &floor);
    return floor + (d + .5 + std::numeric_limits<double>::epsilon());
}

Uma nota simples: std::numeric_limits<T>::epsilon()é definida como "o menor número adicionado a 1 que cria um número diferente de 1." Você geralmente precisa usar um épsilon relativo (ou seja, dimensionar épsilon de alguma forma para explicar o fato de que você está trabalhando com números diferentes de "1"). A soma de d, .5e std::numeric_limits<double>::epsilon()deve estar perto de 1, então o agrupamento de meios de adição que std::numeric_limits<double>::epsilon()será sobre o tamanho certo para o que estamos fazendo. No std::numeric_limits<double>::epsilon()mínimo , será muito grande (quando a soma de todos os três for menor que um) e pode nos fazer arredondar alguns números para cima, quando não deveríamos.


Hoje em dia, você deve considerar std::nearbyint().


Um "épsilon relativo" é denominado 1 ulp (1 unidade no último lugar). x - nextafter(x, INFINITY)está relacionado a 1 ulp para x (mas não use isso; tenho certeza de que há casos esquivos e acabei de inventar isso). O exemplo cppreference para epsilon() tem um exemplo de escalonamento para obter um erro relativo baseado em ULP .
Peter Cordes

2
BTW, a resposta de 2016 -ffloat-storeé: não use x87 em primeiro lugar. Use matemática SSE2 (binários de 64 bits ou -mfpmath=sse -msse2para criar binários de 32 bits antiquados), porque SSE / SSE2 tem temporários sem precisão extra. doublee floatvars em registros XMM estão realmente no formato IEEE de 64 bits ou 32 bits. (Ao contrário do x87, em que os registros são sempre de 80 bits e o armazenamento na memória é arredondado para 32 ou 64 bits.)
Peter Cordes

3

A resposta aceita está correta se você estiver compilando para um destino x86 que não inclui SSE2. Todos os processadores x86 modernos suportam SSE2, portanto, se você pode tirar proveito disso, você deve:

-mfpmath=sse -msse2 -ffp-contract=off

Vamos decompô-lo.

-mfpmath=sse -msse2. Isso executa o arredondamento usando registradores SSE2, que é muito mais rápido do que armazenar todos os resultados intermediários na memória. Observe que este é o padrão no GCC para x86-64. Do wiki do GCC :

Em processadores x86 mais modernos que oferecem suporte a SSE2, a especificação das opções do compilador -mfpmath=sse -msse2garante que todas as operações flutuantes e duplas sejam realizadas em registros SSE e arredondadas corretamente. Essas opções não afetam o ITB e, portanto, devem ser usadas sempre que possível para resultados numéricos previsíveis.

-ffp-contract=off. No entanto, controlar o arredondamento não é suficiente para uma correspondência exata. As instruções FMA (fusão, multiplicação e adição) podem alterar o comportamento de arredondamento em comparação com suas contrapartes não fundidas, portanto, precisamos desativá-lo. Este é o padrão no Clang, não no GCC. Conforme explicado por esta resposta :

Um FMA tem apenas um arredondamento (ele efetivamente mantém uma precisão infinita para o resultado da multiplicação temporária interna), enquanto um ADD + MUL tem dois.

Ao desabilitar o FMA, obtemos resultados que correspondem exatamente na depuração e liberação, ao custo de algum desempenho (e precisão). Ainda podemos aproveitar outras vantagens de desempenho de SSE e AVX.


1

Eu cavei mais neste problema e posso trazer mais precisões. Primeiro, as representações exatas de 4,45 e 4,55 de acordo com gcc em x84_64 são as seguintes (com libquadmath para imprimir a última precisão):

float 32:   4.44999980926513671875
double 64:  4.45000000000000017763568394002504646778106689453125
doublex 80: 4.449999999999999999826527652402319290558807551860809326171875
quad 128:   4.45000000000000000000000000000000015407439555097886824447823540679418548304813185723105561919510364532470703125

float 32:   4.55000019073486328125
double 64:  4.54999999999999982236431605997495353221893310546875
doublex 80: 4.550000000000000000173472347597680709441192448139190673828125
quad 128:   4.54999999999999999999999999999999984592560444902113175552176459320581451695186814276894438080489635467529296875

Como Maxim disse acima, o problema é devido ao tamanho de 80 bits dos registradores da FPU.

Mas por que o problema nunca ocorre no Windows? no IA-32, o x87 FPU foi configurado para usar uma precisão interna para a mantissa de 53 bits (equivalente a um tamanho total de 64 bits:) double. Para Linux e Mac OS, a precisão padrão de 64 bits foi usada (equivalente a um tamanho total de 80 bits:) long double. Portanto, o problema deveria ser possível, ou não, nessas diferentes plataformas, alterando a palavra de controle da FPU (assumindo que a sequência de instruções acionaria o bug). O problema foi relatado ao gcc como bug 323 (leia pelo menos o comentário 92!).

Para mostrar a precisão da mantissa no Windows, você pode compilar em 32 bits com VC ++:

#include "stdafx.h"
#include <stdio.h>  
#include <float.h>  

int main(void)
{
    char t[] = { 64, 53, 24, -1 };
    unsigned int cw = _control87(0, 0);
    printf("mantissa is %d bits\n", t[(cw >> 16) & 3]);
}

e no Linux / Cygwin:

#include <stdio.h>

int main(int argc, char **argv)
{
    char t[] = { 24, -1, 53, 64 };
    unsigned int cw = 0;
    __asm__ __volatile__ ("fnstcw %0" : "=m" (*&cw));
    printf("mantissa is %d bits\n", t[(cw >> 8) & 3]);
}

Observe que com o gcc você pode definir a precisão da FPU com -mpc32/64/80 , embora seja ignorado no Cygwin. Mas lembre-se de que ele modificará o tamanho da mantissa, mas não do expoente, permitindo que a porta se abra para outros tipos de comportamento.

Na arquitetura x86_64, SSE é usado como dito por tmandry , então o problema não ocorrerá a menos que você force o antigo x87 FPU para computação FP com -mfpmath=387, ou a menos que você compile no modo de 32 bits com -m32(você precisará do pacote multilib). Eu poderia reproduzir o problema no Linux com diferentes combinações de sinalizadores e versões do gcc:

g++-5 -m32 floating.cpp -O1
g++-8 -mfpmath=387 floating.cpp -O1

Tentei algumas combinações no Windows ou Cygwin com VC ++ / gcc / tcc, mas o bug nunca apareceu. Suponho que a sequência de instruções geradas não seja a mesma.

Por fim, note que uma forma exótica de evitar esse problema com 4.45 ou 4.55 seria usar _Decimal32/64/128, mas o suporte é muito escasso ... Passei muito tempo só para poder fazer um printf com libdfp!


0

Pessoalmente, tive o mesmo problema indo para o outro lado - do gcc para o VS. Na maioria dos casos, acho melhor evitar a otimização. A única vez que vale a pena é quando você está lidando com métodos numéricos envolvendo grandes matrizes de dados de ponto flutuante. Mesmo depois de desmontar, geralmente fico desapontado com as escolhas dos compiladores. Freqüentemente, é mais fácil usar intrínsecos do compilador ou apenas escrever o assembly você mesmo.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.