wandres.dev
RENDIMIENTO · un editor instantáneo

vim.loader: el cache de bytecode de los modulos Lua

Que hace exactamente el cargador de modulos con cache, por que sustituye al buscador estandar de Lua, cuanto ahorra en resolucion de rutas y en compilacion, que costes no elimina en absoluto, donde vive el cache y como invalidarlo, y como comprobar su efecto con una medicion honesta en lugar de con fe.

⏱ 18 min

Hay una línea que aparece en la primera posición de casi todas las configuraciones serias y que casi nadie sabe explicar: la que activa el cargador con caché. Se repite como un amuleto, se le atribuyen mejoras del cincuenta por ciento y rara vez se comprueba. La realidad es más interesante que el mito, porque el mecanismo ataca dos costes muy concretos —encontrar el fichero de un módulo y convertir su texto en bytecode— y no toca en absoluto un tercero que suele ser el dominante. Entender esa frontera es lo que te permite predecir si tu configuración se beneficiará mucho, poco o nada, en lugar de copiar la línea y esperar.

🎯 Al terminar esta lección sabrás
  • Explicar qué hace el cargador con caché frente al buscador de módulos estándar de Lua.
  • Distinguir los tres costes de un require y saber cuáles se eliminan y cuál permanece intacto.
  • Localizar el caché en disco, entender su clave de invalidación y saber vaciarlo.
  • Comprobar el efecto real con una medición reproducible antes y después.

Los tres costes de un require

Cuando pides un módulo por su nombre, ocurren tres cosas distintas y sucesivas que conviene no mezclar, porque cada una tiene un precio y un remedio diferentes.

La primera es la resolución: traducir un nombre con puntos a una ruta en el disco. Lua lo hace recorriendo una lista de patrones, y Neovim añade su propio buscador que consulta todos los directorios del tiempo de ejecución. Con un gestor de plugins moderno esa lista tiene decenas o centenares de entradas, y cada intento fallido es una llamada al sistema que pregunta por un fichero que no existe. Resolver un módulo cuesta, por tanto, del orden de decenas de comprobaciones inútiles antes del acierto.

La segunda es la compilación: leer el texto del fichero y convertirlo en una función. El compilador de LuaJIT es extraordinariamente rápido, pero no gratuito, y un fichero de mil líneas se paga entero aunque solo vayas a llamar a dos de sus funciones.

La tercera es la ejecución del cuerpo del módulo: todo lo que está en el nivel superior del fichero se ejecuta al cargarlo. Si el módulo construye tablas grandes, lee ficheros, consulta el sistema o llama a otros módulos, ese trabajo ocurre ahí.

-- Los tres costes, uno detras de otro, en cada modulo nuevo
local mod = require("mi.modulo")
--            1) buscar mi/modulo.lua por todo el runtimepath
--            2) leer el fichero y compilarlo a bytecode
--            3) ejecutar su nivel superior y guardar el resultado

El cargador con caché ataca el primero y el segundo. El tercero permanece exactamente igual, y en muchas configuraciones es el más caro de los tres.

🔎

Resolución

Recorrer el tiempo de ejecución preguntando por ficheros que casi nunca están. Crece con el número de plugins instalados, no con el de módulos cargados. El caché la elimina.

⚙️

Compilación

Convertir el texto en bytecode. Crece con el tamaño del fichero, aunque solo uses una función de él. El caché la elimina.

🔥

Ejecución

Todo lo que el módulo hace en su nivel superior. Crece con lo que sus autores decidieron hacer al cargar. El caché no la toca en absoluto.

Qué hace el cargador exactamente

Activarlo sustituye el buscador de módulos por otro que mantiene dos índices persistentes en disco. El primero recuerda, para cada nombre de módulo, la ruta en la que se encontró la última vez, de modo que la búsqueda por el tiempo de ejecución desaparece y se convierte en una consulta a una tabla. El segundo guarda el resultado de compilar cada fichero, es decir, su bytecode serializado, de manera que la segunda vez no se compila nada: se carga un bloque binario ya listo.

-- Primerisima linea de init.lua, antes de cualquier require
vim.loader.enable()

La posición no es un detalle estético. Los módulos que ya se hayan cargado antes de esa línea se cargaron por el camino lento, y ese trabajo no se recupera. Si tu configuración llama a un módulo propio para leer opciones y luego activa el cargador, has excluido del beneficio precisamente a la parte que se ejecuta primero.

flowchart TB
a[require de un modulo] --> b[Indice de rutas en cache]
b --> c[Bytecode ya compilado en cache]
c --> d[Ejecucion del nivel superior del modulo]
d --> e[Resultado guardado en package.loaded]
e --> f[Siguientes require devuelven la tabla ya construida]
style d fill:#f38ba8,color:#11111b
style e fill:#a6e3a1,color:#11111b

El nodo marcado en rojo es el que el caché no toca. Y hay un cuarto elemento que conviene no confundir con nada de lo anterior: la tabla de módulos ya cargados del propio intérprete. Un segundo require del mismo módulo dentro de la misma sesión no ejecuta absolutamente nada, devuelve el valor memorizado. Ese caché es de Lua, existe desde siempre y no tiene relación con el de disco.

Qué ahorra realmente y qué no

Con lo anterior en la mano se puede predecir el beneficio sin medir, y luego comprobarlo. Si tu configuración carga muchos módulos pequeños —el caso típico de un gestor de plugins con un fichero de especificación por plugin— la resolución domina y el ahorro es grande, porque estabas pagando centenares de búsquedas en el sistema de ficheros. Si tu configuración carga pocos módulos pero grandes, ganas sobre todo en compilación. Si tu configuración carga módulos que hacen trabajo pesado al ejecutarse, el caché apenas se nota, y la conclusión correcta no es que el mecanismo falle, sino que tu problema está en otro sitio y lo has localizado gratis.

ℹ️
Lo que queda fuera del alcance

El caché es de módulos Lua cargados por require. No cubre los ficheros de Vimscript del tiempo de ejecución, ni los ficheros de un directorio plugin que se leen por ser lo que son, ni los ficheros de detección de tipo, ni nada que se cargue con dofile o loadfile. Si tu arranque está dominado por Vimscript heredado, este capítulo no es tu solución.

También conviene desmontar un temor recurrente: el caché no queda obsoleto cuando editas un fichero. La clave de cada entrada incluye la ruta y las señas del fichero en el sistema, de modo que cualquier modificación invalida la entrada y provoca una recompilación silenciosa en el siguiente arranque. Editar tu configuración y reiniciar funciona igual que siempre; simplemente ese arranque concreto paga la compilación de lo que tocaste.

Sí hay dos escenarios en los que el mecanismo se comporta de forma sorprendente y merece la pena conocerlos antes de perder una tarde. El primero es el bytecode generado por programa: si escribes ficheros Lua desde un guion y los reescribes con la misma longitud dentro de la misma marca temporal, las señas pueden coincidir con las de la versión anterior y el caché servirá código viejo. El segundo es la portabilidad: el bytecode serializado depende de la versión del intérprete y de la arquitectura, de modo que un directorio de configuración compartido entre máquinas distintas por un sistema de sincronización debe excluir el directorio de caché, o cada máquina lo invalidará al arrancar y el ahorro se convertirá en un gasto.

Comprobarlo en tu máquina

La comprobación tiene que aislar el mecanismo, y para eso hay que tener cuidado con dos trampas. La primera es medir el arranque inmediatamente después de activar el cargador, cuando el caché aún está vacío: esa ejecución es más lenta que la normal, porque además de hacer todo el trabajo lo está serializando a disco. La segunda es olvidarse del caché de páginas del sistema, que ya de por sí acelera lecturas repetidas.

# 1) Localiza el directorio de cache y vacialo para partir de cero
nvim --headless +'lua print(vim.fn.stdpath("cache"))' +q
rm -rf "$(nvim --headless +'lua io.write(vim.fn.stdpath("cache"))' +q 2>&1)/luac"

# 2) Un arranque de calentamiento que reconstruye el cache
nvim --headless +q

# 3) Cinco medidas con cache caliente
for i in $(seq 5); do nvim --startuptime /tmp/con.log +q; tail -1 /tmp/con.log | awk '{print $1}'; done

Después basta con desactivarlo temporalmente y repetir la serie. La comparación honesta es mediana contra mediana, sobre la marca del primer redibujado, y con la misma orden de arranque en ambos casos.

-- Instrumentacion directa: cuanto cuesta cargar una lista de modulos
local objetivos = { "lspconfig", "cmp", "telescope", "nvim-treesitter" }
local total = 0
for _, nombre in ipairs(objetivos) do
  package.loaded[nombre] = nil
  local t0 = vim.uv.hrtime()
  pcall(require, nombre)
  local ms = (vim.uv.hrtime() - t0) / 1e6
  total = total + ms
  print(("%-18s %7.3f ms"):format(nombre, ms))
end
print(("%-18s %7.3f ms"):format("TOTAL", total))

Conviene tener una expectativa calibrada antes de mirar el resultado, para no confundir una mejora real con ruido ni una decepción con un fallo. En una configuración pequeña, con una decena de plugins y pocos módulos propios, la diferencia suele quedarse en unos pocos milisegundos y puede ser indistinguible de la variación entre ejecuciones. En una configuración grande, con centenares de ficheros de especificación y un tiempo de ejecución con muchas entradas, el ahorro se cuenta en decenas de milisegundos y domina la resolución sobre la compilación, porque el número de búsquedas fallidas crece con el número de directorios instalados aunque no cargues ni un módulo más. Ese es el criterio para decidir si la investigación merece continuar: si el efecto que mides es mucho menor del que esperabas por el tamaño de tu configuración, casi siempre significa que algo se estaba cargando antes de la línea que activa el cargador.

La API expone además tres operaciones que resuelven los casos raros. Una desactiva el cargador en caliente, útil para comparar sin reiniciar. Otra descarta la entrada de una ruta concreta, pensada para cuando generas ficheros Lua por programa y las señas del sistema no cambian como esperabas. Y una tercera resuelve un nombre de módulo a su ruta sin cargarlo, que es la herramienta de diagnóstico cuando sospechas que estás cargando una copia distinta de la que crees.

vim.loader.disable()                       -- volver al buscador estandar
vim.loader.reset(vim.fn.stdpath("config")) -- invalidar todo lo que cuelgue de ahi
vim.print(vim.loader.find("telescope"))    -- que fichero resolveria este nombre
El caché no es una optimización: es la prueba de que tu arranque estaba pagando dos veces

Merece la pena detenerse en por qué este mecanismo existe y qué revela, porque su lección es más general que su efecto. Un editor extensible arranca resolviendo nombres contra un espacio de búsqueda que él mismo ha hecho enorme al permitir que cada plugin añada un directorio, y compilando desde cero un texto que no ha cambiado desde el arranque anterior. Las dos cosas son trabajo perfectamente determinista: dadas las mismas entradas producen exactamente el mismo resultado, arranque tras arranque, durante meses. Ese es el olor característico de un cálculo que no debería repetirse, y la respuesta canónica en informática lleva décadas siendo la misma, memorizar el resultado y comprobar barato que sigue siendo válido. Lo interesante es la asimetría de precios que hace que la jugada funcione: comprobar unas señas del sistema de ficheros cuesta microsegundos, y compilar mil líneas cuesta milisegundos, tres órdenes de magnitud de diferencia a favor de recordar. Pero la consecuencia que de verdad cambia tu manera de trabajar es la que se deduce por eliminación. Si el caché elimina la resolución y la compilación, y aun así tu arranque sigue siendo lento, entonces todo el tiempo restante está en la ejecución del nivel superior de tus módulos, es decir, en trabajo que tú o los autores de tus plugins habéis decidido hacer al cargar en lugar de al usar. Eso ya no lo arregla ningún caché, porque no es un cálculo repetido: es una decisión de diseño. El cargador, por tanto, no solo acelera; actúa como un filtro que retira del análisis los dos costes mecánicos y deja al descubierto el único que es responsabilidad tuya. Después de activarlo, cualquier milisegundo que quede en el informe tiene un dueño con nombre y apellidos.

⚔️ Verificar en lugar de creer
  1. Vacía el caché, mide cinco arranques con el cargador activo y cinco con él desactivado, y compara medianas sobre la marca del primer redibujado.
  2. Mide además el arranque inmediatamente posterior a vaciar el caché y explica por qué es el más lento de todos.
  3. Ejecuta la instrumentación por módulos sobre tus diez dependencias más pesadas y clasifica cada una según si su coste está en la carga o en la ejecución.
  4. Toma el módulo más caro de la lista y averigua qué hace en su nivel superior. Decide si ese trabajo podría diferirse a la primera llamada real.
  5. Resuelve con la función de búsqueda tres nombres de módulo que sospeches duplicados en tu tiempo de ejecución y comprueba qué copia gana.