Skip to content

English · Español

Break — KV cache con off-by-one en el índice de posición; corrupción silenciosa

🇪🇸 El bug más sutil del KV cache: escribir K, V de la posición t en el slot t+1. La pérdida no explota — solo crece silenciosamente con la longitud de la secuencia. Lo causamos a propósito y mostramos el patrón que distingue "modelo subentrenado" de "cache corrupto".


Síntoma que verá Borja

Dos ejecuciones con el mismo checkpoint del modelo, mismos prompts, mismos ajustes de sampling:

  • Ejecución A (control): el caché escribe K, V para el token recién calculado en la posición \(t\).
  • Ejecución B (break): el caché escribe K, V para el token recién calculado en la posición \(t + 1\).

Ambas corren limpiamente — sin excepciones, sin NaN, sin crashes obvios. Los dashboards parecen estar bien para las primeras generaciones. Pero cuando puntúas sobre sondas de longitud variable:

Longitud de sonda CCR Ejecución A CCR Ejecución B
5 tokens 89% 89%
10 tokens 87% 82%
20 tokens 85% 71%
40 tokens 83% 52%
80 tokens 81% 34%

El patrón: para secuencias cortas el bug es invisible; para secuencias largas la precisión se desploma. A 80 tokens, la ejecución con bug es apenas mejor que el azar.

Si ingenuamente mides el CCR a la longitud de sonda por defecto de §A13 de 8 tokens, ambas ejecuciones reportan 88%. El bug escapa al arnés.

El break, mecánicamente

En src/miniinfer/kv_cache.py:

# Run A (control)
def append(self, layer_idx: int, k: Tensor, v: Tensor) -> None:
    pos = self.current_pos[layer_idx]
    self.k_cache[layer_idx, :, pos, :] = k
    self.v_cache[layer_idx, :, pos, :] = v
    self.current_pos[layer_idx] += 1

# Run B (break) — one line
def append(self, layer_idx: int, k: Tensor, v: Tensor) -> None:
    pos = self.current_pos[layer_idx]
    self.k_cache[layer_idx, :, pos + 1, :] = k   # <-- off-by-one
    self.v_cache[layer_idx, :, pos + 1, :] = v   # <-- off-by-one
    self.current_pos[layer_idx] += 1

El break entero es +1 en dos sitios. El código sigue pasando el type-check, sigue ejecutándose, sigue terminando.

Por qué esto enseña el concepto

Cuando generas el token \(t+1\):

  1. El caché contiene K, V para las posiciones \(0, 1, ..., t-1\) (de la fase de prefill + pasos de decode previos).
  2. El modelo calcula K, V para la nueva posición \(t\) — basado en el token más recientemente emitido.
  3. Las K, V nuevas se guardan en el slot \(t\) del caché.
  4. La atención entonces calcula \(Q_t \cdot K_{0:t+1}\) — es decir, la nueva query atiende a todas las posiciones hasta \(t\).

Con el off-by-one:

  • El paso 3 guarda K, V en el slot \(t+1\) en vez de \(t\).
  • El slot \(t\) queda con datos obsoletos (lo que estuviera ahí antes — normalmente ceros en la inicialización, pero posiblemente sobras de una ejecución anterior).
  • La atención del paso 4 calcula \(Q_t \cdot K_{0:t+1}\) donde la entrada para la posición \(t\) está obsoleta.
  • La atención efectiva del modelo ve un hueco en su historial en la posición \(t\).

Para secuencias cortas, el hueco es tolerable — el modelo aún tiene 4-7 posiciones de historial correcto. La predicción del siguiente token es plausible.

Para secuencias largas, los huecos se acumulan: la posición \(t-1\) está obsoleta, la posición \(t-2\) está obsoleta (del paso de generación anterior), y así sucesivamente. Para la posición 80, todos los slots K, V históricos están obsoletos. El modelo está generando efectivamente con historial aleatorio.

La corrupción es silenciosa porque la atención es robusta ante pequeñas corrupciones del historial — el modelo promedia sobre las posiciones restantes. Pero la corrupción es acumulativa — cada paso de decode contribuye con un slot obsoleto más.

La escalera diagnóstica que Borja debería recorrer

  1. Primera comprobación: el prefill está bien — la precisión es la misma que sin caché para secuencias cortas. Así que el camino de código del prefill es correcto. El bug está en el camino de decode.
  2. Segunda comprobación: imprime el estado del caché tras generar 5 tokens. Compara con la línea base sin caché (re-ejecuta el mismo prompt sin el caché, captura todos los tensores K, V en cada paso). Deberían ser idénticos; no lo son. Específicamente, el caché tiene una de cada dos posiciones obsoleta.
  3. Tercera comprobación: la variable current_pos. Imprímela. Tras 5 generaciones, lee 5. Los slots escritos son... los slots 1, 2, 3, 4, 5. El slot 0 (la última posición del prefill) es correcto, pero el primer slot de decode está mal. Ahora encuentra el contenido del slot 0 — debería coincidir con las K, V del último token del prefill. Coincide. El contenido del slot 1 coincide con las K, V del primer token de decode — pero el slot 1 debería contener la penúltima posición del prefill. El desplazamiento es de uno.
  4. Diagnóstico: off-by-one. El +1 en la llamada cache.append ha desplazado cada escritura de decode un slot.

Reproductor

# Control
just phase-22-eval cache=correct probe_length=80

# Break
just phase-22-eval cache=broken probe_length=80

# Compare
just phase-22-eval-compare experiments/22-control experiments/22-break

El script compare imprime un gráfico de longitud vs CCR. Las dos curvas divergen en longitud ~10 y la curva rota colapsa en longitud 40.

Cascada de pistas

  1. (Suave) "Ejecuta la eval en múltiples longitudes de sonda. Dibuja CCR vs longitud. ¿Cuál es el patrón?"
  2. (Media) "El bug está en el camino de decode, no en el prefill. ¿Qué hace el código del caché durante un solo paso de decode?"
  3. (Directa) "Imprime los índices de slot K usados durante los primeros 5 pasos de decode. Deberían ser T_prefill, T_prefill+1, .... ¿Qué ves?"

Fix

Cambiar pos + 1 por pos en kv_cache.append(). Re-ejecutar, confirmar que la curva CCR-vs-longitud coincide con el control.

Lo que hace este break educativo

Este es el bug paradigmático de corrupción silenciosa. El compilador no lo caza (los tipos están bien). Los unit tests pasan (la mayoría de unit tests comprueban secuencias de 5 tokens). El dashboard no chilla (sin NaN, sin spike). Solo aparece bajo un régimen específico de evaluación (secuencias largas).

Lección de producción: cuando implementes un KV cache, tu suite de tests debe incluir un test de equivalencia con secuencias largas: generar \(N = 100\) tokens con y sin caché, afirmar que las salidas son idénticas. Este único test caza el off-by-one, el desajuste de dtype, y el flip de dim-batch. La lab/02-correctness-test.md de la Fase 22 lo convierte en el eje central.

Lo que este break NO es

  • No es un bug del modelo.
  • No es un bug de precisión numérica.
  • No es un bug del tokenizer.

Es un bug de indexación en inferencia con estado. La categoría que cuesta más horas-ingeniero por bug porque los síntomas son silenciosos y el descubrimiento requiere el régimen de eval correcto.

Referencias cruzadas

  • theory/05-mini-gpt-memory-worked-example.md — la matemática que se está indexando.
  • lab/02-correctness-test.md — el test de equivalencia que caza esto en producción.
  • theory/01-prefill-vs-decode.md — el límite prefill/decode que el bug cruza.