El contenido de este sitio se ha traducido mediante inteligencia artificial (IA) o tecnología de traducción automática, y puede contener errores.

Skip to content

Optimización de la interoperabilidad entre Lua y C++

Introducción

El motor de Roblox está escrito en una combinación de C++ y Lua, con el código que realiza operaciones computacionalmente intensivas escrito en C++ optimizado, mientras que la lógica del juego y los scripts están escritos en Lua, para facilitar el desarrollo. Para que este modelo sea eficaz, es necesario que las transiciones entre Lua y C++ sean lo más rápidas posible, ya que cualquier tiempo que se pierda en esta zona de transición es, en esencia, milisegundos desperdiciados.

Durante los últimos meses, hemos ido implementando diversas mejoras en esta parte del sistema. Una parte en concreto —la invocación real de métodos de C++ desde Lua— resultó especialmente interesante, ya que condujo a mejoras considerables en la velocidad y requirió indagar en las entrañas de Lua para comprender cómo funcionaban las cosas bajo el capó.

Acabamos modificando la propia máquina virtual de Lua, pero antes de llegar a eso, tenemos que sentar algunas bases.

Compiladores, máquina virtual y código byte

Cuando se compila el código fuente de Lua, se compila en código byte de Lua, que luego ejecuta la máquina virtual de Lua. El código byte de Lua tiene alrededor de 35 instrucciones en total, para cosas como leer/escribir tablas, llamar a funciones, realizar operaciones binarias, saltos y condicionales, etc. La máquina virtual de Lua está basada en registros, a diferencia de muchas otras máquinas virtuales que se basan en la pila, por lo que parte de lo que hace el compilador al generar el código byte es determinar qué registros debe utilizar cada instrucción.

Cada instrucción tiene la forma «OP_CODE A B» o «OP_CODE A B C», donde «OP_CODE» es el código de operación (por ejemplo, CALL para llamar a una función) y A/B/C son los argumentos del código de operación. Los argumentos (o registros) no son valores reales. En su lugar, son índices que apuntan a una de dos tablas: la tabla de constantes (escrita Kst(..)) o la tabla de registros (escrita R(..)).

Para obtener una descripción detallada del código byte de Lua, consulta «Una introducción sencilla a las instrucciones de la máquina virtual de Lua 5.1». Es mucho más interesante de lo que parece; ¡te lo prometo!

Para que te hagas una idea de cómo es el código byte de Lua, primero vamos a repasar algunos programas sencillos y luego pasaremos a ejemplos más relevantes.

Con la utilidad Chunkspy, podemos desensamblar el código byte de Lua en ensamblador de Lua y obtener un listado del código, así como la tabla de constantes, de modo que, en esencia, podamos ver qué código byte se genera para cualquier código fuente de Lua dado.

Ejemplos básicos de código byte

Un programa sencillo como «x = 10» se compila en:

.const "x";  0

.const 10;  1

[1]  loadk       0   1       ;   10

[2]  setglobal   0   0       ;   x 

Las dos primeras líneas muestran la tabla de constantes (con el valor de cadena «x» en la ranura 0 y el valor entero 10 en la ranura 1), y las dos líneas siguientes son los códigos de operación desensamblados.

[Línea 1] Al consultar el código de operación LOADK en «No Frills», vemos que tiene la forma «LOADK A Bx --- R(A) := Kst(Bx)». Por lo tanto, LOADK tiene dos argumentos (los registros A y B) y su operación consiste en asignar el valor que se encuentra en la tabla de constantes en la ranura indicada por el segundo registro, Kst(Bx), al registro indicado por el primer argumento, R(A). «Bx» simplemente significa que, dado que el código de operación solo tiene dos argumentos, el registro B se amplía y se le asignan más bits.

[Línea 2] SETGLOBAL tiene la forma «SETGLOBAL A Bx --- Gbl[Kst(Bx)] := R(A)». Asigna un valor a la tabla global utilizando la clave dada por la tabla de constantes en la ranura del segundo argumento. Como el segundo argumento es 0 y el valor de la tabla de constantes en 0 es «x», escribe algo en la tabla global utilizando la clave «x». Lo que sea que haya en la tabla de registros en la ranura indicada por el primer argumento es lo que se está escribiendo, que la instrucción anterior cargó con el valor 10.

Veamos un ejemplo un poco más complicado: «x = 10; y = x». Dejaré la ejecución manual del código como ejercicio para el lector. :)

.const "x";   0

.const 10;    1

.const"y";    2

[1]  loadk       0   1       ;   10

[2]  setglobal   0   0       ;   x

[3]  getglobal   0   0       ;   x

[4]  setglobal   0   2       ;   y

 

Código de bytes para llamadas a funciones

Veamos el código generado para «foo(10):»

.const "foo"; 0

.const 10;    1

[1]  getglobal  0    0  ;  foo   //  R(A)  :=  Gbl[Kst(Bx)]

[2]  loadk      1     1 ;  10   //  R(A)  :=  Kst(Bx)

[3]  call       0     2      1

 
Para ejecutar llamadas a funciones, la función debe cargarse en el primer registro y los argumentos en los siguientes. La semántica de «CALL A B C» es tal que A contiene la función, B es el número de argumentos (en realidad, es el número de argumentos +1, debido a la forma en que se implementa «...») y C es el número de valores de retorno (de nuevo, es el número de valores de retorno +1, para gestionar múltiples valores de retorno).

Ya conocemos las dos primeras líneas; cargan un valor en la ranura 0 de la tabla de registros y el valor 10 en la ranura 1 de la tabla de registros. La tercera línea es la que realiza la llamada a la función, utilizando el valor del registro A (ranura 0 de la tabla de registros, que se cargó con «foo»), donde B especifica el número de argumentos y C el número de valores de retorno (recuerde que a los valores de B y C se les debe sumar 1). Antes de que se pueda llamar a la función, la VM también verifica que el valor en R(A) sea, de hecho, invocable.

Lua cuenta con un mecanismo que permite a los usuarios ampliar la funcionalidad de las tablas asociando una metatabla a una tabla existente. La metatabla contiene métodos de reserva que se invocan si no se puede realizar un determinado método u operación en la tabla principal (véase https://www.lua.org/pil/13.html para una descripción detallada).

Para nuestros propósitos, las entradas más relevantes de la metatabla son los campos «__index» y «__call». __index se utiliza al buscar un elemento en una tabla, por lo que el código «local x = my_table[10]» llamaría primero al método __index en my_table. Si eso fallara, intentaría llamar a __index en la metatabla de my_table. __call se utiliza de manera similar cuando se intenta tratar algo como una función y llamarla «local x = foo(42)», por ejemplo

Para que Lua y C++ puedan interoperar, necesitan alguna forma de compartir funciones y datos. Lua facilita esto proporcionando un tipo de datos llamado UserData. Los objetos UserData se pueden crear en el entorno de C++ y, dado que son tipos de datos nativos de Lua, pueden adornarse con metatablas que permiten al código de Lua interactuar con ellos como si fueran objetos de Lua normales.

Llamadas a funciones de miembro

¡Bien, volvamos a ver algo de código byte! El siguiente ejemplo es un poco más interesante porque muestra lo que ocurre cuando tienes código como «foo:bar(10)», que está llamando al método bar en la instancia foo (una instancia de la clase Foo).

foo:bar(10)

.const "foo";   0

.const "bar";   1

.const  10;     2

[1]  getglobal  0    0         ;  foo

[2]  self       0     0    257 ;  "bar"

[3]  loadk      2     2        ;  10

[4]  call       0     3      1

 
La novedad aquí es la instrucción self [línea 2], que no habíamos visto antes. Self tiene la sintaxis «SELF A B C --- R(A) := R(B)[RK(C)]; R(A+1) := R(B)», así que analicémosla. En la tabla de registros, en la ranura R(A), se colocará el resultado de la consulta de la tabla en la ranura de registro R(B) utilizando la clave de la ranura RK(C). También se copiará lo que haya en la ranura R(B) a la ranura R(A+1), pero hablaremos de esto más adelante. Quizás notes que el valor del registro C es 257. Esto es válido porque Lua utiliza RK(C) para buscar el valor, y RK utilizará la tabla de registros o la tabla de constantes, dependiendo del valor del noveno bit. Si es un 1, como ocurre en este caso, se utiliza la tabla de constantes; de lo contrario, la búsqueda se realiza en la tabla de registros (tras enmascarar el bit más alto).

La línea 3 coloca 10 en la ranura 2 y, finalmente, la línea 4 ejecutará la llamada a la función.

La instrucción SELF tiene dos funciones. En primer lugar, busca el método «bar» en la clase Foo y lo coloca en R(A). En segundo lugar, dado que foo es un método de instancia y necesitamos la instancia de la clase sobre la que estamos invocando el método al realizar la llamada, coloca esta instancia en R(A+1). Si estás familiarizado con las clases en Python, es posible que reconozcas el concepto: los métodos suelen escribirse como «def my_method(self, arg1, arg2..)», donde self es la instancia de la clase.

Tendremos que profundizar un poco más en esto y ver qué ocurre cuando la instancia foo es un objeto C++, representado en Lua como un objeto UserData.

La llamada a SELF puede verse como una consulta a una tabla, es decir, Foo[“bar”] (la Foo mayúscula representa la clase Foo, en contraposición a foo, la instancia), y sabemos que las consultas utilizarán el método __index. Cuando se creó la instancia foo en el entorno de C++, se asoció una metatabla a la instancia, y el campo __index de la metatabla se estableció en un fragmento de código C++ que se ejecutará cuando se invoque a __index.

Cuando se llama a C/C++ desde Lua, el único dato que se pasa es un objeto lua_State. Este objeto contiene todo lo relacionado con el hilo de Lua que se está ejecutando actualmente. La información más importante del objeto de estado es la pila de Lua, que contiene los argumentos de la función (a los que se accede a través de la familia de funciones lua_tointeger/tostring, etc.) y que también se utiliza para devolver valores a Lua.

En pseudo-C++, nuestra función __index tiene un aspecto similar a este:

int  metaIndex(lua_State*  L)

{

           //  first  argument  is  the  userdata  object

           UserData*  userdata  =  lua_touserdata(L,  1);


           //  get  some  kind  of  descriptor,  that  contains  information

           //  about  what  methods the  class  exposes

           ClassDescriptor*  desc =  getDescriptorForUserData(userdata);


           //  See  if  the  class  has  the  requested  method

           const  char*  methodName  =  lua_tostring(L,  2);

           MemberFunctionPtr  method  = desc->hasMethod(methodName);

           if  (method)

           {

                   //  Upvalues  are  values  that  are  available  when  a  C

                   //  function  is  invoked.

                   lua_pushupvalue(L,  method);

                   lua_pushcfunction(L,  methodInvoker);

                   return  1;

           }

           else

           {

                   lua_pushnil(L);

                   return  0;

           }

}

 Se omiten
muchos detalles internos, pero esta es la idea general. Dado que el objeto UserData se pasa como primer argumento en la pila de Lua, podemos encontrar un descriptor que describe la clase C++ real y, a través de él, ver si esta clase tiene un método con el nombre dado. Si lo tiene, se empuja en la pila de Lua un puntero a función que apunta a un invocador de método, y devolvemos éxito.

Tras esta llamada, la máquina virtual de Lua colocará el resto de los argumentos en la tabla de registros y, a continuación, llamará a la función que devolvimos desde el método metaIndex, que volverá a llamar a C++ y aterrizará en la función de invocación:

int  methodInvoker(lua_State*  L)

{ &nbsp;  <br>  &nbsp; &nbsp;    &nbsp;//  Get  the  userdata  and  the  class  descriptor

           UserData*  userdata  =  lua_touserdata(L,  1);

           ClassDescriptor*  desc  =  getDescriptorForUserData(userdata);

           Class*  instance  =  (Class*)userdata;


           //  Using  Lua's  upvalue  mechanism,  get  the  'method'

           //  that  was  stored  in  metaIndex.

           MemberFunctionPtr  method  =  lua_getupvalue(L,  1);


           //  This  is  hand-wavey,  but  we  have  some  mechanism  of  being

           //  able  to  invoke  a  member  function  via  the  class  descriptor,

           //  and  also  pop  arguments  from  the  Lua  stack,  and  push  return  values

           return  desc-&gt;invokeFunction(instance,  method,  L);
}

 
El methodInvoker también utiliza el ClassDescriptor, pero esta vez es capaz de invocar la función miembro y extraer los argumentos correctos de la pila.

¡Ya casi estamos!

Ahora que podemos ver claramente los dos viajes de ida y vuelta de Lua a C++, podemos intentar averiguar cómo optimizar esto.

Nuestro objetivo final es realizar una única llamada de función de Lua a C++ y tener todas las piezas que necesitamos en la pila de Lua para poder realizar la búsqueda y la invocación del método de una sola vez. El problema parece ser que nos falta un registro. Cuando llamamos a nuestra función combinada de búsqueda/invocación, queremos que la pila de Lua tenga el siguiente aspecto: [self, nombre del método, arg1, arg2, ...], pero al observar SELF, vemos que utiliza su primera ranura para el resultado de la búsqueda de la función del método y la segunda ranura para almacenar la instancia.

Tuvimos una revelación clave al observar cómo funcionaba el metamétodo __call. Si un objeto tiene el metamétodo __call, entonces, antes de que se invoque la función _call, el propio objeto se empuja a la pila y todos los argumentos se desplazan hacia arriba. Aprovechando esta funcionalidad, había una forma de colocar «self» en la pila sin tener que almacenarlo explícitamente en un registro.

La segunda parte consistía en colocar también el nombre del método en la pila. Para ello, tuvimos que ser astutos y alterar el funcionamiento del código de operación SELF.

Recuerda que, en el caso predeterminado, SELF intentaría encontrar la función miembro y almacenarla en R(A) junto con la instancia en R(A+1). Acabamos saltándonos la búsqueda por completo y almacenamos el objeto real en R(A) y el nombre del método en R(A+1).

Si ahora nos aseguráramos de que el objeto en R(A) tuviera un metamétodo __call, entonces también acabaríamos colocando self en la pila. Así, tendríamos una pila que se vería así: [self, nombre del método, argumentos…] y haríamos una sola llamada a C++. ¡Perfecto! Bueno, casi. :)

Antes de dar esto por hecho, queríamos darle unos últimos retoques. No queríamos sobrecargar la semántica del metamétodo __call, así que en su lugar añadimos un metamétodo específico para este tipo de invocación —llamado __namecall— que solo estaba disponible en objetos UserData. También modificamos el código de operación SELF para que solo utilice la nueva semántica si el objeto tiene un metamétodo __namecall.

Lo segundo que hicimos fue, principalmente, hacer que la nueva ruta y la antigua pudieran compartir código fácilmente. En lugar de tener el nombre del método como segundo argumento, lo movimos al último argumento. Así, después de haberlo utilizado para buscar el puntero del método, se podía sacar fácilmente de la pila y esta quedaba igual que si la función se hubiera invocado a través de la ruta antigua.

Conclusión

¿Qué impacto tiene esta optimización? Bueno, como ocurre con la mayoría de las cosas en programación, la respuesta es «depende». En el caso de funciones pesadas —y que no se invocan con frecuencia— no se notará una gran mejora. Pero para funciones más pequeñas que se invocan a menudo, el ahorro puede ser considerable.

Los usuarios del Foro de Desarrolladores se dieron cuenta rápidamente de la aparición de este nuevo y extraño metamétodo, y se presentó una tabla que comparaba la velocidad de __namecall tanto con el antiguo método de invocar métodos de instancia como con una solución alternativa que los desarrolladores habían estado utilizando para optimizar la invocación de métodos:

local  part  =  workspace.Baseplate


local  count  =  1000000


local  start0  =  tick()

for  i=1,count  do

        part:IsA("BasePart")

end

local  end0  =  tick()



local  start1  =  tick()

for  i=1,count  do

       local  isa  =  part.IsA

       isa(part,  "BasePart")

end

local  end1  =  tick()



local  start2  =  tick()

local  isa  =  part.IsA

for  i=1,count  do

       isa(part,  "Basepart")

end

local  end2  =  tick()




print("namecall",  end0  -  start0)

print("index+call",  end1  -  start1)

print("call",  end2  -  start2)



&gt;  namecall  0.49229717254639

&gt;  index+call  0.78510332107544

&gt;  call  0.49960780143738

 
El primer bucle utiliza la nueva ruta de código de __namecall, pero como toda la magia ocurre bajo el capó, no es necesario que los desarrolladores cambien ningún código existente para beneficiarse de la optimización.

El segundo bucle emula la forma antigua de realizar una llamada a un método de instancia: primero se busca el método y luego se invoca.

Y, por último, el tercer bucle muestra una optimización habitual que realizaban los desarrolladores, en la que primero se buscaba el método, se almacenaba en una variable local y, a continuación, se invocaba la variable.

Lo bueno de esto es que demuestra que, con la optimización de __namecall, ya no es necesario almacenar explícitamente en caché las funciones de instancia, ya que es tan rápido como la optimización con caché, por lo que el código más sencillo también será el que ofrezca mejor rendimiento.

Ahora que se ha implementado __namecall y estamos satisfechos con los resultados que estamos viendo, es hora de centrar nuestra atención en el uso de la memoria y ver qué podemos hacer para mejorar el cliente en ese aspecto.