Tu servicio está “ligado a CPU”. Los paneles lo dicen. El CPU está al 80–90%, la latencia es fea, y la primera reacción del equipo es añadir núcleos.
Entonces añades núcleos y nada mejora. O empeora. Felicidades: acabas de encontrarte con el verdadero jefe—la memoria.
Las cachés de CPU (L1/L2/L3) existen porque los CPUs modernos pueden hacer aritmética más rápido de lo que el sistema puede suministrarles datos. La mayoría de las fallas de rendimiento en producción
no son “el CPU es lento”. Son “el CPU está esperando”. Este texto explica las cachés sin palabrería infantil y luego muestra cómo probar lo que está ocurriendo
en una máquina Linux real con comandos que puedes ejecutar hoy.
Por qué la memoria gana (y el CPU mayormente espera)
Los CPUs son una locura. Un núcleo moderno puede ejecutar múltiples instrucciones por ciclo, especular, reordenar, vectorizar y en general actuar como un contable hiperactivo
haciendo impuestos a las 4 a.m. Mientras tanto, la DRAM es comparativamente lenta. El núcleo puede retirar instrucciones en sub-nanosegundos; un viaje a DRAM puede tomar
decenas a cientos de nanosegundos dependiendo de la topología, la contención y si te has internado en NUMA remota.
La consecuencia práctica: tu CPU pasa mucho tiempo parado esperando cargas desde memoria. No es disco. No es red. Ni siquiera “código lento” en el sentido habitual.
Está esperando la siguiente línea de caché.
Las cachés intentan mantener al CPU ocupado manteniendo los datos usados con frecuencia cerca. No son “agradables de tener”. Son la única razón por la que la computación de propósito general
funciona a los ritmos de reloj actuales. Si cada carga golpeara DRAM, tus núcleos pasarían la mayoría de los ciclos torturándose.
Aquí está el modelo mental que sobrevive al contacto con producción: el rendimiento está dominado por qué tan a menudo fallas las cachés y
qué costosas son esas fallas. Las fallas más caras son las que escapan del paquete del chip y van a DRAM, y las realmente picantes son las DRAM NUMA remotas accedidas sobre un interconector mientras otros núcleos pelean por el ancho de banda.
Una regla empírica: cuando tu ruta de solicitud toca “muchas cosas”, el costo no es la aritmética; es el seguimiento de punteros y las fallas de caché.
Y si lo haces concurrentemente con muchos hilos, puedes convertir tu subsistema de memoria en el cuello de botella mientras los gráficos de CPU te mienten en la cara.
L1/L2/L3 en términos sencillos
Piensa en los niveles de caché como “despensas” cada vez más grandes y cada vez más lentas entre el núcleo y la DRAM.
El nombre es histórico y simple: L1 está más cerca del núcleo, L2 es la siguiente, L3 suele ser compartida entre núcleos en un socket (no siempre), y luego DRAM.
Para qué sirve cada nivel
- Caché L1: pequeña y extremadamente rápida. A menudo dividida en L1i (instrucciones) y L1d (datos). Es el primer lugar donde el núcleo busca.
- Caché L2: más grande, un poco más lenta, típicamente privada por núcleo. Atrapa lo que sale de L1.
- Caché L3: mucho más grande, más lenta, frecuentemente compartida entre núcleos. Reduce viajes a DRAM y actúa como amortiguador para la contención.
Qué significan operativamente “hit” y “miss”
Un hit en caché significa que los datos que necesitas ya están cerca; la carga se satisface rápido y la tubería sigue avanzando.
Un miss de caché significa que el CPU debe obtener esos datos de un nivel inferior. Si la falta llega a DRAM, el núcleo puede quedarse muy estancado.
Las fallas ocurren porque las cachés son finitas y porque las cargas reales tienen patrones de acceso desordenados. El CPU intenta predecir y prefetch, pero no puede predecir
todo—especialmente código con punteros, acceso aleatorio o estructuras de datos más grandes que la caché.
Por qué no puedes “simplemente usar L3”
A veces la gente habla como si L3 fuera una piscina mágica compartida que contendrá tu conjunto de trabajo. No lo es. L3 es compartida, contienda y a menudo inclusiva o parcialmente
inclusiva según la arquitectura. Además, el ancho de banda y la latencia de L3 siguen siendo mucho mejores que DRAM, pero no son gratis.
Si el conjunto de trabajo de tu carga es más grande que L3, vas a DRAM. Si es más grande que DRAM… bueno, eso se llama “swap”, y es un grito de ayuda.
Líneas de caché, localidad y la regla “lo tocaste, lo compraste”
Los CPUs no traen bytes individuales a caché. Traen líneas de caché, comúnmente 64 bytes en x86_64. Cuando cargas un valor, a menudo arrastras
valores cercanos también. Eso es bueno si tu código usa memoria vecina (localidad espacial). Es malo si solo querías un campo y el resto es basura,
porque acabas de contaminar la caché con cosas que no reutilizarás.
La localidad es todo el juego:
- Localidad temporal: si lo usas de nuevo pronto, la caché ayuda.
- Localidad espacial: si usas memoria cercana, la caché ayuda.
Bases de datos, caches y enrutadores de solicitudes a menudo viven o mueren por lo predecible que sea su patrón de acceso. Los escaneos secuenciales pueden ser rápidos porque los prefetchers de hardware pueden mantener el ritmo. El rastreo de punteros aleatorio por una gran tabla hash puede ser lento porque cada paso es “sorpresa, ve a memoria”.
Traducción operativa seca: si ves CPU alto pero también muchos ciclos estancados, no tienes un problema de “cálculo”. Tienes un problema de “alimentar al núcleo”.
Tu ruta de código más caliente probablemente está dominada por fallas de caché o mispredicts de ramas, no por matemática.
Broma #1: Las fallas de caché son como “preguntas rápidas” en el chat corporativo—cada una parece pequeña hasta que te das cuenta de que todo tu día está esperando por ellas.
Prefetching: el intento del CPU de ser útil
Los CPUs intentan detectar patrones y prefetch líneas de caché futuras. Funciona bien para accesos en streaming y con paso fijo. Funciona mal para rastreo de punteros, porque
la dirección de la siguiente carga depende del resultado de la carga anterior.
Por eso “optimizé el bucle” a veces no hace nada. El bucle no es el problema; la cadena de dependencia de memoria lo es.
La parte que nadie quiere depurar: coherencia y falso compartido
En sistemas multi-núcleo, cada núcleo tiene sus propias cachés. Cuando un núcleo escribe en una línea de caché, las copias de otros núcleos deben invalidarse o actualizarse para que todos
vean una vista consistente. Eso es coherencia de caché. Es necesario. También es una trampa de rendimiento.
Falso compartido: cuando tus hilos pelean por una línea de caché que no “comparten”
El falso compartido ocurre cuando dos hilos actualizan variables distintas que por casualidad viven en la misma línea de caché. No están compartiendo datos lógicamente, pero el protocolo de
coherencia trata la línea entera como una unidad. Así que cada escritura genera invalidaciones y transferencias de propiedad, y tu rendimiento se desploma.
En cuanto a síntomas, parece “más hilos lo hacen más lento” con mucho tiempo de CPU gastado, pero sin mucho progreso. Verás alto tráfico cache-to-cache y
fallas de coherencia si miras con las herramientas correctas.
Broma #2: El falso compartido es como cuando dos equipos “poseen” la misma celda de una hoja de cálculo; las ediciones son correctas, el proceso no lo es.
Las cargas con muchas escrituras pagan extra
Las lecturas se pueden compartir. Las escrituras requieren propiedad exclusiva de la línea, lo que dispara acciones de coherencia. Si tienes un contador caliente actualizado por muchos hilos,
el contador se convierte en un cuello de botella serializado aunque “tengas muchos núcleos”.
Por eso existen contadores por hilo, bloqueos shardados y batching. No estás siendo elegante. Estás evitando una factura de físicas.
NUMA: el impuesto de latencia al escalar
En muchos servidores, la memoria está físicamente atada a sockets de CPU. Acceder a memoria “local” es más rápido que acceder a memoria conectada a otro socket.
Eso es NUMA (Non-Uniform Memory Access). No es un caso marginal. Es la configuración por defecto en mucho hardware de producción real.
Puedes pasar por alto NUMA hasta que no puedas. El modo de falla aparece cuando:
- mueves hilos a través de sockets,
- tu asignador esparce páginas entre nodos,
- o el planificador migra hilos lejos de su memoria.
Entonces la latencia se dispara, el rendimiento se estanca y el CPU parece “ocupado” porque está estancado. Puedes perder semanas afinando código de aplicación cuando la solución
es pinnear procesos, arreglar la política de asignación, o elegir menos sockets con relojes más altos para cargas sensibles a latencia.
Hechos e historia interesantes para repetir en reuniones
- La “pared de la memoria” se convirtió en una preocupación mainstream en los años 90: la velocidad de CPU mejoró más rápido que la latencia de DRAM, haciendo las cachés obligatorias.
- Las líneas de caché son una elección de diseño: 64 bytes es común en x86, pero otras arquitecturas han usado tamaños distintos; es un equilibrio entre ancho de banda y contaminación.
- L1 suele estar dividida en caché de instrucciones y datos porque mezclarlas causa conflictos; las lecturas de código y las cargas de datos tienen patrones distintos.
- El compartir L3 es intencional: ayuda cuando los hilos comparten datos mayormente de lectura y reduce viajes a DRAM, pero también crea contención bajo carga.
- Existen prefetchers de hardware porque el acceso secuencial es común; pueden acelerar dramáticamente lecturas en streaming sin cambios en el código.
- Los protocolos de coherencia (como variantes de MESI) son una gran razón por la que multi-núcleo “simplemente funciona”, pero también imponen costos reales bajo contención por escrituras.
- Los TLB también son cachés: la Translation Lookaside Buffer cachea traducciones de direcciones; los misses de TLB pueden doler como las fallas de caché.
- Las páginas grandes reducen la presión del TLB mapeando más memoria por entrada; pueden ayudar a algunas cargas y perjudicar a otras.
- Las sorpresas tempranas de escalado multi-núcleo en los 2000 enseñaron a los equipos que “más hilos” no es un plan de rendimiento si la memoria y el locking no se manejan.
Guion rápido de diagnóstico
Cuando un sistema está lento, quieres encontrar el recurso limitante rápido, no escribir poesía sobre microarquitectura. Esta es una lista de verificación de campo.
Primero: confirma si estás limitado por cómputo o por espera
- Revisa la utilización de CPU y métricas de nivel de ejecución: cola de ejecución, cambios de contexto, presión de IRQ.
- Mira ciclos estancados / fallas de caché con
perfsi puedes. - Si las instrucciones por ciclo son bajas y las fallas de caché son altas, probablemente esté limitado por latencia de memoria o por ancho de banda de memoria.
Segundo: decide si es por latencia o por ancho de banda
- Latencia limitada: rastreo de punteros, acceso aleatorio, muchas fallas en LLC, ancho de banda de memoria bajo.
- Ancho de banda limitado: streaming, escaneos grandes, muchos núcleos leyendo/escribiendo, alto ancho de banda cerca del límite de la plataforma.
Tercero: revisa NUMA y topología
- ¿Corren los hilos en un socket pero se asigna memoria en otro?
- ¿Estás haciendo thrashing entre sockets en el LLC?
- ¿La carga es sensible a la latencia de cola (casi siempre lo es), haciendo la memoria remota un asesino silencioso?
Cuarto: comprueba lo “obvio pero aburrido”
- ¿Estás haciendo swap o hay presión de memoria (tormentas de reclaim)?
- ¿Estás alcanzando límites de memoria de cgroup?
- ¿Estás saturando un único bloqueo o contador (falso compartido, mutex contendido)?
Idea parafraseada (atribuida): el mensaje operativo de Gene Kim es que los bucles de retroalimentación rápidos vencen a los heroísmos—mide primero, luego cambia una cosa a la vez.
Tareas prácticas: comandos, salidas y decisiones
Estas están pensadas para ejecutarse en un host Linux donde diagnosticas rendimiento. Algunas requieren root o permisos de perf.
El punto no es memorizar comandos; es conectar salidas con decisiones.
Task 1: Identify cache sizes and topology
cr0x@server:~$ lscpu
Architecture: x86_64
CPU(s): 64
Thread(s) per core: 2
Core(s) per socket: 16
Socket(s): 2
L1d cache: 32K
L1i cache: 32K
L2 cache: 1M
L3 cache: 35.8M
NUMA node(s): 2
NUMA node0 CPU(s): 0-31
NUMA node1 CPU(s): 32-63
Qué significa: tienes dos sockets, dos nodos NUMA y un L3 por socket (a menudo). Tu conjunto de trabajo que sobrepasa ~36MB por socket
empieza a pagar precios de DRAM.
Decisión: si el servicio es sensible a latencia, planifica consciencia NUMA (pinning, política de memoria) y mantén pequeñas las estructuras de datos calientes.
Task 2: Verify cache line size (and stop guessing)
cr0x@server:~$ getconf LEVEL1_DCACHE_LINESIZE
64
Qué significa: los límites de riesgo de falso compartido son de 64 bytes.
Decisión: en código de bajo nivel, alinea contadores/estructuras por hilo calientes a límites de 64B para evitar el ping-pong de líneas de caché.
Task 3: Confirm NUMA distances
cr0x@server:~$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
node 0 size: 256000 MB
node 0 free: 120000 MB
node 1 cpus: 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
node 1 size: 256000 MB
node 1 free: 118000 MB
node distances:
node 0 1
0: 10 21
1: 21 10
Qué significa: la memoria remota es ~2x la “distancia.” No literalmente 2x latencia, pero direccionalmente significativo.
Decisión: si eres sensible a la latencia de cola, mantiene hilos y su memoria locales (o reduce tráfico cruzado de sockets limitando la afinidad de CPU).
Task 4: Check if the kernel is fighting you with automatic NUMA balancing
cr0x@server:~$ cat /proc/sys/kernel/numa_balancing
1
Qué significa: el kernel puede migrar páginas para “seguir” a los hilos. Genial a veces, ruidoso otras.
Decisión: para cargas estables y pinned, puedes desactivarlo (con cuidado, probado) u sobrescribir con colocación explícita.
Task 5: Observe per-process NUMA memory placement
cr0x@server:~$ pidof myservice
24718
cr0x@server:~$ numastat -p 24718
Per-node process memory usage (in MBs) for PID 24718 (myservice)
Node 0 38000.25
Node 1 2100.10
Total 40100.35
Qué significa: el proceso usa mayormente memoria del nodo0. Si sus hilos corren en node1, pagarás penalidades remotas.
Decisión: alinea afinidad de CPU y política de asignación de memoria; si está desbalanceado por accidente, arregla el scheduling o la colocación al arrancar.
Task 6: Check memory pressure and swapping (the performance cliff)
cr0x@server:~$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 0 0 1200000 80000 9000000 0 0 2 15 900 3200 45 7 48 0 0
5 0 0 1180000 80000 8900000 0 0 0 0 1100 4100 55 8 37 0 0
7 0 0 1170000 80000 8850000 0 0 0 0 1300 5200 61 9 30 0 0
Qué significa: no hay swap-in/out (si/so = 0), así que no estás en la categoría “todo es terrible”. El CPU está ocupado, pero no esperando IO.
Decisión: procede al análisis de caché/memoria; no pierdas tiempo culpando al disco.
Task 7: See if you’re bandwidth-bound (quick read on memory throughput)
cr0x@server:~$ sudo perf stat -a -e cycles,instructions,cache-references,cache-misses,LLC-loads,LLC-load-misses -I 1000 -- sleep 5
# time(ms) cycles instructions cache-references cache-misses LLC-loads LLC-load-misses
1000 5,210,000,000 2,340,000,000 120,000,000 9,800,000 22,000,000 6,700,000
2000 5,300,000,000 2,310,000,000 118,000,000 10,200,000 21,500,000 6,900,000
3000 5,280,000,000 2,290,000,000 121,000,000 10,500,000 22,300,000 7,100,000
Qué significa: instrucciones/ciclo es bajo (aprox. 0.43 aquí), y las fallas de caché/LLC son significativas. El CPU está esperando mucho.
Decisión: trata esto como dominado por latencia de memoria a menos que los contadores de ancho de banda muestren saturación; busca acceso aleatorio, rastreo de punteros o NUMA.
Task 8: Identify top functions and whether they stall (profile with perf)
cr0x@server:~$ sudo perf top -p 24718
Samples: 2K of event 'cycles', Event count (approx.): 2500000000
18.50% myservice myservice [.] hashmap_lookup
12.20% myservice myservice [.] parse_request
8.90% libc.so.6 libc.so.6 [.] memcmp
7.40% myservice myservice [.] cache_get
5.10% myservice myservice [.] serialize_response
Qué significa: los hotspots son lookup/compare pesados—candidatos clásicos para fallas de caché y mispredicts de ramas.
Decisión: inspecciona las estructuras de datos: ¿las claves están dispersas? ¿estás persiguiendo punteros? ¿puedes empaquetar datos? ¿puedes reducir comparaciones?
Task 9: Check for scheduler migration (NUMA’s quiet enabler)
cr0x@server:~$ pidstat -w -p 24718 1 3
Linux 6.5.0 (server) 01/09/2026 _x86_64_ (64 CPU)
01:02:11 UID PID cswch/s nvcswch/s Command
01:02:12 1001 24718 1200.00 850.00 myservice
01:02:13 1001 24718 1350.00 920.00 myservice
01:02:14 1001 24718 1100.00 800.00 myservice
Qué significa: los cambios de contexto altos pueden indicar contención por locks o demasiados hilos listos para ejecutar.
Decisión: si la latencia es irregular, reduce el número de hilos, investiga locks o pinnea hilos críticos para reducir migraciones.
Task 10: Check run queue and per-CPU saturation (don’t confuse “busy” with “progress”)
cr0x@server:~$ mpstat -P ALL 1 2
Linux 6.5.0 (server) 01/09/2026 _x86_64_ (64 CPU)
01:03:01 AM CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
01:03:02 AM all 62.0 0.0 9.0 0.1 0.0 0.5 0.0 0.0 0.0 28.4
01:03:02 AM 0 95.0 0.0 4.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0
01:03:02 AM 32 20.0 0.0 5.0 0.0 0.0 0.0 0.0 0.0 0.0 75.0
Qué significa: CPU0 está saturado mientras CPU32 está mayormente idle. Esto puede ser un problema de afinidad, un shard caliente o un embudo de lock.
Decisión: si un solo núcleo está caliente, el escalado no ocurrirá hasta que elimines el embudo. Investiga la distribución de trabajo por núcleo y los locks.
Task 11: Verify CPU affinity and cgroup constraints
cr0x@server:~$ taskset -pc 24718
pid 24718's current affinity list: 0-15
Qué significa: el proceso está pinned a CPUs 0–15 (un subconjunto de un socket). Puede ser intencional o accidental.
Decisión: si está pinned, asegúrate de que la memoria sea local a ese nodo; si es accidental, arregla tu unit file / conjunto de CPU del orquestador.
Task 12: Check LLC miss rate per process (perf stat on PID)
cr0x@server:~$ sudo perf stat -p 24718 -e cycles,instructions,LLC-loads,LLC-load-misses -- sleep 10
Performance counter stats for process id '24718':
18,320,000,000 cycles
7,410,000,000 instructions # 0.40 insn per cycle
210,000,000 LLC-loads
78,000,000 LLC-load-misses # 37.14% of all LLC hits
10.001948393 seconds time elapsed
Qué significa: una tasa de fallas de carga en LLC de ~37% es una gran señal de que tu conjunto de trabajo no cabe en caché o el acceso es aleatorio.
Decisión: reduce el conjunto de trabajo, aumenta la localidad o cambia el diseño de datos. También valida la localidad NUMA.
Task 13: Spot page faults and major faults (TLB and paging hints)
cr0x@server:~$ pidstat -r -p 24718 1 3
Linux 6.5.0 (server) 01/09/2026 _x86_64_ (64 CPU)
01:04:10 UID PID minflt/s majflt/s VSZ RSS %MEM Command
01:04:11 1001 24718 8200.00 0.00 9800000 4200000 12.8 myservice
01:04:12 1001 24718 7900.00 0.00 9800000 4200000 12.8 myservice
01:04:13 1001 24718 8100.00 0.00 9800000 4200000 12.8 myservice
Qué significa: fallas menores altas pueden ser normales (paginación por demanda, archivos mapeados), pero si las fallas aumentan bajo carga puede correlacionar con
churn de páginas y presión de TLB.
Decisión: si las fallas correlacionan con picos de latencia, revisa el comportamiento del asignador, el uso de mmap y considera páginas grandes solo después de medir.
Task 14: Validate transparent huge pages (THP) status
cr0x@server:~$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
Qué significa: THP está siempre activado. Algunas bases de datos lo adoran, algunos servicios sensibles a latencia odian el comportamiento de asignación/compactación.
Decisión: si ves paradas periódicas, prueba madvise o never en staging y compara la latencia de cola.
Task 15: Check memory bandwidth counters (Intel/AMD tooling varies)
cr0x@server:~$ sudo perf stat -a -e uncore_imc_0/cas_count_read/,uncore_imc_0/cas_count_write/ -- sleep 5
Performance counter stats for 'system wide':
8,120,000,000 uncore_imc_0/cas_count_read/
4,010,000,000 uncore_imc_0/cas_count_write/
5.001234567 seconds time elapsed
Qué significa: estos contadores aproximan transacciones DRAM; si son altos y cerca de los límites de la plataforma, estás limitado por ancho de banda.
Decisión: si estás limitado por ancho de banda, añadir núcleos no ayudará. Reduce los datos escaneados, comprime, mejora la localidad o acerca el trabajo a los datos.
Task 16: Identify lock contention (often misdiagnosed as “cache issues”)
cr0x@server:~$ sudo perf lock report -p 24718
Name acquired contended total wait (ns) avg wait (ns)
pthread_mutex_lock 12000 3400 9800000000 2882352
Qué significa: los hilos pasan tiempo real esperando locks. Esto puede amplificar efectos de caché (las líneas de caché rebotan con la propiedad del lock).
Decisión: reduce la granularidad de locks, haz sharding o cambia el algoritmo. No “optimices memoria” si tu cuello de botella es un mutex.
Task 17: Watch LLC occupancy and memory stalls (if supported)
cr0x@server:~$ sudo perf stat -p 24718 -e cpu/mem-loads/,cpu/mem-stores/ -- sleep 5
Performance counter stats for process id '24718':
320,000,000 cpu/mem-loads/
95,000,000 cpu/mem-stores/
5.000912345 seconds time elapsed
Qué significa: tráfico pesado de loads/stores sugiere que el trabajo es centrado en memoria. Combínalo con métricas de fallas de LLC para decidir si es amigable con caché.
Decisión: si hay muchas cargas con altas tasas de fallas, enfócate en la localidad de estructuras de datos y en reducir el rastreo de punteros.
Task 18: Validate that you’re not accidentally throttling (frequency matters)
cr0x@server:~$ cat /proc/cpuinfo | grep -m1 "cpu MHz"
cpu MHz : 1796.234
Qué significa: la frecuencia del CPU es relativamente baja (posible ahorro de energía o restricciones térmicas).
Decisión: si el rendimiento empeoró tras un cambio de plataforma, valida el governor de CPU y la temperatura antes de culpar a las cachés.
Tres mini-historias corporativas desde las trincheras
Mini-historia 1: El incidente causado por una suposición equivocada
Un servicio de pagos empezó a tener timeouts todos los días a más o menos la misma hora. El equipo lo llamó “saturación de CPU” porque los paneles mostraban CPU al 90%,
y el flame graph destacaba parsing de JSON y algo de hashing. Hicieron lo que hacen los equipos: añadieron instancias, aumentaron pools de hilos y subieron límites de autoscaling.
El incidente empeoró. Las colas de latencia se volvieron más agresivas.
La suposición equivocada fue sutil: “CPU alto significa que el núcleo está ocupado calculando.” En realidad, los núcleos estaban ocupados esperando. perf stat mostró IPC bajo
y una alta tasa de fallas en LLC. La ruta de la solicitud tenía una consulta de enriquecimiento respaldada por caché que se había expandido silenciosamente: más claves, más metadatos, más objetos con punteros,
y un conjunto de trabajo que ya no cabía ni cerca de L3.
Entonces el cambio de escalado lo empujó a un nuevo modo de falla. Más hilos significaron más accesos aleatorios en paralelo, lo que incrementó el paralelismo a nivel de memoria
pero también la contención. El controlador de memoria se calentó, el ancho de banda subió y la latencia media subió con él. Fue un clásico: cuanto más empujabas,
más el subsistema de memoria se resistía.
La solución no fue heroica. Redujeron el overhead de objetos, empaquetaron campos en arreglos contiguos para la ruta caliente y limitaron el conjunto de enriquecimiento por solicitud.
También dejaron de pinear el proceso a ambos sockets sin controlar la colocación de memoria. Una vez mejoró la localidad, la utilización de CPU siguió alta,
pero el throughput subió y la latencia de cola bajó. Los gráficos de CPU parecían lo mismo. El sistema se comportó diferente. Esa es la lección.
Mini-historia 2: La optimización que salió mal
Un equipo intentó acelerar una API de analytics “mejorando el cache”. Reemplazaron un vector simple de structs por un hash map con clave string para evitar escaneos lineales. Los microbenchmarks en laptop se vieron geniales. Producción no estuvo de acuerdo.
La nueva estructura destruyó la localidad. El código antiguo escaneaba un arreglo contiguo: predecible, amigable con prefetch y con caché. El código nuevo hacía búsquedas aleatorias, cada una involucrando rastreo de punteros, hashing de strings y múltiples cargas dependientes. En servidores reales bajo carga, convirtió un bucle mayormente amigable con L2/L3 en una fiesta DRAM.
Peor aún, el hash map introdujo una ruta de resize compartida. Bajo picos de tráfico, ocurrieron resizes, los locks contendieron y las líneas de caché rebotaron entre núcleos.
El equipo vio CPU más alto y concluyó “necesitamos más CPU”. Pero el “más CPU” aumentó la contención, y su p99 empeoró.
Revirtieron, luego implementaron un compromiso aburrido: mantener un vector ordenado para la ruta caliente y hacer reconstrucciones ocasionales fuera del hilo de solicitud,
con un pointer de snapshot estable. Aceptaron O(log n) con buena localidad en lugar de O(1) con constantes terribles. Producción volvió a ser aburrida,
que es el tipo de éxito con el que puedes construir una carrera.
Mini-historia 3: La práctica aburrida pero correcta que salvó el día
Un servicio adyacente a almacenamiento—muchas lecturas de metadatos, algunas escrituras—se migró a una nueva plataforma hardware. Todos esperaban que fuera más rápido. No lo fue.
Hubo picos esporádicos de latencia y ocasionales caídas de throughput, pero nada obvio: no swap, discos bien, red bien.
El equipo tenía un hábito que los salvó: un “bundle de triaje de rendimiento” que ejecutaban ante cualquier regresión. Incluía lscpu,
topología NUMA, perf stat para IPC y fallas de LLC, y una comprobación rápida de frecuencia de CPU y governors. No emocionante. Fiable.
El bundle mostró inmediatamente dos sorpresas. Primero, las nuevas máquinas tenían más sockets, y el servicio se estaba planificando a través de sockets sin
colocación de memoria consistente. Segundo, la frecuencia de CPU era más baja bajo carga sostenida por ajustes de energía en la imagen base.
La solución fue procedimental: actualizar la baseline de tuning de hosts (governor, settings de firmware donde correspondiera), y pinear el servicio a un solo
nodo NUMA con memoria ligada a ese nodo. Sin cambios en código. La latencia se estabilizó. El rollout terminó. El postmortem fue corto, que es un lujo.
Errores comunes (síntomas → causa raíz → solución)
1) “CPU está alto así que necesitamos más CPU”
Síntomas: CPU 80–95%, throughput plano, p95/p99 peor al añadir hilos/instancias.
Causa raíz: IPC bajo debido a fallas de caché o stalls de memoria; el CPU está “ocupado esperando”.
Solución: mide IPC y fallas de LLC con perf stat; reduce el conjunto de trabajo, mejora la localidad o arregla la colocación NUMA. No escales hilos a ciegas.
2) “Hash map siempre es más rápido que un escaneo”
Síntomas: más lento tras cambiar a estructura “O(1)”; perf muestra hotspots en hashing/strcmp/memcmp.
Causa raíz: acceso aleatorio y rastreo de punteros causan viajes a DRAM; la mala localidad vence al big-O en hardware real.
Solución: prefiere estructuras contiguas para rutas calientes (arrays, vectores, vectores ordenados). Haz benchmarks con datasets y concurrencia parecidos a producción.
3) “Más hilos = más throughput”
Síntomas: el throughput mejora y luego colapsa; aumentan los cambios de contexto; suben las fallas de LLC.
Causa raíz: saturación de ancho de banda de memoria, contención de locks o falso compartido se vuelven dominantes.
Solución: limita el número de hilos cerca de la rodilla de la curva; shardea locks/contadores; evita escrituras compartidas calientes; pinnea hilos si es sensible a NUMA.
4) “NUMA no importa; Linux lo manejará”
Síntomas: buena latencia promedio, latencia de cola terrible; regresiones al pasar a hosts multi-socket.
Causa raíz: acceso a memoria remota y tráfico entre sockets; migración del planificador rompe la localidad.
Solución: usa numastat y numactl; pinea CPU y memoria; considera correr un proceso por socket para previsibilidad.
5) “Si desactivamos las cachés, podemos probar el peor caso”
Síntomas: alguien sugiere apagar cachés o limpiar constantemente como estrategia de prueba.
Causa raíz: malentendido; los sistemas modernos no están diseñados para ese modo y los resultados no se mapearán a la realidad.
Solución: prueba con conjuntos de trabajo realistas y patrones de acceso; usa contadores de perfilado, no experimentos de feria de ciencias.
6) “Páginas grandes siempre ayudan”
Síntomas: THP activado y paradas periódicas; actividad de compactación; picos de latencia durante el crecimiento de memoria.
Causa raíz: overhead de asignación/compactación de THP; desajuste con patrones de asignación.
Solución: mide always vs madvise vs never; si usas páginas grandes, asigna por adelantado y monitorea la latencia de cola.
Listas de verificación / plan paso a paso
Checklist A: Demuestra que es memoria, no cómputo
- Captura la topología de CPU:
lscpu. Registra sockets/NUMA y tamaños de caché. - Revisa swapping/presión de memoria:
vmstat 1. Sisi/so> 0, arregla memoria primero. - Mide IPC y fallas de LLC:
perf stat(system-wide o PID). IPC bajo + fallas altas de LLC = sospecha de stalls de memoria. - Busca funciones calientes:
perf top. Si los hotspots son lookup/compare/alloc, espera problemas de localidad.
Checklist B: Decide si es por latencia o por ancho de banda
- Si la tasa de fallas de LLC es alta pero los contadores de ancho de banda son moderados: probablemente es rastreo de punteros limitado por latencia.
- Si los contadores de ancho de banda están cerca del límite de la plataforma y los núcleos no ayudan: probablemente es escaneo/stream limitado por ancho de banda.
- Cambia una cosa y vuelve a medir: reduce concurrencia, reduce conjunto de trabajo o cambia el patrón de acceso.
Checklist C: Arregla NUMA antes de reescribir código
- Mapea nodos NUMA:
numactl --hardware. - Revisa memoria por proceso y nodo:
numastat -p PID. - Revisa afinidad de CPU:
taskset -pc PID. - Alinea: pinea CPUs a un nodo y enlaza memoria al mismo nodo (prueba en staging primero).
Checklist D: Haz los datos amigables con caché (lo aburrido gana)
- Aplana estructuras con muchos punteros en las rutas calientes.
- Empaqueta campos calientes juntos; separa campos fríos (hot/cold split).
- Prefiere arrays/vectores e iteración predecible sobre acceso aleatorio.
- Shardea contadores con muchas escrituras; agrupa actualizaciones.
- Haz benchmarks con tamaños similares a producción; los efectos de caché aparecen cuando los datos son lo suficientemente grandes.
Preguntas frecuentes
1) ¿L1 siempre es más rápida que L2, y L2 siempre más rápida que L3?
Generalmente sí en términos de latencia, pero el rendimiento real depende de contención, patrón de acceso y de si la línea ya está presente por prefetching.
Además, las características de ancho de banda difieren; L3 puede entregar alto ancho de banda agregado pero con mayor latencia.
2) ¿Por qué mi CPU muestra 90% de uso si está “esperando en memoria”?
Porque “uso de CPU” significa principalmente que el núcleo no está idle. Una pipeline estancada sigue ejecutando instrucciones, manejando misses, haciendo especulación
y quemando ciclos. Necesitas contadores (IPC, fallas de caché, ciclos estancados) para ver la espera.
3) ¿Cuál es la diferencia entre caché de CPU y la page cache de Linux?
Las cachés de CPU son gestionadas por hardware y son pequeñas (KB/MB). La page cache de Linux es gestionada por el SO, usa DRAM y cachea datos respaldados por archivos (GBs).
Interactúan, pero resuelven problemas distintos a diferentes escalas.
4) ¿Puedo “aumentar L3” cambiando software?
No literalmente. Lo que puedes hacer es actuar como si tuvieras más caché reduciendo tu conjunto de trabajo caliente, mejorando la localidad y evitando contaminación de caché.
5) ¿Por qué las listas enlazadas y los árboles con punteros rinden mal?
Destruyen la localidad espacial. Cada puntero conduce a una línea de caché distinta, a menudo lejana. Eso significa cargas dependientes y viajes frecuentes a DRAM,
que hacen que el núcleo se estanque.
6) ¿Cuándo debería preocuparme por el falso compartido?
Cuando tienes varios hilos actualizando campos/contadores distintos en bucles apretados y el rendimiento empeora con más hilos.
Es común en contadores de métricas, ring buffers y arreglos ingenuos de estado por conexión.
7) ¿Las fallas de caché siempre son malas?
Algunas fallas son inevitables. La pregunta es si tu carga está estructurada para que las faltas se amortigüen (streaming) o sean catastróficas (cargas dependientes aleatorias).
Optimiza para reducir fallas en la ruta caliente, no para alcanzar un mítico “cero fallas”.
8) ¿Los CPUs más rápidos arreglan problemas de memoria?
A veces los empeoran. Los núcleos más rápidos pueden demandar datos más deprisa y golpear la pared de memoria antes. Una plataforma con mejor ancho de banda de memoria,
mejor topología NUMA o caches más grandes puede importar más que GHz puros.
9) ¿Debería pinear todo a un socket?
Para servicios sensibles a latencia, pinear a un socket (y enlazar memoria) puede ser una gran ventaja: localidad predecible, menos accesos remotos.
Para trabajos de alto throughput, esparcirse por sockets puede ayudar—si mantienes la localidad y evitas hotspots de escrituras compartidas.
10) ¿Qué métrica debería vigilar en dashboards para detectar problemas de caché temprano?
Si puedes, exporta IPC (instrucciones por ciclo) y tasas de fallas de LLC o ciclos estancados desde perf/PMU. Si no, vigila este patrón:
CPU sube, throughput plano, latencia sube al escalar. Ese patrón grita memoria.
Conclusión: qué hacer la próxima semana
Las cachés de CPU no son trivia. Son la razón por la que un cambio “simple” puede hundir el p99 y por la que añadir núcleos muchas veces solo añade decepción.
La memoria gana porque marca el ritmo: si tu núcleo no consigue datos barato, no puede hacer trabajo útil.
Pasos prácticos:
- Pon
perf stat(IPC + fallas de LLC) en tu kit estándar de incidentes para páginas “ligadas a CPU”. - Documenta la topología NUMA por clase de host y decide si los servicios deberían pinearse (y cómo) por defecto.
- Audita rutas calientes por localidad: aplana estructuras, separa campos hot/cold y evita hotspots de escrituras compartidas.
- Haz benchmarks con tamaños de dataset realistas. Si tu benchmark cabe en L3, no es un benchmark; es una demo.
- Cuando se sugiera una optimización, haz una pregunta primero: “¿Qué hace esto a las fallas de caché y al tráfico de memoria?”