Rendimiento y Profiling de Memoria en Python
Antes de optimizar, mide. Python ofrece herramientas integradas y de terceros para identificar exactamente dónde se gasta el tiempo y la memoria, evitando optimizaciones prematuras que complican el código sin beneficio real.
Principio fundamental
"Premature optimization is the root of all evil." — Donald Knuth
El flujo correcto:
- Mide con profiler — identifica el cuello de botella real
- Optimiza solo lo que importa (generalmente el 10% del código consume el 90% del tiempo)
- Verifica que la optimización mejoró el rendimiento
timeit — medir fragmentos pequeños
import timeit
# API de línea de comandos
# python -m timeit "x = [i**2 for i in range(1000)]"
# API programática
resultado = timeit.timeit(
stmt='[i**2 for i in range(1000)]',
number=10_000, # número de ejecuciones
)
print(f"10k ejecuciones: {resultado:.3f}s ({resultado/10_000*1e6:.2f} µs c/u)")
# Comparar dos implementaciones
lista = timeit.timeit('[i**2 for i in range(1000)]', number=10_000)
gen = timeit.timeit('list(i**2 for i in range(1000))', number=10_000)
print(f"List comprehension: {lista:.3f}s")
print(f"Generator: {gen:.3f}s")
# En Jupyter/IPython: magic %timeit
# %timeit [i**2 for i in range(1000)]
cProfile — profiling de CPU
import cProfile
import pstats
import io
def funcion_lenta():
resultado = 0
for i in range(100_000):
resultado += sum(j**2 for j in range(100))
return resultado
# Método 1: decorator
import cProfile
profiler = cProfile.Profile()
profiler.enable()
funcion_lenta()
profiler.disable()
# Analizar resultados ordenados por tiempo acumulado
stream = io.StringIO()
stats = pstats.Stats(profiler, stream=stream).sort_stats('cumulative')
stats.print_stats(20) # top 20 funciones
print(stream.getvalue())
# Método 2: context manager
with cProfile.Profile() as pr:
funcion_lenta()
stats = pstats.Stats(pr).sort_stats('cumulative')
stats.print_stats(10)
# Método 3: línea de comandos (más cómodo)
# python -m cProfile -s cumulative mi_script.py | head -30
line_profiler — profiling línea a línea
pip install line_profiler
# Decorar la función a analizar
from line_profiler import LineProfiler
def calcular_estadisticas(datos: list) -> dict:
total = sum(datos)
media = total / len(datos)
sorted_d = sorted(datos)
mediana = sorted_d[len(datos) // 2]
varianza = sum((x - media)**2 for x in datos) / len(datos)
return {'media': media, 'mediana': mediana, 'varianza': varianza}
profiler = LineProfiler()
profiler.add_function(calcular_estadisticas)
profiler.enable()
calcular_estadisticas(list(range(100_000)))
profiler.disable()
profiler.print_stats()
# En bash:
# kernprof -l -v mi_script.py (usa @profile en la función)
memory_profiler — uso de memoria línea a línea
pip install memory_profiler
# @profile — decorador proporcionado por memory_profiler
from memory_profiler import profile
@profile
def cargar_datos_grandes():
# Forma 1: lista (carga todo en RAM)
datos_lista = [i * 1.5 for i in range(1_000_000)]
# Forma 2: generador (mucho menos memoria)
datos_gen = (i * 1.5 for i in range(1_000_000))
# Procesar el generador
total = sum(datos_gen)
return total
cargar_datos_grandes()
# Ejecutar con:
# python -m memory_profiler mi_script.py
# Salida típica:
# Line # Mem usage Increment Occurrences Line Contents
# =====================================================
# 8 50.0 MiB 0.0 MiB 1 @profile
# 9 50.0 MiB 0.0 MiB 1 def cargar_datos_grandes():
# 11 127.4 MiB 77.4 MiB 1 datos_lista = [i * 1.5 for i in range(1_000_000)]
# 14 93.2 MiB -34.2 MiB 1 datos_gen = (i * 1.5 for i in range(1_000_000))
tracemalloc — rastrear allocations de memoria
import tracemalloc
# Iniciar rastreo
tracemalloc.start(10) # guardar los 10 últimos frames del stack
# Código a analizar
datos = [{'id': i, 'valor': i * 1.5, 'nombre': f'item_{i}'} for i in range(100_000)]
# Tomar snapshot
snapshot = tracemalloc.take_snapshot()
# Top 10 allocations por tamaño
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:10]:
print(stat)
# Comparar dos snapshots para detectar leaks
tracemalloc.clear_traces()
snap1 = tracemalloc.take_snapshot()
# ... código sospechoso ...
referencias = {}
for i in range(10_000):
referencias[i] = [0] * 100 # acumulación en dict
snap2 = tracemalloc.take_snapshot()
top_diff = snap2.compare_to(snap1, 'lineno')
for stat in top_diff[:5]:
print(f"+{stat.size_diff/1024:.1f} KB — {stat}")
tracemalloc.stop()
Optimización de memoria con slots
import sys
class PuntoNormal:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
class PuntoSlots:
__slots__ = ('x', 'y', 'z') # sin __dict__ por instancia
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
# Comparar tamaño en memoria
p1 = PuntoNormal(1.0, 2.0, 3.0)
p2 = PuntoSlots(1.0, 2.0, 3.0)
print(f"Normal: {sys.getsizeof(p1)} bytes + {sys.getsizeof(p1.__dict__)} (dict)")
print(f"__slots__: {sys.getsizeof(p2)} bytes (sin dict)")
# Ahorro típico: 40-50% por instancia — importante con millones de objetos
n = 1_000_000
import tracemalloc
tracemalloc.start()
puntos_normales = [PuntoNormal(i, i, i) for i in range(n)]
snap1 = tracemalloc.take_snapshot()
del puntos_normales
puntos_slots = [PuntoSlots(i, i, i) for i in range(n)]
snap2 = tracemalloc.take_snapshot()
print("Diff:", snap2.compare_to(snap1, 'lineno')[0])
Recolección de basura (gc module)
import gc
# El GC de Python usa conteo de referencias + detector de ciclos
# Los ciclos de referencias NO se limpian con el conteo solo
class Nodo:
def __init__(self, valor):
self.valor = valor
self.siguiente = None
# Crear ciclo: a → b → a
a = Nodo(1)
b = Nodo(2)
a.siguiente = b
b.siguiente = a
# Sin gc, estos objetos se acumulan en generación 0
del a, b
print(f"Objetos en generación 0: {gc.get_count()[0]}")
gc.collect() # forzar recolección de ciclos
print(f"Después de gc.collect: {gc.get_count()[0]}")
# Detectar objetos no recolectados
gc.set_debug(gc.DEBUG_LEAK)
# ... código con posibles ciclos ...
gc.collect()
# Deshabilitar GC (¡solo en casos muy específicos de rendimiento!)
# gc.disable()
# ... código sin ciclos, muy crítico en performance ...
# gc.enable()
# Añadir callback cuando se detectan ciclos
def on_garbage(phase, info):
if phase == 'stop':
print(f"GC: recolectados {info['collected']} objetos")
gc.callbacks.append(on_garbage)
Técnicas de optimización probadas
# 1. List comprehensions vs bucles
# Más rápido (C interno):
cuadrados = [x**2 for x in range(10_000)]
# Más lento (overhead de Python):
cuadrados = []
for x in range(10_000):
cuadrados.append(x**2)
# 2. Evitar búsquedas de atributo repetidas (dotting)
import math
# Lento (busca math.sqrt en cada iteración)
r1 = [math.sqrt(x) for x in range(10_000)]
# Rápido (referencia local)
sqrt = math.sqrt
r2 = [sqrt(x) for x in range(10_000)]
# 3. join en lugar de + para concatenar strings
partes = ['Hola', ' ', 'mundo', '!']
# Lento (crea N strings intermedios)
resultado = ''
for p in partes:
resultado += p
# Rápido (una sola allocation)
resultado = ''.join(partes)
# 4. Usar sets para búsqueda en O(1)
numeros_lista = list(range(1_000_000))
numeros_set = set(range(1_000_000))
# O(n) — recorre toda la lista
assert 999_999 in numeros_lista
# O(1) — hash lookup
assert 999_999 in numeros_set
# 5. defaultdict y Counter en lugar de if-else manual
from collections import defaultdict, Counter
# Lento
frecuencias = {}
for palabra in texto.split():
if palabra in frecuencias:
frecuencias[palabra] += 1
else:
frecuencias[palabra] = 1
# Rápido
frecuencias = Counter(texto.split())
Cython y Numba — aceleración de código numérico
# Numba — JIT compilation para NumPy (pip install numba)
from numba import jit
import numpy as np
@jit(nopython=True, parallel=True)
def mandelbrot_numba(c, max_iter):
z = c
for n in range(max_iter):
if abs(z) > 2:
return n
z = z*z + c
return max_iter
# Primera llamada compila (lenta), las siguientes son nativas C
# Cython — para código mezclado Python/C (requiere compilar)
# archivo: rapido.pyx
"""
import numpy as np
cimport numpy as np
def suma_cuadrados(double[:] arr) -> double:
cdef double total = 0.0
cdef int i
for i in range(len(arr)):
total += arr[i] * arr[i]
return total
"""
# Compilar: python setup.py build_ext --inplace
Buenas prácticas
- Siempre mide antes de optimizar — usa
cProfilepara CPU ytracemallocpara memoria para identificar el verdadero cuello de botella. - Los generadores sobre las listas cuando procesas secuencias grandes:
(x for x in data)vs[x for x in data]puede ser la diferencia entre 1 MB y 100 MB. __slots__en clases con millones de instancias — ahorra el__dict__por objeto (40-50% de memoria).- Sets para búsquedas frecuentes —
in setes O(1) vs O(n) en listas. collections.Counterydefaultdictson más rápidos y legibles que los patrones manuales equivalentes.- Numba para código numérico en bucles —
@jit(nopython=True)puede acelerar 100x sin cambiar el código Python.
Conversiones relacionadas
Conversiones frecuentes del catálogo: