wandres.dev
LORO · texto rico y árboles

La apuesta: escrito en Rust, compilado a WebAssembly

Loro es una biblioteca de Rust compilada a WebAssembly con el rendimiento como objetivo declarado desde el principio, y esa decisión traslada una parte del coste al arranque del módulo.

⏱ 20 min

Las otras dos bibliotecas de este bloque nacieron dentro del ecosistema de JavaScript y se fueron optimizando desde dentro. Loro empieza en el extremo contrario: es una biblioteca de Rust cuyo objetivo declarado desde la primera línea fue el rendimiento, y el navegador es solo uno de sus destinos, alcanzado mediante una compilación a WebAssembly y una capa fina de enlace. Esa elección no es una nota de color sobre el lenguaje de implementación. Determina qué estructuras de datos son viables dentro de la biblioteca, determina que la memoria del documento viva fuera del montículo de JavaScript y del recolector de basura, y determina que exista un artefacto binario que hay que descargar, compilar e instanciar antes de que la primera operación pueda ejecutarse. Esta lección desarma la apuesta en sus dos mitades —lo que compra y lo que cuesta— y localiza con precisión el punto donde se paga la factura.

🎯 Al terminar esta lección sabrás
  • Entender por qué escribir el núcleo en Rust habilita representaciones que en JavaScript serían impracticables.
  • Distinguir las tres fases del arranque de un módulo WebAssembly y cuál de ellas domina el coste.
  • Situar el peso del binario de Loro frente a lo que su propia documentación recomienda evaluar.
  • Diseñar una estrategia de carga que saque ese coste del camino crítico del primer render.

El núcleo en Rust y la capa de enlace

La primera consecuencia de escribir el núcleo en un lenguaje compilado con control de la disposición de memoria es que las estructuras de datos dejan de estar limitadas por lo que el motor de JavaScript representa bien. La documentación de Loro lo dice sin rodeos al describir su contenedor de texto: internamente es un árbol B, todas las operaciones básicas de inserción y borrado son logarítmicas respecto al tamaño del documento, y por eso supera con holgura a las operaciones nativas de cadena de JavaScript cuando el documento crece hasta varios millones de caracteres.

Ese detalle merece atención porque es contraintuitivo. Una cadena nativa de JavaScript está implementada en C++ dentro del motor y es extremadamente rápida para lo que hace; lo que no hace bien es insertar en el medio, porque cada inserción reconstruye la representación. Un árbol B mantenido en memoria lineal de WebAssembly gana no por estar escrito en Rust sino por permitirse una representación que en JavaScript habría que emular con objetos, y emularla habría multiplicado la presión sobre el recolector de basura hasta hacerla inviable.

// La API cruza la frontera con operaciones gruesas, no por caracter
import { LoroDoc } from "loro-crdt";

const doc = new LoroDoc();
const texto = doc.getText("contenido");
texto.insert(0, "Hola mundo");     // una llamada, no diez
const bytes = doc.export({ mode: "snapshot" });

La segunda consecuencia es la disposición en memoria. La documentación enumera entre sus decisiones de diseño la fusión de elementos en memoria por codificación de longitud de secuencia: una ráfaga de mil inserciones consecutivas del mismo autor no ocupa mil registros sino uno solo con un rango. Esa compactación exige poder decidir el formato exacto de cada registro, y es justo lo que un lenguaje con control de representación permite y uno con objetos genéricos no.

Donde vive cada cosa

  memoria de JavaScript
    el objeto envoltorio del documento
    las cadenas que pides al leer
    los objetos de evento que recibes

  memoria lineal de WebAssembly
    el arbol B con el texto y sus anclas
    el registro de operaciones comprimido
    las identidades temporales de cada fusion

  entre las dos solo viajan copias

La tercera consecuencia es la que menos se menciona y quizá la más relevante para una aplicación con documentos grandes: la memoria del documento no está bajo la vigilancia del recolector de basura de JavaScript. Un árbol de cientos de miles de nodos vivos en el montículo del motor es una fuente permanente de pausas, porque cada ciclo de recolección tiene que recorrerlo para decidir qué sigue alcanzable. Dentro de la memoria lineal del módulo ese recorrido no existe: para el motor de JavaScript, todo el documento es un único bloque opaco de bytes.

ℹ️
El lenguaje no es la ventaja; es la condición de posibilidad

Conviene formular esto con cuidado para no caer en el folclore de que Rust es rápido y por tanto la biblioteca es rápida. Rust no ejecuta más rápido un mal algoritmo. Lo que aporta es que estructuras con disposición controlada —árboles B con recuentos por subárbol, rangos comprimidos, memoria contigua— dejan de ser un ejercicio heroico y pasan a ser el camino natural. La ventaja medible viene de la representación; el lenguaje es lo que la hace escribible y mantenible.

El módulo: descargar, compilar, instanciar

Aquí aparece el otro lado del trato. Un módulo WebAssembly no es código que el navegador interprete a medida que lo lee: es un artefacto que atraviesa tres fases antes de ejecutar nada. Se descarga por la red, el motor lo compila a código máquina, y después se instancia enlazando sus importaciones con el entorno de JavaScript. Solo entonces existe una función llamable.

flowchart LR
A[peticion del modulo wasm] --> B[descarga por la red]
B --> C[compilacion a codigo maquina]
C --> D[instanciacion y enlace con JS]
D --> E[primera operacion del documento]
style B fill:#f9e2af,color:#11111b
style C fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b

El tamaño importa en las dos primeras fases y por razones distintas. En la descarga importa por ancho de banda, y ahí la compresión ayuda. En la compilación importa por volumen de código a traducir, y ahí la compresión no ayuda en absoluto: el motor compila el binario descomprimido. La propia documentación de Loro cifra su binario de WebAssembly en unos 970 kilobytes comprimidos con gzip, y lo hace precisamente en la sección donde recomienda considerar alternativas, listando la sensibilidad al tamaño del paquete como una de las cuatro razones para no elegir la biblioteca.

Conviene distinguir dos escenarios que la palabra carga confunde. En la primera visita de un usuario las tres fases se pagan enteras y en serie, salvo que se use compilación en flujo, que solapa la descarga con la traducción. En las visitas siguientes el navegador puede tener el módulo ya compilado en su caché, y entonces lo único que queda es la instanciación, que es comparativamente barata. Cualquier medición que solo cubra uno de los dos escenarios está describiendo media realidad, y casi siempre la media que conviene a quien la publica.

💡
Mide en el dispositivo malo, no en el bueno

La fase de compilación depende del procesador de forma mucho más directa que la de descarga, y esa dependencia es lo que hace engañosa cualquier medida tomada en una máquina de desarrollo. Un teléfono de gama media puede tardar varias veces más que un portátil reciente en traducir el mismo binario, y ese multiplicador se aplica a un coste que ya era el mayor de los tres. Si tu público usa teléfonos, la única medida honesta es la tomada en un teléfono con la caché vacía.

⚠️
El coste de arranque es fijo y se paga aunque el documento esté vacío

Es la asimetría que hay que interiorizar. El coste por operación de Loro es bajísimo y mejora con el tamaño del documento; el coste de arranque es constante y no mejora con nada. Un documento de dos párrafos paga exactamente el mismo arranque que uno de diez millones de caracteres. Cualquier razonamiento sobre si compensa tiene que separar estas dos partidas, porque se comportan de forma opuesta.

La frontera y por qué aquí duele menos

El nivel 16 dejó establecido que cruzar entre dos mundos de memoria cuesta, y el nivel 12 mostró la misma frontera en el caso de una base de datos compilada. Aquí el fenómeno es idéntico: los datos del documento viven en la memoria lineal del módulo, y JavaScript solo ve identificadores y copias. Cada valor que cruza se serializa o se copia.

Lo que hace que ese coste sea tolerable en el caso de Loro es la granularidad de la API. Insertar una cadena entera es una llamada. Importar actualizaciones remotas es una llamada con un arreglo de bytes. Exportar una instantánea es una llamada que devuelve otro arreglo de bytes. El árbol B, las identidades, el registro de operaciones y el estado del documento nunca cruzan: se quedan dentro. La frontera se atraviesa un número de veces proporcional a las interacciones del usuario, no al tamaño del documento.

// Lo caro no es operar; es materializar el documento entero en JS
const delta = texto.toDelta();     // copia toda la representacion rica
const plano = texto.toString();    // copia el texto entero
const trozo = texto.slice(0, 200); // copia solo lo que se va a pintar

Hay dos puntos donde esa disciplina se rompe con facilidad y conviene tenerlos fichados. El primero es materializar el documento completo en cada render, con toJSON o con toDelta, cuando basta con una rebanada. El segundo es la conversión de índices: el enlace de WebAssembly expone los índices de texto en unidades de UTF-16 para casar con las cadenas de JavaScript, mientras que dentro conviven varias métricas de longitud, y esa traducción no es gratuita si se hace miles de veces por segundo.

El corolario práctico es que la forma de tu capa de vista determina más el rendimiento observado que la biblioteca misma. Una interfaz que pide el documento entero en cada pulsación convierte una estructura logarítmica en un coste lineal por tecla, y lo hace fuera de la biblioteca, donde ninguna optimización de la biblioteca puede rescatarla. Suscribirse a los eventos y aplicar diferencias, en lugar de releer el estado completo, no es un refinamiento tardío: es la condición para que todo lo anterior signifique algo.

Amortizar la apuesta

La pregunta práctica no es si el módulo pesa, sino dónde colocar su carga para que nadie la note. Y la respuesta tiene una forma reconocible: el arranque es un coste fijo que se puede mover en el tiempo, mientras que el coste por operación no se puede mover a ninguna parte.

// El modulo se pide cuando hace falta, no cuando se pinta la pagina
let cargando = null;

function documento() {
  if (!cargando) {
    cargando = import("loro-crdt").then((m) => new m.LoroDoc());
  }
  return cargando;
}

// La vista de lectura se pinta desde una instantanea plana
// y solo la primera pulsacion de edicion dispara la carga real

Las cuatro estrategias que siguen no compiten entre sí; se combinan, y cada una ataca una fase distinta del arranque. Antes de aplicarlas conviene decidir cuál es el instante que de verdad quieres proteger, porque no es el mismo en todas las aplicaciones: en un editor puede ser el momento en que el usuario ve texto legible, aunque todavía no pueda escribir; en una herramienta de dibujo puede ser el primer trazo. Optimizar sin haber nombrado ese instante produce mejoras en números que a nadie le importan.

🦀

Compilación en flujo

Compilar mientras se descarga elimina la secuencialidad de las dos primeras fases y recorta la latencia percibida sin cambiar una línea de la aplicación.

🧵

Instanciar en un worker

Si el documento vive en un hilo aparte, la compilación no compite con el primer pintado y la interfaz aparece antes de que exista el CRDT.

Carga diferida

La vista de solo lectura puede renderizarse desde una instantánea plana; el módulo se carga cuando el usuario va a editar de verdad.

💾

Caché del módulo compilado

En visitas repetidas el binario ya está en la caché del navegador, y las fases de descarga y compilación dejan de dominar la medida.

Un coste fijo grande no es lo mismo que un coste alto, y confundirlos arruina la decisión

Si esta lección deja una sola idea trasladable a decisiones que nada tienen que ver con CRDT, es esta. Cuando se compara una biblioteca compilada contra una nativa del entorno, la tentación es sumar las dos partidas y quedarse con el total; y ese total es siempre engañoso porque está compuesto por dos magnitudes con dimensiones distintas: una constante que se paga una vez por sesión y una pendiente que se paga por cada unidad de trabajo. Una biblioteca con constante grande y pendiente baja pierde todas las comparaciones triviales y gana todas las que importan cuando hay trabajo real. Lo decisivo, entonces, es calcular el punto de cruce y compararlo con la carga de trabajo esperada, no con la del caso de demostración. Y hay una segunda mitad del argumento que suele olvidarse: la constante es negociable y la pendiente no. El arranque se puede mover a un worker, diferir hasta la primera edición, precargar durante una pantalla de autenticación o servir ya calentado por la caché; ninguna de esas maniobras existe para el coste por operación, que aparece una y otra vez mientras el usuario trabaja. Frente a dos costes, ataca siempre el que no puedes reprogramar en el tiempo y acepta el que sí puedes, porque el primero define el techo de lo que la aplicación podrá hacer y el segundo solo define cuándo empieza a hacerlo. Esta asimetría, además, explica un fenómeno recurrente en el ecosistema: las bibliotecas compiladas suelen perder los concursos de tamaño de paquete y ganar los de escala, y quien elige mirando solo la primera tabla acaba reescribiendo su aplicación al cabo de un año.

⚔️ Mide tu punto de cruce en lugar de discutirlo
  1. Instrumenta la carga separando descarga, compilación e instanciación con marcas de rendimiento del navegador.
  2. Repite la medida con la caché fría y con la caché caliente, y anota cuánto cambia cada fase.
  3. Mide el coste por operación de una inserción en un documento vacío y en uno de un millón de caracteres.
  4. Con esos dos números, calcula a partir de cuántas operaciones el arranque deja de dominar el total.
  5. Mueve la instanciación a un worker y vuelve a medir el tiempo hasta el primer pintado, no el tiempo total.
  6. Escribe la conclusión en una frase que empiece por el tamaño de documento a partir del cual la apuesta se paga.