English · Español
04 — NUMA, pinning de hilos y la "mentira del socket único"¶
Tu portátil tiene un socket — sin NUMA real. Pero el modelo mental NUMA-aware ya te ayuda en una sola CPU: hilos en cores distintos comparten L3 y compiten por ancho de banda; pinear hilos a cores y ligar memoria a páginas concretas es la versión "barata" de lo que en servidores de dos sockets es vida o muerte.
Las páginas de motivación y roofline de la Fase 1 establecieron qué puede hacer el hardware. Esta página cubre dónde viven los datos y los hilos, que en un servidor multi-socket es la diferencia entre 1× y 0.4× del rendimiento pico. En el i5-8250U de Borja (single socket) es un efecto menor — pero el vocabulario y los hábitos de medición son los mismos. La Fase 23 (GPU en la nube) y la Fase 35 (entrenamiento distribuido) los reutilizan.
§1 Qué es NUMA¶
NUMA = Non-Uniform Memory Access. En un ordenador multi-socket, cada socket de CPU tiene su propia DRAM adjunta (y su propio controlador de memoria). Un hilo en el socket 0 accediendo a una página en el socket 1 paga:
- Un salto de latencia extra a través del interconnect entre sockets (Intel UPI/QPI, AMD Infinity Fabric). Típico: 60–150 ns por encima de los ~80 ns de latencia local de DRAM — así que un acceso remoto puede ser 2× más lento en latencia.
- Una parte del ancho de banda del interconnect, que es mucho más lento que el controlador de memoria por socket. La saturación es fácil.
El OS asigna páginas usando una política first-touch por defecto: la CPU del socket que escribe primero en una página la posee. Así que si un único hilo inicializa un tensor de 4 GiB con np.zeros(...) y luego 16 hilos worker (8 en cada socket) lo consumen, la mitad de los workers están pagando el impuesto de acceso remoto.
§2 Por qué esto importa incluso en un portátil de socket único¶
El i5-8250U es un socket, cuatro cores, ocho threads (SMT/Hyper-Threading). Estrictamente, no hay NUMA. Pero tres patologías de socket único comparten el mismo sabor:
- Compartición de L3. Los cuatro cores comparten una L3 (6 MiB). Si el hilo A hace streaming sobre un array de 10 MiB, desaloja el working set del hilo B. El ancho de banda que consume A es ancho de banda que B no obtiene.
- Hermanos SMT. Los hyper-threads en el mismo core físico comparten L1, L2 y las unidades de ejecución. Poner dos hilos compute-bound en hermanos SMT da aproximadamente 1.1–1.3× de speedup, no 2×.
- Throttling de frecuencia. Una carga sostenida en todos los cores reduce la frecuencia turbo all-core (base 1.6 GHz, turbo single-core 3.4 GHz, turbo all-core ~2.6 GHz en este chip). Los benchmarks de tiempo de pared ven esto como "más hilos → menos speedup del esperado."
La solución en un portátil es el mismo movimiento intelectual que arreglar NUMA en un servidor: ligar el trabajo a cores específicos, vigilar los contadores de throttling y cache-miss, y evitar forzar a tu scheduler a tomar decisiones que podrías tomar tú explícitamente.
§3 Herramientas que vas a usar¶
| Herramienta | Qué hace |
|---|---|
lscpu |
Reporta sockets, cores, threads, tamaños de cache por nivel. |
numactl --hardware |
Reporta los nodos NUMA y distancias. En un portátil single-socket: un nodo, distancia 10. |
taskset -c 0,1,2,3 ./prog |
Pinea un proceso a cores específicos (ayuda a evitar colisiones de hermanos SMT). |
OMP_NUM_THREADS=N |
Pone tope al conteo de hilos de OpenMP / BLAS. Ponerlo al número de cores físicos (4 en este portátil), no lógicos (8), suele mejorar el throughput en un 5–15%. |
perf stat -e ... |
Lee los contadores PMU: cache misses, branch mispredicts, IPC. |
§4 Ejercicio trabajado — fija OMP y observa¶
El único experimento que demuestra esto en un portátil:
for T in 1 2 4 8; do
OMP_NUM_THREADS=$T uv run python -c "
import numpy as np, time
A = np.random.randn(2000, 2000).astype(np.float32)
B = np.random.randn(2000, 2000).astype(np.float32)
t0 = time.perf_counter()
for _ in range(5): C = A @ B
dt = (time.perf_counter() - t0) / 5
print(f'OMP_NUM_THREADS={$T} {dt*1000:7.1f} ms/iter')
"
done
Esperado en el i5-8250U:
| Threads | ms/iter | Speedup vs T=1 |
|---|---|---|
| 1 | ~120 | 1.0× |
| 2 | ~65 | 1.85× |
| 4 | ~40 | 3.0× |
| 8 | ~38 | 3.2× |
Pasar de 4 a 8 no compra casi nada porque los hilos 5–8 son hermanos SMT de los 1–4. El kernel de matmul ya mantiene ocupadas las unidades de ejecución; SMT ayuda cuando el kernel tiene slots ociosos (código pesado en memory-stalls), no cuando está saturado en cómputo.
§5 El modelo mental para la Fase 23+¶
En un nodo de GPU en la nube (8× H100, 2 sockets), la jerarquía NUMA es:
NIC ⟷ socket-0 PCIe ⟷ socket-0 DRAM
↕ UPI socket-0 cores ⟷ 4 GPUs por NVLink ↕
NIC ⟷ socket-1 PCIe ⟷ socket-1 DRAM
socket-1 cores ⟷ 4 GPUs por NVLink ↕
Tirar un tensor a "memoria de host" sin especificar qué socket lo asigna puede costar un 30% de throughput en el feed del H100. La disciplina single-socket que aprendiste hoy — sé específico sobre dónde viven la memoria y los hilos — es exactamente la misma disciplina a 100× el coste.
§6 Qué puedes ignorar por ahora¶
- Las políticas de migración de páginas
numactldel kernel Linux (interleave, bind, preferred) — relevantes en NUMA real. En un portátil, el default vale. - Afinidad de CPU para hilos de I/O vs hilos de cómputo — relevante para servidores de inferencia (Fase 33), no ahora.
- Allocators NUMA-aware (jemalloc, mimalloc) — estos sobre todo ayudan cuando muchos hilos hacen
mallocconcurrentemente desde muchos sockets; no es un problema de portátil.
§7 Referencias¶
- Intel 64 and IA-32 Architectures Optimization Reference Manual, §11 (consideraciones multi-socket).
- Drepper, What Every Programmer Should Know About Memory (2007), §5 (soporte NUMA).
- Linux man-pages:
numactl(8),taskset(1),lscpu(1).
§8 Lee a continuación¶
→ El lab de roofline existente (lab/03-roofline-plot.md) — vuelve a ejecutarlo pineando OMP al número de cores físicos y documenta la diferencia.