Módulo 5: Rápido y en paralelo — caché y paralelismo

7. El trade-off velocidad/costo

Descripción

Las lecciones anteriores te dieron dos palancas para acelerar el CI y una condición para usarlas. Podrías salir de aquí pensando "más paralelismo siempre es mejor: subo -n hasta el tope y listo". Esta lección desactiva esa idea, porque el paralelismo tiene un precio y su beneficio no crece para siempre. Cada worker que abres consume un núcleo de CPU; en tu laptop eso se traduce en que la máquina se pone lenta para todo lo demás, y en el CI se traduce, muy literalmente, en dinero: los runners con más núcleos cuestan más por minuto. Y —esto es lo que sorprende— pasado cierto punto, sumar workers casi no acelera. Ignorar esto lleva a pagar el doble por un 10% de mejora.

Al terminar vas a ver la curva real del retorno del paralelismo, medida sobre la suite de Reservo: cómo el tiempo baja rápido al principio (-n 2, -n 4) y luego se aplana (-n 8, -n 12), de modo que los últimos workers aportan cada vez menos. Vas a entender por qué la matriz del módulo 4 multiplica el costo cuando le sumas paralelismo, cómo decidir cuánto paralelizar sopesando velocidad contra recursos, y por qué las dos palancas del módulo no son iguales de baratas: la caché casi siempre paga (ahorra sin costarte nada real), mientras el paralelismo se sopesa. La regla que te llevas es la del módulo entero, ahora con números: mide dónde duele, paraleliza lo que bloquea, no todo por reflejo.

Conexión con el módulo: esta lección cierra el arco técnico. La 3 (caché) y la 4 (paralelismo) te dieron las herramientas; la 5 (dividir) y la 6 (aislar) te dieron cómo aplicarlas bien; esta te da el criterio económico para decidir cuánto. Se apoya directo en la lección 4 —retoma sus tres razones de por qué el speedup no es infinito y las convierte en una curva de costo— y en el módulo 4 —la matriz, que multiplica todo—. No introduce herramientas nuevas: es la lección de la decisión, como la 7 del módulo 4 lo fue para la matriz. La frontera se mantiene: aquí decidimos cuánto gastar en velocidad; la estrategia de qué tests tener es de otra guía.

El equipo de mudanza: más cargadores, ¿siempre más rápido?

Imagina que contratas cargadores para mudar una casa. Con un cargador, la mudanza tarda ocho horas. Contratas un segundo: ahora son dos trabajando a la vez, y terminan en poco más de cuatro —casi la mitad—. Un tercero y un cuarto: bajan a unas dos horas y media. Vas bien. Pero sigues sumando: con ocho cargadores, terminan en dos horas; con doce, en una hora y cincuenta minutos. Los últimos cuatro apenas movieron la aguja, y les pagas a los doce por hora.

¿Qué pasó? Al principio, cada cargador extra hacía una diferencia enorme, porque había mucho trabajo esperando y pocas manos. Pero llega un punto donde los cargadores empiezan a estorbarse —se cruzan en el pasillo, esperan turno en la puerta, hay una sola escalera— y donde, simplemente, ya no queda tanto trabajo por repartir. El cargador número doce cobra completo pero aporta un minuto. Contratar doce cuando ocho terminaban casi igual es pagar de más por casi nada.

El paralelismo de tus tests es idéntico. Cada worker es un cargador: los primeros aceleran muchísimo, los últimos se estorban y aportan poco. Y en el CI, cada worker cuesta —CPU, y en runners grandes, dinero—. La pregunta no es "¿cuántos workers puedo abrir?" (todos los que quieras), sino "¿cuántos valen la pena antes de que el siguiente cobre completo y aporte un minuto?". Para responderla, hay que ver la curva.

El paralelismo tiene retornos decrecientes: los primeros workers aceleran mucho, los últimos casi nada, y todos cuestan. La decisión no es "el máximo de workers", sino "el punto donde el siguiente worker ya no paga su costo".

La curva real, medida

Aquí está la curva del retorno, medida de verdad sobre los doce tests lentos de Reservo (cada uno medio segundo), en la máquina de la guía (12 núcleos), con Python 3.14.0. Corrí la misma selección -m slow variando el número de workers:

ComandoWorkersTiempo realSpeedup vs serial
pytest -m slow (serial)16.11 s1.0×
pytest -m slow -n 223.28 s1.9×
pytest -m slow -n 441.83 s3.3×
pytest -m slow -n 881.49 s4.1×
pytest -m slow -n auto121.17 s5.2×

Lee la columna del tiempo de arriba abajo y mira dónde cae y dónde se aplana:

  • De 1 a 2 workers: 6.11 → 3.28 s. Casi a la mitad. El segundo cargador rindió muchísimo.
  • De 2 a 4: 3.28 → 1.83 s. Otra gran caída, casi a la mitad de nuevo. Duplicar workers casi duplicó la velocidad.
  • De 4 a 8: 1.83 → 1.49 s. Duplicaste los workers (de 4 a 8) y solo ganaste ~0.34 s. El retorno ya se está aplanando.
  • De 8 a 12: 1.49 → 1.17 s. Sumaste cuatro workers más y ganaste ~0.32 s. Los últimos cuatro cargadores aportaron un suspiro.

La forma es inequívoca: el tiempo baja rápido al principio y se aplana al final. Pasar de 1 a 4 workers te dio la mayor parte del beneficio (de 6.11 a 1.83, un 3.3×); pasar de 4 a 12 —el triple de workers— solo recortó de 1.83 a 1.17. Si cada worker costara dinero, los primeros cuatro serían una ganga y los últimos ocho, un lujo caro. Esa curva —mucho retorno al principio, poco al final— es la ley del paralelismo, y es la razón por la que "el máximo de workers" casi nunca es la respuesta correcta.

Cada fila de esa tabla es una corrida real. Así se ve, por ejemplo, la de -n 8 —ocho workers para los doce tests lentos—, con su header a la vista:

python -m pytest -m slow -n 8

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: 8/8 workers
8 workers [12 items]

............                                                             [100%]
============================== 12 passed in 1.51s ==============================

8 workers [12 items]: ocho workers repartiéndose doce tests, así que a dos de ellos les toca un segundo test —ahí está el reparto imperfecto que impide el speedup ideal—. Una nota de honestidad sobre estas cifras: el tiempo de pared oscila una pizca entre corridas (aquí 1.51 s; en la medición de la tabla, 1.49 s), porque depende del arranque de los procesos y de cómo el sistema operativo reparta la CPU en ese instante. Esa variación de centésimas no cambia la forma de la curva —lo que importa es la tendencia: cae fuerte al principio, se aplana al final—, pero es bueno saber que un benchmark de tiempo nunca da el número idéntico dos veces.

Hay dos razones detrás de la meseta, las mismas de la lección 4. Una: arrancar workers cuesta, y ese costo fijo pesa más cuanto menos trabajo queda por repartir. Dos: con doce tests, más de doce workers no ayudarían nada (no hay un treceavo test que darle al treceavo worker), y aun con doce, el reparto imperfecto deja a algún worker con dos tests mientras otros terminan y esperan. El speedup choca contra la cantidad de trabajo disponible y contra el costo de coordinar.

La matriz multiplica el costo

Ahora conecta esto con el módulo 4, porque juntos revelan el costo de verdad. Una matriz corre tu suite en varias combinaciones —tres versiones de Python, digamos—. El paralelismo abre varios workers dentro de cada corrida. Los dos se multiplican.

Piénsalo: una matriz de 3 versiones × 3 sistemas operativos son 9 celdas, cada una una corrida completa de la suite. Si cada celda corre con -n auto en un runner de, digamos, 4 núcleos, estás usando 9 × 4 = 36 "worker-corridas" de CPU por cada push. En minutos facturados, eso puede ser el orden de magnitud entre "el CI cuesta poco" y "el CI es una línea seria en la factura de la nube". El paralelismo dentro de cada celda acelera esa celda, sí —lo cual está bien, porque una matriz lenta es doblemente molesta—, pero el total de recursos consumidos es el producto de las dos dimensiones.

La consecuencia práctica no es "no uses matriz ni paralelismo" —los dos son valiosos—, sino sé consciente del producto. La lección 7 del módulo 4 ya te enseñó a recortar la matriz a lo que de verdad importa (prueba lo que envías más lo que prometes soportar, y nada más); esta lección le suma la otra mitad: dentro de cada celda, paraleliza lo suficiente para no bloquear, no el máximo posible. Una matriz mínima con un paralelismo sensato cuesta una fracción de una matriz inflada con -n auto en todo, y protege casi lo mismo.

Las dos palancas no cuestan igual

Aquí está una asimetría que conviene grabar, porque cambia el orden en que enciendes las palancas. Caché y paralelismo no son igual de caros.

La caché casi siempre paga, y su costo es casi nulo. Guardar y restaurar unos megabytes de dependencias es barato, y el ahorro —no rebajar ni reinstalar en cada corrida— es puro. No consume CPU extra durante los tests, no te obliga a un runner más grande, no tiene retornos decrecientes: o hay cache hit (ahorras) o hay miss (no ahorras esta vez, pero tampoco pierdes). Salvo casos raros (cachés enormes que tardan más en restaurarse que en reinstalar), encender la caché es una decisión fácil: hazlo casi siempre. Es la primera palanca, la de menor riesgo.

El paralelismo se sopesa. Acelera de verdad, pero consume CPU proporcional a los workers, tiene retornos decrecientes (la curva de arriba), y en el CI se traduce en costo. No es "enciéndelo al máximo y olvídate"; es "mide cuánto necesitas y detente donde el retorno se aplana". -n auto es un gran valor por defecto —exprime lo que hay sin que claves un número—, pero en un runner compartido o pagado por núcleo, un -n 4 deliberado que capture la mayor parte del speedup a un tercio del costo puede ser la mejor decisión de ingeniería.

Por eso el orden recomendado es: primero la caché (barata, casi siempre paga), después el paralelismo (potente, pero medido). Si tu CI está lento, cachea las dependencias sin pensarlo mucho, y luego mira la curva para decidir cuántos workers valen la pena.

Cuándo -n auto es de más

-n auto no siempre es la respuesta. Tres casos donde conviene otra cosa:

Una suite ya rápida. Si tu suite corre en 0.2 s en serie, -n auto la hará más lenta, porque arrancar doce workers cuesta más que los 0.2 s que ahorras. Aquí la respuesta es no paralelizar: serial es lo correcto. El paralelismo paga cuando hay bastante trabajo; la suite instantánea no lo tiene.

Un runner compartido o con presupuesto. Si el CI corre en runners que pagas por núcleo, o compartidos con otros equipos, -n auto acapara toda la máquina. Un -n 4 que capture, según la curva, un 3.3× a un tercio de los recursos puede ser el punto dulce: casi toda la velocidad, mucho menos costo. Mides tu propia curva y eliges el codo —donde la línea deja de caer fuerte—.

Tu laptop mientras trabajas. Localmente, -n auto con todos los núcleos puede congelar tu máquina para todo lo demás mientras corren los tests. Un -n 4 te deja núcleos libres para seguir programando. El número fijo es control sobre cuánta máquina cedes.

La regla que une los tres: -n auto es el valor por defecto para exprimir hardware dedicado, pero cuando el recurso es compartido, pagado o escaso, un número fijo elegido con la curva en la mano es más sabio. Medir tu curva —correr -n 2, -n 4, -n 8 y ver dónde se aplana— toma cinco minutos y te dice tu codo exacto.

Errores comunes

Subir -n al máximo creyendo que siempre acelera proporcional. Qué pasa: alguien pasa de -n 4 a -n 16 esperando cuadruplicar la velocidad, y el tiempo apenas baja —o sube, si la máquina no tiene 16 núcleos y los workers se pelean los que hay—. Por qué pasa: la intuición "el doble de workers, el doble de rápido" ignora los retornos decrecientes y el tope de núcleos. Cómo detectarlo: mide con -n 2/4/8; si el tiempo deja de bajar, encontraste tu meseta. Cómo corregirlo: elige el número en el codo de la curva, no el máximo; más allá del codo pagas workers que aportan un suspiro.

Combinar matriz grande con -n auto sin ver el producto. Qué pasa: alguien tiene una matriz de 9 celdas y le pone -n auto a cada una, y la factura de minutos de CI se dispara sin que nadie entienda por qué. Por qué pasa: se piensa en cada palanca por separado, no en su multiplicación. Cómo detectarlo: multiplica celdas × workers; si el producto es grande, ahí se va tu presupuesto. Cómo corregirlo: recorta la matriz a lo esencial (módulo 4) y usa un paralelismo medido por celda; el costo total es el producto, así que baja cualquiera de los dos factores.

Paralelizar antes de cachear. Qué pasa: alguien invierte esfuerzo en afinar -n mientras su CI sigue gastando dos minutos por corrida reinstalando dependencias que no cambiaron. Por qué pasa: el paralelismo es más "emocionante" y visible que la caché. Cómo detectarlo: mira cuánto se va en installing dependencies; si es una tajada grande, la caché te da más ahorro con menos esfuerzo que afinar workers. Cómo corregirlo: enciende la caché primero (barata, casi siempre paga) y luego ajusta el paralelismo. El orden importa: la palanca barata va antes que la que se sopesa.

Ejercicios

Ejercicio 1 — Encuentra el codo. Con la curva medida de Reservo (serial 6.11 s; -n 2 3.28 s; -n 4 1.83 s; -n 8 1.49 s; -n 12 1.17 s), un compañero pregunta cuántos workers le recomiendas si el CI cobra por núcleo. Responde con un número y justifícalo con la curva.

Ver solución

Recomendaría -n 4. Justificación con la curva: de 1 a 4 workers, el tiempo cae de 6.11 a 1.83 s —un 3.3×, la mayor parte del beneficio posible—. De 4 a 8 workers (el doble de recursos) solo se gana de 1.83 a 1.49 s (~0.34 s), y de 4 a 12 (el triple de recursos) solo hasta 1.17 s (~0.66 s). Es decir: los primeros cuatro workers capturan la mayoría del speedup, y los siguientes ocho aportan cada vez menos por un costo cada vez mayor.

Si el CI cobra por núcleo, -n 4 es el punto dulce: casi toda la velocidad (3.3× de 5.2× posible) a un tercio de los recursos de -n 12. Pagar por 12 workers para ganar 0.66 s sobre 4 es "contratar cuatro cargadores extra para terminar un minuto antes". Si el hardware fuera dedicado y gratis, -n auto (12) estaría bien —exprime lo que hay—; pero con costo por núcleo, el codo de la curva está alrededor de 4, y ahí conviene detenerse.

Ejercicio 2 — Calcula el producto matriz × paralelismo. Un equipo tiene una matriz de 3 versiones de Python × 3 sistemas operativos, y corre cada celda con -n auto en runners de 4 núcleos. (a) ¿Cuántas celdas hay? (b) ¿Cuántos "worker-corridas" de CPU consume un push? (c) ¿Qué dos cosas podría recortar para bajar el costo, y cuál recortarías primero?

Ver solución
  • (a) 3 versiones × 3 sistemas operativos = 9 celdas.
  • (b) Cada celda usa -n auto = 4 workers (en un runner de 4 núcleos), así que 9 × 4 = 36 worker-corridas de CPU por push. Ese es el producto de las dos dimensiones —matriz y paralelismo— que multiplica el costo.
  • (c) Podría recortar la matriz (menos celdas: quizá no necesita las 9, sino solo las versiones/SO que de verdad usa o promete soportar —módulo 4—) o el paralelismo por celda (bajar de -n auto a un -n menor si la curva muestra que 4 workers no aportan tanto). Recortaría la matriz primero, porque cada celda que quitas ahorra una corrida completa de la suite (el factor de mayor peso), y una matriz de 9 celdas para una app que corre en un solo entorno suele ser puro ruido (la lección 7 del módulo 4). Después afinaría el paralelismo dentro de las celdas que queden.

La idea: el costo es el producto de celdas × workers, así que bajar cualquiera de los dos factores ayuda —pero recortar la matriz suele dar el mayor ahorro con el menor riesgo, porque elimina corridas enteras.

Ejercicio 3 — Ordena las palancas. Un CI tarda 10 minutos: ~4 min reinstalando dependencias en cada corrida y ~6 min corriendo 2000 tests independientes en fila. Con lo que sabes del trade-off, ¿en qué orden aplicarías las palancas y por qué?

Ver solución

Aplicaría primero la caché, después el paralelismo. Razones:

  1. La caché primero, porque es barata y casi siempre paga. Los 4 minutos de reinstalar dependencias idénticas en cada corrida son puro desperdicio del sumidero 1: actions/cache con la clave del hash los baja casi a cero (los paquetes se restauran en segundos) sin costo real —no consume CPU extra ni obliga a un runner más grande—. Es la mayor mejora con el menor esfuerzo y riesgo. De 10 min bajaríamos a ~6.
  2. El paralelismo después, medido. Los 6 minutos de 2000 tests en fila son el sumidero 2, y los tests son independientes (el requisito de la lección 6 se cumple), así que -n los reparte. Pero aquí mido la curva antes de fijar el número: pruebo -n 4, -n 8, y elijo el codo, en vez de poner -n auto a ciegas —sobre todo si el runner se paga por núcleo—. Con un paralelismo sensato, esos 6 min pueden caer a uno o dos.

El orden importa: la caché es la palanca de bajo riesgo y alto retorno inmediato, así que va primero; el paralelismo es potente pero se sopesa (consume CPU, tiene retornos decrecientes), así que va después y con la curva en la mano. Resultado: de 10 minutos a ~3, sin borrar un solo test.

Resumen y siguiente paso

En esta lección le pusiste precio a la velocidad. Lo viste con el equipo de mudanza: los primeros cargadores aceleran muchísimo, los últimos se estorban y aportan un minuto, y a todos les pagas. Mediste la curva real del paralelismo sobre Reservo —serial 6.11 s, -n 2 3.28 s, -n 4 1.83 s, -n 8 1.49 s, -n 12 1.17 s— y viste su forma de ley: baja rápido al principio y se aplana al final, de modo que pasar de 1 a 4 workers da la mayor parte del beneficio y de 4 a 12 recorta apenas. Entendiste que la matriz multiplica el costo (celdas × workers), que las dos palancas no cuestan igual —la caché casi siempre paga, el paralelismo se sopesa—, y que -n auto es un buen default pero no siempre la respuesta: en runners compartidos, pagados o en tu laptop, un número fijo elegido con la curva es más sabio.

La regla que te llevas, ahora con números detrás: enciende la caché casi siempre (barata), paraleliza lo que bloquea con un número medido (no el máximo), y recorta la matriz a lo esencial —porque el costo total es el producto de todo—. Acelerar con cabeza es tan importante como acelerar.

Antes de avanzar deberías poder: describir la forma de la curva de retorno del paralelismo y por qué se aplana; encontrar el codo de una curva medida y recomendar un número de workers según el costo; calcular el producto matriz × paralelismo; y justificar por qué la caché se enciende antes que el paralelismo.

Lo que sigue, en la lección 8, es el mini-proyecto que junta el módulo entero: aceleras el CI de Reservo de punta a punta. Vas a escribir el workflow con actions/cache y pytest -n auto, medir el speedup real con la paridad local (serial vs -n auto, con tiempos), diagnosticar y arreglar el test que se rompía en paralelo —el Calendar compartido a fixture, verificado corriendo antes y después—, y escribir una nota de trade-off y alcance que refleje las decisiones de esta lección. Es el examen práctico de todo lo que aprendiste sobre hacer el CI rápido sin romperlo.

Recursos