⏱️ Lectura: 17 min

Un monitor que marca el CPU al 100% casi nunca dice la verdad completa: ese número mide tiempo ocupado, no trabajo útil. La causa más común de la diferencia es la latencia de memoria, el tiempo que el procesador pierde esperando datos que no están en sus cachés.

📑 En este artículo
  1. TL;DR
  2. ¿Qué es la latencia de memoria?
  3. Por qué importa para quien programa
  4. Cómo funciona la jerarquía de caché de la CPU
  5. Ejemplos prácticos: de un diccionario a un acceso aleatorio en C
    1. El caso real: un GROUP BY sobre 99 millones de filas
  6. Cómo empezar: medir la latencia de memoria con perf en Linux
  7. Casos de uso reales en motores de bases de datos
  8. Errores comunes y buenas prácticas
  9. Comparativa de estrategias de agregación
  10. Profundizando: qué hay detrás de backend_bound_memory
  11. Preguntas frecuentes
    1. ¿Por qué un CPU al 100% no refleja el tiempo de acceso a RAM real?
    2. ¿Qué diferencia hay entre la jerarquía de caché de la CPU y la memoria RAM?
    3. ¿Cómo mido la velocidad de acceso a memoria en Linux?
    4. ¿Qué significa backend_bound_memory en perf stat?
    5. ¿Los ciclos de espera por memoria afectan igual a todas las bases de datos?
    6. ¿Se pueden eliminar del todo los ciclos de espera por memoria?
  12. Referencias
    1. 📚 Artículos relacionados

Para cualquiera que afina una consulta SQL, un pipeline de datos o un servicio en producción, entender esta diferencia cambia qué hay que optimizar primero. Este artículo explica cómo funciona la jerarquía de caché de la CPU, por qué un GROUP BY con millones de claves se vuelve lento y cómo medir el problema en tu propia máquina con perf stat.

TL;DR

  • La latencia de memoria explica por qué un CPU al 100% puede rendir 400 a 800 veces menos de lo esperado
  • L1 responde en ~1 ns, L3 en ~10-15 ns y la RAM tarda 50-100 ns: cada salto cuesta cientos de instrucciones perdidas
  • Un GROUP BY con 17 millones de claves únicas convierte cada acceso a un HashMap en un viaje directo a RAM
  • perf stat con la métrica backend_bound_memory mide cuánto tiempo del CPU se pierde esperando datos, no calculando
  • Particionar los datos para que cada bloque entre en la caché L2 o L3 es la técnica central de motores como Pivot

¿Qué es la latencia de memoria?

La latencia de memoria es el tiempo que tarda un procesador en obtener un dato que no está disponible en sus cachés internas y debe pedirlo a la memoria RAM. Se mide en nanosegundos, suele rondar entre 50 y 100 ns en hardware moderno y determina cuántas instrucciones quedan congeladas esperando esa respuesta.

Esa espera no es un detalle menor: en un CPU moderno capaz de ejecutar entre 4 y 8 instrucciones por nanosegundo, cada viaje a RAM equivale a dejar pasar entre 400 y 800 instrucciones que el procesador podría haber ejecutado. Es la razón de fondo detrás del dato citado en el blog de Pivot: un CPU puede aparecer ocupado en el monitor del sistema operativo mientras en realidad pasa buena parte del tiempo detenido, esperando.

Por qué importa para quien programa

El sistema operativo considera un CPU ocupado si hay alguna tarea (un task, en términos de Linux) asignada a ese núcleo, sin importar si esa tarea está progresando o solo esperando una respuesta de memoria. Por eso dos procesos que muestran 100% de uso de CPU pueden estar haciendo cantidades de trabajo completamente distintas.

Esto afecta decisiones de ingeniería cotidianas: elegir una estructura de datos, decidir cuántos hilos lanzar, o diagnosticar por qué una consulta SQL tarda más de lo esperado aunque el servidor no aparezca saturado según las métricas habituales. Ignorar la jerarquía de caché de la CPU lleva a optimizar lo que no importa, como agregar más núcleos a un problema que en realidad está limitado por cuántas veces el procesador tiene que ir hasta la RAM.

Cómo funciona la jerarquía de caché de la CPU

Para evitar pagar el costo completo de cada acceso a RAM, los procesadores modernos intercalan varios niveles de memoria cada vez más rápidos y más chicos, soldados dentro del propio chip. El nivel más cercano al núcleo, la caché L1, responde en apenas 1 nanosegundo, pero tiene un tamaño minúsculo (unos 64 KB). Los niveles siguientes, L2 y L3, son más grandes pero también más lentos.

flowchart TD
    A["Nucleo de CPU"] --> B["Cache L1 ~1 ns, ~64 KB"]
    B --> C["Cache L2 ~3-4 ns, ~1 MB"]
    C --> D["Cache L3 ~10-15 ns, cientos de MB"]
    D --> E[("DRAM ~50-100 ns, GBs")]

La tabla siguiente resume los cuatro niveles con los valores que el propio equipo de Pivot usa para razonar sobre el rendimiento de su motor:

Nivel Latencia aproximada Tamaño típico Instrucciones de CPU perdidas por acceso
L1 ~1 ns ~64 KB ~4-8
L2 ~3-4 ns ~1 MB ~15-30
L3 ~10-15 ns cientos de MB ~50-100
DRAM ~50-100 ns GBs ~400-800

La consecuencia práctica es clara: un programa que trabaja con estructuras de datos pequeñas y accede a ellas de forma predecible puede quedarse casi todo el tiempo dentro de L1 o L2. Un programa que salta de forma errática por un espacio de memoria mucho más grande que L3 va a terminar pagando el costo completo de la jerarquía de caché en cada acceso.

Ejemplos prácticos: de un diccionario a un acceso aleatorio en C

El ejemplo más simple de un GROUP BY es un diccionario que acumula totales por clave. En Python:

from collections import defaultdict

ventas = [("ana", 120), ("luis", 75), ("ana", 30), ("luis", 10), ("ana", 5)]
totales = defaultdict(int)
for usuario, monto in ventas:
    totales[usuario] += monto

print(dict(totales))

Este código agrupa ventas por usuario y suma los montos, exactamente lo que hace SELECT SUM(monto) FROM ventas GROUP BY usuario en SQL. La salida es determinística:

{'ana': 155, 'luis': 85}

Con cinco filas y dos claves, todo el diccionario vive cómodamente en la caché L1. El problema aparece cuando la cardinalidad crece: con millones de claves únicas, ese mismo defaultdict deja de caber en cualquier caché y cada totales[usuario] += monto se convierte en un posible salto a RAM.

Para ver el efecto de forma controlada, este programa en C suma 50 millones de enteros de dos maneras: accediendo a ellos en orden y accediendo a ellos siguiendo un salto fijo que visita las mismas posiciones pero en un orden disperso.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    long n = 50000000;
    long *datos = malloc(n * sizeof(long));
    for (long i = 0; i < n; i++) datos[i] = i;

    long suma_secuencial = 0;
    for (long i = 0; i < n; i++) {
        suma_secuencial += datos[i];
    }

    long suma_disperso = 0;
    for (long i = 0; i < n; i++) {
        long idx = (i * 7919L) % n;
        suma_disperso += datos[idx];
    }

    printf("secuencial=%ld disperso=%ld\n", suma_secuencial, suma_disperso);
    free(datos);
    return 0;
}

Como 7919 es primo y no divide a 50.000.000, el segundo bucle visita exactamente las mismas 50 millones de posiciones, solo que en un orden que salta constantemente de página en página. El resultado matemático es idéntico en ambos casos:

secuencial=1249999975000000 disperso=1249999975000000

Misma suma, mismo dato, pero el segundo bucle fuerza al CPU a pedir datos dispersos por toda la memoria en vez de aprovechar el prefetcher de hardware, el circuito que adivina el siguiente bloque a cargar cuando detecta un patrón secuencial. Medir con perf stat la diferencia entre ambos bucles es exactamente lo que hace la sección siguiente.

La caché L1 responde en ~1 ns; la RAM tarda entre 50 y 100 ns, según cifras citadas por Pivot.

El caso real: un GROUP BY sobre 99 millones de filas

El equipo de Pivot llevó este mismo problema a producción. Tomaron el dataset público «hits» de ClickBench (99 millones de filas) y corrieron esta consulta con una implementación naive de GROUP BY, un HashMap que crece sin ninguna optimización de localidad:

SELECT count(*), UserID FROM hits GROUP BY UserID

Con 17 millones de valores únicos de UserID, la consulta tardó 3.9 segundos en la primera corrida y 3.6 segundos en la segunda, ya con todos los datos en la caché de página del sistema operativo. Para entender por qué, corrieron perf stat fijando el proceso a un núcleo específico con taskset y midiendo qué porcentaje de los slots de ejecución del CPU se perdían esperando memoria:

Performance counter stats for 'CPU(s) 3':

 8364109137 ex_no_retire.load_not_complete    # 50.1 % backend_bound_memory
 38233214040 de_no_dispatch_per_slot.backend_stalls
 8977647849 ex_no_retire.not_complete
 11861387798 ls_not_halted_cyc

El 50,1% de backend_bound_memory significa que, durante la mitad del tiempo de ejecución, el CPU tenía trabajo asignado (y el monitor de recursos lo mostraba ocupado) pero no estaba completando ninguna instrucción: estaba parado, esperando que la RAM le devolviera el siguiente valor de UserID. Con 17 millones de claves dispersas en un HashMap, casi ningún acceso cae dentro de L1, L2 o L3.

sequenceDiagram
    participant CPU as CPU
    participant L1 as Cache L1
    participant L2 as Cache L2
    participant L3 as Cache L3
    participant RAM as DRAM
    CPU->>L1: pide UserID en direccion X
    L1-->>CPU: miss
    CPU->>L2: pide direccion X
    L2-->>CPU: miss
    CPU->>L3: pide direccion X
    L3-->>CPU: miss
    CPU->>RAM: pide direccion X
    RAM-->>CPU: dato, 50-100 ns despues
    Note over CPU,RAM: el dato se copia a L1, L2 y L3 al volver

Cómo empezar: medir la latencia de memoria con perf en Linux

Dependencias en Ubuntu o Debian: el paquete linux-tools-common y la versión específica del kernel, linux-tools-$(uname -r). En Arch Linux o Fedora el paquete se llama simplemente perf. En macOS la herramienta equivalente es Instruments (incluida en Xcode); en Windows, Windows Performance Recorder (WPR).

sudo apt install linux-tools-common linux-tools-$(uname -r)

Guardá el programa de la sección anterior como latencia.c, compilalo con optimizaciones (sin -O2 el compilador puede comportarse distinto) y corré perf stat sobre el binario:

gcc -O2 -o latencia latencia.c
perf stat -M backend_bound_memory,retiring -- ./latencia

Vas a ver dos números: backend_bound_memory (porcentaje de slots esperando memoria) y retiring (porcentaje de slots que efectivamente completaron una instrucción). Si tu kernel bloquea el acceso a los contadores de hardware sin privilegios, vas a necesitar sudo o ajustar temporalmente /proc/sys/kernel/perf_event_paranoid a -1.

💡 Tip: No hay un número universal bueno de backend_bound_memory: lo que importa es comparar el mismo programa en dos versiones, como el bucle secuencial contra el disperso del ejemplo anterior. Si tu versión optimizada no bajó ese porcentaje, no solucionaste el problema de memoria, solo lo escondiste.

Casos de uso reales en motores de bases de datos

Los motores columnares como ClickHouse o DuckDB no usan un único HashMap gigante para un GROUP BY. En cambio, dividen el trabajo en dos fases: una agregación local por lotes, vectorizada con instrucciones SIMD y que cabe entera en L1 o L2, y una fusión global al final, cuando ya hay muchos menos valores que combinar. Esa es, en esencia, la técnica que describe Pivot para evitar el escenario del 50,1% de backend_bound_memory: particionar las claves por hash en N baldes, de forma que cada balde entre en una caché de nivel 2 o 3 en vez de dispersarse por toda la RAM.

El mismo principio aparece en sistemas muy distintos: un índice ordenado tipo árbol B+ agrupa físicamente las claves cercanas para que una búsqueda toque pocas páginas de memoria, y un motor de videojuegos que itera un «array de estructuras» en vez de una «estructura de arrays» paga el mismo costo de fallos de caché que el HashMap naive de este artículo. La jerarquía de caché de la CPU no es un detalle de bases de datos: es una restricción física que atraviesa cualquier programa que mueva suficientes datos.

Errores comunes y buenas prácticas

  • Confundir CPU al 100% con CPU saturado de trabajo útil: el sistema operativo marca ocupado cualquier núcleo con una tarea asignada, sin importar si esa tarea avanza o está detenida esperando RAM.
  • Medir solo con un reloj de pared: un cronómetro dice que algo tardó más, pero no dice por qué. Sin perf stat es imposible distinguir un cuello de botella de memoria de uno de CPU puro.
  • Elegir una estructura de datos sin pensar en la cardinalidad: un HashMap es perfecto con miles de claves y un desastre con decenas de millones, no por el algoritmo en sí, sino porque deja de caber en cualquier caché.
  • Ignorar el false sharing en código multihilo: dos hilos que escriben en variables distintas pero ubicadas en la misma línea de caché de 64 bytes se invalidan la caché mutuamente en cada escritura, aunque lógicamente no compartan ningún dato.
  • No alinear structs críticas: dejar que el padding del compilador fragmente una estructura usada en un bucle caliente puede hacer que ocupe dos líneas de caché en vez de una, duplicando los accesos a memoria necesarios.

Comparativa de estrategias de agregación

Estrategia Cuándo usarla Ventaja Limitación
HashMap simple (naive) Pocas claves únicas (miles) Simple de implementar y de razonar Cache misses constantes con millones de claves
Partición por hash (radix) Datasets grandes con muchas claves únicas Cada partición cabe en L2 o L3 Requiere una pasada extra para particionar
Agregación vectorizada por lotes Motores columnares (ClickHouse, DuckDB) Aprovecha SIMD y mantiene todo en L1 Mucho más compleja de implementar desde cero
Estructuras aproximadas (HyperLogLog) Solo necesitás un conteo aproximado de distintos Memoria constante, muy rápida No da el valor exacto ni el desglose por grupo

Profundizando: qué hay detrás de backend_bound_memory

Los procesadores modernos no ejecutan instrucciones estrictamente en el orden del código fuente: usan ejecución fuera de orden, adelantando instrucciones que no dependen del resultado que todavía está esperando memoria. Cuando ese margen de instrucciones independientes se agota y el núcleo literalmente no tiene nada más para hacer mientras espera, ahí aparece el backend_bound_memory que midió Pivot: el porcentaje de slots de ejecución que quedaron vacíos por falta de datos, no por falta de trabajo.

Otro factor que agrava el problema en servidores multisocket es NUMA, el acceso no uniforme a memoria: si el dato que falta está en el banco de RAM conectado al otro socket, la latencia sube por encima de los 50-100 ns de un acceso local, porque el pedido tiene que cruzar la interconexión entre sockets. Fijar procesos a un núcleo cercano a su memoria, lo que hace taskset en el ejemplo de Pivot, es una forma simple de evitar ese costo extra.

La decisión de cuántos bits de hash usar para particionar también importa: con muy pocas particiones, cada una sigue siendo más grande que L3; con demasiadas, el overhead de manejarlas (metadata, branching) supera el ahorro. El objetivo práctico es que el conjunto de trabajo de cada partición, las claves y contadores que se tocan repetidamente, quepa en L2 (del orden de 1 MB) para que la mayoría de los accesos del bucle caliente resuelvan ahí y no en L3 ni en RAM.

flowchart TD
    subgraph Naive["Agregacion naive"]
    A1["Fila nueva"] --> A2["Hash disperso en todo el HashMap"]
    A2 --> A3["Acceso a RAM en casi cada fila"]
    end
    subgraph Particionada["Agregacion particionada"]
    B1["Fila nueva"] --> B2["Hash mod N particiones"]
    B2 --> B3["Particion cabe en L2 o L3"]
    B3 --> B4["Acceso mayormente dentro de cache"]
    end
Un acceso a RAM cruzando sockets en NUMA puede superar ampliamente los 50-100 ns de un acceso local.

⚠️ Ojo: Particionar de más tiene un costo real: cada partición extra suma metadata y ramas condicionales. Si tu dataset ya cabe en L3 sin particionar, agregar particiones solo suma complejidad sin ninguna ganancia medible.

📖 Resumen en Telegram: Ver resumen

Tu próximo paso: compilá el programa latencia.c de este artículo, corré perf stat -M backend_bound_memory,retiring -- ./latencia dos veces (bucle secuencial y disperso) y compará ambos porcentajes en tu propia máquina.

📬 Recibí lo nuevo en tu email

Te avisamos de artículos grandes (1-2 por mes).

Preguntas frecuentes

¿Por qué un CPU al 100% no refleja el tiempo de acceso a RAM real?

Porque el sistema operativo mide ocupación, no progreso: un núcleo con una tarea asignada aparece como 100% aunque esa tarea esté detenida esperando una respuesta de memoria. Solo herramientas como perf stat distinguen entre ciclos que completan trabajo y ciclos que esperan.

¿Qué diferencia hay entre la jerarquía de caché de la CPU y la memoria RAM?

La jerarquía de caché son varios niveles de memoria pequeños y rápidos (L1, L2, L3) soldados dentro del propio chip del procesador; la RAM es memoria externa, mucho más grande pero entre 50 y 100 veces más lenta de acceder que la caché L1.

¿Cómo mido la velocidad de acceso a memoria en Linux?

Con perf stat, usando grupos de métricas como backend_bound_memory y retiring, que muestran qué porcentaje de los slots de ejecución del CPU se perdió esperando memoria frente a los que completaron una instrucción.

¿Qué significa backend_bound_memory en perf stat?

Es el porcentaje de slots de ejecución donde el CPU tenía una instrucción pendiente de completar pero no pudo avanzar porque estaba esperando un dato de memoria. Un valor alto, como el 50,1% que midió Pivot en su GROUP BY naive, indica que el cuello de botella es memoria, no cómputo.

¿Los ciclos de espera por memoria afectan igual a todas las bases de datos?

No a todas por igual: los motores columnares como ClickHouse o DuckDB mitigan el problema con agregación vectorizada y particionada, mientras que una implementación naive de GROUP BY sobre un HashMap sin particionar sufre el costo completo con datasets de millones de claves únicas.

¿Se pueden eliminar del todo los ciclos de espera por memoria?

No del todo: la física de la DRAM impone un piso de 50-100 ns por acceso que ningún software puede evitar. Lo que sí se puede hacer es reducir cuántas veces el programa necesita ese viaje, manteniendo el conjunto de trabajo dentro de L1, L2 o L3 el mayor tiempo posible.

Referencias

  • Pivot blog: benchmark original de GROUP BY naive sobre el dataset «hits» de ClickBench, con los contadores de perf stat citados en este artículo.
  • Wikipedia: CPU cache: explicación general de los niveles de caché L1, L2 y L3 y su rol en la jerarquía de memoria.
  • Perf Wiki (kernel.org): documentación oficial de la herramienta perf y sus eventos de hardware.
  • ClickHouse Docs: documentación del motor columnar que usa agregación vectorizada y particionada para GROUP BY.

📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.

Imagen destacada: Foto de Óscar Salgado en Unsplash

¿Te sirvió? ¿Te dio otro error? Contalo abajo: las preguntas se responden y le sirven al siguiente que llegue.

Dejar un comentario

Javier Alarcón

Ingeniero de infraestructura especializado en redes, sistemas Linux, Kubernetes y arquitecturas cloud. Cubre hardware, networking, observabilidad y prácticas de ingeniería para equipos de producción.

0 Comentarios

Deja un comentario

Marcador de posición del avatar

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Podés incluir código entre <code>…</code> o, para varias líneas, <pre><code>…</code></pre>.

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.