Optimización de llamadas de cola en Mathematica?


Mientras formulaba una respuesta a otra pregunta SO, me encontré con un comportamiento extraño con respecto a la recursión de cola en Mathematica.

La documentación de Mathematica sugiere que se podría realizar una optimización de llamada de cola. Pero mis propios experimentos dan resultados contradictorios. Contrasta, por ejemplo, las dos expresiones siguientes. La primera falla el kernel 7.0.1, presumiblemente debido al agotamiento de la pila:

(* warning: crashes the kernel! *)
Module[{f, n = 0},
  f[x_] := (n += 1; f[x + 1]);
  TimeConstrained[Block[{$RecursionLimit = Infinity}, f[0]], 300, n]
]

El segundo se ejecuta hasta su finalización, parece explotar la optimización de llamadas de cola para devolver un resultado significativo:

Module[{f, n = 0},
  f[x_] := Null /; (n += 1; False);
  f[x_] := f[x + 1];
  TimeConstrained[Block[{$IterationLimit = Infinity}, f[0]], 300, n]
]

Ambas expresiones definen una función recursiva de cola f. En el caso de la primera función, Mathematica aparentemente considera la presencia de una sentencia compuesta lo suficiente como para derrotar cualquier posibilidad de optimización de llamada de cola. También tenga en cuenta que la primera expresión se rige por $RecursionLimit y la segunda por $IterationLimit a una señal de que Mathematica está tratando las dos expresiones de manera diferente. (Nota: la respuesta SO referenciada arriba tiene una función menos artificial que explota con éxito la optimización de llamadas de cola).

Entonces, la pregunta es: ¿alguien conoce las circunstancias bajo las cuales Mathematica realiza la optimización de llamadas de cola de funciones recursivas? Una referencia a una declaración definitiva en la documentación de Mathematica u otro material WRI sería ideal. La especulación también es bienvenida.

Author: Community, 2010-12-19

2 answers

Puedo resumir las conclusiones a las que me llevó mi experiencia personal, con una advertencia de que lo que sigue puede no ser la explicación completamente correcta. El anwer parece estar en las diferencias entre la pila de llamadas de Mathematica y las pilas de llamadas tradicionales, que se originan a partir de funciones definidas por patrones de Mathematica que son realmente reglas. Por lo tanto, no hay llamadas a funciones reales. Mathematica necesita una pila por una razón diferente: ya que la evaluación normal ocurre desde la parte inferior de una expresión árbol, debe mantener expresiones intermedias en caso de que partes cada vez más profundas de (sub)expresiones sean reemplazadas como resultado de la aplicación de reglas (algunas partes de una expresión crecen desde la parte inferior). Este es el caso, en particular, de las reglas que definen lo que llamaríamos funciones no recursivas de cola en otros lenguajes. Así que, una vez más, la pila en Mathematica es una pila de expresiones intermedias, no llamadas a funciones.

Esto significa que si, como resultado de la aplicación de la regla, un la (sub)expresión se puede reescribir en su totalidad, la rama de expresión no necesita mantenerse en la pila de expresiones. Esto es probablemente lo que se conoce como optimización de llamadas de cola en Mathematica - y esta es la razón por la que en tales casos tenemos iteración en lugar de recursión (este es un muy buen ejemplo de las diferencias entre las aplicaciones de reglas y las llamadas a funciones). Reglas como f[x_]:=f[x+1] son de este tipo. Sin embargo, si alguna subexpresión se reescribe, produciendo más estructura de expresión, entonces la expresión debe se almacenará en la pila. La regla f[x_ /; x < 5] := (n += 1; f[x + 1]) es de este tipo, que está un poco oculta hasta que recordemos que ()significa CompoundExpression[]. Esquemáticamente lo que sucede aquí es f[1] -> CompoundExpression[n+=1, f[2]] -> CompoundExpression[n+=1,CompoundExpression[n+=1,f[3]]]->etc. Aunque la llamada a f es la última cada vez, sucede antes de que se ejecute el CompoundExpression[] completo, por lo que debe mantenerse en la pila de expresiones. Tal vez se podría argumentar que este es un lugar donde se podría hacer la optimización, para hacer una excepción para CompoundExpression, pero esto probablemente no es fácil de implementar.

Ahora, para ilustrar el proceso de acumulación de pila que describí esquemáticamente anteriormente, limitemos el número de llamadas recursivas:

Clear[n, f, ff, fff];
n = 0;
f[x_ /; x < 5] := (n += 1; f[x + 1]);

ff[x_] := Null /; (n += 1; False);
ff[x_ /; x < 5] := ff[x + 1];

fff[x_ /; x < 5] := ce[n += 1, fff[x + 1]];

Rastreando la evaluación:

In[57]:= Trace[f[1],f]
Out[57]= {f[1],n+=1;f[1+1],{f[2],n+=1;f[2+1],{f[3],n+=1;f[3+1],{f[4],n+=1;f[4+1]}}}}

In[58]:= Trace[ff[1],ff]
Out[58]= {ff[1],ff[1+1],ff[2],ff[2+1],ff[3],ff[3+1],ff[4],ff[4+1],ff[5]}

In[59]:= Trace[fff[1],fff]
Out[59]= {fff[1],ce[n+=1,fff[1+1]],{fff[2],ce[n+=1,fff[2+1]],{fff[3],ce[n+=1,fff[3+1]],   
{fff[4],ce[n+=1,fff[4+1]]}}}}

Lo que puede ver de esto es que la pila de expresiones se acumula para f y fff (este último se usa solo para mostrar que este es un mecanismo general, con ce[] solo una cabeza arbitraria), pero no para ff, porque, para los propósitos de coincidencia de patrones, la definición para ff es una regla probada pero no coincidente, y la segunda definición reescribe ff[arg_] en su totalidad, y no genera sub-partes más profundas que necesitan más reescritura. Por lo tanto, la línea de fondo parece que debe analizar su función y ver si sus llamadas recursivas crecerán la expresión evaluada desde la parte inferior o no. En caso afirmativo, no es recursivo de cola en lo que respecta a Mathematica.

Mi respuesta no estaría completa sin mostrar cómo hacer la llamada de cola optimización manual. Como ejemplo, consideremos la implementación recursiva de Select. Trabajaremos con listas enlazadas de Mathematica para hacerlo razonablemente eficiente en lugar de un juguete. A continuación se muestra el código para la implementación no recursiva de cola:

Clear[toLinkedList, test, selrecBad, sel, selrec, selTR]
toLinkedList[x_List] := Fold[{#2, #1} &, {}, Reverse[x]];
selrecBad[fst_?test, rest_List] := {fst,If[rest === {}, {}, selrecBad @@ rest]};
selrecBad[fst_, rest_List] := If[rest === {}, {}, selrecBad @@ rest];
sel[x_List, testF_] := Block[{test = testF}, Flatten[selrecBad @@ toLinkedList[x]]]

La razón por la que uso Block y selrecBad es para facilitar el uso de Trace. Ahora, esto sopla la pila en mi máquina:

Block[{$RecursionLimit = Infinity}, sel[Range[300000], EvenQ]] // Short // Timing

Puede rastrear en pequeñas listas para ver por qué:

In[7]:= Trace[sel[Range[5],OddQ],selrecBad]

Out[7]= {{{selrecBad[1,{2,{3,{4,{5,{}}}}}],{1,If[{2,{3,{4,{5,{}}}}}==={},{},selrecBad@@{2,{3,{4, 
{5,{}}}}}]},{selrecBad[2,{3,{4,{5,{}}}}],If[{3,{4,{5,{}}}}==={},{},selrecBad@@{3,{4,{5, 
{}}}}],selrecBad[3,{4,{5,{}}}],{3,If[{4,{5,{}}}==={},{},selrecBad@@{4,{5,{}}}]},{selrecBad[4,
{5,{}}],If[{5,{}}==={},{},selrecBad@@{5,{}}],selrecBad[5,{}],{5,If[{}==={},{},selrecBad@@{}]}}}}}}

Lo que sucede es que el resultado obtiene acumulado más y más en la lista. La solución es no aumentar la profundidad de la expresión resultante, y una forma de lograrlo es hacer que selrecBad acepte un parámetro adicional, que es la lista (vinculada) de resultados acumulados:

selrec[{fst_?test, rest_List}, accum_List] := 
    If[rest === {}, {accum, fst}, selrec[rest, {accum, fst}]];
selrec[{fst_, rest_List}, accum_List] := 
    If[rest === {}, accum, selrec[rest, accum]]

Y modificar la función principal en consecuencia:

selTR[x_List, testF_] := Block[{test = testF}, Flatten[selrec[toLinkedList[x], {}]]]

Esto pasará nuestra prueba de poder muy bien:

In[14]:= Block[{$IterationLimit= Infinity},selTR[Range[300000],EvenQ]]//Short//Timing

Out[14]= {0.813,{2,4,6,8,10,12,14,16,18,20,
<<149981>>,299984,299986,299988,299990,299992,299994,299996,299998,300000}}

(tenga en cuenta que aquí tuvimos que modificar Iter IterationLimit, que es una buena señal). Y el uso de Trace revela la razón:

In[15]:= Trace[selTR[Range[5],OddQ],selrec]

Out[15]= {{{selrec[{1,{2,{3,{4,{5,{}}}}}},{}],If[{2,{3,{4,{5,{}}}}}==={},{{},1},selrec[{2,{3,{4, 
{5,{}}}}},{{},1}]],selrec[{2,{3,{4,{5,{}}}}},{{},1}],If[{3,{4,{5,{}}}}==={},{{},1},selrec[{3, 
{4,{5,{}}}},{{},1}]],selrec[{3,{4,{5,{}}}},{{},1}],If[{4,{5,{}}}==={},{{{},1},3},selrec[{4, 
{5,{}}},{{{},1},3}]],selrec[{4,{5,{}}},{{{},1},3}],If[{5,{}}==={},{{{},1},3},selrec[{5, 
{}},{{{},1},3}]],selrec[{5,{}},{{{},1},3}],If[{}==={},{{{{},1},3},5},selrec[{},{{{{},1},3},5}]]}}}

Es decir, esta versión no acumula la profundidad de la expresión intermedia, ya que los resultados se mantienen en una lista separada.

 22
Author: Leonid Shifrin,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2011-01-07 16:24:01

La idea de esta respuesta es reemplazar los corchetes () por un envoltorio que no haga crecer nuestras expresiones. Tenga en cuenta que la función para la que estamos encontrando una alternativa es realmente CompoundExpression, ya que el OP fue correcto al señalar que esta función estaba arruinando la recursión de cola (vea también la respuesta de Leonid). Se proporcionan dos soluciones. Esto define la primera envoltura

SetAttributes[wrapper, HoldRest];
wrapper[first_, fin_] := fin
wrapper[first_, rest__] := wrapper[rest]

Entonces Tenemos que

Clear[f]
k = 0;
mmm = 1000;
f[n_ /; n < mmm] := wrapper[k += n, f[n + 1]];
f[mmm] := k + mmm
Block[{$IterationLimit = Infinity}, f[0]]

Calcula Correctamente Total [Intervalo [1000]].

------Nota-----

Tenga en cuenta que sería engañoso establecer

wrapper[fin_] := fin;

Como en el caso

f[x_]:= wrapper[f[x+1]]

La recursión de cola no ocurre (debido al hecho de que wrapper, teniendo HoldRest, evaluará el argumento singular antes de aplicar la regla asociada con wrapper[fin_]).

Por otra parte, la definición anterior para f no es útil, ya que uno simplemente podría escribir

f[x_]:= f[x+1]

Y tener la cola deseada recursión.

------Otra nota-----

En caso de que proporcionemos al wrapper muchos argumentos, puede ser más lento de lo necesario. El usuario puede optar por escribir

f[x_]:=wrapper[g1;g2;g3;g4;g5;g6;g7  , f[x+1]]

Segunda envoltura

La segunda envoltura alimenta sus argumentos a CompoundExpression y por lo tanto será más rápida que la primera envoltura si se proporcionan muchos argumentos. Esto define la segunda envoltura.

SetAttributes[holdLastWrapper, HoldAll]
holdLastWrapper[fin_] := fin
holdLastWrapper[other_, fin_] := 
 Function[Null, #2, HoldRest][other, fin]
holdLastWrapper[others__, fin_] := 
 holdLastWrapper[
  Evaluate[CompoundExpression[others, Unevaluated[Sequence[]]]], fin]

Nota: Devolver secuencias (vacías) podría ser muy útil en recursión en general. Ver también mi respuesta aquí

Https://mathematica.stackexchange.com/questions/18949/how-can-i-return-a-sequence

Tenga en cuenta que esta función seguirá funcionando si solo se proporciona un argumento, ya que tiene el atributo HoldAll en lugar de HoldRest, por lo que establecer

f[x]:= holdLastWrapper[f[x+1]]

Producirá una recursión de cola (wrapper no tiene este comportamiento).

Comparación de Velocidad

Vamos a crear una larga lista (en realidad una expresión con Retención de la cabeza) de las instrucciones

nnnn = 1000;
incrHeld = 
  Prepend[DeleteCases[Hold @@ ConstantArray[Hold[c++], nnnn], 
    Hold, {2, Infinity}, Heads -> True], Unevaluated[c = 0]];

Para estas instrucciones, podemos comparar el rendimiento (y el resultado) de nuestras envolturas con CompoundExpression

holdLastWrapper @@ incrHeld // Timing
CompoundExpression @@ incrHeld // Timing
wrapper @@ incrHeld // Timing

--> {{0.000856, 999}, {0.000783, 999}, {0.023752, 999}}

Conclusión

El segundo wrapper es mejor si no está exactamente seguro de cuándo ocurrirá la recursión de cola o cuántos argumentos alimentará al wrapper. Si tiene la intención de alimentar el wrapper 2 argumentos, por ejemplo en el si te das cuenta de que todo lo que hace la segunda envoltura es alimentar a CompoundExpression y decides hacerlo tú mismo, la primera envoltura es mejor.

-----nota final----

En CompoundExpression[args, Unevaluated[expr]], expr todavía se evalúa antes de que CompoundExpression se elimine, por lo que las soluciones de este tipo no sirven.

 4
Author: Jacob Akkerboom,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-04-13 12:55:36