La planta de al lado: NUMA, hugepages y aislamiento de CPU, o por qué tu GPU espera al kernel
Segundo post de la serie “por debajo del motor”. El primero abrió el cable entre GPUs (NVLink/NCCL). Este baja al host: los núcleos, la memoria y el kernel que rodean a esas GPUs y que, mal configurados, las dejan esperando. El tercero explicará cómo Kubernetes automatiza todo esto; aquí está la capa cruda, la que hay que entender antes de delegarla.
TL;DR
Un nodo con 4×H100 SXM es, físicamente, un servidor de dos sockets = dos dominios NUMA. Cada socket tiene sus núcleos, sus canales de memoria y carriles PCIe hacia la mitad de las GPUs y NICs. La inferencia no es solo GPU: el host hace trabajo en la ruta caliente de cada token —lanzar kernels CUDA, samplear el siguiente token, tokenizar, mover buffers pinned entre host y GPU, correr los hilos de NCCL. Si esos hilos y su memoria caen en el socket que no es local a la GPU, cada acceso cruza el enlace inter-socket (UPI/Infinity Fabric): 2-3× más lento y con picos de p99. Hay tres palancas del kernel que deciden la cola de latencia y que casi nadie toca: locality (afinidad NUMA: que CPU, memoria, GPU y NIC estén en la misma “planta”), page tables (hugepages: pocas páginas grandes en vez de millones de pequeñas, y pinned memory para DMA), y jitter (aislamiento de CPU con isolcpus/nohz_full/rcu_nocbs + IRQ affinity, para que el kernel no interrumpa al hilo que lanza el siguiente kernel de decode). Este post explica el mecanismo, da los 10 knobs reales, y conecta con el interconnect y el decode latency-bound. Con escepticismo sobre qué knobs mueven la aguja en inferencia y cuáles son cargo-cult heredado del trading de baja latencia.
Dónde estás: el host por debajo del cable
La analogía: la oficina de dos plantas
Imagina una consultora en un edificio de dos plantas. En cada planta hay mesas de trabajo (núcleos de CPU), un archivo con los expedientes (la memoria de ese socket) y un muelle de carga que conecta con el exterior (los carriles PCIe hacia las GPUs y las NICs de ese socket). Un analista trabaja rápido mientras todo lo que necesita está en su planta: alarga el brazo y coge el expediente del archivo de al lado.
El problema empieza cuando el analista está en la planta 1 pero su expediente está en el archivo de la planta 2. Cada vez que lo necesita, coge el ascensor. El trabajo “funciona”, pero cada consulta cuesta un viaje. Si encima el muelle de carga por el que entran sus materiales (su GPU) está en la otra planta, cada entrega cruza el edificio. Esto es NUMA: acceso local (misma planta) es rápido; acceso remoto (otra planta, vía el enlace inter-socket) es 2-3× más lento.
Y hay dos formas más de arruinar a ese analista aunque esté en la planta correcta:
Interrumpirle constantemente. Cada pocos minutos, megafonía, un compañero que pregunta, una alarma de incendios de prueba. Cada interrupción le saca de concentración justo cuando iba a entregar. Esto es el jitter del kernel: el tick del scheduler, las IRQs de dispositivos, los callbacks de RCU, que interrumpen al hilo de host justo cuando iba a lanzar el siguiente kernel de la GPU. El aislamiento de CPU es ponerle en un despacho con el cartel de “no molestar”.
Darle un índice de mil pestañas diminutas. Si para encontrar cada expediente tiene que buscar en un índice con un millón de entradas minúsculas, pierde tiempo en la búsqueda. Si el índice tiene pocas entradas grandes, encuentra al instante. Esto son las hugepages: páginas de 2 MB o 1 GB en vez de 4 KB reducen la presión sobre la TLB (el caché del índice de páginas).
La tesis: la GPU es cara y rápida, pero pasa una fracción sorprendente del decode esperando al host. Si el host está en la planta equivocada, interrumpido, y buscando en un índice gigante, la GPU —el recurso de 30.000 € — espera. Las tres palancas de este post existen para que no espere.
El mecanismo: qué hace el host en la ruta del token
Es tentador pensar que en inferencia “la GPU lo hace todo”. No es cierto. Por cada token, el host (CPU) hace, como mínimo:
- Lanzar los kernels CUDA de cada operación. La GPU no decide qué ejecutar; el host le va poniendo kernels en la cola. En decode, donde cada kernel es corto, el host tiene que ir por delante alimentando la cola; si el hilo de host se para, la GPU se queda sin trabajo: una burbuja.
- Samplear el siguiente token (argmax/top-p/top-k sobre los logits), que vuelve del device al host.
- Tokenizar la entrada y detokenizar la salida.
- Mover buffers fijados (pinned, page-locked) entre host y GPU por DMA: prompts, logits, y en configuraciones con offload, parte del KV cache.
- Correr los hilos de NCCL que coordinan los colectivos del post anterior.
Todo eso es trabajo de CPU y de memoria de host. Y todo eso sufre si:
- El proceso corre en el socket que no es local a su GPU → cada DMA y cada acceso a memoria cruza el inter-socket link.
- El kernel interrumpe los hilos → burbujas en la cola de la GPU.
- La memoria no está fijada o usa páginas pequeñas → page faults, fallos de TLB, y peor: si la memoria de la transferencia DMA no está pinned, el driver hace una copia intermedia.
El mapa: nvidia-smi topo -m
Todo arranca por ver el mapa. nvidia-smi topo -m muestra, para cada GPU, a qué NUMA node y a qué núcleos es local, y por qué tipo de camino habla con cada NIC y con cada otra GPU:
GPU0 GPU1 GPU2 GPU3 NIC0 CPU Affinity NUMA Affinity
GPU0 X NV18 NV18 NV18 PIX 0-31,64-95 0
GPU1 NV18 X NV18 NV18 SYS 0-31,64-95 0
GPU2 NV18 NV18 X NV18 SYS 32-63,96-127 1
GPU3 NV18 NV18 NV18 X SYS 32-63,96-127 1
Léelo así: GPU0 y GPU1 son locales al NUMA node 0 (núcleos 0-31, 64-95); GPU2 y GPU3 al NUMA node 1. NV18 entre GPUs = 18 enlaces NVLink (lo bueno, del post anterior). En la columna NIC: PIX = un solo switch PCIe de por medio (óptimo para GPUDirect RDMA); SYS = el camino cruza el inter-socket (lo peor). La regla: el proceso que sirve sobre GPU0/1 debe pinnearse a los núcleos 0-31/64-95 y a la memoria del node 0; si además usa RDMA, querrás la NIC que esté en PIX con su GPU.
Las matemáticas que importan: un tick del kernel es una burbuja de la GPU
La cifra que conviene interiorizar no es la del ancho de banda, es la del jitter. En decode, el host lanza muchos kernels cortos por token. Si el hilo de host que los lanza es expropiado por el scheduler (un timer tick, una IRQ, un callback de RCU) durante $t_{\text{stall}}$, y la GPU vacía su cola en ese tiempo, aparece una burbuja: la GPU para.
Pon números. Un timer tick típico o el manejo de una IRQ cuesta del orden de decenas de microsegundos de desvío. Si un kernel de decode dura ~50-100 µs y la cola lleva 2-3 kernels en vuelo, un stall de host de 50-100 µs vacía la cola y la GPU se queda parada hasta que el host se reanuda. Multiplica por la frecuencia de interrupciones de un kernel no aislado (el tick por defecto es de 250-1000 Hz, más IRQs de red y disco): la cola de p99/p999 del TTFT y del inter-token se llena de estos episodios.
$$ \text{jitter}{p99} \approx f{\text{interrupciones}} \times t_{\text{stall}} \times \mathbb{1}[\text{cola GPU vaciada}] $$
La intuición: en throughput medio apenas se nota (las burbujas se promedian), pero en la cola —que es lo que un SLO mide— el jitter del kernel es un contribuyente de primer orden. Por eso el aislamiento de CPU, que nació en el trading de baja latencia, tiene sentido en el decode de LLMs: es la misma física —un hilo crítico que no puede permitirse que el kernel lo pare.
Y el coste NUMA, en paralelo: un acceso a memoria remota (otra planta) tiene latencia ~1,5-2× la local y la mitad de ancho de banda. Para los buffers pinned que se mueven por DMA en cada paso, y para las estructuras del scheduler de vLLM que viven en host, esa penalización se paga token a token.
Las tres palancas, uno a uno
Locality (NUMA): que todo esté en la misma planta
El objetivo es que el proceso de inferencia que usa GPU0/1 tenga sus núcleos, su memoria y (si aplica) su NIC en el NUMA node 0. En crudo, sin Kubernetes:
# Pinnear proceso a node 0 (cores y memoria) para servir sobre GPU0/1
numactl --cpunodebind=0 --membind=0 \
vllm serve meta-llama/Llama-3-70B --tensor-parallel-size 2
--membind=0 es la clave: fuerza que toda la memoria del proceso se asigne en el node 0. Sin --membind, el kernel puede colocar páginas en el node 1 bajo presión, y empiezas a pagar el ascensor sin saberlo.
Page tables (hugepages): pocas páginas grandes y memoria fijada
Dos cosas distintas bajo el mismo paraguas. Primero, hugepages reducen la presión de TLB para los buffers grandes de host (pinned, KV offload). Segundo, pinned memory (page-locked) es lo que permite DMA directo sin copia intermedia. La trampa silenciosa son las transparent hugepages (THP): su compactación en segundo plano causa picos de latencia, justo lo que no quieres.
Jitter (aislamiento de CPU): el despacho con “no molestar”
Tres parámetros de arranque del kernel, coordinados:
isolcpus=2-31,66-95 # saca estos cores del balanceo del scheduler
nohz_full=2-31,66-95 # tickless: sin timer tick si hay 1 solo hilo runnable
rcu_nocbs=2-31,66-95 # offload de callbacks RCU a cores housekeeping
isolcpus aparta los cores; tú tienes que pinnear los hilos de inferencia ahí (los cores no aislados, 0-1, quedan para el sistema). nohz_full quita el tick periódico (solo funciona si hay un único hilo runnable en el core). rcu_nocbs saca de esos cores el trabajo de RCU. Y aparte, IRQ affinity: mover las interrupciones de dispositivos fuera de los cores de inferencia.
Los 10 knobs donde tocar
Ordenados por impacto/frecuencia. Casi todos son sysctl, parámetros de arranque del kernel o numactl. La referencia de bajo nivel es la guía de low-latency de Rigtorp y la doc de tiempo real de Red Hat.
Knob 1 — nvidia-smi topo -m: ver el mapa antes de tocar nada
Igual que en el post del interconnect: primero el mapa. Qué GPU es local a qué NUMA node y a qué cores, y qué camino (PIX/PHB/SYS) hay a cada NIC. Sin esto, cualquier pinning es a ciegas. La mitad de los problemas de “la inferencia tiene picos de latencia” son procesos corriendo en el socket equivocado sin que nadie lo haya mirado.
Knob 2 — numactl --cpunodebind --membind: pinnear al node local
numactl --cpunodebind=0 --membind=0 <proceso> # cores Y memoria en node 0
numactl --hardware # ver nodes, distancias, memoria libre
El --membind es lo que de verdad importa: sin él, la memoria se dispersa. Es el knob de mayor impacto en el throughput sostenido.
Knob 3 — kernel.numa_balancing=0: apagar la migración automática
sysctl -w kernel.numa_balancing=0
El automatic NUMA balancing del kernel migra páginas entre nodes intentando “acercarlas”, pero ese trabajo en segundo plano causa jitter y, con la memoria ya pinneada por el knob 2, no aporta nada. En nodos de inferencia dedicados, apágalo.
Knob 4 — Hugepages explícitas (1 GB) para buffers de host
# Arranque del kernel, para KV offload / buffers pinned grandes
default_hugepagesz=1G hugepagesz=1G hugepages=32
grep Huge /proc/meminfo # verificar reserva
Útil cuando hay memoria de host en la ruta caliente (vLLM con --cpu-offload-gb, o buffers pinned grandes). Si tu despliegue no toca host memory en caliente, las hugepages explícitas aportan poco —no las pongas por cargo-cult.
Knob 5 — THP en madvise o never: evitar los picos de compactación
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
Las transparent hugepages always ahorran TLB pero su compactación dispara latencia impredecible. Para cargas sensibles a la cola, madvise (solo donde la app lo pide) o never es lo recomendado. Es de los pocos knobs con consenso claro: THP always es malo para la latencia.
Knob 6 — isolcpus: apartar los cores de inferencia del scheduler
isolcpus=2-31,66-95
Saca esos cores del balanceo de carga del scheduler; el sistema (kernel threads, daemons) se queda en los no aislados. Tienes que pinnear explícitamente los hilos de inferencia a los cores aislados (vía numactl/taskset o, en K8s, el CPU Manager del próximo post). Aislar sin pinnear no sirve de nada.
Knob 7 — nohz_full + rcu_nocbs: tickless y sin RCU en los cores críticos
nohz_full=2-31,66-95 rcu_nocbs=2-31,66-95
Quita el timer tick periódico y los callbacks de RCU de los cores de inferencia. Dos avisos de la práctica: nohz_full solo elimina el tick si hay un único hilo runnable en el core (si pinneas dos hilos ahí, vuelve el tick); y nohz_full no es compatible con el driver intel_pstate en algunas configuraciones —hay que validarlo, no asumirlo.
Knob 8 — IRQ affinity: las interrupciones, fuera de los cores de inferencia
systemctl stop irqbalance # o configurarlo para respetar isolcpus
# mover IRQs de un dispositivo a los cores housekeeping (0-1)
echo 3 > /proc/irq/<N>/smp_affinity # máscara de cores 0-1
Una IRQ de red o disco que cae en un core de inferencia es una interrupción directa al hilo que alimenta la GPU. Muévelas a los cores housekeeping. (irqbalance puede respetar isolcpus automáticamente si está configurado).
Knob 9 — CPU governor performance + C-states
cpupower frequency-set -g performance
# evitar que cores en idle entren en C-states profundos (latencia de wakeup)
cpupower idle-set -D 0 # o limitar la profundidad de C-state
Con el governor powersave/ondemand, un core que estaba en idle tarda en subir de frecuencia: latencia de wakeup justo cuando llega trabajo. performance lo mantiene a tope. En servidores dedicados a inferencia, el ahorro energético no compensa la cola de latencia.
Knob 10 — Bloqueo de memoria + swappiness=0
sysctl -w vm.swappiness=0 # no expulsar páginas de la inferencia a swap
# y en la app / contenedor: ulimit -l unlimited (memlock) para pinned memory
Una página de la inferencia que el kernel decide swapear a disco es un page fault de milisegundos en la ruta caliente. swappiness=0 y límites de memlock adecuados (para que el driver pueda fijar memoria) cierran esa puerta. En K8s, esto se traduce en QoS Guaranteed y límites de memoria —el puente al próximo post.
Tabla resumen
| # | Knob | Mecanismo | Qué ataca |
|---|---|---|---|
| 1 | nvidia-smi topo -m | diagnóstico | ver afinidad GPU–NUMA–NIC |
| 2 | numactl --cpunodebind --membind | pinning | locality (la palanca mayor) |
| 3 | kernel.numa_balancing=0 | sysctl | jitter por migración de páginas |
| 4 | hugepages 1G explícitas | boot param | TLB en buffers de host |
| 5 | THP madvise/never | sysfs | picos de compactación |
| 6 | isolcpus | boot param | scheduler fuera de cores críticos |
| 7 | nohz_full+rcu_nocbs | boot param | tick + RCU jitter |
| 8 | IRQ affinity | /proc/irq | interrupciones de dispositivo |
| 9 | governor performance | cpupower | latencia de wakeup de frecuencia |
| 10 | swappiness=0 + memlock | sysctl/ulimit | page faults en caliente |
Cómo se conecta con el resto del stack
Con el interconnect (post anterior). Los hilos de host de NCCL quieren cores locales a su GPU; y para multinodo, la NIC de RDMA debe estar en el camino PIX con su GPU (knob 1). Un GPUDirect RDMA con la NIC bajo el otro socket pierde la mitad de su ventaja. NUMA y NVLink son la misma historia vista desde el host y desde el cable.
Con vLLM y el decode. El decode es latency-bound: el hilo de host que alimenta la cola de kernels es exactamente el que el aislamiento de CPU protege. Y --cpu-offload-gb de vLLM mete memoria de host en la ruta caliente, donde NUMA locality + hugepages (knobs 2, 4) pasan de “fino” a “crítico”. El continuous batching ayuda aquí también: más tokens por iteración amortizan tanto la latencia del colectivo como el coste fijo de los lanzamientos de host.
Con Kubernetes (post siguiente). Todo lo de este post se hace a mano (numactl, taskset, parámetros de arranque). En producción no se hace a mano: el kubelet lo automatiza con CPU Manager, Memory Manager y Topology Manager. El próximo post es exactamente cómo se declara esto para que cada pod de vLLM nazca pinneado al NUMA node correcto, sin scripts.
Con la observabilidad. Los picos de p99 por jitter o por acceso remoto se ven: en DCGM, baja utilización de GPU con la cola llena (burbujas); en métricas de sistema, tráfico inter-socket y CPU migrations. La observabilidad GPU con DCGM es donde se diagnostica un “la GPU está al 60 % y no sé por qué” que muchas veces es el host esperando.
Con capacity planning. Reservar cores para el sistema (housekeeping) y dedicar el resto a inferencia cambia el cálculo de cuántos pods/réplicas caben por nodo. El capacity planning debe contar esos cores reservados, no asumir que las 128 vCPU están disponibles para servir.
Trampas y cosas que no son lo que parecen
Cargo-cult del trading de baja latencia. Muchas guías de isolcpus/nohz_full vienen del HFT, donde se exprime el último microsegundo. En inferencia LLM, el aislamiento de CPU sí ayuda en la cola del decode, pero no esperes el milagro: si tu cuello de botella es el ancho de banda de HBM o el interconnect, aislar cores no mueve la aguja. Mide antes; aplica donde el host es el límite.
Aislar sin pinnear. isolcpus saca los cores del scheduler, pero si no pinneas los hilos de inferencia ahí, esos cores quedan vacíos y la inferencia corre en los housekeeping, peor que antes. Aislar y pinnear van siempre juntos.
--membind olvidado. Pinnear cores pero no memoria (--cpunodebind sin --membind) deja que las páginas se dispersen al otro node bajo presión. El pinning de memoria es la mitad que más se olvida y la que más rinde.
THP always “porque ahorra TLB”. Ahorra TLB y regala picos de latencia por compactación. Para cargas con SLO de cola, es un mal negocio. madvise/never.
nohz_full con dos hilos en el core. El tickless solo funciona con un único hilo runnable por core. Si pinneas dos hilos de inferencia al mismo core aislado, el tick vuelve y has complicado el arranque del kernel para nada.
Suponer la topología en vez de leerla. Servidores distintos cablean GPUs y NICs a sockets distintos. nvidia-smi topo -m y numactl --hardware son la verdad; el diagrama del fabricante es orientativo. Léelo en cada modelo de nodo.
Conclusión
La GPU es el recurso caro, pero en decode pasa una parte sorprendente del tiempo esperando al host: a que lance el siguiente kernel, a que samplee, a que mueva un buffer. Si ese host está en la planta equivocada (NUMA remoto), interrumpido (jitter del kernel) o buscando en un índice gigante (páginas de 4 KB), la GPU se queda con la cola vacía y el p99 se dispara —sin que ningún dashboard de la API diga por qué. De los diez knobs, el primero (leer el mapa con nvidia-smi topo -m) y el segundo (pinnear cores y memoria al node local con --membind) resuelven la mayoría; el aislamiento de CPU (isolcpus/nohz_full/IRQ affinity) es la segunda capa, la que recorta la cola del decode, y tiene sentido donde el host es el límite, no como ritual. La idea que reordena la intuición: la inferencia no es “todo GPU”; es un baile entre GPU y host, y el host baila mejor cerca, sin que le interrumpan, y con pocas páginas grandes. El próximo post enseña cómo Kubernetes coreografía ese baile para cada pod sin un solo script a mano.
Ver también
- NVLink, NVSwitch y NCCL: el cable por el que pasa cada token — el post anterior de la serie; los hilos de host de NCCL y la NIC de RDMA quieren la misma localidad NUMA que aquí se explica.
- Resource managers de RKE2: cómo el kubelet pinnea NUMA por ti — el siguiente post; la automatización declarativa de todo lo que aquí se hace a mano con numactl e isolcpus.
- El stack de inferencia LLM on-premise en siete capas — el edificio completo; este post es el sótano (host/kernel) sobre el que se apoya todo.
- Optimizaciones de decode en vLLM — la fase latency-bound donde el jitter de host se convierte en cola de p99 y donde el aislamiento de CPU rinde.
- Continuous batching — batchear amortiza el coste fijo de los lanzamientos de host además del de los colectivos.
- Observabilidad GPU con DCGM — cómo se ve una GPU “al 60 % sin razón” que en realidad es el host esperando: burbujas, migraciones de CPU, tráfico inter-socket.
- Capacity planning de inferencia on-premise — por qué hay que descontar los cores housekeeping reservados del presupuesto de cómputo por nodo.
- Entornos mixtos NVIDIA + Intel — la afinidad NUMA se complica (y se vuelve más importante) cuando el nodo mezcla GPUs, aceleradores y NICs heterogéneas.
Referencias
- Erik Rigtorp, Low Latency Tuning Guide (isolcpus, nohz_full, IRQ affinity, THP): https://rigtorp.se/low-latency-guide/.
- Red Hat, Usage, constraints and implications of isolcpus=, nohz_full= and rcu_nocbs=: https://access.redhat.com/articles/3720611.
- NVIDIA, nvidia-smi topo -m y matriz de afinidad GPU–NUMA–NIC (CUDA docs / Developer Forums): https://forums.developer.nvidia.com/t/nvidia-smi-topo-m-revisited/216584.
- Chaim Rand, The Crucial Role of NUMA Awareness in High-Performance Deep Learning: https://chaimrand.medium.com/the-crucial-role-of-numa-awareness-in-high-performance-deep-learning-99ae3e8eb49a.
- SUSE Labs, CPU Isolation – nohz_full (part 3): https://www.suse.com/c/cpu-isolation-nohz_full-part-3/.
- Linux kernel, Automatic NUMA Balancing y Transparent Hugepage Support (Documentation/admin-guide): https://docs.kernel.org/admin-guide/mm/transhuge.html.