The KV Cache
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
kv-cache.ts
import type { Tracer } from "../trace";import { Kernels } from "./kernels";import { SelfAttention } from "./self-attention";/** Keep every token's key and value, or project the whole prefix again at every step. */export type DecodeMode = "cache" | "recompute";export interface DecodeResult { /** weights[query][key] over the keys at or before the query: a lower triangle, each row summing to one. */ weights: number[][]; /** One mixed value vector per decoded token. */ outputs: number[][]; /** Multiply-adds spent at each step, by the run's own ledger. */ work: number[]; /** The ledger's sum over every step. */ total: number; /** What the same run costs without a cache, by the closed form. */ naiveTotal: number;}/** * Causal decoding one token at a time over a self-attention's public * weights. Every step projects the new token into a query, a key, and a * value, scores the query against the key of every token so far, and * blends their values. The keys and values of earlier tokens never * change, because each one depends only on its own vector and the fixed * matrices, so a cache keeps them and a step projects one token; the * query is used once and is never kept. Without the cache the same * step projects the whole prefix again. A ledger counts multiply-adds: * a vector through a d by d matrix is d², a dot product or a blend over * n keys is n·d. * * Time per step: O(d² + n·d) with the cache, O(n·d²) without. * Space: O(n·d) for the cache, whatever the mode spends per step. */export class KvCache { readonly attention: SelfAttention; private ledger = 0; constructor( readonly dim: number, readonly mode: DecodeMode = "cache", seed = 1, private readonly tracer?: Tracer, ) { this.attention = new SelfAttention(dim, "identity", seed); } /** One d-vector through a d by d matrix: d² multiply-adds on the step's ledger. */ private project(m: readonly (readonly number[])[], x: readonly number[]): number[] { this.ledger += this.dim * this.dim; return m.map((row) => row.reduce((s, weight, i) => s + weight * (x[i] ?? 0), 0)); } decode(inputs: readonly (readonly number[])[]): DecodeResult { if (inputs.some((x) => x.length !== this.dim)) throw new RangeError(`every input must have ${this.dim} numbers`); const scale = 1 / Math.sqrt(this.dim); const k: number[][] = []; const keyCache: number[][] = []; const v: number[][] = []; const valueCache: number[][] = []; const weights: number[][] = []; const outputs: number[][] = []; const work: number[] = []; let naiveTotal = 0; for (let step = 0; step < inputs.length; step++) { this.ledger = 0; if (this.mode === "recompute") { // Nothing survives a step without the cache: the list is rebuilt from the first token. k.length = 0; keyCache.length = 0; v.length = 0; valueCache.length = 0; } let q: number[] = []; let queryVector: number[] = []; for (let index = k.length; index <= step; index++) { for (let index = keyCache.length; index <= step; index++) { const x = inputs[index]!; const recomputed = index < step; const key = this.project(this.attention.wk, x); const value = this.project(this.attention.wv, x); // Only the newest token needs a query: nothing asks on behalf of an earlier one again. const query = recomputed ? null : this.project(this.attention.wq, x); if (query) q = query; if (query) queryVector = query; k.push(key); keyCache.push(key); v.push(value); valueCache.push(value); // ▸ a token's vector through the fixed matrices; an old one needs no query this.tracer?.emit("project", { step, index, recomputed, query: query ? [...query] : null, key: [...key], value: [...value] }); } // ▸ the new key and value join the cache for every later step if (this.mode === "cache") this.tracer?.emit("append", { step, entries: k.length }); if (this.mode === "cache") this.tracer?.emit("append", { step, entries: keyCache.length }); const scores: number[] = []; for (let index = 0; index < k.length; index++) { for (let index = 0; index < keyCache.length; index++) { const dot = q.reduce((s, value, i) => s + value * k[index]![i]!, 0); const dot = queryVector.reduce((s, value, i) => s + value * keyCache[index]![i]!, 0); this.ledger += this.dim; const scaled = dot * scale; scores.push(scaled); // ▸ the new query against one cached key, scaled down by root d this.tracer?.emit("score", { query: step, key: index, dot, scaled }); } const row = Kernels.softmax(scores); const top = row.indexOf(Math.max(...row)); weights.push(row); // ▸ the scores over the tokens so far become weights that sum to one this.tracer?.emit("softmax", { query: step, top, weights: [...row] }); const output = Array.from({ length: this.dim }, (_, i) => row.reduce((s, w, index) => s + w * v[index]![i]!, 0)); const output = Array.from({ length: this.dim }, (_, i) => row.reduce((s, w, index) => s + w * valueCache[index]![i]!, 0)); this.ledger += k.length * this.dim; this.ledger += keyCache.length * this.dim; outputs.push(output); // ▸ the output is the cached values blended by those weights this.tracer?.emit("mix", { query: step, output: [...output] }); const cachedWork = 3 * this.dim * this.dim + 2 * (step + 1) * this.dim; const naiveWork = (2 * (step + 1) + 1) * this.dim * this.dim + 2 * (step + 1) * this.dim; work.push(this.ledger); naiveTotal += naiveWork; // ▸ the step's multiply-adds beside what projecting everything again costs this.tracer?.emit("compare", { step, work: this.ledger, cachedWork, naiveWork, saved: naiveWork - this.ledger }); } return { weights, outputs, work, total: work.reduce((s, w) => s + w, 0), naiveTotal }; }}Every token decoded: 672 multiply-adds against 1,568 without a cache, 2.33 times less work, for 256 bytes of cached keys and values.