<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Vishal Vaibhav</title>
    <link>https://codeyogico.github.io/</link>
    <description>Notes on engineering, systems, search, and tech culture.</description>
    <language>en-us</language>
    <atom:link href="https://codeyogico.github.io/feed.xml" rel="self" type="application/rss+xml" />
    <lastBuildDate>Wed, 27 May 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>When collision is good: semantic query caching with LSH</title>
      <link>https://codeyogico.github.io/posts/when-collision-is-good/</link>
      <guid isPermaLink="false">when-collision-is-good</guid>
      <pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[Exact-match caches waste memory storing the same products under dozens of near-identical query strings. Here's how Locality-Sensitive Hashing fixes that — by designing hash collisions on purpose.]]></description>
      <content:encoded><![CDATA[<p>Everyone learns the same rule on day one: hash collisions are bad. Two inputs landing in the same bucket means wasted work — longer lookup chains, unpredictable performance, security headaches. The whole point of a good hash function is to scatter inputs as randomly and evenly as possible.</p>
<p>Locality-Sensitive Hashing (LSH) breaks this rule deliberately. The goal is to make similar inputs land in the <em>same</em> bucket, not different ones. Collisions are the product, not the defect.</p>
<p>This post explains why you&#39;d want that, how MinHash makes it work, and how an LSH-backed cache can triple the effective capacity of a search cache without adding a single byte of hardware.</p>
<h2>the problem with exact-match caching</h2>
<p>A search cache is simple: hash the query string, look up the result. If a user has typed this exact query before, return the cached product list and skip the expensive retrieval pipeline.</p>
<p>The problem is that users don&#39;t type the same string twice. They type variations:</p>
<pre><code>&quot;nike running shoes&quot;
&quot;nike running shoe&quot;
&quot;running shoes nike&quot;
&quot;nike running sneakers&quot;
</code></pre>
<p>Those four strings produce four different hash values, four separate cache entries, and four copies of essentially the same product list. At the scale of a large retailer — hundreds of millions of searches per day, billions of cached entries — this redundancy is not a rounding error. It&#39;s a significant fraction of your cache budget.</p>
<p>The question isn&#39;t whether this waste exists. It&#39;s whether we can do anything about it without making wrong cache hits a thing.</p>
<pre><code>exact-match cache
─────────────────
&quot;nike running shoes&quot;     →  [products]
&quot;nike running shoe&quot;      →  [products]
&quot;running shoes nike&quot;     →  [products]
&quot;nike running sneakers&quot;  →  [products]

   4 cache entries — same data, copied 4 times


LSH cache
─────────
&quot;nike running shoes&quot;     ╮
&quot;nike running shoe&quot;      │
                         ├──→  [products]
&quot;running shoes nike&quot;     │
&quot;nike running sneakers&quot;  ╯

   1 cache entry — shared across all 4 queries
</code></pre>
<h2>jaccard similarity: a way to measure &quot;same thing&quot;</h2>
<p>Before we can build a smarter cache, we need a way to measure whether two queries are saying the same thing.</p>
<p><strong>Jaccard similarity</strong> is the simplest useful measure: divide the number of words the two queries share by the total number of unique words across both.</p>
<pre><code>A = {&quot;nike&quot;, &quot;running&quot;, &quot;shoes&quot;}
B = {&quot;nike&quot;, &quot;running&quot;, &quot;shoe&quot;}

intersection = {&quot;nike&quot;, &quot;running&quot;} → size 2
union        = {&quot;nike&quot;, &quot;running&quot;, &quot;shoes&quot;, &quot;shoe&quot;} → size 4

Jaccard(A, B) = 2 / 4 = 0.5
</code></pre>
<p>Two identical queries score 1.0. Completely unrelated queries score 0.0. The score lives cleanly in [0, 1].</p>
<p>For the cases we care about, similar queries score 0.5–0.9. Genuinely different queries (different category, different brand, different intent) tend to score below 0.2.</p>
<h2>minhash: turning jaccard into a hash</h2>
<p>Now the clever part. There&#39;s a family of hash functions — called <strong>MinHash</strong> — with a remarkable property:</p>
<blockquote>
<p>For any two sets A and B, if you pick a random MinHash function h, then <code>P(h(A) == h(B)) = Jaccard(A, B)</code>.</p>
</blockquote>
<p>Read that again. The <em>probability</em> that two queries produce the same hash value equals their Jaccard similarity. If two queries are 80% similar, a random MinHash function will give them the same hash 80% of the time.</p>
<p>This is the mathematical foundation that makes everything else work. The proof is elegant but not required here — the key intuition is: MinHash works by randomly permuting the set and taking the minimum element. Two similar sets have a higher chance of sharing their minimum.</p>
<h2>votes: making the signal reliable</h2>
<p>A single MinHash function with 80% agreement probability is noisy. You&#39;d see it disagree 20% of the time even for very similar queries, and agree 20% of the time even for dissimilar ones.</p>
<p>The fix is to run many hash functions and count agreements.</p>
<p>With 36 independent MinHash functions and a vote threshold of 18:</p>
<ul>
<li>A query pair with Jaccard 0.8 agrees on ~29 out of 36 functions on average. Getting at least 18 agreements is almost certain.</li>
<li>A query pair with Jaccard 0.2 agrees on ~7 out of 36 functions on average. Getting at least 18 agreements is extremely unlikely.</li>
</ul>
<p>The number of agreements follows a binomial distribution. With enough functions, the tails shrink and the two populations become cleanly separated. The vote count turns a noisy per-function signal into a reliable group decision.</p>
<p>Play with the numbers:</p>
<div data-widget="lsh-match-calc"></div><p>Two things worth noticing:</p>
<p>First, the S-curve crossover falls at the threshold ratio. With 18/36 votes (50%), the crossover is at Jaccard 0.5 — queries more than 50% similar get matched, queries less than 50% similar don&#39;t. Shift the threshold to 27/36 (75%) and the crossover shifts right.</p>
<p>Second, more hash functions means a <em>steeper</em> curve — a sharper boundary between &quot;matched&quot; and &quot;not matched&quot;. Fewer functions gives a softer, fuzzier boundary. A common choice — 36 functions with a vote threshold of 18 — gives a curve steep enough to reliably separate similar from dissimilar while staying cheap to compute.</p>
<h2>how the system is actually built</h2>
<p>There are two distinct parts, running at very different timescales.</p>
<p><strong>The offline cluster builder (nightly batch job)</strong></p>
<p>Once a day, run a job over the past 30 days of query logs. For each of the top ~60M queries:</p>
<ol>
<li>Compute all 36 MinHash values.</li>
<li>For each hash, record which bucket that query lands in.</li>
<li>Any two queries that land in the same bucket across multiple hash functions increment an edge weight between them.</li>
<li>Prune edges below a vote threshold (e.g., 20/36).</li>
<li>Find connected components — each component is a semantic cluster.</li>
<li>Pick a <strong>canonical query</strong> per cluster (simplest: most frequent query in the cluster).</li>
<li>Publish the mapping: <code>canonical_query → which buckets it lives in</code>.</li>
</ol>
<p>The output is a static index: given any bucket ID, which canonical queries appear in it?</p>
<p><strong>The online lookup (real-time, per request)</strong></p>
<p>When a user query arrives:</p>
<ol>
<li>Normalize (lowercase, trim whitespace).</li>
<li>Compute 36 MinHash values.</li>
<li>For each value, look up the canonical queries that appear in that bucket. Tally votes.</li>
<li>If the top-voted canonical query has ≥ 18 votes: it wins. Fetch its cached result.</li>
<li>If no winner: cache miss. Fall through to the full retrieval pipeline.</li>
</ol>
<pre><code class="language-python">def get_cached_results(user_query):
    q = normalize(user_query)
    votes = Counter()

    for h in HASH_FUNCTIONS:          # 36 functions
        bucket = h(q)
        for canonical in bucket_index[bucket]:
            votes[canonical] += 1

    if not votes:
        return CACHE_MISS

    winner, count = votes.most_common(1)[0]
    return cache.get(winner) if count &gt;= MIN_VOTES else CACHE_MISS
</code></pre>
<p>The 36 hash lookups can run in parallel. Each lookup is a hash table read against a compact in-memory index. At that point it&#39;s not doing search — it&#39;s doing arithmetic and array access.</p>
<h2>why token weights matter</h2>
<p>Plain Jaccard treats all words equally. That&#39;s not quite right for queries.</p>
<p>Consider:</p>
<ul>
<li>&quot;nike shoes&quot; vs &quot;adidas shoes&quot; → Jaccard = 0.33, but these are different brand queries with different expected results</li>
<li>&quot;nike shoes&quot; vs &quot;nike sneakers&quot; → Jaccard = 0.33, but these almost certainly return the same products</li>
</ul>
<p>A word like &quot;shoes&quot; carries more semantic meaning about the product category than a brand name. If we weight tokens by their importance — category words higher, brand names and modifiers lower — we get a similarity score that better tracks &quot;would these two queries return the same results?&quot;</p>
<p>This is <strong>weighted Jaccard</strong>. A typical implementation uses a tagger to label each token by its role — head noun, modifier, brand, and so on — and assigns weights to match. If your system already has a query tagger somewhere in the pipeline, reuse it. If not, a basic part-of-speech tagger that boosts nouns and discounts adjectives gets you 80% of the way there.</p>
<p>The math of weighted MinHash is slightly more involved (you weight the random permutation by token weight), but any decent library handles it — <code>datasketch</code> in Python, for instance. You pass in token weights, it gives you a MinHash. The rest of the system doesn&#39;t change.</p>
<h2>the numbers</h2>
<p><strong>Cache capacity.</strong> If a cluster of 4–5 near-duplicate queries now shares one cache entry instead of four, and the average cluster size in your query log is 3–5 queries, you&#39;re storing 3–5x fewer entries for the same result coverage. In practice this lands around a ~3x improvement in effective cache capacity.</p>
<p><strong>Hit rate on tail queries.</strong> This is where the gains are biggest. Head queries (the top 1000 searches) already have high hit rates under exact-match caching because users type them verbatim repeatedly. Tail queries — rare, varied, one-off phrasings — are where the cache fails today. LSH clustering effectively &quot;borrows&quot; hits from the canonical query to cover all the tail variations. On long-tail traffic, reported gains run into the multiple-x range on F1 — often cited around 250%.</p>
<p><strong>Latency.</strong> The cost is real. An exact-match cache lookup is one hash + one table read (~0.1 ms). LSH lookup adds 36 hashes + 36 table reads + a vote tally (~2 ms). That&#39;s a 20x increase in cache lookup overhead. The question is whether that 2 ms is acceptable given the p99 savings from serving more cache hits (and skipping 50 ms+ retrieval pipelines on misses). For most search SLOs, it is — but measure it before you commit.</p>
<h2>what can go wrong</h2>
<p><strong>Wrong cache hits.</strong> If LSH assigns a user query to the wrong canonical, they get irrelevant results with no recovery path — the cache says &quot;hit&quot; but the results are wrong. The vote threshold is your main defense. Set it too low and false matches creep in. The offline evaluation step (replaying query logs and comparing returned results against ground-truth retrieval output) is how you find the right threshold empirically before touching production.</p>
<p><strong>Cluster staleness.</strong> Product catalogs change. A query cluster that was semantically coherent last month may not be today if a brand launches a new category or discontinues a product line. Nightly re-clustering handles the slow drift. You&#39;ll want a fast invalidation path — either a manual override or an automated signal from catalog change events — for sudden shifts.</p>
<p><strong>Cold start for new queries.</strong> Any query that has never appeared in the training window won&#39;t be in any cluster. It&#39;s a cache miss, same as today. This is fine — it&#39;s the same baseline behavior — but it means LSH doesn&#39;t help at all for genuinely novel queries. Those are also your most expensive queries (novel phrasing → harder retrieval), but that&#39;s a separate problem.</p>
<h2>one breath</h2>
<p>The insight is that hash collisions, normally a defect to engineer away, can be made into a feature. MinHash is designed so that the probability of a collision equals the Jaccard similarity between two sets. With enough hash functions and a vote threshold, the resulting match decision is reliable. Similar queries cluster together and share one cache entry; dissimilar queries don&#39;t. Cache capacity goes up, tail-query hit rate goes up, retrieval load goes down. You pay ~2 ms extra per lookup and accept that your cache is slightly fuzzy.</p>
<p>The math is worth understanding once. After that, the implementation is a library call and a batch job.</p>
<p>— v</p>
]]></content:encoded>
    </item>
    <item>
      <title>The KV cache, from first principles</title>
      <link>https://codeyogico.github.io/posts/kv-cache-from-first-principles/</link>
      <guid isPermaLink="false">kv-cache-from-first-principles</guid>
      <pubDate>Sat, 16 May 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[How attention and the KV cache actually work — from the basics up.]]></description>
      <content:encoded><![CDATA[<p>The number that decides how much your LLM inference bill is doesn&#39;t appear on the model card. It isn&#39;t the parameter count. It isn&#39;t the context length. It&#39;s the <strong>KV cache</strong> — a per-request scratchpad in GPU memory that grows with every word the model generates.</p>
<p>If you serve models, this is the dominant resource you&#39;re managing. Every recent inference trick exists to shrink it.</p>
<p>This post explains what the KV cache is from scratch, using a simple analogy.</p>
<h2>what an LLM does, in one line</h2>
<p>A language model takes the words you&#39;ve written so far and predicts the next word. That&#39;s it. Chat, code generation, agents — everything is this one trick called in a loop.</p>
<pre><code>input:  &quot;The quick brown ___&quot;
output: &quot;fox&quot; (95% likely)
        &quot;dog&quot; (3%)
        …
</code></pre>
<p>Pick one, append it, repeat. That&#39;s how an LLM writes a paragraph — one word at a time, each one a guess at what comes next.</p>
<h2>words become numbers from a fixed list</h2>
<p>Models don&#39;t see text — they see numbers. Before anything else, the model breaks your sentence into chunks from a fixed list of about 50,000 known chunks (called <strong>tokens</strong>). Common words like &quot;the&quot; are one chunk. Rare words like &quot;tokenization&quot; get split into a few chunks (&quot;token&quot; + &quot;ization&quot;) because the model has those but not the whole word.</p>
<p>You can watch this happen live at <a href="https://tiktokenizer.vercel.app/?model=cl100k_base">tiktokenizer.vercel.app</a> — paste anything.</p>
<p>That&#39;s all you need to know about tokenization. The interesting part starts now.</p>
<h2>the library</h2>
<p>Imagine your sentence is a small library, with one book per word on the shelf.</p>
<pre><code>[the] [cat] [sat] [on] [the] [mat]
</code></pre>
<p>When you want to understand any single book — say, &quot;sat&quot; — you can&#39;t just look at it in isolation. The word &quot;sat&quot; could mean a hundred things (sat for an exam, sat in a chair, …). You need to understand it in the context of the other books on the shelf.</p>
<p>The library has a <strong>catalog</strong>. Every book has an entry in the catalog with two cards:</p>
<ul>
<li>A <strong>title card (K)</strong> — what the book advertises itself as. <em>&quot;I&#39;m an action verb. I&#39;m about sitting.&quot;</em></li>
<li>A <strong>contents card (V)</strong> — what the book actually delivers if matched. <em>&quot;Action of sitting, past tense, requires a subject and a location.&quot;</em></li>
</ul>
<p>And every book also has its own <strong>question (Q)</strong> — what it needs to know to understand its place in this library:</p>
<ul>
<li>&quot;sat&quot; asks: <em>&quot;What&#39;s the subject doing me? Where&#39;s it happening?&quot;</em></li>
<li>&quot;cat&quot; asks: <em>&quot;What action am I taking? Where am I?&quot;</em></li>
<li>&quot;the&quot; asks (a small question): <em>&quot;Which noun am I attached to?&quot;</em></li>
</ul>
<p>To answer each book&#39;s question, the model browses the catalog:</p>
<p>For &quot;sat&quot; specifically:</p>
<ul>
<li>&quot;cat&quot;&#39;s title card says <em>&quot;subject, noun, animal&quot;</em> → <strong>high match</strong> → pull in lots of &quot;cat&quot;&#39;s contents card</li>
<li>&quot;on&quot;&#39;s title card says <em>&quot;position word&quot;</em> → <strong>high match</strong> → pull in lots of &quot;on&quot;&#39;s contents</li>
<li>&quot;the&quot;&#39;s title card says <em>&quot;just a determiner&quot;</em> → <strong>low match</strong> → pull in almost nothing</li>
<li>&quot;mat&quot;&#39;s title card says <em>&quot;noun being acted on&quot;</em> → <strong>medium match</strong> → pull in some of &quot;mat&quot;&#39;s contents</li>
</ul>
<p>The combined result — a blend of contents weighted by how well each title matched — is the new &quot;sat&quot;. It&#39;s no longer the abstract verb &quot;sat&quot;, it carries context: <em>a sitting action done by a cat onto a mat</em>. Every other book on the shelf gets the same treatment in parallel, each using its own question against everyone else&#39;s title cards.</p>
<p><strong>Q asks. K announces. V delivers.</strong></p>
<p>That&#39;s attention — the engine of every modern language model. The 2017 paper that introduced it is <a href="https://arxiv.org/abs/1706.03762">Attention Is All You Need</a> — eight authors, eleven pages.</p>
<p>(In the actual model, the cards aren&#39;t paper — they&#39;re short lists of numbers. But the role each plays is exactly what the analogy says.)</p>
<h2>doing it many times, in parallel</h2>
<p>One catalog focuses on one type of relationship between books (maybe grammar — who&#39;s the subject of what verb). To capture different kinds of relationships — meaning, position, long-range references — the model maintains <strong>many parallel catalogs at once</strong>. Llama 3 8B has 32 of them.</p>
<p>Then it does the whole browsing-and-combining process again, this time using the previous round&#39;s enriched results. And again. <strong>Stacked 32 layers deep.</strong> Each layer refines the previous layer&#39;s understanding.</p>
<p>By layer 32, every book has a deeply layered understanding of its place in the library.</p>
<h2>generating one word at a time</h2>
<p>To write the next word, the model:</p>
<ol>
<li>Runs all 32 layers of browsing and combining over the existing books.</li>
<li>Looks at the last book&#39;s final understanding.</li>
<li>Turns that into a probability over every word in the vocabulary.</li>
<li>Picks one — usually the most likely.</li>
<li>Adds that word&#39;s book to the end of the shelf.</li>
<li>Goes back to step 1, now with one more book on the shelf.</li>
</ol>
<p>One rule when generating: <strong>each book can only consult catalog entries for books to its left.</strong> It can&#39;t peek at books that haven&#39;t been placed yet — those are what&#39;s being predicted. This means every book&#39;s catalog entry depends only on books to its left, never on anything to its right. <strong>Once a book&#39;s entry is in the catalog, it never changes.</strong></p>
<p>That property is the opening for the optimization.</p>
<h2>the KV cache</h2>
<p>Here&#39;s the thing nobody tells you up front: <strong>the model doesn&#39;t remember anything between words.</strong> When it generates the next word, it doesn&#39;t pick up where it left off — it starts the whole sentence over from the beginning.</p>
<p>Every single word the model generates means going through every existing book again and re-generating every book&#39;s catalog entry. Just to add <em>one</em> new word at the end.</p>
<p>Imagine, every time a new book arrives at the shelf, throwing out the entire catalog and re-cataloging every existing book from scratch — including all 1,000 books that have been on the shelf for years.</p>
<p>Why does it work this way? Because each prediction is a self-contained calculation: <em>&quot;given the sentence so far, what comes next?&quot;</em> The &quot;given the sentence so far&quot; part is rebuilt every time. The model has no built-in memory between predictions.</p>
<p>For a 3-word sentence, that means re-cataloging 3 books to get word #4. For a 1,000-word sentence, re-cataloging 1,000 books to get word #1,001. The cost gets brutal fast.</p>
<p>But notice: <strong>every existing book&#39;s catalog entry would come out identical every single time.</strong> Each entry depends only on books to its left, and nothing to its left has changed. So re-cataloging is pure wasted work.</p>
<p>The fix is obvious once you see it: <strong>keep the catalog.</strong> After each book&#39;s title and contents cards are generated the first time, save them. Next time we add a new word, only the brand-new book needs a fresh catalog entry. All the old ones are still on file.</p>
<p>That saved catalog is the <strong>KV cache</strong>.</p>
<svg viewBox="0 0 720 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="KV cache growth across three generation steps" style="display: block; width: 100%; max-width: 720px; height: auto; margin: 28px auto;">
  <style>
    .step-title { fill: var(--ink); font-family: var(--mono); font-size: 12px; text-anchor: middle; font-weight: 500; }
    .step-sub { fill: var(--ink-faint); font-family: var(--mono); font-size: 10px; text-anchor: middle; }
    .row-label { fill: var(--ink-soft); font-family: var(--mono); font-size: 11px; }
    .slot-text { fill: var(--ink); font-family: var(--mono); font-size: 10px; text-anchor: middle; }
    .slot-text-new { fill: white; font-family: var(--mono); font-size: 10px; text-anchor: middle; font-weight: 600; }
    .cached { fill: var(--bg-alt); stroke: var(--rule); stroke-width: 1; }
    .new-slot { fill: var(--accent); stroke: var(--accent); stroke-width: 1; }
    .legend-text { fill: var(--ink-soft); font-family: var(--mono); font-size: 10.5px; }
    .caption { fill: var(--ink-faint); font-family: var(--mono); font-size: 10px; text-anchor: middle; }
    .col-divider { stroke: var(--rule); stroke-width: 1; stroke-dasharray: 2 4; }
  </style>
  <line class="col-divider" x1="230" y1="20" x2="230" y2="190"/>
  <line class="col-divider" x1="450" y1="20" x2="450" y2="190"/>
  <text class="step-title" x="100" y="30">step 1</text>
  <text class="step-sub" x="100" y="46">generated: "The"</text>
  <text class="row-label" x="20" y="100">K</text>
  <rect class="new-slot" x="50" y="80" width="60" height="34" rx="4"/>
  <text class="slot-text-new" x="80" y="101">k_the</text>
  <text class="row-label" x="20" y="150">V</text>
  <rect class="new-slot" x="50" y="130" width="60" height="34" rx="4"/>
  <text class="slot-text-new" x="80" y="151">v_the</text>
  <text class="step-title" x="335" y="30">step 2</text>
  <text class="step-sub" x="335" y="46">generated: "The cat"</text>
  <text class="row-label" x="240" y="100">K</text>
  <rect class="cached" x="270" y="80" width="60" height="34" rx="4"/>
  <text class="slot-text" x="300" y="101">k_the</text>
  <rect class="new-slot" x="334" y="80" width="60" height="34" rx="4"/>
  <text class="slot-text-new" x="364" y="101">k_cat</text>
  <text class="row-label" x="240" y="150">V</text>
  <rect class="cached" x="270" y="130" width="60" height="34" rx="4"/>
  <text class="slot-text" x="300" y="151">v_the</text>
  <rect class="new-slot" x="334" y="130" width="60" height="34" rx="4"/>
  <text class="slot-text-new" x="364" y="151">v_cat</text>
  <text class="step-title" x="585" y="30">step 3</text>
  <text class="step-sub" x="585" y="46">generated: "The cat sat"</text>
  <text class="row-label" x="460" y="100">K</text>
  <rect class="cached" x="490" y="80" width="60" height="34" rx="4"/>
  <text class="slot-text" x="520" y="101">k_the</text>
  <rect class="cached" x="554" y="80" width="60" height="34" rx="4"/>
  <text class="slot-text" x="584" y="101">k_cat</text>
  <rect class="new-slot" x="618" y="80" width="60" height="34" rx="4"/>
  <text class="slot-text-new" x="648" y="101">k_sat</text>
  <text class="row-label" x="460" y="150">V</text>
  <rect class="cached" x="490" y="130" width="60" height="34" rx="4"/>
  <text class="slot-text" x="520" y="151">v_the</text>
  <rect class="cached" x="554" y="130" width="60" height="34" rx="4"/>
  <text class="slot-text" x="584" y="151">v_cat</text>
  <rect class="new-slot" x="618" y="130" width="60" height="34" rx="4"/>
  <text class="slot-text-new" x="648" y="151">v_sat</text>
  <rect class="cached" x="160" y="240" width="24" height="18" rx="3"/>
  <text class="legend-text" x="195" y="253">cached from a previous step — no recomputation</text>
  <rect class="new-slot" x="160" y="270" width="24" height="18" rx="3"/>
  <text class="legend-text" x="195" y="283">computed this step — the new token's K and V</text>
  <text class="caption" x="360" y="335">one layer of the KV cache, growing by one column per generated token.</text>
  <text class="caption" x="360" y="352">past entries never change — that's why caching them is trivially correct.</text>
</svg><p>Now each generation step only requires the brand-new book to write its catalog entry. The library doesn&#39;t get re-cataloged from scratch every time. Generation stays fast even as the sentence grows.</p>
<p>It&#39;s safe to cache because each catalog entry is <strong>frozen once written</strong>. A book&#39;s K and V depend only on books to its left. Nothing to its right (which is what&#39;s being added) can ever change them. So a cached entry can never go stale.</p>
<p>The only question is: how much memory does the catalog take?</p>
<h2>the memory cost</h2>
<p>This is where the inference bill lives.</p>
<p>There&#39;s a catalog entry per book, per layer, per parallel catalog. For a typical 7B model:</p>
<ul>
<li>~32 layers</li>
<li>~32 parallel catalogs per layer</li>
<li>~128 numbers per card</li>
</ul>
<p>That works out to about <strong>0.5 MB per book</strong>, per conversation. A 4,000-word conversation: <strong>~2 GB of GPU memory per concurrent user</strong>, just for the catalog.</p>
<p>Try the numbers yourself:</p>
<div data-widget="kv-cache-calc"></div><p>A few things worth playing with:</p>
<ul>
<li>Switch from <strong>Llama 2 7B</strong> to <strong>Llama 3 8B</strong>. Total memory drops 4× — Llama 3 uses a catalog-shrinking trick (read on).</li>
<li>Bump <strong>seq length</strong> from 4k to 32k. The catalog grows linearly with the number of books on the shelf. This is why long-context models are expensive even when the model itself is unchanged.</li>
<li>Bump <strong>batch</strong> to 32 (serving 32 conversations at once). You pay 32× the memory. This is when the catalog starts to dominate GPU memory — not the model weights.</li>
<li>Switch <strong>dtype</strong> to int8. Catalog size halves. Tiny accuracy hit, big memory win.</li>
</ul>
<h2>why everyone&#39;s optimizing the catalog</h2>
<p>Every modern inference innovation is some variation on shrinking the catalog:</p>
<ul>
<li><strong>GQA (Grouped-Query Attention)</strong> — instead of every question-asker having its own dedicated title and contents cards, <em>groups</em> of questions share one set of cards. Fewer entries to store. Used by Llama 2 70B, Llama 3, Mistral.</li>
<li><strong>Sliding-window attention</strong> — only keep catalog entries for the last <em>w</em> books. Older books &quot;leave the library.&quot; Bounded memory, less long-range memory.</li>
<li><strong>Quantized KV cache</strong> — write the catalog entries in shorthand (int8 or int4 instead of fp16). Half or quarter the memory at modest quality cost.</li>
<li><strong>Prefix caching</strong> — if many conversations start with the same intro (&quot;You are a helpful assistant…&quot;), share those catalog entries across conversations.</li>
</ul>
<p>If you&#39;re running an inference service, the KV cache <strong>is</strong> your dominant resource. Every serving framework you&#39;ve heard of — vLLM, TGI, TensorRT-LLM — is mostly a story about managing the catalog well.</p>
<h2>one breath</h2>
<ul>
<li>Your sentence is a <strong>library</strong>, one <strong>book</strong> per word.</li>
<li>Every book has three things: a <strong>question (Q)</strong>, a <strong>title card (K)</strong>, and a <strong>contents card (V)</strong>.</li>
<li><strong>Attention</strong> = for every book, match its question against every other book&#39;s title card, pull in their contents weighted by how well the titles matched.</li>
<li>Many parallel catalogs (heads), repeated many layers — Llama 3 8B = 32 × 32.</li>
<li>When generating, new books get added to the shelf one at a time; each can only consult books already on the shelf to its left.</li>
<li>Existing books&#39; catalog entries never change — so we save them in <strong>the catalog</strong> and reuse them across generation steps. <strong>That&#39;s the KV cache.</strong></li>
<li>The catalog dominates inference memory. Shrinking it is most of what serving frameworks do.</li>
</ul>
<p>If you want to go deeper later: Karpathy&#39;s <a href="https://www.youtube.com/watch?v=kCc8FmEb1nY">Let&#39;s build GPT</a> is a 2-hour notebook walkthrough that builds a transformer from scratch. Best next step.</p>
<p>— v</p>
]]></content:encoded>
    </item>
    <item>
      <title>Retiring pull-request-code-coverage</title>
      <link>https://codeyogico.github.io/posts/retiring-pull-request-code-coverage/</link>
      <guid isPermaLink="false">retiring-pull-request-code-coverage</guid>
      <pubDate>Fri, 15 May 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[Sunsetting a small library we wrote seven years ago to bootstrap test-driven development — and what it taught me about tools.]]></description>
      <content:encoded><![CDATA[<p>Seven years ago, on a team trying to drag itself toward test-driven development, the principal engineer I worked with wrote a small library called <a href="https://github.com/target/pull-request-code-coverage"><code>pull-request-code-coverage</code></a>. It&#39;s been around long enough. Time to retire it.</p>
<h2>the problem we were trying to solve</h2>
<p>Most teams that try to adopt TDD hit the same wall: the existing coverage is awful. If you measure the whole codebase, the number is depressingly low and stays that way for months, no matter how much new code you cover. People stop looking at the dashboard. The flywheel never spins up.</p>
<p>The trick was reframing what we measured. Instead of asking <em>&quot;what&#39;s the coverage of the codebase?&quot;</em> we asked <em>&quot;what&#39;s the coverage of this pull request?&quot;</em> Just the lines that changed. Just the work being shipped right now.</p>
<p>That meant a team with 8% global coverage could set a 90% bar on new code and watch things improve one PR at a time. No three-quarter heroics. No big-bang test sprints. Just a different denominator.</p>
<h2>why it worked</h2>
<p>Two reasons, really:</p>
<ul>
<li><strong>It changed the conversation in code review.</strong> &quot;You added 40 lines, 12 are covered&quot; is a concrete, immediate ask. &quot;Our project is at 23%&quot; is no one&#39;s problem.</li>
<li><strong>It met teams where they were.</strong> You didn&#39;t have to apologize for the legacy. The tool simply ignored it. You were rewarded for the next commit, not punished for the last decade.</li>
</ul>
<p>We open-sourced it eventually. It got more use than I expected, which felt good.</p>
<h2>why retire it now</h2>
<p>Seven years is a long time in tooling. The same idea — diff-aware coverage — is now built into every major coverage tool, every CI provider, every code review platform. Codecov, SonarQube, Coveralls, GitHub&#39;s own checks all do it natively, with better integrations and a fraction of the setup our library needed.</p>
<p>And honestly: we couldn&#39;t keep up. The ecosystem moved fast. The original maintainers moved on to other work. Patches stalled, integrations lagged, the docs drifted. That&#39;s how most small open-source tools end — not with a decision, but with a slow loss of velocity.</p>
<p>When the better-supported alternatives exist and you can&#39;t give a tool the attention it deserves, the right move is to send people there. Maintaining a library because you wrote it isn&#39;t a reason. It&#39;s a habit.</p>
<h2>what it taught me</h2>
<p>Two things have stuck:</p>
<ol>
<li><strong>The most useful tools are reframings, not features.</strong> What changed our test culture wasn&#39;t the algorithm — it was the <em>denominator</em>. A small idea, well-aimed, did more than any process document ever did.</li>
<li><strong>Open source is a temporary stewardship.</strong> You ship something, you support it while it&#39;s the best fit, and you let it go gracefully when it isn&#39;t. That isn&#39;t failure. That&#39;s the lifecycle working correctly.</li>
</ol>
<p>To the people who used it, contributed, filed issues, sent patches — thank you. To the principal engineer who wrote that first commit: you were right.</p>
<p>The repo: <a href="https://github.com/target/pull-request-code-coverage">github.com/target/pull-request-code-coverage</a></p>
<p>— v</p>
]]></content:encoded>
    </item>
    <item>
      <title>Always be building</title>
      <link>https://codeyogico.github.io/posts/always-be-building/</link>
      <guid isPermaLink="false">always-be-building</guid>
      <pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[Why the work got more interesting once agents joined the team.]]></description>
      <content:encoded><![CDATA[<p>This is the only post on this site for now. It feels right that it&#39;s about why I still build with my hands — because the work has gotten more interesting since agents showed up, not less.</p>
<p>I&#39;ll be direct: agents are great. They scaffold projects, write tests, ship features, fix bugs, update docs. They get faster every month. The first time I watched one rebuild a service in an afternoon that would have cost me a weekend, I didn&#39;t feel anxious. I felt the same thing I felt the first time I used a really good profiler — <em>I have more headroom now.</em></p>
<p>What I do with that headroom is build more, not less.</p>
<h2>the work got bigger</h2>
<p>When the prototype is cheap, the question worth asking is bigger. I used to spend most of my notebook entries figuring out whether an idea was worth a weekend. Now the weekend is half an evening, and the entries are about <em>what would have to be true for this system to matter</em>. The thinking moved up a level.</p>
<p>That&#39;s the actual gift. Not &quot;look how fast I can ship.&quot; It&#39;s that the bar for what&#39;s worth shipping rises with the cost coming down.</p>
<h2>taste is built, not preserved</h2>
<p>I still write code by hand most days. Not because I&#39;m protecting some skill from rusting — that framing always sounded a little defensive — but because writing is how I think. The friction of getting a thing to work is the friction of understanding it. Agents don&#39;t take that away; they let me have it on better questions.</p>
<p>When I review what an agent wrote, I bring opinions formed by my own builds that week. Those opinions are the part of the job that gets harder with leverage, not easier. They&#39;re worth investing in.</p>
<h2>what I&#39;ve changed</h2>
<p>A few things look different than they did a year ago:</p>
<ul>
<li>I write more code at the seams — review, glue, the parts where systems meet — and let agents do more of the centers.</li>
<li>I treat the agent like a strong collaborator: opinionated, fast, sometimes seeing what I missed. The pairing is real. I learn things.</li>
<li>I read more, because there&#39;s more time to. The bottleneck moved from producing code to having something true to say.</li>
<li>The notebook is bigger. Systems that used to stay theoretical now get prototyped. Most still don&#39;t ship — but I learn from a finished prototype what I never would have from an outline.</li>
</ul>
<h2>why &quot;always&quot;</h2>
<p>The &quot;always&quot; isn&#39;t about hustle, and it isn&#39;t about holding a line. It&#39;s about staying close to the problem. Building keeps me close. So does writing here.</p>
<blockquote>
<p>The point isn&#39;t to keep up with agents. It&#39;s to keep wanting things — wanting the system to exist, wanting it to be good. Wanting is the part nothing else does for you.</p>
</blockquote>
<p>The cost of building dropped by an order of magnitude. The reason to do it didn&#39;t change. If anything, it&#39;s clearer now — building is the cheap, fast loop where I find out what I actually think.</p>
<p>— v</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
