Informazioni su un'approssimazione più rapida del registro (x)


10

Qualche tempo fa avevo scritto un codice che tentava di calcolare il senza usare le funzioni di libreria. Ieri stavo rivedendo il vecchio codice e ho cercato di renderlo il più veloce possibile (e corretto). Ecco il mio tentativo finora: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;
}

Qui sto cercando di trovare modo che sia appena sopra n, quindi aggiungo il valore del logaritmo di , che è inferiore a 1. A questo punto, l'espansione di Taylor del può essere utilizzata senza preoccuparsi.e a naea log(1-x)nealog(1  x)

Di recente ho sviluppato un interesse per l'analisi numerica, ed è per questo che non posso fare a meno di porre la domanda, quanto più velocemente questo segmento di codice può essere eseguito nella pratica, pur essendo abbastanza corretto? Devo passare ad altri metodi, ad esempio utilizzando la frazione continua, come questa ?

La funzione fornita con la libreria standard C è quasi 5,1 volte più veloce di questa implementazione.log(x)

AGGIORNAMENTO 1 : Utilizzando la serie di arcani iperbolici menzionata in Wikipedia , il calcolo sembra essere quasi 2,2 volte più lento della funzione di registro della libreria C standard. Tuttavia, non ho verificato ampiamente le prestazioni e, per numeri più grandi, la mia attuale implementazione sembra REALMENTE lenta. Voglio controllare sia la mia implementazione per il limite di errori sia il tempo medio per un ampio intervallo di numeri, se posso farcela. Ecco il mio secondo sforzo.

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;
}

Ogni suggerimento o critica è apprezzato.

AGGIORNAMENTO 2: Sulla base dei suggerimenti forniti di seguito, ho aggiunto alcune modifiche incrementali qui, che sono circa 2,5 volte più lente dell'implementazione della libreria standard. Tuttavia, questa volta l'ho testato solo per interi , per numeri più grandi il tempo di esecuzione aumenterebbe. Per adesso. Non conosco ancora le tecniche per generare doppi numeri casuali , quindi non è ancora completamente testato. Per rendere il codice più solido, ho aggiunto correzioni per casi angolari. L'errore medio per i test che ho fatto è di circa .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;
}

Risposte:


17

Questa non è davvero una risposta autorevole , più un elenco di problemi che penso dovresti considerare e non ho testato il tuo codice.

0. Come hai testato la correttezza e la velocità del tuo codice? Entrambi sono importanti, un po 'complicati da fare bene, e tu non dai dettagli. In altre parole, se mi paragono la funzione con la sulla mia macchina, posso anche ottenere gli stessi numeri, , ? Nella mia esperienza con la lettura di parametri di cronometraggio di altre persone nella letteratura accademica, richiede molta cura e precisione per ottenere tempi riproducibili , che sono gli unici tempi a cui qualcuno si preoccuperà mai. I microbenchmark in particolare sono notoriamente inaffidabili.log5.12.15.1

1. Il problema comune con la valutazione di una funzione direttamente con le sue serie (non modificate) di Taylor è il numero di termini necessari per la convergenza. Ci sono 52 bit nella mantissa di a , quindi quando all'inizio del ciclo della serie Taylor, puoi aspettarti che il ciclo prenda circa 50 iterazioni. È piuttosto costoso e dovrebbe essere ottimizzato.n 1f(x)doublen12

1.5. Hai controllato il tuo codice per grande ? Ho provato , il che porta a , e poi a nel ciclo della serie Taylor, portando a una convergenza estremamente lenta: converge come la serie Harmonic, cioè non lo fa ma ci saranno al massimo termini. Come regola generale, dovresti avere una sorta di "conteggio massimo di iterazioni" associato al ciclo. In questo caso si comporta in questo modo perché è finito, ma trabocca a nel ciclo di riduzione degli argomenti. La risposta corretta è .10 17 n 709.78266108405500745 n1.7976e+308term=infn=11017nterm *= e709.78266108405500745

2. Le funzioni implementate nelle librerie standard dovrebbero essere estremamente solide. Restituire ( ) come logaritmo di un numero negativo (o zero) non è corretto. Il logaritmo di dovrebbe essere , il logaritmo di un numero negativo dovrebbe essere NaN.0 0 - 1030000

Ho il sospetto che con un piccolo sforzo puoi sacrificare un po 'di quella robustezza per le prestazioni, ad esempio limitando la gamma degli argomenti o restituendo risultati leggermente meno accurati.

3. Le prestazioni di questo tipo di codice possono dipendere molto dall'architettura della CPU su cui è in esecuzione. È un argomento profondo e coinvolto, ma i produttori di CPU come Intel pubblicano guide di ottimizzazione che spiegano le diverse interazioni tra il codice e la CPU su cui è in esecuzione. La memorizzazione nella cache può essere relativamente semplice, ma cose come la previsione del ramo, il parallelismo a livello di istruzione e le bancarelle della pipeline a causa delle dipendenze dei dati sono difficili da vedere esattamente nel codice di alto livello, ma contano molto per le prestazioni.

4. Implementare una funzione come questa correttamente solito significa che si garantisce che per l'ingresso a virgola mobile numero , l'uscita è entro una certa distanza della virgola mobile più vicino numero al valore vero . Verifica che ciò non sia del tutto banale, nel tuo codice non ci sono prove che tu l'abbia fatto, quindi non so se la tua funzione sia corretta (sono sicuro che sia abbastanza accurata, ma quanto precisa?). Ciò non equivale a dimostrare che la serie Taylor converge, a causa della presenza di errori di arrotondamento in virgola mobile.˜ y = ˜ f ( ˜ x )y=f( ˜ x )x~y~=f~(x~)y=f(x~)

4.5. Un buon modo per testare l'accuratezza di una funzione non testata sarebbe quello di valutarla in ciascuno dei quattro miliardi (meno se si sta eseguendo correttamente la riduzione dell'argomento, come qui) float a precisione singola e confrontare gli errori con il registro standard di libm. Richiede un po 'di tempo, ma almeno è accurato.

5. Poiché conosci dall'inizio la precisione dei doppi, non devi avere un ciclo illimitato: il numero di iterazioni può essere calcolato in anticipo (probabilmente è circa 50). Usa questo per rimuovere i rami dal tuo codice, o almeno imposta il numero di iterazioni in anticipo.

Si applicano anche tutte le solite idee sullo srotolamento circolare.

6. È possibile utilizzare tecniche di approssimazione diverse dalle serie di Taylor. Esistono anche serie di Chebyshev (con la ricorrenza di Clenshaw), approssimatori di Pade e talvolta metodi di ricerca delle radici come il metodo di Newton ogni volta che la tua funzione può essere rifusa come radice di una funzione più semplice (ad esempio, il famoso trucco sqrt ).

Le frazioni continue probabilmente non saranno troppo grandi, perché coinvolgono la divisione, che è molto più costosa delle moltiplicazioni / aggiunte. Se si guarda _mm_div_ssa https://software.intel.com/sites/landingpage/IntrinsicsGuide/ , divisione ha una latenza di 13-14 cicli e il throughput di 5-14, a seconda dell'architettura, rispetto a 3-5 / 0,5-1 per moltiplicare / aggiungere / madd. Quindi in generale (non sempre) ha senso cercare di eliminare il più possibile le divisioni.

Purtroppo, la matematica non è come una grande guida qui, perché espressioni con brevi formule non sono necessariamente i più veloci. La matematica non penalizza le divisioni, per esempio.

7. I numeri in virgola mobile sono memorizzati internamente nella forma (mantissa , , esponente ). Il registro naturale di è molto meno naturale del registro base-2, per il quale la prima parte del codice può essere sostituita con una chiamata a . m 1x=m×2emex12<m1exfrexp

8. Confronta i tuoi logcon login libmo openlibm(es: https://github.com/JuliaLang/openlibm/blob/master/src/e_log.c ). Questo è di gran lunga il modo più semplice per scoprire cosa hanno già capito gli altri. Esistono anche versioni ottimizzate libm appositamente per i produttori di CPU, ma quelle di solito non hanno il loro codice sorgente pubblicato.

Boost :: sf ha alcune funzioni speciali, ma non quelle di base. Potrebbe essere istruttivo esaminare l'origine di log1p, tuttavia: http://www.boost.org/doc/libs/1_58_0/libs/math/doc/html/math_toolkit/powers/log1p.html

Esistono anche librerie aritmetiche di precisione arbitraria open source come mpfr, che potrebbero utilizzare algoritmi diversi da libm a causa della maggiore precisione richiesta.

9. L'accuratezza e la stabilità di Higham degli algoritmi numerici è una buona introduzione di livello superiore all'analisi degli errori degli algoritmi numerici. Per gli stessi algoritmi di approssimazione, Pratica di approssimazione della teoria dell'approssimazione di Trefethen è un buon riferimento.

10. So che questo è detto un po 'troppo spesso, ma i progetti software ragionevolmente grandi raramente dipendono dal runtime di una piccola funzione richiamata più e più volte. Non è così comune preoccuparsi delle prestazioni del registro, a meno che non sia stato profilato il programma e verificato che sia importante.


26414e15

1.13e13term

 1e8

1
k=11071lnk

2
frexp x=m×2elnx=eln2+lnm

5

La risposta di Kirill ha già toccato un gran numero di questioni rilevanti. Vorrei ampliarne alcuni sulla base dell'esperienza pratica nella progettazione di librerie matematiche. Una nota in anticipo: i progettisti di biblioteche matematiche tendono a utilizzare ogni ottimizzazione algoritmica pubblicata, così come molte ottimizzazioni specifiche della macchina, non tutte verranno pubblicate. Il codice verrà frequentemente scritto in linguaggio assembly, anziché utilizzare il codice compilato. È pertanto improbabile che un'implementazione semplice e compilata raggiunga oltre il 75% delle prestazioni di un'implementazione esistente di librerie matematiche di alta qualità, ipotizzando identici set di funzionalità (accuratezza, gestione di casi speciali, segnalazione degli errori, supporto della modalità di arrotondamento).

explogerfcΓ

La precisione viene in genere valutata confrontandola con un riferimento (di terze parti) con maggiore precisione. Le funzioni a precisione singola a argomento singolo possono essere facilmente testate esaustivamente, altre funzioni richiedono test con vettori di test casuali (diretti). Chiaramente non si possono calcolare risultati di riferimento infinitamente precisi, ma la ricerca sul dilemma del produttore di tavoli suggerisce che per molte semplici funzioni è sufficiente calcolare un riferimento con una precisione di circa tre volte la precisione del bersaglio. Vedi ad esempio:

Vincent Lefèvre, Jean-Michel Muller, "Casi peggiori per l'arrotondamento corretto delle funzioni elementari in doppia precisione". In Atti del 15 ° Simposio IEEE sull'aritmetica informatica , 2001,111-118). (prestampa online)

In termini di prestazioni, è necessario distinguere tra l'ottimizzazione per la latenza (importante quando si guarda al tempo di esecuzione delle operazioni dipendenti), e l'ottimizzazione per il throughput (rilevante quando si considera il tempo di esecuzione di operazioni indipendenti). Negli ultimi vent'anni, la proliferazione di tecniche di parallelizzazione hardware come il parallelismo a livello di istruzione (ad esempio processori superscalari, fuori servizio), parallelismo a livello di dati (ad esempio istruzioni SIMD) e parallelismo a livello di thread (ad esempio hyper-threading, processori multi-core) ha portato a porre l'accento sul rendimento computazionale come metrica più rilevante.

log(1+x)=p(x)log(x)=2atanh((x1)/(x+1))=p(((x1)/(x+1))2)p

L'operazione di fusione multipla ( FMA ), introdotta per la prima volta da IBM 25 anni fa e ora disponibile su tutte le principali architetture di processori, è un elemento fondamentale delle moderne implementazioni delle librerie matematiche. Fornisce una riduzione dell'errore di arrotondamento, offre una protezione limitata contro la cancellazione sottrattiva e semplifica enormemente l' aritmetica doppio-doppio .

C99log()C99fma()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;
}

(+1) Sai se le comuni implementazioni open-source (come openlibm) sono le migliori che possono essere o possono essere migliorate le loro funzioni speciali?
Kirill,

1
@Kirill L'ultima volta che ho esaminato le implementazioni open source (molti anni fa), non stavano sfruttando i vantaggi dell'FMA. All'epoca IBM Power e Intel Itanium erano le uniche architetture che includevano l'operazione, ora il supporto hardware è onnipresente. Inoltre, all'epoca le approssimazioni polinomiali table-plus erano allo stato dell'arte, ora le tabelle non sono favorevoli: l'accesso alla memoria comporta un maggiore consumo di energia, possono (e fare) interferire con la vettorializzazione e il throughput computazionale è aumentato più del throughput della memoria con conseguente potenziale impatto negativo sulle prestazioni dalle tabelle.
njuffa,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.