Kodokon kodokon.com

Rendimiento: profiling, GIL, generadores frente a listas, trampas de memoria

Mide antes de optimizar: timeit y cProfile, lo que el GIL bloquea realmente, la frugalidad de los generadores y las trampas de memoria de las cadenas y los slices.

11 min · 3 preguntas

Abrir esta lección en Kodokon

Nunca adivines, mide. timeit aísla un micro-benchmark fiable: desactiva el recolector de basura y repite la ejecución para suavizar el ruido. cProfile cartografía las llamadas de todo un programa - ordena por cumtime (tiempo acumulado, subllamadas incluidas) para identificar a los verdaderos culpables. La regla de oro de cualquier trabajo de rendimiento: la línea lenta casi nunca es la que sospechas.

BASH
python -m timeit "sum(x * x for x in range(1000))"
python -m timeit -s "s = 'abc' * 100" "s.upper()"
python -m cProfile -s cumtime -m timeit "min(range(50))"
La opción -s de timeit prepara el contexto fuera de la medición.

El GIL (Global Interpreter Lock) garantiza que solo un hilo ejecuta bytecode de Python a la vez dentro de un proceso. Consecuencia: los hilos no aceleran el cálculo puro. Siguen siendo, en cambio, perfectos para la E/S, porque el GIL se libera durante las lecturas de red o de disco, así como por muchas bibliotecas en C como NumPy. Para saturar varios núcleos con cálculo, pasa por multiprocessing o concurrent.futures.ProcessPoolExecutor. Python 3.13 ofrece una variante experimental "free-threaded" sin GIL, todavía poco habitual en producción.

PYTHON
import sys

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(squares_list), "bytes")
print(sys.getsizeof(squares_gen), "bytes")
print(sum(squares_gen))
Alrededor de 8 MB para la lista, alrededor de 200 bytes para el generador.

Un generador pesa unos pocos cientos de bytes sea cual sea la longitud de la secuencia: solo almacena su estado de ejecución y produce cada valor bajo demanda. Encadénalos en pipelines - sum(x * x for x in data) - para procesar flujos sin materializar nunca una lista intermedia. Un compromiso que conviene conocer: un generador es de un solo uso. Una vez agotado, no produce nada más, en silencio - una fuente clásica de errores en la que la segunda iteración parece "vacía".

PYTHON
import timeit

def concat(n: int) -> str:
    out = ""
    for i in range(n):
        out += str(i)
    return out

def join(n: int) -> str:
    return "".join(str(i) for i in range(n))

t1 = timeit.timeit(lambda: concat(20_000), number=10)
t2 = timeit.timeit(lambda: join(20_000), number=10)
print(f"concat: {t1:.3f} s / join: {t2:.3f} s")
Mide en tu máquina: join sigue siendo la opción portable y segura.

Prueba de conocimientos

Comprueba que has retenido los puntos clave de esta lección.

  1. ¿Qué impide exactamente el GIL?
    • Cualquier forma de paralelismo en Python, incluido multiprocessing
    • La ejecución simultánea de bytecode de Python por varios hilos del mismo proceso - la E/S y mucho código en C lo liberan
    • El uso de varios hilos en un mismo programa
    • Las escrituras concurrentes sobre la misma variable
  2. ¿Por qué sys.getsizeof de un generador se mantiene minúsculo comparado con la lista equivalente?
    • El generador no almacena ningún elemento: solo conserva su estado de ejecución y calcula cada valor bajo demanda
    • getsizeof no sabe medir los generadores
    • Los generadores comprimen sus datos internamente
  3. ¿Por qué ''.join(parts) gana a out += part repetido dentro de un bucle?
    • join usa varios hilos para ensamblar la cadena
    • Es un mito: el rendimiento es idéntico desde Python 3
    • Como los str son inmutables, cada += vuelve a copiar toda la cadena (coste cuadrático); join calcula el tamaño final y reserva memoria una sola vez
    • join evita crear los objetos str intermedios del generador