El inconveniente del compilador JIT: ¿Por qué evitar NYI?

API7.ai

September 30, 2022

OpenResty (NGINX + Lua)

En el artículo anterior, analizamos FFI en LuaJIT. Si tu proyecto solo utiliza la API proporcionada por OpenResty y no necesitas llamar a funciones en C, entonces FFI no es tan importante para ti. Solo necesitas asegurarte de que lua-resty-core esté habilitado.

Pero NYI en LuaJIT, de lo que hablaremos hoy, es un problema crucial del que ningún ingeniero que use OpenResty puede escapar, y que afecta significativamente el rendimiento.

Puedes escribir rápidamente código lógicamente correcto usando OpenResty, pero sin entender NYI, no podrás escribir código eficiente ni aprovechar el poder de OpenResty. La diferencia de rendimiento entre ambos es de al menos un orden de magnitud.

¿Qué es NYI?

Comencemos recordando un punto que hemos mencionado antes.

El entorno de ejecución de LuaJIT, además de una implementación en ensamblador del intérprete de Lua, tiene un compilador JIT que puede generar código de máquina directamente.

La implementación del compilador JIT en LuaJIT aún no está completa. No puede compilar algunas funciones porque son difíciles de implementar y porque los autores de LuaJIT están actualmente semi-retirados. Estas incluyen la función común pairs(), la función unpack(), el módulo Lua basado en la implementación de Lua CFunction, y así sucesivamente. Esto permite que el compilador JIT recurra al modo intérprete cuando encuentra una operación que no soporta en la ruta de código actual.

El sitio web oficial de LuaJIT tiene una lista completa de estos NYIs, y te sugiero que la revises. El objetivo del artículo no es que memorices esta lista, sino que te recuerdes conscientemente de ella al escribir código.

A continuación, he tomado algunas funciones de la lista NYI para la biblioteca de cadenas.

string library

El estado de compilación de string.byte es sí, lo que significa que puede ser optimizado con JIT, y puedes usarlo en tu código sin miedo.

El estado de compilación de string.char es 2.1, lo que significa que ha sido soportado desde LuaJIT 2.1. Como sabemos, LuaJIT en OpenResty está basado en LuaJIT 2.1, por lo que puedes usarlo con seguridad.

El estado de compilación de string.dump es nunca, es decir, no será optimizado con JIT y recurrirá al modo intérprete. Hasta ahora, no hay planes de soportarlo en el futuro.

string.find tiene un estado de compilación de 2.1 parcial, lo que significa que está parcialmente soportado desde LuaJIT 2.1, y la nota después de eso dice que solo soporta la búsqueda de cadenas fijas, no la coincidencia de patrones. Por lo tanto, para encontrar cadenas fijas, string.find puede ser optimizado con JIT.

Naturalmente, deberíamos evitar usar NYI para que más de nuestro código pueda ser compilado por JIT y se garantice el rendimiento. Sin embargo, en un entorno real, a veces inevitablemente necesitamos usar algunas funciones NYI, entonces, ¿qué debemos hacer?

Alternativas a NYI

No te preocupes. La mayoría de las funciones NYI podemos dejarlas atrás respetuosamente e implementar su funcionalidad de otras maneras. A continuación, he seleccionado algunos NYIs típicos para explicar y guiarte a través de los diferentes tipos de alternativas a NYI. De esta manera, también puedes aprender sobre otros NYIs.

string.gsub()

Primero veamos la función string.gsub(), que es una función de manipulación de cadenas incorporada en Lua que realiza una sustitución global de cadenas, como en el siguiente ejemplo.

$ resty -e 'local new = string.gsub("banana", "a", "A"); print(new)'
bAnAnA

Esta función es una función NYI y no puede ser compilada por JIT.

Podríamos intentar encontrar una función de reemplazo en la API de OpenResty, pero para la mayoría de las personas, no es práctico recordar todas las API y su uso. Es por eso que siempre abro la página de documentación de GitHub para el módulo lua-nginx en mi trabajo de desarrollo.

Por ejemplo, podemos usar gsub como una palabra clave para buscar en la página de documentación, y ngx.re.gsub vendrá a la mente.

También podemos usar la herramienta restydoc recomendada antes para buscar en la API de OpenResty. Puedes intentar usarla para buscar gsub.

$ restydoc -s gsub

Como puedes ver, en lugar de devolver el ngx.re.gsub que esperábamos, se muestran las funciones de Lua. De hecho, en esta etapa, restydoc devuelve una coincidencia exacta única, por lo que es más adecuado para usar si conoces explícitamente el nombre de la API. Para búsquedas aproximadas, aún tienes que hacerlo manualmente en la documentación.

Volviendo a los resultados de la búsqueda, vemos que la definición de la función ngx.re.gsub es la siguiente:

newstr, n, err = ngx.re.gsub(subject, regex, replace, options?)

Aquí, los parámetros de la función y los valores de retorno están nombrados con significados específicos. De hecho, en OpenResty, no te recomiendo que escribas muchos comentarios. La mayoría de las veces, un buen nombre es mejor que varias líneas de comentarios.

Para los ingenieros no familiarizados con el sistema de expresiones regulares de OpenResty, pueden confundirse al ver la variable options al final. Sin embargo, la explicación de la variable no está en esta función, sino en la documentación de la función ngx.re.match.

Si miras la documentación de options, verás que si lo configuramos como jo, activa PCRE JIT, de modo que el código que usa ngx.re.gsub puede ser compilado por JIT tanto por LuaJIT como por PCRE JIT.

No entraré en detalles sobre la documentación. La documentación de OpenResty es excelente, así que léela cuidadosamente y podrás resolver la mayoría de tus problemas.

string.find()

A diferencia de string.gsub, string.find es JIT-able en modo simple (es decir, búsqueda de cadenas), mientras que string.find no es JIT-able para búsquedas de cadenas con regularidad, lo cual se hace usando la API de OpenResty ngx.re.find.

Por lo tanto, cuando haces una búsqueda de cadenas en OpenResty, primero debes distinguir claramente si estás buscando una cadena fija o una expresión regular. Si es lo primero, usa string.find y recuerda configurar plain como true al final.

string.find("foo bar", "foo", 1, true)

En el segundo caso, deberías usar la API de OpenResty y activar la opción JIT para PCRE.

ngx.re.find("foo bar", "^foo", "jo")

Sería más apropiado hacer una capa de envoltura aquí y activar las opciones de optimización por defecto, sin dejar que el usuario final conozca tantos detalles. De esa manera, es una función de búsqueda de cadenas uniforme hacia el exterior. Como puedes sentir, a veces demasiadas opciones y demasiada flexibilidad no son algo bueno.

unpack()

La tercera función que veremos es unpack(). unpack() también es una función que debe evitarse, especialmente no en el cuerpo del bucle. En su lugar, puedes acceder a ella usando los números de índice de un array, como en este ejemplo del siguiente código.

$ resty -e '
 local a = {100, 200, 300, 400}
 for i = 1, 2 do
    print(unpack(a))
 end'

$ resty -e 'local a = {100, 200, 300, 400}
 for i = 1, 2 do
    print(a[1], a[2], a[3], a[4])
 end'

Profundicemos un poco más en unpack, y esta vez podemos usar restydoc para buscar.

$ restydoc -s unpack

Como puedes ver en la documentación de unpack, unpack(list [, i [, j]]) es equivalente a return list[i], list[i+1], list[j], y puedes pensar en unpack como azúcar sintáctico. De esta manera, puedes acceder exactamente como un índice de array sin romper la compilación JIT de LuaJIT.

pairs()

Finalmente, veamos la función pairs() que recorre la tabla hash, que tampoco puede ser compilada por JIT.

Sin embargo, desafortunadamente, no hay una alternativa equivalente a esta. Solo puedes intentar evitarla o usar arrays accedidos por índice numérico en su lugar, y en particular, no recorras la tabla hash en la ruta de código caliente. Aquí explico la ruta de código caliente, que significa que el código se ejecutará muchas veces, por ejemplo, dentro de un gran bucle.

Habiendo dicho estos cuatro ejemplos, resumamos que para evitar el uso de funciones NYI, debes prestar atención a estos dos puntos.

  • Usa la API proporcionada por OpenResty en preferencia a las funciones de la biblioteca estándar de Lua. Recuerda que Lua es un lenguaje embebido, y estamos programando en OpenResty, no en Lua.
  • Si tienes que usar el lenguaje NYI como último recurso, asegúrate de que no esté en la ruta de código caliente.

¿Cómo detectar NYI?

Todo este discurso sobre la evitación de NYI es para enseñarte qué hacer. Sin embargo, sería inconsistente con una de las filosofías que OpenResty defiende si terminara abruptamente aquí.

Lo que puede ser hecho automáticamente por la máquina no involucra a los humanos.

Las personas no son máquinas, y siempre habrá descuidos. Automatizar la detección de NYI usados en el código es una reflexión esencial del valor de un ingeniero.

Aquí recomiendo los módulos jit.dump y jit.v que vienen con LuaJIT. Ambos imprimen el proceso de cómo funciona el compilador JIT. El primero produce información detallada que puede ser usada para depurar LuaJIT mismo. Puedes referirte a su código fuente para una comprensión más profunda; el segundo produce una salida más sencilla, con cada línea correspondiente a un trace, y generalmente se usa para verificar si puede ser JIT.

¿Cómo deberíamos hacer esto? Podemos comenzar agregando las siguientes dos líneas de código a init_by_lua.

local v = require "jit.v"
v.on("/tmp/jit.log")

Luego, ejecuta tu herramienta de prueba de estrés o unos cientos de conjuntos de pruebas unitarias para que LuaJIT se caliente lo suficiente como para activar la compilación JIT. Una vez hecho eso, revisa los resultados de /tmp/jit.log.

Por supuesto, este enfoque es relativamente tedioso, así que si quieres mantener las cosas simples, resty es suficiente, y la CLI de OpenResty viene con las siguientes opciones.

$resty -j v -e 'for i=1, 1000 do
      local newstr, n, err = ngx.re.gsub("hello, world", "([a-z])[a-z]+", "[$0,$1]", "i")
 end'
 [TRACE   1 (command line -e):1 stitch C:107bc91fd]
 [TRACE   2 (1/stitch) (command line -e):2 -> 1]

Donde -j en resty es la opción relacionada con LuaJIT, los valores dump y v siguen, correspondiendo a activar el modo jit.dump y jit.v.

En la salida del módulo jit.v, cada línea es un objeto trace compilado con éxito. Justo ahora es un ejemplo de un trace capaz de JIT, y si se encuentran funciones NYI, la salida especificará que son NYIs, como en el ejemplo del siguiente pairs.

$resty -j v -e 'local t = {}
 for i=1,100 do
     t[i] = i
 end

 for i=1, 1000 do
     for j=1,1000 do
         for k,v in pairs(t) do
             --
         end
     end
 end'

No puede ser JIT'd, por lo que el resultado indica una función NYI en la línea 8.

 [TRACE   1 (command line -e):2 loop]
 [TRACE --- (command line -e):7 -- NYI: bytecode 72 at (command line -e):8]

Escribe al final

Esta es la primera vez que hablamos sobre problemas de rendimiento de OpenResty con más detalle. Después de leer estas optimizaciones sobre NYI, ¿qué piensas? Puedes dejar un comentario con tu opinión.

Finalmente, te dejo una pregunta reflexiva al discutir alternativas a la función string.find(); mencioné que sería mejor hacer una capa de envoltura y activar las opciones de optimización por defecto. Así que, te dejo esa tarea para una pequeña prueba.

Siéntete libre de escribir tus respuestas en la sección de comentarios, y eres bienvenido a compartir este artículo con tus colegas y amigos para comunicarte y progresar juntos.