¿Cómo se puede evaluar un const expr tan rápido?

13

He estado probando expresiones constantes que se evalúan en tiempo de compilación. Pero jugué con un ejemplo que parece increíblemente rápido cuando se ejecuta en tiempo de compilación.

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

Cuando ejecuto este código, tarda unos 7 segundos en ejecutarse. Hasta aquí todo bien. Pero cuando me cambio long int res = fib(45)a const long int res = fib(45)ella no lleva ni un segundo. A mi entender, se evalúa en tiempo de compilación. Pero la compilación tarda unos 0.3 segundos

¿Cómo puede el compilador evaluar esto tan rápido, pero en tiempo de ejecución lleva mucho más tiempo? Estoy usando gcc 5.4.0.

Peter234
fuente
77
Supongo que el compilador almacena en caché las llamadas a la función fib. La implementación de los números de Fibonacci que tiene arriba es muy lenta. Intente almacenar en caché los valores de la función en el código de tiempo de ejecución y será mucho más rápido.
n314159
44
Este fibonacci recursivo es terriblemente ineficiente (tiene un tiempo de ejecución exponencial), por lo que supongo que la evaluación del tiempo de compilación es más inteligente que esto y optimiza el cálculo.
Blaze
1
@AlanBirtles Sí, lo compilé con -O3.
Peter234
1
Supongo que la función de caché del compilador llama a la función solo debe ser evelizada 46 veces (una por cada posible argumento 0-45) en lugar de 2 ^ 45 veces. Sin embargo, no sé si gcc funciona así.
churill
3
@Someprogrammerdude lo sé. Pero, ¿cómo puede ser tan rápida la compilación cuando la evaluación lleva tanto tiempo en tiempo de ejecución?
Peter234

Respuestas:

5

El compilador almacena en caché valores más pequeños y no necesita volver a calcular tanto como la versión de tiempo de ejecución.
(El optimizador es muy bueno y genera mucho código, incluyendo trucos con casos especiales que son incomprensibles para mí; las ingenuas 2 ^ 45 recursiones tomarían horas).

Si también almacena valores anteriores:

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

La versión de tiempo de ejecución es mucho más rápida que el compilador.

molbdnilo
fuente
No hay forma de evitar recurrir dos veces, a menos que haga un almacenamiento en caché. ¿Crees que el optimizador implementa algo de almacenamiento en caché? ¿Eres capaz de mostrar esto en la salida del compilador, ya que eso sería realmente interesante?
Suma
... también es posible que el compilador en lugar del almacenamiento en caché sea capaz de probar alguna relación entre fib (n-2) y fib (n-1) y en lugar de llamar a fib (n-1) que usa para fib (n-2 ) valor para calcular eso. Creo que coincide con lo que veo en la salida de 5.4 al eliminar constexpr y usar -O2.
Suma
1
¿Tiene un enlace u otra fuente que explica qué optimizaciones se pueden hacer en tiempo de compilación?
Peter234
Mientras el comportamiento observable no cambie, el optimizador es libre de hacer casi cualquier cosa. La fibfunción dada no tiene efectos secundarios (no hace referencia a variables externas, la salida depende solo de las entradas), con un optimizador inteligente se puede hacer mucho.
Suma
@Suma No hay problema para recurrir solo una vez. Como existe una versión iterativa, por supuesto, también hay una versión recursiva, que utiliza, por ejemplo, la recursividad de cola.
Ctx
1

Puede encontrar interesante con 5.4 la función no se elimina por completo, necesita al menos 6.1 para eso.

No creo que haya ningún almacenamiento en caché. Estoy convencido de que el optimizador es lo suficientemente inteligente como para demostrar la relación entre fib(n - 2)y fib(n-1)y evita la segunda llamada por completo. Esta es la salida GCC 5.4 (obtenida de godbolt) sin constexpry -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

Tengo que admitir que no entiendo la salida con -O3: el código generado es sorprendentemente complejo, con muchos accesos a la memoria y aritmética de puntero y es muy posible que haya algún almacenamiento en caché (memorización) con esa configuración.

Suma
fuente
Creo que estoy equivocado Hay un bucle en .L3, y el fib se repite sobre todos los fibs inferiores. Con -O2 sigue siendo exponencial.
Suma