Optimizaciones reales en código Python puro

El intérprete de CPython ejecuta bytecode, y ese bytecode tiene costos muy distintos según de dónde venga el dato que necesitas. Entender eso te permite escribir código que es funcional y eficiente sin tocar C ni Cython.

La distinción fundamental: cuando accedes a una variable local, CPython emite LOAD_FAST, que indexa directamente en un array de tamaño fijo asociado al frame. Cuando accedes a una global, emite LOAD_GLOBAL, que hace una búsqueda en el diccionario __globals__ del módulo. La diferencia parece pequeña, pero en un loop que corre millones de veces se acumula hasta un ~20% de diferencia real. La misma lógica aplica al attribute lookup: cada vez que escribes objeto.metodo() dentro de un loop, Python recorre el MRO en busca del atributo. Si lo haces diez millones de veces, estás pagando ese costo diez millones de veces.

Hay tres patrones más que completan el cuadro antes de mirar código. Primero, concatenar strings con += en un loop es O(n²) porque cada operación crea un string nuevo y copia todo lo anterior; "".join(partes) es O(n) porque primero calcula el tamaño total y hace una sola asignación. Segundo, las list comprehensions no son solo azúcar sintáctica: su bytecode usa LIST_APPEND a nivel de instrucción especializada, mientras que el patrón lista.append(x) en un for normal implica lookup del atributo append en cada iteración (a menos que lo guardes). Tercero, la membresía en un set o lookup en un dict es O(1) por tabla hash; buscar en una lista es O(n) lineal. Usarlos mal en hot paths puede cambiar la complejidad algorítmica completa de tu función.

La advertencia que vale repetir: nada de esto importa fuera de hot paths. Primero profila con cProfile o line_profiler, identifica dónde se gasta el 80% del tiempo, y aplica estas técnicas ahí. Micro-optimizar código frío es ruido.

import time
import cProfile
from typing import Callable

# ── Datos de prueba ────────────────────────────────────────────────────────────
WORDS = ["hola", "mundo", "python", "optimizacion", "bytecode"] * 200_000
TARGETS = {"python", "bytecode", "cProfile"}  # set: O(1) membership
TARGETS_LIST = list(TARGETS)                  # lista equivalente: O(n)


# ── 1. Concatenación: += vs join ───────────────────────────────────────────────

def concat_plus(words: list[str]) -> str:
    result = ""
    for w in words:
        result += w + ","   # crea un nuevo objeto str en cada iteración
    return result


def concat_join(words: list[str]) -> str:
    # join calcula la longitud total primero, alloca una vez
    return ",".join(words)


# ── 2. Attribute lookup fuera del loop ────────────────────────────────────────

def collect_slow(words: list[str]) -> list[str]:
    result = []
    for w in words:
        result.append(w.upper())    # lookup de 'append' y 'upper' en cada vuelta


    return result


def collect_fast(words: list[str]) -> list[str]:
    result = []
    append = result.append      # referencia directa: LOAD_FAST en lugar de LOAD_ATTR
    upper  = str.upper          # método desligado: evita lookup por objeto también
    for w in words:
        append(upper(w))
    return result


def collect_comprehension(words: list[str]) -> list[str]:
    # el compilador usa LIST_APPEND especializado; más limpio que guardar append
    return [w.upper() for w in words]


# ── 3. Variable local vs global en loop interno ───────────────────────────────

MULTIPLIER = 1.5   # global

def process_global(data: list[float]) -> list[float]:
    return [x * MULTIPLIER for x in data]   # LOAD_GLOBAL en cada iteración


def process_local(data: list[float]) -> list[float]:
    multiplier = MULTIPLIER     # copia al scope local: a partir de aquí LOAD_FAST
    return [x * multiplier for x in data]


# ── 4. Búsqueda: set vs list ──────────────────────────────────────────────────

def filter_list(words: list[str], targets: list[str]) -> list[str]:
    return [w for w in words if w in targets]   # O(n * m)


def filter_set(words: list[str], targets: set[str]) -> list[str]:
    return [w for w in words if w in targets]   # O(n * 1) — misma sintaxis, distinto costo


# ── Benchmark simple ──────────────────────────────────────────────────────────

def bench(label: str, fn: Callable, *args, runs: int = 3) -> None:
    times = []
    for _ in range(runs):
        t0 = time.perf_counter()
        fn(*args)
        times.append(time.perf_counter() - t0)
    avg = sum(times) / runs
    print(f"{label:<35} {avg*1000:.1f} ms")


if __name__ == "__main__":
    data_floats = [float(i) for i in range(1_000_000)]

    print("── Concatenación ─────────────────────────")
    bench("+=  (O(n²))",          concat_plus,         WORDS[:5_000])
    bench("join (O(n))",           concat_join,         WORDS[:5_000])

    print("\n── Colección de resultados ───────────────")
    bench("append en loop (slow)", collect_slow,        WORDS)
    bench("append ref (fast)",     collect_fast,        WORDS)
    bench("comprehension",         collect_comprehension, WORDS)

    print("\n── Global vs local en comprehension ──────")
    bench("global MULTIPLIER",     process_global,      data_floats)
    bench("local multiplier",      process_local,       data_floats)

    print("\n── Membresía set vs list ─────────────────")
    bench("in list  O(n*m)",       filter_list,         WORDS, TARGETS_LIST)
    bench("in set   O(n)",         filter_set,          WORDS, TARGETS)

    print("\n── cProfile sobre filter_list ────────────")
    cProfile.run("filter_list(WORDS, TARGETS_LIST)", sort="cumulative")

Qué hace cada decisión y por qué importa

concat_join vs concat_plus: el problema con += no es solo “crear objetos nuevos”; es que CPython no puede optimizar eso a un buffer porque los strings son inmutables. Algunos intérpretes aplican una optimización cuando hay una sola referencia al string, pero es frágil y no está garantizada. join es el contrato estable.

append = result.append fuera del loop: esto no es micro-optimización prematura si el loop es tu hot path. Lo que haces es convertir un LOAD_GLOBAL + LOAD_ATTR (dos operaciones, una con búsqueda en dict) en un solo LOAD_FAST. Con str.upper como método desligado evitas incluso el lookup por instancia. La comprehension logra el mismo efecto con menos ruido visual; úsala cuando la expresión sea simple.

multiplier = MULTIPLIER antes de la comprehension: fíjate que el cambio es trivial —una línea antes del return— y ya metes el valor en co_varnames, el array local del frame. CPython no hace este hoisting automáticamente; es trabajo tuyo cuando el profiler te señala esa función.

set vs list para membresía: aquí la diferencia no es de constantes sino de complejidad. Con WORDS de un millón de elementos y TARGETS_LIST de tres elementos, la lista sigue siendo O(n) por lookup pero son solo tres comparaciones; la diferencia real emerge cuando targets crece. Si tienes decenas de targets, el set gana sin discusión.

El cProfile al final: no está de adorno. El patrón correcto siempre es medir primero, ver qué función acumula cumtime, y aplicar las técnicas de arriba solo ahí. line_profiler (tercero, pip install line-profiler) te da resolución de línea cuando cProfile te señala una función larga.

Errores que debes conocer

Error: guardar la referencia al método antes de que la lista exista, lo que provoca que append quede ligado al objeto equivocado o a None.

# ❌ Wrong
append = [].append      # lista temporal anónima; 'result' y esta lista son objetos distintos
result = []
for w in words:
    append(w)           # estás llenando la lista temporal, no 'result'
print(result)           # []

# ✅ Right
result = []
append = result.append  # referencia al método del objeto que vas a devolver
for w in words:
    append(w)

El método ligado (bound method) captura el objeto en el momento en que lo resuelves; si creas la lista después, ya es tarde.


Error: usar join sobre un generador que produce objetos que no son str, esperando que la conversión sea implícita.

# ❌ Wrong
nums = [1, 2, 3]
result = ",".join(nums)     # TypeError: sequence item 0: expected str instance, int found

# ✅ Right
result = ",".join(str(n) for n in nums)

join exige que todos los elementos sean str; la conversión es responsabilidad tuya, y hacerla con un generador evita crear la lista intermedia.


Error: convertir una lista a set dentro del loop en lugar de hacerlo una sola vez antes.

# ❌ Wrong
def filter_targets(words, targets_list):
    return [w for w in words if w in set(targets_list)]  # set() se construye en cada llamada... no, en cada... espera

# El error real es más sutil: si esto está dentro de otro loop externo:
for batch in batches:
    found = [w for w in batch if w in set(targets_list)]  # O(m) de construcción en cada batch

# ✅ Right
targets_set = set(targets_list)   # una sola vez, fuera del loop externo
for batch in batches:
    found = [w for w in batch if w in targets_set]

Construir el set una sola vez amortiza el costo O(m) de construcción contra todas las búsquedas; hacerlo dentro del loop lo pagas en cada iteración y destruyes la ventaja.

167

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio