Por qué tu Emacs no va más rápido con 72 cores (monohilo, NUMA y numactl)


26 de junio de 2026

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 aprovecha varios cores no es Emacs en sí, sino lo que Emacs lanza:

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
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_maps

Lo que esto NO hace (honestidad)

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.

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario