coldwa.st
Todas las guíasProgramaciónWebDatosHerramientasBases de datosHaskellConceptosCabal y buildsToolchainCompiladorRendimientoEditor y HLS

Haskell · Cabal · rendimiento de compilación

Paralelizar cabal-install: cómo las builds de Haskell usan tus núcleos

Por ColdwastActualizado el 13 de junio de 20266 min de lectura#haskell#cabal#performance
Carriles de compilación paralela que muestran paquetes Haskell independientes compilando de forma concurrente con cabal build -j
Los paquetes independientes del plan de instalación se compilan de forma concurrente — la idea central detrás de las builds con -j de hoy.
Reescritura actualizada y mantenida por la comunidad. El proyecto original del Google Summer of Code de 2011 "Parallelising cabal-install" lo realizó el anterior propietario del dominio; esta guía es texto nuevo y original sobre el tema y no reproduce ni reclama la autoría de aquel trabajo. Explica la idea y la pone al día con el toolchain de 2026.

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:

  1. Ordena topológicamente el grafo de dependencias.
  2. Mantiene un conjunto "listo" de paquetes cuyas dependencias están todas compiladas.
  3. 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)

Líneas de código fuente en una pantalla oscura
Líneas de código fuente en una pantalla oscura

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 -j de 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, -j en 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.


Relacionado: Cabal 2.0 y las builds al estilo Nix · Los sandboxes de Cabal y lo que los reemplazó · todas las guías