¿Cambio de la clase de complejidad a través de la optimización del compilador?

8

Estoy buscando un ejemplo en el que un algoritmo aparentemente está cambiando su clase de complejidad debido a las estrategias de optimización del compilador y / o procesador.

Lorenz Lo Sauer
fuente
2
Esto definitivamente podría suceder para la complejidad del espacio con algo como eliminar la recursividad de la cola.
Eliot Ball
44
Simple: un bucle vacío O (n) puede optimizarse lejos O (1): vea esta publicación SO: stackoverflow.com/questions/10300253/…
Doc Brown
Es más fácil encontrar ejemplos de esto en Haskell, aunque no es realmente una optimización, solo la semántica perezosa del lenguaje, lo que significa que no se evaluarán fragmentos de código potencialmente grandes para las funciones que se llamaron porque los resultados nunca se usan. Incluso es bastante común en Haskell definir funciones recursivas ilimitadas que devuelven listas infinitas. Siempre y cuando solo use un fragmento finito de la lista, solo se evalúa una cantidad finita de la recursión (por extraño que parezca, posiblemente de forma no recursiva) y solo se calcula esa parte finita de la lista.
Steve314
1
@ Steve314: Creo que hubo un ejemplo de esto en el Computer Benchmark Game, donde la implementación de Haskell fue 10 veces más rápida que la implementación de C debido al simple hecho de que los resultados del benchmark nunca se imprimieron y, por lo tanto, todo el programa de Haskell esencialmente compilado aint main(void) { exit(0); };
Jörg W Mittag
@ Jörg: no me sorprendería, pero creo que los desarrolladores de Haskell estaban haciendo trampa. Usted puede forzar algo que debe ser evaluado con entusiasmo en Haskell si es necesario, y por extraño que parezca no es principalmente para la optimización - estricta evaluación / ganas es a menudo mucho más rápido de lo vago, ya que evita los gastos generales para el aplazamiento de la evaluación. Un buen compilador de Haskell capta mucho de esto en sí mismo mediante el "análisis de rigor", pero hay momentos en los que tiene que forzar el problema.
Steve314

Respuestas:

4

Tomemos un programa simple que imprime el cuadrado de un número ingresado en la línea de comando.

#include <stdio.h>

int main(int argc, char **argv) {
    int num = atoi(argv[1]);
    printf("%d\n",num);
    int i = 0;
    int total = 0;
    for(i = 0; i < num; i++) {
        total += num;
    }
    printf("%d\n",total);
    return 0;
}

Como puede ver, este es un cálculo de O (n), que se repite una y otra vez.

Compilar esto con gcc -Suno obtiene un segmento que es:

LBB1_1:
        movl    -36(%rbp), %eax
        movl    -28(%rbp), %ecx
        addl    %ecx, %eax
        movl    %eax, -36(%rbp)
        movl    -32(%rbp), %eax
        addl    $1, %eax
        movl    %eax, -32(%rbp)
LBB1_2:
        movl    -32(%rbp), %eax
        movl    -28(%rbp), %ecx
        cmpl    %ecx, %eax
        jl      LBB1_1

En esto, puede ver la adición realizada, una comparación y un salto hacia atrás para el ciclo.

Haciendo la compilación gcc -S -O3para obtener optimizaciones del segmento entre las llamadas a printf:

        callq   _printf
        testl   %ebx, %ebx
        jg      LBB1_2
        xorl    %ebx, %ebx
        jmp     LBB1_3
LBB1_2:
        imull   %ebx, %ebx
LBB1_3:
        movl    %ebx, %esi
        leaq    L_.str(%rip), %rdi
        xorb    %al, %al
        callq   _printf

Ahora se puede ver que no tiene bucle y, además, no se agrega. En cambio, hay una llamada a la imullque multiplica el número por sí mismo.

El compilador ha reconocido un bucle y el operador matemático dentro y lo ha reemplazado por el cálculo adecuado.


Tenga en cuenta que esto incluyó una llamada a atoipara obtener el número. Cuando el número ya existe en el código, el cumplidor calculará previamente el valor en lugar de realizar llamadas reales como se demuestra en una comparación entre el rendimiento de sqrt en C # y C donde sqrt(2)(una constante) se sumaba en un bucle 1,000,000 de veces.

Comunidad
fuente
7

Tail Call Optimization puede reducir la complejidad del espacio. Por ejemplo, sin TCO, esta implementación recursiva de un whilebucle tiene una complejidad espacial en el peor de los casos Ο(#iterations), mientras que con TCO tiene una complejidad espacial en el peor de los casos Ο(1):

// This is Scala, but it works the same way in every other language.
def loop(cond: => Boolean)(body: => Unit): Unit = if (cond) { body; loop(cond)(body) }

var i = 0
loop { i < 3 } { i += 1; println(i) }
// 1
// 2
// 3

// E.g. ECMAScript:
function loop(cond, body) {
    if (cond()) { body(); loop(cond, body); };
};

var i = 0;
loop(function { return i < 3; }, function { i++; print(i); });

Esto ni siquiera necesita un TCO general, solo necesita un caso especial muy estrecho, a saber, la eliminación de la recursión directa de la cola.

Sin embargo, lo que sería muy interesante es cuando la optimización del compilador no solo cambia la clase de complejidad sino que también cambia el algoritmo por completo.

El glorioso compilador de Glasgow Haskell a veces hace esto, pero eso no es realmente de lo que estoy hablando, es más como hacer trampa. GHC tiene un lenguaje de coincidencia de patrones simple que permite al desarrollador de la biblioteca detectar algunos patrones de código simples y reemplazarlos con un código diferente. Y la implementación de GHC de la biblioteca estándar de Haskell no contiene algunas de esas anotaciones, para que los usos específicos de las funciones específicas que son conocidos por ser ineficiente se vuelven a escribir en versiones más eficientes.

Sin embargo, estas traducciones están escritas por humanos, y están escritas para casos específicos, por eso considero que hacer trampa.

Un Supercompilador puede cambiar el algoritmo sin intervención humana, pero AFAIK no se ha construido ningún supercompilador a nivel de producción.

Jörg W Mittag
fuente
Gracias por el gran ejemplo y por mencionar a GHC. Una pregunta más: ¿Qué pasa con la ejecución fuera de orden? ¿Hay algún ejemplo conocido en el que este tipo de optimización condujo a un cambio en la clase de complejidad de un algoritmo?
Lorenz Lo Sauer
0

Un compilador que es consciente de que el lenguaje está usando la reducción de fuerza de gran número (reemplazando las multiplicaciones por el índice de un ciclo por una suma) cambiaría la complejidad de esa multiplicación de O (n log n) en el mejor de los casos a O (n) .

Un programador
fuente