En 05-01 gastamos memoria sin remordimientos: un set auxiliar aquí, una caché ilimitada allá — todo a cambio de tiempo. Esta lección presenta la factura. El caso que nos acompañará es el job nocturno de RutaBus: un proceso que cada madrugada lee el fichero de fichajes del día — varios millones de líneas — y genera estadísticas por línea y parada. Funciona perfecto en el portátil del desarrollador; en producción, dentro de un contenedor con 512 MB, muere con un escueto Killed. Aprenderemos por qué la memoria importa incluso cuando "sobra", cómo medirla de verdad (que no es tan obvio como cronometrar), y un repertorio de técnicas concretas — generadores, lotes, __slots__, representaciones compactas — para que ese job procese millones de fichajes sin tenerlos jamás todos a la vez en la RAM.
Contenido
- Por qué importa la memoria aunque "sobre"
- Medir memoria (I):
sys.getsizeofy sus trampas - Medir memoria (II):
tracemalloc - Generadores e iteradores: procesar sin materializar
- Procesamiento por lotes (chunks)
- Objetos ligeros:
__slots__, tuplas,namedtupleydataclass - Datos numéricos homogéneos:
array(y una mención a NumPy) - Interning y compartición de referencias
- La representación del grafo según la densidad
- El trade-off inverso: cambiar tiempo por memoria
Por qué importa la memoria aunque "sobre"
"Mi máquina tiene 32 GB, ¿qué más da una lista de un millón de elementos?" Cuatro razones por las que sí da:
- Las cachés del procesador. La RAM es lenta comparada con las cachés L1/L2/L3 de la CPU (decenas frente a cientos de ciclos por acceso). Un conjunto de datos compacto cabe en caché y se recorre rápido; uno disperso y voluminoso provoca fallos de caché constantes. Menos memoria suele significar más velocidad, aunque el algoritmo sea el mismo.
- El recolector de basura (GC). En Python, cada objeto vivo es trabajo para el gestor de memoria. Millones de objetos pequeños significan más ciclos de recolección y pausas más largas.
- Contenedores y cloud con límites. En producción el proceso no tiene "toda la máquina": tiene lo que declara su contenedor (512 MB, 1 GB...). Superarlo no da un error elegante: el sistema operativo mata el proceso (OOM kill — el
Killedde nuestro job). Y en cloud, más memoria reservada es literalmente más factura. - La escala cambia sola. El job que hoy procesa 2 millones de fichajes procesará 10 dentro de dos años. Un diseño O(n) en memoria tiene fecha de caducidad; uno O(1), no.
Y la advertencia simétrica a la de 05-01: tampoco aquí se adivina. Primero se mide.
Medir memoria (I): sys.getsizeof y sus trampas
sys.getsizeof(objeto) devuelve los bytes que ocupa ese objeto... y ahí empiezan las trampas:
import sys
sys.getsizeof(42) # 28 — sí, un entero de Python ocupa 28 bytes
sys.getsizeof("Plaza Mayor") # 60
sys.getsizeof([]) # 56 — una lista vacía ya pesa
sys.getsizeof([1, 2, 3]) # 88
fichaje = ["Plaza Mayor", "L1", 512, "AB-4471"]
sys.getsizeof(fichaje) # 88 ¡¿solo?!Trampa 1: la medida es superficial (shallow). Esos 88 bytes son la lista — es decir, su cabecera más las 4 referencias — pero no los objetos referenciados: el string "Plaza Mayor" (60 bytes), el "L1", el entero... no están incluidos. Para un contenedor, getsizeof mide el envoltorio, no el contenido. (Es la distinción copias-vs-referencias de 02-02 aplicada a la medición.)
Trampa 2: las referencias pueden estar compartidas. Si un millón de fichajes referencian el mismo string "Plaza Mayor", ese string ocupa 60 bytes en total, no 60 millones. Sumar getsizeof recursivamente puede sobreestimar tanto como la medida superficial subestima.
Trampa 3: los contenedores reservan de más. Una lista crece con la reserva geométrica que vimos en 02-03 (el análisis amortizado de append): tras un millón de appends, la lista tiene hueco reservado para más elementos de los que contiene.
Conclusión: getsizeof sirve para comparar el peso unitario de dos representaciones (lo usaremos así en el apartado 6), pero no responde "¿cuánta memoria usa mi programa?". Para eso está la herramienta siguiente.
Medir memoria (II): tracemalloc
tracemalloc es a la memoria lo que cProfile era al tiempo en 05-01: registra qué línea de código hizo cada reserva. Midamos la primera versión (ingenua) del job de fichajes:
import tracemalloc
tracemalloc.start()
with open("fichajes.txt", encoding="utf-8") as f:
lineas = f.readlines() # ¡materializa TODO el fichero!
fichajes = [linea.rstrip().split(";") for linea in lineas]
total_l1 = sum(1 for f in fichajes if f[1] == "L1")
actual, pico = tracemalloc.get_traced_memory()
print(f"actual: {actual/1e6:.1f} MB pico: {pico/1e6:.1f} MB")
# actual: 812.4 MB pico: 812.6 MB (con 2 millones de líneas)
for stat in tracemalloc.take_snapshot().statistics("lineno")[:3]:
print(stat)
# job.py:7: size=622 MiB, count=2000003, average=326 B ← la comprehension
# job.py:6: size=154 MiB, count=2000001, average=81 B ← readlines()
# job.py:8: size=0.4 KiB, count=4, average=112 B ← el sum()Cómo leerlo:
get_traced_memory()da la memoria actual y el pico — el pico es el que mata al contenedor.- El snapshot por
linenoseñala a los culpables: la línea 7 (la lista de fichajes troceados: 2 millones de listas pequeñas con sus strings) retiene 622 MiB, y la línea 6 (readlines) otros 154 MiB. La línea 8, que es la que hace el trabajo útil, gasta 400 bytes.
El diagnóstico es demoledor: el 99,9 % de la memoria se va en materializar datos que solo se recorren una vez. Ese es exactamente el problema que resuelven las dos técnicas siguientes.
Generadores e iteradores: procesar sin materializar
En 02-02 vimos que un generador ocupa O(1): es una receta que produce los valores de uno en uno, no la colección entera. La aplicación al job es directa, porque los ficheros en Python ya son iteradores de líneas:
def leer_fichajes(ruta):
"""Genera fichajes de uno en uno: memoria O(1) respecto al fichero."""
with open(ruta, encoding="utf-8") as f:
for linea in f: # línea a línea, sin readlines()
parada, linea_bus, minuto, abono = linea.rstrip().split(";")
yield {"parada": parada, "linea": linea_bus,
"minuto": int(minuto), "abono": abono}
total_l1 = sum(1 for f in leer_fichajes("fichajes.txt") if f["linea"] == "L1")En cada instante hay un fichaje vivo en memoria: el que se está procesando. Medido con tracemalloc, el pico baja de 812 MB a menos de 1 MB — el job cabe en cualquier contenedor, y cabrá igual cuando los fichajes se quintupliquen. Mismo resultado, misma complejidad temporal, memoria de O(n) a O(1).
Las agregaciones típicas encajan sin esfuerzo en este molde: sum, max, min, un diccionario de contadores... todas consumen el flujo de uno en uno. Los límites son los que ya anotamos en 02-02: un generador no se indexa, no tiene len, y se agota al recorrerlo — para dos pasadas hay que volver a llamarlo (releer el fichero: tiempo por memoria, otra vez el trade-off).
Procesamiento por lotes (chunks)
Entre "todo en memoria" y "de uno en uno" hay un término medio útil: procesar por lotes. Es la solución cuando el destino de los datos prefiere grupos — insertar en una base de datos de 1.000 en 1.000, enviar a una API en bloques — o cuando conviene amortiguar un coste fijo por operación:
from itertools import islice
def por_lotes(iterable, tamano):
"""Agrupa cualquier iterable en listas de hasta `tamano` elementos."""
iterador = iter(iterable)
while lote := list(islice(iterador, tamano)): # islice: toma los siguientes `tamano`
yield lote
for lote in por_lotes(leer_fichajes("fichajes.txt"), 10_000):
guardar_en_bd(lote) # 200 inserciones masivas en vez de 2.000.000 individualesLa memoria pasa de O(1) a O(tamaño del lote) — controlada y constante, elegida por nosotros, independiente del total. El parámetro tamano es un dial entre memoria y overhead por operación: se ajusta midiendo, como todo en este módulo.
Objetos ligeros: __slots__, tuplas, namedtuple y dataclass
Aunque el flujo sea perezoso, a veces hay que retener una parte: los 200.000 fichajes de la línea L1 para un análisis posterior, por ejemplo. Entonces el peso unitario de cada objeto se multiplica por 200.000, y la representación importa. Comparemos las opciones para un Fichaje de 4 campos (bytes superficiales por instancia, CPython 3.12, 64 bits):
| Representación | Bytes/instancia* | Acceso | Mutable |
|---|---|---|---|
dict {"parada": ..., ...} |
184 | f["parada"] |
sí |
| clase normal | 48 (+ su __dict__: 296) |
f.parada |
sí |
class Fichaje con __slots__ |
72 | f.parada |
sí |
tuple |
72 | f[0] (posicional) |
no |
namedtuple |
72 | f.parada |
no |
@dataclass(slots=True) |
72 | f.parada |
sí |
* Medidos con getsizeof (superficial: los valores aparte, pero son los mismos en todos los casos — lo que cambia es el envoltorio).
La clave está en la segunda fila: una clase normal guarda sus atributos en un diccionario interno (__dict__), flexible pero caro. __slots__ declara los atributos de antemano y elimina ese diccionario:
class Fichaje:
__slots__ = ("parada", "linea", "minuto", "abono") # atributos fijos, sin __dict__
def __init__(self, parada, linea, minuto, abono):
self.parada, self.linea, self.minuto, self.abono = parada, linea, minuto, abonoPara 200.000 instancias: ~69 MB con clase normal, ~14 MB con __slots__ — unas 5 veces menos, con la misma sintaxis de uso. El precio: no se pueden añadir atributos no declarados (f.extra = 1 → AttributeError), lo cual en un objeto-registro es más virtud que limitación. Criterio rápido:
- Datos internos, posicionales y fugaces → tupla.
- Registro inmutable con nombres →
namedtuple. - Registro mutable con nombres y millones de instancias →
__slots__o@dataclass(slots=True)(esta última añade__init__,repry comparación gratis). dictpor defecto para datos masivos, no: es la opción más pesada de la tabla.
Datos numéricos homogéneos: array (y una mención a NumPy)
Cuando lo que se retiene son números del mismo tipo — los minutos de cada fichaje, por ejemplo — hay un salto más. Una lista de Python guarda referencias a objetos enteros (28+ bytes cada uno, dispersos por la RAM); el módulo array de la biblioteca estándar guarda los valores en crudo, contiguos:
from array import array
minutos_lista = [f.minuto for f in fichajes_l1] # 200.000 enteros-objeto
minutos_array = array("i", minutos_lista) # "i": int de 4 bytes, en crudo
# lista: ~1.6 MB de referencias + ~5.6 MB de objetos int ≈ 7.2 MB
# array: 200.000 × 4 bytes ≈ 0.8 MB (9x menos, y contiguo → amable con la caché)La misma idea, con esteroides, es NumPy: arrays n-dimensionales compactos con operaciones vectorizadas en C (minutos.mean(), matriz @ vector). Para el trabajo numérico serio — la matriz D de Floyd-Warshall a gran escala, estadísticas masivas — es la herramienta estándar del ecosistema; aquí nos basta con saber que existe, por qué gana (misma razón que array: datos en crudo y contiguos) y que su parte vectorizada volverá a asomar en 05-03.
Interning y compartición de referencias
Última vuelta de tuerca al job: cada fichaje trae el nombre de su parada como string. Dos millones de fichajes, pero solo ~3.000 paradas distintas: leyendo el fichero, Python crea dos millones de objetos string, casi todos copias repetidas de los mismos 3.000 valores. La solución es compartir referencias: que todos los fichajes de Plaza Mayor apunten al mismo string. sys.intern mantiene esa tabla de ejemplares únicos:
from sys import intern
parada = intern(parada) # dentro de leer_fichajes: devuelve el ejemplar canónicoCon ello los dos millones de campos parada pesan lo que 3.000 strings, no lo que 2.000.000 (decenas de MB recuperados), y de regalo las comparaciones parada == otra entre strings internados se resuelven comparando referencias. El mismo efecto se consigue con una caché casera (vistos.setdefault(parada, parada)). Dos matices honestos: Python ya interna por su cuenta algunos strings pequeños e identificadores (por eso a veces "no se nota" el problema), y esta es la razón profunda de la trampa 2 de getsizeof — con referencias compartidas, sumar tamaños unitarios sobreestima.
La representación del grafo según la densidad
Este criterio de "representación según los datos" ya lo aplicamos al grafo de RutaBus en 04-06, entonces con la vista puesta en el tiempo. Repasemos la misma tabla desde el ángulo de la memoria:
| Representación | Memoria | Red urbana (V=300, E≈900) | Red metropolitana (V=20.000, E≈60.000) |
|---|---|---|---|
| dict de adyacencia (04-05) | O(V + E) | ~1.200 entradas: KB | ~80.000 entradas: pocos MB |
matriz de adyacencia D (04-06) |
O(V²) | 90.000 celdas: aún MB | 400 millones de celdas: ~3 GB |
La matriz paga una celda por par posible, exista o no el tramo; el diccionario paga solo por los tramos reales. En un grafo disperso (E ≪ V², como toda red de transporte: cada parada conecta con 2–4 vecinas, no con las 20.000) la matriz desperdicia casi todas sus celdas en INF. En uno denso, la matriz compensa: sin el coste por entrada de los dicts y con acceso O(1) a cualquier par — y si además la salida que se quiere es todos-los-pares (Floyd-Warshall), el O(V²) es irreducible por ser el tamaño de la respuesta, como ya notamos en 04-06. La moraleja generaliza el apartado 6: la representación correcta depende de la forma de los datos — densidad aquí, homogeneidad en array, repetición en el interning.
El trade-off inverso: cambiar tiempo por memoria
En 02-02, construir_indice gastaba memoria para ganar tiempo, y en 05-01 lru_cache hizo lo mismo. Esta lección ha recorrido el sentido contrario del mismo eje, y conviene decirlo explícitamente: a veces la jugada correcta es pagar tiempo para liberar memoria.
- Recalcular en vez de cachear. Si
distancias_desde(05-01, ejercicio 2) cachea las distancias desde 20.000 orígenes, son 20.000 diccionarios de 20.000 entradas: gigabytes. Si cada origen se consulta pocas veces, mejor recalcular: Dijkstra tarda milisegundos y la caché ilimitada era un OOM en diferido. El término medio es acotar:lru_cache(maxsize=500)retiene los 500 orígenes más consultados y recalcula el resto. - Releer en vez de retener. El generador que se agota y obliga a una segunda lectura del fichero es este trade-off: dos pasadas de 30 segundos contra 800 MB retenidos.
- El criterio es el de siempre: medir ambas magnitudes y mirar cuál es el recurso escaso en tu entorno. En el contenedor de 512 MB, escasea la memoria; en el informe que debe salir en 5 minutos y va sobrado de RAM, escasea el tiempo. El error no es elegir una u otra: es elegir sin haber medido ninguna.
Errores Comunes y Consejos
- Confiar en
getsizeofpara contenedores. Mide el envoltorio, no el contenido. Para "¿cuánto usa mi programa y quién?",tracemalloc;getsizeof, solo para comparar pesos unitarios. - Materializar por costumbre.
readlines(),list(...)o una comprehension "para verlo mejor" convierten O(1) en O(n) sin necesidad. Pregúntate: ¿voy a recorrer esto más de una vez? ¿Necesito indexarlo? Si no, deja el generador en paz. - Recorrer dos veces un generador. La segunda pasada no da error: da vacío, silenciosamente. Si necesitas dos pasadas, materializa a sabiendas o regenera el flujo.
- Optimizar memoria que no importa. El aviso de 02-02 sigue vigente: si el job procesa 5.000 fichajes, los 800 MB nunca ocurren y
__slots__es ruido. Estas técnicas se justifican con untracemallocen la mano, igual que 05-01 exigía un perfil. - Olvidar el pico.
tracemallocda memoria actual y pico; el contenedor muere por el pico. Una copia temporal enorme dentro de una función (unsorted(...)de todo el flujo, por ejemplo) puede no dejar rastro en la memoria final y aun así matar el proceso. - Consejo: en jobs de datos, decide primero el contrato de memoria ("este proceso usa O(1)/O(lote), nunca O(n)") y protégelo: cualquier
list(...)sobre el flujo completo es una violación del contrato que debe saltar en la revisión de código.
Ejercicios
Ejercicio 1. Este job calcula el minuto medio de fichaje por línea. Señala todos los puntos donde materializa datos innecesariamente y reescríbelo con memoria O(L), siendo L el número de líneas de autobús (3), independiente del número de fichajes:
def minuto_medio_por_linea(ruta):
with open(ruta, encoding="utf-8") as f:
lineas = f.readlines()
fichajes = [l.rstrip().split(";") for l in lineas]
resultado = {}
for nombre in {f[1] for f in fichajes}:
minutos = [int(f[2]) for f in fichajes if f[1] == nombre]
resultado[nombre] = sum(minutos) / len(minutos)
return resultadoEjercicio 2. Con getsizeof, estima cuánta memoria superficial ahorrarían 500.000 fichajes retenidos si pasaran de dict de 4 claves (184 B) a @dataclass(slots=True) (72 B). Después explica por qué el ahorro real medido con tracemalloc sería aún mayor si además se aplica intern a los nombres de parada. ¿Puede getsizeof detectar ese segundo ahorro?
Ejercicio 3. RutaBus quiere publicar la matriz de tiempos mínimos todos-con-todos de la red metropolitana (V = 20.000, E ≈ 60.000, dispersa). Un compañero propone: "Floyd-Warshall sobre matriz, como en 04-06". Rebate o apoya la propuesta con números de memoria (celdas de la matriz a 8 bytes cada una), y propón qué representación y estrategia usarías si solo el 1 % de los pares se consulta realmente. (Es la tercera vez que este dilema aparece en el curso — 04-06 lo miró en tiempo, aquí toca memoria.)
Soluciones
Solución 1. Materializa tres veces: readlines() (todo el fichero), la lista fichajes (todo troceado) y las listas minutos (una pasada extra por línea de autobús, releyendo la lista completa). Versión de una sola pasada con acumuladores:
def minuto_medio_por_linea(ruta):
suma, cuenta = {}, {} # O(L): 3 líneas de autobús
for f in leer_fichajes(ruta): # generador de esta lección: O(1)
suma[f["linea"]] = suma.get(f["linea"], 0) + f["minuto"]
cuenta[f["linea"]] = cuenta.get(f["linea"], 0) + 1
return {l: suma[l] / cuenta[l] for l in suma}Memoria: dos diccionarios de 3 entradas más el fichaje en curso. Da igual que el fichero tenga dos mil o dos mil millones de líneas. (De regalo, también es más rápido: una pasada en vez de L+1.)
Solución 2. Ahorro superficial: 500.000 × (184 − 72) = 56.000.000 bytes = 56 MB, solo en envoltorios. El ahorro adicional del interning está en los valores: sin él hay hasta 500.000 objetos string para ~3.000 paradas distintas (~60 B cada uno ≈ 30 MB → ~0,2 MB con interning). getsizeof no puede detectarlo: es una medida superficial que no sigue referencias, así que no distingue si dos fichajes comparten el string o tienen copias (trampas 1 y 2). Solo una medición global como tracemalloc ve la diferencia.
Solución 3. La matriz necesita V² = 400.000.000 celdas × 8 B = 3,2 GB — solo la matriz de distancias; con la matriz sig de reconstrucción de 04-06, el doble. Inviable en un contenedor normal, y el 99 % de las celdas ni se consultará. Alternativa coherente con la densidad del grafo: dict de adyacencia (O(V+E) ≈ pocos MB) + Dijkstra bajo demanda desde el origen consultado, con una caché acotada de resultados por origen (lru_cache(maxsize=...) sobre distancias_desde, ejercicio 2 de 05-01) para los orígenes populares. Se cambia un precálculo O(V²) en memoria por recálculo O((V+E) log V) en tiempo — exactamente el trade-off inverso del apartado 10, elegido porque aquí el recurso escaso es la memoria y las consultas son dispersas.
Conclusión
El job nocturno de RutaBus ha pasado de morir con 812 MB a correr en O(1) de memoria, y por el camino ha dejado un método: medir (tracemalloc para el global y el pico, getsizeof solo para comparar envoltorios unitarios), no materializar lo que solo se recorre (generadores, línea a línea, lotes cuando conviene amortiguar), compactar lo que sí se retiene (__slots__, namedtuple, array, interning) y elegir la representación según la forma de los datos — la densidad del grafo decidió entre dict y matriz igual que la homogeneidad decidió entre lista y array. Y hemos cerrado el círculo abierto en 02-02: tiempo y memoria son dos platillos de la misma balanza, y saber cuál cargar exige haber medido los dos. Con el código afinado (05-01) y la memoria bajo control, queda un recurso sin explotar: los demás núcleos del procesador, que durante todo este módulo han estado mirando cómo trabaja uno solo. La próxima lección — paralelización — reparte el trabajo entre ellos, y de paso desmonta el mito más persistente de Python: por qué los hilos no siempre aceleran nada, y qué usar cuando no lo hacen.
Curso de Análisis y Diseño de Algoritmos
Módulo 1: Introducción a los Algoritmos
Módulo 2: Análisis de Algoritmos
- Análisis de Complejidad Temporal
- Análisis de Complejidad Espacial
- Casos de Complejidad: Mejor, Peor y Promedio
Módulo 3: Estrategias de Diseño de Algoritmos
Módulo 4: Algoritmos Clásicos
- Búsqueda Binaria
- Ordenamiento por Inserción
- Ordenamiento por Mezcla (Merge Sort)
- Ordenamiento Rápido (Quick Sort)
- Algoritmo de Dijkstra
- Algoritmo de Floyd-Warshall
