<?xml version="1.0" encoding="UTF-8"?>
<rss  xmlns:atom="http://www.w3.org/2005/Atom" 
      xmlns:media="http://search.yahoo.com/mrss/" 
      xmlns:content="http://purl.org/rss/1.0/modules/content/" 
      xmlns:dc="http://purl.org/dc/elements/1.1/" 
      version="2.0">
<channel>
<title>Akarsh Jha</title>
<link>https://angry-kratos.github.io/</link>
<atom:link href="https://angry-kratos.github.io/index.xml" rel="self" type="application/rss+xml"/>
<description>LLM systems, from scratch: measured, not asserted.</description>
<generator>quarto-1.10.19</generator>
<lastBuildDate>Sat, 17 Oct 2026 00:00:00 GMT</lastBuildDate>
<item>
  <title>1000× Less Math, 6× Less Time</title>
  <dc:creator>Akarsh Jha</dc:creator>
  <link>https://angry-kratos.github.io/posts/2026-10-kv-cache/</link>
  <description><![CDATA[ 




<div class="callout callout-style-simple callout-note no-icon callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon no-icon"></i>
</div>
<div class="callout-title-container flex-fill">
<span class="screen-reader-only">Note</span>TL;DR
</div>
</div>
<div class="callout-body-container callout-body">
<ul>
<li>I wrote GPT-2 small (124M) in PyTorch and loaded OpenAI’s weights. My logits match Hugging Face’s to within <img src="https://latex.codecogs.com/png.latex?6.1%20%5Ctimes%2010%5E%7B-5%7D">, and cached and uncached generation produce identical tokens.</li>
<li>At 1,000 tokens of context, the KV cache does <strong>1,000× less arithmetic</strong> per generated token. The token comes out only <strong>6× faster</strong>: 29.69 ms → 4.95 ms. Over a full 1,016-token generation: <strong>14.66 s → 5.03 s (2.9×)</strong>.</li>
<li>The cached step takes 4.95 ms at <em>every</em> context length I tested. Its math would take about 8 µs at the GPU’s peak rate. Something other than math sets the floor.</li>
<li>The cache costs <strong>72 KiB per token</strong>. My causal masks quietly cost another <strong>48 MiB</strong>, two-thirds of a full cache, for nothing.</li>
</ul>
</div>
</div>
<section id="the-question" class="level2">
<h2 class="anchored" data-anchor-id="the-question">The question</h2>
<p>I had imagined the KV cache as an intricate inference trick, some clever mechanism buried deep inside a serving engine. Then I implemented one. The idea is almost embarrassingly simple: compute something once, keep it, use it again. It’s just storing things.</p>
<p>So how does something that ordinary make such a difference? And how <em>much</em> of a difference, exactly?</p>
<p>Every explanation I’d read stops at “the cache avoids recomputing past keys and values.” That’s true. It doesn’t say how much faster generation gets, or why, or what’s left once the cache is in. I wanted numbers. So I wrote GPT-2 small from scratch (about 150 lines of PyTorch), loaded the pretrained weights, built the cache myself, and timed every generated token from 8 tokens of context up to the model’s 1,024-token limit.</p>
<p>I thought the cache would be the complicated part. It wasn’t. The interesting complication was everything around it.</p>
<div class="callout callout-style-simple callout-tip no-icon callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon no-icon"></i>
</div>
<div class="callout-title-container flex-fill">
<span class="screen-reader-only">Tip</span>Setup
</div>
</div>
<div class="callout-body-container callout-body">
<p>GPT-2 small (124M), written from scratch, pretrained weights · NVIDIA A40 · fp32, TF32 off · batch size 1 · greedy decoding · prompt: <em>“Hello, I’m a language model,”</em> · every timing is bracketed by <code>torch.cuda.synchronize()</code>, after warmup, and reported as the <strong>median of 5 runs</strong> at each context length. These numbers describe this implementation on this hardware, not LLM inference in general.</p>
</div>
</div>
</section>
<section id="clue-1-the-naive-loop-throws-away-almost-everything-it-computes" class="level2">
<h2 class="anchored" data-anchor-id="clue-1-the-naive-loop-throws-away-almost-everything-it-computes">Clue 1: the naive loop throws away almost everything it computes</h2>
<p>A causal transformer is efficient to train because it handles a whole sequence in one forward pass. Every position produces a prediction for the token after it, so a sequence of length <img src="https://latex.codecogs.com/png.latex?T"> gives you <img src="https://latex.codecogs.com/png.latex?T"> training signals at once.</p>
<p>Generation turns that strength into waste. You only need the prediction at the <em>last</em> position:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1">logits <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> model(idx)                          <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># (B, T, 50257): a prediction at every position</span></span>
<span id="cb1-2">next_token <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> logits[:, <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>, :].argmax(<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>)     <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># keep the last one, throw away the rest</span></span></code></pre></div></div>
<p>Step 500 runs all 12 layers over 500 tokens to produce one new token. Step 501 runs them over 501 tokens, and 500 of those it processed a moment ago. Nothing in the model forgets the earlier work. The program just has nowhere to keep it.</p>
</section>
<section id="clue-2-the-old-keys-and-values-cant-change" class="level2">
<h2 class="anchored" data-anchor-id="clue-2-the-old-keys-and-values-cant-change">Clue 2: the old keys and values can’t change</h2>
<p>Keeping old work only makes sense if that work can’t go stale. I didn’t want to assume it, so I proved it.</p>
<p>Let <img src="https://latex.codecogs.com/png.latex?h_j%5E%7B%5Cell%7D"> be the hidden state at position <img src="https://latex.codecogs.com/png.latex?j"> after layer <img src="https://latex.codecogs.com/png.latex?%5Cell">. <strong>Claim:</strong> <img src="https://latex.codecogs.com/png.latex?h_j%5E%7B%5Cell%7D"> depends only on tokens <img src="https://latex.codecogs.com/png.latex?0,%20%5Cdots,%20j">.</p>
<ul>
<li><strong>Base case.</strong> <img src="https://latex.codecogs.com/png.latex?h_j%5E%7B0%7D%20=%20%5Cmathrm%7Bwte%7D%5Bx_j%5D%20+%20%5Cmathrm%7Bwpe%7D%5Bj%5D">, which depends on token <img src="https://latex.codecogs.com/png.latex?j"> alone.</li>
<li><strong>Inductive step.</strong> Assume the claim at layer <img src="https://latex.codecogs.com/png.latex?%5Cell">. LayerNorm and the MLP act on each position independently, so they can’t mix positions. Attention at <img src="https://latex.codecogs.com/png.latex?j"> reads only positions <img src="https://latex.codecogs.com/png.latex?m%20%5Cle%20j">, because the causal mask forbids anything else. Each <img src="https://latex.codecogs.com/png.latex?h_m%5E%7B%5Cell%7D"> depends only on tokens <img src="https://latex.codecogs.com/png.latex?%5Cle%20m%20%5Cle%20j">. So <img src="https://latex.codecogs.com/png.latex?h_j%5E%7B%5Cell+1%7D"> depends only on tokens <img src="https://latex.codecogs.com/png.latex?0,%20%5Cdots,%20j">. <img src="https://latex.codecogs.com/png.latex?%5Cblacksquare"></li>
</ul>
<p>Keys and values are linear projections of these states, <img src="https://latex.codecogs.com/png.latex?k_j%5E%7B%5Cell%7D%20=%20h_j%5E%7B%5Cell%7D%20W_K"> and <img src="https://latex.codecogs.com/png.latex?v_j%5E%7B%5Cell%7D%20=%20h_j%5E%7B%5Cell%7D%20W_V">. So <strong>the K and V of token <img src="https://latex.codecogs.com/png.latex?j">, in every layer, are fixed the moment token <img src="https://latex.codecogs.com/png.latex?j"> is processed.</strong> Recomputing them gives the same numbers, up to floating-point rounding.</p>
<p>That’s what made the cache click for me. The optimization doesn’t ask the model to compromise anything. It just stops a calculation whose answer is already known.</p>
<p>It also tells you exactly what to keep. Only attention reads other positions, and it reads them only through K and V. An old query is used once and never again. Old MLP outputs are fixed too, but nothing reads them again either. <strong>A KV cache stores exactly what future steps will re-read, and nothing more.</strong></p>
<p>The proof also says when it breaks. Drop the causal mask (bidirectional attention, as in BERT) and future tokens leak into past positions. Add any layer that mixes positions <em>outside</em> attention, such as a normalization whose statistics span the sequence, and old states change when new tokens arrive. Either one breaks the cache.</p>
</section>
<section id="clue-3-without-a-cache-a-floor-and-then-a-line" class="level2 page-columns page-full">
<h2 class="anchored" data-anchor-id="clue-3-without-a-cache-a-floor-and-then-a-line">Clue 3: without a cache, a floor and then a line</h2>
<div id="fig-baseline" class="quarto-float quarto-figure quarto-figure-center anchored">
<figure class="quarto-float quarto-float-fig figure">
<div aria-describedby="fig-baseline-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
<img src="https://angry-kratos.github.io/posts/2026-10-kv-cache/fig1.png" class="img-fluid figure-img">
</div>
<figcaption class="quarto-float-caption-bottom quarto-float-caption quarto-float-fig" id="fig-baseline-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
Figure&nbsp;1: Time to generate one token without a cache, against the number of tokens the model re-processes. Median of five runs; faint points are individual runs.
</figcaption>
</figure>
</div>
<p>Figure&nbsp;1 has two regimes. Up to about 200 tokens, each step costs a nearly constant <strong>~5 ms</strong>. After that, cost grows roughly linearly, to <strong>~30 ms</strong> at 1,024 tokens.</p>
<p>The linear part can be predicted on paper. A matmul against a weight matrix with <img src="https://latex.codecogs.com/png.latex?n"> parameters costs about <img src="https://latex.codecogs.com/png.latex?2n"> FLOPs per token. GPT-2’s Linear layers hold <img src="https://latex.codecogs.com/png.latex?N%20%5Capprox%20123.5%5Ctext%7BM%7D"> weights, counting the output projection that shares its weights with the token embedding. Attention adds two <img src="https://latex.codecogs.com/png.latex?t%20%5Ctimes%20t"> products per head. With <img src="https://latex.codecogs.com/png.latex?L%20=%2012"> layers and width <img src="https://latex.codecogs.com/png.latex?d%20=%20768">:</p>
<p><img src="https://latex.codecogs.com/png.latex?%0A%5Ctext%7BFLOPs%7D_%7B%5Ctext%7Bno%20cache%7D%7D(t)%20%5C;%5Capprox%5C;%20%5Cunderbrace%7B2Nt%7D_%7B%5Ctext%7BLinear%20layers%7D%7D%20%5C;+%5C;%20%5Cunderbrace%7B4Ld%5C,t%5E%7B2%7D%7D_%7B%5Ctext%7Battention%7D%7D%20%5C;%5Capprox%5C;%20247%5Ctext%7BM%7D%5Ccdot%20t%20%5C;+%5C;%2036%7B,%7D864%5Ccdot%20t%5E%7B2%7D%0A"></p>
<p>The two terms are equal at <img src="https://latex.codecogs.com/png.latex?t%20%5Capprox%206%7B,%7D700">, far past GPT-2’s 1,024-token limit. At <img src="https://latex.codecogs.com/png.latex?t%20=%201%7B,%7D000">, attention is about <strong>13%</strong> of the work. “Attention is quadratic” is the usual reason given for the cache, but it doesn’t describe this loop. At this model size, <strong>most of the waste is re-running the Linear layers on old tokens.</strong></p>
<p>This is also where two different quantities get mixed up. The cost <strong>per step</strong> is roughly linear in context. The <strong>total</strong> cost of generating <img src="https://latex.codecogs.com/png.latex?n"> tokens adds up those linear steps, so it grows quadratically.</p>
<p>Two more things in Figure&nbsp;1 need explaining:</p>
<ul>
<li><strong>The floor.</strong> At <img src="https://latex.codecogs.com/png.latex?t%20=%2010"> a step is about 2.5 GFLOPs, which the A40 can do in under 0.1 ms. The step takes 5 ms. Hold on to that; it comes back.</li>
<li><strong>The staircase.</strong> The line jumps at roughly 256, 448, 640, 768 and 896 tokens. My FLOP formula is smooth, so it can’t produce steps. GPU matmul kernels work in fixed-size tiles, and the library can switch kernels at certain shapes, so crossing a boundary can add a whole tile of work at once. That’s my hypothesis; I haven’t tested it.</li>
</ul>
<p>One easy saving falls out of the formula. The output projection (<img src="https://latex.codecogs.com/png.latex?768%20%5Ctimes%2050%7B,%7D257">) runs on <strong>every</strong> position, even though generation uses one. That’s about <strong>27% of each step’s FLOPs</strong> spent predicting tokens I already have.<sup>1</sup></p>
<div class="no-row-height column-margin column-container"><div id="fn1"><p><sup>1</sup>&nbsp;The fraction is <img src="https://latex.codecogs.com/png.latex?%5Cdfrac%7B2%20%5Ccdot%20768%20%5Ccdot%2050%7B,%7D257%20%5Ccdot%20(t-1)%7D%7B%5Ctext%7BFLOPs%7D_%7B%5Ctext%7Bno%20cache%7D%7D(t)%7D%20%5Capprox%2027%5C%25"> for any <img src="https://latex.codecogs.com/png.latex?t"> in this range. Slicing to the last position before the output projection removes it, with or without a cache.</p></div></div></section>
<section id="building-the-cache" class="level2">
<h2 class="anchored" data-anchor-id="building-the-cache">Building the cache</h2>
<p>The implementation is small. The mistakes are not.</p>
<p>In each attention layer: compute q, k and v for the <strong>new</strong> tokens only, append the new k and v <em>after</em> the cached ones, attend, and hand the updated cache back.</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb2-1"><span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">def</span> forward(<span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">self</span>, x, past_kv<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">None</span>):               <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># x: (B, T_new, C)</span></span>
<span id="cb2-2">    q, k, v <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">self</span>.c_attn(x).split(<span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">self</span>.n_embd, dim<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span>)</span>
<span id="cb2-3">    <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># reshape each to (B, n_head, T_new, head_dim)</span></span>
<span id="cb2-4"></span>
<span id="cb2-5">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> past_kv <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">is</span> <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">not</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">None</span>:</span>
<span id="cb2-6">        past_k, past_v <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> past_kv</span>
<span id="cb2-7">        k <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> torch.cat([past_k, k], dim<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span>)        <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># dim 2 is time: OLD first, then new</span></span>
<span id="cb2-8">        v <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> torch.cat([past_v, v], dim<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span>)</span>
<span id="cb2-9">    present <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> (k, v)</span>
<span id="cb2-10"></span>
<span id="cb2-11">    T_total <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> k.size(<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span>)</span>
<span id="cb2-12">    T_past <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> T_total <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span> T_new</span>
<span id="cb2-13">    att <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> q <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">@</span> k.transpose(<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>, <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span>) <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">/</span> head_dim<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">**</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">0.5</span>           <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># (B, nh, T_new, T_total)</span></span>
<span id="cb2-14">    mask <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">self</span>.mask[:, :, T_past:T_total, :T_total]</span>
<span id="cb2-15">    att <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> att.masked_fill(mask <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">==</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>, <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">float</span>(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"-inf"</span>))</span>
<span id="cb2-16">    ...</span>
<span id="cb2-17">    <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">return</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">self</span>.c_proj(y), present</span></code></pre></div></div>
<p>One mask slice covers both phases. During <strong>prefill</strong>, when the whole prompt goes in at once and <img src="https://latex.codecogs.com/png.latex?T_%7B%5Ctext%7Bpast%7D%7D%20=%200">, it’s the ordinary causal triangle. During <strong>decode</strong>, one new token, it’s a single row of ones: the new token may see everything before it, and itself.</p>
<p>The second change is easy to miss. Position embeddings must start where the cache ends:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb3" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb3-1">T_past <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span> <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">if</span> past_kvs <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">is</span> <span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">None</span> <span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">else</span> past_kvs[<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>][<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>].size(<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">2</span>)</span>
<span id="cb3-2">pos <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> torch.arange(T_past, T_past <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">+</span> T, device<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>idx.device)   <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># not arange(T)</span></span></code></pre></div></div>
<p>Generation becomes one prefill, then one token at a time:</p>
<div class="code-copy-outer-scaffold"><div class="sourceCode" id="cb4" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb4-1">logits, kvs <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> model(prompt)                       <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># prefill: fills the cache</span></span>
<span id="cb4-2"><span class="cf" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">for</span> _ <span class="kw" style="color: #003B4F;
background-color: null;
font-weight: bold;
font-style: inherit;">in</span> <span class="bu" style="color: null;
background-color: null;
font-style: inherit;">range</span>(max_new_tokens):</span>
<span id="cb4-3">    next_token <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> logits[:, <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>, :].argmax(<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span><span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>, keepdim<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>)</span>
<span id="cb4-4">    logits, kvs <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> model(next_token, kvs)          <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># decode: one token + cache</span></span></code></pre></div></div>
<p><strong>Before trusting any timing, I checked correctness.</strong> My logits match Hugging Face’s GPT-2 to a maximum absolute difference of <img src="https://latex.codecogs.com/png.latex?6.1%20%5Ctimes%2010%5E%7B-5%7D">, about ten float32 rounding steps at logit magnitudes near 100. Cached and uncached greedy generation produce identical tokens over 200 tokens. A fast cache that’s subtly wrong is worse than no cache, and as the bugs below show, passing tests can still miss things.</p>
</section>
<section id="the-prediction" class="level2">
<h2 class="anchored" data-anchor-id="the-prediction">The prediction</h2>
<p>With a cache, the Linear layers process one token instead of <img src="https://latex.codecogs.com/png.latex?t">, and attention compares one query against <img src="https://latex.codecogs.com/png.latex?t"> cached keys:</p>
<p><img src="https://latex.codecogs.com/png.latex?%0A%5Ctext%7BFLOPs%7D_%7B%5Ctext%7Bcached%7D%7D(t)%20%5C;%5Capprox%5C;%202N%20+%204Ld%5C,t%0A"></p>
<p>Put that next to the uncached formula and it falls out in one line:</p>
<p><img src="https://latex.codecogs.com/png.latex?%0A%5Ctext%7BFLOPs%7D_%7B%5Ctext%7Bno%20cache%7D%7D(t)%20%5C;%5Capprox%5C;%202Nt%20+%204Ld%5C,t%5E%7B2%7D%20%5C;=%5C;%20t%5Ccdot%5Cbig(2N%20+%204Ld%5C,t%5Cbig)%20%5C;=%5C;%20t%20%5Ccdot%20%5Ctext%7BFLOPs%7D_%7B%5Ctext%7Bcached%7D%7D(t)%0A"></p>
<p><strong>An uncached step does exactly <img src="https://latex.codecogs.com/png.latex?t"> times the arithmetic of a cached step.</strong> At <img src="https://latex.codecogs.com/png.latex?t%20=%201%7B,%7D000"> that’s 1,000× less work for the same output. If time followed FLOPs, generation at long context would be a thousand times faster.</p>
<p>It isn’t.</p>
</section>
<section id="the-result-6-not-1000" class="level2">
<h2 class="anchored" data-anchor-id="the-result-6-not-1000">The result: 6×, not 1,000×</h2>
<div id="fig-result" class="quarto-float quarto-figure quarto-figure-center anchored">
<figure class="quarto-float quarto-float-fig figure">
<div aria-describedby="fig-result-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
<img src="https://angry-kratos.github.io/posts/2026-10-kv-cache/fig2.png" class="img-fluid figure-img">
</div>
<figcaption class="quarto-float-caption-bottom quarto-float-caption quarto-float-fig" id="fig-result-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
Figure&nbsp;2: Per-token latency with and without the cache. The cached curve stays at 4.95 ms from 8 to 1,024 tokens. The 6.2× label is at the last point, t = 1,023.
</figcaption>
</figure>
</div>
<div id="tbl-speed" class="quarto-float quarto-figure quarto-figure-center anchored">
<figure class="quarto-float quarto-float-tbl figure">
<div aria-describedby="tbl-speed-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
<table class="table">
<colgroup>
<col style="width: 20%">
<col style="width: 20%">
<col style="width: 20%">
<col style="width: 20%">
<col style="width: 20%">
</colgroup>
<thead>
<tr class="header">
<th style="text-align: right;">context <img src="https://latex.codecogs.com/png.latex?t"></th>
<th style="text-align: right;">no cache</th>
<th style="text-align: right;">KV cache</th>
<th style="text-align: right;">measured speedup</th>
<th style="text-align: right;">FLOP reduction</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td style="text-align: right;">10</td>
<td style="text-align: right;">4.89 ms</td>
<td style="text-align: right;">4.94 ms</td>
<td style="text-align: right;">1.0×</td>
<td style="text-align: right;">10×</td>
</tr>
<tr class="even">
<td style="text-align: right;">100</td>
<td style="text-align: right;">5.20 ms</td>
<td style="text-align: right;">4.95 ms</td>
<td style="text-align: right;">1.1×</td>
<td style="text-align: right;">100×</td>
</tr>
<tr class="odd">
<td style="text-align: right;">500</td>
<td style="text-align: right;">12.70 ms</td>
<td style="text-align: right;">4.95 ms</td>
<td style="text-align: right;">2.6×</td>
<td style="text-align: right;">500×</td>
</tr>
<tr class="even">
<td style="text-align: right;">1,000</td>
<td style="text-align: right;">29.69 ms</td>
<td style="text-align: right;">4.95 ms</td>
<td style="text-align: right;"><strong>6.0×</strong></td>
<td style="text-align: right;"><strong>1,000×</strong></td>
</tr>
</tbody>
</table>
</div>
<figcaption class="quarto-float-caption-bottom quarto-float-caption quarto-float-tbl" id="tbl-speed-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
Table&nbsp;1: Per-token latency, median of 5 runs.
</figcaption>
</figure>
</div>
<p>The cached step costs <strong>4.95 ms at every context length</strong>. Not slowly rising, not roughly flat: the same, within noise, from 10 tokens to 1,000. At short context the cache buys nothing, because both versions sit on the same floor.</p>
<div id="fig-total" class="quarto-float quarto-figure quarto-figure-center anchored">
<figure class="quarto-float quarto-float-fig figure">
<div aria-describedby="fig-total-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
<img src="https://angry-kratos.github.io/posts/2026-10-kv-cache/fig3.png" class="img-fluid figure-img">
</div>
<figcaption class="quarto-float-caption-bottom quarto-float-caption quarto-float-fig" id="fig-total-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
Figure&nbsp;3: Total time to generate 1,016 tokens. Uncached, each step costs more than the last, so the total grows quadratically. Cached, each step costs the same, so the total grows linearly.
</figcaption>
</figure>
</div>
<p>End to end, generating 1,016 tokens takes <strong>14.66 s</strong> without the cache and <strong>5.03 s</strong> with it, a <strong>2.9×</strong> speedup (Figure&nbsp;3). That’s the number a user actually feels. It’s smaller than the per-token 6× because most steps happen at shorter contexts, where the two versions are closer.</p>
</section>
<section id="chasing-the-missing-167" class="level2">
<h2 class="anchored" data-anchor-id="chasing-the-missing-167">Chasing the missing 167×</h2>
<div id="fig-gap" class="quarto-float quarto-figure quarto-figure-center anchored">
<figure class="quarto-float quarto-float-fig figure">
<div aria-describedby="fig-gap-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
<img src="https://angry-kratos.github.io/posts/2026-10-kv-cache/fig4.png" class="img-fluid figure-img">
</div>
<figcaption class="quarto-float-caption-bottom quarto-float-caption quarto-float-fig" id="fig-gap-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
Figure&nbsp;4: How many times cheaper a cached step is, in estimated FLOPs (dashed) and in measured time (solid), log scale. Labels at t = 1,023.
</figcaption>
</figure>
</div>
<p>At 1,000 tokens: 1,000× less math, 6× less time. The ratio between those is about 167. That isn’t a measurement of anything. It’s the size of the thing I have to explain.</p>
<p>First question: how much arithmetic does each step get done per second? Estimated FLOPs per step, divided by measured step time, compared with the A40’s 37.4 TFLOPS fp32 peak:</p>
<div id="fig-throughput" class="quarto-float quarto-figure quarto-figure-center anchored">
<figure class="quarto-float quarto-float-fig figure">
<div aria-describedby="fig-throughput-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
<img src="https://angry-kratos.github.io/posts/2026-10-kv-cache/fig5.png" class="img-fluid figure-img">
</div>
<figcaption class="quarto-float-caption-bottom quarto-float-caption quarto-float-fig" id="fig-throughput-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
Figure&nbsp;5: Estimated FLOPs per step divided by step time, as a percentage of the A40’s fp32 peak (log scale). This is arithmetic throughput, not a direct measurement of GPU utilization.
</figcaption>
</figure>
</div>
<div id="tbl-throughput" class="quarto-float quarto-figure quarto-figure-center anchored">
<figure class="quarto-float quarto-float-tbl figure">
<div aria-describedby="tbl-throughput-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
<table class="table">
<thead>
<tr class="header">
<th style="text-align: right;">context <img src="https://latex.codecogs.com/png.latex?t"></th>
<th style="text-align: right;">no cache</th>
<th style="text-align: right;">% of peak</th>
<th style="text-align: right;">KV cache</th>
<th style="text-align: right;">% of peak</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td style="text-align: right;">10</td>
<td style="text-align: right;">0.51 TFLOPS</td>
<td style="text-align: right;">1.4%</td>
<td style="text-align: right;">0.050 TFLOPS</td>
<td style="text-align: right;">0.13%</td>
</tr>
<tr class="even">
<td style="text-align: right;">100</td>
<td style="text-align: right;">4.82 TFLOPS</td>
<td style="text-align: right;">12.9%</td>
<td style="text-align: right;">0.051 TFLOPS</td>
<td style="text-align: right;">0.14%</td>
</tr>
<tr class="odd">
<td style="text-align: right;">500</td>
<td style="text-align: right;">10.46 TFLOPS</td>
<td style="text-align: right;">28.0%</td>
<td style="text-align: right;">0.054 TFLOPS</td>
<td style="text-align: right;">0.14%</td>
</tr>
<tr class="even">
<td style="text-align: right;">1,000</td>
<td style="text-align: right;">9.56 TFLOPS</td>
<td style="text-align: right;">25.6%</td>
<td style="text-align: right;">0.057 TFLOPS</td>
<td style="text-align: right;">0.15%</td>
</tr>
</tbody>
</table>
</div>
<figcaption class="quarto-float-caption-bottom quarto-float-caption quarto-float-tbl" id="tbl-throughput-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
Table&nbsp;2: Achieved arithmetic throughput.
</figcaption>
</figure>
</div>
<p>A cached step at <img src="https://latex.codecogs.com/png.latex?t%20=%201%7B,%7D000"> is about 0.28 GFLOPs. At peak rate, that’s roughly <strong>8 µs</strong> of arithmetic, inside a step that takes <strong>4,950 µs</strong>.</p>
<p>That doesn’t prove the GPU is idle 99.8% of the time. Peak FLOPS is a ceiling no real kernel reaches, and small kernels can be limited by memory traffic before they’re limited by arithmetic. What it does prove is that arithmetic can’t explain this step’s cost. Something else is setting it.</p>
<p>My leading suspect is fixed per-step overhead. Each step, Python walks the forward pass and launches dozens of small GPU kernels per layer, one after another, each too small to keep the GPU busy. The evidence that fits:</p>
<ul>
<li>The cached step’s time <strong>doesn’t change with context</strong>, even though its attention work grows 100× from <img src="https://latex.codecogs.com/png.latex?t%20=%2010"> to <img src="https://latex.codecogs.com/png.latex?t%20=%201%7B,%7D000">.</li>
<li>The uncached loop sits on the <strong>same ~5 ms floor</strong> at short context, where it also does little math.</li>
</ul>
<p>What I don’t have yet is a profiler trace that pins it down. Until then, overhead is the leading suspect, not a confirmed cause. The test is to remove it: CUDA graphs capture a whole step and replay it with a single launch. If the floor drops, the suspect is confirmed.</p>
</section>
<section id="two-bugs-that-taught-me-how-to-test" class="level2">
<h2 class="anchored" data-anchor-id="two-bugs-that-taught-me-how-to-test">Two bugs that taught me how to test</h2>
<section id="the-cache-in-the-wrong-order-and-a-test-that-passed-anyway" class="level3">
<h3 class="anchored" data-anchor-id="the-cache-in-the-wrong-order-and-a-test-that-passed-anyway">The cache in the wrong order, and a test that passed anyway</h3>
<p>My first version did <code>torch.cat([k, past_k])</code>, new key first. In hindsight it’s plainly wrong. My single-token test compared cached against uncached outputs, and it passed with a maximum difference of <img src="https://latex.codecogs.com/png.latex?9%20%5Ctimes%2010%5E%7B-8%7D">.</p>
<p>It passed because of how attention works. During single-token decode, the new query may see every key, so the mask row is all ones and masks nothing. Attention is then a weighted average over a <em>set</em> of (key, value) pairs:</p>
<p><img src="https://latex.codecogs.com/png.latex?%0A%5Ctext%7Bout%7D%20=%20%5Csum_j%20%5Coperatorname%7Bsoftmax%7D_j%5C!%5Cleft(%5Cfrac%7Bq%20%5Ccdot%20k_j%7D%7B%5Csqrt%7Bd_h%7D%7D%5Cright)%20v_j%0A"></p>
<p>Reorder the keys and values <em>together</em>, and every weight still multiplies its own value. The sum doesn’t change. <strong>Attention doesn’t care about the order of keys. Only the mask does.</strong> Position isn’t lost either, because it was added to the input before <img src="https://latex.codecogs.com/png.latex?k"> was computed. Each key carries its position inside its numbers.</p>
<p>The bug shows up as soon as two new tokens go in at once with a cache, which is what speculative decoding does. The mask then assumes old keys come first, hides the wrong columns, and a token can see its own future. Anything that treats cache index as time breaks the same way, including sliding-window eviction and a preallocated cache. A two-token test caught it immediately.</p>
<p>That changed how I think about tests. “It passed” isn’t enough. The real question is: <strong>what kind of error could this test even catch?</strong></p>
</section>
<section id="wrong-positions-no-crash-just-nonsense" class="level3">
<h3 class="anchored" data-anchor-id="wrong-positions-no-crash-just-nonsense">Wrong positions: no crash, just nonsense</h3>
<p>If decode uses <code>arange(T)</code> instead of <code>arange(T_past, T_past + T)</code>, every new token is embedded as if it were at position 0. The shapes all work and the forward pass runs. Here’s what comes out:</p>
<pre class="text"><code>correct:  Hello, I'm a language model, not a programming language. I'm a language model. ...
wrong:    Hello, I'm a language model, not,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,</code></pre>
<p>The first new token is right, because it came from prefill, before the bug could matter. Then generation collapses. There’s something humbling about a bug that is completely legal tensor algebra and completely wrong modelling. A shape mismatch crashes. A wrong <em>value</em> in the right shape never does. That’s why the cached-vs-uncached equality check runs before any timing.</p>
</section>
</section>
<section id="memory-72-kib-per-token-every-token" class="level2">
<h2 class="anchored" data-anchor-id="memory-72-kib-per-token-every-token">Memory: 72 KiB per token, every token</h2>
<p>Per token, the cache holds one key and one value vector per layer:</p>
<p><img src="https://latex.codecogs.com/png.latex?%0A%5Ctext%7Bbytes%20per%20token%7D%20%5C;=%5C;%20%5Cunderbrace%7B2%7D_%7BK,%5C,V%7D%20%5Ctimes%20%5Cunderbrace%7B12%7D_%7BL%7D%20%5Ctimes%20%5Cunderbrace%7B768%7D_%7Bd%7D%20%5Ctimes%20%5Cunderbrace%7B4%7D_%7B%5Ctext%7Bfp32%7D%7D%20%5C;=%5C;%2073%7B,%7D728%5C%20%5Ctext%7BB%7D%20%5C;=%5C;%2072%5C%20%5Ctext%7BKiB%7D%0A"></p>
<p>Measuring that turned into its own small investigation. My first measurement subtracted the memory in use before generation from the memory in use after. Both numbers PyTorch offers came out <strong>above</strong> the formula, at every context length:</p>
<div id="tbl-mem-first" class="quarto-float quarto-figure quarto-figure-center anchored">
<figure class="quarto-float quarto-float-tbl figure">
<div aria-describedby="tbl-mem-first-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
<table class="table">
<thead>
<tr class="header">
<th style="text-align: right;">tokens</th>
<th style="text-align: right;">formula</th>
<th style="text-align: right;">requested by tensors</th>
<th style="text-align: right;">allocated</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td style="text-align: right;">64</td>
<td style="text-align: right;">4.5 MiB</td>
<td style="text-align: right;">12.6 MiB</td>
<td style="text-align: right;">12.6 MiB</td>
</tr>
<tr class="even">
<td style="text-align: right;">384</td>
<td style="text-align: right;">27.0 MiB</td>
<td style="text-align: right;">35.1 MiB</td>
<td style="text-align: right;">44.6 MiB</td>
</tr>
<tr class="odd">
<td style="text-align: right;">1,024</td>
<td style="text-align: right;">72.0 MiB</td>
<td style="text-align: right;">80.1 MiB</td>
<td style="text-align: right;">80.1 MiB</td>
</tr>
</tbody>
</table>
</div>
<figcaption class="quarto-float-caption-bottom quarto-float-caption quarto-float-tbl" id="tbl-mem-first-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
Table&nbsp;3: First attempt. Two different discrepancies.
</figcaption>
</figure>
</div>
<p>There are two discrepancies here, and they behave differently.</p>
<p><strong>Clue A: a constant offset.</strong> The bytes the tensors requested sit exactly 8,519,688 bytes above the formula at <em>every</em> point, from 64 tokens to 1,024. A constant doesn’t grow per token, so it isn’t cache. It’s something allocated once, after my baseline was taken. And 8,519,680 is a recognizable number: <img src="https://latex.codecogs.com/png.latex?2%20%5Ctimes%204%5C,%5Ctext%7BMiB%7D%20+%208%20%5Ctimes%2016%5C,%5Ctext%7BKiB%7D">, the default size of the cuBLAS workspace PyTorch uses on this GPU. PyTorch allocates it lazily, through its own allocator, on the first matrix multiply. My baseline was taken before the model had run a single matmul, so the workspace landed inside my measurement. The last 8 bytes are the one-element <code>int64</code> tensor holding the next token.</p>
<p>The fix is one warmup forward pass before taking the baseline. With that:</p>
<div id="fig-memory" class="quarto-float quarto-figure quarto-figure-center anchored">
<figure class="quarto-float quarto-float-fig figure">
<div aria-describedby="fig-memory-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
<img src="https://angry-kratos.github.io/posts/2026-10-kv-cache/fig6.png" class="img-fluid figure-img">
</div>
<figcaption class="quarto-float-caption-bottom quarto-float-caption quarto-float-fig" id="fig-memory-caption-0ceaefa1-69ba-4598-a22c-09a6ac19f8ca">
Figure&nbsp;6: KV cache size for one sequence, measured after a warmup pass: the formula (dashed), the bytes the cache tensors requested (filled), and the memory PyTorch’s caching allocator handed out (hollow).
</figcaption>
</figure>
</div>
<p><strong>Clue B: a bump.</strong> With the workspace accounted for, the requested bytes land on the formula line (Figure&nbsp;6). What’s left is the hump in <code>torch.cuda.memory_allocated()</code> between ~384 and ~640 tokens, peaking at ~9.5 MiB above what was requested and returning to the line at 704 tokens. Smaller allocator bumps also appear at 832 and 960 tokens. That’s PyTorch’s caching allocator handing out reusable blocks that can be larger than the request. It’s real memory the process holds, but it isn’t cache payload.</p>
<p>The lesson is the same one the bugs taught me, from another angle: a measurement is only as good as its baseline. Subtract “before” from “after,” and you measure everything that happened in between, not only the thing you meant to measure.</p>
<p>At batch size 1, 72 MiB is small next to the model’s <strong>475 MiB</strong> of fp32 weights. But the cache grows with batch × context, and the weights don’t. The cache overtakes the weights at about <strong>6,750 tokens of batch × context</strong>, roughly seven full-length sequences. Sixty-four full-context requests need <strong>4.5 GiB</strong> of cache, for a 124M model. That’s why serving systems put so much work into KV memory: paged allocation, lower-precision caches, and architectures with fewer KV heads.</p>
<p>And then there’s a cost I inflicted on myself. My implementation registers a <img src="https://latex.codecogs.com/png.latex?1024%20%5Ctimes%201024"> fp32 causal mask in <strong>every</strong> layer. Twelve copies take <strong>48 MiB</strong>, two-thirds of a full-length cache, to store twelve identical triangles of ones. A single shared mask, a <code>bool</code> mask (4× smaller), or no stored mask at all (letting a fused attention kernel apply causality) fixes it. I came in thinking the cache was the complicated optimization. I left having found that my most ordinary buffers were expensive too.</p>
</section>
<section id="what-i-havent-verified-yet" class="level2">
<h2 class="anchored" data-anchor-id="what-i-havent-verified-yet">What I haven’t verified yet</h2>
<div class="callout callout-style-simple callout-warning no-icon">
<div class="callout-body d-flex">
<div class="callout-icon-container">
<i class="callout-icon no-icon"></i>
</div>
<div class="callout-body-container">
<p>Everything above is measured, except these three:</p>
<ol type="1">
<li><strong>The floor’s cause.</strong> “Python plus kernel-launch overhead” is inferred from Table&nbsp;2 and the flat cached curve. It hasn’t been profiled.</li>
<li><strong>The staircase in Figure&nbsp;1.</strong> Tile quantization or kernel switching is a hypothesis. Timing a single matmul across the same sizes would test it.</li>
<li><strong>Scope.</strong> A 124M model at batch size 1 is the worst case for overhead. A bigger model or batch puts more math behind each kernel launch, and the measured speedup should move toward the FLOP ratio. The 6× here is not a universal number.</li>
</ol>
</div>
</div>
</div>
</section>
<section id="what-id-tell-myself-before-starting" class="level2">
<h2 class="anchored" data-anchor-id="what-id-tell-myself-before-starting">What I’d tell myself before starting</h2>
<ol type="1">
<li><strong>Verify before measuring.</strong> Match a reference implementation, then prove cached output equals uncached output, on both single-token <em>and</em> multi-token paths.</li>
<li><strong>The cache is mathematically exact.</strong> Under causal attention, old K and V are determined once their token is processed. Storing them changes nothing about what the model computes.</li>
<li><strong>FLOP ratios are not speedups.</strong> 1,000× less math became 6× per token and 2.9× end to end. Divide your FLOPs by your time; it tells you which regime you’re in.</li>
<li><strong>At this scale, the waste is the Linear layers, not attention.</strong> The <img src="https://latex.codecogs.com/png.latex?t%5E2"> term is 13% of the work at 1,000 tokens.</li>
<li><strong>Wrong values in correct shapes don’t crash.</strong> Cache order, positions, mask slices and redundant buffers all fail silently.</li>
<li><strong>Don’t promote a plausible explanation to a fact</strong> until you’ve run the experiment that could prove it wrong.</li>
</ol>
</section>
<section id="next-the-5-ms-floor" class="level2">
<h2 class="anchored" data-anchor-id="next-the-5-ms-floor">Next: the 5 ms floor</h2>
<p>This experiment didn’t end with a tidy answer. It ended with a suspect. In the next post I’ll profile a decode step, capture it with CUDA graphs and <code>torch.compile</code>, replace <code>torch.cat</code> with a preallocated cache, and apply the output projection to the last position only. If the floor drops, the overhead story holds. If it doesn’t, I’ll have learned something better than a neat explanation that happened to sound right.</p>
<p>For now, here’s what stays with me. A transformer trains efficiently by processing every position at once, and the same design makes naive generation wasteful. The causal math gives us permission to keep exactly what we need. The measurements show that permission to do less math is not a promise to finish proportionally sooner.</p>
<p>The surprising part wasn’t that caching works. It was how much it saves, and then how much time is still left.</p>
<div class="callout callout-style-simple callout-note no-icon callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon no-icon"></i>
</div>
<div class="callout-title-container flex-fill">
<span class="screen-reader-only">Note</span>Reproduce it
</div>
</div>
<div class="callout-body-container callout-body">
<p>All numbers and figures come from one script run against the from-scratch model, both in this site’s repository under <a href="https://github.com/angry-kratos/angry-kratos.github.io/tree/main/code/kv-cache"><code>code/kv-cache</code></a>: <code>python kv_experiments.py</code> runs every experiment and writes the raw data to <code>results/results.json</code>, and <code>--plots-only</code> redraws the figures from it.</p>
</div>
</div>


</section>


 ]]></description>
  <category>inference</category>
  <category>kv-cache</category>
  <category>gpt-2</category>
  <category>from-scratch</category>
  <guid>https://angry-kratos.github.io/posts/2026-10-kv-cache/</guid>
  <pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate>
  <media:content url="https://angry-kratos.github.io/posts/2026-10-kv-cache/fig2.png" medium="image" type="image/png" height="98" width="144"/>
</item>
</channel>
</rss>
