Consejos para una mejora de rendimiento 10x en OpenResty: Estructura de datos `Table`

API7.ai

December 9, 2022

OpenResty (NGINX + Lua)

En OpenResty, además de los frecuentes problemas de rendimiento con las operaciones de string, las operaciones con table también son un obstáculo para el rendimiento. En artículos anteriores, hemos cubierto funciones relacionadas con table de manera esporádica, pero no específicamente en términos de mejoras de rendimiento. Hoy, te llevaré a través del impacto en el rendimiento de las operaciones con table.

Hay dos razones principales por las que los desarrolladores saben poco sobre la optimización de rendimiento relacionada con table en comparación con las operaciones de string.

  1. El Lua utilizado en OpenResty es una rama de LuaJIT mantenida por ellos mismos, en lugar de LuaJIT estándar o Lua estándar. La mayoría de los desarrolladores no son conscientes de esta diferencia y tienden a usar la biblioteca table de Lua estándar para escribir código en OpenResty.
  2. Ya sea en LuaJIT estándar o en la rama de LuaJIT mantenida por OpenResty, la documentación relacionada con las operaciones de table está oculta y es difícil de encontrar para los desarrolladores. Además, no hay código de ejemplo en la documentación, por lo que los desarrolladores necesitan buscar ejemplos en proyectos de código abierto.

Estos dos puntos hacen que aprender OpenResty tenga una barrera cognitiva relativamente alta, lo que lleva a resultados polarizados: los desarrolladores veteranos de OpenResty pueden escribir código de muy alto rendimiento, mientras que aquellos que recién comienzan se preguntarán si el alto rendimiento de OpenResty es real. Pero después de aprender esta lección, podrás cruzar fácilmente la barrera de percepción y lograr una mejora de rendimiento de 10 veces.

Antes de entrar en detalles sobre la optimización de table, me gustaría enfatizar un principio simple relacionado con la optimización de table.

Intenta reutilizar las tablas y evita la creación innecesaria de tablas.

Introduciremos la optimización en términos de creación de table, inserción de elementos, vaciado y uso de bucles.

Array pre-generado

El primer paso es crear un array. En Lua, la forma en que creamos un array es simple.

local t = {}

La línea de código anterior crea un array vacío. También puedes agregar los datos inicializados al crearlo:

local color = {first = "red", "blue", third = "green", "yellow"}

Sin embargo, la segunda forma de escribirlo es más costosa en términos de rendimiento, ya que implica la asignación de espacio, resize y rehash del array cada vez que se agrega o elimina un elemento.

Entonces, ¿cómo debería optimizarse? La idea común de optimización es intercambiar espacio por tiempo. Dado que el cuello de botella aquí es la asignación dinámica de espacio en el array, podemos pre-generar un array de un tamaño especificado. Aunque esto puede desperdiciar algo de espacio en memoria, las múltiples asignaciones de espacio, resize y rehash se pueden combinar en una sola, lo que es mucho más eficiente.

La función table.new(narray, nhash) en LuaJIT fue agregada.

Esta función pre-asigna el tamaño especificado del array y del espacio hash en lugar de crecer automáticamente al insertar elementos. Este es el significado de sus dos parámetros, narray y nhash.

Aquí hay un ejemplo simple para ver cómo usarla. Dado que esta función es una extensión de LuaJIT, necesitamos requerirla antes de poder usarla.

local new_tab = require "table.new"
 local t = new_tab(100, 0)
 for i = 1, 100 do
   t[i] = i
 end

Además, debido a que OpenResty anterior no estaba completamente vinculado a LuaJIT y aún soporta Lua estándar, algunos códigos antiguos harán esto para compatibilidad. Si no se encuentra la función table.new, se simulará una función vacía para garantizar la uniformidad del llamador.

local ok, new_tab = pcall(require, "table.new")
  if not ok then
    new_tab = function (narr, nrec) return {} end
  end

Cálculo manual del subíndice de table

Una vez que tienes un objeto table, el siguiente paso es agregarle elementos. La forma más directa de insertar un elemento es llamar a la función table.insert:

local new_tab = require "table.new"
 local t = new_tab(100, 0)
 for i = 1, 100 do
   table.insert(t, i)
 end

Alternativamente, primero obtén la longitud del array actual e inserta elementos usando índices:

local new_tab = require "table.new"
 local t = new_tab(100, 0)
 for i = 1, 100 do
   t[#t + 1] = i
 end

Sin embargo, ambos métodos necesitan calcular primero la longitud del array y luego agregar nuevos elementos. Esta operación tiene una complejidad de tiempo de O(n). En el ejemplo de código anterior, el bucle for calculará la longitud del array 100 veces, por lo que el rendimiento no es bueno, y cuanto más grande sea el array, menor será el rendimiento.

Veamos cómo la biblioteca oficial lua-resty-redis resuelve este problema.

local function _gen_req(args)
    local nargs = #args

    local req = new_tab(nargs * 5 + 1, 0)
    req[1] = "*" .. nargs .. "\r\n"
    local nbits = 2

    for i = 1, nargs do
        local arg = args[i]
        req[nbits] = "$"
        req[nbits + 1] = #arg
        req[nbits + 2] = "\r\n"
        req[nbits + 3] = arg
        req[nbits + 4] = "\r\n"
        nbits = nbits + 5
    end
    return req
end

Esta función pre-genera el array req, cuyo tamaño está determinado por los parámetros de entrada de la función, para garantizar que se desperdicie la menor cantidad de espacio posible.

Utiliza la variable nbits para mantener manualmente el subíndice de req, en lugar de usar la función table.insert incorporada en Lua y el operador # para obtener la longitud.

Puedes ver que en el bucle for, algunas operaciones como nbits + 1 están insertando elementos directamente como subíndices y manteniendo el subíndice en el valor correcto al final con nbits = nbits + 5.

La ventaja de esto es obvia, omite la operación O(n) de obtener el tamaño del array y en su lugar accede directamente con el índice, y la complejidad del tiempo se convierte en O(1). La desventaja también es obvia, reduce la legibilidad del código y la probabilidad de error aumenta enormemente, por lo que es una espada de doble filo.

Reutilización de una sola table

Dado que el costo de crear una table es tan alto, naturalmente queremos reutilizarla tanto como sea posible. Sin embargo, hay condiciones para la reutilización. Primero necesitamos limpiar los datos originales en la table para evitar impactos en el siguiente usuario.

Aquí es donde la función table.clear es útil. Por su nombre puedes ver lo que hace, limpia todos los datos en el array, pero la longitud del array no cambia. Es decir, si usas table.new(narray, nhash) para generar un array de longitud 100, después de limpiarlo, la longitud sigue siendo 100.

Para que tengas una mejor idea de su implementación, he dado un ejemplo de código a continuación que es compatible con Lua estándar:

local ok, clear_tab = pcall(require, "table.clear")
  if not ok then
    clear_tab = function (tab)
      for k, _ in pairs(tab) do
        tab[k] = nil
      end
    end
  end

Como puedes ver, la función clear establece cada elemento en nil.

Generalmente, colocamos este tipo de table circular en el nivel superior de un módulo. De esta manera, cuando usas las funciones en el módulo, puedes decidir si usarlas directamente o después de limpiarlas, dependiendo de tu situación.

Veamos un ejemplo práctico de aplicación. El siguiente pseudo-código está tomado del gateway de API de microservicios de código abierto Apache APISIX, y esta es su lógica al cargar el plugin.

local local_plugins = core.table.new(32, 0)
local function load(plugin_names, wasm_plugin_names)
    local processed = {}
    for _, name in ipairs(plugin_names) do
        if processed[name] == nil then
            processed[name] = true
        end
    end
    for _, attrs in ipairs(wasm_plugin_names) do
        if processed[attrs.name] == nil then
            processed[attrs.name] = attrs
        end
    end

    core.log.warn("new plugins: ", core.json.delay_encode(processed))

    for name, plugin in pairs(local_plugins_hash) do
        local ty = PLUGIN_TYPE_HTTP
        if plugin.type == "wasm" then
            ty = PLUGIN_TYPE_HTTP_WASM
        end
        unload_plugin(name, ty)
    end

    core.table.clear(local_plugins)
    core.table.clear(local_plugins_hash)

    for name, value in pairs(processed) do
        local ty = PLUGIN_TYPE_HTTP
        if type(value) == "table" then
            ty = PLUGIN_TYPE_HTTP_WASM
            name = value
        end
        load_plugin(name, local_plugins, ty)
    end

    -- sort by plugin's priority
    if #local_plugins > 1 then
        sort_tab(local_plugins, sort_plugin)
    end

    for i, plugin in ipairs(local_plugins) do
        local_plugins_hash[plugin.name] = plugin
        if enable_debug() then
            core.log.warn("loaded plugin and sort by priority:",
                          " ", plugin.priority,
                          " name: ", plugin.name)
        end
    end

    _M.load_times = _M.load_times + 1
    core.log.info("load plugin times: ", _M.load_times)
    return true
end

Puedes ver que el array local_plugins es la variable de nivel superior para el módulo plugin. Al inicio de la función load, se limpia la table, y se genera una nueva lista de plugins basada en la situación actual.

tablepool

Hasta ahora, has dominado la optimización de recorrer una sola tabla. Luego puedes ir un paso más allá y usar un pool de caché para mantener múltiples tablas disponibles para su acceso en cualquier momento, que es la función del lua-tablepool oficial.

El siguiente código muestra el uso básico de un pool de tablas. Podemos obtener una tabla de un pool especificado y liberarla de nuevo cuando terminemos de usarla:

local tablepool = require "tablepool"
local tablepool_fetch = tablepool.fetch
local tablepool_release = tablepool.release


local pool_name = "some_tag"
local function do_sth()
     local t = tablepool_fetch(pool_name, 10, 0)
     -- -- using t for some purposes
    tablepool_release(pool_name, t)
end

tablepool utiliza varios de los métodos que presentamos anteriormente, y tiene menos de cien líneas de código, por lo que recomiendo buscarlo y estudiarlo tú mismo. Aquí, principalmente presentaré sus dos APIs.

El primero es el método fetch, que toma los mismos argumentos que table.new, pero con un pool_name adicional. fetch llamará a table.new para crear un nuevo array si no hay un array libre en el pool.

tablepool.fetch(pool_name, narr, nrec)

El segundo es release, una función que devuelve la tabla al pool. Entre sus argumentos, el último, no_clear, se usa para configurar si se llama a table.clear para limpiar el array.

tablepool.release(pool_name, tb, [no_clear])

¿Ves cómo todos los métodos que presentamos anteriormente ahora están relacionados? Sin embargo, ten cuidado de no abusar de tablepool por esta razón. tablepool no se usa mucho en proyectos reales. Por ejemplo, no lo usa Kong, y APISIX tiene solo unas pocas llamadas relacionadas con él. En la mayoría de los casos, incluso sin la encapsulación de esta capa tablepool, es suficiente para que nosotros usemos.

Resumen

La optimización de rendimiento, un área difícil en OpenResty, es un punto caliente. Hoy te presenté consejos de optimización de rendimiento relacionados con table. Espero que puedan ayudarte con tu proyecto real.

Finalmente, piensa en una pregunta: ¿Puedes hacer una prueba de rendimiento tú mismo y comparar la diferencia de rendimiento antes y después de usar técnicas de optimización relacionadas con table? Nuevamente, bienvenido a dejar un comentario y comunicarte conmigo, ya que me encantaría conocer tu práctica y puntos de vista. Bienvenido a compartir este artículo para que más personas puedan participar juntas.