wandres.dev
ÁMBITO Y GLOBALES · _ENV lo explica todo

El coste de un global: una lectura es un acceso a tabla

Qué instrucción emite el compilador al leer un nombre libre, cómo medir con honestidad la diferencia frente a una local, el idioma de cachear funciones de la biblioteca estándar en locales, y el criterio para saber cuándo esa optimización importa de verdad y cuándo es ruido.

⏱ 15 min

Existe un consejo repetido en todo código Lua serio: cachear en locales las funciones de la biblioteca estándar que se usan dentro de un bucle. Suele presentarse como folclore, sin justificación y sin medida. La justificación es exacta y se ve en el bytecode: leer una local es leer un registro; leer un global es indexar una tabla con una clave de texto, y math.sqrt son dos indexaciones encadenadas. La medida es más matizada de lo que el folclore sugiere, y saber cuándo la diferencia es relevante —y cuándo cachear te cuesta más de lo que te da— es lo que separa el idioma comprendido del culto a la carga.

🎯 Al terminar esta lección sabrás
  • Leer en el bytecode la diferencia entre acceder a una local, a un upvalue y a un global.
  • Construir un banco de pruebas honesto y saber interpretar lo que mide.
  • Aplicar el idioma de cachear la biblioteca estándar en locales de módulo.
  • Decidir con criterio cuándo la optimización importa y qué se paga por ella.

Qué ocurre realmente al leer un global

Como veremos en detalle en 9.3, un nombre libre x no es una categoría especial de variable: el compilador lo traduce a _ENV.x, donde _ENV es un upvalue. El bytecode lo deja a la vista. Compila este fichero con luac -l -l:

local function con_global (n) return math.sqrt(n) end

local sqrt = math.sqrt
local function con_local (n) return sqrt(n) end

La primera función emite algo equivalente a GETTABUP 0 0 0 ; _ENV "math" seguido de GETFIELD 0 0 1 ; "sqrt". La segunda emite un solo GETUPVAL. Es decir: donde la versión cacheada hace una lectura directa de un hueco, la versión con global hace dos búsquedas en tabla hash encadenadas, una para math en el entorno y otra para sqrt dentro de math.

Conviene ser justo con la implementación: esas búsquedas están muy optimizadas. Las cadenas cortas en Lua son internalizadas, así que la comparación de claves es de punteros y el hash está precalculado y guardado en el objeto cadena. Un acceso a global no es caro en términos absolutos. Pero sigue siendo un acceso a tabla con todo lo que ello implica: leer el upvalue, comprobar el tipo, calcular la posición en el nodo, seguir la cadena de colisiones y —si la clave no estuviera— consultar la metatabla.

La escritura es simétrica y suele olvidarse. contador = contador + 1 sobre un global emite un GETTABUP y un SETTABUP: dos accesos a tabla por iteración, y el de escritura puede además provocar el rehash de la tabla del entorno si la clave es nueva, o disparar __newindex si hay metatabla. Un acumulador global en un bucle es, con diferencia, el peor patrón medible del lenguaje.

total = 0
for i = 1, 1e6 do total = total + i end   -- GETTABUP + SETTABUP por vuelta

local suma = 0
for i = 1, 1e6 do suma = suma + i end     -- aritmetica sobre registros, sin tablas
ℹ️
Un global no tiene coste acotado

Si el entorno tiene una metatabla con __index, una lectura de global puede ejecutar código Lua arbitrario. Eso es justo lo que hace el clásico modo estricto que veremos en 9.4. La consecuencia teórica es fuerte: el coste de leer una local está acotado por el diseño del lenguaje; el de leer un global no lo está en absoluto, porque depende de qué haya en el entorno en ese instante.

Medirlo con honestidad

El error habitual es medir un bucle vacío y extrapolar. Un banco de pruebas mínimamente serio compara el mismo trabajo con la misma cantidad de iteraciones y ejecuta las variantes varias veces alternándolas, para diluir el ruido del sistema y del recolector:

local N = 20e6

local function medir (etiqueta, f)
  collectgarbage()                 -- estado de memoria comparable
  local t0 = os.clock()
  local acc = f(N)
  local dt = os.clock() - t0
  print(string.format("%-12s %.3f s  (acc=%.0f)", etiqueta, dt, acc))
end

local sqrt = math.sqrt             -- la cache

medir("global", function (n)
  local acc = 0
  for i = 1, n do acc = acc + math.sqrt(i) end
  return acc
end)

medir("local", function (n)
  local acc = 0
  for i = 1, n do acc = acc + sqrt(i) end
  return acc
end)

Lo importante no es el número que salga en tu máquina, sino cómo se comporta ese número. La versión cacheada gana siempre, porque hace estrictamente menos trabajo, pero la ventaja relativa se desploma en cuanto el cuerpo del bucle hace algo sustancial. Si el bucle solo lee el nombre, la diferencia es muy visible; si además abre un fichero, construye una tabla o llama a una función que hace un trabajo real, la diferencia se diluye hasta ser irrelevante. Es la ley de Amdahl aplicada al nivel más humilde posible: aceleras un porcentaje minúsculo del programa.

Añade una tercera variante que llame a sqrt a través de una tabla local, como t.sqrt(i), y verás que el coste no era “ser global”: era indexar. Un campo de una tabla propia cuesta prácticamente lo mismo que un global. Lo que abarata la local es haber resuelto la indexación en tiempo de compilación, no la ausencia de la palabra global.

El idioma de cachear la biblioteca estándar

En la cabecera de un módulo de rendimiento crítico verás siempre esta forma, y ahora sabes exactamente qué compra:

-- Cabecera canonica de un modulo caliente
local math, string, table = math, string, table
local sqrt, floor, max = math.sqrt, math.floor, math.max
local format, byte, sub = string.format, string.byte, string.sub
local concat, insert = table.concat, table.insert
local ipairs, pairs, type, select = ipairs, pairs, type, select
local setmetatable, rawget = setmetatable, rawget

Cada línea funciona por la regla de ámbito de 9.1: el nombre de la derecha todavía no está declarado, así que se resuelve al entorno. A partir de ahí, cada uso dentro del módulo es un GETUPVAL desde las funciones internas y un acceso a registro en el propio chunk.

El idioma tiene precio, y es honesto enumerarlo. Fijas el valor en el momento de cargar el módulo: si alguien sustituye después string.format —recarga en caliente, instrumentación, un mock en un test, un perfilador—, tu módulo seguirá usando la función vieja. Si el módulo se ejecuta bajo un entorno alternativo, tampoco lo verá. Consumes upvalues, con un techo de 255 por función. Y ensucias la cabecera con veinte líneas que no dicen nada del dominio del problema. Cachear es congelar, y congelar es renunciar a la indirección que hacía dinámico al programa.

Cuándo importa de verdad

La regla profesional es corta: cachea lo que se usa dentro de un bucle caliente, y no toques nada más. Un módulo de configuración que se ejecuta una vez al arrancar no gana nada medible; un decodificador que procesa un millón de mensajes por segundo sí. Perfila antes, y solo luego escribe la cabecera.

Estos cuatro casos cubren casi todas las decisiones reales:

🔥

Bucle caliente: cachea

Un cuerpo que se ejecuta millones de veces y llama a dos o tres funciones de la biblioteca por iteración. Aquí la cabecera de locales es obligatoria y se nota en el perfil.

🧊

Código de arranque: no toques

Configuración, registro de comandos, construcción de tablas que ocurre una vez. Cachear no aporta nada medible y sí resta legibilidad y capacidad de recarga.

🌉

Puente con C: cachea la referencia

Cuando el nombre cruza a código nativo, como las funciones de la API de Neovim, evitar la doble indexación ahorra trabajo real antes incluso de la llamada.

🧪

Código instrumentable: no caches

Si un test, un perfilador o una recarga en caliente deben poder sustituir la función, congelarla en un upvalue rompe justo la indirección que necesitas.

Hay un matiz de ecosistema que decide el asunto en la práctica. Bajo LuaJIT, el compilador de trazas suele elevar fuera del bucle las lecturas invariantes, con lo que el idioma pierde buena parte de su ventaja en el código que llega a compilarse; en el intérprete y en el código frío la conserva íntegra. Bajo el Lua de referencia —el intérprete puro— la ventaja es siempre real, aunque pequeña en términos absolutos. Y en Neovim el argumento cambia de naturaleza: acceder a vim.api.nvim_buf_set_lines cruza además el puente entre Lua y C, y ahí cachear la referencia sí evita trabajo apreciable en un bucle.

flowchart TD
A[Uso de un nombre en el codigo] --> B[Es local o upvalue]
B -- si --> C[Acceso a un hueco resuelto al compilar]
B -- no --> D[Se traduce a un campo de _ENV]
D --> E[Buscar la clave en la tabla entorno]
E -- encontrada --> F[Valor listo]
E -- ausente --> G[Consultar la metatabla del entorno]
G --> H[Puede ejecutar codigo arbitrario]
El coste real de un global nunca fueron los ciclos

Medir la diferencia entre math.sqrt y una local cacheada es un ejercicio útil, pero el número que sale es la parte menos interesante del asunto. Lo que la medición revela es que en Lua un global no es una variable: es una consulta a una estructura de datos mutable, viva y compartida por todo el programa, resuelta en el instante mismo de la ejecución. Y eso tiene tres consecuencias que ningún perfilador te va a poner en pantalla. La primera es que el coste no está acotado: si el entorno tiene un __index, tu inocente lectura de nombre puede disparar código arbitrario, con lo que ni siquiera puedes razonar sobre la complejidad de una línea sin saber qué entorno la está ejecutando. La segunda es que el valor no está garantizado: entre la línea que escribiste y la que se ejecuta, cualquier módulo cargado, cualquier corrutina despertada o cualquier plugin de terceros pudo cambiar esa entrada; el compilador no puede verificar nada y tú tampoco. La tercera es la más importante: cachear en una local no es sobre todo una optimización, es un acto de fijación semántica. Estás diciendo, por escrito y de forma verificable, que a partir de aquí ese nombre significa este valor y ya no puede cambiar bajo tus pies. Que además vaya más rápido es casi un efecto secundario del hecho de que el compilador, por fin, sabe de qué estás hablando. Cuando entiendes esto dejas de cachear por superstición de rendimiento y empiezas a cachear por control del significado, que es lo que este nivel entero va a formalizar en 9.5 con las globales declaradas.

⚔️ Perfila antes de creer
  1. Escribe el fichero de las dos funciones y ejecútalo con luac -l -l; identifica GETTABUP, GETFIELD y GETUPVAL y cuenta las instrucciones de cada versión.
  2. Ejecuta el banco de pruebas y anota la razón entre ambos tiempos. Repítelo tres veces y observa la dispersión.
  3. Añade una tercera variante con t.sqrt(i) sobre una tabla local y comprueba que el coste era indexar, no ser global.
  4. Añade trabajo real al cuerpo del bucle —una concatenación, una tabla nueva— y mide cómo se derrumba la ventaja relativa.
  5. Instala una metatabla con __index en el entorno que imprima la clave buscada y comprueba de primera mano que una lectura de global puede ejecutar código tuyo.