Módulo 7: Analizar resultados y CI
4. Detectar una regresión de rendimiento
Descripción
Una regresión de rendimiento es cuando algo que antes era rápido se vuelve más lento tras un cambio. No es un bug de corrección —la respuesta sigue siendo correcta—, es un bug de velocidad: el mismo endpoint, con la misma lógica, ahora tarda más. Y tiene una característica que lo hace escurridizo: no se puede detectar mirando una sola corrida. Un p95 de 61 ms puede estar perfectamente dentro de tu SLO y aun así ser una regresión, si la semana pasada era 6 ms. Para atraparla necesitas dos corridas —una baseline (el rendimiento de antes) y una actual (después del cambio)— y compararlas. En esta lección construyes exactamente eso: un chequeo de regresión en Python que lee el p95 de dos results.json, calcula cuánto empeoró, y falla con un código de salida si superó un umbral relativo. Lo ves atrapar la regresión de /quote_slow (FAIL, exit code 1) y aprobar dos corridas equivalentes (PASS, exit code 0). Y entiendes por qué este chequeo relativo atrapa cosas que un threshold absoluto (módulo 5) deja pasar.
Conexión con el módulo: esta lección es el corazón de analizar. Consume los archivos que la lección 3 aprendió a exportar (el baseline y la actual) y los que la lección 2 aprendió a leer, y los compara. Reúsa el exit code del módulo 5 (sys.exit), pero para una regla nueva: no "¿es rápido?" (absoluto) sino "¿es más lento que antes?" (relativo). Lo que sigue (lección 5) es la pregunta natural después de detectar una regresión: "¿dónde se fue el tiempo?" —el cuello de botella—. Y la lección 6 pone este mismo chequeo en el pipeline de CI.
El velocímetro comparado con el de ayer, no con el límite de la carretera
Imagina que manejas siempre la misma ruta al trabajo. Hay dos preguntas distintas que te puedes hacer sobre tu velocidad. La primera: "¿voy por debajo del límite de la carretera (120 km/h)?". Es una regla absoluta: comparas tu velocidad contra un número fijo. La segunda: "¿voy más lento que de costumbre en este tramo?". Es una regla relativa: comparas tu velocidad de hoy contra tu velocidad histórica en el mismo lugar.
Las dos preguntas atrapan problemas distintos. Puedes ir a 40 km/h —muy por debajo del límite de 120, así que la regla absoluta dice "todo bien"— y sin embargo estar yendo la mitad de rápido que de costumbre, porque hay un accidente adelante. La regla absoluta no ve ese problema; la relativa sí. El threshold del módulo 5 es el límite de la carretera: "el p95 debe estar bajo 200 ms". El chequeo de regresión de esta lección es la comparación con tu tiempo de siempre: "el p95 de hoy no debe ser mucho peor que el de ayer". Necesitas los dos, porque atrapan cosas distintas —y una regresión de 6 a 61 ms es justo la que la regla absoluta deja pasar y la relativa atrapa—.
Qué es una regresión (y por qué necesita dos corridas)
Una regresión de rendimiento tiene tres rasgos que la definen:
- Es relativa, no absoluta. No se trata de cruzar un límite fijo, sino de empeorar respecto a un punto de referencia (el baseline). Un p95 de 61 ms no es "malo" en abstracto; es una regresión porque antes era 6 ms.
- La corrección se mantiene. El endpoint sigue devolviendo la respuesta correcta (
price_cents: 7500, error 0%). Si la respuesta fuera incorrecta, sería un bug funcional, que atrapa un test de corrección (E2E, unitario). La regresión es puramente de velocidad. - Viene de un cambio. Algo entre el baseline y la corrida actual la causó: un commit que añadió una llamada de red, quitó un índice, metió una consulta N+1. Detectarla temprano —en CI, antes de desplegar— es lo que evita que llegue a los usuarios.
Por el primer rasgo, una regresión no se puede ver en una corrida aislada. Necesitas el baseline. De ahí que la lección 3 (exportar) sea el prerrequisito: sin haber guardado el resultado de antes, no hay contra qué comparar. La regresión vive en la diferencia entre dos corridas, no en los números de una sola.
El chequeo de regresión (ejecutable)
Aquí está el chequeo que sí corre. Lee dos results.json —el baseline y el actual—, extrae el p95 de cada uno, calcula el cambio porcentual, y falla con sys.exit(1) si el p95 empeoró más de un umbral relativo que le pasas (por ejemplo, +20%):
"""Chequeo de regresion de rendimiento: compara dos corridas exportadas.
Lee el p95 de una corrida BASELINE y de una corrida ACTUAL (dos results.json) y
falla con exit code 1 si el p95 empeoro mas de un umbral relativo. Es un test de
regresion de performance: no pregunta "¿es rapido?" sino "¿es MAS LENTO que antes?".
Uso: python3.14 check_regression.py baseline.json actual.json MAX_INCREASE_PCT
"""
import json
import sys
baseline_path = sys.argv[1]
actual_path = sys.argv[2]
max_increase_pct = float(sys.argv[3]) if len(sys.argv) > 3 else 10.0
with open(baseline_path) as f:
baseline = json.load(f)
with open(actual_path) as f:
actual = json.load(f)
p95_base = baseline["latency_ms"]["p95"]
p95_now = actual["latency_ms"]["p95"]
delta = p95_now - p95_base
increase_pct = (delta / p95_base) * 100 if p95_base else 0.0
print("CHEQUEO DE REGRESION DE RENDIMIENTO (p95)")
print("-" * 56)
print(f"baseline ({baseline['label']:>8}) : p95 = {p95_base:.2f} ms")
print(f"actual ({actual['label']:>8}) : p95 = {p95_now:.2f} ms")
print(f"cambio : {delta:+.2f} ms ({increase_pct:+.1f}%)")
print(f"umbral permitido : +{max_increase_pct:.1f}%")
print("-" * 56)
if increase_pct > max_increase_pct:
print(f"REGRESION: el p95 subio de {p95_base:.2f} ms a {p95_now:.2f} ms "
f"(+{increase_pct:.1f}%, supera +{max_increase_pct:.1f}%)")
print("RESULTADO: FAIL (exit code 1)")
sys.exit(1)
else:
print(f"OK: el p95 no empeoro mas del umbral (+{increase_pct:.1f}% "
f"<= +{max_increase_pct:.1f}%)")
print("RESULTADO: PASS (exit code 0)")
sys.exit(0)
Tres decisiones que definen el chequeo:
- Compara p95, no promedio. El p95 es el que refleja la experiencia de la cola (módulo 3); una regresión suele golpear primero la cola. Podrías comparar también p99 o RPS; el p95 es el indicador más común de una regresión de latencia.
- El umbral es relativo (%), no absoluto (ms).
max_increase_pctes "cuánto más lento tolero respecto al baseline". Un umbral relativo se adapta: si el baseline sube legítimamente con el crecimiento del producto, el chequeo sigue midiendo la degradación relativa, no un número fijo que se queda obsoleto. Se deja un margen (no 0%) porque hay ruido natural entre corridas —el p95 varía unos milisegundos aunque el código no cambie—. - Falla con
sys.exit(1). El mismo mecanismo del módulo 5: un exit code distinto de cero es cómo un programa le dice "fallé" a un pipeline. La lección 6 lo conecta al CI.
El caso FAIL: la regresión de /quote_slow
Comparamos la baseline (endpoint rápido, exportada en la lección 3) contra la actual (endpoint lento), con un umbral de +20%. Salida real:
Qué esperar — el p95 pasó de 6.66 a 61.27 ms, un +820%: muy por encima del +20% permitido, así que REGRESIÓN y exit code 1:
$ python3.14 check_regression.py results_baseline.json results_actual.json 20
CHEQUEO DE REGRESION DE RENDIMIENTO (p95)
--------------------------------------------------------
baseline (baseline) : p95 = 6.66 ms
actual ( actual) : p95 = 61.27 ms
cambio : +54.61 ms (+820.0%)
umbral permitido : +20.0%
--------------------------------------------------------
REGRESION: el p95 subio de 6.66 ms a 61.27 ms (+820.0%, supera +20.0%)
RESULTADO: FAIL (exit code 1)
$ echo $?
1
El echo $? imprime 1: el shell recibió el código de salida del chequeo. Ese 1 es lo que un pipeline de CI usa para poner el build en rojo y bloquear el deploy —lo verás montado en la lección 6—.
El caso PASS: dos corridas equivalentes
Ahora comparamos la baseline contra otra corrida del mismo endpoint rápido (/quote), para ver que el chequeo no salta con falsas alarmas cuando el rendimiento no cambió. Salida real:
Qué esperar — dos corridas del endpoint rápido dan p95 casi idénticos (6.66 vs 6.71 ms), un +0.8% dentro del margen: OK y exit code 0:
$ python3.14 check_regression.py results_baseline.json results_baseline2.json 20
CHEQUEO DE REGRESION DE RENDIMIENTO (p95)
--------------------------------------------------------
baseline (baseline) : p95 = 6.66 ms
actual (baseline2) : p95 = 6.71 ms
cambio : +0.05 ms (+0.8%)
umbral permitido : +20.0%
--------------------------------------------------------
OK: el p95 no empeoro mas del umbral (+0.8% <= +20.0%)
RESULTADO: PASS (exit code 0)
$ echo $?
0
Ese +0.8% es el ruido natural entre dos corridas idénticas: el p95 nunca sale exactamente igual dos veces, porque el sistema operativo reparte la CPU de forma ligeramente distinta cada vez. El margen de +20% absorbe ese ruido y solo salta ante una degradación real. Ese equilibrio —lo bastante estricto para atrapar regresiones, lo bastante holgado para no saltar con el ruido— es la parte de arte de un chequeo de regresión.
Regresión relativa vs threshold absoluto: por qué necesitas los dos
Este es el punto que hace valiosa la lección. En la lección 2, el analizador juzgó la corrida degradada (p95 = 61.27 ms) contra un SLO absoluto de p95 < 200 ms y dijo PASS —61 está por debajo de 200—. En esta lección, el chequeo de regresión juzgó la misma corrida contra el baseline y dijo FAIL —61 es 9x peor que 6.66—. La misma corrida, dos veredictos opuestos, porque responden preguntas distintas:
| Threshold absoluto (M5) | Chequeo de regresión (M7) | |
|---|---|---|
| Pregunta | ¿Está por debajo del SLO? | ¿Es más lento que antes? |
| Compara contra | Un número fijo (200 ms) | El baseline (la corrida anterior) |
| Atrapa | Cruzar el límite del SLO | Degradarse, aunque siga bajo el límite |
| Deja pasar | Una regresión que aún no cruza el SLO | Un p95 malo que siempre fue malo |
| Analogía | El límite de la carretera | Tu velocidad de siempre |
Ninguno reemplaza al otro. El threshold absoluto protege el compromiso con el usuario (el SLO); si lo quitas, un p95 que siempre fue lento pasaría desapercibido. El chequeo de regresión protege contra la degradación gradual; si lo quitas, el rendimiento se erosiona commit a commit y solo te enteras cuando por fin cruza el SLO —momento en el que ya hay muchos cambios candidatos y cuesta encontrar el culpable—. Un pipeline maduro corre los dos: el SLO como suelo, la regresión como alarma temprana.
Errores comunes
Intentar detectar una regresión con una sola corrida. Qué pasa: se mira un p95 de 61 ms y se pregunta "¿esto es una regresión?" sin tener con qué comparar. Por qué pasa: se olvida que la regresión es relativa. Cómo detectarlo: si no tienes el p95 de antes (el baseline), no puedes responder. Cómo corregirlo: guarda (exporta) cada corrida, y compara siempre contra un baseline —la lección 3 es el prerrequisito de esta—.
Poner el umbral de regresión en 0%. Qué pasa: se exige que el p95 no suba nada, y el chequeo falla constantemente por el ruido natural entre corridas. Por qué pasa: no se contempla que el p95 varía unos milisegundos aunque el código no cambie. Cómo detectarlo: FAILs frecuentes con incrementos de +1% o +2% que no corresponden a ningún cambio real. Cómo corregirlo: deja un margen (10–25% es típico) que absorba el ruido y solo salte ante una degradación de verdad. Un chequeo que grita siempre se acaba ignorando.
Comparar contra un baseline malo. Qué pasa: el "baseline" se tomó de una corrida en una máquina saturada, o con un perfil de carga distinto, y la comparación no tiene sentido. Por qué pasa: baseline y actual se midieron en condiciones distintas. Cómo detectarlo: resultados de regresión erráticos que no se corresponden con los cambios de código. Cómo corregirlo: mide baseline y actual en las mismas condiciones —misma máquina/entorno, mismo número de peticiones y concurrencia, mismo endpoint— para que la única variable sea el cambio de código. Comparar peras con peras.
Ejercicios
Ejercicio 1 — ¿Regresión o no? Umbral relativo: +20%. Para cada par (baseline → actual) di si el chequeo da PASS o FAIL. (a) 100 ms → 105 ms. (b) 100 ms → 130 ms. (c) 8 ms → 60 ms. (d) 200 ms → 180 ms.
Ver solución
- (a) PASS. +5% ≤ +20%. Dentro del margen (ruido normal).
- (b) FAIL. +30% > +20%. El p95 empeoró más de lo tolerado.
- (c) FAIL. +650% > +20%. Se multiplicó por 7.5; regresión enorme (aunque 60 ms pueda estar bajo el SLO absoluto).
- (d) PASS. El p95 bajó (−10%): mejoró, no empeoró. El chequeo solo falla ante aumentos.
Ejercicio 2 — El caso que dispara la lección. La corrida degradada tiene p95 = 61.27 ms. El SLO absoluto es p95 < 200 ms y el umbral de regresión es +20% sobre un baseline de 6.66 ms. (a) ¿Qué dice el threshold absoluto? (b) ¿Qué dice el chequeo de regresión? (c) ¿Por qué no se contradicen?
Ver solución
- (a) El threshold absoluto dice PASS: 61.27 ms < 200 ms, cumple el SLO.
- (b) El chequeo de regresión dice FAIL: 61.27 es +820% respecto a 6.66, muy por encima del +20% permitido.
- (c) Porque responden preguntas distintas. El absoluto pregunta "¿cumple el compromiso con el usuario (200 ms)?" —y sí lo cumple—. El relativo pregunta "¿empeoró respecto a antes?" —y sí empeoró, muchísimo—. No se contradicen: describen dos hechos ciertos a la vez. La corrida cumple el SLO de hoy y es una regresión respecto a ayer. Por eso un pipeline maduro corre los dos chequeos.
Ejercicio 3 — Extiende el chequeo. El chequeo actual solo compara el p95. Describe (en prosa, sin escribir todo el código) cómo lo ampliarías para que también falle si (a) el p99 empeoró más del umbral, y (b) el RPS cayó más de un cierto porcentaje. ¿Por qué el RPS se compara al revés que la latencia?
Ver solución
- (a) Leería
p99de los dos JSON (baseline["latency_ms"]["p99"]y el de actual), calcularía su incremento porcentual igual que con el p95, y añadiría una condición alif: si cualquiera de los dos (p95 o p99) supera el umbral, es regresión. Vigilar el p99 además del p95 atrapa regresiones que golpean solo la cola extrema. - (b) Para el RPS, el problema es una caída, no una subida: si el throughput baja mucho, el sistema empeoró. Calcularía el cambio porcentual del RPS y fallaría si bajó más del umbral (
rps_now < rps_base * (1 - max_drop)). Se compara al revés que la latencia porque en latencia "más alto = peor", mientras que en throughput "más bajo = peor": son métricas de sentido opuesto. Una regresión completa vigila las dos direcciones.
Resumen y siguiente paso
En esta lección construiste un chequeo de regresión de rendimiento: un programa que compara el p95 de dos corridas —un baseline y la actual— y falla con exit code si el p95 empeoró más de un umbral relativo. Entendiste que una regresión es relativa (empeorar respecto a un punto de referencia), mantiene la corrección (es un bug de velocidad, no de resultado) y viene de un cambio, y que por eso no se detecta en una corrida aislada: vive en la diferencia entre dos. Lo viste atrapar la regresión de /quote_slow (FAIL, exit 1) y aprobar dos corridas equivalentes (PASS, exit 0), con el ruido natural absorbido por el margen.
El punto central: el chequeo de regresión (relativo, "¿más lento que antes?") y el threshold absoluto (M5, "¿bajo el SLO?") atrapan cosas distintas y se necesitan los dos. La misma corrida degradada pasa el SLO de 200 ms y a la vez es una regresión de 9x —dos hechos ciertos que solo se ven con las dos herramientas—.
Antes de avanzar deberías poder: definir una regresión con sus tres rasgos; explicar por qué necesita un baseline; y contrastar el chequeo relativo con el threshold absoluto. Lo que sigue, en la lección 5, es la pregunta que nace justo después de detectar una regresión: ¿dónde se fue el tiempo? —cómo se investiga si el cuello de botella está en la app, la base de datos o la red— sin optimizar aquí, porque eso es el "después".
Recursos
- k6 — Thresholds — el contraste con esta lección: el threshold es el chequeo absoluto (SLO); la regresión es el relativo (vs baseline). Los dos usan un exit code para gatear.
- Google SRE Book — Service Level Objectives — por qué se vigilan a la vez el compromiso absoluto (el SLO) y la tendencia (la degradación relativa), y cómo se elige el margen.
sys.exit— documentación de Python — el mecanismo con el que el chequeo devuelve 0 (sin regresión) o 1 (regresión), el mismo idioma de exit codes que un CI usa para bloquear el deploy.json— documentación de Python —json.load, con el que el chequeo lee el p95 de los dosresults.jsonexportados en la lección 3.