¿Qué limitaciones impone la JVM a la optimización de llamadas de cola?

36

Clojure no realiza la optimización de llamadas de cola por sí solo: cuando tiene una función recursiva de cola y desea optimizarla, debe usar la forma especial recur. Del mismo modo, si tiene dos funciones recursivas entre sí, puede optimizarlas solo mediante el uso trampoline.

El compilador Scala puede realizar TCO para una función recursiva, pero no para dos funciones recursivas mutuamente.

Siempre que leí sobre estas limitaciones, siempre se les atribuyó alguna limitación intrínseca al modelo JVM. No sé casi nada sobre compiladores, pero esto me desconcierta un poco. Déjame tomar el ejemplo de Programming Scala. Aquí la función

def approximate(guess: Double): Double =
  if (isGoodEnough(guess)) guess
  else approximate(improve(guess))

se traduce a

0: aload_0
1: astore_3
2: aload_0
3: dload_1
4: invokevirtual #24; //Method isGoodEnough:(D)Z
7: ifeq
10: dload_1
11: dreturn
12: aload_0
13: dload_1
14: invokevirtual #27; //Method improve:(D)D
17: dstore_1
18: goto 2

Entonces, a nivel de bytecode, uno solo necesita goto. En este caso, de hecho, el compilador realiza el trabajo duro.

¿Qué facilidad de la máquina virtual subyacente permitiría al compilador manejar el TCO más fácilmente?

Como nota al margen, no esperaría que las máquinas reales fueran mucho más inteligentes que la JVM. Aún así, muchos lenguajes que se compilan en código nativo, como Haskell, no parecen tener problemas para optimizar las llamadas de cola (bueno, Haskell a veces puede deberse a la pereza, pero ese es otro problema).

Andrea
fuente

Respuestas:

25

Ahora, no sé mucho sobre Clojure y poco sobre Scala, pero lo intentaré.

En primer lugar, necesitamos diferenciar entre tail-CALLs y tail-RECURSION. La recursividad de la cola es bastante fácil de transformar en un bucle. Con llamadas de cola, es mucho más difícil de imposible en el caso general. Necesita saber cómo se llama, pero con el polimorfismo y / o las funciones de primera clase, rara vez lo sabe, por lo que el compilador no puede saber cómo reemplazar la llamada. Solo en el tiempo de ejecución conoce el código de destino y puede saltar allí sin asignar otro marco de pila. Por ejemplo, el siguiente fragmento tiene una llamada de cola y no necesita ningún espacio de pila cuando se optimiza correctamente (incluido TCO), sin embargo, no se puede eliminar al compilar para la JVM:

function forward(obj: Callable<int, int>, arg: int) =
    let arg1 <- arg + 1 in obj.call(arg1)

Si bien aquí es un poco ineficiente, hay estilos de programación completos (como el estilo de paso de continuación o CPS) que tienen toneladas de llamadas de cola y rara vez regresan. Hacer eso sin un TCO completo significa que solo puede ejecutar pequeños bits de código antes de quedarse sin espacio en la pila.

¿Qué facilidad de la máquina virtual subyacente permitiría al compilador manejar el TCO más fácilmente?

Una instrucción de cola, como en la VM Lua 5.1. Su ejemplo no se vuelve mucho más simple. El mío se convierte en algo como esto:

push arg
push 1
add
load obj
tailcall Callable.call
// implicit return; stack frame was recycled

Como nota al margen, no esperaría que las máquinas reales fueran mucho más inteligentes que la JVM.

Tienes razón, no lo son. De hecho, son menos inteligentes y, por lo tanto, ni siquiera saben (mucho) sobre cosas como los marcos de pila. Esa es precisamente la razón por la que uno puede hacer trucos como reutilizar el espacio de la pila y saltar al código sin presionar una dirección de retorno.


fuente
Veo. No me di cuenta de que ser menos inteligente podría permitir una optimización que de otra manera estaría prohibida.
Andrea
77
+1, la tailcallinstrucción para JVM ya se ha propuesto ya en 2007: bloguea en sun.com a través de la máquina wayback . Después de la adquisición de Oracle, este enlace es 404. Supongo que no llegó a la lista de prioridades de JVM 7.
K.Steff
1
Una tailcallinstrucción solo marcaría una llamada de cola como una llamada de cola. Si la JVM realmente optimizó dicha llamada de cola es una pregunta completamente diferente. El CLI CIL tiene un .tailprefijo de instrucción, sin embargo, el CLR de 64 bits de Microsoft durante mucho tiempo no lo optimizó. Otoh, el IBM J9 JVM hace detectar las llamadas de la cola y los optimiza, sin necesidad de una instrucción especial para decirle en que las llamadas son llamadas de cola. Anotar las llamadas de cola y optimizar las llamadas de cola son realmente ortogonales. (Aparte del hecho de que deducir estáticamente qué llamada es una cola puede o no ser indecidible. No sé.)
Jörg W Mittag
@ JörgWMittag Haces un buen punto, una JVM puede detectar fácilmente el patrón call something; oreturn. El trabajo principal de una actualización de la especificación JVM no sería introducir una instrucción de cola explícita, sino exigir que dicha instrucción esté optimizada. Dicha instrucción solo hace que los trabajos de los escritores de compiladores sean más fáciles: el autor de JVM no tiene que asegurarse de reconocer esa secuencia de instrucciones antes de que se estropee más allá del reconocimiento, y el compilador X-> bytecode puede estar seguro de que su bytecode es inválido o realmente optimizado, nunca correcto-pero-pila-desbordamiento.
@delnan: la secuencia call something; return;solo sería equivalente a una llamada de cola si la cosa que se llama nunca pide un seguimiento de la pila; Si el método en cuestión es virtual o llama a un método virtual, la JVM no tendrá forma de saber si podría preguntar sobre la pila.
supercat
12

Clojure podría realizar una optimización automática de la recursión de la cola en bucles: ciertamente es posible hacer esto en la JVM como lo demuestra Scala.

En realidad, fue una decisión de diseño no hacer esto: debe usar explícitamente el recurformulario especial si desea esta función. Vea el hilo de correo Re: ¿Por qué no hay optimización de llamadas de cola en el grupo de google Clojure?

En la JVM actual, lo único que es imposible de hacer es la optimización de llamadas de cola entre diferentes funciones (recursión mutua). Esto no es particularmente complejo de implementar (otros lenguajes como Scheme han tenido esta característica desde el principio) pero requeriría cambios en la especificación JVM. Por ejemplo, tendría que cambiar las reglas sobre la preservación de la pila completa de llamadas a funciones.

Es probable que una iteración futura de la JVM obtenga esta capacidad, aunque probablemente como una opción para mantener un comportamiento compatible con versiones anteriores del código anterior. Digamos, la Vista previa de características en Geeknizer enumera esto para Java 9:

Añadiendo llamadas de cola y continuaciones ...

Por supuesto, las hojas de ruta futuras siempre están sujetas a cambios.

Como resultado, no es un gran problema de todos modos. En más de 2 años de codificación de Clojure, nunca me he encontrado con una situación en la que la falta de TCO sea un problema. Las principales razones para esto son:

  • Ya puede obtener una recursión de cola rápida para el 99% de los casos comunes con recuro un bucle. El caso de recursión de cola mutua es bastante raro en el código normal
  • Incluso cuando necesita recursividad mutua, a menudo la profundidad de recursión es lo suficientemente baja como para que pueda hacerlo en la pila de todos modos sin TCO. TCO es solo una "optimización" después de todo ...
  • En los casos muy raros en los que necesita alguna forma de recursión mutua que no consuma pila, hay muchas otras alternativas que pueden lograr el mismo objetivo: secuencias perezosas, trampolines, etc.
mikera
fuente
"iteración futura" - La Vista previa de características en Geeknizer dice para Java 9: Agregar llamadas de cola y continuaciones , ¿es eso?
mosquito
1
Sí, eso es. Por supuesto, las hojas de ruta futuras siempre están sujetas a cambios ...
mikera
5

Como nota al margen, no esperaría que las máquinas reales fueran mucho más inteligentes que la JVM.

No se trata de ser más inteligente, sino de ser diferente. Hasta hace poco, la JVM fue diseñada y optimizada exclusivamente para un solo lenguaje (Java, obviamente), que tiene una memoria muy estricta y modelos de llamadas.

No solo no había ninguno gotoo punteros, ni siquiera había forma de llamar a una función 'desnuda' (una que no fuera un método definido dentro de una clase).

Conceptualmente, cuando se dirige a JVM, un escritor compilador tiene que preguntarse "¿cómo puedo expresar este concepto en términos de Java?". Y obviamente, no hay forma de expresar TCO en Java.

Tenga en cuenta que estos no se consideran fallas de JVM, porque no son necesarios para Java. Tan pronto como Java necesite alguna característica como esta, se agrega a JVM.

Solo recientemente, las autoridades de Java comenzaron a tomar en serio a JVM como una plataforma para lenguajes que no son Java, por lo que ya ha obtenido cierto soporte para características que no tienen equivalente Java. El más conocido es la escritura dinámica, que ya está en JVM pero no en Java.

Javier
fuente
3

Entonces, en el nivel de bytecode, uno solo necesita ir a. En este caso, de hecho, el compilador realiza el trabajo duro.

¿Has notado que la dirección del método comienza con 0? ¿Que todos los métodos de conjuntos comienzan con 0? JVM no permite que uno salte fuera de un método.

No tengo idea de lo que sucedería con una rama con desplazamiento fuera del método que Java cargó: tal vez sería atrapado por el verificador de bytecode, tal vez generaría una excepción y tal vez saltaría fuera del método.

El problema, por supuesto, es que realmente no se puede garantizar dónde estarán otros métodos de la misma clase, y mucho menos los métodos de otras clases. Dudo que JVM garantice dónde cargará los métodos, aunque me encantaría que me corrijan.

Daniel C. Sobral
fuente
Buen punto. Pero para optimizar la cola de una función auto recursiva, todo lo que necesita es un GOTO dentro del mismo método . Por lo tanto, esta limitación no descarta el costo total de propiedad de los métodos auto recursivos.
Alex D