Módulo 8: Proyecto — un pipeline de CI para Reservo
5. Caché y paralelismo para la velocidad
Descripción
La matriz de la lección anterior te dio cobertura de versiones, pero te cobró en tiempo: ahora el pipeline corre el trabajo completo tres veces por push. Si cada celda baja las dependencias de internet desde cero y corre los tests en fila india, el ciclo de feedback —ese bucle de "subo, espero, veo el resultado" que hace útil al CI— se alarga justo cuando más lo usas. La capa que apilamos aquí ataca ese costo por dos frentes: cachear las dependencias para no volver a bajarlas en cada corrida, y paralelizar la suite con pytest-xdist para correr los tests en varios procesos a la vez.
Es la capa del módulo 5, y trae consigo la lección más importante —y más incómoda— del rendimiento: no toda optimización paga. El caché casi siempre ayuda. El paralelismo ayuda cuando la suite es lenta, y estorba cuando ya es rápida, porque tiene una sobrecarga fija que hay que amortizar. Vas a ver pytest-xdist ejecutado de verdad sobre una suite lenta —serial 4.07s que bajan a 1.19s con -n auto— y también la honestidad del caso de Reservo: su suite real es de 0.02 segundos, y ahí -n auto solo añade sobrecarga. Medir antes de optimizar no es una frase de cajón; es la diferencia entre acelerar y frenar.
Al terminar vas a saber cachear dependencias en el YAML, correr la suite en paralelo con xdist, y —lo que separa a quien copia -n auto de un tutorial de quien decide con datos— juzgar cuándo el paralelismo paga en tu proyecto y cuándo no.
Conexión con el módulo: esta es la cuarta capa, apilada sobre la matriz (lección 4) que la hizo necesaria. Modifica dos steps que ya tienes: el de instalar dependencias (que gana el caché) y el de correr pytest (que gana -n auto). Y tiende un puente incómodo a la lección 7: el paralelismo cambia el orden en que corren los tests, y ese cambio de orden es uno de los disparadores clásicos de los flaky —un test que asumía correr después de otro, o con un recurso para él solo, se destapa cuando xdist los reparte—. Aceleras aquí; en la lección 7 manejas el efecto secundario.
El equipo de mudanza y las cajas que se traían de nuevo
Piensa en una cuadrilla de mudanzas que vacía una casa grande. La primera versión, ingenua, hace esto: un solo cargador sube una caja al camión, baja, sube la siguiente, baja, una por una, él solo. Tardan el día entero. Dos ineficiencias saltan a la vista. La primera: un solo cargador cuando podrían ser cuatro trabajando en paralelo, cada uno con su pila de cajas. La segunda, más tonta: cada vez que necesitan cinta o plástico de burbuja, alguien va a la tienda a comprarlo de nuevo, aunque ya compraron ayer y el rollo está en la bodega.
La cuadrilla eficiente arregla las dos cosas. Contra la primera, paraleliza: cuatro cargadores a la vez, y la mudanza que tardaba ocho horas tarda dos. Contra la segunda, cachea: guarda la cinta y el plástico en la bodega y los reutiliza, en vez de correr a la tienda en cada mudanza. Las dos optimizaciones atacan tiempos distintos —el paralelismo, el trabajo repetible que se puede dividir; el caché, el material que no cambia entre mudanzas— y juntas transforman un día de trabajo en una mañana.
Tu pipeline tiene las dos ineficiencias. Bajar las dependencias de internet en cada corrida es correr a la tienda por cinta que ya tienes: las dependencias de Reservo no cambian entre push y push, así que descargarlas cada vez es desperdicio —el caché las guarda y las reutiliza—. Correr los tests en serie es el cargador solitario: pytest-xdist pone a varios procesos a correr tests distintos a la vez. Con un matiz que la cuadrilla también conoce: contratar cuatro cargadores para mudar una caja es absurdo —el tiempo de organizarlos supera el de cargar la caja—. El paralelismo paga cuando hay muchas cajas, no cuando hay una.
Dos optimizaciones, dos desperdicios. El caché evita re-descargar lo que no cambia (correr a la tienda por cinta que ya tienes). El paralelismo divide el trabajo entre procesos (varios cargadores). El caché casi siempre paga; el paralelismo paga cuando hay suficiente trabajo que dividir.
Cachear las dependencias
Cada vez que el pipeline corre, pip install -r requirements-dev.txt baja pytest, cov, xdist, rerunfailures y sus transitivas de internet. Esos paquetes no cambian entre corridas —están pinneados (lección 3)—, así que bajarlos cada vez es puro desperdicio de tiempo y de red. El caché los guarda tras la primera corrida y los restaura en las siguientes.
La forma más simple es la opción cache: pip que trae setup-python, que envuelve el mecanismo de caché por ti:
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip # cachea la descarga de pip
cache-dependency-path: requirements-dev.txt
Dos líneas nuevas. cache: pip le dice a setup-python que guarde y restaure el directorio de descargas de pip. cache-dependency-path apunta al archivo cuyo contenido define la clave del caché: mientras requirements-dev.txt no cambie, la clave es la misma y el caché se reutiliza; el día que edites una versión, la clave cambia y el caché se reconstruye. Esa es la magia y la trampa a la vez: el caché es válido porque está atado al contenido del archivo de dependencias. Si el caché se atara a algo que no refleja las dependencias, podrías restaurar paquetes viejos —el clásico bug de "el caché está envenenado"—. Atarlo al hash de requirements-dev.txt lo mantiene honesto.
Para control fino —cachear directorios específicos, componer la clave a mano— existe actions/cache, más explícito:
- name: Cache pip downloads
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('requirements-dev.txt') }}
hashFiles('requirements-dev.txt') calcula un hash del archivo: si el archivo cambia, el hash cambia, la clave cambia, y el caché se reconstruye. Para Reservo, cache: pip de setup-python basta y es más limpio; actions/cache se gana su lugar cuando cacheas algo más que descargas de pip (artefactos de build, modelos, datasets). Ambos son el mismo mecanismo por debajo.
Paralelizar con pytest-xdist
pytest-xdist corre tu suite en varios procesos worker a la vez. La bandera estrella es -n:
-n auto— xdist detecta cuántos CPU tiene la máquina y arranca un worker por cada uno. Es lo idiomático para CI, porque se adapta al runner sin que fijes un número.-n 4— un número fijo de workers, cuando quieres control (por ejemplo, para no saturar una máquina compartida).
El step de pytest gana la bandera:
- name: Run the test suite
run: python -m pytest -n auto
xdist reparte los tests entre los workers, cada uno corre los suyos en su propio proceso, y al final xdist junta los resultados. En una suite lenta, el efecto es dramático. Pero "lenta" es la palabra clave, y aquí es donde la honestidad de la guía importa.
Ejemplo trabajado 1: el paralelismo sobre una suite lenta (paga)
La suite real de Reservo es de 0.02s —demasiado rápida para que el paralelismo signifique algo, como veremos—. Para ver a xdist pagar, necesito una suite lenta. Armé una de ocho tests que simulan trabajo lento (cada uno espera medio segundo, como haría un test que golpea la red o un recurso externo). El sleep sustituye una llamada real; el punto es el tiempo, no lo que hace. Primero, en serie:
python -m pytest perf_demo/
Qué esperar (real, ocho tests de 0.5s cada uno, en serie):
collected 8 items
perf_demo/test_slow_integration.py ........ [100%]
============================== 8 passed in 4.07s ===============================
Ocho tests × 0.5s ≈ 4.07 segundos, uno tras otro. Ahora con -n auto:
python -m pytest -n auto perf_demo/
Qué esperar (real, misma suite en paralelo):
created: 12/12 workers
12 workers [8 items]
........ [100%]
============================== 8 passed in 1.19s ===============================
De 4.07s a 1.19s —más de tres veces más rápido—. Mira la línea created: 12/12 workers: la máquina tiene doce CPU lógicos, así que -n auto arrancó doce workers, y con ocho tests cada uno cupo en su propio worker, corriendo los ocho medios-segundos casi a la vez. El tiempo total dejó de ser "la suma de todos" y pasó a ser "el más lento más la sobrecarga de arrancar y coordinar workers". Con un número fijo:
python -m pytest -n 4 perf_demo/
created: 4/4 workers
4 workers [8 items]
============================== 8 passed in 1.32s ===============================
Con cuatro workers, los ocho tests se reparten en dos rondas (cuatro y cuatro), así que el tiempo teórico es ~1.0s más la sobrecarga: 1.32s medido. Un poco más lento que los doce workers, pero aún un salto enorme sobre los 4.07s en serie. Esa es la forma de la curva: más workers ayudan hasta que te quedas sin CPU o sin tests que repartir.
Ejemplo trabajado 2: el paralelismo sobre la suite de Reservo (no paga)
Ahora la honestidad. La suite real de Reservo corre en 0.02 segundos. ¿Qué pasa si le echo -n auto? Mídelo mentalmente con lo que ya sabes: xdist tiene una sobrecarga fija —arrancar doce procesos, repartirles tests, recolectar resultados— del orden de un segundo. Esa sobrecarga es cincuenta veces mayor que los 0.02s que la suite tarda en serie. El resultado es que -n auto haría la suite de Reservo más lenta, no más rápida: pasaría de 0.02s a más de un segundo, casi todo en organizar workers que no tienen nada pesado que hacer.
Es la cuadrilla contratando doce cargadores para mudar una caja: el tiempo de organizarlos supera con creces el de cargarla. La conclusión no es "xdist es malo"; es "xdist es la herramienta correcta para una suite lenta, y Reservo hoy no la tiene". El paralelismo se justifica cuando la suite tarda lo suficiente para amortizar la sobrecarga —segundos, minutos—, no cuando ya es instantánea. Por eso, en el pipeline de Reservo, -n auto es una inversión a futuro: lo dejas puesto sabiendo que hoy no paga, para que el día que la suite crezca a cientos de tests lentos, la aceleración esté lista sin tocar el YAML. O, con igual criterio, lo omites hasta que la suite lo pida. Ambas son decisiones defendibles; lo indefendible es encenderlo por reflejo creyendo que "paraleliza = más rápido" siempre.
El trade-off velocidad/costo
Acelerar no es gratis en dos monedas. La primera es el costo de cómputo: más workers usan más CPU y memoria a la vez; en un runner compartido o facturado por recursos, -n auto en una máquina de muchos cores puede subir el consumo instantáneo. La segunda, más sutil, es la complejidad: el paralelismo introduce no determinismo en el orden de ejecución, y ese no determinismo es un caldo de cultivo para los flaky (lección 7). Un test que en serie siempre corría después de otro —y dependía sin querer de ese orden— puede fallar de forma intermitente cuando xdist los reparte en workers distintos.
La regla práctica: cachea siempre (casi nunca tiene desventaja), y paraleliza cuando la suite sea lo bastante lenta para que la aceleración supere la sobrecarga y el riesgo de destapar flaky. Mide antes de decidir: corre la suite en serie, cronométrala, y solo mete -n auto si el número duele. Optimizar sin medir es cómo se llega a una suite de Reservo cincuenta veces más lenta "por acelerarla".
Errores comunes
Encender -n auto por reflejo en una suite rápida. Qué pasa: alguien lee "xdist acelera el CI", le pone -n auto a una suite de 0.02s como la de Reservo, y la vuelve más lenta —de instantánea a más de un segundo— sin entender por qué. Por qué pasa: "paralelizar = más rápido" es un atajo mental que ignora la sobrecarga fija de arrancar workers. Cómo detectarlo: cronometra la suite en serie y con -n auto; si la versión paralela es más lenta o igual, la sobrecarga te está costando. Cómo corregirlo: paraleliza solo cuando la suite en serie tarde lo suficiente para amortizar el arranque de workers (segundos hacia arriba). Para una suite instantánea, serie gana.
Atar el caché a algo que no refleja las dependencias. Qué pasa: alguien pone una clave de caché fija (key: pip-cache, sin hash) o atada a algo que no cambia cuando cambian las dependencias. Edita requirements-dev.txt, sube una versión, pero el caché sigue restaurando los paquetes viejos porque la clave no cambió, y el pipeline corre con dependencias desactualizadas —un rojo o, peor, un verde engañoso—. Por qué pasa: el caché "funciona" (restaura algo), así que el bug es silencioso. Cómo detectarlo: si cambiaste una versión y el CI sigue usando la vieja, sospecha de la clave del caché. Cómo corregirlo: ata la clave al hash del archivo de dependencias (hashFiles('requirements-dev.txt') o cache-dependency-path), para que cualquier cambio en las dependencias invalide el caché y lo reconstruya.
Optimizar sin medir. Qué pasa: alguien apila caché, -n auto y otras banderas "de rendimiento" sin haber cronometrado nada, guiado por la sensación de que "más rápido es mejor". Algunas ayudan, otras estorban, y sin medición no sabe cuáles ni por qué. Por qué pasa: la optimización se siente productiva aunque sea a ciegas. Cómo detectarlo: si no puedes decir en segundos cuánto tardaba antes y cuánto después de cada cambio, estás optimizando por fe. Cómo corregirlo: mide primero (cronometra la suite en serie), identifica el cuello de botella real (¿es la instalación?, ¿son los tests?), y aplica la optimización que ataca ese cuello. Para Reservo hoy, el cuello no es la suite (0.02s); si acaso, sería la instalación repetida en la matriz —que el caché ataca—, no el paralelismo.
Ejercicios
Ejercicio 1 — Predice el efecto de -n auto. Tienes dos suites: la A tarda 90 segundos en serie con 600 tests; la B tarda 0.05 segundos con 20 tests. Para cada una, predice si -n auto (en una máquina de 8 cores) la haría más rápida o más lenta, y por qué.
Ver solución
Suite A (90s, 600 tests): -n auto la haría mucho más rápida. Con 8 cores, xdist reparte los 600 tests en 8 workers, así que en el caso ideal el tiempo baja de 90s a algo cercano a 90/8 ≈ 11-12s, más la sobrecarga de arrancar workers (uno o dos segundos). Los 90 segundos de trabajo real son enormes comparados con la sobrecarga fija, así que la aceleración domina: paralelizar paga con creces. Este es el caso para el que xdist existe.
Suite B (0.05s, 20 tests): -n auto la haría más lenta. La sobrecarga de arrancar 8 workers, repartirles tests y recolectar resultados es del orden de un segundo —veinte veces más que los 0.05s que la suite tarda en serie—. No hay suficiente trabajo real que dividir para amortizar el arranque, así que el tiempo total subiría de 0.05s a más de un segundo. Es la suite de Reservo en miniatura: instantánea en serie, más lenta en paralelo. La regla: -n auto paga cuando el trabajo real (segundos, minutos) supera con holgura la sobrecarga fija (≈1s); en una suite instantánea, no lo supera.
Ejercicio 2 — La clave de caché que envenena. Un compañero escribe key: pip-deps (una clave fija, sin hash) en su actions/cache. Explica qué bug esconde esto y cómo lo arreglas, usando lo que sabes de cómo el caché decide reutilizar.
Ver solución
El bug: con una clave fija (pip-deps, que nunca cambia), el caché se guarda la primera vez y se restaura siempre, sin importar si las dependencias cambiaron. El día que el compañero edite requirements-dev.txt —suba pytest de 9.1.1 a 9.2.0, digamos—, la clave sigue siendo pip-deps, así que el caché restaura los paquetes viejos (pytest 9.1.1) y el pip install ni se molesta en bajar la versión nueva porque cree que ya está. El pipeline corre con dependencias desactualizadas: en el mejor caso da un rojo confuso, en el peor un verde engañoso que no refleja las versiones que el archivo pide. El caché "funciona" (restaura algo), así que el bug es silencioso y frustrante de diagnosticar.
El arreglo: atar la clave al contenido del archivo de dependencias, con hashFiles:
key: pip-${{ hashFiles('requirements-dev.txt') }}
Ahora la clave incluye un hash del archivo. Mientras requirements-dev.txt no cambie, el hash es el mismo y el caché se reutiliza (rápido). En cuanto edites una versión, el hash cambia, la clave cambia, el caché viejo ya no coincide, y pip baja e instala las dependencias nuevas —reconstruyendo el caché con la clave nueva—. La lección: un caché es válido solo si su clave cambia exactamente cuando cambia lo que cachea. Atarlo al hash del archivo de dependencias es lo que lo mantiene honesto.
Ejercicio 3 — Cachear sí, paralelizar aún no. Para el pipeline de Reservo tal como está, decide (y justifica) qué haces con cada una de las dos optimizaciones de esta lección: ¿cacheas las dependencias? ¿pones -n auto? Escribe la decisión como la defenderías en un review.
Ver solución
Cachear las dependencias: sí. Aunque las dependencias de Reservo son pocas, la matriz corre tres celdas por push, y sin caché cada celda baja pytest/cov/xdist/rerunfailures y sus transitivas de internet, repetido en cada corrida. El caché guarda esas descargas tras la primera vez y las restaura, recortando el tiempo de instalación en cada celda con prácticamente cero desventaja (atando la clave al hash de requirements-dev.txt para que se reconstruya cuando las dependencias cambien). El caché casi siempre paga; aquí ataca el cuello de botella real —la instalación repetida en la matriz—.
Poner -n auto: no (todavía), o solo como inversión documentada. La suite de Reservo corre en 0.02 segundos. -n auto arrancaría doce workers cuya sobrecarga fija (≈1s) es cincuenta veces mayor que lo que la suite tarda, así que la volvería más lenta, no más rápida. No hay trabajo real que dividir. La decisión defendible es omitir -n auto mientras la suite sea instantánea, y agregarlo el día que crezca a cientos de tests o a tests lentos que justifiquen la aceleración. (Una postura alternativa igual de válida: dejarlo puesto como inversión a futuro con un comentario que diga "no paga hoy, listo para cuando la suite crezca" —lo importante es la intención explícita, no el reflejo—.)
Cómo lo defendería en un review: "Cacheé porque la matriz repite la instalación tres veces y el caché lo elimina sin desventaja. No paralelicé porque medí la suite en 0.02s y -n auto la haría más lenta por la sobrecarga de workers; lo agregaré cuando la suite tarde lo suficiente para que pague." Esa es la diferencia entre optimizar con método y encender banderas por reflejo.
Resumen y siguiente paso
En esta lección apilaste la cuarta capa: caché y paralelismo para la velocidad, la respuesta al costo que la matriz introdujo. Aprendiste a cachear las dependencias —cache: pip en setup-python, o actions/cache con la clave atada al hash del archivo de dependencias— para no re-descargar en cada corrida lo que no cambia, la optimización que casi siempre paga. Y a paralelizar con pytest-xdist —-n auto o -n N— que reparte los tests entre workers.
Sobre todo, viste medido el matiz que separa optimizar de frenar: sobre una suite lenta, -n auto bajó de 4.07s a 1.19s (doce workers); sobre la suite real de Reservo, de 0.02s, el paralelismo solo añadiría sobrecarga y la haría más lenta. La lección de fondo —mide antes de optimizar; el paralelismo paga cuando la suite es lenta, no siempre— es lo que distingue una decisión de rendimiento con método de un -n auto copiado de un tutorial. Y quedó sembrado el puente a la lección 7: el paralelismo cambia el orden de ejecución, y ese cambio destapa flaky.
Antes de avanzar deberías poder: cachear dependencias con la clave atada al hash del archivo; correr la suite con -n auto y -n N; predecir si el paralelismo ayudará o estorbará según cuánto tarda la suite; y justificar en un review por qué cacheas siempre pero paralelizas solo cuando paga.
Lo que sigue, en la lección 6, es la capa que cambia la naturaleza del pipeline de "corre los tests" a "exige calidad": la puerta de cobertura. Hasta ahora tu pipeline se pone rojo si un test falla; la puerta lo pone rojo si la cobertura cae por debajo de un umbral —si llega código nuevo sin tests que lo ejerciten—. Vas a ver --cov-fail-under romper el build de verdad (exit 1 a 95%, exit 0 a 85%), leer qué líneas faltan, y enfrentar la decisión honesta de dónde poner el umbral —incluyendo por qué el 100% es un fetiche y cómo un hueco de cobertura de Reservo es, de hecho, código cubierto por otra celda de la matriz—.
Recursos
- pytest-xdist — la documentación del plugin de paralelismo:
-n auto,-n N, los modos de distribución, y las advertencias sobre tests que dependen del orden. La referencia de lo que ejecutaste en esta lección. - Caching dependencies to speed up workflows — GitHub Actions — cómo funciona
actions/cache, la clave, el hash de archivos y la invalidación. La página que explica por quéhashFilesmantiene el caché honesto. actions/setup-python: caching packages — la opcióncache: pipque usamos como forma simple, con sucache-dependency-path. El caché de dependencias en dos líneas.- Speeding up your tests — documentación de pytest — buenas prácticas sobre rendimiento de la suite, incluyendo cuándo el paralelismo ayuda y cuándo la sobrecarga no vale la pena. El respaldo de la regla "mide antes de optimizar".