Tenho um código que analisa números de ponto flutuante e retorna um unsigned int se o número puder ser convertido para unsigned sem perder precisão:
#include <charconv>
#include <string_view>
#include <stdint.h>
uint64_t read_uint(std::string_view num)
{
double d;
auto r = std::from_chars(num.data(), num.data() + num.size(), d);
if (r.ec == std::errc() && r.ptr == num.data() + num.size())
{
uint64_t u = (uint64_t)d;
if (d == u + 0.0) // conversion back to a double produced identical value
return u;
}
return ~0ull; // error, return -1
}
e as expectativas são:
assert(read_uint("1.0") == 1);
assert(read_uint("1.0654553e+07") == 10654553);
assert(read_uint("1.1") == ~0ull); // error
assert(read_uint("-123") == ~0ull); // error
No entanto, esse código falha miseravelmente com clang em compilações otimizadas para x64/x86 ao mirar em avx
/ avx2
/ avx512
e usar -fast-math
. Especificamente, a análise de números negativos falha: assert(read_uint("-123") == ~0llu);
em vez de retornar -1, ele retorna -123 (convertido para uint64_t). O motivo da falha é porque a conversão de volta para double
para verificar se o resultado é idêntico produz um resultado diferente:
uint64_t u = (uint64_t)d;
if (d == u + 0.0) // u + 0.0 produces different result
return u;
como nota lateral, a fundição também produz valores diferentes quando se tem como alvo avx512
:
uint64_t u = (uint64_t)d; // u might not be exact when targeting avx512
Claramente, esse código está cheio de bugs e problemas e eu tenho algumas perguntas:
- Quais são os problemas com isso, há algum UB? (ignorando coisas óbvias como o uint64_t subjacente pode não ser representável por um double)
- Por que
uint64_t u = (uint64_t)d
os resultados são diferentes com fast-math e avx512? - Por que produz
u + 0.0
resultados diferentes com fast-math e avxN? - Qual seria a abordagem adequada aqui?
- Existe um sinalizador de tempo de compilação para identificar esses casos possíveis no código?
Note que com o compilador MS não vi nenhum dos problemas acima. Os valores são sempre exatos/idênticos, independentemente de otimizações, modelo de ponto flutuante ou arquitetura de destino.
Como nota lateral, este não é o código exato usado no prod, mas alguns extratos dele. Ele analisa números retornados por APIs json do polygon.io. Talvez eles tenham despejado números descuidadamente usando python e eu vi casos em que os valores eram algo como "1.0", "1.0654553e+07" etc. no lugar de inteiros simples. Até agora, como uma solução alternativa simples, alterei a conversão para uint64_t para:
uint64_t u = (uint64_t)fabs(d);
Exemplo mínimo: https://godbolt.org/z/cKzrK6ven (se você remover -O2 do comando clang, a saída será alterada)
Sim, seu código tem comportamento indefinido.
N4928 conv.fpint p1
O valor truncado é -123, que não pode ser representado no tipo de destino
uint64_t
(ele só pode representar valores não negativos), portanto, esse é um comportamento indefinido.Observe que isso se aplica tanto se você usar uma conversão no estilo C
(uint64_t)d
quanto sestatic_cast<uint64_t>(d)
.É verdade que converter um valor do tipo inteiro com o valor -123 para
uint64_t
produz um resultado bem definido (ou seja, 2^64 - 123 = 18446744073709551493). Mas isso não se aplica ao converter um valor do tipo ponto flutuante.