Por qué tu Emacs no va más rápido con 72 cores (monohilo, NUMA y numactl)
Tengo una workstation con dos Xeon, 72 hilos lógicos, una burrada de RAM. Y Emacs, en esa bestia, se siente igual que en un portátil modesto. ¿Cómo puede ser? La respuesta tiene tres capas: Emacs es monohilo, las máquinas de doble socket tienen un peaje oculto (NUMA), y hay un truco de una línea para quitar ese peaje. Vamos por partes.
Capa 1: Emacs es monohilo (y no va a cambiar)
El corazón de Emacs —el intérprete de Emacs Lisp y el redisplay— corre en un solo hilo. Da igual que tengas 4 cores o 72: el bucle principal usa uno.
¿Y los "hilos" de Emacs Lisp (make-thread, desde Emacs 26)? Son
cooperativos: comparten un lock
global, solo corre uno a la vez.
Sirven para no congelar la UI mientras algo trabaja de fondo, no para
repartir trabajo entre cores. Es concurrencia, no paralelismo.
CPU con 8 cores, Emacs corriendo:
core0 core1 core2 core3 core4 core5 core6 core7
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│EMACS│ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ ███ │ │ · │ │ · │ │ · │ │ · │ │ · │ │ · │ │ · │
└─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘
▲ └────────────── ociosos para Emacs ──────────────┘
│
todo el peso aquí → manda la VELOCIDAD DE UN SOLO CORE
Lo que sí aprovecha varios cores no es Emacs en sí, sino lo que Emacs lanza:
native-comp(Emacs 28+): paraleliza la compilación de paquetes (varios procesos a la vez). En runtime, monohilo.- Subprocesos: el servidor LSP,
ripgrep,git, el parser de tree-sitter, la shell… corren aparte y usan otros cores. Pero eso es el ecosistema, no el bucle de Emacs.
Conclusión incómoda: para que Emacs se sienta rápido, lo que importa es el single-core. Una CPU vieja con 72 hilos pero single-core flojo pierde contra un portátil moderno con single-core fuerte. Punto.
Capa 2: el peaje oculto del doble socket (NUMA)
Aquí viene lo interesante. En una máquina de doble socket (dos CPUs físicas), la memoria RAM está dividida: una parte "cuelga" de la CPU 0, otra de la CPU 1. Es lo que se llama NUMA (Non-Uniform Memory Access): no toda la memoria está a la misma distancia.
┌───────── NODO 0 ─────────┐ ┌───────── NODO 1 ─────────┐
│ CPU 0 (cores 0..17) │ │ CPU 1 (cores 18..35) │
│ │ │ │ │ │
│ ┌─────┴─────┐ │ │ ┌─────┴─────┐ │
│ │ RAM_0 │ ◄─────┼───┼────► │ RAM_1 │ │
│ │ (rápida │ LENTO │ │ │ (rápida │ │
│ │ para 0) │ cross-│ │ │ para 1) │ │
│ └───────────┘ socket│ │ └───────────┘ │
└──────────────────────────┘ └──────────────────────────┘
Acceso LOCAL (CPU0 → RAM_0): rápido
Acceso REMOTO (CPU0 → RAM_1): cruza el enlace entre sockets → +latencia
El problema: el planificador del kernel, intentando balancear carga, puede mover el proceso de Emacs de un socket al otro. Y si su memoria se quedó en el nodo 0 pero ahora ejecuta en el nodo 1, cada acceso a memoria es remoto → latencia extra, caché fría. No es un cuelgue, es un jitter: micro-tirones que notas justo en lo interactivo (scroll, autocompletado, redisplay).
SIN pinear: Emacs rebota entre sockets, su memoria se queda atrás
t0: [NODO 0] EMACS▓ mem aquí ✓ [NODO 1]
t1: [NODO 0] mem aquí [NODO 1] EMACS▓ ← accede REMOTO ✗
t2: [NODO 0] EMACS▓ mem aquí ✓ [NODO 1]
└── el scheduler lo pasea, la memoria no le sigue (o tarde) ──┘
Capa 3: el fix de una línea
— numactl
La herramienta numactl permite
fijar un proceso a un nodo NUMA
concreto y, de paso, obligar a que su memoria viva en ese mismo
nodo:
numactl --cpunodebind=0 --membind=0 tu-programa--cpunodebind=0→ el proceso solo corre en los cores del nodo 0 (no rebota a otro socket).--membind=0→ su memoria se reserva en el nodo 0 (siempre local).
CON pinear: Emacs clavado en el nodo 0, memoria siempre local
t0: [NODO 0] EMACS▓ mem aquí ✓ [NODO 1]
t1: [NODO 0] EMACS▓ mem aquí ✓ [NODO 1] ← nunca cruza
t2: [NODO 0] EMACS▓ mem aquí ✓ [NODO 1]
└── acceso siempre LOCAL: sin jitter, caché caliente ──┘
Un nodo entero (en mi caso, 18 cores físicos + su RAM local) es de sobra para Emacs y sus subprocesos LSP. No lo estás ahogando: le das un socket limpio.
Cómo lo dejé permanente (NixOS / home-manager)
El daemon de Emacs corre como servicio systemd --user. Basta envolver el ExecStart con numactl:
# Antes:
ExecStart = "${emacs}/bin/emacs --fg-daemon";
# Después (fijado al nodo NUMA 0 + memoria local):
ExecStart = "${numactl}/bin/numactl --cpunodebind=0 --membind=0 "
+ "${emacs}/bin/emacs --fg-daemon";numactl fija la política y hace exec de Emacs: el proceso
hereda el binding y luego se reemplaza por Emacs. Por
eso si miras el cmdline verás emacs a secas, pero la política sigue aplicada.
Verificarlo:
# afinidad de CPU (debe listar solo los cores del nodo elegido)
grep Cpus_allowed_list /proc/$(pgrep -f 'emacs --fg-daemon')/status
# política de memoria (debe decir bind:0)
grep -m1 bind /proc/$(pgrep -f 'emacs --fg-daemon')/numa_mapsLo que esto NO hace (honestidad)
- No sube los GHz. El single-core es una pared de silicio. Esto quita el jitter de NUMA, no acelera el core. La diferencia es "consistente y sin tirones", no "el doble de rápido".
- En máquinas de un solo socket no
sirve. La inmensa mayoría de portátiles y desktops
(incluido cualquier Apple Silicon) tienen un solo nodo
NUMA →
numactl --cpunodebind=0sería un no-op. Esto es exclusivo de bestias de doble socket. - Si algo satura la CPU, te roba turbo. Una CPU sube su frecuencia máxima en un core solo si los demás están tranquilos. Compilaciones, contenedores o cualquier carga all-core tiran el turbo de todo el paquete → tu single-core efectivo baja. Para máximo rendimiento interactivo, mantén los cores de Emacs libres de cargas pesadas mientras trabajas.
Resumen
┌──────────────────────────────────────────────────────────┐
│ ¿Por qué Emacs no aprovecha mis 72 cores? │
│ │
│ 1. Emacs es MONOHILO → manda el single-core │
│ 2. Doble socket = NUMA → riesgo de memoria remota │
│ 3. numactl --cpunodebind → lo clavas a un nodo, │
│ --membind memoria siempre local │
│ │
│ Resultado: mismo techo de GHz, pero SIN jitter. │
└──────────────────────────────────────────────────────────┘
Si tu workstation es de doble socket y notas micro-tirones en Emacs que no explicas, mira el NUMA antes de culpar a tu config. A veces el editor está bien; es el kernel paseándolo entre sockets.
Coda: las cosas que le quitan el sueño a un nerd
Publiqué esto y al rato me llegó el mejor feedback posible: "ostia, esos microtirones eran ESTO, ya decía yo". No se los estaba imaginando — eran reales, y ahora no están.
Y ahí está el matiz que más me gusta: el techo de velocidad no subió ni un MHz. Lo que cambió es que ahora es consistente. A veces "ir fino" no es ir más rápido — es ir SIEMPRE igual de rápido, sin baches. El cerebro humano nota los baches mucho más que la media: un editor que va a 100 y de vez en cuando cae a 80 molesta más que uno que va siempre a 85.
Por eso un microtirón cada pocos minutos —algo que ni siquiera aparece en un benchmark— es justo de esas cosas que le quitan el sueño a un nerd. No es el rendimiento medio. Es el bache que no deberías estar notando… y notas.
Comentarios (0)
Sin comentarios todavia. Se el primero!
Deja un comentario