wandres.dev
RENDIMIENTO · el runtime de Elm

Paquetes pequeños: optimize, eliminación de código muerto y por qué las aplicaciones de Elm pesan poco

Que una aplicación de Elm compilada ocupe unas pocas decenas de kilobytes comprimidos, incluyendo su runtime completo y todas sus dependencias, suele atribuirse a que el lenguaje es pequeño. La explicación es insuficiente y esconde lo interesante. El peso reducido es la consecuencia acumulada de decisiones de diseño que hacen posible una eliminación de código muerto genuina: compilación de programa completo en lugar de por módulos, ausencia total de reflexión, de evaluación dinámica y de acceso por nombre calculado, imposibilidad de que un módulo ejecute efectos al cargarse, y un gestor de paquetes que impide tener dos versiones distintas de la misma biblioteca en el mismo grafo. Esta lección examina cada una de esas piezas, explica qué hace exactamente la bandera optimize y qué pide a cambio, muestra el proceso completo de compilación, minificación y compresión con las opciones concretas que el propio compilador recomienda, y termina fijando un método para medir el resultado y para diagnosticar de dónde viene el peso cuando una aplicación crece más de lo esperado.

⏱ 16 min

La discusión sobre el tamaño de los paquetes suele plantearse como una competición entre herramientas de empaquetado, y esa forma de plantearla oculta el hecho decisivo: eliminar código que no se usa no es un problema de la herramienta, es un problema del lenguaje que la herramienta debe analizar. Un empaquetador que reciba un lenguaje donde una función puede invocarse por un nombre calculado en tiempo de ejecución, donde un módulo puede tener efectos al cargarse, donde existe la reflexión y donde el código puede construirse como texto y evaluarse después, no puede demostrar que nada es inalcanzable. Puede intentarlo, con análisis conservadores y con anotaciones que el programador promete cumplir, y el resultado será casi siempre correcto y ocasionalmente no, motivo por el cual esas herramientas ofrecen listas de excepciones y modos estrictos que hay que ajustar a mano. Elm no tiene ese problema y no lo tiene por astucia del compilador sino porque ninguna de esas construcciones existe: no hay forma de referirse a un valor que no esté escrito literalmente en el código fuente. Bajo esa condición, la alcanzabilidad deja de ser una estimación y pasa a ser un cálculo exacto sobre un grafo, y lo que sobra se puede tirar sin miedo. Esta lección recorre esa cadena entera, desde la propiedad del lenguaje hasta el fichero servido al navegador.

🎯 Al terminar esta lección sabrás
  • Explicar por qué la eliminación de código muerto es exacta en Elm y aproximada en la mayoría de los ecosistemas.
  • Enumerar qué hace la bandera --optimize, qué prohíbe a cambio y por qué esa prohibición es coherente.
  • Ejecutar el proceso completo de compilación, minificación y compresión con las opciones recomendadas.
  • Medir el resultado con criterio y diagnosticar el origen del peso cuando una aplicación crece de forma inesperada.

Por qué aquí sobra de verdad lo que sobra

El compilador analiza el programa entero de una vez, no módulo a módulo, y construye el grafo de dependencias entre todos los valores definidos partiendo de main. Lo que no aparece en ese grafo no se emite. La propiedad que convierte ese cálculo en exacto y no en una aproximación prudente es la ausencia de cualquier forma de referirse a un valor sin nombrarlo: no hay reflexión, no hay evaluación de cadenas como código, no hay acceso a un miembro por una clave calculada, no hay carga condicional de módulos. Si una función no está escrita en ninguna parte alcanzable, es demostrablemente inalcanzable.

A esa propiedad se suman otras dos que rara vez se mencionan y que pesan tanto como ella. La primera es que un módulo no puede ejecutar nada al cargarse: sus definiciones son valores, no sentencias con efectos, así que importar un módulo para usar una sola función no obliga a conservar el resto por si alguna parte tuviera un efecto observable. Ese es exactamente el motivo por el que la eliminación de código muerto en otros ecosistemas se queda corta tan a menudo, ya que el analizador debe conservar todo aquello cuyo efecto al cargar no pueda descartar. La segunda es que el gestor de paquetes impone una única versión de cada biblioteca en todo el grafo de dependencias, con lo que desaparece la duplicación de copias distintas de la misma biblioteca que engorda tantos paquetes sin que nadie lo advierta.

Conviene apreciar la diferencia entre esto y lo que hacen los empaquetadores al uso, porque la palabra que emplean es la misma y el resultado no. Un empaquetador razona sobre módulos y sobre exportaciones, y su unidad de decisión suele ser el fichero o, en el mejor de los casos, la exportación con nombre; además debe respetar cualquier construcción que no sepa analizar, y la lista de esas construcciones es larga en un lenguaje dinámico. El compilador de Elm razona sobre definiciones individuales dentro de un grafo único, así que puede descartar una función de un módulo del que conserva otras diez sin necesidad de que nadie haya estructurado el código pensando en ello. La granularidad y la certeza son distintas, y ambas diferencias empujan en la misma dirección.

🕸️

Programa completo

El grafo de alcanzabilidad se calcula desde main sobre todo el código a la vez, no módulo a módulo con fronteras opacas.

🚫

Sin nombres calculados

Sin reflexión, sin evaluación dinámica, sin acceso por clave calculada. Lo inalcanzable se demuestra en lugar de estimarse.

🧼

Módulos sin efectos

Importar no ejecuta nada. No hay que conservar código por si su carga tuviera consecuencias observables.

📦

Una versión por biblioteca

El gestor de paquetes prohíbe copias distintas de la misma dependencia en el mismo grafo, así que no se duplica nada.

Qué hace exactamente la bandera y qué pide a cambio

Compilar con --optimize no activa un empaquetador distinto: aplica un conjunto de transformaciones que solo son válidas si el programa renuncia a la depuración. Acorta los nombres de los campos de los registros, que en una aplicación con registros grandes es un ahorro apreciable porque esos nombres aparecen repetidos en cada acceso y en cada actualización. Elimina la información que el inspector de estado necesita para mostrar modelos y mensajes de forma legible, que no es poca. Reduce las representaciones de las uniones etiquetadas cuando su forma lo permite. Y habilita transformaciones locales del código generado que el modo de desarrollo deja pasar para conservar la trazabilidad.

Hay un matiz que conviene fijar porque se confunde a menudo: la eliminación de lo inalcanzable no depende de la bandera y ocurre siempre, incluso en el modo de desarrollo, porque es una consecuencia de cómo se construye el grafo y no una optimización opcional. Lo que la bandera aporta es otra cosa, a saber, un conjunto de transformaciones sobre el código que sí se emite. Confundir ambas cosas lleva a conclusiones equivocadas al comparar cifras, ya que la diferencia entre compilar con la bandera y sin ella no mide en absoluto la eficacia del análisis de alcanzabilidad, sino solo el peso de los nombres y de los metadatos de depuración.

A cambio prohíbe cualquier uso del módulo de depuración. Esa prohibición desconcierta la primera vez y es enteramente coherente: las funciones de trazado necesitan convertir cualquier valor en texto legible, lo cual exige conservar los nombres de campos y de constructores que la optimización acaba de borrar. No es una restricción arbitraria sino la consecuencia directa de la transformación, y tiene el efecto colateral saludable de que ninguna traza de depuración puede llegar a producción por descuido.

# Desarrollo: nombres intactos y depurador disponible
elm make src/Main.elm --output=app.js

# Produccion: transformaciones activadas y depuracion prohibida
elm make src/Main.elm --optimize --output=elm.js
⚠️
La bandera sola no basta: el segundo paso es donde está la mitad del ahorro

Es un malentendido frecuente creer que --optimize produce ya el fichero final. Lo que produce es código preparado para que un minificador haga bien su trabajo, y sin ese segundo paso se deja sobre la mesa una parte muy grande de la reducción. La razón es que el compilador genera funciones auxiliares para la aplicación parcial cuyos nombres son conocidos y que carecen de efectos, de modo que se le puede indicar al minificador que puede eliminarlas y reordenarlas con seguridad. Esa información es la que permite pasar de la compresión ordinaria a la agresiva sin riesgo, porque la garantía la aporta el compilador y no una conjetura del minificador.

El proceso completo y cómo medirlo

Existe además un compilador alternativo mantenido por la comunidad que aplica transformaciones adicionales sobre la salida del oficial y que suele mejorar tanto el tamaño como la velocidad de arranque. Es una herramienta legítima y bien probada, pero conviene adoptarla después de haber medido con el proceso estándar y no antes, porque introduce una pieza más entre el fuente y el fichero servido y eso complica el diagnóstico cuando algo va mal.

La secuencia recomendada tiene tres etapas y conviene ejecutarlas siempre juntas para que las cifras que se comparen signifiquen lo mismo. Primero la compilación optimizada. Después la minificación en dos pasadas: una de compresión, en la que se declaran como puras las funciones auxiliares que genera el compilador, y otra de acortamiento de nombres. Por último la compresión del servidor, que es la etapa cuya cifra debe usarse para hablar del coste real, porque es la que el usuario descarga.

elm make src/Main.elm --optimize --output=elm.js

uglifyjs elm.js \
  --compress 'pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe' \
  | uglifyjs --mangle --output elm.min.js

gzip -9 -c elm.min.js | wc -c
flowchart LR
F[Codigo fuente] --> C[Compilacion de programa completo]
C --> D[Eliminacion exacta de lo inalcanzable]
D --> O[Bandera optimize acorta campos y quita depuracion]
O --> M[Minificacion en dos pasadas]
M --> G[Compresion del servidor]
G --> U[Lo que descarga el usuario]
style D fill:#a6e3a1,color:#11111b
style O fill:#89b4fa,color:#11111b
style U fill:#cba6f7,color:#11111b

Conviene automatizar esa secuencia desde el primer día en lugar de reconstruirla cada vez que hay que desplegar. Un guion breve que compile, minifique, comprima e imprima la cifra resultante convierte el tamaño en una magnitud observada de forma continua y no en un descubrimiento desagradable el día del lanzamiento. La misma medida, registrada a lo largo del tiempo, hace visible el crecimiento gradual, que es la forma en que los paquetes engordan de verdad: nunca de golpe, sino unos pocos kilobytes por dependencia añadida sin que nadie los sume.

# Un umbral explicito convierte el tamano en una condicion verificable
BYTES=$(gzip -9 -c elm.min.js | wc -c)
echo "Comprimido: $BYTES bytes"
test "$BYTES" -lt 60000 || echo "Aviso: el paquete supera el umbral acordado"

Medir bien exige dos precauciones. La primera es comparar siempre la cifra comprimida y nunca la del fichero en disco, porque el código generado es muy repetitivo y se comprime extraordinariamente bien, de modo que las dos magnitudes no guardan una proporción constante y las comparaciones entre ellas engañan. La segunda es medir el conjunto de lo que se descarga y no solo el fichero compilado: una aplicación de treinta kilobytes acompañada de trescientos en tipografías, iconos y bibliotecas del anfitrión no es una aplicación ligera, y el esfuerzo puesto en recortar el primero se desperdicia si nadie mira los segundos.

💡
Cuando el tamaño crece de golpe, el sospechoso casi nunca es el código de la aplicación

Un crecimiento repentino admite pocas causas y conviene revisarlas en orden. La primera es haber compilado sin la bandera, que es sorprendentemente frecuente cuando el despliegue cambia de manos. La segunda es la incorporación de datos literales grandes en el fuente, como tablas de traducción o conjuntos de constantes, que el compilador conserva enteros porque son alcanzables. La tercera es haber añadido una dependencia que arrastra un módulo grande del cual se usa una función; aunque lo inalcanzable se elimine, lo alcanzable puede ser mucho, sobre todo con bibliotecas de análisis o de manipulación de fechas. Comprobar las tres antes de sospechar del compilador ahorra una tarde entera.

Qué queda dentro del fichero

Saber qué contiene el resultado ayuda a interpretar las cifras y a decidir dónde merece la pena actuar. Hay tres bloques. El primero es el runtime, con el bucle de eventos, los gestores de efectos que el programa realmente usa y la implementación del DOM virtual; es un coste fijo y modesto que se paga una vez y no crece con la aplicación. El segundo son las partes alcanzables de las bibliotecas del núcleo y de las dependencias, que crecen solo con lo que se usa de verdad. El tercero es el código propio, que en una aplicación mediana suele ser sorprendentemente pequeño una vez acortados los nombres de campos.

Falta una pieza que en otros ecosistemas es central y que aquí no existe: no hay división automática del código en fragmentos cargados bajo demanda. La ausencia se nota poco porque el punto de partida es bajo, pero conviene saber qué opciones quedan cuando una aplicación crece lo suficiente para que importe. La primera es compilar programas independientes para zonas del producto que nunca se visitan juntas, como el panel de administración y la parte pública, cada uno con su propio fichero. La segunda es incrustar un programa secundario dentro de un elemento personalizado que el anfitrión carga solo cuando hace falta. Ambas son decisiones de arquitectura y no ajustes del empaquetado, con la ventaja de que cada frontera se declara explícitamente en lugar de emerger de la heurística de una herramienta.

# Dos programas independientes, cada uno con su propio fichero
elm make src/Publico.elm --optimize --output=publico.js
elm make src/Panel.elm --optimize --output=panel.js

# Comparar siempre la cifra comprimida, que es la que se descarga
for f in publico panel; do printf "%s: " "$f"; gzip -9 -c "$f.js" | wc -c; done
📝
El tiempo de análisis pesa tanto como los bytes en un teléfono modesto

Reducir el tamaño no importa solo por la descarga. Todo el código descargado ha de analizarse y compilarse antes de que se ejecute la primera línea, y ese trabajo ocurre en el hilo principal, con lo que retrasa el primer dibujado y consume batería. En un teléfono de gama baja la diferencia entre unas decenas de kilobytes y unos cientos deja de ser una cuestión de red para convertirse en segundos de pantalla en blanco. Cuando midas, mide también ese tramo en el perfilador, porque es el que percibe el usuario y el que ninguna cifra de tamaño refleja por sí sola.

El peso de un programa es una medida de cuánto tuvo que suponer quien lo analizó

La costumbre de tratar el tamaño de un paquete como un asunto de herramientas es un error de atribución muy revelador, porque el tamaño no lo decide la herramienta sino cuánto puede demostrar la herramienta sobre el código que recibe. Un analizador que trabaja sobre un lenguaje en el que un valor puede alcanzarse por un nombre construido en tiempo de ejecución no tiene manera de saber que nadie lo hará, y ante la duda debe conservar; un analizador que no puede descartar que importar un módulo tenga consecuencias observables debe conservar también. Cada conservación por precaución es una cesión de terreno, y el peso final de un paquete es, con bastante exactitud, la suma de todas las cesiones que el analizador se vio obligado a hacer. Vistos así, los kilobytes que se descargan de más son la factura del dinamismo, presentada al usuario final en forma de tiempo de descarga, de tiempo de análisis sintáctico y de batería consumida en un teléfono modesto. El asunto excede con mucho al empaquetado y describe una regla general que conviene tener presente al elegir cualquier tecnología: la potencia expresiva que un lenguaje concede en tiempo de ejecución se cobra siempre en el poder de razonamiento de todo lo que lo analiza, y ese poder de razonamiento es el que después reaparece convertido en optimizaciones, en refactorizaciones seguras, en mensajes de error precisos y en herramientas que no necesitan conjeturar. Elm no pesa poco porque su biblioteca estándar sea corta, que no lo es tanto, ni porque su runtime sea diminuto, aunque lo es. Pesa poco porque decidió muy pronto que no habría ninguna forma de nombrar un valor que no estuviera escrita en el código, y esa decisión, que en su momento pudo parecer una limitación puritana, es la que permite que la pregunta de qué código sobra tenga una respuesta exacta en lugar de una estimación prudente. La lección que conviene llevarse es que las garantías baratas no existen, pero las garantías se acumulan: la misma renuncia que hace posible el cortocircuito por referencia, la memoización segura de una vista y el versionado semántico verificado es la que hace posible este último resultado.

⚔️ Mide, recorta y explica de dónde viene cada byte
  1. Compila un mismo proyecto con y sin la bandera, mide ambos ficheros comprimidos y explica de dónde sale la diferencia.
  2. Añade el paso de minificación con las opciones recomendadas y razona por qué declarar puras esas funciones auxiliares es seguro.
  3. Intenta compilar en modo optimizado con una traza de depuración presente y explica por qué el compilador lo rechaza.
  4. Importa un módulo grande, usa solo una de sus funciones y comprueba con mediciones cuánto acaba en el fichero final.
  5. Enumera tres construcciones de otro lenguaje que impidan demostrar la inalcanzabilidad y describe qué se ve obligado a conservar.
  6. Argumenta en contra de esta lección: defiende el dinamismo en tiempo de ejecución y enumera con honestidad qué se paga por él.