Muy simple, ¿qué es la optimización de llamadas de cola?
Más específicamente, ¿cuáles son algunos pequeños fragmentos de código en los que se podría aplicar y, en caso contrario, con una explicación de por qué?
Muy simple, ¿qué es la optimización de llamadas de cola?
Más específicamente, ¿cuáles son algunos pequeños fragmentos de código en los que se podría aplicar y, en caso contrario, con una explicación de por qué?
Respuestas:
La optimización de llamada de cola es donde puede evitar asignar un nuevo marco de pila para una función porque la función de llamada simplemente devolverá el valor que obtiene de la función llamada. El uso más común es la recursividad de cola, donde una función recursiva escrita para aprovechar la optimización de llamada de cola puede usar espacio de pila constante.
Scheme es uno de los pocos lenguajes de programación que garantiza en la especificación que cualquier implementación debe proporcionar esta optimización (JavaScript también lo hace, comenzando con ES6) , así que aquí hay dos ejemplos de la función factorial en Scheme:
(define (fact x)
(if (= x 0) 1
(* x (fact (- x 1)))))
(define (fact x)
(define (fact-tail x accum)
(if (= x 0) accum
(fact-tail (- x 1) (* x accum))))
(fact-tail x 1))
La primera función no es recursiva de cola porque cuando se realiza la llamada recursiva, la función necesita realizar un seguimiento de la multiplicación que necesita hacer con el resultado después de que la llamada regresa. Como tal, la pila tiene el siguiente aspecto:
(fact 3)
(* 3 (fact 2))
(* 3 (* 2 (fact 1)))
(* 3 (* 2 (* 1 (fact 0))))
(* 3 (* 2 (* 1 1)))
(* 3 (* 2 1))
(* 3 2)
6
Por el contrario, el seguimiento de la pila para el factor recursivo de cola se ve de la siguiente manera:
(fact 3)
(fact-tail 3 1)
(fact-tail 2 3)
(fact-tail 1 6)
(fact-tail 0 6)
6
Como puede ver, solo necesitamos hacer un seguimiento de la misma cantidad de datos para cada llamada a la cola de hechos porque simplemente estamos devolviendo el valor que obtenemos a la cima. Esto significa que incluso si tuviera que llamar (hecho 1000000), solo necesito la misma cantidad de espacio que (hecho 3). Este no es el caso con el hecho no recursivo de la cola, y como tales valores grandes pueden causar un desbordamiento de la pila.
Veamos un ejemplo simple: la función factorial implementada en C.
Comenzamos con la definición recursiva obvia
unsigned fac(unsigned n)
{
if (n < 2) return 1;
return n * fac(n - 1);
}
Una función termina con una llamada de cola si la última operación antes de que la función regrese es otra llamada de función. Si esta llamada invoca la misma función, es recursiva de cola.
A pesar fac()
de que a primera vista parece recursiva, no es que lo que realmente sucede es
unsigned fac(unsigned n)
{
if (n < 2) return 1;
unsigned acc = fac(n - 1);
return n * acc;
}
es decir, la última operación es la multiplicación y no la llamada a la función.
Sin embargo, es posible reescribir fac()
para ser recursivo de cola pasando el valor acumulado hacia abajo en la cadena de llamadas como un argumento adicional y pasando solo el resultado final nuevamente como el valor de retorno
unsigned fac(unsigned n)
{
return fac_tailrec(1, n);
}
unsigned fac_tailrec(unsigned acc, unsigned n)
{
if (n < 2) return acc;
return fac_tailrec(n * acc, n - 1);
}
Ahora, ¿por qué es útil? Como volvemos inmediatamente después de la llamada de cola, podemos descartar el stackframe anterior antes de invocar la función en posición de cola o, en caso de funciones recursivas, reutilizar el stackframe tal cual.
La optimización de llamada de cola transforma nuestro código recursivo en
unsigned fac_tailrec(unsigned acc, unsigned n)
{
TOP:
if (n < 2) return acc;
acc = n * acc;
n = n - 1;
goto TOP;
}
Esto se puede incorporar fac()
y llegamos a
unsigned fac(unsigned n)
{
unsigned acc = 1;
TOP:
if (n < 2) return acc;
acc = n * acc;
n = n - 1;
goto TOP;
}
que es equivalente a
unsigned fac(unsigned n)
{
unsigned acc = 1;
for (; n > 1; --n)
acc *= n;
return acc;
}
Como podemos ver aquí, un optimizador suficientemente avanzado puede reemplazar la recursividad de cola con iteración, lo cual es mucho más eficiente ya que evita la sobrecarga de llamadas de función y solo usa una cantidad constante de espacio de pila.
TCO (Tail Call Optimization) es el proceso mediante el cual un compilador inteligente puede realizar una llamada a una función y no ocupa espacio adicional en la pila. La única situación en la que esto sucede es si la última instrucción ejecutada en una función f es una llamada a una función g (Nota: g puede ser f ). La clave aquí es que f ya no necesita espacio en la pila: simplemente llama a gy luego devuelve lo que g devolvería. En este caso, se puede hacer la optimización de que g simplemente se ejecuta y devuelve cualquier valor que tenga para lo que se llama f.
Esta optimización puede hacer que las llamadas recursivas tomen un espacio de pila constante, en lugar de explotar.
Ejemplo: esta función factorial no es TCOptimizable:
def fact(n):
if n == 0:
return 1
return n * fact(n-1)
Esta función hace cosas además de llamar a otra función en su declaración de retorno.
La siguiente función es TCOptimizable:
def fact_h(n, acc):
if n == 0:
return acc
return fact_h(n-1, acc*n)
def fact(n):
return fact_h(n, 1)
Esto se debe a que lo último que sucede en cualquiera de estas funciones es llamar a otra función.
Probablemente la mejor descripción de alto nivel que he encontrado para llamadas de cola, llamadas de cola recursivas y optimización de llamadas de cola es la publicación del blog
"Qué diablos es: una llamada de cola"
por Dan Sugalski. Sobre la optimización de la llamada de cola, escribe:
Considere, por un momento, esta función simple:
sub foo (int a) { a += 15; return bar(a); }
Entonces, ¿qué puedes hacer tú, o más bien tu compilador de idiomas? Bueno, lo que puede hacer es convertir el código del formulario
return somefunc();
en la secuencia de bajo nivelpop stack frame; goto somefunc();
. En nuestro ejemplo, eso significa que antes de llamarbar
, sefoo
limpia y luego, en lugar de llamarbar
como una subrutina, hacemos unagoto
operación de bajo nivel al comienzo debar
.Foo
Ya se ha limpiado de la pila, por lo que cuandobar
comienza parece que quien llamófoo
realmente ha llamadobar
, y cuandobar
devuelve su valor, lo devuelve directamente a quien llamófoo
, en lugar de devolverlo afoo
quien luego lo devolvería a su interlocutor.
Y en la recursividad de la cola:
La recursión de cola ocurre si una función, como su última operación, devuelve el resultado de llamarse a sí misma . La recursión de la cola es más fácil de manejar porque, en lugar de tener que saltar al comienzo de alguna función aleatoria en algún lugar, simplemente debes volver al principio de ti mismo, lo cual es algo muy simple.
Para que esto:
sub foo (int a, int b) { if (b == 1) { return a; } else { return foo(a*a + a, b - 1); }
se convierte silenciosamente en:
sub foo (int a, int b) { label: if (b == 1) { return a; } else { a = a*a + a; b = b - 1; goto label; }
Lo que me gusta de esta descripción es lo sucinto y fácil que es comprender a aquellos que provienen de un entorno de lenguaje imperativo (C, C ++, Java)
foo
función inicial no está optimizada? Solo está llamando a una función como su último paso, y simplemente está devolviendo ese valor, ¿verdad?
Tenga en cuenta en primer lugar que no todos los idiomas lo admiten.
El TCO se aplica a un caso especial de recursión. Lo esencial es que, si lo último que hace en una función es llamarse a sí mismo (por ejemplo, se llama a sí mismo desde la posición de "cola"), esto puede ser optimizado por el compilador para actuar como iteración en lugar de recursión estándar.
Verá, normalmente durante la recursividad, el tiempo de ejecución necesita realizar un seguimiento de todas las llamadas recursivas, de modo que cuando uno regrese pueda reanudarse en la llamada anterior, y así sucesivamente. (Intente escribir manualmente el resultado de una llamada recursiva para tener una idea visual de cómo funciona). Hacer un seguimiento de todas las llamadas ocupa espacio, lo que se vuelve significativo cuando la función se llama mucho a sí misma. Pero con TCO, solo puede decir "volver al principio, solo que esta vez cambiar los valores de los parámetros a estos nuevos". Puede hacerlo porque nada después de la llamada recursiva se refiere a esos valores.
foo
método inicial no está optimizado?
Ejemplo de ejecución mínima de GCC con análisis de desmontaje x86
Veamos cómo GCC puede hacer automáticamente las optimizaciones de llamadas de cola mirando el ensamblaje generado.
Esto servirá como un ejemplo extremadamente concreto de lo que se mencionó en otras respuestas como https://stackoverflow.com/a/9814654/895245 de que la optimización puede convertir las llamadas de funciones recursivas en un bucle.
Esto a su vez ahorra memoria y mejora el rendimiento, ya que los accesos a la memoria son a menudo lo principal que hace que los programas sean lentos hoy en día .
Como entrada, le damos a GCC un factorial basado en una pila ingenua no optimizada:
tail_call.c
#include <stdio.h>
#include <stdlib.h>
unsigned factorial(unsigned n) {
if (n == 1) {
return 1;
}
return n * factorial(n - 1);
}
int main(int argc, char **argv) {
int input;
if (argc > 1) {
input = strtoul(argv[1], NULL, 0);
} else {
input = 5;
}
printf("%u\n", factorial(input));
return EXIT_SUCCESS;
}
Compilar y desmontar:
gcc -O1 -foptimize-sibling-calls -ggdb3 -std=c99 -Wall -Wextra -Wpedantic \
-o tail_call.out tail_call.c
objdump -d tail_call.out
donde -foptimize-sibling-calls
es el nombre de generalización de las llamadas de cola de acuerdo con man gcc
:
-foptimize-sibling-calls
Optimize sibling and tail recursive calls.
Enabled at levels -O2, -O3, -Os.
como se menciona en: ¿Cómo verifico si gcc está realizando la optimización de recursión de cola?
Elijo -O1
porque:
-O0
. Sospecho que esto se debe a que faltan las transformaciones intermedias requeridas.-O3
produce un código impío y eficiente que no sería muy educativo, aunque también está optimizado.Desmontaje con -fno-optimize-sibling-calls
:
0000000000001145 <factorial>:
1145: 89 f8 mov %edi,%eax
1147: 83 ff 01 cmp $0x1,%edi
114a: 74 10 je 115c <factorial+0x17>
114c: 53 push %rbx
114d: 89 fb mov %edi,%ebx
114f: 8d 7f ff lea -0x1(%rdi),%edi
1152: e8 ee ff ff ff callq 1145 <factorial>
1157: 0f af c3 imul %ebx,%eax
115a: 5b pop %rbx
115b: c3 retq
115c: c3 retq
Con -foptimize-sibling-calls
:
0000000000001145 <factorial>:
1145: b8 01 00 00 00 mov $0x1,%eax
114a: 83 ff 01 cmp $0x1,%edi
114d: 74 0e je 115d <factorial+0x18>
114f: 8d 57 ff lea -0x1(%rdi),%edx
1152: 0f af c7 imul %edi,%eax
1155: 89 d7 mov %edx,%edi
1157: 83 fa 01 cmp $0x1,%edx
115a: 75 f3 jne 114f <factorial+0xa>
115c: c3 retq
115d: 89 f8 mov %edi,%eax
115f: c3 retq
La diferencia clave entre los dos es que:
los -fno-optimize-sibling-calls
usos callq
, que es la llamada de función típica no optimizada.
Esta instrucción empuja la dirección de retorno a la pila, por lo tanto, la aumenta.
Además, esta versión también lo hace push %rbx
, lo que empuja %rbx
a la pila .
GCC hace esto porque almacena edi
, que es el primer argumento de función ( n
) ebx
, luego llama factorial
.
GCC necesita hacer esto porque se está preparando para otra llamada a factorial
, que utilizará la nueva edi == n-1
.
Elige ebx
porque este registro está guardado por la persona que llama: los registros se conservan a través de una llamada a la función linux x86-64 para que la llamada secundaria a factorial
no lo cambie y pierda n
.
el -foptimize-sibling-calls
no utiliza ningún instrucciones que empujan a la pila: sólo lo hace goto
salta dentro factorial
de las instrucciones je
y jne
.
Por lo tanto, esta versión es equivalente a un ciclo while, sin ninguna llamada a la función. El uso de la pila es constante.
Probado en Ubuntu 18.10, GCC 8.2.
Mira aquí:
http://tratt.net/laurie/tech_articles/articles/tail_call_optimization
Como probablemente sepa, las llamadas a funciones recursivas pueden causar estragos en una pila; Es fácil quedarse rápidamente sin espacio de pila. La optimización de llamadas de cola es la forma en que puede crear un algoritmo de estilo recursivo que utiliza espacio de pila constante, por lo tanto, no crece y crece y obtiene errores de pila.
Deberíamos asegurarnos de que no haya declaraciones de goto en la función en sí misma. Cuidamos que la llamada a la función sea lo último en la función de llamada.
Las recursiones a gran escala pueden usar esto para optimizaciones, pero en pequeña escala, la sobrecarga de instrucciones para hacer que la función llame una llamada de cola reduce el propósito real.
El TCO puede causar una función que se ejecuta para siempre:
void eternity()
{
eternity();
}
El enfoque de la función recursiva tiene un problema. Construye una pila de llamadas de tamaño O (n), lo que hace que nuestra memoria total cueste O (n). Esto lo hace vulnerable a un error de desbordamiento de pila, donde la pila de llamadas se vuelve demasiado grande y se queda sin espacio.
Esquema de optimización de llamada de cola (TCO). Donde puede optimizar las funciones recursivas para evitar la acumulación de una gran pila de llamadas y, por lo tanto, ahorra el costo de la memoria.
Hay muchos lenguajes que están haciendo TCO como (JavaScript, Ruby y algunos C) mientras que Python y Java no hacen TCO.
El lenguaje JavaScript ha confirmado usando :) http://2ality.com/2015/06/tail-call-optimization.html
En un lenguaje funcional, la optimización de la llamada de cola es como si una llamada de función pudiera devolver una expresión parcialmente evaluada como resultado, que luego sería evaluada por la persona que llama.
f x = g x
f 6 se reduce a g 6. Entonces, si la implementación pudiera devolver g 6 como resultado, y luego llamar a esa expresión, guardaría un marco de pila.
también
f x = if c x then g x else h x.
Reduce a f 6 a g 6 o h 6. Entonces, si la implementación evalúa c 6 y encuentra que es cierto, entonces puede reducir,
if true then g x else h x ---> g x
f x ---> h x
Un simple intérprete de optimización de llamada no final podría verse así,
class simple_expresion
{
...
public:
virtual ximple_value *DoEvaluate() const = 0;
};
class simple_value
{
...
};
class simple_function : public simple_expresion
{
...
private:
simple_expresion *m_Function;
simple_expresion *m_Parameter;
public:
virtual simple_value *DoEvaluate() const
{
vector<simple_expresion *> parameterList;
parameterList->push_back(m_Parameter);
return m_Function->Call(parameterList);
}
};
class simple_if : public simple_function
{
private:
simple_expresion *m_Condition;
simple_expresion *m_Positive;
simple_expresion *m_Negative;
public:
simple_value *DoEvaluate() const
{
if (m_Condition.DoEvaluate()->IsTrue())
{
return m_Positive.DoEvaluate();
}
else
{
return m_Negative.DoEvaluate();
}
}
}
Un intérprete de optimización de llamada de cola podría verse así,
class tco_expresion
{
...
public:
virtual tco_expresion *DoEvaluate() const = 0;
virtual bool IsValue()
{
return false;
}
};
class tco_value
{
...
public:
virtual bool IsValue()
{
return true;
}
};
class tco_function : public tco_expresion
{
...
private:
tco_expresion *m_Function;
tco_expresion *m_Parameter;
public:
virtual tco_expression *DoEvaluate() const
{
vector< tco_expression *> parameterList;
tco_expression *function = const_cast<SNI_Function *>(this);
while (!function->IsValue())
{
function = function->DoCall(parameterList);
}
return function;
}
tco_expresion *DoCall(vector<tco_expresion *> &p_ParameterList)
{
p_ParameterList.push_back(m_Parameter);
return m_Function;
}
};
class tco_if : public tco_function
{
private:
tco_expresion *m_Condition;
tco_expresion *m_Positive;
tco_expresion *m_Negative;
tco_expresion *DoEvaluate() const
{
if (m_Condition.DoEvaluate()->IsTrue())
{
return m_Positive;
}
else
{
return m_Negative;
}
}
}