Herramientas de optimiauzación en Python
Optimiza tu código en Python sin perder ni un bytegote

Principios generales de la optimización: ¿Qué estamos afinando?
Antes de abrir un profiler o reemplazar tu precioso for
por un generador oscuro, conviene detenerse a reflexionar: ¿por qué y para qué quieres optimizar tu código? Optimizar no es simplemente hacerlo más rápido: también puede significar que consuma menos memoria, que sea más eficiente energéticamente, o que escale mejor cuando aumentan los datos o los usuarios. Como todo buen gato sabe, no se trata solo de correr más rápido, sino de moverse con elegancia y precisión.
Tipos de optimización:
- Optimización del tiempo de ejecución: reducir el tiempo que tarda el código en completar una tarea. Es el tipo más común y se mide fácilmente con herramientas como
,timeit
o inclusocProfile
.perf_counter
- Optimización del uso de memoria: evitar que el programa consuma RAM innecesaria, algo crucial cuando se trabaja con grandes volúmenes de datos o en entornos limitados (por ejemplo, procesamiento de datos en streaming, servidores con contenedores, etc.).
- Optimización de recursos de hardware (CPU, disco, red): puede que tu script no sea lento por el código en sí, sino por estar saturando la CPU con cálculos innecesarios o bloqueando la red con lecturas pesadas. Aquí entra en juego el concepto de bound (más abajo lo explicamos).
- Optimización algorítmica: implica repensar el enfoque general. A veces, una mejora real no pasa por trucos de bajo nivel, sino por cambiar el algoritmo o la estructura de datos que usas. Si tienes un algoritmo de orden
O(
cuando podría sern^2
)O(
, ningúnn log n
)
te salvará del desastre.jit
- Optimización de escalabilidad: tu código puede funcionar bien con 1000 elementos, pero colapsar con un millón. En producción, este tipo de optimización es la que separa a los scripts de juguete de los sistemas robustos.
Optimizar sin medir es un error clásico
Una de las mayores trampas para desarrolladores con buenas intenciones es lanzarse a optimizar partes del código que parecen lentas… sin verificar si realmente lo son. Esta práctica, conocida como premature optimization, ha sido denunciada desde los años 70 (Donald Knuth la llamó “the root of all evil”). La solución no es ignorar el rendimiento, sino medir primero y actuar después.
Esto se traduce en:
- No optimices por intuición: el fragmento que parece lento podría estar ejecutándose una sola vez.
- Mide el rendimiento real: con herramientas de profiling (ver apartado 2), y no solo con prints o cronómetros mentales.
- Evalúa el impacto de la optimización: ¿cuánto mejora? ¿vale la pena el coste en legibilidad o mantenimiento?
Boundaries: ¿CPU-bound o I/O-bound?
Entender el tipo de cuello de botella es esencial para saber cómo optimizar. Estas son las dos grandes categorías:
Tipo de carga | Características | Estrategia típica |
---|---|---|
CPU-bound | El proceso está limitado por la velocidad del procesador | Paralelismo real ( , ) |
I/O-bound | El proceso espera lectura/escritura en disco, red, etc. | Asincronía ( , ) |
Ejemplo:
- Si estás procesando imágenes pixel a pixel → CPU-bound.
- Si estás leyendo archivos grandes o esperando a una API → I/O-bound.
Elegir mal la estrategia puede hacer que “optimices” sin notar mejora real (¡o incluso ralentices el sistema!).
🐈⬛ Moraleja gatuna de esta sección:
Un gato no salta sin saber dónde va a caer. Tú tampoco deberías optimizar sin medir.
Michidato curioso
Los gatos tienen dificultades para ver a menos de 30 cm, pero los bigotes compensan esta limitación de la vista captando los diferentes estímulos y siendo capaces de ofrecerles una imagen 3D de lo que tienen delante.
Benchmarking y profiling: mide antes de saltar
En esta sección vamos a medir el rendimiento de tu código de forma precisa, tanto en términos de tiempo de ejecución como de consumo de recursos, para identificar cuellos de botella reales. Esto te permite centrar tus esfuerzos donde realmente importa.
Benchmarking: medir tiempos de ejecución
Opción 1: timeit
Ideal para comparar fragmentos de código pequeños y ver cuál es más rápido.
import timeit
tiempo = timeit.timeit("sum(range(1000))", number=10000)
print(tiempo)
- El parámetro
indica cuántas veces se repite el fragmento.number
- Internamente,
se ocupa de eliminar interferencias (como carga inicial del intérprete o variabilidad del sistema operativo).timeit
Consejo felino: Usa
para comparar soluciones concretas, no para analizar todo un script.timeit
Opción 2: perf_counter
Útil para medir tramos más largos o procesos reales.
from time import perf_counter
inicio = perf_counter()
mi_funcion_pesada()
fin = perf_counter()
print(f"Tardó {fin - inicio:.4f} segundos")
A diferencia de
, time.time()
tiene mayor resolución y es inmune a cambios del reloj del sistema.perf_counter()
Profiling: encontrar los cuellos de botella
Benchmarking te dice cuánto tarda algo. El profiling te dice dónde está el problema.
Herramientas principales:
Herramienta | Qué mide | Ideal para... |
---|---|---|
| Tiempo por función | Scripts completos |
| Tiempo por línea de código | Análisis fino de funciones |
| Consumo de memoria por línea | Scripts pesados en RAM |
,
| Sampling sin modificar código | Análisis en producción / live |
Ejemplo básico con cProfile
:
import cProfile
def calcular():
total = 0
for i in range(10000):
total += i ** 0.5
return total
cProfile.run("calcular()")
La salida muestra:
- Tiempo total de ejecución.
- Número de llamadas a cada función.
- Tiempo acumulado y por llamada.
👉 Si ves que una función se ejecuta 100.000 veces pero no lo esperabas... ahí tienes un indicio.
Ejemplo más preciso con line_profiler
Instalación:
pip install line_profiler
Marcamos con decorador:
@profile
def calcular():
total = 0
for i in range(10000):
total += i ** 0.5
return total
Se ejecuta así:
kernprof -l -v mi_script.py
Te muestra línea a línea el tiempo exacto de ejecución, y la proporción sobre el total.
Memory profiling
Cuando el problema es la RAM,
te da visibilidad sobre el consumo por línea.memory_profiler
pip install memory_profiler
Decorador y ejecución:
from memory_profiler import profile
@profile
def mi_funcion():
...
python -m memory_profiler mi_script.py
Especialmente útil con
, pandas
, listas grandes o cuando tienes leaks por referencias circulares.numpy
Bonus: profiling sin modificar tu código
Para analizar scripts ya escritos o en producción sin editar, existen herramientas como:
py-spy
pip install py-spy
py-spy top --pid
- Muestra funciones más costosas en tiempo real.
- No necesita modificar el script.
- Compatible con Docker y producción.
scalene
Otra joya moderna: mide CPU, memoria y tiempo, todo en uno.
pip install scalene
scalene mi_script.py
Con esta biblioteca podrás generar un informe coloreado, detallado y legible.
Complejidad algorítmica y estructuras de datos: Que no se te escape el gato por un O(n²)
Cuando hablamos de optimización, muchas personas piensan inmediatamente en bucles, bibliotecas mágicas o en usar multiprocessing para todo. Pero lo cierto es que la optimización más efectiva empieza en el diseño. Elegir la estructura de datos adecuada y comprender la complejidad algorítmica de tu enfoque es, en muchos casos, el mayor salto de rendimiento que puedes conseguir.
🐾 Como diría cualquier felino sabio: No se trata solo de correr más rápido, sino de elegir el camino más corto al cuenco de comida.
¿Qué es la complejidad algorítmica y por qué deberías prestarle atención?
La complejidad algorítmica describe cómo cambia el tiempo de ejecución o el uso de memoria de un algoritmo a medida que aumenta el tamaño de los datos. Se expresa en notación Big-O, que no indica tiempo real, sino cómo escala el algoritmo:
Notación | Nombre | Descripción breve | Ejemplo común |
---|---|---|---|
O(1) | Constante | Siempre tarda lo mismo | Acceso a índice de una lista |
O(log n) | Logarítmica | Reduce a la mitad en cada paso | Búsqueda binaria |
O(n) | Lineal | Recorre todos los elementos | Suma de lista |
O(n log n) | Cuasilineal | Divide y conquista (mezclas, árboles) | Merge sort |
O(n²) | Cuadrática | Comparaciones entre todos | Búsqueda en parejas |
O(2ⁿ) | Exponencial | Muy lento con entradas grandes | Fuerza bruta en combinatoria |
Estructuras de datos y su impacto en el rendimiento
No todas las estructuras de datos están diseñadas para lo mismo. Elegir la errónea puede convertir una operación trivial en un desastre de rendimiento. Aquí tienes una tabla con las operaciones más frecuentes:
Operación | list | set | dict | deque |
---|---|---|---|---|
Acceso por índice | O(1) | — | O(1) | O(1) |
Búsqueda | O(n) | O(1) | O(1) | O(n) |
Inserción al final | O(1) | O(1) | O(1) | O(1) |
Eliminación | O(n) | O(1) | O(1) | O(1) |
Inserción al principio | O(n) | O(1) | O(1) | O(1) |
🔎 Consejo felino: Si haces muchas búsquedas, no uses listas. Si accedes mucho por clave,
. Si trabajas por extremos, dict
.deque
Ejemplos comparativos
Caso 1: búsqueda eficiente
# Lento: búsqueda en lista
nombres = ['Vera', 'Salem', 'Nibi', 'Paquísimo']
if 'Vera' in nombres: # O(n)
print("¡Está!")
# Rápido: búsqueda en set
nombres = {'Vera', 'Salem', 'Nibi', 'Paquísimo'}
if 'Vera' in nombres: # O(1)
print("¡Está!")
Caso 2: agrupación dinámica
# Sin defaultdict
agrupados = {}
for animal, tipo in [('Vera', 'reina gata'), ('Paquísimo', '¿dios gato?')]:
if tipo not in agrupados:
agrupados[tipo] = []
agrupados[tipo].append(animal)
# Con defaultdict
from collections import defaultdict
agrupados = defaultdict(list)
for animal, tipo in [('Vera', 'reina gata'), ('Paquísimo', '¿dios gato?')]:
agrupados[tipo].append(animal)
Menos líneas, menos errores, más eficiente.
Cuando el algoritmo es el verdadero cuello de botella
Imagina que tienes un script que compara cada elemento de una lista con todos los demás:
# Comparación doble - O(n²)
for i in range(len(data)):
for j in range(len(data)):
if i != j and data[i] == data[j]:
print("Duplicado encontrado")
Esto funciona para 100 elementos. Para 10.000, no. Una forma mejor:
# Comparación eficiente - O(n)
vistos = set()
for item in data:
if item in vistos:
print("Duplicado encontrado")
vistos.add(item)
Este tipo de cambio puede reducir tiempos de ejecución de minutos a milisegundos.
Otros recursos felinos en Python
: contar elementos sin necesidad de bucles anidados.collections.Counter
: para colas de prioridad sin ordenar listas completas.heapq
: para listas de enteros o floats con menor uso de memoria.array.array
: búsqueda binaria sobre listas ordenadas.bisect
ynamedtuple
: estructuras ligeras que mejoran legibilidad y eficiencia.dataclass
🐾 Un gato eficiente no persigue la misma mariposa dos veces. Usa una trampa mejor diseñada.
Vectorización total: deja que numpy ronronee por ti
Si Python fuera una pandilla de gatos, los bucles for
serían los gatitos callejeros: simpáticos, accesibles… pero algo ruidosos y lentos. En cambio,
sería el gato elegante que vive sobre el teclado, rodeado de eficiencia en C: no hace ruido, pero hace magia.numpy
La vectorización consiste en sustituir bucles explícitos por operaciones que se aplican en bloque sobre arrays. En lugar de decirle a Python “haz esto para cada elemento”, le dices “haz esto sobre todo el array”. Y como
usa código en C muy optimizado, eso se traduce en muuuucha velocidad.numpy
¿Qué hace a numpy tan rápido?
- Implementación en C: las operaciones vectorizadas no pasan por el intérprete de Python.
- Procesamiento en bloque: accede a la memoria de forma contigua, evitando saltos de dirección.
- Broadcasting: permite operar entre arrays de diferentes formas automáticamente.
- Operaciones in-place: reduce copias de memoria y reutiliza buffers.
🧪 Ejemplo comparativo: suma de dos arrays
Veamos el clásico:
# Python puro (loop explícito)
result = []
for i in range(len(a)):
result.append(a[i] + b[i])
Ahora en modo vectorizado:
# numpy vectorizado
import numpy as np
a = np.arange(10**6)
b = np.arange(10**6)
c = a + b
Benchmark comparativo:
import time
import numpy as np
# Datos
N = 10**6
a = np.arange(N)
b = np.arange(N)
# Loop clásico
start = time.perf_counter()
resultado = [a[i] + b[i] for i in range(N)]
t_loop = time.perf_counter() - start
# Vectorizado
start = time.perf_counter()
resultado_np = a + b
t_vectorizado = time.perf_counter() - start
print(f"Loop: {t_loop:.4f} s")
print(f"Vectorizado: {t_vectorizado:.4f} s")
Visualización (simulada):
Método | Tiempo (s) |
---|---|
Loop en Python | 0.2200 |
Vectorizado | 0.0021 |
¡Un bucle más de 100 veces más lento que su equivalente en numpy
!
Otras operaciones vectorizables comunes
- Multiplicación de vectores:
a * b
- Potencias:
a ** 2
- Operaciones condicionales:
a[a > 0]
- Transformaciones:
,np.sqrt(a)
np.log1p(a)
- Comparaciones:
(a > b) & (b < 10)
Broadcasting: como un gato que se adapta a cualquier recipiente donde echar una siesta
El broadcasting permite realizar operaciones entre arrays de diferentes formas, ampliando automáticamente dimensiones cuando es posible.
a = np.array([[1], [2], [3]]) # Forma (3, 1)
b = np.array([10, 20, 30]) # Forma (3,)
resultado = a * b # Se ajustan a (3, 3)
Esto elimina la necesidad de bucles anidados y mejora la legibilidad del código.
Operaciones in-place: menos memoria, más velocidad
Muchas operaciones en numpy
permiten evitar crear una copia:
a *= 2 # Doble en el mismo array
Esto no solo es más rápido, sino que reduce el uso de memoria, crucial en datasets grandes.
Comparación extendida con Python puro
Sin vectorizar
def sin_vectorizar():
a = list(range(10**6))
b = list(range(10**6))
return [x + y for x, y in zip(a, b)]
Vectorizado
def vectorizado():
a = np.arange(10**6)
b = np.arange(10**6)
return a + b
⚠️ Aunque el resultado sea el mismo, en términos de recursos utilizados hay una diferencia abismal.
Benchmark extendido con perfplot
Puedes usar perfplot
para generar gráficos como este:
import perfplot
import numpy as np
perfplot.show(
setup=lambda n: (np.arange(n), np.arange(n)),
kernels=[
lambda a_b: [x + y for x, y in zip(*a_b)],
lambda a_b: a_b[0] + a_b[1],
],
labels=["Python loop", "Vectorizado"],
n_range=[2**k for k in range(10, 22)],
xlabel="Tamaño del array (n)",
logx=True,
logy=True,
equality_check=np.allclose,
)
📈 El gráfico resultante muestra cómo el tiempo de ejecución del método vectorizado escala mucho mejor en grandes volúmenes de datos.
Cuando se trata de rendimiento numérico,
es tu mejor aliado. Es como ese gato que parece tranquilo, pero salta a tres metros cuando le conviene. Vectorizar no solo te da velocidad, sino también limpieza, elegancia y eficiencia en memoria.numpy
- Si puedes expresarlo sin bucles, hazlo.
- Si puedes evitar
, mejor..append()
- Si puedes usar operaciones in-place, gato feliz asegurado.
Concurrencia, paralelismo y asincronía: divide y vencerás… o no
¿Has visto alguna vez a un grupo de gatos cazando juntos? No lo hacen. Cada uno va a lo suyo, pero si lo piensas... todos están sincronizados con el objetivo final: eficiencia máxima sin pisarse las colas.
En Python, optimizar el rendimiento no siempre implica acelerar un bucle, sino organizar las tareas de forma más inteligente. Aquí entran en juego tres estrategias clave: concurrencia, paralelismo y asincronía.
¿En qué se diferencian?
Técnica | ¿Qué hace? | Ideal para... | Afectado por el GIL |
---|---|---|---|
threading | Ejecuta múltiples hilos en un solo núcleo (conmutación rápida) | Tareas I/O-bound | ✅ Sí |
multiprocessing | Ejecuta procesos separados, cada uno con su propio núcleo | Tareas CPU-bound | ❌ No |
asyncio | Usa un único hilo con event loop no bloqueante | I/O intensivo estructurado | ❌ No (pero un solo hilo) |
¿Qué es el GIL y por qué nos importa?
El Global Interpreter Lock (GIL) es un mecanismo de CPython que impide que múltiples hilos ejecuten bytecode Python al mismo tiempo. Esto significa que en operaciones CPU-bound, usar
no acelera nada… e incluso puede empeorarlo.threading
Para tareas intensivas de cálculo → multiprocessing
Para tareas que esperan mucho (APIs, ficheros, bases de datos) → threading o asyncio
⚙️ Ejemplo práctico: CPU-bound (cálculo intensivo)
Supón que tienes que elevar al cuadrado un millón de números:
def cuadrado(x): return x * x
Con un bucle clásico:
resultados = [cuadrado(x) for x in range(10**6)]
Con multiprocessing:
from multiprocessing import Pool
with Pool() as pool:
resultados = pool.map(cuadrado, range(10**6))
📈 Benchmark orientativo:
Núcleos usados | Tiempo (s) estimado |
---|---|
1 (bucle) | 1.10 |
4 (pool) | 0.35 |
8 (pool) | 0.20 |
✅ Escala bien con más núcleos (ideal en máquinas multi-core o servidores).
Ejemplo práctico: I/O-bound (esperas de red)
Descargar muchas URLs al mismo tiempo:
import requests
def descargar(url):
return requests.get(url).text
Con
(esperas de red en paralelo):threading
from concurrent.futures import ThreadPoolExecutor
urls = ["https://example.com"] * 10
with ThreadPoolExecutor() as executor:
resultados = list(executor.map(descargar, urls))
Con
(requiere librerías async como asyncio
):aiohttp
import aiohttp
import asyncio
async def descargar(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
return await resp.text()
async def main():
urls = ["https://example.com"] * 10
tareas = [descargar(url) for url in urls]
resultados = await asyncio.gather(*tareas)
asyncio.run(main())
Ojo:
requiere que todo el stack sea asincrónico. No es un simple “copiar y pegar”.asyncio
Comparación resumida
Escenario | Mejor opción | Justificación |
---|---|---|
Leer miles de archivos pequeños | threading | Muchos bloqueos por disco |
Calcular transformadas de Fourier | multiprocessing | CPU-bound, se benefician de núcleos reales |
Consultar múltiples APIs | asyncio | Muchas esperas de red, sin bloqueo real |
Web scraping con esperas | threading o asyncio | Depende del stack usado |
ETL con pandas + procesamiento | multiprocessing | pandas no es thread-safe |
Visualización: escalado de rendimiento
Imagina este gráfico lineal (simulado) donde se ve cómo disminuye el tiempo con más núcleos:
Núcleos | Tiempo sincrónico (s) | Tiempo paralelo (s) |
---|---|---|
1 | 10 | 10 |
2 | — | 5.2 |
4 | — | 2.7 |
8 | — | 1.4 |
🧪 Visualización sugerida: usa
o perfplot
para representar esta mejora, especialmente útil si estás enseñando optimización en un entorno académico o corporativo.matplotlib
🐾 Buenas prácticas felinas
- No mezcles
ythreading
sin saber lo que haces: pueden surgir bloqueos y condiciones de carrera.multiprocessing
brilla cuando tu código espera mucho pero trabaja poco.asyncio
- Para paralelizar código con pandas, usa
,swifter
omodin
en lugar dedask
a secas.apply
- Siempre mide: a veces paralelizar empeora el rendimiento en scripts pequeños.
Concurrencia, paralelismo y asincronía no son lo mismo, pero cada uno tiene su momento. Un desarrollador experto —o un gato muy avispado— sabe cuándo ronronear con calma y cuándo sacar todas las garras del multiprocesamiento.
👉 No necesitas hacer todo más rápido, necesitas hacer cada parte en su terreno ideal.
Memoria: ¿cuánto pesa tu gato?
La eficiencia no siempre va de velocidad. A veces, optimizar significa reducir el consumo de memoria, especialmente en entornos con recursos limitados, servidores compartidos, pipelines de datos pesados o notebooks que colapsan con un
en la celda 17.MemoryError
Python es cómodo, flexible… pero no es el lenguaje más ligero en cuanto a consumo de RAM. De hecho, su gestión automática de memoria (con recolección de basura y objetos dinámicos) puede generar copias innecesarias, referencias perdidas y estructuras pesadas si no se controlan.
¿Por qué puede tu código estar usando más memoria de la cuenta?
- Estás copiando listas y diccionarios en lugar de trabajar con referencias.
- Usas estructuras dinámicas cuando podrías usar arrays compactos.
- Mantienes referencias circulares que impiden liberar objetos.
- Estás leyendo archivos enteros en memoria en lugar de procesarlos en streaming.
- Usas pandas sin ajustar tipos de datos, lo que puede multiplicar por 4 el tamaño.
🧪 Herramientas para analizar memoria
Herramienta | ¿Qué hace? | Nivel |
---|---|---|
sys.getsizeof() | Tamaño aproximado en bytes de un objeto | Básico |
memory_profiler | Medición por línea de consumo de memoria | Intermedio |
tracemalloc | Rastreo detallado de asignaciones | Avanzado |
gc | Acceso al recolector de basura | Interno |
Ejemplo con memory_profiler
Instalación:
pip install memory_profiler
Uso:
from memory_profiler import profile
@profile
def cargar_datos():
data = [x * 2 for x in range(10**6)]
return data
cargar_datos()
🧠 Resultado: te muestra línea a línea cuántos MB ocupas, y en qué línea sube.
Ejemplo con tracemalloc
import tracemalloc
tracemalloc.start()
# código sospechoso
data = [x**2 for x in range(1000000)]
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:5]:
print(stat)
🔎 Ideal para proyectos grandes: puedes ver en qué archivo y línea se disparan las asignaciones.
Técnicas para reducir el consumo de memoria
1. Usa generadores
Evita listas en memoria cuando no necesitas todos los elementos a la vez.
# En lugar de:
resultados = [procesar(x) for x in datos]
# Usa:
def generar_resultados(datos):
for x in datos:
yield procesar(x)
Esto permite “ir generando sobre la marcha”, como si el programa comiera grano a grano.
2. Clases ligeras con __slots__
Las clases normales en Python tienen un diccionario interno (
) para almacenar atributos, lo que consume memoria innecesaria si sabes de antemano los campos.__dict__
class Gato:
__slots__ = ['nombre', 'peso']
def __init__(self, nombre, peso):
self.nombre = nombre
self.peso = peso
Esto puede reducir el uso de memoria por instancia en más del 40% si tienes millones de objetos.
3. Evita copias innecesarias
# Mal
datos_filtrados = list(filter(condicion, datos))
datos_copia = datos_filtrados.copy()
# Bien
datos_filtrados = [x for x in datos if condicion(x)] # Ya es nueva lista
❌
puede duplicar estructuras de cientos de MB si no se necesita..copy()
4. Usa gc.collect()
si gestionas objetos manualmente
Cuando trabajas con estructuras grandes que ya no usas (como grafos o árboles con referencias circulares), puedes forzar la liberación:
import gc
del estructura_pesada
gc.collect()
Especialmente útil si estás procesando por lotes dentro de un
.for
5. Ajusta tipos en pandas
Una columna
con valores del 1 al 100 cabe perfectamente en int64
. Lo mismo para floats o categóricos.int8
df['columna'] = df['columna'].astype('int8')
df['ciudad'] = df['ciudad'].astype('category')
Esto puede reducir el tamaño de un DataFrame
en un 70-90% fácilmente.
Comparación de estructuras (orientativa)
Estructura | Tamaño (10⁶ enteros) | RAM estimada |
---|---|---|
list | 10⁶ | ~45 MB |
numpy array | 10⁶ | ~8 MB |
array.array | 10⁶ | ~4 MB |
generator | 10⁶ | ~0.1 MB |
🔧 Usa
array.array('i')
para enteros planos si no necesitas operaciones matemáticas pesadas.
🐾 Buenas prácticas felinas para ahorrar RAM
- Procesa archivos línea a línea (
) y evitawith open(...)
read()
.
- Divide cargas grandes en lotes (chunking).
- Usa
siempre que puedas en transformaciones.yield
- Borra estructuras temporales si ya no las necesitas (
).del
- Piensa antes de convertir cosas en
solo para “verlas”.list()
Optimizar memoria no es solo para quien trabaja con big data. Es una forma de hacer tu código más elegante, más estable y más profesional. Un buen programa, como un gato bien cepillado, no deja pelos por todas partes.
🔄 Reutiliza, no repitas.
♻️ Transforma, no acumules.
⚖️ Y sobre todo, piensa antes de asumir que “tampoco es para tanto”.
Reutilización y caching inteligente: no le des mil vueltas al mismo ratón
Si hay algo que los gatos no hacen, es repetir esfuerzos innecesarios. Si han encontrado el camino a la comida una vez, lo recuerdan. En Python, podemos hacer lo mismo: evitar repetir cálculos costosos almacenando sus resultados.
Este proceso se llama caching o memoización. Permite ganar tiempo a costa de memoria, guardando el resultado de funciones para no recalcularlo si se vuelven a invocar con los mismos argumentos.
¿Cuándo aplicar caching?
✅ Tareas deterministas: una función que devuelve siempre lo mismo para la misma entrada.
✅ Funciones recursivas o muy repetitivas.
✅ Operaciones costosas que no cambian en el tiempo.
✅ Procesos que acceden a fuentes externas lentas (como APIs o discos).
❌ No lo uses si los datos cambian entre llamadas.
❌ Cuidado con resultados grandes: la caché se guarda en memoria.
Técnicas principales de cacheo
Técnica | ¿Dónde se guarda? | Ventajas | Inconvenientes |
---|---|---|---|
functools.lru_cache | Memoria RAM | Fácil, automático | No controlas el espacio usado |
cachetools.TTLCache | RAM + caducidad | Expira entradas automáticamente | Necesita instalación extra |
joblib.Memory | Disco local | Persiste entre sesiones | Más lento que RAM |
Caché manual (dict ) | RAM | Flexible | Más código, más errores |
1. Caching con functools.lru_cache
(Least Recently Used) es parte de la librería estándar. Memoriza los últimos resultados y los reutiliza si se repite una llamada.lru_cache
from functools import lru_cache
@lru_cache(maxsize=128) # o None para tamaño ilimitado
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
🚀 Con
, la complejidad de @lru_cache
pasa de exponencial a lineal. Un cambio brutal.fibonacci(n)
📊 Benchmark simple:
Función | Tiempo con n=35 |
---|---|
Sin caché | 5.0 s |
Con lru_cache | 0.001 s |
2. Caché manual con dict
Si necesitas más control (como invalidar entradas), puedes usar tu propia caché:
cache = {}
def expensive(x):
if x in cache:
return cache[x]
resultado = x * 42 # cálculo costoso simulado
cache[x] = resultado
return resultado
🧪 Ventaja: puedes borrar, limpiar o guardar en disco lo que necesites.
3. Expiración con cachetools.TTLCache
A veces no quieres guardar los resultados para siempre. Para eso, existen caches con Time To Live.
pip install cachetools
from cachetools import TTLCache
cache = TTLCache(maxsize=100, ttl=300) # 5 minutos
def get_precio(producto):
if producto in cache:
return cache[producto]
precio = calcular_precio(producto)
cache[producto] = precio
return precio
Esto es útil para datos que cambian a lo largo del día pero no cada segundo (como el clima, cotizaciones, etc.).
4. Caching persistente con joblib.Memory
Ideal para scripts largos o notebooks donde quieres evitar repetir cálculos costosos entre ejecuciones (incluso tras cerrar el programa).
from joblib import Memory
memoria = Memory("./.cachedir", verbose=0)
@memoria.cache
def cargar_dataset_grande():
# Simular carga pesada
import time
time.sleep(2)
return [i**2 for i in range(1000000)]
🧠 Al segundo intento, la función se devuelve al instante, incluso tras reiniciar el kernel.
🐾Al usar caché...
- Limpieza: si usas mucha caché, controla el tamaño para no agotar la RAM.
- Evita caché para funciones con side effects (como escritura en disco).
- No caches funciones que acceden a estados cambiantes, como
,datetime.now()
oinput()
(salvo que quieras resultados congelados).random.random()
- No abuses del almacenamiento en disco: puedes acabar con miles de archivos si cacheas con argumentos variables (como objetos complejos o rutas).
Recuerda: una caché mal gestionada puede convertirse en un problema. No todos los recuerdos merecen guardarse… ni siquiera los de un gato.
Pandas a tope: velocidad y memoria en big data
Pandas es como ese gato que parece tranquilo en el sofá, pero puede transformarse en una máquina salvaje si lo agobias demasiado. Es una librería poderosa para el análisis de datos, pero su rendimiento puede degradarse drásticamente si no se usa con cabeza.
Michidato curioso
El primer commit de pandas pesaba solo 213 líneas Fue creado por Wes McKinney en 2008 mientras trabajaba en una empresa de trading. Hoy es una de las bibliotecas más influyentes en ciencia de datos, y su repo supera los 2.000 archivos y 1 millón de líneas entre código, docs y tests.
¿Qué hace lento a pandas?
- Uso excesivo de
y funciones lambda..apply()
- Iteración fila a fila con
(⚠️ nunca lo uses en producción).iterrows()
- No ajustar los tipos de datos (
,int64
,object
por defecto).float64
- Uniones (
,merge
) mal planteadas o sin índices.join
- Copias innecesarias de columnas o
.DataFrames
❌ Evita estas prácticas
1. .apply()
con lambda
# Lento y peligroso
df['nuevo'] = df.apply(lambda row: row['col1'] + row['col2'], axis=1)
🐢 Esto fuerza a pandas a entrar en modo “fila a fila”, perdiendo toda la eficiencia de los arrays de numpy
.
Mejor: vectorizar la operación
df['nuevo'] = df['col1'] + df['col2']
2. iterrows()
o for row in df.iterrows()
# Este patrón es equivalente a usar Excel... con palillos
for _, row in df.iterrows():
procesar(row['valor'])
Internamente, convierte cada fila en una serie separada (con conversión de tipos), lo que es lentísimo.
Alternativas:
- Operaciones vectorizadas.
(más rápido queitertuples()
si necesitas iterar).iterrows()
- Procesamiento externo con
onumpy
si la carga es masiva.polars
Vectoriza siempre que puedas
Ejemplo: calcular ratio
# Malo
df['ratio'] = df.apply(lambda row: row['ventas'] / row['coste'], axis=1)
# Bueno
df['ratio'] = df['ventas'] / df['coste']
🧪 Benchmark (simulado):
Método | Tiempo (s) con 1M filas |
---|---|
apply | 2.7 |
Vectorizado | 0.03 |
¡Una mejora de 90x solo por pensar como numpy!
Uniones eficientes: merge, join, concat
Reglas básicas:
- Asegúrate de que las claves de unión estén indexadas.
- Usa
si solo quieres añadir columnas por índice.join()
- Usa
merge()
si quieres controlar más condiciones.
Ejemplo:
df_ventas = df_ventas.merge(df_clientes, on='cliente_id', how='left')
Evita:
for i, fila in df.iterrows():
df.at[i, 'nuevo'] = buscar_info_externa(fila['id']) # 😿
📉 Reducción de memoria
1. Cambia los tipos de datos
df['edad'] = df['edad'].astype('int8')
df['provincia'] = df['provincia'].astype('category')
📉 Esto puede reducir un
de 500 MB a 100 MB.DataFrame
2. Usa pd.to_numeric(..., downcast='integer')
df['valor'] = pd.to_numeric(df['valor'], downcast='float')
Medir uso de memoria
df.info(memory_usage='deep')
Y para columnas individuales:
df['columna'].memory_usage(deep=True)
También puedes usar:
import sys
sys.getsizeof(df['columna'])
Procesamiento por bloques (chunking)
Ideal para archivos CSV grandes que no caben en memoria:
for chunk in pd.read_csv('grande.csv', chunksize=100000):
procesar(chunk)
Esto te permite trabajar con volúmenes de datos arbitrariamente grandes, sin colapsar el sistema.
Alternativas más rápidas a pandas
Librería | Ventaja principal | Ideal para... |
---|---|---|
polars | Basado en Rust, muy rápido, lazy eval | ETL, cargas grandes, series temporales |
modin | Drop-in de pandas + multiprocessing | Aprovechar núcleos sin cambiar código |
dask | Paralelización distribuida | Procesamiento por lotes / clúster |
cuDF | GPU (NVIDIA) con sintaxis pandas | Dataframes masivos en GPU |
🐾 Buenas prácticas en pandas
- Piensa en arrays, no en filas.
- Filtra antes de transformar.
- Usa
para afinar los tipos.astype()
- Carga solo lo necesario:
,usecols
,nrows
.chunksize
- Usa
o.values
si necesitas interoperar con numpy..to_numpy()
Pandas puede parecer inofensivo, pero si lo fuerzas con mal código, se convierte en un monstruo tragamemoria.
📈 Un
que tarda segundos en lugar de minutos es como una siesta de 20 minutos que te rinde como 3 horas.DataFrame
Cython, Numba y extensiones nativas: para gatos ninja
A veces, incluso el mejor código en Python llega a su límite. Has vectorizado, paralelizado, reducido memoria… y aún así, necesitas más velocidad. Es entonces cuando entras en el territorio de los gatos ninja: herramientas como Cython, Numba, o incluso extensiones en C o Rust que llevan tu código al nivel de un lenguaje compilado, sin renunciar a la flexibilidad de Python.
¿Por qué Python es lento y cómo evitarlo?
Python es interpretado y dinámico. Cada vez que ejecutas una línea, el intérprete tiene que:
- Comprobar tipos.
- Acceder a objetos en memoria dinámica.
- Gestionar referencias.
- Delegar operaciones a otras librerías.
Esto lo hace cómodo pero mucho más lento que C o Rust, que son lenguajes compilados y tipados.
🔧 Solución: convertir partes críticas de tu código a un entorno más próximo al metal.
Comparativa técnica
Herramienta | Tipo | Velocidad | Dificultad | Integración con Python |
---|---|---|---|---|
Numba | JIT compilador | Muy alta | Baja | Excelente |
Cython | C transpilador | Máxima | Media-alta | Muy buena |
C / Rust | Extensión externa | Extrema (raw) | Alta | Manual (bindings) |
1. Numba: Compila sobre la marcha
Numba
convierte funciones de Python en código máquina usando LLVM. Ideal para cálculos numéricos puros.
Instalación:
pip install numba
Uso:
from numba import jit
@jit(nopython=True)
def calcular():
total = 0
for i in range(1000000):
total += i ** 0.5
return total
🐾
nopython=True
obliga a que se compile sin depender del intérprete Python. Es lo que da la mayor ganancia.
📊 Benchmark real (n=1.000.000):
Versión | Tiempo (s) |
---|---|
Python puro | 1.32 |
Con Numba | 0.01 |
🧠 Ideal para: bucles pesados, cálculos numéricos, recursiones controladas.
Michidato curioso
Numba usa LLVM para compilar funciones de Python directamente a instrucciones de máquina. Esto permite que un for clásico, si está bien escrito, se ejecute tan rápido como en C. ¡Sin salir del archivo .py!
2. Cython: Python + C = turbo
Cython
permite escribir código Python que se compila a C, con tipado opcional para ganar eficiencia.
Instalación:
pip install cython
Usas archivos
y compilas con .pyx
, o bien desde Jupyter con setup.py
.%%cython
Ejemplo sencillo:
# archivo: calcular.pyx
def calcular():
cdef int i
cdef double total = 0
for i in range(1000000):
total += i ** 0.5
return total
Para compilar:
# setup.py
from setuptools import setup
from Cython.Build import cythonize
setup(ext_modules=cythonize("calcular.pyx"))
Luego:
python setup.py build_ext --inplace
🧪 Con el tipado correcto (cdef
int
,
), Cython puede ser tan rápido como código en C.cdef double
¿Cuándo elegir Cython sobre Numba?
- Si necesitas más control sobre tipos, estructuras, buffers.
- Si vas a construir módulos que se distribuyen como binarios.
- Si integras con C/C++ ya existentes.
3. Extensiones en C o Rust: si lo haces tú, hazlo bien
Cuando necesitas máximo control y rendimiento, puedes escribir módulos en C o Rust y exponerlos a Python con:
ctypes
cffi
(para C++)pybind11
(para Rust)maturin
Ejemplo en Rust:
#[no_mangle]
pub extern fn doble(x: i32) -> i32 {
x * 2
}
Luego compilas y enlazas como librería compartida (
, .so
, .dll
) y lo llamas desde Python con .dylib
.ctypes
Casos donde merece la pena:
Escenario | Herramienta sugerida |
---|---|
Bucle numérico intensivo | Numba |
Algoritmo con estructuras complejas | Cython |
Lógica a bajo nivel o uso en producción | C / Rust |
🧪 Benchmark general estimado:
Código | Tiempo (ms) | Mejora |
---|---|---|
Python puro | 1200 | — |
Numba (@jit ) | 12 | ~100x |
Cython tipado | 8 | ~150x |
Rust vía FFI | 5 | ~200x |
🐾 Buenas prácticas felinas
- Usa Numba para soluciones rápidas con poco esfuerzo.
- Usa Cython si vas a distribuir código optimizado a otros usuarios.
- No uses extensiones nativas si no tienes experiencia con C o Rust, o no necesitas ese rendimiento.
- Siempre mide antes de decidir compilar: a veces cambiar un algoritmo es más barato y eficaz.
Cuando ya lo has intentado todo y tu código sigue siendo lento, hay herramientas para convertir partes de Python en auténticos misiles de rendimiento.
💡 No todo el código tiene que estar compilado. Solo lo que lo necesita.
💡Optimiza el núcleo crítico y deja el resto en Python legible.
Métricas, tests y control de calidad
Después de todo el esfuerzo invertido en vectorizar, paralelizar, tipar, y compilar, ¿cómo sabes que realmente has mejorado algo? ¿Y cómo evitas romper cosas en futuras versiones? Aquí entra en juego la medición continua, el benchmarking automatizado y los tests de rendimiento.
¿Qué queremos medir?
- Tiempo de ejecución (latencia individual).
- Consumo de memoria (espacial).
- Velocidad relativa entre versiones.
- Estabilidad bajo carga o datos reales.
🐾 No se trata de microoptimizar cada línea, sino de tener visibilidad sobre el impacto real de tus mejoras.
Herramientas clave
Herramienta | ¿Para qué sirve? | Nivel de integración |
---|---|---|
pytest-benchmark | Añadir tests de rendimiento a pytest | Alta |
asv (airspeed velocity) | Comparar rendimiento entre commits | Muy alta (histórica) |
perfplot | Comparar algoritmos en distintos tamaños | Alta (visual) |
timeit | Benchmarks básicos en scripts | Básica |
1. pytest-benchmark
: testea si tu código es lento sin darte cuenta
Instalación:
pip install pytest-benchmark
Ejemplo de test:
# test_rendimiento.py
def calcular():
return sum(i * i for i in range(1000))
def test_rendimiento(benchmark):
resultado = benchmark(calcular)
assert resultado > 0
Ejecutas:
pytest test_rendimiento.py --benchmark-only
Te da métricas como:
- Tiempo mínimo, medio y máximo.
- Desviación estándar.
- Iteraciones por segundo.
✅ También puedes comparar con una versión anterior y detectar regresiones automáticas.
2. asv
: benchmarking histórico por commit
te permite comparar el rendimiento entre versiones de tu proyecto, ideal si estás trabajando en una librería o código de producción con repositorio Git.asv
Instalación:
pip install asv
Configuración básica:
asv quickstart
asv run
asv publish
asv preview
Permite generar una web interactiva de benchmarks, con gráficos por commit y pruebas como:
- Tiempo de ejecución por función.
- Uso de memoria.
- Cambios de rendimiento entre ramas.
🐾 Un must para cualquier desarrollador serio que mantenga código abierto o librerías de uso general.
3. perfplot
: visualización comparativa de algoritmos
Perfecta para notebooks o análisis de alternativas:
import perfplot
import numpy as np
perfplot.show(
setup=lambda n: np.random.rand(n),
kernels=[
lambda a: sorted(a),
lambda a: np.sort(a),
],
labels=["sorted", "np.sort"],
n_range=[2**k for k in range(10, 20)],
xlabel="Tamaño del array",
logx=True,
logy=True,
equality_check=np.allclose
)
👁️ Visualiza a simple vista cuándo una solución empieza a ser más eficiente que otra.
4. Pruebas de regresión de rendimiento
Al igual que testeas lógica, puedes testear performance.
Ejemplo:
def test_algo_es_igual_o_mas_rapido(benchmark):
resultado = benchmark(calcular)
assert resultado < 0.5 # segundos
O incluso mejor:
def test_optimizado_vs_antiguo(benchmark):
resultado = benchmark(calcular_opt)
resultado_ref = calcular_antiguo()
assert resultado < resultado_ref * 0.8
🔐 Esto evita que futuras optimizaciones sean involutivas.
🐈 Buenas prácticas felinas para benchmarking
- No midas solo una vez: usa herramientas que repitan muchas veces para compensar ruido del sistema.
- Evita código que cambia entre ejecuciones, como
, a menos que lo controles con semilla.random
- Aísla el benchmark: si otras tareas están corriendo en paralelo (indexación, antivirus…), tus números no serán fiables.
- Borra cachés si usas caching (
), o separa benchmarks fríos y calientes.functools.lru_cache.clear_cache()
Un código optimizado pero sin métricas es como un gato invisible: puede estar ahí, puede funcionar… pero también puede estar arañando tus recursos sin que lo veas.
Recuerda:
📏 Medir es parte esencial del trabajo profesional. Si no puedes probar que tu código es más rápido, tal vez no lo sea.
🧪 Los tests de rendimiento son tu seguro frente a regresiones.
📊 Las visualizaciones comparativas te ayudan a tomar decisiones reales, no suposiciones.
Michidato curioso
Python guarda metainformación en cada objeto: por eso pesa tanto Un simple int en C puede ocupar 4 bytes. En Python, un entero puede pesar entre 28 y 32 bytes, porque incluye punteros, tipo, contador de referencias, etc. Esto explica por qué listas de enteros en Python pueden consumir 5-10 veces más RAM que un array.array.
Cosas que recordar:
La optimización de código en Python es tanto una disciplina técnica como una toma de decisiones informadas.
Hemos explorado estrategias que abarcan desde la selección de estructuras de datos adecuadas y el uso de operaciones vectorizadas, hasta la paralelización, la gestión eficiente de memoria y la integración con herramientas de compilación. Sin embargo, el objetivo no es simplemente acelerar el código, sino hacerlo más robusto, escalable y sostenible.
Medir antes de actuar, validar después de optimizar, y mantener la legibilidad y calidad del código son principios clave para cualquier profesional que aspire a escribir software eficiente. Una optimización bien planteada no solo mejora el rendimiento, sino que también refleja un mayor nivel de comprensión y dominio sobre el lenguaje y sus recursos.