Haskell · Cabal · rendimiento de compilación
Paralelizar cabal-install: cómo las builds de Haskell usan tus núcleos
-j de hoy.En 2011, compilar un proyecto Haskell con un gran árbol de dependencias era una tarea secuencial y pesada: cabal install compilaba un paquete, luego el siguiente, y el siguiente — dejando ociosa la mayor parte de una máquina multinúcleo. Un proyecto del Google Summer of Code de aquel año abordó exactamente esto: enseñar a cabal-install a compilar paquetes independientes en paralelo. Esa idea está ahora integrada en toda build de Haskell. Aquí está cómo funciona y cómo sacarle el máximo partido hoy.
La clave: una build es un DAG
Un plan de instalación es un grafo acíclico dirigido de paquetes. Si aeson y vector dependen ambos solo de base, no dependen entre sí — así que no hay razón para compilarlos uno tras otro. Puedes compilar a la vez cada paquete cuyas dependencias ya estén compiladas, limitado únicamente por cuántos núcleos quieras usar.
Concretamente, el compilador:
- Ordena topológicamente el grafo de dependencias.
- Mantiene un conjunto "listo" de paquetes cuyas dependencias están todas compiladas.
- Ejecuta hasta N builds de ese conjunto de forma concurrente; a medida que cada una termina, cualquier paquete recién desbloqueado se une al conjunto listo.
En un grafo de dependencias ancho y una máquina de 8 o 16 núcleos, eso convierte una build en frío de varios minutos en algo drásticamente más corto.
Usarlo hoy (2026)
El paralelismo a nivel de paquete está activado por defecto y se puede ajustar con -j:
$ cabal build -j # use all available cores
$ cabal build -j4 # cap at 4 concurrent package builds O configúralo de forma persistente en cabal.project (o ~/.cabal/config):
# cabal.project
jobs: 4 Esto es seguro hoy de una forma que no lo era al principio, gracias a las builds locales al estilo Nix (consulta Cabal 2.0): cada combinación de paquete-versión-flags se compila en un almacén global direccionado por contenido, así que dos builds paralelas nunca pueden pisarse la salida la una a la otra. El aislamiento hace que el paralelismo agresivo no tenga riesgo.
Dos capas de paralelismo
En realidad hay dos ejes independientes, y entender ambos es la forma de evitar sobrecargar tu CPU:
- A nivel de paquete (
cabal build -j): compilar varios paquetes independientes a la vez. Este es el descendiente del trabajo de 2011. - A nivel de módulo (el propio
-jde GHC): dentro de un solo paquete, GHC puede compilar módulos independientes en paralelo. Lo activas mediante opciones de GHC, p. ej.ghc-options: -j.
La trampa es multiplicarlos: 8 paquetes paralelos × 8 módulos paralelos cada uno = hasta 64 hilos de GHC concurrentes en una máquina de 8 núcleos, lo que provoca thrashing en lugar de ayudar.
La solución moderna: un semáforo de build
Las versiones recientes de GHC y Cabal resuelven el problema de la sobrecarga con un semáforo de trabajos compartido. En lugar de que cada paquete genere de forma independiente N trabajos de módulo, Cabal le entrega a GHC un único semáforo para que la concurrencia total — entre todos los paquetes y todos los módulos — se mantenga limitada por un solo número:
# cabal.project
jobs: 8
package *
ghc-options: -jsem # coordinate module + package parallelism
# via a shared semaphore Con un semáforo, "8 trabajos" significa como máximo 8 tareas de compilación de GHC ejecutándose a la vez en total, ya vengan de los módulos de un paquete grande o de ocho paquetes pequeños — exactamente el comportamiento correcto en una máquina de 8 núcleos.
Ajuste práctico
- Builds en frío de CI:
cabal build -j(todos los núcleos) es casi siempre lo más ventajoso — el grafo de dependencias es ancho y las máquinas de CI están ociosas de todos modos. - Desarrollo interactivo: considera dejar uno o dos núcleos libres (
-j$(($(nproc)-2))) para que tu editor/LSP siga respondiendo durante una recompilación. - El límite suele ser la memoria, no los núcleos. GHC puede usar mucha RAM por compilación; si una build paralela empieza a hacer swap, baja
-j. Los núcleos son baratos; la presión sobre la RAM es lo que realmente atasca las builds grandes de Haskell. - Mide una vez: cronometra una build limpia con
-j2,-j4,-jen tu propio hardware y conjunto de dependencias — el punto óptimo depende de ambos.
La conclusión
El trabajo del GSoC de 2011 respondió a una pregunta que todavía importa: una build es un grafo de dependencias, y los nodos independientes deberían compilarse de forma concurrente. Quince años después esa idea es la predeterminada, hecha segura por el almacenamiento direccionado por contenido y refinada por los semáforos de build. Si tus builds de Haskell aún se sienten secuenciales, casi con seguridad estás dejando núcleos sin aprovechar — empieza con cabal build -j.