Über eine schnellere Approximation von log (x)


10

Ich hatte vor einiger Zeit einen Code geschrieben, der versuchte, log(x) ohne Verwendung von Bibliotheksfunktionen zu berechnen . Gestern habe ich den alten Code überprüft und versucht, ihn so schnell wie möglich (und korrekt) zu machen. Hier ist mein bisheriger Versuch:

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

Hier versuche ich, zu finden a , so dass ea etwas mehr als n, und dann füge ich den Logarithmus Wert von , das kleiner als 1 ist. Zu diesem Zeitpunkt kann die Taylor-Expansion vonlog(1-x)ohne Bedenken verwendet werden.nealog(1  x)

Ich habe in letzter Zeit ein Interesse an numerischen Analysen entwickelt, und deshalb kann ich mir die Frage stellen, wie viel schneller dieses Codesegment in der Praxis ausgeführt werden kann, während es korrekt genug ist. Muss ich auf einige andere Methoden wechseln, beispielsweise Kettenbruch verwenden, wie dies ?

Die mit der C-Standardbibliothek bereitgestellte Funktion ist fast 5,1-mal schneller als diese Implementierung.log(x)

UPDATE 1 : Unter Verwendung der in Wikipedia erwähnten hyperbolischen Arctan-Reihe scheint die Berechnung fast 2,2-mal langsamer zu sein als die Protokollfunktion der C-Standardbibliothek. Obwohl ich die Leistung nicht ausführlich überprüft habe, scheint meine aktuelle Implementierung bei größeren Zahlen WIRKLICH langsam zu sein. Ich möchte sowohl meine Implementierung auf fehlergebundene als auch die durchschnittliche Zeit für eine Vielzahl von Zahlen überprüfen, wenn ich dies verwalten kann. Hier ist meine zweite Anstrengung.

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

Jeder Vorschlag oder jede Kritik wird geschätzt.

1e81e3084e15

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

Antworten:


17

Dies ist keine wirklich maßgebliche Antwort, sondern eine Liste von Problemen, die Sie meiner Meinung nach berücksichtigen sollten, und ich habe Ihren Code nicht getestet.

log2.15.1

f(x)doublen12

n1.7976e+308term=infn=11017nterm *= e709.78266108405500745

1030000

Ich vermute, dass Sie mit ein wenig Aufwand einen Teil dieser Robustheit für die Leistung opfern können, z. B. indem Sie den Argumentationsbereich einschränken oder etwas weniger genaue Ergebnisse zurückgeben.

3. Die Leistung dieser Art von Code kann stark von der CPU-Architektur abhängen, auf der er ausgeführt wird. Es ist ein tiefgreifendes und kompliziertes Thema, aber CPU-Hersteller wie Intel veröffentlichen Optimierungshandbücher, in denen die unterschiedlichen Wechselwirkungen zwischen Ihrem Code und der CPU, auf der er ausgeführt wird, erläutert werden. Das Caching kann relativ einfach sein, aber Dinge wie Verzweigungsvorhersage, Parallelität auf Befehlsebene und Pipeline-Verzögerungen aufgrund von Datenabhängigkeiten sind im Code auf hoher Ebene schwer genau zu erkennen, aber für die Leistung von großer Bedeutung.

x~y~=f~(x~)y=f(x~)genau?). Dies ist nicht dasselbe wie zu zeigen, dass die Taylor-Reihe aufgrund des Vorhandenseins von Gleitkomma-Rundungsfehlern konvergiert.

4.5. Eine gute Möglichkeit, eine nicht getestete Funktion auf Genauigkeit zu testen, besteht darin, sie bei jedem der vier Milliarden Floats mit einfacher Genauigkeit (wie hier, wenn Sie die Argumentreduktion korrekt durchführen) zu bewerten und die Fehler mit dem Standardprotokoll von zu vergleichen libm. Dauert ein bisschen, ist aber zumindest gründlich.

5. Da Sie von Anfang an die Präzision von Doppel kennen, müssen Sie keine unbegrenzte Schleife haben: Die Anzahl der Iterationen kann im Voraus ermittelt werden (wahrscheinlich sind es etwa 50). Verwenden Sie diese Option, um Verzweigungen aus Ihrem Code zu entfernen oder zumindest die Anzahl der Iterationen im Voraus festzulegen.

Es gelten auch alle üblichen Ideen zum Abrollen von Schleifen.

6. Es ist möglich, andere Approximationstechniken als Taylor-Reihen zu verwenden. Es gibt auch Chebyshev-Reihen (mit der Clenshaw-Wiederholung), Pade-Approximanten und manchmal Wurzelfindungsmethoden wie Newtons Methode, wenn Ihre Funktion als Wurzel einer einfacheren Funktion neu formuliert werden kann (z. B. der berühmte sqrt-Trick ).

Fortgesetzte Brüche werden wahrscheinlich nicht zu groß sein, da sie eine Division beinhalten, die viel teurer ist als Multiplikationen / Additionen. Wenn man sich anschaut , _mm_div_ssbei https://software.intel.com/sites/landingpage/IntrinsicsGuide/ hat Teilung von 13 bis 14 Zyklen Latenz und Durchsatz von 5-14, je nach Architektur, im Vergleich zu 3-5 / 0,5-1 für multiplizieren / addieren / maddieren. Im Allgemeinen (nicht immer) ist es daher sinnvoll, Spaltungen so weit wie möglich zu beseitigen.

Leider ist Mathematik hier kein so guter Leitfaden, da Ausdrücke mit kurzen Formeln nicht unbedingt die schnellsten sind. Mathematik bestraft zum Beispiel keine Spaltungen.

7. Gleitkommazahlen werden intern in der Form gespeichertx=m×2em12<m1exfrexp

8. Vergleichen Sie Ihre logmit dem login libmoder openlibm(z . B. https://github.com/JuliaLang/openlibm/blob/master/src/e_log.c ). Dies ist bei weitem der einfachste Weg, um herauszufinden, was andere Leute bereits herausgefunden haben. Es gibt auch speziell libm für CPU-Hersteller optimierte Versionen , deren Quellcode jedoch normalerweise nicht veröffentlicht wird.

Boost :: sf hat einige spezielle Funktionen, aber nicht die grundlegenden. Es kann jedoch lehrreich sein, sich die Quelle von log1p anzusehen: http://www.boost.org/doc/libs/1_58_0/libs/math/doc/html/math_toolkit/powers/log1p.html

Es gibt auch Open-Source-Arithmetikbibliotheken mit beliebiger Genauigkeit wie mpfr, die aufgrund der höheren erforderlichen Genauigkeit möglicherweise andere Algorithmen als libm verwenden.

9. Highams Genauigkeit und Stabilität numerischer Algorithmen ist eine gute Einführung in die Analyse von Fehlern numerischer Algorithmen auf höherer Ebene. Für Approximationsalgorithmen selbst ist Approximationstheorie Approximation Practice von Trefethen eine gute Referenz.

10. Ich weiß, dass dies etwas zu oft gesagt wird, aber relativ große Softwareprojekte hängen selten davon ab, dass die Laufzeit einer kleinen Funktion immer wieder aufgerufen wird. Es ist nicht so üblich, sich um die Leistung des Protokolls sorgen zu müssen, es sei denn, Sie haben Ihr Programm profiliert und sichergestellt, dass es wichtig ist.


26414e15

1.13e13term

 1e8

1
k=11071lnk

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

5

Kirills Antwort berührte bereits eine Vielzahl relevanter Themen. Ich möchte einige davon auf der Grundlage praktischer Erfahrungen im Entwurf von Mathematikbibliotheken erweitern. Ein Hinweis im Vorfeld: Entwickler von Mathematikbibliotheken verwenden in der Regel jede veröffentlichte algorithmische Optimierung sowie viele maschinenspezifische Optimierungen, von denen nicht alle veröffentlicht werden. Der Code wird häufig in Assemblersprache geschrieben, anstatt kompilierten Code zu verwenden. Es ist daher unwahrscheinlich, dass eine einfache und kompilierte Implementierung mehr als 75% der Leistung einer vorhandenen hochwertigen Mathematikbibliotheksimplementierung erzielt, wenn identische Funktionssätze vorausgesetzt werden (Genauigkeit, Behandlung von Sonderfällen, Fehlerberichterstattung, Unterstützung im Rundungsmodus).

explogerfcΓ

Die Genauigkeit wird in der Regel durch Vergleich mit einer Referenz von (Drittanbietern) mit höherer Genauigkeit bewertet. Funktionen mit einfacher Genauigkeit und einfachem Argument können leicht ausführlich getestet werden, andere Funktionen erfordern das Testen mit (gerichteten) zufälligen Testvektoren. Natürlich kann man nicht unendlich genaue Referenzergebnisse berechnen, aber die Untersuchung des Table-Maker-Dilemmas legt nahe, dass es für viele einfache Funktionen ausreicht, eine Referenz mit einer Genauigkeit von etwa dem Dreifachen der Zielgenauigkeit zu berechnen. Siehe zum Beispiel:

Vincent Lefèvre, Jean-Michel Müller, "Schlimmste Fälle für eine korrekte Rundung der Elementarfunktionen in doppelter Präzision". In Proceedings 15. IEEE Symposium on Computer Arithmetic , 2001, 111-118). (Preprint online)

In Bezug auf die Leistung muss zwischen der Optimierung der Latenz (wichtig, wenn man die Ausführungszeit abhängiger Operationen betrachtet) und der Optimierung des Durchsatzes (relevant, wenn die Ausführungszeit unabhängiger Operationen berücksichtigt wird) unterschieden werden. In den letzten zwanzig Jahren hat die Verbreitung von Hardware-Parallelisierungstechniken wie Parallelität auf Befehlsebene (z. B. superskalar, Prozessoren außerhalb der Reihenfolge), Parallelität auf Datenebene (z. B. SIMD-Befehle) und Parallelität auf Thread-Ebene (z. B. Hyper-Threading) zugenommen. Multi-Core-Prozessoren) hat dazu geführt, dass der Rechendurchsatz als relevantere Metrik im Vordergrund steht.

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

Die Fused Multiply-Add-Operation ( FMA ), die vor 25 Jahren von IBM eingeführt wurde und jetzt auf allen wichtigen Prozessorarchitekturen verfügbar ist, ist ein entscheidender Baustein für die Implementierung moderner Mathematikbibliotheken. Es bietet eine Reduzierung von Rundungsfehlern, einen begrenzten Schutz gegen subtraktive Löschung und vereinfacht die Doppel-Doppel-Arithmetik erheblich .

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) Wissen Sie, ob die gängigen Open-Source-Implementierungen (wie openlibm) so gut wie möglich sind oder ob ihre speziellen Funktionen verbessert werden können?
Kirill

1
@Kirill Zuletzt habe ich mir Open-Source-Implementierungen angesehen (vor vielen Jahren), sie haben die Vorteile der FMA nicht genutzt. Zu der Zeit waren IBM Power und Intel Itanium die einzigen Architekturen, die den Betrieb beinhalteten. Jetzt ist die Hardwareunterstützung dafür allgegenwärtig. Auch Tabellen-plus-Polynom-Näherungen waren damals Stand der Technik, jetzt sind Tabellen ungünstig: Speicherzugriff führt zu einem höheren Energieverbrauch, sie können (und tun) die Vektorisierung stören, und der Rechendurchsatz hat mehr zugenommen als der Speicherdurchsatz Dies führt zu möglichen negativen Auswirkungen auf die Leistung von Tabellen.
Njuffa
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.