<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Vivek Kalyanarangan]]></title><description><![CDATA[Vivek Kalyanarangan]]></description><link>https://vivekkalyanarangan.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Vivek Kalyanarangan</title><link>https://vivekkalyanarangan.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 23 Sep 2026 11:48:00 GMT</lastBuildDate><atom:link href="https://vivekkalyanarangan.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why Long LLM Conversations Get Expensive: A Deep Dive into the KV Cache]]></title><description><![CDATA[If you have built anything on a large language model, you have probably noticed something strange.
The first few messages are fast. An hour into a long session, with a big document pasted in and a cod]]></description><link>https://vivekkalyanarangan.hashnode.dev/why-long-llm-conversations-get-expensive-kv-cache</link><guid isPermaLink="true">https://vivekkalyanarangan.hashnode.dev/why-long-llm-conversations-get-expensive-kv-cache</guid><category><![CDATA[llm]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[GPU]]></category><category><![CDATA[performance]]></category><category><![CDATA[Python]]></category><dc:creator><![CDATA[vivekkalyanarangan]]></dc:creator><pubDate>Thu, 17 Sep 2026 15:24:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aabfb30b5867a8c86dcb10b/43a226f1-98b6-411d-8f5e-83129072b2ba.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you have built anything on a large language model, you have probably noticed something strange.</p>
<p>The first few messages are fast. An hour into a long session, with a big document pasted in and a coding agent halfway through a refactor, the same model feels sluggish.</p>
<p>The usual explanation is that the model is "thinking harder". It is not.</p>
<p>The model does exactly the same amount of work per word at message one and at message ten thousand. What changed is how much it has to read before it can produce each word.</p>
<p>This article is about what it reads, why that grows, and what you can do about it. I will end with measurements from my own research, but most of what follows applies whatever stack you run.</p>
<h2>What the model keeps in memory</h2>
<p>When a language model generates text, it produces one token at a time. To produce the next token, it has to look back at everything that came before.</p>
<p>Recomputing that history on every token would be quadratically disastrous. So every serving stack keeps a cache instead.</p>
<p>For each token in the conversation, and for each layer of the model, it stores two vectors: a key and a value. This is the KV cache.</p>
<p>Here is the mental model that helps. The keys are an index, and the values are the content.</p>
<p>When the model generates a new token, it forms a query vector. It compares that query against every key in the cache to decide how relevant each past token is. Then it mixes the corresponding values together in proportion to that relevance.</p>
<p>That is attention. It happens in every layer, for every token generated.</p>
<p>So the cost of generating one token is dominated by one thing: reading the entire KV cache.</p>
<h2>How big the cache actually gets</h2>
<p>Take Qwen3-8B. It has 36 layers and 8 key-value heads per layer, each holding 128 numbers per token.</p>
<p>Each token needs a key and a value in every one of those heads, in every layer. At 16-bit precision that works out to about 144 KB of cache for every single token of context.</p>
<p>Multiply that out:</p>
<ul>
<li><p>32,000 tokens of context: about 4.8 GB of cache, read once per generated token</p>
</li>
<li><p>1,000,000 tokens: about 150 GB</p>
</li>
</ul>
<p>Now think about what that means physically.</p>
<p>An A100 has 80 GB of memory in total, and your model weights already sit in it. Long before you reach a million tokens, the cache stops fitting beside the weights.</p>
<p>It has to live somewhere slower. Usually that is ordinary system RAM, reached across a PCIe bus roughly an order of magnitude slower than the GPU's own memory.</p>
<p>This is what vLLM's CPU offload, LMCache and similar layers do. It is not exotic any more. If you run agents with long context, you are probably already here.</p>
<h2>The first fix: stop attending to everything</h2>
<p>Here is the observation that a lot of good work is built on.</p>
<p>When a model attends over a long context, the attention weights are extremely concentrated. A handful of tokens get almost all of the weight. The long tail contributes close to nothing.</p>
<p>So do not read the long tail. Score every key, take the top k, and compute attention over just those k tokens.</p>
<p>This is top-k sparse attention. With k of a few hundred to a few thousand, it stays remarkably faithful to full attention.</p>
<p>The savings look enormous. Instead of reading 150 GB, you read the values for k tokens. At k=512 that is a couple of hundred megabytes, no matter how long the conversation gets.</p>
<p>The cost stops growing with context. Except it does not, and this is the part that surprised me.</p>
<h2>The catch</h2>
<p>To take the top k keys, you have to score all n keys. You cannot know which ones matter without looking.</p>
<p>So every top-k method carries a second, cheaper data structure. It is a compressed representation of every key, small enough to scan quickly.</p>
<p>Loki keeps 32 principal components. Double Sparsity keeps 32 channels chosen offline. SparQ lets each query pick its own 32 channels.</p>
<p>In practice these all land around the same size, roughly 136 bits per token. That sounds tiny.</p>
<p>Run the numbers anyway. For Qwen3-8B, 136 bits per token across all 288 layer-and-head pairs comes to:</p>
<ul>
<li><p>about 160 MB per generated token at 32,000 tokens of context</p>
</li>
<li><p>about 5.1 GB per generated token at 1,000,000 tokens</p>
</li>
</ul>
<p>And unlike the k winners, this one grows linearly with the conversation.</p>
<p>So at long context, the thing your decode step waits on is not the model weights. It is not the tokens the model decided to attend to either.</p>
<p>It is the index scan used to decide.</p>
<p>In my measurements the winner rows moved about 200 MB per step while the scan moved 5.1 GB. Most of the effort in this area has gone into the 4%.</p>
<h2>Where the memory lives changes the answer</h2>
<p>Before going further, one distinction matters enormously. It is the thing I would most like developers to take away from this article.</p>
<p>If that index sits in GPU memory, reading fewer bytes barely helps. GPU memory is fast, and the scan is limited by arithmetic rather than by bandwidth. Reading less does not reduce the number of multiply-adds.</p>
<p>If the index sits in host memory, bytes are time, almost exactly. Everything moves at the speed of the bus. The arithmetic units sit idle waiting, and halving the bytes nearly halves the step.</p>
<p>Any efficiency claim you read about KV caches is implicitly about one of these two worlds. Check which one before you believe it applies to you.</p>
<h2>How to read fewer bits per key</h2>
<p>Given that, the lever in the offloaded world is obvious. Read fewer bits per key.</p>
<p>Every method above does this by choosing fewer channels. Keep 32 of the 128 channels, read them at 4 bits, discard the rest. The choice is made once, at design time.</p>
<p>The alternative is to keep all the channels but read each one to a different depth.</p>
<p>To see why that might work, you need one fact about quantization. When you store a number at 4 bits instead of 16, you introduce rounding error, and each additional bit cuts that error by a factor of four.</p>
<p>So precision has sharply diminishing returns. Going from 1 bit to 2 bits on a channel buys you a lot. Going from 3 bits to 4 buys you very little.</p>
<p>Now combine that with what a query actually needs.</p>
<p>For any given query, most of the score comes from a few dozen channels, and which ones differ from query to query. So the fourth bit of an important channel can genuinely be worth less than the first bit of a channel you were about to throw away.</p>
<p>That means there is an optimal allocation, and it is not "all channels at the same depth".</p>
<h2>Bit planes: how a partial read becomes a valid read</h2>
<p>The obstacle is storage.</p>
<p>If your cache is stored the ordinary way, as 4-bit numbers laid out one after another, you cannot read "the first two bits" of each number without reading the whole thing anyway. And what you get back is not a meaningful number.</p>
<p>The fix is to transpose the storage. Instead of storing numbers, store bit planes.</p>
<p>All the first bits of a block of tokens go together, then all the second bits, and so on.</p>
<pre><code class="language-text">ordinary layout:   [1011][0110][1101][0010] ...   four numbers, four bits each

bit-plane layout:  [1010...]  plane 0   the most significant bit of every number
                   [0110...]  plane 1
                   [1101...]  plane 2
                   [1010...]  plane 3
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6aabfb30b5867a8c86dcb10b/b0ca2c83-caf3-4655-98f6-1a07bc5ba8df.png" alt="" style="display:block;margin:0 auto" />

<p>Now read only plane 0 and plane 1.</p>
<p>What you have is every number rounded to 2 bits. Not an approximation of one, and not garbage. Exactly the 2-bit version, with the same scale factor.</p>
<p>Reading a prefix of the planes is mathematically identical to having stored a lower-precision copy.</p>
<p>This idea is not mine. Any-Precision LLM used it for model weights, so that a prefix read gives you a smaller model.</p>
<p>What is new here is the application to the key cache, so that a prefix read gives you a cheaper index. And then choosing the prefix length per query.</p>
<h2>How each query picks its own depth</h2>
<p>With the storage sorted out, the allocation problem has a clean answer.</p>
<p>For a given query, each channel has an importance, which is roughly how much of the score variance that channel carries.</p>
<p>Spending one more bit on a channel reduces the remaining error by an amount proportional to that importance, divided by four to the power of the bits already spent.</p>
<p>Allocating a fixed budget to maximise the total reduction is a classic problem with a closed form. It is called reverse water-filling.</p>
<p>You pick a threshold. You give every channel however many bits puts it at that threshold. Then you binary search the threshold until the total hits your budget.</p>
<p>Important channels get all four planes. Marginal channels get one. Channels that carry nothing for this query get skipped entirely.</p>
<p>That is the whole method. Store the 4-bit key cache as bit planes, and let each query water-fill its bit budget across channels.</p>
<h2>What the measurements show</h2>
<p>I measured this on rented A100s across five models, from Llama-3.1-8B to Qwen2.5-7B-Instruct-1M, at context lengths up to 128,000 tokens. I timed full decode steps out to a million.</p>
<h3>Accuracy per byte</h3>
<p>To match the attention error of the 136-bit fixed-depth scans, this needs 46 to 74 bits per token. That holds across all seven model and context settings tested.</p>
<p>So the scan reads 1.8 to 2.9 times fewer bytes at the same quality.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aabfb30b5867a8c86dcb10b/66c349be-0bbc-4a73-8d97-0dce4ae08381.png" alt="" style="display:block;margin:0 auto" />

<h3>End-to-end speed</h3>
<p>With the cache and index in pinned host memory on an A100, a decode step at one million tokens runs 1.67 times faster in GPU time than with the 136-bit scans. It is 2.5 times faster than with a block-landmark index.</p>
<h3>Downstream behaviour</h3>
<p>On synthetic long-context retrieval tasks, every per-token scan matches exact top-k decoding. Those tasks do not separate the methods at all.</p>
<p>On real coding-agent sessions of 80,000 to 100,000 tokens, decoding the agent's next step, the gap is visible. At a 2% attention budget this agrees with exact top-k decoding 0.67 of the time, against 0.49 for the closest comparable method.</p>
<h2>Where this does not work</h2>
<p>Back to the distinction from earlier, because this is the honest part.</p>
<p>Move the index into GPU memory and the method is not faster. It is slower.</p>
<p>Extracting individual bits costs one or two integer operations per bit, roughly four times the per-bit cost of reading a 4-bit number whole. The kernel ends up about 1.4 times slower than a plain 4-bit scan, despite reading 38% fewer bytes.</p>
<p>That is not a quirk of one GPU.</p>
<p>Counting integer lanes against memory bandwidth, an A100 offers roughly 5 integer operations per byte of bandwidth. An H100 is about the same. If a scan is arithmetic-bound on one current data-centre GPU, it is arithmetic-bound on all of them.</p>
<p>There is also a storage cost. As a separate index this is 68 bytes per token per KV head, four times what Double Sparsity's label cache costs.</p>
<p>It only becomes free in a stack whose key cache is already 4-bit and stored channel-major, where the index and the cache are the same bytes.</p>
<h2>One measurement worth carrying around</h2>
<p>Even if you never touch any of this, keep this number.</p>
<p>Copying from pinned host memory in contiguous runs reached about 26 GB/s at every size from 4 KB upward. Issuing one copy call per run instead got 0.4 GB/s at 4 KB.</p>
<p>Same bytes. Same hardware. Sixty times slower, purely because of how the reads were grouped.</p>
<p>Layout is not a detail.</p>
<h2>What to do with this</h2>
<p>If you run long-context inference today, three things here are worth more to you than the method itself.</p>
<p>Find out whether your KV index lives in GPU memory or in host memory. That single fact decides whether any efficiency claim you read applies to you at all.</p>
<p>Measure GPU time, not wall-clock, when your harness is Python. In my own measurements the two disagreed by up to 25%, and the gap differed by method, which is exactly how you end up reporting the wrong winner.</p>
<p>Look at your memory layout before you look at your kernel. The 26 GB/s against 0.4 GB/s result above was not a code change. It was the same bytes, grouped differently.</p>
<p>If you want the full method, the measurements, or the code to check any number in this article yourself, it is all at <a href="https://github.com/vivekkalyanarangan30/fathom">github.com/vivekkalyanarangan30/fathom</a>.</p>
<p>Paper: <a href="https://arxiv.org/abs/2609.17652">https://arxiv.org/abs/2609.17652</a></p>
]]></content:encoded></item></channel></rss>