← Volver al blog
· 5 min de lectura · 842 palabras

Caché CPU y su impacto en el rendimiento de tu backend

Introducción

Tu CPU tiene una jerarquía de memoria: registros → L1 → L2 → L3 → RAM → disco. Cada nivel es más grande pero más lento. Acceder a L1 cuesta ~1ns, a RAM ~100ns, a disco ~10ms. La diferencia entre un acierto en L1 y un fallo que va a RAM puede ser de 10-50 ciclos de CPU.

El código que es "cache-friendly" organiza el acceso a datos para maximizar aciertos en los niveles superiores de caché. En lenguajes de alto nivel como Python no tienes control directo sobre la caché, pero entender cómo funciona te ayuda a escribir código que se comporte bien.

Jerarquía de caché

En un procesador moderno (ej. AMD Zen 4):

Nivel Tamaño Latencia Asociatividad
L1 (datos) 32 KB ~1ns (4 ciclos) 8-way
L1 (instrucciones) 32 KB ~1ns 8-way
L2 1 MB ~4ns (14 ciclos) 8-way
L3 32 MB ~14ns (45 ciclos) 16-way
RAM 32 GB+ ~100ns -

Líneas de caché

La unidad mínima de transferencia entre RAM y caché es una "cache line": 64 bytes. Cuando accedes a una variable, la CPU carga los 64 bytes que la contienen. Acceder a datos cercanos (misma línea) es gratis después de la primera carga.

Implicaciones para Python

Listas vs arrays densos

En Python puro, las listas son arrays de punteros a objetos PyObject. Cada elemento implica una indirección:

lista = [1, 2, 3, 4, 5]
# En memoria: [ptr_to_obj1, ptr_to_obj2, ...]
# Los enteros están en ubicaciones dispersas del heap

Con numpy, los datos son contiguos en memoria:

import numpy as np
arr = np.array([1, 2, 3, 4, 5], dtype=np.int64)
# En memoria: [1, 2, 3, 4, 5] contiguo

La diferencia de rendimiento entre recorrer una lista de Python y un array de numpy no es solo por C vs Python: también es por el patrón de acceso a memoria.

Acceso por filas vs columnas

En una matriz 2D almacenada en orden row-major (C-order, el de numpy por defecto):

matriz = np.random.rand(10000, 10000)

# Bueno: accede por filas (contiguo en memoria)
for i in range(10000):
    for j in range(10000):
        _ = matriz[i, j]

# Malo: accede por columnas (saltos grandes)
for j in range(10000):
    for i in range(10000):
        _ = matriz[i, j]

El segundo bucle es 10-50x más lento porque cada acceso salta 80000 bytes (10000 × 8), destruyendo la localidad espacial.

False sharing

Cuando dos hilos modifican variables que están en la misma cache line, el hardware invalida la línea en ambos cores, causando que tengan que recargarla constantemente:

import threading
import numpy as np

# Dos contadores que pueden compartir cache line
contadores = np.zeros(2, dtype=np.int64)

def inc(i):
    for _ in range(10**7):
        contadores[i] += 1  # False sharing si están en misma línea

t1 = threading.Thread(target=inc, args=(0,))
t2 = threading.Thread(target=inc, args=(1,))

La solución: separar los contadores con suficiente espacio (más de 64 bytes) para que estén en diferentes cache lines. numpy facilita esto: np.zeros(2, dtype=np.int64) con padding.

Cómo escribir código cache-friendly

1. Acceso secuencial

# Bueno: acceso secuencial (prefetch funciona)
for v in array:
    procesar(v)

# Malo: saltos aleatorios
for idx in indices_aleatorios:
    procesar(array[idx])

2. Bloquear operaciones

Para operaciones que exceden la caché L2/L3, divide los datos en bloques:

def multiplicacion_bloques(A, B, tam_bloque=256):
    n = A.shape[0]
    C = np.zeros((n, n))
    for i in range(0, n, tam_bloque):
        for j in range(0, n, tam_bloque):
            for k in range(0, n, tam_bloque):
                C[i:i+tam_bloque, j:j+tam_bloque] += \
                    A[i:i+tam_bloque, k:k+tam_bloque] @ \
                    B[k:k+tam_bloque, j:j+tam_bloque]
    return C

Cada bloque cabe en L2 → reduce los fallos de caché drásticamente.

3. Estructuras de datos compactas

# Malo: objetos dispersos en memoria
class Punto:
    def __init__(self, x, y):
        self.x = x
        self.y = y
puntos = [Punto(i, i*2) for i in range(10**6)]

# Bueno: arrays paralelos contiguos
xs = np.arange(10**6, dtype=np.float64)
ys = np.arange(10**6, dtype=np.float64) * 2

4. Prefetching

La CPU tiene instrucciones de prefetch que cargan datos antes de que se necesiten. numpy y Numba las usan automáticamente para acceso secuencial.

Medir fallos de caché

# Con perf
perf stat -e cache-misses,cache-references python app.py

# Cache misses por kilociclo
perf stat -e cache-misses,cycles python app.py

Una tasa de miss superior al 5-10% indica que tu código no es cache-friendly.

# Desde Python (con PMU disponible)
import os

def get_cache_misses():
    with open('/sys/devices/cpu/events/cache-misses', 'r') as f:
        event = f.read().strip()
    os.system(f'perf stat -e {event} python -c "..."')

Conclusión

La caché de la CPU es invisible desde Python, pero sus efectos son medibles. Un código que accede a memoria secuencialmente, usa arrays contiguos (numpy) y evita accesos aleatorios puede ser 10-100x más rápido que uno que no.

Para backends Python, la lección práctica: usa numpy para datos grandes, prefiere acceso secuencial, evita objetos dispersos, y mide los fallos de caché cuando sospeches de rendimiento subóptimo.