Módulo 5: Rápido y en paralelo — caché y paralelismo
1. Presentación del módulo: rápido y en paralelo
Descripción
Hasta el módulo 4, tu pipeline creció en cobertura pero también en peso. Empezó corriendo la suite de Reservo en un solo entorno; después le sumaste una matriz de versiones de Python y, quizá, de sistemas operativos, y de golpe cada push dispara no una corrida sino tres, seis o nueve. Cada una de esas celdas hace exactamente lo mismo que las demás: descarga e instala pytest desde cero, y luego ejecuta los tests uno tras otro, en fila india. El resultado es un pipeline más completo y, al mismo tiempo, más lento. Y la lentitud del CI no es un detalle cosmético: es el factor que decide si el pipeline se usa o se ignora.
Al terminar esta lección vas a entender por qué la velocidad del CI importa tanto —no solo por comodidad, sino porque un CI lento se salta— y cuáles son las dos palancas que este módulo te da para acelerarlo. La primera es cachear las dependencias: dejar de reinstalar lo mismo en cada corrida y reutilizar lo ya instalado mientras requirements.txt no cambie. La segunda es paralelizar la suite con una herramienta llamada pytest-xdist: en lugar de correr los tests en fila, repartirlos entre todos los núcleos de tu procesador y correrlos a la vez. Vas a ver esta segunda palanca funcionar de verdad, en local, sobre la suite de Reservo, con el speedup medido: la misma suite lenta que tarda 6.10 segundos en serie baja a 1.15 segundos en paralelo. No es un número inventado; lo ejecuté para escribir esto.
Conexión con el módulo: esta lección es el mapa. Aquí instalas las dos ideas grandes —caché y paralelismo— y las ves latir una vez. La lección 2 abre en canal el por qué: qué le pasa a un equipo cuando el CI tarda demasiado, y dónde exactamente se va el tiempo. La 3 es la primera palanca completa: actions/cache y la clave del hash de requirements.txt. La 4 es la segunda palanca ejecutada de verdad: pytest-xdist con -n auto, con la demo del speedup real y el header nuevo de pytest. La 5 divide la suite en rápida y lenta para que el feedback más urgente llegue primero. La 6 es la lección bisagra: el aislamiento, el requisito que hace posible el paralelismo, demostrado con un test que se rompe bajo -n. La 7 es la decisión económica: cuánto paralelizar sin quemar dinero. Y la 8, el mini-proyecto, junta todo: aceleras el CI de Reservo con caché y -n auto, y arreglas el test que se rompía en paralelo.
Una nota sobre la frontera, porque este módulo se apoya en los anteriores y no invade a los que siguen. La matriz —correr en varias versiones— fue el módulo 4; aquí no la reescribimos, aunque sí la nombramos, porque es justo la que más se beneficia de acelerar. Las puertas de cobertura —un umbral que rompe el build— son el módulo 6, no este. Y los tests flaky —los que fallan de forma intermitente— son el módulo 7. Ese último límite es sutil y conviene fijarlo desde ya: en la lección 6 vas a ver un test que falla bajo paralelismo, y podrías pensar "eso es un flaky". No lo es. Es un test mal aislado, que falla de forma determinista en cuanto lo repartes entre procesos; el aislamiento es requisito de este módulo. El flaky de verdad —el que falla a veces sí y a veces no sin que cambies nada— es del módulo 7. Aquí el foco es uno: la velocidad. Caché para no reinstalar, paralelismo para no correr en fila.
El buffet que reabastece de golpe y las cajas que se multiplican
Imagina un buffet muy concurrido a la hora de la comida. Hay dos cuellos de botella distintos, y confundirlos lleva a arreglar lo que no es.
El primero: cada vez que se vacía una bandeja, el cocinero baja al almacén, corta las verduras desde cero, las lava y las cocina, aunque sean exactamente las mismas que cortó hace media hora. Es trabajo repetido que no cambió de una vez a la otra. La solución obvia no es cocinar más rápido: es preparar las verduras una vez y guardarlas listas, y solo volver a cortar cuando de verdad cambie el menú. Eso es cachear: reutilizar un resultado que ya calculaste porque las entradas no cambiaron. En tu CI, las "verduras que se cortan una y otra vez" son las dependencias que se instalan idénticas en cada corrida.
El segundo cuello de botella: hay una sola caja registradora, y los comensales hacen una fila larguísima. La comida está lista, pero el pago va de a uno. La solución tampoco es que el cajero teclee más rápido: es abrir más cajas y repartir la fila entre ellas. Con cuatro cajas, la fila avanza casi cuatro veces más rápido —siempre que cada cliente pueda pagar en cualquier caja, sin depender de lo que hizo el de la caja de al lado—. Eso es paralelizar: repartir un trabajo entre varios ejecutores que corren a la vez. En tu CI, "los clientes en fila" son los tests, y "abrir más cajas" es pytest-xdist repartiéndolos entre los núcleos de tu procesador.
Fíjate en el detalle del final, porque es la lección 6 entera: las cajas paralelas solo funcionan si cada cliente es independiente. Si el cliente de la caja 2 necesita el cambio que dejó el cliente de la caja 1, abrir más cajas rompe el sistema en vez de acelerarlo. Un test que depende de lo que otro test dejó atrás es exactamente ese cliente: pasa cuando hay una sola caja (todo en orden, en fila), y se rompe en cuanto repartes.
Un CI lento tiene dos fuentes: trabajo repetido (reinstalar lo mismo) y trabajo en fila (correr los tests uno tras otro). La caché ataca la primera; el paralelismo, la segunda. Y el paralelismo solo funciona si los tests son independientes.
Reservo, tal como lo dejamos — y una suite que ahora pesa
Seguimos con Reservo, el sistema de reservas de salas de un coworking que venimos probando desde el primer módulo. Lógica pura de Python: sin base de datos, sin red, sin relojes escondidos. Sus piezas, por si necesitas refrescar:
Room(id,name,capacity,hourly_cents),Member(id,name,tier:"basic"o"pro"),Booking(con su campoprice_cents, elstart, elendcomo rango medio-abierto[start, end), y sustatus).- Las funciones núcleo:
price_cents(room, member, hours),refund_cents(booking, price_paid_cents, now),overlaps,is_available,book, y elCalendarque guarda las reservas en memoria. - Los números-ancla, el checksum de toda la guía: basic 3 h → 7500, pro 3 h → 6000 (20% de descuento), y el reembolso sobre 6000 pagados: 6000 si cancelas 72 h antes (≥ 48 h, 100%), 3000 a 36 h (24–48 h, 50%), 0 a 12 h (< 24 h). Todo en centavos int.
Esa suite —11 tests repartidos en test_pricing.py, test_refunds.py y test_availability.py— es rápida como un rayo: corre en centésimas de segundo. Y ahí está el problema para este módulo: una suite que ya vuela no sirve para demostrar cómo acelerar. Necesitamos una suite que pese de verdad, porque la velocidad solo se nota cuando había lentitud que quitar.
Así que a Reservo le sumamos algo realista: un reporte mensual de ingresos. Un producto que crece termina teniendo tests más caros que los unitarios —tests que renderizan un PDF, que consultan una fuente de datos, que arrancan un subproceso—. Reservo estrena test_reports.py con doce de esos tests lentos, uno por mes, cada uno sumando el ingreso de un puñado de reservas. Para que la demo sea reproducible en cualquier máquina, la lentitud está simulada con una espera:
# tests/test_reports.py (fragmento)
import time
import pytest
from reservo.models import Room, Member
from reservo.pricing import price_cents
ROOM = Room(id="r1", name="Focus", capacity=4, hourly_cents=2500)
BASIC = Member(id="m1", name="Ana", tier="basic")
PRO = Member(id="m2", name="Ben", tier="pro")
def _slow_report_total(bookings):
"""Suma el precio de cada reserva, en centavos. Lento a proposito."""
time.sleep(0.5) # simula una fuente de datos lenta o un render
return sum(b for b in bookings)
@pytest.mark.slow
def test_report_january_basic_hours():
total = _slow_report_total([price_cents(ROOM, BASIC, 3) for _ in range(4)])
assert total == 30000 # 4 reservas basic de 3h: 4 x 7500
Detente en el time.sleep(0.5). No es trampa; es un sustituto honesto. Un test lento de verdad tarda medio segundo porque espera a la red, al disco o a un subproceso; aquí ese medio segundo lo produce un sleep para que tú obtengas exactamente los mismos tiempos que yo, sin depender de tu red ni de tu disco. La aritmética que se prueba sí es real y en centavos: cuatro reservas basic de 3 h suman 4 × 7500 = 30000. Los doce tests llevan @pytest.mark.slow, un marcador —una etiqueta que le pones a un test— que en la lección 5 nos servirá para separar lo lento de lo rápido. Por ahora quédate con la foto: la suite de Reservo pasó de 11 tests instantáneos a 23 tests, doce de ellos con medio segundo de espera cada uno. Eso es, sumado, seis segundos de fila. Justo lo que necesitamos para ver el paralelismo hacer su magia.
El primer contacto: la misma suite, en serie y en paralelo
Antes de desmenuzar nada, veamos las dos fotos que resumen el módulo. Las dos son reales, ejecutadas en la máquina donde escribo esto: Python 3.14.0, pytest 9.1.1, con pytest-xdist instalado.
Primero, la suite completa en serie, como la corriste toda la guía: los 23 tests, uno tras otro.
python -m pytest
Qué esperar (salida real):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0
collected 23 items
tests/test_availability.py .... [ 17%]
tests/test_pricing.py .... [ 34%]
tests/test_refunds.py ... [ 47%]
tests/test_reports.py ............ [100%]
============================== 23 passed in 6.10s ==============================
Lee el final: 23 passed in 6.10s. Todo verde, pero seis segundos largos. Esos seis segundos son, casi enteros, los doce sleep(0.5) corriendo en fila: medio segundo, uno tras otro, doce veces. Los otros 11 tests apenas se notan. Ahora la misma suite, sin cambiar una sola línea de test, agregando solo la bandera -n auto:
python -m pytest -n auto
Qué esperar (salida real):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0
created: 12/12 workers
12 workers [23 items]
........................ [100%]
============================== 23 passed in 1.15s ==============================
23 passed in 1.15s. La misma suite, los mismos 23 tests, todos verdes, en una quinta parte del tiempo. De 6.10 s a 1.15 s. No cambiamos ningún test; solo dejamos que corrieran a la vez en lugar de en fila.
Y fíjate en las dos líneas nuevas del header, porque las vas a leer todo el módulo:
created: 12/12 workers— pytest-xdist arrancó doce procesos worker, uno por cada núcleo de esta máquina (tiene 12 CPUs).-n autosignifica "usa tantos workers como núcleos tengas".12 workers [23 items]— reemplaza a la líneacollected 23 itemsde la corrida en serie. Dice: doce workers se van a repartir 23 tests. En lugar de un proceso masticando la fila entera, doce se la reparten.
Esas son las dos palancas anunciadas en miniatura. En la corrida en serie no había caché ni workers; en la de -n auto, el paralelismo repartió el trabajo. La caché no se ve en estas fotos porque es una optimización del CI (no se reinstala nada en local entre corridas), pero su efecto es el mismo tipo de ahorro: no repetir trabajo que ya hiciste.
La honestidad del módulo: qué corre de verdad y qué es contenido
Como en toda la guía, conviene ser claro sobre qué se ejecuta y qué se explica, porque en este módulo la línea pasa justo por la mitad.
El paralelismo con pytest-xdist se ejecuta de verdad, en local. Cada tiempo que cite —6.10 s, 1.15 s, 1.83 s— lo medí corriendo la suite en una máquina real con Python 3.14.0. El speedup que ves es genuino, y lo puedes reproducir: instalas pytest-xdist con pip, corres python -m pytest -n auto, y obtienes tu propio número (que dependerá de cuántos núcleos tenga tu máquina). El header N workers [M items] sale de una corrida auténtica.
La caché de dependencias con actions/cache es contenido. actions/cache es una acción de GitHub que solo tiene sentido dentro de un runner de CI, guardando y restaurando archivos entre corridas de la nube. Aquí no tenemos runner, así que el YAML de la caché lo vas a escribir y leer como contenido —te muestro cómo se ve el step y cómo se leería su log, con cache hit y cache miss—, y lo anclamos a algo que sí ves en local: el Using cached que pip imprime cuando reutiliza un paquete que ya bajó. Es el mismo principio (no rebajar lo que ya tienes), en dos escalas.
Dicho de otro modo: cuando leas "1.15 s con -n auto", ese número lo medí ejecutando; cuando leas un log de CI con Cache restored from key: ..., ese es el formato honesto de cómo se vería en un runner, no una captura de un CI fantasma.
Errores comunes
Creer que "el CI está verde" basta, sin mirar cuánto tarda. Qué pasa: un equipo monta un pipeline correcto pero lento —doce minutos por push— y lo da por bueno porque "pasa". A las pocas semanas nadie espera el verde: hacen merge en cuanto los tests locales pasan y el CI queda como un adorno que a veces avisa tarde de un problema ya mezclado. Por qué pasa: la corrección se ve; la lentitud se sufre en silencio y se normaliza. Cómo detectarlo: pregunta a tu equipo "¿esperas el verde del CI antes de mergear?". Si la respuesta honesta es "casi nunca, tarda mucho", tu CI está lento de más. Cómo corregirlo: las dos palancas de este módulo. Un CI que da veredicto en un minuto se espera; uno que tarda quince, se ignora.
Intentar acelerar cocinando más rápido en vez de atacar la fuente. Qué pasa: alguien ve un CI lento y compra un runner más potente, o reescribe los tests para que cada uno sea un pelín más veloz, y apenas mejora. Por qué pasa: no distinguió las dos fuentes de lentitud —trabajo repetido y trabajo en fila— y aplicó la herramienta equivocada. Cómo detectarlo: mira dónde se va el tiempo. Si la mitad es "installing dependencies", el problema es de caché, no de CPU. Si la mitad es "running tests" en fila, el problema es de paralelismo. Cómo corregirlo: mide primero (la lección 5 te enseña --durations), y aplica la palanca que corresponde a cada fuente.
Confundir "falla en paralelo" con "es flaky". Qué pasa: alguien enciende -n auto, un test que siempre pasaba se pone rojo, y concluye "los tests en paralelo son inestables, mejor no uso xdist". Por qué pasa: el test comparte estado con otro y el paralelismo lo reveló; pero es fácil culpar a la herramienta. Cómo detectarlo: si el test falla siempre que corres en paralelo (no a veces), no es flaky, es un problema de aislamiento determinista. Cómo corregirlo: aísla el test (lección 6). Un flaky de verdad —fallo intermitente sin causa clara— es otro animal, del módulo 7. No apagues el paralelismo por un test mal aislado; arréglalo.
Ejercicios
Ejercicio 1 — Identifica la palanca. Para cada síntoma de CI lento, di cuál de las dos palancas de este módulo lo ataca —caché o paralelismo— y por qué en una frase. (a) "El step Install dependencies tarda 90 segundos en cada corrida, aunque no toqué requirements.txt en semanas." (b) "Tengo 400 tests que corren en fila y tardan 8 minutos, aunque cada uno es independiente." (c) "Mi matriz de 3 versiones instala las mismas dependencias tres veces por push."
Ver solución
- (a) Caché. El trabajo se repite idéntico (instalar lo mismo) sin que las entradas cambien. Cachear las dependencias evita reinstalar mientras
requirements.txtno cambie: eso es exactamente lo que haceactions/cachecon la clave del hash (lección 3). - (b) Paralelismo. El problema es "trabajo en fila": 400 tests independientes corriendo uno tras otro. Repartirlos entre núcleos con
pytest-xdist -n autolos corre a la vez (lección 4). La pista de que se puede es "cada uno es independiente" —el requisito de la lección 6—. - (c) Caché (principalmente). Instalar lo mismo tres veces es trabajo repetido; una caché por celda de la matriz evita bajar y compilar de nuevo en cada versión. (El paralelismo dentro de cada celda también ayudaría, pero la queja aquí es la reinstalación repetida.)
La regla mecánica: si el desperdicio es "hago lo mismo otra vez sin que cambie nada", es caché. Si el desperdicio es "podría hacer varias cosas a la vez pero las hago en fila", es paralelismo.
Ejercicio 2 — Lee el header de xdist. Un compañero corre su suite con -n auto y ve este header. Responde: ¿cuántos procesos worker arrancó?, ¿cuántos tests hay?, y ¿qué te dice esto sobre cuántos núcleos tiene su máquina?
created: 8/8 workers
8 workers [150 items]
Ver solución
- Workers arrancados: 8. La línea
created: 8/8 workersdice que pytest-xdist creó los 8 workers que pidió, todos listos. - Tests: 150.
8 workers [150 items]significa "ocho workers se van a repartir 150 tests". Es la versión paralela decollected 150 items. - Núcleos de la máquina: como usó
-n autoy auto elige un worker por núcleo, su máquina tiene (muy probablemente) 8 CPUs. En la máquina de esta guía,-n autodaba 12 workers porque tiene 12 núcleos; en la suya, 8.
La moraleja: -n auto adapta el número de workers al hardware, así que el mismo comando exprime 8 núcleos en su máquina y 12 en la de la guía, sin que tú escribas un número fijo.
Ejercicio 3 — ¿Qué módulo resuelve esto? Para cada situación, di si la resuelve este módulo (velocidad: caché y paralelismo) o si le toca a otro módulo de la guía, y nómbralo en una frase. (a) "Quiero que mi suite lenta corra en 1 segundo en vez de 6." (b) "Un test pasa a veces y falla a veces, sin que yo cambie nada." (c) "Quiero que el build se rompa si la cobertura baja del 80%." (d) "Encendí -n auto y un test que siempre pasaba ahora falla siempre en paralelo."
Ver solución
- (a) Correr la suite lenta más rápido → este módulo, en concreto la lección 4 (paralelismo con
pytest-xdist). Es la definición del speedup que perseguimos. - (b) Un test que pasa a veces y falla a veces → módulo 7 (flaky tests en CI). Un fallo intermitente sin cambio de código es la definición de flaky, y su tratamiento —retry, cuarentena— es del módulo 7.
- (c) Romper el build si la cobertura baja → módulo 6 (puertas de calidad y umbrales de cobertura). Un umbral que rompe el build (
--cov-fail-under) es una puerta de calidad, no una optimización de velocidad. - (d) Un test que falla siempre en paralelo → este módulo, la lección 6 (aislamiento). Ojo con la trampa: falla siempre en paralelo, no a veces, así que no es flaky; es un test mal aislado que comparte estado, y el aislamiento es el requisito que este módulo te enseña a cumplir.
La distinción fina entre (b) y (d): "a veces sí, a veces no" es flaky (módulo 7); "siempre que paralelizo" es falta de aislamiento (este módulo, lección 6).
Resumen y siguiente paso
En esta lección instalaste las dos ideas que sostienen el módulo. Un CI lento no es solo incómodo: se ignora, y un pipeline ignorado no protege nada. La lentitud tiene dos fuentes distintas —trabajo repetido (reinstalar lo mismo) y trabajo en fila (correr los tests uno tras otro)— y a cada una le corresponde una palanca: cachear las dependencias para no reinstalar, y paralelizar con pytest-xdist para no correr en fila. Lo viste con la analogía del buffet: preparar las verduras una vez y abrir más cajas, con la advertencia de que las cajas paralelas solo sirven si cada cliente es independiente.
Y lo viste latir con una demo local real: Reservo estrenó una suite lenta —test_reports.py, doce tests con medio segundo de espera cada uno— y corriste la suite completa de dos formas. En serie: 23 passed in 6.10s. Con -n auto: 23 passed in 1.15s, la misma suite en una quinta parte del tiempo, con el header nuevo 12 workers [23 items]. También quedó clara la honestidad del módulo: el paralelismo corre de verdad en local, el YAML de actions/cache es contenido anclado al Using cached de pip.
Antes de avanzar deberías poder: nombrar las dos fuentes de lentitud del CI y la palanca que ataca cada una; leer el header de xdist (N workers [M items]); explicar por qué un CI lento se termina ignorando; y distinguir un test que falla siempre en paralelo (aislamiento, este módulo) de uno que falla a veces (flaky, módulo 7).
Lo que sigue, en la lección 2, es abrir en canal el por qué humano y técnico: qué le pasa de verdad a un equipo cuando el CI tarda demasiado, cómo se rompe el bucle de feedback cuando la espera pasa de segundos a minutos, y dónde exactamente se va el tiempo en una corrida —para que las dos palancas que siguen caigan sobre el desperdicio real y no sobre una sospecha.
Recursos
- pytest-xdist en PyPI — la página oficial del plugin que corre tus tests en paralelo, con el resumen de
-n autoy las opciones de distribución. La referencia de instalación para la demo real de este módulo. - Caching dependencies to speed up workflows — GitHub Actions — la guía oficial de
actions/cache: qué se cachea, la clave, y por qué acelera. La abrimos línea por línea en la lección 3. - Cómo invocar pytest (documentación de pytest) — la referencia de las formas de correr la suite, incluidas las banderas que combinaremos con
-na lo largo del módulo. El punto de partida para todo lo que ejecutamos aquí.