Optimizar código Python sin medir primero es uno de los errores más comunes: dedicar horas a acelerar una función que apenas se llama una vez por hora mientras el verdadero cuello de botella pasa desapercibido. Las herramientas de profiling te dicen dónde gasta tiempo tu programa antes de que toques una sola línea. Este tutorial cubre cProfile, line_profiler, memory_profiler y dis con ejemplos reales.
timeit: medir operaciones pequeñas
import timeit
# Comparar concatenación de strings vs join
tiempo_concat = timeit.timeit(
'resultado = ""; [resultado := resultado + s for s in palabras]',
setup='palabras = ["hola"] * 1000',
number=1000
)
tiempo_join = timeit.timeit(
'"".join(palabras)',
setup='palabras = ["hola"] * 1000',
number=1000
)
print(f"Concatenación: {tiempo_concat:.4f}s")
print(f"join: {tiempo_join:.4f}s")
print(f"join es {tiempo_concat/tiempo_join:.1f}x más rápido")
# En Jupyter o IPython: # %timeit sorted(lista) # %timeit lista.sort()
cProfile: perfil de funciones
import cProfile
import pstats
from pstats import SortKey
def funcion_lenta():
"""Suma de cuadrados de forma ineficiente."""
total = 0
for i in range(100_000):
total += i ** 2
return total
def funcion_principal():
for _ in range(10):
funcion_lenta()
return "listo"
# Perfilar una función directamente
with cProfile.Profile() as perfil:
funcion_principal()
stats = pstats.Stats(perfil)
stats.sort_stats(SortKey.CUMULATIVE)
stats.print_stats(10) # mostrar las 10 funciones con más tiempo acumulado
# Desde la terminal: # python -m cProfile -s cumulative mi_script.py # python -m cProfile -o perfil.prof mi_script.py
pstats: analizar el fichero de perfil
import pstats
from pstats import SortKey
# Cargar y analizar el fichero .prof guardado
stats = pstats.Stats('perfil.prof')
stats.strip_dirs()
# Ordenar por tiempo propio (tiempo en la función, sin hijos)
stats.sort_stats(SortKey.TIME)
stats.print_stats(15)
# Filtrar por nombre de fichero
stats.print_stats('mi_modulo')
# Mostrar quién llamó a una función concreta
stats.print_callers('funcion_lenta')
line_profiler: tiempo línea por línea
# pip install line-profiler
# Decorar las funciones que quieres perfilar:
from line_profiler import profile
@profile
def calcular_estadisticas(datos: list[float]) -> dict:
total = sum(datos)
n = len(datos)
media = total / n
varianza = sum((x - media) ** 2 for x in datos) / n
desv = varianza ** 0.5
datos_ord = sorted(datos)
mediana = datos_ord[n // 2]
return {'media': media, 'desv': desv, 'mediana': mediana}
if __name__ == '__main__':
import random
datos = [random.gauss(100, 15) for _ in range(100_000)]
calcular_estadisticas(datos)
# Ejecutar:
# kernprof -l -v mi_script.py
memory_profiler: consumo de memoria línea por línea
# pip install memory-profiler
from memory_profiler import profile
@profile
def procesar_fichero_grande(ruta: str) -> int:
# ¿Cuánta memoria consume cada paso?
with open(ruta, 'r') as f:
lineas = f.readlines() # carga todo en memoria
palabras = [l.split() for l in lineas]
planas = [p for sublist in palabras for p in sublist]
return len(planas)
# Ejecutar:
# python -m memory_profiler mi_script.py
# Salida: MiB por línea, incremento respecto a la anterior
dis: inspeccionar el bytecode
import dis
def suma_lista(lst):
total = 0
for item in lst:
total += item
return total
def suma_builtin(lst):
return sum(lst)
print("=== suma_lista ===")
dis.dis(suma_lista)
print("n=== suma_builtin ===")
dis.dis(suma_builtin)
# suma_builtin tiene muchas menos instrucciones:
# LOAD_GLOBAL sum, LOAD_FAST lst, CALL, RETURN
# Comparar list comprehension vs generador
def con_lista(n):
return sum([i * i for i in range(n)])
def con_generador(n):
return sum(i * i for i in range(n))
dis.dis(con_lista)
dis.dis(con_generador)
py-spy: profiler de muestreo sin modificar el código
# pip install py-spy # No necesitas modificar el código fuente # Perfil durante 30 segundos de un proceso ya en marcha: # py-spy record -o perfil.svg --pid 12345 --duration 30 # Perfil de un script desde el inicio: # py-spy record -o perfil.svg -- python mi_script.py # Vista top en tiempo real: # py-spy top --pid 12345
tracemalloc: rastrear asignaciones de memoria
import tracemalloc
tracemalloc.start()
# Código a analizar
datos = [list(range(1000)) for _ in range(100)]
procesado = {i: sum(fila) for i, fila in enumerate(datos)}
instantanea = tracemalloc.take_snapshot()
top_stats = instantanea.statistics('lineno')
print("Top 5 asignaciones de memoria:")
for stat in top_stats[:5]:
print(f" {stat}")
tracemalloc.stop()
Errores típicos al interpretar resultados
- Confundir tiempo acumulado con tiempo propio: una función con mucho tiempo acumulado puede ser un coordinador que llama a funciones lentas, no la causa del problema.
- Optimizar sin benchmark previo: mide antes y después de cada cambio para confirmar que la mejora es real.
- Ignorar el GC: el recolector de basura puede causar pausas inesperadas;
gc.disable()durante el benchmark da resultados más estables. - Perfilar en desarrollo, no en producción: las condiciones de I/O, caché de CPU y carga del sistema cambian los resultados significativamente.
