Quando se trata de Matemática, seria ótimo que o computador fizesse o que aprendemos na escola. Temos consciência de algumas diferenças, como a precisão limitada de tipos numéricos como int ou mesmo double, mas a realidade é um pouco mais complicada. Existem vários fatos que precisamos conhecer bem para evitar problemas e para executar cálculos com precisão e desempenho.

Java não é uma linguagem criada especialmente para a programação numérica (como Fortran). Os tipos de dados e APIs disponíveis na J2SE são mais que suficientes para a maioria das aplicações, mas não atendem às necessidades de muitos nichos, como aplicações de engenharia, científicas, gráficos avançados, e outras que precisem de cálculos mais complexos. Por exemplo, Java não tem suporte nativo a números complexos, matrizes ou vetores da álgebra linear, e não inclui APIs para resolver equações lineares, fazer interpolações, análises estatísticas etc. Há bibliotecas de terceiros que oferecem esses recursos para quem precisar (para um exemplo, veja o quadro “Commons Math etc.”). Porém, como veremos, tudo aquilo que o Java implementa, faz direito – ao contrário da grande maioria das linguagens. Numa outra vertente, também podemos precisar de matemática decimal exata, mais adequada para tarefas como cálculos financeiros. Isso é suportado pelas APIs de aritmética decimal, que também examinaremos.

Quando lemos os JavaDocs das APIs numéricas (como java.lang.Math e java.math.BigDecimal), nos deparamos com conceitos obscuros como ULPs, semi-monotonicidade, norma IEEE, meta-números (como NaN), precisão versus escala, regras de arredondamento... Pode parecer que tais detalhes só interessam (e só podem ser compreendidos) por matemáticos profissionais mas, na prática, um conhecimento mais profundo da matemática computacional revela-se acessível e bastante útil.

Neste artigo, vamos explicar esses conceitos e ilustrá-los com exemplos práticos, que mostram o impacto de diferentes técnicas de programação numérica. Além disso, em muitas aplicações, o desempenho dos cálculos costuma ser tão importante quanto sua precisão, mas os benchmarks mais populares costumam focar somente no desempenho. Aqui, tentei abordar essa questão de forma integrada, com um novo benchmark gráfico que explora tanto a velocidade quanto a qualidade dos cálculos, o que também serve para ilustrar noções que parecem um tanto abstratas.

FP em Java

Os tipos float e double do Java seguem o padrão IEEE-754 com grande rigor (veja o quadro “O padrão IEEE-754 e Java”, onde também são definidos alguns termos importantes usados neste artigo). A maioria das linguagens evita alguns casos suportados pelo Java em troca de algum desempenho. Todavia, estes custos têm-se tornado menores com o avanço dos compiladores e também das CPUs, cujas unidades de ponto flutuante (FPUs) são cada vez mais poderosas.

Um fator sempre importante no Java é a portabilidade. Cálculos de ponto flutuante não são exatos, precisando, na maioria das vezes, serem aproximados, e pode haver diferentes algoritmos para computar alguma função não suportada diretamente pela CPU. Os algoritmos mais precisos costumam ser mais lentos – em linguagens tradicionais como C/C++, cada compilador faz uma opção diferente. No Java, todavia, o padrão exige que todos os cálculos de ponto flutuante sejam reproduzíveis bit por bit em qualquer implementação da JVM, para qualquer plataforma.

Reprodutibilidade bit-por-bit

Reprodutibilidade bit por bit significa que, para qualquer operação x = f(a1,a2,...,aN), se a representação binária dos argumentos (a1..aN) for exatamente igual, bit por bit, o resultado deve ser o mesmo em qualquer plataforma, também bit por bit. Para um valor double, por exemplo, o valor em bits é obtido com Double.doubleToLongBits(valor). Mas por que essa especificação?

Primeiro, em cada plataforma, o padrão de bits correspondente a este valor poderia ser diferente. Isso poderia acontecer porque uma das plataformas não utiliza a representação IEEE-754. Ou porque utiliza uma precisão maior que a necessária (como 80 bits, nos chips Intel). Ou então, poderiam usar algoritmos diferentes para calcular funções de biblioteca, gerando resultados ligeiramente diferentes devido a diferenças de precisão ou arredondamento.

Mas em Java nenhuma discrepância é permitida: nem sequer num único bit. Digamos que um mesmo cálculo seja feito em duas JVMs diferentes, produzindo os resultados x’ e x’’, que deveriam ser iguais, mas diferem apenas no último bit da mantissa (um erro minúsculo de precisão). Se esses valores fossem usados num cálculo como yx, aquele erro pequenino logo se tornaria bem maior. Ou então, digamos que o valor produzido numa das plataformas seja transmitido para a outra e ambos são comparados: x1 == x2 resultaria em false, novamente devido àquela diferença mínima no último bit.

Aderência à norma IEEE

Além dos tipos e operações básicas, a norma IEEE-754 também determina (às vezes de forma opcional) comportamentos desejáveis para algumas funções matemáticas. A plataforma Java especifica todas as funções matemáticas de forma compatível com a norma, mesmo quando a norma IEEE é opcional. Isso não é comum em outras linguagens. Você sabe, por exemplo, como o Java implementa a função Math.max(double a, double b)? Se respondeu “simples: return a >= b ? a : b”, errou – embora esta seja a resposta correta para nove entre dez linguagens. No Java, a implementação é a mostrada na Listagem 1.

Esse código é necessário para suportar os valores especiais do IEE-754, o que garante que max(+0, –0) = +0, e max(NaN, x) = max(x, NaN) = NaN para qualquer x. Todos os métodos de java.lang.Math tratam os valores especiais de maneira a produzir os resultados que façam mais sentido ou, pelo menos, de forma a não produzir resultados absurdos. Para os valores infinitos, não é necessário código adicional, porque os operadores como >= tratam os infinitos. O código da Listagem 1 revela alguns detalhes interessantes da IEEE-754 suportados pelo Java.

No primeiro if, temos que (a != a) = false para NaN. O valor NaN é especificado pelo padrão IEEE como não-ordenado, portanto não pode ser comparado nem a si mesmo. Logo em seguida, max() precisa fazer uma comparação “bit por bit” para verificar se um número é um zero negativo. É que a comparação (x == –0.0d) não seria conclusiva, pois todos os tipos de zero são iguais entre si. De fato, só há um zero, e o sinal é apenas uma maneira de indicar que esse zero foi produzido em circunstâncias especiais.

Os valores NaN e os infinitos também “absorvem” outros valores, inclusive os infinitos, em todas as operações aritméticas. Por exemplo, x * –Infinito = –Infinito, e NaN + Infinito = NaN.

Precisão em ULPs

Todas as funções de java.lang.Math e java.lang.StrictMath têm precisão padronizada, especificada numa unidade chamada ULP (Unit in the Last Place – unidade na última posição). Para um dado cálculo, isso corresponde a uma unidade no último dígito significativo do argumento; assim, uma precisão de 0.5 ulp significa que o resultado é tão preciso quanto possível na representação de ponto flutuante (FP), pois se o cálculo matematicamente exato necessitasse de mais dígitos que o disponível na mantissa, o último dígito terá sido arredondado corretamente na direção do primeiro dígito não-representável, e este arredondamento implica num erro máximo de 0.5 ulp.

Por exemplo, se tivéssemos que representar o 2/3 = 0.666..., numa notação de expoente/mantissa decimais, tendo a mantissa cinco dígitos de precisão, o resultado seria 100 * 0.66667, pois o primeiro dígito não representável é 6, que, sendo >= 5, arredonda o último dígito representável para 7. Assim, o erro é 0.6666700000... – 0.6666666666... = 0.0000033333..., que é menor que 0.000005 = 0.5 ulp, pois, para este número, 1 ulp = 0.00001. Se o número fosse 666.67, este seria representado como 103*0.66667 e teríamos 1 ulp = 0.01. É por isso que o valor quantitativo correspondente a 1 ulp depende não só do tipo, mas também da magnitude do número em termos de expoente. Nos tipos da IEEE-754, a única diferença é que tudo está em binário. Em Java, você pode usar os métodos Math.ulp(x) para descobrir o valor de 1 ulp correspondente a um dado número, como no exemplo da Listagem 2.

Nem todos os métodos de java.lang.Math/StrictMath têm precisão de 0.5 ulp, pois em alguns casos isso teria um custo muito alto em desempenho. Mas sempre há uma precisão bem especificada. No exemplo da Listagem 2, calculamos as funções sin() e sinh() de um valor fornecido pela linha de comando, exibindo não só o resultado da função mas também o erro máximo que este valor pode ter em relação ao resultado matematicamente exato. Para Math.sin(), o erro máximo é de 1 ulp; para Math.sinh() (função nova no J2SE 5.0), esse erro é de 2.5 ulp. Com ajuda de Math.ulp(), também nova no J2SE 5.0, e da especificação de cada função, podemos calcular o erro máximo de qualquer cálculo. Estes erros máximos especificados são sempre para o pior caso. Para os valores dentro do domínio “canônico” de cada função, por exemplo [0..2p] para sin(), o resultado geralmente terá precisão de 0.5 ulp. Para os operadores como *, o erro máximo também é sempre de 0.5 ulp.

Para expressões com várias operações, os fatores de erro de cada operação devem ser combinados segundo fórmulas de propagação de erros que você encontrará num livro de Análise Numérica.

Semi-monotonicidade

Um problema que pode ocorrer quando aceitamos erros de representação é a perda da propriedade de semi-monotonicidade. Diz-se que uma função é semi-monotônica quando, sempre que a função matemática exata for não-decrescente, sua representação em ponto flutuante (FP) também será não-decrescente; e sempre que a função exata for não-crescente, a representação FP também o será.

...
Quer ler esse conteúdo completo? Tenha acesso completo