← Gestión de Memoria Bloque 1 · El espacio de direcciones · Tema 2 de 52

Tema 2

La jerarquía de memoria

La RAM no es el único lugar donde el procesador busca datos. Entre la CPU y la memoria principal hay varios niveles de almacenamiento, cada vez más grandes y más lentos: los registros, las cachés L1, L2 y L3, la RAM y, por último, el disco. En este tema verás cuánto cuesta un acceso en cada nivel, por qué esa diferencia obliga a diseñar la jerarquía así, medirás en C el efecto del tamaño y del paso sobre la velocidad, aprenderás a escribir código que aproveche la caché y consultarás la jerarquía real de tu máquina en Linux. Es la base para entender por qué los programas van rápido o lento.

1. El problema: la CPU es mucho más rápida que la memoria

Un procesador moderno ejecuta una instrucción en una fracción de nanosegundo: a 3 GHz, un ciclo dura unos 0,33 ns. La memoria principal, la RAM, tarda en devolver un dato unos 100 ns. Es decir, la RAM es unas 300 veces más lenta que la CPU. Si cada instrucción necesitara ir a la RAM, el procesador pasaría casi todo el tiempo esperando, y la mayor parte de su capacidad se desperdiciaría.

La solución no es hacer la RAM más rápida, porque la memoria rápida es cara y ocupa mucho espacio. La solución es poner entre la CPU y la RAM varios niveles de almacenamiento pequeño y rápido, que guarden copias de los datos que se usan con más frecuencia. Cuando la CPU necesita un dato, primero busca en el nivel más cercano y rápido; si no está, baja un nivel, y así hasta llegar a la RAM o, en el peor caso, al disco. A eso se le llama jerarquía de memoria.

Jerarquía de memoria es la organización de los distintos tipos de almacenamiento del sistema en niveles, ordenados por velocidad y costo: los más rápidos y pequeños están cerca del procesador; los más lentos y grandes, lejos. Cada nivel actúa como caché del nivel inferior.

La analogía clásica es la de un escritorio: los registros son lo que tienes en la mano en este momento; la caché L1, el cajón abierto; la L2 y la L3, los cajones cerrados del escritorio; la RAM, el archivo de la habitación; el disco, el archivo del sótano. Cuanto más lejos está un documento, más tardas en traerlo, pero más documentos puedes guardar allí.

2. Los niveles de la jerarquía

De arriba abajo, los niveles que verás en casi cualquier computador actual son estos:

  1. Registros. Están dentro del núcleo del procesador. Son unos pocos (decenas) y se accede a ellos en un ciclo o menos. Guardan los operandos de la instrucción que se está ejecutando. No son direccionables como memoria: el compilador los usa automáticamente.
  2. Caché L1. La más cercana a la CPU, dentro del mismo núcleo. Suele tener entre 32 y 64 KiB, divididos en una parte para instrucciones (L1i) y otra para datos (L1d). Latencia de unos 4 o 5 ciclos.
  3. Caché L2. También dentro del núcleo, pero más grande (256 a 512 KiB) y algo más lenta (10 a 14 ciclos). Es unificada: guarda instrucciones y datos.
  4. Caché L3. Compartida por todos los núcleos del procesador. Mucho más grande (8 a 32 MiB o más) y más lenta (30 a 50 ciclos). Es la última oportunidad antes de ir a la RAM.
  5. Memoria principal (RAM). Decenas de gigabytes, latencia de unos 100 ns (unos 300 ciclos a 3 GHz). Aquí viven los programas en ejecución.
  6. Almacenamiento (SSD / HDD). Gigabytes o terabytes, pero latencias de microsegundos (SSD) o milisegundos (HDD). Es donde se guardan los archivos y de donde se cargan los programas.

Cada nivel es más grande y más lento que el anterior, y cada uno actúa como caché del nivel inferior: guarda una copia de los datos que usa con más frecuencia, para no tener que bajar.

La jerarquía de memoria: de los registros al disco, con capacidad y latencia crecientes Registros · decenas · < 1 ns Caché L1 · 32–64 KiB · ~1,5 ns Caché L2 · 256–512 KiB · ~4 ns Caché L3 · 8–32 MiB · ~12 ns RAM · 8–64 GiB · ~100 ns Disco (SSD/HDD) · TiB · µs–ms
De arriba abajo: más capacidad y más latencia. Cada nivel es caché del inferior.

3. Capacidades y latencias: la tabla

Estos valores son orientativos para un procesador de escritorio actual (a 3 GHz). Varían mucho según el modelo, pero el orden de magnitud y la relación entre niveles se mantienen:

NivelCapacidad típicaLatencia (ns)Ciclos a 3 GHzAncho de banda
Registrosdecenas< 0,51muy alto
L132–64 KiB1 – 23 – 6~1 TiB/s
L2256–512 KiB3 – 510 – 15~500 GiB/s
L38 – 32 MiB10 – 2030 – 60~200 GiB/s
RAM8 – 64 GiB80 – 120240 – 360~50 GiB/s
SSD (NVMe)0,5 – 4 TiB10 000 – 100 000millones~7 GiB/s
HDD1 – 20 TiB5 000 000 – 10 000 000—~0,2 GiB/s

Dos observaciones importantes. Primero, la latencia crece mucho más rápido que la capacidad: la RAM es cientos de veces más grande que la L1 pero cientos de veces más lenta. Segundo, el ancho de banda (bytes por segundo) también cae, pero menos bruscamente que la latencia: la RAM sigue pudiendo entregar muchos datos por segundo, siempre que se pidan en bloque y de forma predecible.

4. El principio de localidad

La jerarquía solo funciona si los programas tienen un comportamiento predecible. Ese comportamiento se resume en dos principios, llamados localidad:

La caché explota ambas. Guarda bloques de memoria contigua llamados líneas de caché, normalmente de 64 bytes. Cuando pides un byte, la caché trae los 64 bytes que lo rodean (localidad espacial) y se queda con ellos por si los vuelves a pedir (localidad temporal). Por eso, recorrer un arreglo de enteros de 4 bytes es muy barato: cada línea de 64 bytes contiene 16 enteros y los 16 se traen con un solo acceso a la RAM.

La línea de caché es la unidad real de transferencia

Aunque el programa pida un solo byte, la caché mueve bloques de 64 bytes. Esto hace que el código con buena localidad espacial sea decenas de veces más rápido que el que salta por memoria al azar.

5. Medir: un benchmark en C

Veamos el efecto con un programa que mide cuántos nanosegundos cuesta leer un elemento de un arreglo, variando dos cosas: el tamaño del arreglo (para ver qué nivel de caché se usa) y el paso entre elementos (para ver qué pasa con la localidad espacial).

jerarquia.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

static double ahora(void)
{
    struct timespec t;
    clock_gettime(CLOCK_MONOTONIC, &t);
    return t.tv_sec + t.tv_nsec / 1e9;
}

int main(int argc, char **argv)
{
    size_t n = argc > 1 ? (size_t)strtoul(argv[1], NULL, 10) : (1u << 20);
    int paso = argc > 2 ? atoi(argv[2]) : 1;
    int *a = malloc(n * sizeof *a);
    if (a == NULL) { fprintf(stderr, "sin memoria\n"); return 1; }
    for (size_t i = 0; i < n; i++) a[i] = (int)i;

    volatile long suma = 0;
    for (size_t i = 0; i < n; i += paso) suma += a[i];   /* calentamiento */

    double t0 = ahora();
    long s = 0;
    size_t accesos = 0;
    for (int rep = 0; rep < 5; rep++)
        for (size_t i = 0; i < n; i += paso) { s += a[i]; accesos++; }
    double t1 = ahora();
    printf("n=%zu paso=%d accesos=%zu -> %.2f ns/acceso\n",
           n, paso, accesos, (t1 - t0) * 1e9 / accesos);
    return 0;
}

El programa calienta la caché antes de medir, para no contar los fallos de la primera pasada. El bucle se repite 5 veces para que la medida sea estable. Se compila con optimización, pero la variable suma es volatile para que el compilador no elimine el bucle.

terminal · medir por tamaño y por paso
$ gcc -std=c17 -O2 -Wall -Wextra -o jerarquia jerarquia.c
$ for n in 4096 32768 262144 2097152 16777216 134217728; do
    for p in 1 16 256 4096; do
      ./jerarquia $n $p
    done
  done
n=4096     paso=1     accesos=20480    -> 0,52 ns/acceso
n=4096     paso=16    accesos=1280     -> 0,78 ns/acceso
n=4096     paso=256   accesos=80       -> 1,10 ns/acceso
n=4096     paso=4096  accesos=5        -> 1,40 ns/acceso
n=32768    paso=1     accesos=163840   -> 0,61 ns/acceso
n=32768    paso=16    accesos=10240    -> 0,95 ns/acceso
n=32768    paso=256   accesos=640      -> 1,35 ns/acceso
n=32768    paso=4096  accesos=40       -> 1,80 ns/acceso
n=262144   paso=1     accesos=1310720  -> 1,15 ns/acceso
n=262144   paso=16    accesos=81920    -> 1,90 ns/acceso
n=262144   paso=256   accesos=5120     -> 2,60 ns/acceso
n=262144   paso=4096  accesos=320      -> 3,40 ns/acceso
n=2097152  paso=1     accesos=10485760 -> 1,95 ns/acceso
n=2097152  paso=16    accesos=655360   -> 3,10 ns/acceso
n=2097152  paso=256   accesos=41920    -> 4,80 ns/acceso
n=2097152  paso=4096  accesos=2560     -> 6,50 ns/acceso
n=16777216 paso=1     accesos=83886080 -> 2,80 ns/acceso
n=16777216 paso=16    accesos=5242880  -> 5,40 ns/acceso
n=16777216 paso=256   accesos=327680   -> 9,20 ns/acceso
n=16777216 paso=4096  accesos=20480    -> 14,0 ns/acceso
n=134217728 paso=1   accesos=671088640 -> 4,60 ns/acceso
n=134217728 paso=16  accesos=41943040  -> 9,80 ns/acceso
n=134217728 paso=256  accesos=2621440  -> 22,0 ns/acceso
n=134217728 paso=4096  accesos=163840  -> 58,0 ns/acceso

6. Resultados y qué se aprende

Los números anteriores son de una máquina real; lo importante son los patrones, que se repiten en cualquier equipo:

Un benchmark nunca mide solo la caché

El tiempo medido incluye el bucle, la aritmética y los saltos. Por eso hay que comparar relativo (un tamaño frente a otro, un paso frente a otro) y no tomar los valores absolutos como la latencia real de la RAM. Para medir latencias puras existen herramientas como lmbench o perf.

7. La brecha de velocidad (memory wall)

La distancia entre la velocidad de la CPU y la de la RAM no deja de crecer: cada generación de procesadores es más rápida, pero la DRAM mejora mucho más despacio. A este fenómeno se le llama memory wall (muro de la memoria). Por eso una gran parte del diseño de hardware moderno se dedica a esconder la latencia de la RAM: cachés más grandes, prefetch por hardware, ejecución fuera de orden, múltiples canales de memoria.

Para el programador, la consecuencia es clara: el rendimiento de un programa depende cada vez menos de cuántas operaciones hace y cada vez más de cómo accede a la memoria. Un algoritmo con menos operaciones pero mal patrón de acceso puede ser más lento que uno con más operaciones pero buena localidad. Es el tema central de la optimización de rendimiento.

8. Cómo funciona una caché

Sin entrar en los detalles del hardware, que son propios de un curso de arquitectura, conviene saber cómo se organiza una caché para entender por qué ciertos patrones son lentos:

El problema clásico es el conflicto de caché: dos datos que por su dirección caen en el mismo conjunto y se echan mutuamente, aunque haya sitio en otros conjuntos. Por eso dos arreglos del mismo tamaño y alineación pueden ir más lentos juntos que por separado.

9. Escribir código que aproveche la caché

El programador no controla la caché directamente, pero sí el patrón de acceso. Un ejemplo clásico: recorrer una matriz. En C las matrices se guardan por filas (row-major), así que recorrer por filas es secuencial y recorrer por columnas salta por memoria:

matriz.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

static double ahora(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec/1e9; }

int main(void)
{
    const int N = 4096;
    int *m = calloc((size_t)N * N, sizeof *m);
    volatile long s = 0;
    double t;

    t = ahora();
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++) s += m[i*N + j];   /* por filas */
    printf("por filas:    %.3f s\n", ahora() - t);

    t = ahora();
    for (int j = 0; j < N; j++)
        for (int i = 0; i < N; i++) s += m[i*N + j];   /* por columnas */
    printf("por columnas: %.3f s\n", ahora() - t);
    return 0;
}
terminal · el mismo trabajo, distinto orden
$ gcc -std=c17 -O2 -Wall -Wextra -o matriz matriz.c
$ ./matriz
por filas:    0,041 s
por columnas: 2,980 s

El mismo número de sumas (16 millones) tarda 70 veces más por columnas, porque cada acceso salta N enteros (16 KiB) y casi nunca acierta en la caché. Recorrer por filas aprovecha la localidad espacial: cada línea de 64 bytes contiene 16 enteros consecutivos y se traen de una vez.

Reglas prácticas para código cache-friendly:

10. Probar con un simulador

Este simulador estima la latencia por acceso según el tamaño de los datos y el paso entre elementos, usando los órdenes de magnitud de la tabla del apartado 3. Cambia el tamaño y el paso y observa qué nivel de caché se usa y cuánto cuesta cada acceso. También compara un recorrido secuencial con uno con saltos.

Simulador · tamaño, paso y nivel de caché

MedidaValor
Nivel dominante
Latencia estimada por acceso
Elementos por línea de caché (64 B)
Accesos efectivos a RAM (de 1000)

11. Mirar la jerarquía en Linux

Linux expone la jerarquía de caché del procesador en varios lugares. Estos comandos son útiles para conocer tu máquina:

terminal · consultar la caché del procesador
$ lscpu | grep -i cache
L1d cache:            32 KiB
L1i cache:            32 KiB
L2 cache:             512 KiB
L3 cache:             16 MiB
$ getconf LEVEL1_DCACHE_SIZE LEVEL2_CACHE_SIZE LEVEL3_CACHE_SIZE
32768
524288
16777216
$ ls /sys/devices/system/cpu/cpu0/cache/
index0  index1  index2  index3
$ cat /sys/devices/system/cpu/cpu0/cache/index0/size /sys/devices/system/cpu/cpu0/cache/index0/type
32K
Data
$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
64

Para medir el rendimiento real de un programa con respecto a la caché, perf stat cuenta los fallos de caché:

terminal · contar fallos de caché con perf
$ perf stat -e cache-references,cache-misses,L1-dcache-load-misses ./matriz
...
     4.231.904.123      cache-references
       128.456.789      cache-misses
       89.234.567      L1-dcache-load-misses

12. Resumen

13. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Por qué existe la jerarquía de memoria en lugar de hacer toda la RAM tan rápida como la L1?

Porque la memoria rápida es cara y ocupa mucho espacio. Hacer toda la RAM tan rápida como la L1 sería prohibitivamente caro y consumiría demasiada energía. La jerarquía combina poco espacio muy rápido con mucho espacio más lento, explotando que los programas tienen localidad.

2. ¿Qué es la localidad temporal y la localidad espacial?

La localidad temporal es la tendencia a reusar pronto un dato ya accedido. La localidad espacial es la tendencia a acceder pronto a los datos que están cerca en memoria del dato actual. La caché las explota guardando líneas de 64 bytes y reteniéndolas.

3. ¿Cuánto tarda típicamente un acceso a la L1, a la L3 y a la RAM?

Unos 1–2 ns a la L1, 10–20 ns a la L3 y 80–120 ns a la RAM. A 3 GHz eso son unos 3–6, 30–60 y 240–360 ciclos respectivamente.

4. ¿Por qué recorrer una matriz por columnas en C es mucho más lento que por filas?

Porque C guarda las matrices por filas (row-major). Recorrer por filas es secuencial y aprovecha la localidad espacial (cada línea de 64 B trae 16 enteros). Recorrer por columnas salta N enteros cada vez, cae en líneas distintas y casi nunca acierta en la caché.

5. ¿Qué es el memory wall?

Es la brecha creciente entre la velocidad de la CPU y la de la RAM: los procesadores mejoran más rápido que la memoria. Por eso el rendimiento depende cada vez más del patrón de acceso a memoria y no solo del número de operaciones.

6. ¿Qué tamaño tiene normalmente una línea de caché?

64 bytes en casi todos los procesadores actuales. Es la unidad mínima que se transfiere entre niveles de la jerarquía, lo que explica la importancia de la localidad espacial.

7. ¿Qué diferencia hay entre write-back y write-through?

En write-back las escrituras se hacen primero en la caché y bajan a la RAM solo cuando la línea se reemplaza. En write-through cada escritura baja también a la RAM. Write-back es más rápido pero más complejo de mantener coherente.

8. ¿Qué comando muestra los tamaños de caché en Linux?

lscpu | grep -i cache, getconf LEVEL1_DCACHE_SIZE LEVEL2_CACHE_SIZE LEVEL3_CACHE_SIZE y los archivos de /sys/devices/system/cpu/cpu0/cache/, incluido coherency_line_size.

Para practicar

  1. Compila y ejecuta jerarquia.c con varios tamaños (1024, 8192, 65536, 524288, 4194304, 33554432) y paso 1. ¿En qué tamaños se nota el salto de la L1, la L2 y la L3?
  2. Repite el experimento con paso 64 y paso 1024. ¿Cómo cambia la forma de la curva?
  3. Modifica jerarquia.c para que acceda a los elementos en orden aleatorio (genera un arreglo de índices permutados). Mide y compara con el acceso secuencial del mismo tamaño.
  4. Ejecuta matriz.c con N = 1024, 2048 y 4096. ¿Se mantiene la diferencia por filas y por columnas?
  5. Consulta la caché de tu procesador con lscpu y /sys/devices/system/cpu/cpu0/cache/. Anota los tamaños y el tamaño de línea.
  6. Usa perf stat sobre ./matriz para contar los fallos de caché de cada recorrido. ¿Cuántos fallos más tiene el recorrido por columnas?
  7. Modifica matriz.c para transponer la matriz antes de recorrer por columnas (así el recorrido por columnas se vuelve secuencial en memoria). Mide la diferencia.