Optimizar sin medir es apostar. Y en Python, las apuestas suelen salir mal porque el intérprete tiene suficiente magia interna —caché de bytecode, interning de objetos, el GIL— como para que tu intuición sobre qué es lento falle con más frecuencia de lo que aciertas. La regla de oro no es un consejo de estilo: es la diferencia entre pasar una tarde mejorando el rendimiento real de tu aplicación y pasar esa misma tarde puliendo código que nunca fue el cuello de botella.
El flujo correcto tiene cuatro pasos que no son negociables: hacer funcionar, medir, identificar hot paths, optimizar solo lo que importa. Este orden importa porque el código correcto y sin optimizar siempre es un mejor punto de partida que el código optimizado incorrecto. Un bug en producción cuesta infinitamente más que 50 ms de latencia.
Para medir tienes tres herramientas principales según el granulado que necesitas. timeit.timeit() es para microbenchmarks: expresiones o funciones pequeñas aisladas, ejecutadas miles de veces para promediar el ruido. time.perf_counter() es el reloj de mayor resolución que expone Python en todas las plataformas —usa el contador de hardware del sistema, no el reloj de pared— y es el candidato natural para instrumentar bloques de código en contexto real. cProfile es el profiler de producción: te dice qué funciones acumulan tiempo real cuando tu aplicación procesa datos reales.
El punto crítico que los microbenchmarks ocultan: medir sorted([3,1,2]) en un bucle de 100 000 iteraciones te da datos sobre esa lista de tres elementos en caché caliente, sin presión de memoria, sin contención de GIL, sin I/O. Eso no predice el comportamiento cuando ordenas 50 000 objetos que acabas de deserializar de una base de datos mientras otros hilos trabajan en paralelo. Los microbenchmarks son útiles para comparar dos implementaciones del mismo algoritmo; no para decidir si un algoritmo es el cuello de botella de tu sistema.
import timeit
import time
import cProfile
import pstats
import io
from typing import Sequence
# ─── Datos de ejemplo ───────────────────────────────────────────────────────
def generate_data(n: int) -> list[int]:
"""Genera datos semi-aleatorios de forma determinista."""
return [(i * 2654435761) % (2**32) for i in range(n)]
# ─── Dos implementaciones a comparar ────────────────────────────────────────
def sum_with_loop(numbers: Sequence[int]) -> int:
total = 0
for n in numbers:
total += n
return total
def sum_with_builtin(numbers: Sequence[int]) -> int:
return sum(numbers)
# ─── 1. timeit para microbenchmarks comparativos ────────────────────────────
def compare_with_timeit() -> None:
setup = "from __main__ import generate_data, sum_with_loop, sum_with_builtin; data = generate_data(10_000)"
iterations = 1_000
t_loop = timeit.timeit(
stmt="sum_with_loop(data)",
setup=setup,
number=iterations,
)
t_builtin = timeit.timeit(
stmt="sum_with_builtin(data)",
setup=setup,
number=iterations,
)
print("── timeit (1 000 iteraciones, lista de 10 000 elementos) ──")
print(f" loop manual : {t_loop * 1000:.2f} ms totales | {t_loop / iterations * 1e6:.1f} µs/llamada")
print(f" sum() : {t_builtin * 1000:.2f} ms totales | {t_builtin / iterations * 1e6:.1f} µs/llamada")
print(f" ratio : {t_loop / t_builtin:.1f}x más lento el loop manual\n")
# ─── 2. perf_counter para bloques de código en contexto real ────────────────
def measure_pipeline_with_perf_counter() -> None:
data = generate_data(500_000)
# perf_counter devuelve segundos en float; la diferencia es lo que importa
start = time.perf_counter()
filtered = [x for x in data if x % 3 == 0]
total = sum(filtered)
elapsed = time.perf_counter() - start # nunca uses time.time() para esto
print("── perf_counter (pipeline sobre 500 000 elementos) ──")
print(f" elementos filtrados : {len(filtered):,}")
print(f" suma : {total:,}")
print(f" tiempo total : {elapsed * 1000:.3f} ms\n")
# ─── 3. cProfile para encontrar hot paths reales ────────────────────────────
def realistic_workload() -> dict[str, object]:
"""Simula una carga de trabajo con varios pasos para que cProfile tenga algo que contar."""
data = generate_data(200_000)
sorted_data = sorted(data)
buckets: dict[int, list[int]] = {}
for value in sorted_data:
key = value % 100
buckets.setdefault(key, []).append(value)
stats = {
bucket: sum(values) / len(values)
for bucket, values in buckets.items()
}
return stats
def profile_workload() -> None:
profiler = cProfile.Profile()
profiler.enable()
realistic_workload()
profiler.disable()
# Capturamos la salida para controlar el formato
stream = io.StringIO()
ps = pstats.Stats(profiler, stream=stream)
ps.sort_stats(pstats.SortKey.CUMULATIVE) # ordena por tiempo acumulado: muestra primero los cuellos de botella
ps.print_stats(10) # las 10 funciones más costosas son suficientes para empezar
print("── cProfile (top 10 por tiempo acumulado) ──")
print(stream.getvalue())
# ─── Entrypoint ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
compare_with_timeit()
measure_pipeline_with_perf_counter()
profile_workload()
Para ejecutar el benchmark de timeit directamente desde la terminal sin montar un script:
python -m timeit -n 1000 -s "data = list(range(10_000))" "sum(data)"
python -m timeit -n 1000 -s "data = list(range(10_000))" "total = 0
for x in data:
total += x"
Cómo se conectan las tres herramientas
compare_with_timeit() usa el parámetro setup para aislar el coste real de la función medida del coste de construir los datos. Si pones la construcción de data dentro de stmt, estás midiendo la suma más la generación de la lista —el número que obtienes no te dice nada sobre ninguna de las dos cosas por separado. El parámetro number=1_000 es deliberado: con datos de 10 000 elementos, 1 000 repeticiones son suficientes para estabilizar el promedio sin que el test tarde segundos. Para expresiones triviales (comparar x in set vs x in list), usa number=100_000 o más.
time.perf_counter() aparece en measure_pipeline_with_perf_counter() midiendo un pipeline completo —filtrado más suma— tal como ocurriría en código real. La clave es que perf_counter() usa el contador de hardware de alta resolución: en Linux es clock_gettime(CLOCK_MONOTONIC), en macOS mach_absolute_time(). No deriva, no se ve afectado por ajustes NTP, y tiene resolución de nanosegundos en la mayoría de plataformas modernas. time.time() existe para saber qué hora es, no para medir duración.
profile_workload() muestra el patrón correcto de uso de cProfile: envuelves la carga de trabajo realista —no una función aislada, sino el flujo completo con datos representativos— y dejas que el profiler cuente llamadas y acumule tiempos. Ordenar por CUMULATIVE en lugar de TOTTIME es la decisión más importante: el tiempo total en una función incluye el tiempo en sus llamadas, así que las funciones arriba del stack aparecen primero y te señalan directamente el camino hacia el hot path real. TOTTIME es útil después, cuando ya sabes qué función escavar y quieres saber si el coste viene de su propia lógica o de lo que llama.
El ratio que aparezca en la comparación timeit entre el loop manual y sum() será consistente porque sum() está implementado en C y opera sobre el iterador sin boxing/unboxing adicional en Python. Pero ese ratio —que puede ser 3x o 5x— solo importa si esa función aparece en el informe de cProfile consumiendo tiempo real. Si no está ahí, optimizarla es trabajo decorativo.
Errores que debes conocer
Error: incluir la construcción de datos dentro de stmt en lugar de en setup, contaminando el resultado con trabajo ajeno a lo que quieres medir.
# ❌ Wrong
t = timeit.timeit(
stmt="data = list(range(10_000)); sum(data)",
number=10_000,
)
# ✅ Right
t = timeit.timeit(
stmt="sum(data)",
setup="data = list(range(10_000))",
number=10_000,
)
setup se ejecuta una sola vez antes del bucle; stmt se ejecuta number veces. Si construyes los datos en stmt, mides construcción más operación y el número resultante no describe ninguna de las dos cosas con fidelidad.
Error: usar time.time() o datetime.now() para medir duración de código, con el riesgo de que ajustes del reloj del sistema o resolución insuficiente introduzcan ruido o resultados negativos.
# ❌ Wrong import time start = time.time() result = some_computation() print(time.time() - start) # puede retroceder si NTP ajusta el reloj # ✅ Right start = time.perf_counter() result = some_computation() print(time.perf_counter() - start) # monotónico, alta resolución, sin deriva
perf_counter() es monotónico por definición: nunca puede devolver un valor menor que la llamada anterior, lo que hace imposible obtener duraciones negativas.
Error: perfilar con cProfile usando datos de juguete o volúmenes muy pequeños, obteniendo un top de funciones dominado por el overhead del propio profiler o por funciones de setup.
# ❌ Wrong profiler.enable() realistic_workload(n=10) # tan rápido que el overhead de cProfile distorsiona todo profiler.disable() # ✅ Right profiler.enable() realistic_workload(n=200_000) # volumen representativo del caso de uso real profiler.disable()
Con datos demasiado pequeños, el tiempo total es tan bajo que los porcentajes relativos no son estables entre ejecuciones y puedes identificar como cuello de botella algo que en producción es insignificante.
N° 166