Sobre uma aproximação mais rápida do log (x)

10

Há algum tempo eu escrevi um código que tentava calcular o sem usar as funções da biblioteca. Ontem, eu estava revisando o código antigo e tentei torná-lo o mais rápido possível (e correto). Aqui está minha tentativa até agora:log(x)

const double ee = exp(1);

double series_ln_taylor(double n){ /* n = e^a * b, where a is an non-negative integer */
    double lgVal = 0, term, now;
    int i, flag = 1;

    if ( n <= 0 ) return 1e-300;
    if ( n * ee < 1 )
        n = 1.0 / n, flag = -1; /* for extremely small n, use e^-x = 1/n */

    for ( term = 1; term < n ; term *= ee, lgVal++ );
    n /= term;

    /* log(1 - x) = -x - x**2/2 - x**3/3... */
    n = 1 - n;
    now = term = n;
    for ( i = 1 ; ; ){
        lgVal -= now;
        term *= n;
        now = term / ++i;
        if ( now < 1e-17 ) break;
    }

    if ( flag == -1 ) lgVal = -lgVal;

    return lgVal;
}

Aqui, estou tentando encontrar para que acabe de n e, em seguida, adiciono o valor do logaritmo de , que é menor que 1. Neste ponto, a expansão do de Taylor pode ser usada sem se preocupar.anea log(1-x)nealog(1  x)

Recentemente, interessei-me pela análise numérica, e é por isso que não consigo deixar de fazer a pergunta: quanto mais rápido esse segmento de código pode ser executado na prática, enquanto está correto o suficiente? Preciso mudar para outros métodos, por exemplo, usando a fração continuada, como esta ?

A função fornecida com a biblioteca padrão C é quase 5,1 vezes mais rápida que esta implementação.log(x)

ATUALIZAÇÃO 1 : Usando a série arctan hiperbólica mencionada na Wikipedia , o cálculo parece quase 2,2 vezes mais lento que a função de log de biblioteca padrão C. No entanto, não verifiquei extensivamente o desempenho e, para números maiores, minha implementação atual parece REALMENTE lenta. Quero verificar minha implementação quanto ao limite de erros e ao tempo médio para obter uma ampla variedade de números, se eu puder gerenciar. Aqui está o meu segundo esforço.

double series_ln_arctanh(double n){ /* n = e^a * b, where a is an non-negative integer */
    double lgVal = 0, term, now, sm;
    int i, flag = 1;

    if ( n <= 0 ) return 1e-300;
    if ( n * ee < 1 ) n = 1.0 / n, flag = -1; /* for extremely small n, use e^-x = 1/n */

    for ( term = 1; term < n ; term *= ee, lgVal++ );
    n /= term;

    /* log(x) = 2 arctanh((x-1)/(x+1)) */
    n = (1 - n)/(n + 1);

    now = term = n;
    n *= n;
    sm = 0;
    for ( i = 3 ; ; i += 2 ){
        sm += now;
        term *= n;
        now = term / i;
       if ( now < 1e-17 ) break;
    }

    lgVal -= 2*sm;

    if ( flag == -1 ) lgVal = -lgVal;
    return lgVal;
}

Qualquer sugestão ou crítica é apreciada.

ATUALIZAÇÃO 2: Com base nas sugestões feitas abaixo, adicionei algumas mudanças incrementais aqui, que são cerca de 2,5 vezes mais lentas que a implementação da biblioteca padrão. No entanto, eu testei apenas para números inteiros desta vez, para números maiores, o tempo de execução aumentaria. Por enquanto. Ainda não conheço técnicas para gerar números duplos aleatórios , por isso ainda não está totalmente aferido. Para tornar o código mais robusto, adicionei correções para casos de canto. O erro médio para os testes que fiz é de cerca de .1 e 308 4 e - 151e81e3084e15

double series_ln_better(double n){ /* n = e^a * b, where a is an non-negative integer */
    double lgVal = 0, term, now, sm;
    int i, flag = 1;

    if ( n == 0 ) return -1./0.; /* -inf */
    if ( n < 0 ) return 0./0.;   /* NaN*/
    if ( n < 1 ) n = 1.0 / n, flag = -1; /* for extremely small n, use e^-x = 1/n */

    /* the cutoff iteration is 650, as over e**650, term multiplication would
       overflow. For larger numbers, the loop dominates the arctanh approximation
       loop (with having 13-15 iterations on average for tested numbers so far */

    for ( term = 1; term < n && lgVal < 650 ; term *= ee, lgVal++ );
    if ( lgVal == 650 ){
        n /= term;
        for ( term = 1 ; term < n ; term *= ee, lgVal++ );
    }
    n /= term;

    /* log(x) = 2 arctanh((x-1)/(x+1)) */
    n = (1 - n)/(n + 1);

    now = term = n;
    n *= n;
    sm = 0;

    /* limiting the iteration for worst case scenario, maximum 24 iteration */
    for ( i = 3 ; i < 50 ; i += 2 ){
        sm += now;
        term *= n;
        now = term / i;
        if ( now < 1e-17 ) break;
    }

    lgVal -= 2*sm;

    if ( flag == -1 ) lgVal = -lgVal;

    return lgVal;
}
sarker306
fonte

Respostas:

17

Esta não é realmente uma resposta autorizada , é mais uma lista de questões que acho que você deve considerar e não testei seu código.

0. Como você testou seu código quanto à correção e velocidade? Ambos são importantes, um tanto complicados de se dar bem, e você não dá detalhes. Em outras palavras, se eu comparar a sua função com a log de minha máquina, também eu obter os mesmos números, , ? Na minha experiência com a leitura de referências de cronometragem de outras pessoas na literatura acadêmica, é preciso muito cuidado e precisão para obter tempos reproduzíveis , que são os únicos momentos em que alguém se preocupa. As micro-marcas especialmente são notoriamente não confiáveis.5.12.15.1

1. O problema comum com a avaliação de uma função diretamente com sua série de Taylor (não modificada) é o número de termos necessários para a convergência. Existem 52 bits na mantissa de a , portanto, quando no início do loop da série Taylor, você pode esperar que o loop leve cerca de 50 iterações. Isso é muito caro e deve ser otimizado.n 1f(x)doublen12

1.5 Você verificou seu código para grande ? Eu tentei , o que leva ao , e depois ao loop da série Taylor, levando a uma convergência extremamente lenta: converge como a série Harmonic, ou seja, não, mas haverá, no máximo, termos. Como regra geral, você deve ter algum tipo de "contagem de iteração máxima" vinculada ao loop. Nesse caso, ele se comporta assim porque é finito, mas excede em no loop de redução de argumento. A resposta correta é .10 17 n 709,78266108405500745 n1.7976e+308term=infn=11017nterm *= e709.78266108405500745

2. Espera-se que as funções implementadas nas bibliotecas padrão sejam extremamente robustas. Retornar ( ) como o logaritmo de um número negativo (ou zero) não está correto. O logaritmo de deve ser , o logaritmo de um número negativo deve ser NaN.0 0 - 1030000

Suspeito que, com um pouco de esforço, você possa sacrificar parte dessa robustez pelo desempenho, por exemplo, restringindo o intervalo de argumentos ou retornando resultados um pouco menos precisos.

3. O desempenho desse tipo de código pode depender muito da arquitetura da CPU em que está sendo executada. É um tópico profundo e envolvido, mas fabricantes de CPU como a Intel publicam guias de otimização que explicam as diferentes interações entre seu código e a CPU em que ele está sendo executado. O armazenamento em cache pode ser relativamente direto, mas coisas como previsão de ramificação, paralelismo no nível de instruções e paralisações de pipeline devido a dependências de dados são difíceis de serem visualizadas com precisão no código de alto nível, mas são importantes para o desempenho.

4. A implementação correta de uma função como essa normalmente significa que você garante que, para o número do ponto flutuante de entrada , a saída esteja a uma certa distância do ponto flutuante mais próximo número com o valor verdadeiro . Verificar se isso não é totalmente trivial, não há evidências em seu código de que você tenha feito isso, então não sei se sua função está correta (tenho certeza de que é bastante precisa, mas qual é a precisão?). Isso não é o mesmo que mostrar que a série Taylor converge devido à presença de erros de arredondamento de ponto flutuante.x~y~=f~(x~)y=f(x~)

4.5 Uma boa maneira de testar a precisão de uma função não testada seria avaliá-la em cada um dos quatro bilhões (menos se você estiver fazendo a redução de argumentos corretamente, como aqui) flutua com precisão única e compare os erros com o log padrão de libm. Demora um pouco de tempo, mas pelo menos é completo.

5. Como você sabe desde o início a precisão das duplas, não precisa ter um loop ilimitado: o número de iterações pode ser descoberto antecipadamente (provavelmente são cerca de 50). Use isso para remover ramificações do seu código ou, pelo menos, definir o número de iterações antecipadamente.

Todas as idéias usuais sobre desenrolamento de loop também se aplicam.

6. É possível usar técnicas de aproximação diferentes das séries de Taylor. Também existem séries Chebyshev (com a recorrência de Clenshaw), aproximações de Pade e, às vezes, métodos de busca de raiz como o método de Newton sempre que sua função pode ser reformulada como a raiz de uma função mais simples (por exemplo, o famoso truque sqrt ).

As frações contínuas provavelmente não serão muito grandes, porque envolvem divisão, que é muito mais cara do que multiplica / adiciona. Se você olhar para _mm_div_ssa https://software.intel.com/sites/landingpage/IntrinsicsGuide/ , divisão tem latência de 13-14 ciclos e taxa de transferência de 5-14, dependendo da arquitetura, em comparação com 3-5 / 0,5-1 para multiplicar / adicionar / madd. Portanto, em geral (nem sempre), faz sentido tentar eliminar divisões o máximo possível.

Infelizmente, a matemática não é como um grande guia aqui, porque expressões com curtas fórmulas não são necessariamente os mais rápidos. A matemática não penaliza divisões, por exemplo.

7. Os números de ponto flutuante são armazenados internamente no formato (mantissa , , expoente ). O log natural de é muito menos natural que o log de base 2, para o qual a primeira parte do seu código pode ser substituída por uma chamada para .x=m×2em12<m1exfrexp

8. Compare o seu logcom o login libmou openlibm(por exemplo: https://github.com/JuliaLang/openlibm/blob/master/src/e_log.c ). Essa é de longe a maneira mais fácil de descobrir o que as outras pessoas já descobriram. Também existem versões especialmente otimizadas, libm específicas para fabricantes de CPU, mas essas geralmente não têm seu código fonte publicado.

O Boost :: sf possui algumas funções especiais, mas não as básicas. Pode ser instrutivo procurar a fonte do log1p, no entanto: http://www.boost.org/doc/libs/1_58_0/libs/math/doc/html/math_toolkit/powers/log1p.html

Também existem bibliotecas aritméticas de precisão arbitrária de código aberto como mpfr, que podem usar algoritmos diferentes do libm devido à maior precisão necessária.

9. A precisão e a estabilidade dos algoritmos numéricos de Higham é uma boa introdução de nível superior para analisar erros de algoritmos numéricos. Para os algoritmos de aproximação em si, a Approximation Theory Approximation Practice de Trefethen é uma boa referência.

10. Sei que isso é dito com muita frequência, mas projetos de software razoavelmente grandes raramente dependem do tempo de execução de uma pequena função que é chamada repetidamente. Não é tão comum ter que se preocupar com o desempenho do log, a menos que você tenha perfilado seu programa e tenha certeza de que é importante.

Kirill
fonte
Obrigado por uma resposta completa, isso me ajudou em algumas questões muito urgentes. Primeiro, não tenho que lidar com números muito grandes, geralmente tenho que trabalhar apenas com números inteiros; portanto, números dentro do intervalo são muito bons para mim. E a série arctanh teve um desempenho melhor para mim em termos de velocidade e tem boa convergência que a de Taylor. Atualizei os casos de canto para números negativos e zero. No estado atual, a implementação mostra erro em torno de no máximo, mas enquanto eu não estiver usando o cálculo em cascata, o erro não deve ser muito problemático. 26414e15
sarker306
Para a maior entrada possível, isto é, 1.7976e + 308 e 1.7976e-308, a implementação atual mostra um erro absoluto de , o que pode exigir algum trabalho para corrigir. Para neutralizar o estouro devido à multiplicação repetida, uma condição pode ser aplicada, quando a variável de loop estiver em torno de 700, podemos dividir n uma vez e continuar novamente. (Isso pode explicar por que o valor do erro é um pouco maior aqui). 1.13e13term
sarker306
Como a expansão da série arctanh parece convergir em média em 13 iterações quando testada com números inteiros , talvez possamos fazer com 20 iterações ou mais. Seria ótimo se tivéssemos uma regra básica de iterações com base no valor numérico da entrada. E para entradas maiores, o primeiro loop começaria a dominar. Quadrados repetidos podem ajudar, provavelmente, vou dar uma olhada. E obrigado pelo link libm. Eu estou olhando para isso.  1e8
sarker306
11
@ sarker306 Tentei avaliar , e suas três versões são mais lentas que o log da minha libm por fatores de 19.4, 9.4, 8.3. k=11071lnk
21415 Kirill
2
@ sarker306 Você pode eliminar completamente o primeiro loop usando frexppara obter a mantissa e o expoente do número , de modo que . x=m×2elnx=eln2+lnm
21915 Kirill
5

A resposta de Kirill já abordou um grande número de questões relevantes. Gostaria de expandir alguns deles com base na experiência prática em design de bibliotecas de matemática. Uma observação adiantada: os projetistas de bibliotecas de matemática tendem a usar todas as otimizações algorítmicas publicadas, bem como muitas otimizações específicas de máquinas, nem todas serão publicadas. O código freqüentemente será escrito em linguagem assembly, em vez de usar código compilado. Portanto, é improvável que uma implementação direta e compilada atinja mais de 75% do desempenho de uma implementação existente de biblioteca matemática de alta qualidade, assumindo conjuntos de recursos idênticos (precisão, tratamento de casos especiais, relatório de erros, suporte ao modo de arredondamento).

Em termos de precisão, para funções elementares (por exemplo , , ) e funções especiais simples (por exemplo , , ), o erro ulp substituiu o erro relativo como a métrica de erro relevante. Um objetivo de projeto comum para funções elementares é um erro máximo de menos de 1 ulp, resultando em uma função fielmente arredondada. Uma função fielmente arredondada retorna o número de ponto flutuante mais próximo do resultado infinitamente preciso ou um número de ponto flutuante imediatamente adjacente.explogerfcΓ

A precisão é normalmente avaliada por comparação com uma referência de alta precisão (de terceiros). As funções de precisão única de argumento único podem ser facilmente testadas exaustivamente; outras funções requerem teste com vetores de teste aleatórios (direcionados). Claramente, não se pode calcular resultados de referência infinitamente precisos, mas a pesquisa no dilema do criador de tabelas sugere que, para muitas funções simples, basta calcular uma referência com uma precisão de cerca de três vezes a precisão do objetivo. Veja por exemplo:

Vincent Lefèvre, Jean-Michel Muller, "Piores casos de arredondamento correto das funções elementares em dupla precisão". Em Proceedings 15th IEEE Symposium on Computer Arithmetic , 2001, 1111-118). (pré-impressão online)

Em termos de desempenho, é preciso distinguir entre otimizar a latência (importante quando se olha o tempo de execução das operações dependentes) e otimizar o throughput (relevante ao considerar o tempo de execução das operações independentes). Nos últimos vinte anos, a proliferação de técnicas de paralelização de hardware, como paralelismo em nível de instrução (por exemplo, superescalar, processadores fora de ordem), paralelismo em nível de dados (por exemplo, instruções SIMD) e paralelismo em nível de encadeamento (por exemplo, hiperencadeamento, processadores multinúcleo) levou a uma ênfase na taxa de transferência computacional como a métrica mais relevante.

Aproximações principais para funções elementares são quase exclusivamente aproximações mínimas . Devido ao custo relativamente alto da divisão, aproximações polinomiais de minimax. Ferramentas como Mathematica ou Maple possuem recursos internos para gerá-los; também existem ferramentas especializadas como o Sollya . Para o logaritmo, as opções básicas de aproximação do núcleo, após a redução do argumento para valores próximos à unidade, são e , em que é uma aproximação polinomial de minimax. Em termos de desempenho, o primeiro é geralmente preferido para implementações de precisão única (consulte esta respostal O g ( x ) = 2 um t um n h ( ( x - 1 ) / ( x + 1 ) ) = p ( ( ( x - 1 ) / ( x + 1 ) ) 2 ) plog(1+x)=p(x)log(x)=2atanh((x1)/(x+1))=p(((x1)/(x+1))2)p para um exemplo trabalhado), enquanto o último é preferido para implementações de precisão dupla.

A operação de adição múltipla ( FMA ), introduzida pela IBM há 25 anos e agora disponível em todas as principais arquiteturas de processadores, é um componente essencial das implementações modernas de bibliotecas de matemática. Ele fornece redução de erro de arredondamento, oferece proteção limitada contra cancelamento subtrativo e simplifica enormemente a aritmética duplo-duplo .

A C99implementação exemplar de precisão dupla IEEE-754 log()abaixo demonstra o uso de FMA (exposto C99como a fma()função matemática padrão), juntamente com o uso muito limitado de técnicas de duplo duplo para melhorar a precisão de produtos com constantes transcendentais. São fornecidas duas aproximações principais diferentes, ambas com resultados fielmente arredondados, conforme demonstrado pelo teste com vetores de teste aleatórios. As aproximações mínimas utilizadas foram calculadas com minhas próprias ferramentas, com base no algoritmo Remez .233

#include <math.h>

/* compute natural logarithm

   USE_ATANH == 1: maximum error found: 0.83482 ulp @ 0.7012829191167614
   USE_ATANH == 0: maximum error found: 0.83839 ulp @ 1.2788954397331760
*/
double my_log (double a)
{
    const double LOG2_HI = 0x1.62e42fefa39efp-01; // 6.9314718055994529e-01
    const double LOG2_LO = 0x1.abc9e3b39803fp-56; // 2.3190468138462996e-17
    double m, r, i, s, t, p, f, q;
    int e;

    m = frexp (a, &e);
    if (m < 0.70703125) { // 181/256
        m = m + m;
        e = e - 1;
    }
    i = (double)e;

    /* m in [181/256, 362/256] */

#if USE_ATANH
    /* Compute q = (m-1) / (m+1) */
    p = m + 1.0;
    m = m - 1.0;
    q = m / p;

    /* Compute (2*atanh(q)/q-2*q) as p(q**2), q in [-75/437, 53/309] */
    s = q * q;
    r =             0x1.2f1da230fb057p-3;  // 1.4800574027992994e-1
    r = fma (r, s,  0x1.399f73f934c01p-3); // 1.5313616375223663e-1
    r = fma (r, s,  0x1.7466542530accp-3); // 1.8183580149169243e-1
    r = fma (r, s,  0x1.c71c51a8bf129p-3); // 2.2222198291991305e-1
    r = fma (r, s,  0x1.249249425f140p-2); // 2.8571428744887228e-1
    r = fma (r, s,  0x1.999999997f6abp-2); // 3.9999999999404662e-1
    r = fma (r, s,  0x1.5555555555593p-1); // 6.6666666666667351e-1
    r = r * s;

    /* log(a) = 2*atanh(q) + i*log(2) = LOG2_LO*i + p(q**2)*q + 2q + LOG2_HI*i.
       Use K.C. Ng's trick to improve the accuracy of the computation, like so:
       p(q**2)*q + 2q = p(q**2)*q + q*t - t + m, where t = m**2/2.
    */
    t = m * m * 0.5;
    r = fma (q, t, fma (q, r, LOG2_LO * i)) - t + m;
    r = fma (LOG2_HI, i, r);

#else // USE_ATANH

    /* Compute f = m -1 */
    f = m - 1.0;
    s = f * f;

    /* Approximate log1p (f), f in [-75/256, 106/256] */
    r = fma (-0x1.961d64ddd82b6p-6, f, 0x1.d35fd598b1362p-5); // -2.4787281515616676e-2, 5.7052533321928292e-2
    t = fma (-0x1.fcf5138885121p-5, f, 0x1.b97114751d726p-5); // -6.2128580237329929e-2, 5.3886928516403906e-2
    r = fma (r, s, t);
    r = fma (r, f, -0x1.b5b505410388dp-5); // -5.3431043874398211e-2
    r = fma (r, f,  0x1.dd660c0bd22dap-5); //  5.8276198890387668e-2
    r = fma (r, f, -0x1.00bda5ecdad6fp-4); // -6.2680862565391612e-2
    r = fma (r, f,  0x1.1159b2e3bd0dap-4); //  6.6735934054864471e-2
    r = fma (r, f, -0x1.2489f14dd8883p-4); // -7.1420614809115476e-2
    r = fma (r, f,  0x1.3b0ee248a0ccfp-4); //  7.6918491287915489e-2
    r = fma (r, f, -0x1.55557d3b497c3p-4); // -8.3333481965921982e-2
    r = fma (r, f,  0x1.745d4666f7f48p-4); //  9.0909266480136641e-2
    r = fma (r, f, -0x1.999999d959743p-4); // -1.0000000092767629e-1
    r = fma (r, f,  0x1.c71c70bbce7c2p-4); //  1.1111110722131826e-1
    r = fma (r, f, -0x1.fffffffa61619p-4); // -1.2499999991822398e-1
    r = fma (r, f,  0x1.249249262c6cdp-3); //  1.4285714290377030e-1
    r = fma (r, f, -0x1.555555555f03cp-3); // -1.6666666666776730e-1
    r = fma (r, f,  0x1.999999999759ep-3); //  1.9999999999974433e-1
    r = fma (r, f, -0x1.fffffffffff53p-3); // -2.4999999999999520e-1
    r = fma (r, f,  0x1.555555555555dp-2); //  3.3333333333333376e-1
    r = fma (r, f, -0x1.0000000000000p-1); // -5.0000000000000000e-1

    /* log(a) = log1p (f) + i * log(2) */
    p = fma ( LOG2_HI, i, f);
    t = fma (-LOG2_HI, i, p);
    f = fma ( LOG2_LO, i, f - t);
    r = fma (r, s, f);
    r = r + p;
#endif // USE_ATANH

    /* Handle special cases */
    if (!((a > 0.0) && (a <= 0x1.fffffffffffffp1023))) {
        r = a + a;  // handle inputs of NaN, +Inf
        if (a  < 0.0) r =  0.0 / 0.0; //  NaN
        if (a == 0.0) r = -1.0 / 0.0; // -Inf
    }
    return r;
}
njuffa
fonte
(+1) Você sabe se as implementações comuns de código aberto (como o openlibm) são tão boas quanto podem ser ou suas funções especiais podem ser aprimoradas?
21416 Kirill
11
@Kirill Última vez que olhei para implementações de código aberto (há muitos anos), elas não estavam explorando os benefícios das FMA. Na época, IBM Power e Intel Itanium eram as únicas arquiteturas que incluíam a operação, agora o suporte a hardware é onipresente. Além disso, as aproximações de tabela com polinômio eram de última geração, agora as tabelas são desfavoráveis: o acesso à memória resulta em maior uso de energia, elas podem (e fazem) interferir na vetorização e o rendimento computacional aumentou mais do que o rendimento da memória resultando em um potencial impacto negativo no desempenho das tabelas.
Njuffa