Skip to content

English · Español

03 — Vectorización y perfilado

La regla del orden de magnitud: un bucle Python sobre un array NumPy de 10⁶ elementos es ~100× más lento que la expresión vectorizada equivalente. Si la sientes, no la sufres. Y si quieres saber exactamente dónde se va el tiempo o la memoria, hay cuatro herramientas — cProfile, line_profiler, memory_profiler, py-spy — cada una con su caso de uso.


El modelo de coste de Python+NumPy

Dos fuerzas en competencia:

  1. Python es interpretado. Cada operación de Python implica: dispatch de bytecode, búsquedas de atributos, dispatch de tipos y posiblemente asignaciones. Un único a + b entre dos objetos int de Python tarda ~50 nanosegundos. Un for x in arr: total += x sobre un array NumPy de 10⁶ elementos es ~50 ms (10⁶ × 50 ns).
  2. Las operaciones de NumPy liberan el GIL y corren en C. Un arr + 1 vectorizado se implementa como un bucle C compacto sobre el buffer subyacente. Está acotado por el ancho de banda de memoria (aplica el roofline de la Fase 1) y el rendimiento SIMD, no por el sobrecoste de Python. Las mismas 10⁶ sumas: ~1 ms.

La ratio de 50× de arriba es conservadora. Para ops por elemento más complejas (np.exp, np.tanh), la ratio puede ser 100×–500×.

La regla

Cada vez que iteres a nivel Python sobre un ndarray, tienes un bug de rendimiento.

Excepciones:

  • El array tiene <100 elementos y lo haces una vez. Bien.
  • Estás haciendo algo que genuinamente no se vectoriza (rara vez es cierto; normalmente no te estás esforzando lo suficiente).
  • Estás llamando a código C externo por elemento — misma situación que abajo.

Patrones de vectorización

Patrón 1: reemplazar bucles explícitos por ops sobre el array completo

# Mal:
total = 0.0
for x in arr:
    total += x

# Bien:
total = arr.sum()

Patrón 2: usar broadcasting para evitar bucles sobre pares

# Mal:
dists = np.empty((N, M))
for i in range(N):
    for j in range(M):
        dists[i, j] = np.sqrt(((a[i] - b[j]) ** 2).sum())

# Bien (broadcasting):
diff = a[:, None, :] - b[None, :, :]    # shape (N, M, D)
dists = np.sqrt((diff ** 2).sum(-1))    # shape (N, M)

La versión "bien" reserva (N, M, D) una vez — rápido en NumPy pero posiblemente pesado en memoria. Para N×M enormes, usa cdist o enfoques por batch.

Patrón 3: einsum para contracciones no obvias

# Mal:
out = np.empty((B, N, K))
for b in range(B):
    out[b] = a[b] @ w   # si a es (B, N, M) y w es (M, K)

# Bien:
out = np.einsum('bnm,mk->bnk', a, w)   # o simplemente a @ w (NumPy 1.10+ maneja batch dims)

einsum es la herramienta más potente para "tengo tensores N-D y quiero contraer sobre estos ejes". Más cara que np.matmul para los casos comunes; equivalente o mejor para los inusuales.

Patrón 4: evitar if de Python dentro de bucles calientes

# Mal:
out = np.empty_like(x)
for i in range(len(x)):
    out[i] = x[i] if x[i] > 0 else 0

# Bien:
out = np.maximum(x, 0)   # o np.where(x > 0, x, 0)

Patrón 5: pre-reservar

# Mal (re-reserva):
result = np.array([])
for i in range(N):
    result = np.append(result, expensive(i))  # tiempo cuadrático!

# Bien:
result = np.empty(N)
for i in range(N):
    result[i] = expensive(i)

# Mejor (si expensive se vectoriza):
result = expensive(np.arange(N))

np.append y np.concatenate re-reservan en cada llamada. Para construcción incremental, pre-reserva y rellena, o acumula en una list y haz np.array(...) al final (sin explosión cuadrática).

Cuándo la vectorización no ayuda

Tres situaciones donde el bucle Python está bien:

  1. La op es compleja por elemento y NumPy no envía una primitiva. Ej.: ordenación por fila con una regla de desempate basada en otro array. Puede que necesites np.apply_along_axis (que es en sí un bucle Python, solo oculto) o aceptar el coste.
  2. Los datos son lo bastante pequeños como para que el sobrecoste de Python desaparezca. Un bucle sobre 50 elementos tarda ~5 μs. No importa.
  3. Estás llamando a una función de librería que ya vectoriza internamente. scipy.optimize.minimize llama a tu función objetivo muchas veces — no es tu trabajo vectorizar a través de las iteraciones del optimizador.

Los cuatro profilers

Cuando das con una ruta lenta y necesitas diagnosticar, cuatro herramientas cubren el terreno:

1. cProfile — a nivel función, estadístico

python -m cProfile -s cumtime my_script.py

Incluido en la stdlib. Cuenta cada llamada de función, tiempo total, tiempo por llamada, callees. Sobrecoste: ~3–5× — tu código corre más lento bajo cProfile, así que los tiempos absolutos están sesgados; el ranking relativo es fiable.

Úsalo cuando: quieres saber qué función domina. La salida es grande; pásala por snakeviz (pip install snakeviz; snakeviz output.prof) para un flamegraph.

import cProfile, pstats
with cProfile.Profile() as pr:
    expensive_thing()
pstats.Stats(pr).sort_stats('cumtime').print_stats(20)

2. line_profiler — línea a línea, instrumentado

@profile
def my_function():
    ...

# Ejecutar con:
#   kernprof -l -v my_script.py

Reporta tiempo por línea dentro de las funciones decoradas. Mayor sobrecoste (~10×) pero ves exactamente qué línea es lenta.

Úsalo cuando: cProfile dijo "la función X es lenta"; necesitas saber qué línea.

3. memory_profiler — memoria línea a línea

@profile
def my_function():
    a = np.zeros(100_000_000)   # ← reporta +763 MB aquí
    ...

# Ejecutar con:
#   python -m memory_profiler my_script.py

Reporta el delta de memoria por línea. Lento (muestrea RSS por línea). Útil para cazar "¿dónde se fue el GB?".

4. py-spy — muestreo, sin instrumentación

py-spy record -o profile.svg --pid 12345
# o para ejecutar desde el inicio:
py-spy record -o profile.svg -- python my_script.py

Se engancha a un proceso Python en marcha vía ptrace (Linux/macOS) y muestrea la pila de llamadas. No requiere cambios de código, sin sobrecoste dentro de tu código (~1–2% de sobrecoste de muestreo). Produce un SVG de flamegraph.

Úsalo cuando: perfilas un job de entrenamiento de larga duración, o código de producción que no puedes modificar. La feature asesina.

Caveat de permisos en Fedora: py-spy necesita hacer ptrace a tu proceso. O lo corres con sudo, o pones kernel.yama.ptrace_scope = 0 (menos seguro; revierte después).

Cuándo usar cuál (árbol de decisión)

¿El código ya está corriendo y no puedo añadir @profile?
├─ Sí → py-spy
└─ No
   ├─ ¿Sé qué función es lenta?
   │  ├─ No  → cProfile (luego snakeviz)
   │  └─ Sí → line_profiler
   └─ ¿Es un problema de memoria, no de tiempo?
      └─ memory_profiler (a nivel línea) o `tracemalloc` (diff de snapshot)

Logging frente a print

Pasada la Fase 6, print es code smell en cualquier código commiteado (los print de depuración en experimentos están bien, eliminados antes del commit). Razones:

  • No estructurado. print(f"loss={loss}") produce un string que las herramientas downstream (grep, jq, dashboards) no pueden parsear de forma fiable.
  • Niveles mezclados. print no distingue ruido de depuración de "esto es un error crítico".
  • Rendimiento. print hace flush a stdout, posiblemente a un TTY con line buffering, posiblemente causando context switches que no quieres durante un paso de entrenamiento.
  • Formateo eager. print(f"x={expensive_repr(x)}") evalúa expensive_repr(x) aunque decidas silenciar los prints.

El sustituto de la Fase 6 es structlog:

from src.utils.logging import get_logger
log = get_logger(__name__)

log.info("training_step", step=step, loss=loss, lr=lr)
# Emite una línea JSON: {"event": "training_step", "step": 42, "loss": 0.123, "lr": 0.001, ...}

structlog está configurado (en el lab 00 de la Fase 6) para:

  • Emitir JSON para consumo por máquina (cuando stdout es una pipe).
  • Emitir salida bonita y coloreada para humanos (cuando stdout es un TTY).
  • Incluir siempre un timestamp, nivel de log y campos de contexto configurables (ej., phase).

La razón por la que esto importa: para la Fase 18 estarás entrenando modelos durante decenas de minutos por ejecución, y las líneas de log serán la única ventana al estado en vivo. Hacer grep sobre JSON es sano; hacer grep sobre strings de print ad-hoc es miseria.

Un presupuesto de vectorización trabajado

El lab 03 (vectorization-budget) mide la ratio bucle-Python-frente-a-NumPy para sum a lo largo de tamaños de array 2^k para k ∈ [4..24]. Forma esperada del resultado:

Tamaño Bucle Python NumPy .sum() Ratio
16 1.0 μs 1.5 μs 0.7×
256 18 μs 1.6 μs 11×
4096 280 μs 5 μs 56×
65536 4.5 ms 70 μs 64×
1048576 75 ms 1.0 ms 75×
16777216 1.2 s 16 ms 75×

En tamaños pequeños, el sobrecoste por llamada de NumPy (~1.5 μs para entrar al kernel C) pierde frente a un bucle Python compacto. En ~256 elementos se cruzan. En 1M de elementos la ratio se estabiliza en ~75× — NumPy es ahora bandwidth-bound (¡roofline de Fase 1!), y el sobrecoste de Python por elemento es lo que queda tras restar la cota de ancho de banda.

El lab de Borja producirá este plot en su propia máquina; los números exactos variarán, la forma no.

Escollos

  1. Perfilar sin warmup. La primera llamada a una función de NumPy carga librerías compartidas, reserva pools y es lenta. Corre siempre una iteración descartable antes de cronometrar.
  2. %timeit en Jupyter. Fácil, preciso, pero solo funciona para ops cortas (decide auto el conteo de bucles). Para ops de varios segundos, recurre a time.perf_counter_ns().
  3. Recolección de basura durante la medición. Una reserva de 100 MB podría disparar un GC de gen-2. Para mediciones precisas, gc.disable() alrededor del bloque cronometrado (y re-habilitar después).
  4. Cronometraje consciente de threading. time.process_time() excluye sleep; time.perf_counter() incluye todo. Elige deliberadamente.
  5. Ruido de hardware. Escalado de frecuencia de CPU, throttling térmico, procesos en background. El lab 00 de la Fase 1 puso el governor en performance; mantén ese hábito para las mediciones de la Fase 6 también.
  6. Orden de reducción de np.sum. Para arrays fp32 muy grandes, el orden de la suma afecta al resultado (cancelación catastrófica, Fase 2). NumPy usa suma por pares, que es más precisa que la naive; aún no es Kahan. La Fase 2 lo cubrió.

Recapitulación en un párrafo

Python+NumPy tiene un modelo de coste de dos niveles: los bucles Python sobre arrays son ~100× más lentos que las expresiones vectorizadas a gran escala, porque los kernels C de NumPy liberan el GIL y saturan el ancho de banda de memoria, mientras que el dispatch de bytecode de Python domina el coste por elemento. La vectorización es lo predeterminado; los bucles explícitos son code smell que necesita justificación. Cuando necesitas diagnosticar lentitud, cuatro profilers cubren el terreno (cProfile a nivel función, line_profiler a nivel línea, memory_profiler para memoria, py-spy en vivo sin instrumentación). Y el logging estructurado (structlog) reemplaza a print desde esta fase en adelante — cada fase posterior depende de líneas de log parseables.


Siguiente: La Fase 6 no tiene más páginas de teoría. Pasa a lab/00-environment-and-utilities.md.