Wie kann ein const expr so schnell ausgewertet werden?

13

Ich habe const-Ausdrücke ausprobiert, die zur Kompilierungszeit ausgewertet werden. Aber ich habe mit einem Beispiel gespielt, das unglaublich schnell erscheint, wenn es zur Kompilierungszeit ausgeführt wird.

#include<iostream> 

constexpr long int fib(int n) { 
    return (n <= 1)? n : fib(n-1) + fib(n-2); 
} 

int main () {  
    long int res = fib(45); 
    std::cout << res; 
    return 0; 
} 

Wenn ich diesen Code ausführe, dauert die Ausführung ca. 7 Sekunden. So weit, ist es gut. Aber wenn ich dazu wechsle long int res = fib(45), const long int res = fib(45)dauert es nicht einmal eine Sekunde. Nach meinem Verständnis wird es zur Kompilierungszeit ausgewertet. Die Kompilierung dauert jedoch ca. 0,3 Sekunden

Wie kann der Compiler dies so schnell auswerten, aber zur Laufzeit dauert es so viel länger? Ich benutze gcc 5.4.0.

Peter234
quelle
7
Ich vermute, dass der Compiler die Funktionsaufrufe zwischenspeichert fib. Die Implementierung der Fibonacci-Zahlen, die Sie oben haben, ist vorab ziemlich langsam. Versuchen Sie, die Funktionswerte im Laufzeitcode zwischenzuspeichern, und dies wird viel schneller.
n314159
4
Diese rekursive Fibonacci ist furchtbar ineffizient (sie hat eine exponentielle Laufzeit). Ich vermute daher, dass die Bewertung der Kompilierungszeit klüger ist und die Berechnung optimiert.
Blaze
1
@AlanBirtles Ja, ich habe es mit -O3 kompiliert.
Peter234
1
Ich gehe davon aus, dass der Compiler Cache-Funktion aufruft, die Funktion muss nur 46 Mal (einmal für jedes mögliche Argument 0-45) anstatt 2 ^ 45 Mal eveluiert werden. Ich weiß jedoch nicht, ob gcc so funktioniert.
Churill
3
@Someprogrammerdude Ich weiß. Aber wie kann die Kompilierung so schnell sein, wenn die Auswertung zur Laufzeit so viel Zeit in Anspruch nimmt?
Peter234

Antworten:

5

Der Compiler speichert kleinere Werte zwischen und muss nicht so viel neu berechnen wie die Laufzeitversion.
(Der Optimierer ist sehr gut und generiert viel Code, einschließlich Tricks mit Sonderfällen, die für mich unverständlich sind. Die naiven 2 ^ 45-Rekursionen würden Stunden dauern.)

Wenn Sie auch vorherige Werte speichern:

int cache[100] = {1, 1};

long int fib(int n) {
    int res = cache[n];
    return res ? res : (cache[n] = fib(n-1) + fib(n-2));
} 

Die Laufzeitversion ist viel schneller als der Compiler.

molbdnilo
quelle
Es gibt keine Möglichkeit, ein zweimaliges Rekursieren zu vermeiden, es sei denn, Sie führen ein Caching durch. Denken Sie, dass der Optimierer etwas Caching implementiert? Können Sie dies in der Compiler-Ausgabe anzeigen, da dies wirklich interessant wäre?
Suma
... es ist auch möglich, dass der Compiler anstelle des Cachings eine Beziehung zwischen fib (n-2) und fib (n-1) nachweisen kann und statt fib (n-1) fib (n-2) aufruft ) Wert, um das zu berechnen. Ich denke, das stimmt mit dem überein, was ich in der Ausgabe von 5.4 sehe, wenn constexpr entfernt und -O2 verwendet wird.
Suma
1
Haben Sie einen Link oder eine andere Quelle, die erklärt, welche Optimierungen zur Kompilierungszeit vorgenommen werden können?
Peter234
Solange das beobachtbare Verhalten unverändert bleibt, kann der Optimierer fast alles tun. Die gegebene fibFunktion hat keine Nebenwirkungen (verweist auf keine externen Variablen, die Ausgabe hängt nur von den Eingaben ab). Mit einem cleveren Optimierer kann viel getan werden.
Suma
@Suma Es ist kein Problem, nur einmal zu rekursieren. Da es eine iterative Version gibt, gibt es natürlich auch eine rekursive Version, die zum Beispiel die Schwanzrekursion verwendet.
Ctx
1

Vielleicht finden Sie mit 5.4 interessant, dass die Funktion nicht vollständig eliminiert ist, dafür benötigen Sie mindestens 6.1.

Ich glaube nicht, dass Caching stattfindet. Ich bin überzeugt, dass der Optimierer klug genug ist, um die Beziehung zwischen fib(n - 2)und zu beweisen fib(n-1)und den zweiten Aufruf vollständig zu vermeiden. Dies ist die GCC 5.4-Ausgabe (von Godbolt erhalten) mit no constexprund -O2:

fib(long):
        cmp     rdi, 1
        push    r12
        mov     r12, rdi
        push    rbp
        push    rbx
        jle     .L4
        mov     rbx, rdi
        xor     ebp, ebp
.L3:
        lea     rdi, [rbx-1]
        sub     rbx, 2
        call    fib(long)
        add     rbp, rax
        cmp     rbx, 1
        jg      .L3
        and     r12d, 1
.L2:
        lea     rax, [r12+rbp]
        pop     rbx
        pop     rbp
        pop     r12
        ret
.L4:
        xor     ebp, ebp
        jmp     .L2

Ich muss zugeben, dass ich die Ausgabe mit -O3 nicht verstehe - der generierte Code ist überraschend komplex, mit vielen Speicherzugriffen und Zeigerarithmetik, und es ist durchaus möglich, dass mit diesen Einstellungen etwas Caching (Memoization) durchgeführt wird.

Suma
quelle
Ich glaube ich liege falsch. Bei .L3 gibt es eine Schleife, und die Fib durchläuft alle unteren Fibs. Mit -O2 ist es immer noch exponentiell.
Suma