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:
- 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 + bentre dos objetosintde Python tarda ~50 nanosegundos. Unfor x in arr: total += xsobre un array NumPy de 10⁶ elementos es ~50 ms (10⁶ × 50 ns). - Las operaciones de NumPy liberan el GIL y corren en C. Un
arr + 1vectorizado 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¶
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:
- 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. - 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.
- Estás llamando a una función de librería que ya vectoriza internamente.
scipy.optimize.minimizellama 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¶
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¶
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.
printno distingue ruido de depuración de "esto es un error crítico". - Rendimiento.
printhace 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úaexpensive_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¶
- 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.
%timeiten Jupyter. Fácil, preciso, pero solo funciona para ops cortas (decide auto el conteo de bucles). Para ops de varios segundos, recurre atime.perf_counter_ns().- 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). - Cronometraje consciente de threading.
time.process_time()excluye sleep;time.perf_counter()incluye todo. Elige deliberadamente. - 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. - 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.