Não é segredo que as informações financeiras (contas, lançamentos e outras contas contábeis) não são muito amigáveis com números de ponto flutuante, e muitos artigos recomendam o uso de uma aritmética de ponto fixo. De fato, em Java, esse formato é representado apenas pela classe BigDecimal, que nem sempre pode ser usada por motivos de desempenho. Temos que procurar alternativas. Este artigo descreve uma biblioteca Java auto-escrita para executar operações aritméticas em números de precisão fixa. A biblioteca foi criada para funcionar em aplicativos financeiros de alto desempenho e permite trabalhar com uma precisão de 9 casas decimais, mantendo um desempenho aceitável. Um link para as fontes e referências é fornecido no final do artigo.
Aritmética de ponto flutuante
Os computadores modernos podem executar operações aritméticas apenas com precisão limitada. Esses são dispositivos discretos que podem não funcionar com todos os números possíveis, mas apenas com alguns subconjuntos contáveis. O formato mais comum para trabalhar com números reais na memória do computador é um ponto flutuante (binário) - ponto flutuante (binário), quando os números são armazenados no formato M * 2 ^ E, onde M e E são mantissa inteira e a ordem do número. Mas alguns números, como 0,1, não podem ser representados com precisão nesse formato. Portanto, no curso de cálculos complexos, algum erro se acumula inevitavelmente. Ou seja, o resultado do cálculo da máquina, digamos 0,1 + 0,1 + 0,1, não coincide com o matematicamente correto 0,3. Dado o exposto, ao programar aritmética complexa, você pode seguir várias estratégias:
Estratégia 1 - ignore. Não preste atenção ao erro, considere todas as operações como matemáticas ideais e espere que a precisão disponível seja suficiente para resultados aceitáveis. A opção mais comum.
Estratégia 2 - calcule meticulosamente. As fórmulas para calcular erros da máquina são conhecidas há décadas. Eles permitem estimar acima o erro relativo de qualquer operação aritmética. Provavelmente, é isso que você precisa fazer para uma simulação numérica séria. O problema é que consome muito tempo. De fato, cada caractere + - * / no código deve ser acompanhado por um cálculo de erro. Você precisa levar em consideração todas as dependências entre os cálculos e repetir o procedimento toda vez que alterar o código.
Estratégia 3 - use um ponto decimal (ponto decimal flutuante) em vez de binário. Ou seja, armazene os números no formato M * 10 ^ E. Isso não resolve os problemas de erro (a mantissa ainda é arredondada para um número finito de dígitos significativos), mas pelo menos todos os números "simples" para uma pessoa (como 1.1) agora estão representados com precisão na memória. O retorno será o desempenho. Qualquer normalização de números (ou seja, uma diminuição equivalente na mantissa e um aumento na ordem) requer uma divisão por uma potência de 10, o que não é muito rápido, ao contrário de uma divisão por uma potência de 2. E você precisa normalizar muito - com cada adição ou subtração com ordens diferentes.
Estratégia 4 - use um ponto fixo (ponto decimal fixo). Simplificação da estratégia 3, quando fixamos a ordem E. Nesse caso, a normalização não é necessária para adição / subtração. Além disso, todos os cálculos terão o mesmo erro absoluto. Este artigo é dedicado a essa estratégia.
Aritmética de ponto fixo
Em contraste com a física, onde o erro relativo é importante, apenas o absoluto é necessário nas finanças. Se, após uma transação financeira complexa, o cliente receber US $ 1.000.000,23 enquanto espera US $ 1.000.000,18, poderão surgir algumas dificuldades. Explicações como "por que você precisa de precisão em 8 dígitos significativos?" pode não andar. E o ponto aqui não está em 5 centavos de perda (errar pelo contrário, “a favor” do cliente não é muito melhor), mas em inconsistências na contabilidade. Portanto, as regras de cálculos e arredondamentos são claramente especificadas entre as partes, e os artefatos do uso de variáveis double e float às vezes complicam a vida.
Java possui uma classe padrão para aritmética de ponto fixo - BigDecimal. Existem dois problemas: é lento (devido à sua universalidade) e não é estável. Não estabilidade significa que qualquer operação aloca um objeto no heap. Selecionar e liberar em termos de um objeto leva um pouco de tempo, mas cálculos intensivos no código "quente" criam uma carga decente no GC, o que é inaceitável em alguns casos. Você pode confiar na análise de escape e na escalarização, mas elas são muito instáveis no sentido de que mesmo uma pequena alteração no código ou no JIT (como o carregamento lento de uma nova implementação de interface) pode virar toda a estrutura inline de cabeça para baixo e o método funcionou bem há um minuto, de repente começa a alocar furiosamente a memória.
UPD devido a perguntas nos comentários: O principal motivo para o abandono do BigDecimal e do BigInteger não é o baixo desempenho dos cálculos, mas a não estabilidade e a seleção de objetos.
A biblioteca descrita é o resultado de estar cansado de reescrever a aritmética de ponto fixo sem memória para cada novo empregador, e eu decidi escrever minha própria biblioteca para posterior fornecimento.
Mostrarei imediatamente um exemplo de uso antes de passar aos detalhes da implementação:
public class Sample { private final Decimal margin; private final Quantity cumQuantity = new Quantity(); private final Quantity contraQuantity = new Quantity(); private final Quantity cumContraQuantity = new Quantity(); private final Price priceWithMargin = new Price(); private final Price avgPrice = new Price(); public Sample(int marginBp) {
Ideia de implementação
Portanto, precisamos de um invólucro mutável de um primitivo inteiro, mais precisamente - um long'a, o que nos dará quase 19 dígitos significativos (o suficiente para a parte inteira e a parte fracionária). Por muito tempo, queremos dizer N casas decimais. Por exemplo, com N = 2, o número 2,56 é armazenado como 256 (binário 100000000). Os números negativos são armazenados como padrão, em código adicional:
-2,56
-256
(A seguir, itálico indica números e cálculos "matemáticos" e em negrito sua representação interna)
Também me pareceu útil inserir NaN como um valor separado, retornado em caso de erros aritméticos (em vez de uma exceção ou lixo). NaN é representado internamente como Long.MIN_VALUE , “propagado” por todas as operações e permite determinar a inversão de sinal para todos os números restantes.
Vamos tentar estimar os algoritmos de operações aritméticas para o caso em que N = 2.
A adição e subtração não exigem gestos extras, basta usar os valores como estão:
1,20 + 2,30 = 3,50
120 + 230 = 350
Multiplicação e divisão requerem normalização adicional, ou seja, multiplicação / divisão por 10 ^ N (por 100 em nosso exemplo)
1,20 * 2,00 = 2,40
120 * 200/100 = 240
1,20 / 2,00 = 0,60
100 * 120/200 = 60
Divisão adicional não é a operação mais rápida. Mas, neste caso, essa é uma divisão por uma constante, porque anteriormente fixamos N = 2 e 10 ^ N = 100. A divisão por constante, especialmente por "bonita" (tipo 10), é intensivamente otimizada na CPU e muito mais rápida que a divisão por um número aleatório. Fazemos muitas divisões por 10 toda vez que convertemos qualquer número em uma string (por exemplo, nos logs), e os fabricantes de CPU sabem disso ( para obter mais detalhes sobre otimização, consulte "Divisão por uma constante").
Para consolidar a compreensão do que estamos fazendo, darei mais uma operação: inversão unária de um número, ou seja, 1 / x. Este é um caso especial de divisão, basta enviar 1,00 em nosso formato e não se esqueça de normalizar:
1,00 / 2,00 = 0,50
100 * 100/200 = 50
Bem, enquanto tudo é bastante simples, vamos tentar nos aprofundar nos detalhes.
Arredondamento
Vamos tentar desenhar outro número:
1,00 / 3,00 = 0,33
100 * 100/300 = 33
Um resultado matemático honesto fica entre 0,33 e 0,34, mas não podemos imaginá-lo exatamente. Qual o caminho a percorrer? Geralmente arredondado para 0, e esta é a maneira mais rápida (suportada por hardware). Mas, voltando a problemas financeiros reais, esse nem sempre é o caso. Normalmente, ao processar transações com um cliente, o arredondamento é "a favor do cliente". Ou seja, o preço é arredondado para cima se o cliente estiver vendendo e para baixo se o cliente estiver comprando. Porém, outras opções podem ser necessárias, por exemplo, arredondamento aritmético para o número mais próximo com subtipos (meio para cima, meio para baixo, meio par) para minimizar as inconsistências contábeis. Ou arredondar para ± infinito para preços negativos (para alguns instrumentos financeiros). O Java BigDecimal já contém uma lista de modos de arredondamento padrão, e a biblioteca descrita suporta todos eles. DESNECESSÁRIO retorna NaN se a operação inesperadamente exigir arredondamento.
No modo arredondado, nosso cálculo deve fornecer:
1,00 / 3,00 = 0,34
100 * 100/300 + 1 = 34
Como descobrir o que você precisa adicionar uma unidade? Você precisa do restante da divisão 10.000% 300 = 100. O que é tão lento quanto a própria divisão. Felizmente, se você escrever em uma linha no código "a / b; a% b", o JIT descobrirá que 2 divisões não são necessárias, apenas um comando div do assembler que retorna 2 números (quociente e restante).
Outras opções de arredondamento são um pouco mais complicadas, mas também podem ser calculadas com base no restante e no divisor.
Na API, intencionalmente mencionei o arredondamento onde quer que ocorra, como parâmetro ou como sufixo próprio D ou D em métodos em que o padrão é zero.
Estouro
Chegamos à parte mais difícil. Lembre novamente nossa multiplicação:
1,20 * 2,00 = 2,40
120 * 200/100 = 240
Agora imagine que estamos na década de 1980 e temos processadores de 16 bits. Ou seja, apenas um curto está disponível para nós com um valor máximo de 65535. A primeira multiplicação excederá e será igual a 240000 & 0xFFFF = 44392 (se não tiver sinal, com um sinal também será negativo), o que quebrará o resultado para nós.
Não vai dar certo Temos dois argumentos normais (se encaixam em nosso intervalo de valores) e o mesmo resultado esperado normal, mas estamos no meio do caminho. A mesma situação é possível com um long'om de 64 bits, apenas números precisam de mais.
Nos anos 80, precisaríamos de uma multiplicação que desse um resultado de 32 bits. Hoje precisamos de uma multiplicação com um resultado de 128 bits. O mais irritante é que ambas as multiplicações estão disponíveis nos montadores 8086 e x86-64, respectivamente, mas não podemos usá-las no Java! O JNI, mesmo no caso de um hack com JavaCritical rápido, fornece uma sobrecarga de dezenas de nanossegundos, introduz dificuldades com a implantação e a compatibilidade, congela o GC pela duração da chamada. Além disso, precisaríamos, de alguma forma, retornar um resultado de 128 bits do método nativo, e escrever por referência a uma matriz (na memória) é um atraso adicional.
Em geral, eu tive que escrever multiplicação e divisão manuais. Coluna Eu precisava de duas operações auxiliares:
- A (64) * B (64) = T (128); T (128) / N (32) = Q (64), R (32) - como parte do ponto fixo de multiplicação A * B
- N (32) * A (64) = T (96); T (96) / B (64) = Q (64), R (64) - como parte da divisão de pontos fixos A / B
(entre parênteses indica a dimensão dos dados em bits, T é uma variável temporária que não deve ser excedida)
Ambas as operações retornam o quociente e o restante (um como resultado do método, o segundo no campo de objeto). Eles também podem transbordar, mas apenas na última etapa, quando isso é inevitável. Aqui está um exemplo (da década de 1980):
500,00 / 0,50 = 1000,00
100 * 50.000 / 50 = 100.000 - estouro!
A divisão de colunas à Knut não é o algoritmo mais fácil. Além disso, também deve ser relativamente rápido. Portanto, o código de ambas as operações é centenas de linhas de mágica bastante grave, levarei muito tempo para lembrar novamente o que exatamente está acontecendo lá. Coloquei-os em uma aula separada e comentei em detalhes o quanto pude.
O algoritmo de multiplicação não se limita a invocar a operação 1, mas o código restante não é tão complicado e apenas adiciona suporte para números negativos, arredondamentos e NaN.
Geralmente (exceto em casos especiais), ambas as operações contêm 4 multiplicações e 2 divisões. A operação 1 é significativamente mais rápida que 2, pois nela essas divisões são constantes.
A propósito, se alguém notou, N (32) é nosso 10 ^ N para normalização. É de 32 bits, e daí resulta que N pode ter no máximo 9. Nas aplicações reais que eu vi, foram usadas 2, 4 ou 8 casas decimais. Eu não vi mais de 9, então isso deve ser suficiente. Se você criar 10 ^ N de 64 bits, o código se tornará mais complicado (e mais lento) ainda mais.
Vários precisão diferente
Às vezes, é necessário executar uma operação em argumentos com um número diferente de casas decimais. No mínimo, insira operações que envolvam o tempo normal.
Por exemplo:
2.0000 (N = 4) + 3.00 (N = 2) = 5.0000 (N = 4)
20.000 + 300 * 100 = 50.000
3,00 (N = 2) + 2,0000 (N = 4) = 5,00 (N = 2)
300 + 20.000 / 100 = 500
Nesse caso, é necessária uma normalização adicional de um dos argumentos. Observe que matematicamente as duas operações são equivalentes, mas devido à precisão diferente do resultado, elas são calculadas de maneira diferente. Também é importante notar que a segunda operação geralmente requer arredondamento.
O número de casas decimais NÃO é armazenado no objeto. Em vez disso, uma subclasse separada é assumida para cada precisão. Os nomes das classes podem ser orientados para os negócios, por exemplo Preço (N = 8), Quantidade (N = 2). E eles podem ser generalizados: Decimal1, Decimal2, Decimal3, ... Quanto maior a precisão, menor o intervalo de valores armazenados, o intervalo mínimo possui Decimal9: ± 9223372036. Supõe-se que uma ou duas classes sejam suficientes para cobrir a funcionalidade necessária; nesse caso, o método getScale abstrato provavelmente será virtualizado e embutido. As subclasses (em vez de um campo adicional) permitem tipificar estritamente a precisão dos argumentos e resultados, bem como sinalizar sobre possíveis arredondamentos no estágio de compilação.
A biblioteca permite operações com no máximo 2 (mas não 3) de precisão diferente. Ou seja, a precisão dos dois argumentos deve coincidir ou a precisão de um dos argumentos e o resultado. Novamente, o suporte a três diferentes precisão desaceleraria bastante o código e complicaria a API. Como argumentos, você pode passar um longo regular, para o qual é assumida uma precisão de N = 0.
2.0000 / 3.0 = 0.6667 - ok (precisão diferente de 2)
2/3 = 0,6667 - ok (argumentos longos, resultado decimal)
2 / 3,0 = 0,66667 - impossível! (3 precisão diferente)
Vantagens e desvantagens
Obviamente, a computação de alto bit realizada pela biblioteca é mais lenta que a suportada por hardware. No entanto, a sobrecarga não é tão grande (veja os benchmarks abaixo).
Além disso, devido à falta de sobrecarga do operador em Java, o uso de métodos em vez de operadores aritméticos complica a percepção do código.
Com base nisso, a biblioteca geralmente é usada em locais onde a perda de precisão absoluta é crítica. Por exemplo, cálculo de estatísticas financeiras precisas, levando em consideração os indicadores financeiros atuais (posições de negociação, PnL, ordens executadas). Na troca de informações financeiras em rede entre sistemas, também é mais conveniente usar formatos com um ponto decimal (em vez de binários).
Algoritmos matemáticos complexos (modelagem, estatística, previsão) são geralmente mais fáceis de serem executados de maneira padronizada em dobro, pois seu resultado não é absolutamente preciso.
Código e referências
Código
Referência | Mode | Cnt | Pontuação | Erro | Unidades
|
---|
DecimalBenchmark.control | avgt | 200 | 10.072 | ± 0,074 | ns / op
|
DecimalBenchmark.multiplyNative | avgt | 200 | 10,625 | ± 0,142 | ns / op
|
DecimalBenchmark.multiplyMyDecimal | avgt | 200 | 35.840 | ± 0,121 | ns / op
|
DecimalBenchmark.multiplyBigDecimal | avgt | 200 | 126.098 | ± 0,408 | ns / op
|
DecimalBenchmark.quotientNative | avgt | 200 | 70.728 | ± 0,230 | ns / op
|
DecimalBenchmark.quotientMyDecimal | avgt | 200 | 138.581 | ± 7,102 | ns / op
|
DecimalBenchmark.quotientBigDecimal | avgt | 200 | 179.650 | ± 0,849 | ns / op
|
Em geral, a multiplicação é 4 vezes mais rápida que BigDecimal, a divisão é 1,5. A taxa de divisão é altamente dependente dos argumentos, daí a dispersão de valores.