Sobre métodos mutáveis ​​de um objeto Math em JavaScript

Hoje estamos publicando uma tradução de um artigo sobre cálculos matemáticos em JavaScript, que é uma versão escrita da apresentação de seu autor no WaffleJS . E essa afirmação em si foi um pouco de continuação dessa conversa no Twitter.


Educação matemática

Interesse em matemática


Tudo começou com minha educação matemática, com o recebimento do qual demorei um pouco. Quando estudei ciência da computação, pude me comunicar com professores de matemática de classe mundial, mas perdi essa oportunidade. Não gostei de matemática: os temas estudados estavam muito longe da prática. Além disso, eu já estava desequilibrado por um programa profundamente teórico de treinamento em informática. Eu então acreditei que ela estava divorciada da vida e, na maioria das vezes, continuo pensando assim.

Mas agora, alguns anos depois de concluir meus estudos, uma sede de aprender matemática despertou em mim. Fui inspirado pelo efeito que até uma pequena aplicação de conhecimento matemático pode ter no meu trabalho e no meu hobby. Mas eu não tinha um plano de estudo claro.

Então, em 2012, encontrei uma maneira de estudar matemática começando o trabalho no projeto Simple Statistics . Desde então, expandi e apoiei este projeto. Agora inclui implementações de muitos algoritmos, que acabou sendo uma das bibliotecas matemáticas JavaScript mais " estreladas ". E, aparentemente, as pessoas realmente usam essa biblioteca.

Mas comecei a trabalhar em 2012. Se falamos sobre como a tecnologia mudou ao longo desse tempo, isso foi há muito, muito tempo atrás. Desde então, 8 lançamentos LTS de Node.js foram liberados . Desde então, o próprio JavaSript e os ambientes nos quais os programas escritos nesta linguagem funcionam muito. Em 2012, a biblioteca React ainda não existia, então o primeiro commit no projeto Babel ainda não foi feito.


A passagem do tempo

Ao longo dos anos, notei que meus testes falharam ao atualizar o Node.js. Por exemplo, eu posso ter algo como este teste:

t.equal(ss.gamma(11.54), 13098426.039156161);

Este teste funciona bem no Node.js v10, mas é interrompido no Node.js v12. E aqui não é um método super complicado que é testado: a função é gammaimplementada usando funções JavaScript padrão - Math.pow, Math.sqrte Math.sin.

Aritmética


Eu sei o que você pode pensar aqui: aritmética. No Twitter, discussões acaloradas surgem periodicamente devido aos resultados do cálculo da seguinte expressão:

0.1 + 0.2 = 0.30000000000000004

Mas, como eu já escrevi , todas as linguagens de programação populares se comportam dessa maneira, mesmo as antiquadas e pedantes como Haskell. A aritmética de ponto flutuante pode parecer estranha, mas eles se comportam da mesma forma, seu comportamento é bem documentado. Ou seja, estamos falando sobre o padrão IEEE 754 , cujos requisitos são estritamente implementados em linguagens de programação. Portanto, o problema não está na aritmética: na implementação de adição, subtração, divisão e multiplicação em linguagens, pode-se dizer que a programação é “esculpida em pedra”.

Objeto de matemática


Meu problema estava no objeto JavaScript padrão Math. Em particular, em todos os métodos deste objeto.

Técnicas, tais como Math.sin, Math.cos, Math.exp, Math.pow, Math.tan- é os ingredientes básicos para geométricas cálculos e outras. Quando entendi isso, comecei a estudar separadamente as alterações no comportamento dos métodos básicos do objeto Mathem diferentes versões do Node.js. Aqui estão alguns exemplos.

Cálculo Math.tanh(0.1):

// Node 4
0.09966799462495590234
// Node 6
0.09966799462495581907

Cálculo Math.pow(1/3, 3):

// Node 10
0.03703703703703703498
// Node 12
0.03703703703703702804

Pior ainda, esse problema não é apenas aparente no Node.js. O mesmo acontece nos navegadores e em outros ambientes que suportam JavaScript.

Isso nos leva à seguinte pergunta: o que é cálculo matemático?


Representação gráfica de cálculos Os

métodos trigonométricos são fáceis de visualizar. Se você tem um único círculo e vários meses de ensino médio à sua disposição, sabe que o cosseno e o seno são as coordenadas de um certo ponto na extremidade do círculo e que os gráficos das funções sin e cos parecem ondas. De fato, no ensino médio, eles estudam a derivação desses valores, mas o método usado para isso - a série Taylor - se baseia em uma série infinita, e não é fácil para um computador resolver tais problemas.

Aqui está o que você pode aprender com a Wikipediaem relação ao algoritmo de cálculo senoidal: “Não existe um algoritmo padrão para calcular o seno. O IEEE 754-2008, o padrão mais amplamente usado para cálculos de ponto flutuante, não afeta o cálculo de funções trigonométricas como um seno ".

Os computadores usam muitas aproximações e algoritmos diferentes para executar cálculos, algo como CORDIC , todos os tipos de truques e tabelas de pesquisa complicados. Toda essa heterogeneidade explica a presença de muitas bibliotecas fastmath no GitHub . O fato é que existem muitas maneiras de implementar o método Math.sin. E outras funções também. Por exemplo, como você sabe, Quake III Arena usou um rápidosubstituindo o método de cálculo da raiz quadrada inversa padrão para acelerar a renderização.

Como resultado, os cálculos matemáticos são o resultado da implementação de certos algoritmos. Na prática, muitos algoritmos comuns e suas variantes são usados.

A especificação JavaScript, em vez de especificar qual algoritmo específico usar nas implementações de linguagem, oferece muito espaço para manobras nas implementações em termos de funções usadas em cálculos matemáticos.

Aqui está o que o padrão diz sobre isso (ECMA-262, edição 10, seção 20.2.2):

"O comportamento das funções acos, acosh, asin, asinh, atan, atanh, atan2, cbrt, cos, cosh, exp, expm1, hypot, log, log1p, log2, log2, log10, pow, random, sin, sinh, sqrt, tan e tanh não está totalmente descrito aqui, com exceção dos requisitos para o retorno de determinados resultados para valores específicos dos argumentos, que são casos de fronteira dignos de nota ".

Não sei como funcionam as atividades internas dos membros do comitê responsável pelo padrão ECMA-262, mas acredito que eles fizeram o padrão para que não houvesse uma crise de compatibilidade no JavaScript se a Intel ou a AMD emitissem novas instruções matemáticas ultra-rápidas em seus novos processadores.

Devido ao fato de existirem muitos intérpretes de JavaScript amplamente usados, devido ao fato de que o JavaScript é frequentemente usado em navegadores, e entre navegadores ainda existe algo como concorrência e pelo fato de mesmo implementações populares de JavaScript estarem sob pressão constante e forçados a evoluir rapidamente, fornecendo o melhor desempenho ... por tudo isso, temos o que temos. Qualquer pessoa que use JavaScript experimentará regularmente o fato de que em diferentes implementações os resultados dos cálculos matemáticos realizados por meio do objeto Mathsão diferentes.

Isso não é tão significativo em outras linguagens interpretadas, pois elas geralmente têm algumas implementações "canônicas". Por exemplo, isso é verdade para o intérprete Python.

Onde são realizados os cálculos?


Agora, vamos examinar exatamente onde os cálculos são realizados. No caso do JavaScript, há três áreas nas quais os cálculos matemáticos básicos são realizados:

  1. CPU.
  2. Intérprete de linguagem (código C ++ e C de uma implementação JavaScript específica).
  3. Código JavaScript, por exemplo, código para bibliotecas especializadas.

▍1 CPU


A primeira ideia que me veio à cabeça quando pensei nos locais onde os cálculos são feitos foi que os cálculos são realizados no processador. Sugeri que, como os processadores implementam cálculos aritméticos, eles também podem implementar alguns cálculos mais complexos. Acontece que os processadores têm instruções para executar cálculos trigonométricos e outros, mas essas instruções raramente são usadas. Por exemplo, a implementação do cálculo senoidal em processadores com arquitetura x86 não era muito popular, pois essa implementação não se mostra necessariamente mais rápida que as implementações de software (aquelas que usam operações aritméticas do processador). Além disso, não é necessariamente mais preciso que as implementações de software.

Intel também sofreu uma vergonha devido à muito fortesuperestimação da precisão das operações trigonométricas na documentação. Tais erros são especialmente trágicos devido ao fato de que um microchip, ao contrário de um programa, não pode ser corrigido.

§ 2 Intérprete de idiomas


É assim que os subsistemas de computação funcionam na maioria das implementações de JavaScript. Eles implementam esses subsistemas de várias maneiras.

  • Os mecanismos V8 e SpiderMonkey usam portas de biblioteca fdlibm para cálculos , que são ligeiramente diferentes. Esta biblioteca, originalmente escrita na Sun Microsystems, foi transmitida de geração em geração.
  • JavaScriptCore (Safari) usa a biblioteca cmath para executar a maioria das operações.
  • O Internet Explorer usa cmath e alguns blocos de código gravados no assembler . Aqui, até métodos trigonométricos de processadores foram usados ​​- no caso em que o navegador foi compilado para processadores com instruções semelhantes.

Por razões históricas, as ferramentas usadas para executar cálculos em diferentes mecanismos JS foram alteradas. Portanto, o V8 usou sua própria solução para cálculos, a porta JavaScript fdlibm foi usada e somente então a versão C do fdlibm foi usada.

▍ Por que isso é um problema?


O ponto aqui é que tudo isso reduz a capacidade do JavaScript de produzir resultados uniformes na solução de quaisquer problemas que envolvam cálculos matemáticos. Isso é especialmente difícil para a ciência de dados. Gostaria que o JavaScript fosse mais adequado para fazer cálculos de ciência de dados em um navegador. Além disso, a incapacidade de produzir resultados uniformes significa o agravamento da crise de reprodutibilidade , característica de todas as ciências. Isso sem mencionar alguns outros problemas de JavaScript, como recursos de digitação de números e a falta de uma biblioteca amplamente usada para trabalhar com quadros de dados.

▍3 Usando bibliotecas especializadas


Existe uma maneira confiável de fazer os cálculos em JavaScript. Consiste no uso de bibliotecas especializadas. Portanto, a biblioteca stdlib implementa cálculos de alto nível usando apenas operações aritméticas. Os cálculos aritméticos são totalmente descritos nas especificações, pois são padrão; portanto, os resultados retornados pelo stdlib nos fornecem, independentemente da plataforma na qual o código é executado, resultados completamente uniformes.

Isso é alcançado à custa da complexidade e da velocidade das decisões. Os métodos stdlib não são tão rápidos quanto os métodos internos. Além disso, para "apenas contar o seno", você precisa conectar uma biblioteca inteira ao projeto.

Mas, se você pensar de maneira mais ampla, isso é completamente normal. A plataforma WebAssembly, por exemplo, não fornece ao programador meios para realizar cálculos matemáticos de alto nível. Na documentação para isso, é recomendável incluir independentemente implementações dos mecanismos correspondentes em seus próprios módulos:

“O WebAssembly não inclui suas próprias implementações de funções matemáticas - como sin, cos, exp, pow, etc. A estratégia do WebAssembly para tais funções é permitir que os desenvolvedores os implementem como ferramentas de biblioteca na própria plataforma WebAssembly (observe que as instruções sin e cos da plataforma x86 são lentas e imprecisas e, atualmente, de qualquer maneira, tente não usar). ”

É assim que as linguagens compiladas sempre funcionaram: quando um programa escrito em C é compilado, os métodos importados math.hsão incluídos no programa compilado.

Usando o valor epsilon


Se alguém não quiser incluir a biblioteca stdlib em seu projeto JavaScript para realizar cálculos, mas precisar testar o código que executa alguns cálculos complexos, provavelmente deve recorrer ao método que já é usado na biblioteca de estatísticas simples. Trata-se de usar um valor epsilonque define os limites dentro dos quais as diferenças nos números não são levadas em consideração. Se considerarmos as opções para usar o símbolo epsilon na matemática, podemos dizer que estou falando sobre ele como um "pequeno valor positivo arbitrário". Estatísticas simples usa o valor epsilon igual 0.0001.

Se você precisar descobrir se dois números são iguais, uma condição do formulário é verificadaMath.abs(result — expected) < epsilon. Se essa condição for verdadeira, podemos dizer que a diferença entre os números está dentro do intervalo especificado e os consideramos iguais.

Aditivos


▍ Precisão


Os comentaristas no Twitter indicaram que as variações nos resultados obtidos no exemplo estão fora do intervalo de dígitos significativos do número de ponto flutuante. Do ponto de vista técnico, isso está correto e significa que você pode encontrar uma maneira mais precisa de comparar números do que aquele que usa o valor epsilon. Mas, na prática, a mesma história aqui - os números no final do número afetam o resultado e introduzem imprecisões no resultado final. Além disso, os exemplos dados aqui não são exaustivos. O fato é que os recursos de implementação dos intérpretes JavaScript podem, sem se afastar da especificação, levar ao aparecimento de diferenças na maioria dos resultados numéricos.

▍JavaScript


Eu não quero criticar JavaScript. Acredito que o JavaScript fez um compromisso justificável, levando em consideração a incerteza do futuro e o número de plataformas nas quais as implementações de linguagem são criadas. Devo dizer que é muito difícil comparar diretamente o JavaScript e outros idiomas. O ponto é o ecossistema JavaScript. O fato de, ao mesmo tempo, haver muitos intérpretes da mesma língua é completamente atípico para outras línguas. Além disso, esse é um dos principais pontos fortes do JavaScript. Além disso, não se pode dizer que esses são fenômenos de um plano completamente diferente dos que ocorrem na própria linguagem. E o JavaScript muda com o tempo e muitas coisas boas aparecem nele .

DStdlib ou epsilon?


Acredito que, na prática, na maioria dos casos, vale a pena usar a abordagem que implica o uso do valor epsilon. A biblioteca stdlib é uma ferramenta poderosa maravilhosa, mas o preço da inclusão de uma biblioteca adicional para cálculos matemáticos no projeto pode ser bastante alto. E, na maioria dos casos, pequenas discrepâncias nos resultados dos cálculos não importam para aplicativos.

Sumário


Aqui, eu gostaria de tirar conclusões do exposto e compartilhar algumas idéias.

  1. , , , . . — « ». , , , Math.sin , , , . , , , , , . , , , .
  2. . , , Node.js, , , simply-statistics. , , , , . — .
  3. , . V8, , . , . , , , .

Queridos leitores! Você encontrou problemas relacionados a alterações nos resultados do cálculo ao atualizar para novas versões do Node.js.


All Articles