wandres.dev
RENDIMIENTO · un editor instantáneo

Latencia durante la edicion: cuando el editor se atasca

Por que un bucle de eventos de un solo hilo convierte cualquier autocomando caro en tiron perceptible, presupuesto de tiempo por pulsacion y amortiguacion con temporizadores, coste del resaltado por expresiones regulares frente al arbol sintactico en ficheros grandes, sincronizacion con el servidor de lenguaje, tokens semanticos y diagnosticos, y una guarda para ficheros enormes.

⏱ 24 min

El arranque se mide una vez al día; la latencia de edición se sufre miles de veces. Y sin embargo casi todo el esfuerzo colectivo se dedica al primero, quizá porque produce un número que se puede presumir, mientras que la segunda solo produce una sensación difusa de que el editor a veces se traba. Esa sensación tiene causas perfectamente identificables y casi siempre las mismas: trabajo síncrono enganchado a eventos que se disparan con cada pulsación, resaltado que no escala con el tamaño del fichero, y una conversación con el servidor de lenguaje que nadie ha amortiguado. Este capítulo cierra el nivel bajando el cronómetro de los segundos del arranque a los milisegundos de cada tecla.

🎯 Al terminar esta lección sabrás
  • Explicar por qué un bucle de eventos de un solo hilo convierte cualquier cálculo síncrono en latencia perceptible.
  • Fijar un presupuesto por pulsación y amortiguar los manejadores caros con temporizadores.
  • Diagnosticar el coste del resaltado con syntime y acotar el del árbol sintáctico en ficheros grandes.
  • Domar la conversación con el servidor de lenguaje: sincronización, tokens semánticos y diagnósticos.

Un solo hilo para todo

El editor procesa entrada, ejecuta tu código Lua, redibuja la pantalla y atiende a los plugins en el mismo hilo. Los procesos externos y la entrada y salida son asíncronos, pero el código que tú escribes no lo es: mientras una función tuya se ejecuta, no se procesa ninguna tecla y no se pinta nada. No hay planificador que te quite la ejecución, no hay prioridad que te salve. Un manejador que tarde ochenta milisegundos es un editor congelado ochenta milisegundos, y si está enganchado al movimiento del cursor, es un editor congelado ochenta milisegundos por cada pulsación de una tecla de dirección.

La única vía de escape es la que ofrece la biblioteca de eventos: temporizadores y procesos que devuelven el control inmediatamente y avisan después. Ese aviso llega en un contexto restringido —el llamado contexto rápido— desde el que no se puede tocar el estado del editor, y por eso la regla de oro consiste en aplazar el trabajo real a un momento seguro.

-- Peligroso: se ejecuta dentro del contexto rapido de la biblioteca de eventos
local t = vim.uv.new_timer()
t:start(200, 0, function()
  vim.api.nvim_buf_set_lines(0, 0, -1, false, {})  -- fallara
end)

-- Correcto: aplazado al bucle principal
t:start(200, 0, vim.schedule_wrap(function()
  vim.api.nvim_buf_set_lines(0, 0, -1, false, {})
end))

El presupuesto que conviene tener en la cabeza es concreto. Para que el editor se sienta inmediato, cada pulsación debe resolverse por debajo de la duración de un fotograma, es decir, alrededor de dieciséis milisegundos contando el redibujado. Como el redibujado ya consume una parte de eso, la cifra que debes exigir a tus manejadores es de un milisegundo o menos. A partir de unos cincuenta milisegundos la mayoría de la gente percibe el retraso conscientemente; a partir de cien, lo describe como que el editor va lento.

El coste por pulsación

Los eventos que se disparan con la escritura son pocos y bien conocidos, y toda la latencia autoinfligida vive en ellos: el movimiento del cursor en modo normal y en inserción, el cambio de texto en ambos modos, y la pausa de inactividad, cuya duración depende de la opción updatetime. Enganchar a cualquiera de ellos algo que lea del disco, lance un proceso o recorra el búfer entero es el error clásico.

La respuesta correcta casi nunca es hacer el trabajo más rápido: es hacerlo menos veces. Un temporizador que se reinicia con cada evento y solo dispara cuando la ráfaga cesa convierte doscientas ejecuciones por minuto en cinco, y el usuario no nota ninguna diferencia salvo que el editor ha dejado de trabarse.

local function amortiguar(ms, fn)
  local timer = vim.uv.new_timer()
  return function(...)
    local args = { ... }
    timer:stop()
    timer:start(ms, 0, vim.schedule_wrap(function() fn(unpack(args)) end))
  end
end

local refrescar = amortiguar(150, function(args)
  if vim.api.nvim_buf_is_valid(args.buf) then calcular_algo_caro(args.buf) end
end)

vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { callback = refrescar })
⚠️
La línea de estado se evalúa en cada redibujado

Es el punto caliente más frecuente y el menos sospechado. Todo lo que pongas en la línea de estado se recalcula cada vez que la pantalla cambia, es decir, muchas veces por segundo. Una función que consulte el estado de un repositorio, cuente diagnósticos recorriendo listas o llame a un proceso externo desde ahí es una fuente permanente de latencia. Calcula esos valores en un autocomando amortiguado, guárdalos en una variable y que la línea de estado se limite a leerla.

Existe un segundo tipo de expresión que se evalúa con una frecuencia todavía mayor: la que calcula el nivel de plegado, invocada una vez por línea visible en cada redibujado que afecte al plegado. Una expresión de plegado escrita en Vimscript sobre un fichero grande es una de las pocas cosas capaces de hacer inusable un editor moderno, y la versión nativa en Lua sobre el árbol sintáctico existe precisamente por eso.

flowchart TB
a[Pulsacion de tecla] --> b[Bucle principal de un solo hilo]
b --> c[Autocomandos sincronos]
c --> d[Parseo incremental del arbol]
d --> e[Redibujado y linea de estado]
e --> f[Aviso de cambio al servidor de lenguaje]
f --> g[Diagnosticos tokens semanticos y pistas]
style c fill:#f38ba8,color:#11111b
style e fill:#f9e2af,color:#11111b

Resaltado en ficheros grandes

Hay dos motores de resaltado conviviendo, con perfiles de coste opuestos. El heredado aplica expresiones regulares línea a línea, con estados que se arrastran hacia atrás; su coste depende del patrón, y una expresión con retroceso catastrófico puede multiplicar por cien el tiempo de una sola línea. El moderno construye un árbol sintáctico completo, con un coste inicial proporcional al tamaño del fichero y actualizaciones posteriores proporcionales a lo que cambió.

Para el primero existe un instrumento exacto que casi nadie usa y que resuelve el diagnóstico en dos órdenes.

:syntime clear
:syntime on
" desplaza por el fichero lento durante unos segundos
:syntime report

El informe ordena los patrones por tiempo total y señala el culpable con nombre. Cuando el culpable es inevitable, dos opciones acotan el daño: la que limita hasta qué columna se analiza cada línea y la que fija el tiempo máximo dedicado a redibujar antes de rendirse, ambas pensadas exactamente para ficheros con líneas larguísimas generadas por máquina.

Para el segundo motor el problema es distinto. El análisis incremental es barato, pero el primero no lo es, y hay dos multiplicadores que suelen pasar desapercibidos: las inyecciones de otros lenguajes, que obligan a mantener un bosque de árboles en lugar de uno solo, y las consultas de resaltado que se ejecutan sobre el rango visible en cada redibujado. En ficheros de decenas de megabytes la respuesta razonable no es afinar nada, es apagarlo.

vim.api.nvim_create_autocmd("BufReadPre", {
  callback = function(args)
    local ok, st = pcall(vim.uv.fs_stat, vim.api.nvim_buf_get_name(args.buf))
    if not ok or not st or st.size < 2 * 1024 * 1024 then return end

    vim.b[args.buf].fichero_enorme = true
    vim.opt_local.swapfile = false
    vim.opt_local.undofile = false
    vim.opt_local.foldmethod = "manual"
    vim.opt_local.synmaxcol = 200
    vim.opt_local.wrap = false
    vim.cmd("syntax clear")
    vim.schedule(function() pcall(vim.treesitter.stop, args.buf) end)
  end,
})

El servidor de lenguaje

La conversación con el servidor es asíncrona por diseño, de modo que su lentitud no bloquea el editor, pero sí genera tres costes locales que se pagan en tu hilo. El primero es preparar y enviar el aviso de cambio con cada edición: si la sincronización es completa, cada tecla serializa el búfer entero, lo cual en un fichero grande es exactamente tan caro como suena; si es incremental, solo viaja el rango modificado. El segundo es procesar las respuestas: aplicar tokens semánticos, colocar pistas en línea y dibujar diagnósticos son operaciones sobre marcas extendidas que se ejecutan en tu hilo y escalan con la cantidad de elementos. El tercero es todo lo que enganchas tú a la pausa de inactividad, típicamente el resaltado de las apariciones del símbolo bajo el cursor y la ventana flotante de documentación.

vim.diagnostic.config({
  update_in_insert = false,     -- no recalcular mientras escribes
  virtual_text = { severity = { min = vim.diagnostic.severity.WARN } },
  severity_sort = true,
})

vim.api.nvim_create_autocmd("LspAttach", {
  callback = function(args)
    local cliente = vim.lsp.get_client_by_id(args.data.client_id)
    if not cliente then return end
    if vim.b[args.buf].fichero_enorme then
      vim.lsp.buf_detach_client(args.buf, cliente.id)
      return
    end
    -- Costosos y opcionales: actualos solo donde compensan
    pcall(vim.lsp.semantic_tokens.stop, args.buf, cliente.id)
    vim.lsp.inlay_hint.enable(false, { bufnr = args.buf })
  end,
})

Actualizar diagnósticos mientras escribes es el ajuste que más se nota de todo el capítulo, porque combina las tres fuentes: obliga al servidor a reanalizar con cada tecla, obliga a redibujar texto virtual con cada respuesta y hace que el error aparezca y desaparezca en mitad de una palabra a medio escribir, lo cual además de costar, distrae. Los tokens semánticos y las pistas en línea son igual de reales y menos evidentes: ambos añaden centenares de marcas extendidas por pantalla que hay que recalcular en cada desplazamiento.

La latencia no es lentitud: es una promesa rota sobre quién manda

Conviene enunciar por qué la latencia de edición pesa tanto más de lo que su magnitud sugiere, porque la explicación no es de ingeniería sino de acoplamiento entre una máquina y un sistema nervioso. Escribir en un editor modal es una actividad motora aprendida hasta la automatización, comparable a tocar un instrumento: la mano ejecuta una secuencia entera antes de que la atención llegue a examinarla, y esa automatización solo se sostiene mientras la respuesta sea inmediata y sobre todo predecible. Un retraso de cincuenta milisegundos no te hace escribir un cinco por ciento más lento; rompe el lazo de realimentación, devuelve a la conciencia una tarea que se había vuelto refleja y te obliga a esperar y comprobar, que es lo contrario de fluir. Por eso la variabilidad es peor que la latencia media: un editor que responde siempre en veinte milisegundos se aprende y desaparece; uno que responde en cinco casi siempre y en doscientos de vez en cuando no se aprende nunca, porque la mano no puede automatizar sobre una promesa que a veces se incumple. De ahí sale un criterio de diseño que contradice la intuición acumulativa con la que se configura un editor, y es que el objetivo no es minimizar el tiempo medio sino acotar el peor caso: más vale un resaltado que renuncia a analizar más allá de la columna doscientos y responde siempre, que uno perfecto que se atasca en la línea generada de treinta mil caracteres. Y de ahí sale también la razón última por la que todo este nivel importa. Un editor instantáneo no es un lujo de aficionados a los números ni una competición de arranques; es la condición material que permite que la herramienta se vuelva transparente y desaparezca, dejando entre tú y el texto exactamente nada. Todo lo que has medido en estos cinco capítulos existe para eso: no para que el editor sea rápido, sino para que dejes de notarlo.

⚔️ Bajar el peor caso
  1. Enumera todos tus autocomandos enganchados a movimiento de cursor y a cambio de texto, e instrumenta cada uno para medir su tiempo propio. Ordena por coste multiplicado por frecuencia.
  2. Aplica amortiguación al más caro y comprueba con una medida que la latencia por pulsación baja del milisegundo.
  3. Abre el fichero más grande de tu proyecto, ejecuta el informe de tiempos de sintaxis y localiza el patrón responsable. Acota el daño con el límite de columna y vuelve a medir.
  4. Instala la guarda de ficheros enormes, ajústala a un umbral razonable para tu trabajo y verifica que un fichero de cincuenta megabytes se abre en menos de un segundo.
  5. Desactiva la actualización de diagnósticos en modo inserción, los tokens semánticos y las pistas en línea, escribe durante cinco minutos y reactívalos de uno en uno decidiendo cuál te compensa de verdad.