Std: Clamp gera assembly menos eficiente do que std:min(max,std:max(min,v))

Desenvolvedores de C++ estão analisando por que `std::clamp` pode gerar assembly menos eficiente do que compor manualmente `std::min(std::max(v, lo), hi)` em alguns alvos x86, e como habilitar flags específicas da CPU (`-march=native`, AVX) muitas vezes muda completamente o panorama. Além da contagem bruta de instruções, eles destacam armadilhas semânticas sutis: `std::clamp` tem comportamento indefinido quando `lo > hi`, interage de forma diferente com NaNs e zero com sinal, e pode ser alterado por flags como `-ffast-math`, que relaxam garantias da IEEE-754. A conclusão mais ampla é que o desempenho e a correção até mesmo de utilitários numéricos simples dependem muito da arquitetura de destino, das opções do compilador e de quanta estrita conformidade de ponto flutuante você está disposto a trocar por velocidade.

Desempenho de std::clamp vs std::min/std::max

  • Compiladores frequentemente emitem código ligeiramente menos eficiente para std::clamp do que para um std::min(std::max(v, lo), hi) escrito manualmente em x86, especialmente sem AVX.
  • Alguns exemplos mostram mov extras ou uma ordem diferente de instruções para clamp; as explicações incluem:
    • Restrições de ABI (argumento e valor de retorno ambos em xmm0, exigindo um movimento de “salvar/restaurar”).
    • Particularidades da alocação de registradores.
    • Semântica de NaN de minsd/maxsd do x86 restringindo reordenações.
  • Com -march=znver1, -mavx ou alvos x86 mais modernos, tanto GCC quanto Clang podem gerar a sequência ideal até em níveis baixos de otimização; os modelos padrão “k8-generic” estão desatualizados.
  • Microbenchmarks mostram que, uma vez que os compiladores otimizam no contexto, a microineficiência do artigo pode ser negligenciável, e ramos previsíveis podem superar código branchless mais “esperto”, dependendo dos padrões de dados.

Semântica e correção de std::clamp

  • O std::clamp padrão é normalmente implementado como std::min(std::max(v, lo), hi), mas com ordem e comportamento especificados.
  • O comportamento quando lo > hi é explicitamente indefinido; algumas implementações fazem assert (por exemplo, no Windows), outras apenas executam a sequência min/max.
  • Isso leva alguns a preferirem min/max explícitos para poderem definir o que acontece em um “intervalo vazio”.
  • A ordem também afeta NaN e zero com sinal:
    • Para ponto flutuante, o padrão exige uma ordem estrita fraca; NaNs violam isso, então usar NaN com clamp é efetivamente indefinido.
    • Um std::clamp correto deve retornar v quando v == lo == hi (incluindo -0.0 vs +0.0), o que algumas versões “otimizadas” falham em fazer.

Flags de ponto flutuante e -ffast-math

  • Com -ffast-math (ou -Ofast), GCC e Clang frequentemente igualam as diferenças entre clamp e min/max, provavelmente porque relaxam o tratamento de NaN/Inf.
  • Vários comentários alertam que opções fast-math:
    • Permitem reordenação/mudanças aritméticas que podem alterar significativamente o comportamento numérico.
    • Podem quebrar std::isnan/std::isinf e assumir que NaNs/Infs não existem.
    • Em algumas toolchains, ativam globalmente os bits FTZ/DAZ, afetando todo o código no processo, incluindo outras bibliotecas.
  • Outros argumentam que, em muitas cargas de trabalho práticas (por exemplo, muitas tarefas de ML e numéricas), um pequeno erro extra de ULP é aceitável e fast-math é amplamente usado, mas reconhecem os riscos.

Conselho mais amplo

  • Sempre especifique uma CPU de destino moderna (-march=native, x86-64-v2/v3, etc.) em vez de depender de padrões muito antigos.
  • Para operações em massa de min/max/clamp em que o desempenho realmente importa, considere SIMD/intrinsics e políticas explícitas para NaN.
  • Há cautela semelhante em relação a std::lerp: ele oferece garantias numéricas fortes, mas pode ser mais lento do que uma expressão afim simples.