Coagir o ponto flutuante a ser determinístico no .NET?

Eu tenho lido muito sobre o determinismo de ponto flutuante no .NET, ou seja, garantir que o mesmo código com as mesmas inputs forneça os mesmos resultados em diferentes máquinas. Como o .NET carece de opções como o fpstrict do Java e o fp: strict do MSVC, o consenso parece ser que não há maneira de contornar esse problema usando código gerenciado puro. O jogo C # AI Wars decidiu usar a matemática de ponto fixo , mas esta é uma solução complicada.

A questão principal parece ser que o CLR permite que resultados intermediários vivam em registradores FPU com maior precisão do que a precisão nativa do tipo, levando a resultados de precisão imprevisivelmente maiores. Um artigo do MSDN do engenheiro da CLR, David Notario, explica o seguinte:

Observe que, com as especificações atuais, ainda é uma escolha de idioma para fornecer “previsibilidade”. O idioma pode inserir instruções conv.r4 ou conv.r8 após cada operação de FP para obter um comportamento ‘previsível’. Obviamente, isso é realmente caro, e idiomas diferentes têm compromissos diferentes. C #, por exemplo, não faz nada, se você quiser estreitar, você terá que inserir (float) e (double) lançamentos à mão.

Isso sugere que é possível obter um determinismo de ponto flutuante simplesmente inserindo casts explícitos para cada expressão e sub-expressão que é avaliada como flutuante. Pode-se escrever um tipo de wrapper em torno de float para automatizar essa tarefa. Esta seria uma solução simples e ideal!

Outros comentários, no entanto, sugerem que não é tão simples. Eric Lippert declarou recentemente (ênfase minha):

em alguma versão do tempo de execução, a conversão para float explicitamente fornece um resultado diferente do que não fazer isso. Quando você explicitamente converter para float, o compilador C # dá uma dica para o tempo de execução para dizer “tirar essa coisa do modo de alta precisão extra se você estiver usando essa otimização”.

Apenas o que é essa “dica” para o tempo de execução? A especificação C # estipula que um casting explícito para flutuar causa a inserção de uma conv.r4 no IL? A especificação CLR estipula que uma instrução conv.r4 faz com que um valor seja reduzido ao seu tamanho nativo? Apenas se ambos forem verdadeiros, podemos confiar em castings explícitos para fornecer “previsibilidade” de ponto flutuante, como explicado por David Notario.

Finalmente, mesmo se pudermos coagir todos os resultados intermediários para o tamanho nativo do tipo, isso é suficiente para garantir a reprodutibilidade entre máquinas, ou existem outros fatores como configurações de tempo de execução de FPU / SSE?

Apenas o que é essa “dica” para o tempo de execução?

Conforme você conjectura, o compilador controla se uma conversão para double ou float estava realmente presente no código-fonte e, se fosse, sempre inseria o opcode conv apropriado.

A especificação C # estipula que um casting explícito para flutuar causa a inserção de uma conv.r4 no IL?

Não, mas garanto que existem testes de unidade nos casos de teste do compilador que garantem isso. Embora a especificação não exija, você pode confiar nesse comportamento.

O único comentário da especificação é que qualquer operação de ponto flutuante pode ser feita com uma precisão maior do que a exigida pelo capricho do tempo de execução, e isso pode tornar seus resultados inesperadamente mais precisos. Veja a seção 4.1.6.

A especificação CLR estipula que uma instrução conv.r4 faz com que um valor seja reduzido ao seu tamanho nativo?

Sim, na Partição I, seção 12.1.3, que eu notei que você poderia ter procurado a si mesmo em vez de pedir à internet para fazer isso por você. Essas especificações são gratuitas na web.

Uma pergunta que você não fez, mas provavelmente deveria ter:

Existe alguma outra operação além da conversão que trunca flutuações fora do modo de alta precisão?

Sim. Atribuindo a um campo estático, o campo de instância ou o elemento de um array double[] ou float[] truncado.

O truncamento consistente é suficiente para garantir a reprodutibilidade entre máquinas?

Não. Encorajo-vos a ler a seção 12.1.3, que tem muito a dizer sobre denormals e NaNs.

E finalmente, outra pergunta que você não fez, mas provavelmente deveria ter:

Como posso garantir aritmética reproduzível?

Use inteiros.

O design do chip 8087 Floating Point Unit foi o erro de bilhões de dólares da Intel. A ideia parece boa no papel, dar-lhe uma pilha de 8 registros que armazena valores em precisão estendida, 80 bits. Para que você possa escrever cálculos cujos valores intermediários são menos propensos a perder dígitos significativos.

A besta é, no entanto, impossível de otimizar. Armazenar um valor da pilha FPU de volta à memory é caro. Então, mantê-los dentro da FPU é um objective de otimização forte. Inevitável, ter apenas 8 registros exigirá um write-back se o cálculo for profundo o suficiente. Ele também é implementado como registros de pilha, não endereçáveis ​​livremente, de modo que também é necessária a ginástica, que pode gerar um write-back. Inevitavelmente, um write back irá truncar o valor de 80 bits de volta para 64 bits, perdendo a precisão.

Portanto, as consequências são que o código não otimizado não produz o mesmo resultado que o código otimizado. E pequenas alterações no cálculo podem ter grandes efeitos no resultado quando um valor intermediário acaba precisando ser gravado novamente. A opção / fp: strict é um truque que força o gerador de código a emitir um write-back para manter os valores consistentes, mas com a perda inevitável e considerável de perf.

Esta é uma rocha completa e um lugar difícil. Para o jitter x86, eles simplesmente não tentaram resolver o problema.

A Intel não cometeu o mesmo erro ao projetar o conjunto de instruções SSE. Os registradores XMM são livremente endereçáveis ​​e não armazenam bits extras. Se você deseja resultados consistentes, a compilation com o destino AnyCPU e um sistema operacional de 64 bits é a solução rápida. O jitter x64 usa SSE em vez de instruções FPU para matemática de ponto flutuante. Embora isso tenha adicionado uma terceira maneira, um cálculo pode produzir um resultado diferente. Se o cálculo estiver errado porque ele perde muitos dígitos significativos, ele estará consistentemente errado. O que é um pouco de brometo, na verdade, mas normalmente apenas no que se refere a um programador.