wandres.dev
RENDIMIENTO EN CI · Evitar la regresión

La variabilidad de las medidas y cómo no volverse loco con ella

De dónde viene el ruido, por qué reducirlo a la mitad cuesta cuatro veces más, la aritmética de los falsos positivos que mata las comprobaciones, y las tres técnicas que sí funcionan.

⏱ 21 min

Un equipo pone un umbral de trescientos milisegundos sobre una medida cuyo ruido de fondo es de ochenta. La comprobación falla en peticiones de cambios que no tocan nada relacionado. La primera vez se investiga, la segunda se relanza el trabajo, y la tercera alguien la desactiva “temporalmente”. No es un fallo de disciplina: es la consecuencia matemática inevitable de poner una puerta determinista sobre una variable aleatoria, y se puede calcular de antemano.

🎯 Al terminar esta lección sabrás
  • Enumerar las fuentes de varianza y ordenarlas por magnitud.
  • Calcular cuántas pasadas hacen falta para una precisión dada y ver por qué es caro.
  • Calcular la tasa de falsos positivos de un conjunto de comprobaciones.
  • Aplicar el entrelazado, el intervalo por remuestreo y la confirmación consecutiva.

De dónde viene el ruido

Ordenadas por cuánto aportan en un entorno de integración continua típico:

La contención del procesador en la máquina. La causa dominante y la más fácil de subestimar. Un ejecutor compartido ejecuta trabajos de otros al mismo tiempo, y la velocidad efectiva de tu medida depende de qué estén haciendo ellos. Puede introducir variaciones del treinta por ciento entre ejecuciones del mismo commit.

La gestión de frecuencia del procesador. Las máquinas modernas suben y bajan la frecuencia según la carga y la temperatura. La primera medida tras un rato de reposo es más rápida que la quinta seguida.

El recolector de basura. Sus pausas caen en momentos distintos en cada ejecución. Una pausa que cae dentro de una tarea larga la alarga; en otra ejecución cae entre dos y no se nota.

El estado de las cachés. Del sistema de ficheros, del código compilado del navegador, del DNS. La primera ejecución es sistemáticamente distinta.

El contenido, si no está fijado. Terceros que cambian, anuncios distintos, pruebas A/B activas, datos de una API que devuelve más elementos hoy. Si tu medición toca la red externa, esta causa domina a todas las demás.

Y el ruido no se reparte por igual entre métricas, cosa que casi nadie tiene en cuenta al poner umbrales:

Métrica Ruido relativo típico Motivo
Tamaño de los paquetes 0 % Determinista
Primer pintado con contenido 3 a 8 % Dominado por red, bastante estable
Pintado del elemento más grande 5 a 15 % Depende de red y de CPU
Tiempo de bloqueo total 15 a 40 % Suma de colas de tareas largas
Desplazamiento acumulado bimodal O es cero o es un salto entero

El tiempo de bloqueo total merece una explicación aparte porque es la métrica que más falsos positivos genera y la que más se usa como puerta. Su definición suma el exceso de cada tarea por encima de cincuenta milisegundos. Eso hace que sea muy sensible a la partición de las tareas: una tarea de 105 milisegundos aporta 55; si esa misma tarea se parte en dos de 52 y 53, aporta 5. Un cambio minúsculo en cuándo cede el hilo puede mover la métrica en un factor de diez sin que el usuario note absolutamente nada. Es una métrica excelente para diagnosticar y pésima para poner una puerta.

Y el desplazamiento acumulado en laboratorio es bimodal: si el salto ocurre, la métrica es grande; si por un milisegundo de diferencia el recurso llega antes, es cero. La media de esa distribución no representa a ninguna ejecución real.

Cuánto cuesta reducir el ruido

La aritmética es desalentadora y conviene conocerla antes de prometer precisión a nadie.

El error típico de la mediana de N muestras es aproximadamente proporcional a uno partido por la raíz de N. Es decir:

Para reducir la incertidumbre a la mitad
hacen falta cuatro veces mas pasadas.

  3 pasadas  -> incertidumbre relativa 1,00
  5 pasadas  -> 0,77
 12 pasadas  -> 0,50
 48 pasadas  -> 0,25

Con un ejemplo concreto. Si el tiempo de bloqueo tiene una desviación típica de 60 milisegundos, la mediana de cinco pasadas tiene un error típico de unos 34 milisegundos, y su intervalo de confianza del noventa y cinco por ciento es de unos ±67. Es decir, con cinco pasadas no puedes distinguir de forma fiable una regresión menor de unos setenta milisegundos. Detectar veinte requeriría del orden de sesenta pasadas, que a treinta segundos cada una son media hora de máquina por petición de cambios. Nadie va a pagar eso.

La conclusión no es rendirse: es reconocer que hay un suelo de detección, calcularlo, y poner los umbrales por encima de él. Un umbral por debajo del suelo de detección no detecta regresiones, produce ruido.

Medir tu suelo es trivial y hay que hacerlo antes de configurar nada:

# Diez ejecuciones del MISMO commit, sin cambiar una linea.
for i in $(seq 1 10); do
  npx lhci collect --numberOfRuns=5
  npx lhci assert --preset=lighthouse:no-pwa || true
done

Toma la mediana de cada una de las diez ejecuciones y mira el rango. Ese rango es tu ruido, y cualquier umbral tiene que estar cómodamente fuera de él.

La aritmética de los falsos positivos

Este cálculo es el que hay que enseñar a quien proponga añadir “unas cuantas comprobaciones más por si acaso”.

Si una comprobación falla por azar con probabilidad α, y tienes m comprobaciones independientes, la probabilidad de que al menos una falle sin motivo en una ejecución cualquiera es:

P(al menos un falso positivo) = 1 - (1 - alfa)^m

Los números:

Comprobaciones α = 5 % α = 1 % α = 0,2 %
1 5 % 1,0 % 0,2 %
5 23 % 4,9 % 1,0 %
10 40 % 9,6 % 2,0 %
20 64 % 18,2 % 3,9 %

Lee la fila de diez comprobaciones con umbrales razonablemente holgados: cuatro de cada diez peticiones de cambios fallan sin motivo. Con veinte comprobaciones, casi dos de cada tres. Ningún equipo aguanta eso, y la reacción no es afinar los umbrales sino desactivar el mecanismo entero, porque desde fuera parece que el mecanismo no funciona.

De ahí salen tres reglas de diseño.

Menos comprobaciones. Cada puerta adicional multiplica el riesgo. Cinco bien elegidas valen más que veinte exhaustivas. Que sea posible comprobar algo no es motivo para comprobarlo.

Umbrales por encima del suelo de detección. Un umbral que deja la tasa de falsos positivos por debajo del uno por ciento por comprobación, con cinco comprobaciones, da un cinco por ciento de ejecuciones con falsa alarma. Aceptable.

Un objetivo explícito de tasa de falsos positivos. Escríbelo: como mucho una falsa alarma cada veinte ejecuciones, en total. Con ese objetivo, los umbrales se derivan; sin él, se ponen a ojo y sale la fila de arriba.

Las tres técnicas que funcionan

Uno: entrelazar las dos versiones en la misma máquina. Es la técnica con mejor relación entre esfuerzo y resultado y la que menos gente usa. En lugar de comparar tu medida de hoy con una guardada de ayer, compila las dos versiones y mídelas alternando en la misma sesión: base, cambio, base, cambio. Como las dos sufren la misma deriva de la máquina, la deriva se cancela en la diferencia.

#!/usr/bin/env bash
set -euo pipefail

git worktree add /tmp/base origin/main
(cd /tmp/base && npm ci && npm run build && npm run preview -- --port 4001 &)
(npm run build && npm run preview -- --port 4002 &)
sleep 8

for i in $(seq 1 6); do
  npx lighthouse http://localhost:4001/ --quiet --output=json \
    --output-path="res/base-$i.json" --only-categories=performance
  npx lighthouse http://localhost:4002/ --quiet --output=json \
    --output-path="res/cambio-$i.json" --only-categories=performance
done

El efecto es grande: elimina de golpe la fuente de varianza dominante, que es la diferencia entre máquinas y entre momentos. Cuesta el doble de tiempo de ejecución y compensa con creces.

Dos: intervalo de confianza por remuestreo. En vez de comparar dos medianas y declarar una regresión si la diferencia pasa de X, calcula un intervalo para la diferencia. Si el intervalo contiene el cero, no has detectado nada. El remuestreo no exige suponer ninguna distribución, que es justo lo que quieres con datos tan asimétricos:

// Intervalo de confianza del 95 % para la diferencia de medianas.
function mediana(xs) {
  const s = [...xs].sort((a, b) => a - b);
  const m = s.length >> 1;
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}

function remuestrear(xs) {
  const out = new Array(xs.length);
  for (let i = 0; i < xs.length; i++) {
    out[i] = xs[(Math.random() * xs.length) | 0];
  }
  return out;
}

export function intervaloDeDiferencia(base, cambio, repeticiones = 5000) {
  const difs = new Array(repeticiones);
  for (let i = 0; i < repeticiones; i++) {
    difs[i] = mediana(remuestrear(cambio)) - mediana(remuestrear(base));
  }
  difs.sort((a, b) => a - b);
  const inf = difs[Math.floor(repeticiones * 0.025)];
  const sup = difs[Math.floor(repeticiones * 0.975)];
  return {
    diferencia: Math.round(mediana(cambio) - mediana(base)),
    inferior: Math.round(inf),
    superior: Math.round(sup),
    significativo: inf > 0 || sup < 0,
  };
}
const base   = [280, 310, 295, 340, 288, 302];
const cambio = [350, 372, 358, 401, 366, 355];
console.log(intervaloDeDiferencia(base, cambio));
// { diferencia: 66, inferior: 43, superior: 91, significativo: true }

Ese resultado se puede informar tal cual y es honesto: la regresión está entre 43 y 91 milisegundos con un noventa y cinco por ciento de confianza. Compara eso con “TBT: 366, umbral 300, fallo”.

Tres: exigir confirmación consecutiva. Para las alertas de tendencia sobre la rama principal, no alertes por un valor: alerta cuando K ejecuciones seguidas estén por encima. Con K igual a tres y una tasa de falso positivo del cinco por ciento por ejecución, la probabilidad de tres seguidas por azar es de uno entre ocho mil. Se pierde algo de rapidez en la detección y se gana una alerta que la gente cree.

El ruido no es un defecto de tu montaje que puedas eliminar: es una propiedad del fenómeno, y la ingeniería consiste en diseñar decisiones que lo toleren

Hay una etapa por la que pasa todo el mundo que monta esto por primera vez y que conviene saltarse, porque consume meses. Consiste en creer que el ruido es un problema de configuración y que con suficiente esfuerzo se puede eliminar: más pasadas, mejor ejecutor, fijar la versión del navegador, aislar la red, desactivar cosas del sistema. Todas esas medidas ayudan, todas merecen la pena, y ninguna cierra el asunto, porque el rendimiento de una página no es una cantidad con un valor verdadero que la medición estropea. Es una distribución. Cargar la misma página dos veces en la misma máquina son dos eventos genuinamente distintos, con el planificador en otro estado, la memoria en otra disposición y el recolector en otro punto de su ciclo. Preguntar cuánto tarda la página en cargar está tan mal planteado como preguntar cuánto mide una persona de una ciudad: la respuesta es una distribución, y solo cuando aceptas eso puedes empezar a hacer ingeniería. Aceptarlo cambia la naturaleza del trabajo de una forma que se nota en todas las decisiones. Dejas de perseguir el número exacto y empiezas a diseñar decisiones robustas a la incertidumbre, que es una disciplina distinta y bastante más interesante. Un umbral pasa a ser una hipótesis con una tasa de error que puedes calcular y que puedes elegir. Una regresión pasa a ser una afirmación con un intervalo. Una comprobación pasa a ser un instrumento con una sensibilidad conocida, y como conoces su sensibilidad, sabes qué tamaño de efecto es capaz de detectar y dejas de esperar de ella lo que no puede dar. Fíjate en que esto es exactamente el mismo salto conceptual que separa una métrica de laboratorio de un percentil de campo, que ya conoces: un percentil es la aceptación explícita de que los usuarios no tienen una experiencia, tienen una distribución de experiencias, y lo que decides es qué parte de esa distribución te comprometes a servir bien. Aquí es lo mismo aplicado a tus propias medidas. Y hay una consecuencia final que rara vez se dice y que ahorra mucha frustración: si un cambio produce una mejora tan pequeña que hace falta un montaje estadístico sofisticado para detectarla, es una mejora que a tus usuarios les da igual. Los cambios que de verdad importan —quitar doscientos kilobytes de JavaScript, eliminar una cascada de tres niveles, arreglar una imagen del elemento más grande que se descubría tarde— salen del ruido de forma tan aparatosa que se ven con tres pasadas y sin ninguna estadística. Cuando te encuentres construyendo maquinaria para detectar un tres por ciento, párate y pregúntate si el problema es la maquinaria o si es que estás optimizando lo que no toca.

⚔️ Cuantifica tu ruido
  1. Ejecuta diez veces la auditoría sobre el mismo commit sin cambiar nada y anota el rango de cada métrica. Ese es tu suelo.
  2. Calcula la probabilidad de al menos un falso positivo con tu número actual de comprobaciones y su tasa individual.
  3. Monta el entrelazado con dos árboles de trabajo y compara la varianza de la diferencia con la del método secuencial.
  4. Implementa el intervalo por remuestreo y usa su salida en el comentario en lugar de un veredicto binario.
  5. Cambia tus alertas de tendencia para exigir tres ejecuciones consecutivas por encima del umbral.