<?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>Rahul Pradeep — Blog</title>
  <link>https://raahulpradeep.com/blog</link>
  <atom:link href="https://raahulpradeep.com/feed.xml" rel="self" type="application/rss+xml"/>
  <description>Notes on backend systems, distributed data stores, and fine-tuning and serving open-source language models.</description>
  <language>en</language>
  <lastBuildDate>Fri, 14 Aug 2026 12:00:00 +0000</lastBuildDate>
  <item>
    <title>Understanding RL as a Data and Reward Problem</title>
    <link>https://raahulpradeep.com/blog/rl-on-a-small-model</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/rl-on-a-small-model</guid>
    <pubDate>Fri, 14 Aug 2026 12:00:00 +0000</pubDate>
    <description>The core objective was to learn how Reinforcement Learning (RL) is done. Particularly since I learnt that all the massive progress that was made in LLMs…</description>
    <content:encoded><![CDATA[<p>The core objective was to learn how Reinforcement Learning (RL) is done. Particularly since I learnt that all the massive progress that was made in LLMs is because of RL. Previously, my attempt was to do SFT and then RL on top of that. But for niche use cases, after SFT on small models, I failed to find any need of RL. The details are mentioned in my <a href="https://raahulpradeep.com/blog/model-fine-tuning-learnings">previous blog</a>.
So, I didn't bother to do any SFT this time. I jumped into RL right away. Obviously, this is just to learn and shouldn't be done when you solve a real use case. If you run out of data for SFT and still find real use cases failing in your production, there is a chance RL might fit. The reason I say 'might' is because that too depends on the variance observed in your model output.</p>
<p>This blog is about how to approach a problem where RL 'might' fit. The problem statement I tried is 'PII de-identification' - find every Aadhaar number, PAN, phone number, address, account number and name in a document. The model chosen is <code>Qwen2.5-0.5B-Instruct</code>.</p>
<hr>
<h2>Getting some data which may or may not be useful !</h2>
<p>RL is more of a 'data research' problem than a 'training' problem. Moreover, to do data
research at scale is a challange. But how do you do this 'data research'. I really don't
know, but I did the best approach I could think of -- trial and error !</p>
<p>Idea is simple - have some cheap ways of generating 'real looking' data and then find the best subset suitable to do RL.</p>
<p>Two ways I tried to generate data:</p>
<ul>
<li><strong>A synthetic generator</strong> that injects known PII into templates styled as KYC forms, bank
  statements, chats, emails and medical notes. Because the generator places each entity, the
  ground truth is exact by construction and the supply of labelled data is unlimited and free. This was my primary <em>training data</em>.</li>
<li><strong>Microsoft's <a href="https://presidio.dataprivacystack.org/" target="_blank" rel="noopener">Presidio</a> sentence faker</strong>, <em>for
  evaluation</em>. Its sentence templates, span conventions and entity taxonomy are kept exactly as
  they are; only the <em>values</em> are regenerated with Faker's <code>en_IN</code> provider, because the bundled
  records are Czech and Italian names and useless for an Indian-locale test. Sentences are then
  concatenated into multi-PII documents — the gold carries no character offsets, only
  <code>(text, type, cluster)</code>, so concatenation composes by simple union with nothing to realign.</li>
</ul>
<p>The split matters. The training documents are mine end to end, which means the scorer and the
generator share an author and therefore share every convention. The evaluation documents are
somebody else's structure with my values, which is the only arrangement that can surface a
convention I got wrong without me noticing.</p>
<p>One deliberate addition to the evaluation set: <strong>distractor sentences</strong> whose only entities were
dropped by the type mapping — organisations, cities, dates. They read as entity-bearing and
contribute no gold at all. That is the "looks like PII, is not PII" pressure that my training
generator never applies. These were generated using the <a href="https://presidio.dataprivacystack.org/" target="_blank" rel="noopener">Presidio</a> sentence faker.</p>
<h2>Data distribution</h2>
<p>The training data generator has <strong>53 types</strong> — recipes, each generating
unlimited examples — keyed like <code>govt_id|kyc_form|d4|c1|x0|p6</code>: government IDs, KYC styling,
4 entities, 1 name form, no distractors, 6 units of filler.</p>
<figure class="chart"><svg style="overflow:visible;width:100%;height:auto" viewBox="0 0 720 360" role="img" aria-label="The seven axes the document grid varies: entity family with seven values, density with three, padding with two, coreference depth with three, distractors on or off, carrier style with five, and a zero-PII control."><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="48" y="50" text-anchor="end">entity family</text><rect style="fill:#00897b" class="s1f" x="60.0" y="30" width="62.4" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="91.2" y="50" text-anchor="middle">govt_id</text><rect style="fill:#00897b" class="s1f" x="132.4" y="30" width="76.8" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="170.8" y="50" text-anchor="middle">financial</text><rect style="fill:#00897b" class="s1f" x="219.2" y="30" width="62.4" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="250.4" y="50" text-anchor="middle">contact</text><rect style="fill:#00897b" class="s1f" x="291.6" y="30" width="55.2" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="319.2" y="50" text-anchor="middle">person</text><rect style="fill:#00897b" class="s1f" x="356.8" y="30" width="62.4" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="388.0" y="50" text-anchor="middle">digital</text><rect style="fill:#00897b" class="s1f" x="429.2" y="30" width="62.4" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="460.4" y="50" text-anchor="middle">vehicle</text><rect style="fill:#00897b" class="s1f" x="501.6" y="30" width="48.0" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="525.6" y="50" text-anchor="middle">mixed</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="48" y="90" text-anchor="end">density</text><rect style="fill:#00897b" class="s1f" x="60.0" y="70" width="19.2" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="69.6" y="90" text-anchor="middle">1</text><rect style="fill:#00897b" class="s1f" x="89.2" y="70" width="19.2" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="98.8" y="90" text-anchor="middle">4</text><rect style="fill:#00897b" class="s1f" x="118.4" y="70" width="26.4" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="131.6" y="90" text-anchor="middle">10</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="48" y="130" text-anchor="end">padding</text><rect style="fill:#00897b" class="s1f" x="60.0" y="110" width="40.8" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="80.4" y="130" text-anchor="middle">none</text><rect style="fill:#00897b" class="s1f" x="110.8" y="110" width="62.4" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="142.0" y="130" text-anchor="middle">6 units</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="48" y="170" text-anchor="end">coreference</text><rect style="fill:#00897b" class="s1f" x="60.0" y="150" width="19.2" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="69.6" y="170" text-anchor="middle">1</text><rect style="fill:#00897b" class="s1f" x="89.2" y="150" width="19.2" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="98.8" y="170" text-anchor="middle">2</text><rect style="fill:#00897b" class="s1f" x="118.4" y="150" width="19.2" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="128.0" y="170" text-anchor="middle">4</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="48" y="210" text-anchor="end">distractors</text><rect style="fill:#00897b" class="s1f" x="60.0" y="190" width="33.6" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="76.8" y="210" text-anchor="middle">off</text><rect style="fill:#00897b" class="s1f" x="103.6" y="190" width="26.4" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="116.8" y="210" text-anchor="middle">on</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="48" y="250" text-anchor="end">carrier</text><rect style="fill:#00897b" class="s1f" x="60.0" y="230" width="69.6" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="94.8" y="250" text-anchor="middle">KYC form</text><rect style="fill:#00897b" class="s1f" x="139.6" y="230" width="76.8" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="178.0" y="250" text-anchor="middle">bank stmt</text><rect style="fill:#00897b" class="s1f" x="226.4" y="230" width="40.8" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="246.8" y="250" text-anchor="middle">chat</text><rect style="fill:#00897b" class="s1f" x="277.2" y="230" width="48.0" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="301.2" y="250" text-anchor="middle">email</text><rect style="fill:#00897b" class="s1f" x="335.2" y="230" width="62.4" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="366.4" y="250" text-anchor="middle">medical</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="48" y="290" text-anchor="end">control</text><rect style="fill:#a4560b" class="s2f" x="60.0" y="270" width="69.6" height="30" rx="4" opacity="0.16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="94.8" y="290" text-anchor="middle">zero-PII</text></svg></figure>

<p>The grid is not a full cross product. That would be 1,260 types, most of them uninformative
corners. So, it is a bit custom to ensure data generated is real-looking.</p>
<h2>Reward Design</h2>
<p>RL needs a <em>reward</em> for every attempt. If a human has to read the output, the loop is too slow; if
another model judges it, the reward is itself an unreliable model. So the first requirement is
narrow and non-negotiable: <strong>correctness has to be decidable by a deterministic program.</strong></p>
<p>I decided on a non-binary reward scoring function. It is a few hundred lines of pure functions —
no model, no network call, nothing that can be argued with — and it scores every answer from 0
to 1 as a weighted sum of five checks:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>component</th>
<th>weight</th>
<th>question</th>
</tr>
</thead>
<tbody>
<tr>
<td>recall</td>
<td><strong>0.40</strong></td>
<td>of the PII actually present, how much was found?</td>
</tr>
<tr>
<td>precision</td>
<td><strong>0.25</strong></td>
<td>of the spans emitted, how many were really PII?</td>
</tr>
<tr>
<td>coreference</td>
<td>0.15</td>
<td>were "Meera Iyer" and "Ms. Iyer" linked as one person?</td>
</tr>
<tr>
<td>grounding</td>
<td>0.10</td>
<td>did every emitted span appear verbatim in the document?</td>
</tr>
<tr>
<td>type accuracy</td>
<td>0.10</td>
<td>right span <em>and</em> right label — PAN versus passport?</td>
</tr>
</tbody>
</table></div>
<p><strong>Recall outweighs precision</strong> because a missed Aadhaar number is a regulatory incident and an
over-redacted street name is merely an annoyance.</p>
<p><strong>Potential prevention of reward hacking</strong> Emit nothing and recall
collapses; emit everything and precision collapses. RL will find a shortcut if one exists, so
both have to be unprofitable before training starts. Grounding ensures that the model doesn't make up stuff.</p>
<p><strong>The score is dense, not binary</strong>, and that makes it easier to find a useful <em>gradient</em>.</p>
<hr>
<h2>Finding the advantage - is there scope to learn more from RL ?</h2>
<p>Next was to see if the base model actually had a scope to improve or already solves our problem.
I used standard metrics to quantify this. The samples where we couldn't find a useful gradient is essentially not helpful data. As I mentioned earlier, this is essentially 'trial and error'.</p>
<p>Before going further, few definitions (aka jargon) used in the context of RL</p>
<ul>
<li><strong>Environment</strong> — for a language model there is no game world or a setup. It is a prompt plus a scoring
  function: show a document, let the model answer, score the answer.</li>
<li><strong>Rollout</strong> — one complete attempt. Sampled at temperature 1.0, not greedy, because the whole
  method depends on the same prompt producing <em>different</em> answers on different attempts.</li>
<li><strong>Group</strong> — the <strong>k</strong> rollouts generated from one prompt. This is the unit that matters. Why groups of K ? That is because of the way <a href="https://arxiv.org/abs/2402.03300" target="_blank" rel="noopener"><em>GRPO</em></a> works.</li>
<li><strong>Policy</strong> — the model being trained, understood as a probability distribution over next
  tokens. This is the <em>policy that we optimise</em> using the <em><a href="https://arxiv.org/abs/2402.03300" target="_blank" rel="noopener">Grouped Relative Policy Optimization (GRPO)</a></em>.</li>
<li><strong>Effective fraction</strong> is the share of groups that are useful, and it is the one that says whether RL can learn here at all.</li>
<li><strong>pass@k</strong> asks whether <em>any</em> of the k attempts cleared the bar. This says whether model <em>can</em> give the right answer.</li>
<li><strong>Mean reward</strong> says how well on average was the task ever done properly.</li>
</ul>
<p>So, to find data where where a useful learning signal exists, I need to setup an <em>environment</em> where I can generate <em>rollouts</em>, score them with a reward function, and compute <em>relative advantage</em> within each groups of K.</p>
<p>The scale of rollouts follows from the grid. Each of the 53 types gets 10 documents, and each document gets k rollouts:</p>
<pre><code>53 types × 10 documents          =    530 prompts
530 prompts × 32 rollouts        = 16,960 rollouts
</code></pre>
<p>Two passes were run this way, at k=8 and k=32.</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>pass</th>
<th>k</th>
<th>rollouts</th>
<th>weights changed</th>
</tr>
</thead>
<tbody>
<tr>
<td>scope measurement</td>
<td>8</td>
<td>15,360</td>
<td>no</td>
</tr>
<tr>
<td>scope verification</td>
<td>32</td>
<td>16,960</td>
<td>no</td>
</tr>
</tbody>
</table></div>
<p>Running the untrained model across that grid shows where the difficulty actually lives:</p>
<figure class="chart"><svg style="overflow:visible;width:100%;height:auto" viewBox="0 0 720 296" role="img" aria-label="Recall and pass@8 both fall sharply as PII items per document rise from 1 to 10: recall 0.51 to 0.17, pass@8 97 percent to 11 percent."><rect style="fill:#00897b" class="s1f" x="60" y="11" width="14" height="10" rx="2"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="key" x="84" y="20">recall</text><rect style="fill:#a4560b" class="s2f" x="250" y="11" width="14" height="10" rx="2"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="key" x="274" y="20">pass@8 — any of 8 attempts is correct</text><g class="grid"><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="246.0" x2="702" y2="246.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="195.0" x2="702" y2="195.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="144.0" x2="702" y2="144.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="93.0" x2="702" y2="93.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="42.0" x2="702" y2="42.0"/></g><g class="tick"><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="250.0" text-anchor="end">0.0</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="199.0" text-anchor="end">0.2</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="148.0" text-anchor="end">0.5</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="97.0" text-anchor="end">0.8</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="46.0" text-anchor="end">1.0</text></g><rect style="fill:#00897b" class="s1f" x="103.0" y="141.8" width="60" height="104.2" rx="4"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="133.0" y="133.8" text-anchor="middle">0.51</text><rect style="fill:#a4560b" class="s2f" x="167.0" y="48.1" width="60" height="197.9" rx="4"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="197.0" y="40.1" text-anchor="middle">0.97</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="167.0" y="268" text-anchor="middle">1 item</text><rect style="fill:#00897b" class="s1f" x="317.0" y="195.0" width="60" height="51.0" rx="4"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="347.0" y="187.0" text-anchor="middle">0.25</text><rect style="fill:#a4560b" class="s2f" x="381.0" y="154.2" width="60" height="91.8" rx="4"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="411.0" y="146.2" text-anchor="middle">0.45</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="381.0" y="268" text-anchor="middle">4 items</text><rect style="fill:#00897b" class="s1f" x="531.0" y="210.5" width="60" height="35.5" rx="4"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="561.0" y="202.5" text-anchor="middle">0.17</text><rect style="fill:#a4560b" class="s2f" x="595.0" y="223.6" width="60" height="22.4" rx="4"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="625.0" y="215.6" text-anchor="middle">0.11</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="595.0" y="268" text-anchor="middle">10 items</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="axis" x="381" y="288" text-anchor="middle">PII items per document</text></svg><figcaption>The model essentially misses PII entities if there are more entities in the sample.</figcaption></figure>

<p>Density dominates every other axis. Recall falls threefold from one entity to ten, and the
chance of a clean answer in eight attempts goes from 97% to 11%. Precision meanwhile <em>rises</em>,
from 0.498 to 0.575 (not shown in the graph above) — so the model is not getting noisier under load, it is getting
<strong>incomplete</strong>. For PII redaction use case, that can be dangerous.</p>
<h3>Why any of this is learnable</h3>
<p>GRPO generates <strong>k</strong> answers to one prompt, scores them, and computes each answer's <strong>advantage</strong>
as its distance from <em>its own group's mean</em>:</p>
<pre><code>advantage_i = (reward_i − mean(group)) / std(group)
</code></pre>
<p>The group is the baseline here.</p>
<blockquote>
<p><em>If all k answers score the same, the spread is zero, every advantage is zero, and that prompt</em>
<em>teaches nothing. This type is essentially a useless sample</em></p>
</blockquote>
<p>This is also where the dense reward earns its place. Take a group of eight failures. Under binary
scoring: eight zeros, no spread, <strong>no advantage</strong>. Under partial credit: eight attempts that fail
by <em>different amounts</em>, real spread, a usable advantage.</p>
<blockquote>
<p><em>Whether a task is trainable is not purely a property of the model. It is a joint property of</em>
<em>the model and the reward you wrote.</em></p>
</blockquote>
<h2>Interpretting the base model performance</h2>
<p>The first run of the grid produced a mean reward of <strong>0.303</strong>, and roughly a quarter of that
shortfall had nothing to do with finding PII.</p>
<p>The model has to return JSON. When it returns something that is not JSON the score is <strong>0.0
regardless of content</strong> — a gate failure — and it was failing that gate <strong>23% of the time</strong>. A
quarter of the apparent incompetence was punctuation.</p>
<p>The cheap fix is <strong>guided decoding</strong>, and it needs no training at all. Rather than sampling
freely and hoping, the sampler is constrained at every step to tokens that keep the output
matching a grammar. In vLLM that is a <code>guided_decoding</code> regex or JSON schema passed with the
sampling parameters; it compiles to a finite-state machine over the vocabulary and masks every
token that would break the pattern. Malformed output becomes structurally impossible, if we use this method.</p>
<p>Re-running the identical grid under the constraint:</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>decoding</th>
<th>gate failures</th>
<th>mean reward</th>
</tr>
</thead>
<tbody>
<tr>
<td>free-form</td>
<td>23%</td>
<td>0.303</td>
</tr>
<tr>
<td>guided</td>
<td><strong>2%</strong></td>
<td><strong>0.437</strong></td>
</tr>
</tbody>
</table></div>
<p><strong>+0.134 with zero training.</strong> That number is worth isolating, because it separates two problems
that usually get argued about rather than measured. This is the cheapest reward lift I got in the entire exercise ! 
Formatting has exactly one correct target shape, which makes it a supervised fine-tuning job — and here it did not even need that much.
The remaining 0.563 to the ceiling is capability, which I hope can come out of RL !</p>
<p>Guided decoding is also the better <em>instrument</em> than fixing format by SFT training, because it
changes no weights and so cannot leak task knowledge into the model being measured. It is not
free at run time — grammar-constrained sampling in vLLM ran at 5,648 tokens/second against 23,543
free-form, a <strong>4.2× throughput penalty</strong> — which is fine for a measurement job.</p>
<h3>Increasing K can potentially make more of your data useful</h3>
<p>The k=8 pass reported <strong>10 of the 53 types as never solved</strong>. Essentially their pass@8 scores were 0. The obvious reading is that these
are beyond the model, RL has nothing to reinforce, and they should be dropped from training.</p>
<p>So I repeated the pass at k=32. Hypothesis was that maybe there are <em>right answers</em> lying around in the model output distribution, which we cannot capture at k=8.</p>
<figure class="chart"><svg style="overflow:visible;width:100%;height:auto" viewBox="0 0 720 250" role="img" aria-label="At k=8, 43 of 53 task types look trainable and 10 look unreachable. At k=32, 51 are trainable and only 2 are unreachable."><rect style="fill:#00897b" class="s1f" x="60" y="11" width="14" height="10" rx="2"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="key" x="84" y="20">carries a gradient</text><rect style="fill:#a4560b" class="s2f" x="250" y="11" width="14" height="10" rx="2"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="key" x="274" y="20">never solved</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="48" y="75" text-anchor="end">k = 8</text><rect style="fill:#00897b" class="s1f" x="60" y="54" width="520.9" height="30" rx="4"/><rect style="fill:#a4560b" class="s2f" x="582.9" y="54" width="119.1" height="30" rx="4"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="72" y="74">43</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="592.9" y="74">10</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="48" y="137" text-anchor="end">k = 32</text><rect style="fill:#00897b" class="s1f" x="60" y="116" width="617.8" height="30" rx="4"/><rect style="fill:#a4560b" class="s2f" x="679.8" y="116" width="22.2" height="30" rx="4"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="72" y="136">51</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="689.8" y="136">2</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="axis" x="60" y="240">53 task types</text></svg><figcaption>Quadrupling the rollouts moved 8 task types from “hopeless” to “trainable”.</figcaption></figure>

<p><strong>Eight of the ten flipped to trainable.</strong></p>
<p>Summary of the metric movement when we changed measurement from k=8 to k=32. The <em>effective fraction</em> increase tells you that we got more data than before which are trainable.</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th>metric</th>
<th>mean absolute change, k=8 → k=32</th>
</tr>
</thead>
<tbody>
<tr>
<td>mean reward</td>
<td>0.042</td>
</tr>
<tr>
<td>effective fraction</td>
<td>0.013</td>
</tr>
<tr>
<td><strong>pass@k</strong></td>
<td><strong>0.238</strong></td>
</tr>
</tbody>
</table></div>
<p>The RL scoping verdict: <strong>51 of 53 types carried a usable advantage</strong>, with effective fraction at
98–100% almost everywhere, including types solved about 1% of the time.</p>
<hr>
<h2>Training runs</h2>
<div class="tablewrap"><table>
<thead>
<tr>
<th>setting</th>
<th>value</th>
</tr>
</thead>
<tbody>
<tr>
<td>algorithm</td>
<td>GRPO</td>
</tr>
<tr>
<td>policy</td>
<td><code>Qwen2.5-0.5B-Instruct</code></td>
</tr>
<tr>
<td>prompts per step</td>
<td>4</td>
</tr>
<tr>
<td>rollouts per prompt (k)</td>
<td>8</td>
</tr>
<tr>
<td>trajectories per step</td>
<td><strong>32</strong></td>
</tr>
<tr>
<td>decoding</td>
<td>guided, constrained to the answer schema</td>
</tr>
<tr>
<td>optimizer steps</td>
<td>150</td>
</tr>
<tr>
<td>learning rate</td>
<td>2e-6, annealed to zero</td>
</tr>
<tr>
<td>KL penalty (β)</td>
<td>0.02</td>
</tr>
<tr>
<td>total rollouts</td>
<td><strong>~4,800</strong></td>
</tr>
</tbody>
</table></div>
<p>Read as a loop: at each training step, sample 4 prompts and generate 8 constrained rollouts per
prompt, producing 32 trajectories. Score each trajectory with the reward function and use GRPO to
calculate relative advantages among the eight responses to each prompt. Use those advantages to
estimate the policy gradient, while applying a 0.02 KL penalty to discourage excessive deviation
from the reference policy. Update the model starting with a learning rate of 2e-6, gradually
decay it to zero, and repeat for 150 optimizer steps — yielding about 4,800 total rollouts.</p>
<figure class="chart"><svg style="overflow:visible;width:100%;height:auto" viewBox="0 0 720 430" role="img" aria-label="Training reward rises from 0.43 to about 0.93 over 151 steps. Sealed evaluations move from 0.46 at step 0 to 0.92 at step 150 on seen types and 0.85 on held-out types. Below, the share of groups producing no gradient rises from near zero to about 0.37."><line style="stroke:#00897b;stroke-width:2" class="s1" x1="60" y1="16" x2="76" y2="16"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="key" x="84" y="20">train reward, per step</text><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="246" cy="16" r="5"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="key" x="258" y="20">sealed eval, seen types</text><rect style="fill:none;stroke:#a4560b;stroke-width:2" class="ring2" x="426" y="11" width="10" height="10"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="key" x="444" y="20">held-out types</text><g class="grid"><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="250.0" x2="702" y2="250.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="191.7" x2="702" y2="191.7"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="133.4" x2="702" y2="133.4"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="75.1" x2="702" y2="75.1"/></g><g class="tick"><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="254.0" text-anchor="end">0.3</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="195.7" text-anchor="end">0.5</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="137.4" text-anchor="end">0.7</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="79.1" text-anchor="end">0.9</text></g><polyline style="fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round;stroke:#00897b;stroke-width:2" class="s1" points="74.2,220.2 78.4,215.6 82.6,134.2 86.7,174.8 90.9,172.0 95.1,128.4 99.3,138.9 103.5,159.9 107.7,126.9 111.9,102.4 116.0,101.0 120.2,110.2 124.4,102.4 128.6,81.1 132.8,79.5 137.0,121.0 141.2,167.6 145.3,126.0 149.5,128.3 153.7,140.4 157.9,132.6 162.1,110.4 166.3,106.6 170.5,80.2 174.6,105.2 178.8,109.9 183.0,117.4 187.2,103.9 191.4,89.3 195.6,138.5 199.7,96.9 203.9,109.9 208.1,93.5 212.3,144.4 216.5,135.1 220.7,105.1 224.9,100.1 229.0,101.8 233.2,107.3 237.4,109.6 241.6,88.6 245.8,82.6 250.0,98.6 254.2,75.9 258.3,68.0 262.5,70.0 266.7,75.3 270.9,91.0 275.1,89.6 279.3,79.7 283.5,70.2 287.6,68.0 291.8,79.4 296.0,96.8 300.2,123.4 304.4,77.7 308.6,108.5 312.8,71.5 316.9,86.0 321.1,93.7 325.3,60.8 329.5,72.8 333.7,86.2 337.9,77.7 342.1,81.0 346.2,87.6 350.4,68.8 354.6,64.1 358.8,68.5 363.0,93.2 367.2,100.9 371.4,58.4 375.5,91.6 379.7,78.1 383.9,71.1 388.1,68.1 392.3,66.9 396.5,70.4 400.6,60.4 404.8,80.8 409.0,83.0 413.2,74.9 417.4,63.1 421.6,53.7 425.8,86.0 429.9,63.7 434.1,78.1 438.3,65.9 442.5,76.2 446.7,85.2 450.9,66.2 455.1,66.5 459.2,74.3 463.4,63.7 467.6,69.2 471.8,71.0 476.0,71.5 480.2,83.9 484.4,63.8 488.5,68.7 492.7,61.0 496.9,66.0 501.1,60.9 505.3,71.8 509.5,84.4 513.7,67.6 517.8,72.8 522.0,60.3 526.2,67.8 530.4,80.6 534.6,64.8 538.8,63.7 543.0,59.3 547.1,95.9 551.3,79.0 555.5,72.8 559.7,59.4 563.9,79.1 568.1,54.7 572.3,69.9 576.4,65.6 580.6,64.6 584.8,101.5 589.0,55.3 593.2,62.5 597.4,75.7 601.5,72.1 605.7,48.6 609.9,64.2 614.1,76.6 618.3,65.7 622.5,58.8 626.7,60.5 630.8,91.1 635.0,75.5 639.2,67.3 643.4,56.7 647.6,73.0 651.8,78.5 656.0,61.5 660.1,55.5 664.3,118.5 668.5,68.0 672.7,60.5 676.9,66.6 681.1,72.9 685.3,73.0 689.4,62.4 693.6,76.1 697.8,73.1"/><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="70.0" cy="202.8" r="5"/><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="383.9" cy="69.8" r="5"/><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="697.8" cy="67.9" r="5"/><rect style="fill:none;stroke:#a4560b;stroke-width:2" class="ring2" x="65.0" y="183.7" width="10" height="10"/><rect style="fill:none;stroke:#a4560b;stroke-width:2" class="ring2" x="378.9" y="80.7" width="10" height="10"/><rect style="fill:none;stroke:#a4560b;stroke-width:2" class="ring2" x="692.8" y="83.9" width="10" height="10"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f;font-style:italic" class="note" x="689.8" y="53.9" text-anchor="end">0.925 seen · 0.853 held out</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f;font-style:italic" class="note" x="80.0" y="218.8">measured base</text><g class="grid"><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="372.0" x2="702" y2="372.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="336.0" x2="702" y2="336.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="60" y1="300.0" x2="702" y2="300.0"/></g><g class="tick"><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="376.0" text-anchor="end">0.00</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="340.0" text-anchor="end">0.25</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="50" y="304.0" text-anchor="end">0.50</text></g><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="key" x="60" y="288">share of groups with zero gradient</text><polyline style="fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round;stroke:#a4560b;stroke-width:2" class="s2" points="74.2,367.7 174.6,367.7 279.3,332.4 383.9,328.8 488.5,320.9 593.2,320.2 697.8,318.7"/><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="74.2" cy="367.7" r="3.5"/><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="174.6" cy="367.7" r="3.5"/><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="279.3" cy="332.4" r="3.5"/><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="383.9" cy="328.8" r="3.5"/><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="488.5" cy="320.9" r="3.5"/><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="593.2" cy="320.2" r="3.5"/><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="697.8" cy="318.7" r="3.5"/><g class="tick"><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="70.0" y="396" text-anchor="middle">0</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="279.3" y="396" text-anchor="middle">50</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="488.5" y="396" text-anchor="middle">100</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="697.8" y="396" text-anchor="middle">150</text></g><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="axis" x="381" y="422" text-anchor="middle">training step</text></svg><figcaption>The jagged line is not a learning curve — each step draws four prompts of wildly different difficulty, so most of its movement is which types were drawn. The markers are the claim. The lower panel is the reason to stop: by the end, more than a third of every step's rollouts scored identically to each other and produced no gradient at all, indicating that the model has learnt enough.</figcaption></figure>

<div class="tablewrap"><table>
<thead>
<tr>
<th></th>
<th>reward</th>
</tr>
</thead>
<tbody>
<tr>
<td>base, measured at step 0</td>
<td>0.462 seen · 0.510 held out</td>
</tr>
<tr>
<td>step 150</td>
<td><strong>0.925 seen</strong> · 0.853 held out</td>
</tr>
</tbody>
</table></div>
<p>Reward roughly doubled, and held on 11 document types that were never trained on (the held out set). Every reward
component rose together — recall eightfold, alongside precision, grounding and coreference —
which is the standard check for reward hacking, since a model gaming one term shows that term
running away while the others stall.</p>
<h3>Reading an RL training run is a bit different from SFT run</h3>
<p><strong>Train reward is not a learning curve.</strong> In SFT, training loss falls smoothly because every
step sees the same kind of example. Here each step draws 4 prompts from types that differ
enormously in difficulty, so most of the step-to-step movement is <em>which types were drawn</em>, not
whether the model improved. Read it over ~20-step windows, or not at all.</p>
<p><strong>The loss is not the metric.</strong> The SFT habit of watching train-loss against eval-loss
maps here onto <strong>train reward against sealed-eval reward</strong>.</p>
<p><strong>The degenerate-group rate is the stopping signal</strong>, and it has no SFT analogue at all. The share of groups where all eight rollouts scored
identically and therefore produced no gradient. It is exactly <code>1 − effective fraction</code>, measured live. It climbed from 0.03 to <strong>0.37</strong>.</p>
<hr>
<h2>Final results</h2>
<p>These numbers are the end of several iterations rather than one run, and the config moved as
issues surfaced. Recall and precision started at 0.40/0.25 and were rebalanced to 0.325 each;
weight later shifted to type accuracy (0.10 → 0.25) once the component log showed it was the
only term still varying. Training settled at 150 steps, because external scores
peak around there and decay afterwards.</p>
<div class="tablewrap"><table>
<thead>
<tr>
<th></th>
<th>base</th>
<th>trained</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>sealed reward, trained document types</td>
<td>0.462</td>
<td>0.925</td>
<td><span style="color:#0a7d55;font-weight:640" class="up">▲ +0.463</span></td>
</tr>
<tr>
<td>sealed reward, held-out document types</td>
<td>0.510</td>
<td>0.853</td>
<td><span style="color:#0a7d55;font-weight:640" class="up">▲ +0.343</span></td>
</tr>
<tr>
<td>external strict token F1</td>
<td>0.105</td>
<td>0.406</td>
<td><span style="color:#0a7d55;font-weight:640" class="up">▲ +0.301</span></td>
</tr>
<tr>
<td>external typeless token F1</td>
<td>not measured</td>
<td>0.582</td>
<td><span style="color:#7d8b95;font-weight:640" class="flat">—</span></td>
</tr>
<tr>
<td>unparseable JSON (guided decoding, no training)</td>
<td>23%</td>
<td>2%</td>
<td><span style="color:#0a7d55;font-weight:640" class="up">▲ −21 pts</span></td>
</tr>
<tr>
<td>external token precision, steps 50 → 300</td>
<td>0.391</td>
<td>0.253</td>
<td><span style="color:#b3341a;font-weight:640" class="down">▼ −0.138</span></td>
</tr>
</tbody>
</table></div>
<p>Measured on 600 documents with 3,214 gold spans across 12 entity types, written by someone else.
Strict token F1 requires the right span <em>and</em> the right label; typeless requires only the right
span. A 0.5B model went from 0.105 to 0.406 on data it had never seen, for about a dollar of GPU.</p>
<p><strong>Why external precision falls as training continues is the open question.</strong> It drops at every
checkpoint — 0.391 at step 50 to 0.253 at step 300 — while the reward's own precision component
sits near 0.96 and flat throughout, because a document made only of PII contains nothing that
should not be extracted. Something about longer training makes the model reach further, and the
training signal cannot see it happening. <strong>I do not have the answer yet !</strong></p>]]></content:encoded>
  </item>
  <item>
    <title>Learning to fine-tune small models, and what the data kept teaching me</title>
    <link>https://raahulpradeep.com/blog/model-fine-tuning-learnings</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/model-fine-tuning-learnings</guid>
    <pubDate>Fri, 07 Aug 2026 12:00:00 +0000</pubDate>
    <description>A big general-purpose language model can do almost anything, and you pay for that breadth on every single call. The claim I wanted to test is that for one…</description>
    <content:encoded><![CDATA[<p>A big general-purpose language model can do almost anything, and you pay for that breadth on
every single call. The claim I wanted to test is that for one narrow job you can take a much
smaller open model, train it a little on your own examples, and end up with something both
cheaper to run and better at that one job than the big model. Cheaper is easy to believe.
Better is the surprising half.</p>
<p>Training a model further on your own examples is called <strong>fine-tuning</strong>, and the claim only
means anything if you can measure it. So I picked three jobs where a computer — not a human
reading the output and nodding — can decide whether an answer is right. I took these <strong>machine verifiable</strong> use cases to also try and learn how to do Reinforcement Learning with Verifiable Rewards (RLVR). I also thought that these are narrow enough for a small model to learn. The three use cases I tried are:</p>
<ul>
<li><strong>Text to PromQL.</strong> A question in English goes in ("how much memory is this service using?"),
  a query in a monitoring language called PromQL (Prometheus Query Language) comes out. You can just run the query and see whether it returns what it should.</li>
<li><strong>Address cleanup.</strong> A messy Indian postal address goes in, tidy labelled fields come out.
  Since I generated the messy version from the tidy one, I know the right answer exactly.</li>
<li><strong>Screenshot to HTML.</strong> A picture of a web page goes in, the code to rebuild it comes out.
  Render that code and compare the result to the original picture.</li>
</ul>
<p>The common shape: a program can score the answer. That is the constraint the whole project
rests on, because it means I can measure a change instead of forming an impression of one.</p>
<p>What follows is a brief description of the broad learnings that I had.</p>
<hr>
<h2>The training data, and the part of it I could not see</h2>
<p>Training examples came from two places. Where Hugging Face — a public library of datasets and
models — already had something close to my task, I started there. Where it did not, I generated examples myself. I thought generating data would be pretty easy given how good LLMs are now-a-days. For addresses that meant starting from the real India Post directory of
pincodes and localities, building a clean address, and then deliberately messing it up. That way
the clean version is the right answer by construction, for free, and I never have to label
anything by hand.</p>
<p>Then I looked at what I had actually produced, and found three problems. None of them showed up
as an error. All of them capped how good the model could get.</p>
<p><strong>I accidentally built a village dataset.</strong> I picked pincodes uniformly at random from the
19,238 that exist. India has far more villages than cities, so uniform picking gives you mostly
villages: Mumbai got the same chance as any hamlet. Across 2,300 generated addresses the big
metros turned up about eleven times, and Kochi and Gurugram never appeared at all. Real address
traffic is the other way round — it piles up in cities.</p>
<p><strong>One of my mess-up rules did nothing 95.7% of the time.</strong> People write city names badly, so one data generation rule replaced a proper name with a sloppy one — <em>Bengaluru</em> becomes <em>Bangalore</em>, or <em>blr</em>, or
<em>Banglore</em>. Teaching the model to undo that was close to the whole point. But the rule worked off
a hand-written table of ten cities, and if the address's city was not in the table it quietly
left the address alone. My dataset was mostly villages. So the table missed almost every time,
and the single behaviour I most wanted to teach was present in about 4% of my examples.</p>
<p><strong>My table disagreed with my own directory.</strong> I had typed those city names from memory, and the
official directory does not use the names I do. It says <em>Ernakulam</em>, not Kochi. <em>Gurgaon</em>, not
Gurugram.  There is no district called "Delhi" at all — it is nine
separate sub-districts. Every name that did not match was a city that silently never appeared.</p>
<p>None of this is visible in the row count, and the row count is what you instinctively check. Four
thousand examples sounds like plenty.</p>
<blockquote>
<p>What matters is not how many examples you have. It is how many genuinely different situations
they contain.</p>
</blockquote>
<p>There is a second trap in generating your own data, and I walked into it. After training, the
model scored 0.99 on my held-out test examples — near perfect. Then I typed in a real address I
copied off a delivery app:</p>
<pre><code>d1301, sattva sanctity, sarjapura road, bengaluru
</code></pre>
<p>It misspelled the building name and filed the <em>city</em> under locality. The model was not broken. My
generator always wrote addresses in the same field order, used thirteen invented building names,
and always included a locality. Real addresses skip fields, arrive in any order, and are full of
proper nouns no generator would invent. My test examples came out of the same generator as my
training examples, so all that 0.99 ever proved was that the model had learned my <em>simulation</em> of
the problem. Fifty real addresses set aside at the start would have exposed this on day one.</p>
<hr>
<h2>Grading the answers, and then grading the grader</h2>
<p>To measure anything I need a <strong>scorer</strong>: an ordinary program that takes the model's answer and
the correct answer and returns a number between 0 and 1. No second model is involved in judging —
it is just code. For PromQL it runs both queries and compares results. For addresses it compares
the fields one by one. For screenshots it compares the rendered page against the original.</p>
<p>Then there is a step I nearly skipped, and it turned out to matter more than anything else in the
project. <strong>Hand the scorer the correct answer, pretending the model produced it. It has to come
back with exactly 1.0.</strong> A perfect answer must score perfectly. If it scores 0.87, the scorer
itself is wrong, and every number the project produces afterwards is partly measuring that error
rather than the model.</p>
<p>I ran this check — <em>gold against gold</em> — on every task, and it found a bug every time. On
<em>screenshot-to-HTML</em> task, it found four. Two are worth explaining, because they are the kind of thing
that hides forever:</p>
<p><strong>It was comparing where things appear on screen with where they appear in the code.</strong> A block on
a web page has two different orders: the order you see it in, and the order it is written in the
HTML source. CSS can make these disagree — a sidebar can be written second in the source but
displayed on the left. My scorer took the model's <em>on-screen</em> order and compared it against the
original's <em>source-code</em> order. So a flawless reproduction of a page with a sidebar was marked
down for the original page's own layout, scoring 0.429 out of 1.0.</p>
<p><strong>Ties were broken by luck.</strong> To compare two pages I sort their blocks by position — down the
page first, then across. But two blocks can sit at the same position, and then the sort has to
fall back on something else to break the tie. It fell back on whatever order the blocks happened
to be sitting in, which was different for the two lists being compared. The result: 117 documents
came out "different" from themselves. The fix was to add a final tiebreaker so that identical
inputs always sort identically.</p>
<p>The other two were worse in effect. Comparing two empty pages returned 0.55 instead of 1.0. And
position was scored on exact pixels, so a layout shifted 40 pixels — indistinguishable to a
human — scored 0.117, and at 120 pixels it scored zero. That last one meant a fifth of the
available score was <strong>impossible for any model to earn</strong>, because no model reproduces pixel
positions exactly. I would have paid to train against a target that could not be reached, and
then concluded the model was incapable.</p>
<p>The address scorer had a bug with a nastier shape. One of my mess-up rules deleted the pincode
from the input, but the expected answer still contained it. The only way to score well was to
invent a six-digit number that was not there. I was one run away from explicitly training a model
to make up postcodes.</p>
<p>Write the scorer first. Then try to break it.</p>
<hr>
<h2>Ask what the base model can already do</h2>
<p>The most useful hour I spent was the one where I trained nothing at all. I ran the untouched
model — Qwen3.5-0.8B, a small open model — against my scorer, and looked at the results field by
field.</p>
<p>The address task has eight fields. Five are copied out of the input: <code>premise</code> (flat or plot
number), <code>building</code>, <code>street</code>, <code>locality</code>, <code>landmark</code>. Three have to be produced in an official
form: <code>city</code>, <code>state</code>, <code>pincode</code>. Alongside those I score <code>grounded</code>, which asks whether the
model stuck to what the input actually said instead of inventing content.</p>
<pre><code>Qwen3.5-0.8B, before any training (300 held-out addresses)

grounded  █████████████████████  1.00   already solved
pincode   █████████████████░░░░  0.82   already solved
landmark  █████████████░░░░░░░░  0.61
field_f1  █████████░░░░░░░░░░░░  0.44   how well fields match overall
geo       █████████░░░░░░░░░░░░  0.42   do city, state and pincode agree
city      █████░░░░░░░░░░░░░░░░  0.26   ← the actual problem

                     overall score  0.51
</code></pre>
<p>This changed what I built. The model was already refusing to invent things, and already pulling
pincodes out of the text 82% of the time — effort spent there could win almost nothing. Turning a
sloppy city name into its official form sat at 0.26, and the failures repeated: shown a city it
did not recognise, it guessed a familiar big one.</p>
<p>Had I skipped this hour, I would have built a tidy dataset covering all eight fields evenly and
spent most of my budget teaching the model things it could already do.</p>
<hr>
<h2>Training, and knowing when to stop</h2>
<p>So the training set is not a fair sample of the task. It is deliberately skewed towards the
places the model was failing — heavy on unfamiliar city names, light on the pincodes it already
handled.</p>
<p>Then you train, and the question becomes when to stop. Two numbers move while training runs.
<strong>Training loss</strong> measures how well the model fits the examples it is being shown; it will keep
improving for as long as you let it run, which sounds good and is not — past a point it just
means the model is memorising. <strong>Eval loss</strong> measures the same thing on examples it is not being
trained on, which is what you actually care about. So you save the model periodically — each
saved copy is a <strong>checkpoint</strong> — and keep the one where eval loss was lowest.</p>
<figure class="chart">
<svg style="overflow:visible;width:100%;height:auto" viewBox="0 0 720 470" role="img" aria-label="Training and evaluation loss over 240 steps. Training loss falls from 1.23 to 0.38 while evaluation loss falls to 0.4865 at step 160 and then flattens, ending at 0.4873."><line style="stroke:#00897b;stroke-width:2" class="s1" x1="62" y1="18" x2="78" y2="18"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="key" x="86" y="22">training loss</text><line style="stroke:#a4560b;stroke-width:2" class="s2" x1="212" y1="18" x2="228" y2="18"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="key" x="236" y="22">eval loss</text><g class="grid"><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="62" y1="248.0" x2="702" y2="248.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="62" y1="197.0" x2="702" y2="197.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="62" y1="146.0" x2="702" y2="146.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="62" y1="95.0" x2="702" y2="95.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="62" y1="44.0" x2="702" y2="44.0"/></g><g class="tick"><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="52" y="252.0" text-anchor="end">0.30</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="52" y="201.0" text-anchor="end">0.55</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="52" y="150.0" text-anchor="end">0.80</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="52" y="99.0" text-anchor="end">1.05</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="52" y="48.0" text-anchor="end">1.30</text></g><line style="stroke:#7d8b95;stroke-width:1;stroke-dasharray:3 3" class="marker" x1="469.3" y1="44" x2="469.3" y2="248"/><polyline style="fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round;stroke:#00897b;stroke-width:2" class="s1" points="62.0,57.6 120.2,183.7 178.4,188.1 236.5,197.7 294.7,203.5 352.9,214.2 411.1,210.8 469.3,210.8 527.5,221.5 585.6,229.4 643.8,229.0 702.0,231.4"/><polyline style="fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round;stroke:#a4560b;stroke-width:2" class="s2" points="62.0,181.9 120.2,193.4 178.4,199.2 236.5,203.4 294.7,205.2 352.9,207.3 411.1,208.8 469.3,210.0 527.5,207.8 585.6,209.0 643.8,209.6 702.0,209.8"/><g class="grid"><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="62" y1="406.7" x2="702" y2="406.7"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="62" y1="380.0" x2="702" y2="380.0"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="62" y1="353.3" x2="702" y2="353.3"/><line style="stroke:#dfe5e7;stroke-width:1" class="gridline" x1="62" y1="326.7" x2="702" y2="326.7"/></g><g class="tick"><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="52" y="410.7" text-anchor="end">0.49</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="52" y="384.0" text-anchor="end">0.53</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="52" y="357.3" text-anchor="end">0.57</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="52" y="330.7" text-anchor="end">0.61</text></g><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="key" x="62" y="298">eval loss, zoomed</text><line style="stroke:#7d8b95;stroke-width:1;stroke-dasharray:3 3" class="marker" x1="469.3" y1="312" x2="469.3" y2="412"/><polyline style="fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round;stroke:#a4560b;stroke-width:2" class="s2" points="62.0,317.3 120.2,355.0 178.4,374.0 236.5,387.5 294.7,393.5 352.9,400.3 411.1,405.1 469.3,409.0 527.5,402.1 585.6,405.9 643.8,407.9 702.0,408.5"/><circle style="fill:#a4560b;stroke:#fbfcfc;stroke-width:2" class="dot2" cx="469.3" cy="409.0" r="5"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f;font-style:italic" class="note" x="479.3" y="326">best — step 160, eval 0.4865</text><g class="tick"><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="120.2" y="436" text-anchor="middle">40</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="236.5" y="436" text-anchor="middle">80</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="352.9" y="436" text-anchor="middle">120</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="469.3" y="436" text-anchor="middle">160</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="585.6" y="436" text-anchor="middle">200</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" x="702.0" y="436" text-anchor="middle">240</text></g><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="axis" x="382" y="462" text-anchor="middle">training step</text></svg>
<figcaption>The two curves stop agreeing around step 120. Training loss keeps falling to 0.38; eval loss flattens near 0.49 and stays there. The kept checkpoint at step 160 beats step 240 by 0.0008 — which is the honest reading: the curve tells you when to stop, not which exact step to keep.</figcaption>
</figure>

<p>The textbook picture is a U: eval loss falls, reaches a bottom, then climbs again as the model
starts memorising. My run did not do that. Eval loss dropped hard for the first hundred steps and
then went flat — 0.4996, 0.4924, 0.4865, 0.4969, 0.4912, 0.4881, 0.4873 — jittering inside a band
about a thousandth of a point wide, while training loss carried on down from 0.48 to 0.38. The
memorising is real, but you see it as the <em>gap opening between the two lines</em>, not as the eval
line turning upwards.</p>
<p>That makes "keep the best checkpoint" a less precise instruction than it sounds. Step 160 was
picked as best at 0.4865; step 240 finished at 0.4873. The difference is <strong>0.0008</strong>, which is far
too small to mean anything. What the curve honestly told me was <em>when to stop</em> — past about step
120 I was spending money for nothing — not which exact checkpoint to ship.</p>
<h3>Did it learn the job, or memorise my examples?</h3>
<p>This is the question that makes or breaks the whole exercise, and answering it needs the test
examples to be held back in the right way. Splitting rows at random is close to useless here,
because a random test row was built from the same ingredients as the training rows. So I held out
whole <em>categories</em> instead: six entire states appear nowhere in training, not in one single
address, and two of the ways of messing up an address were never shown during training either.</p>
<p>Then I compared the trained and untrained model as two arms of the same run, over the same
addresses, switching the trained part on and off — so nothing but the training differs.</p>
<pre><code>                                          score
base model, untrained                      0.51
trained, familiar territory                0.63   +0.12
trained, mess-up types never seen before   0.64
trained, states never seen before          0.55
</code></pre>
<p>Read the last two lines against the first. The trained model handles kinds of mess it was never
shown, and places it was never shown, better than the untrained model handled the easy case. It
did not simply memorise the cities in its training data — it picked up a <em>method</em>: find the city
in the text and rewrite it properly. Had I split rows at random, every one of these lines would
have read the same and I would have learned nothing.</p>
<p>The same split also showed me what did <strong>not</strong> transfer. Broken down by field:</p>
<pre><code>                  familiar states   unseen states
pincode                0.94             0.92      transfers fine
city                   0.46             0.34      degrades
geo consistency        0.69             0.47      falls apart
</code></pre>
<p>The pincode is sitting right there in the input, so reading it is a skill that works anywhere.
Geographic consistency is different: to check that the city, state and pincode agree, the model
has to <em>know</em> which state a pincode belongs to. That is not a skill, it is a fact — and facts
about states I deliberately withheld were never learned. The model answers confidently and wrong.</p>
<blockquote>
<p>Fine-tuning is good at teaching a method the model can apply to whatever is in front of it. It
cannot install knowledge you kept from it.</p>
</blockquote>
<p>The fix was not more training. Turning a pincode into a state is a lookup in a table I already
had in code. It should never have been the model's job.</p>
<hr>
<h2>You run out of data before you run out of problems</h2>
<p>Eventually the data stops coming. You have used the public datasets that were close enough to be
useful, your generator is producing variations on things it has already produced, and the honest
description of what happens next is that you stop looking rather than that you finish.</p>
<p>That is survivable. What is not survivable is trusting the number you happen to be holding at
that moment.</p>
<p>The temptation at that point is to run the old model over one batch of examples, the new model
over another, compare the two averages and call the difference an improvement. That does not
work, because examples vary enormously among themselves — some are simply harder than others — so
two averages can differ for reasons that have nothing to do with the models. On a small sample
that difference can easily be larger than the effect you are looking for.</p>
<p>The fix costs nothing: run both models over the <em>same</em> examples and compare them one at a time.
My eval script ends up doing something stricter still — it loads the model once and switches the
trained part on and off between the two arms, so the two runs share not just the same examples
but the same weights, the same batching and the same sampling. Nothing differs except the thing
being measured.</p>
<p>Then you deploy, and real traffic starts finding cases your generator never imagined — a field
order you did not think of, a building name no generator would invent, a question phrased in a
way your training set never covered. This is not a failure of the method, it <em>is</em> the method:
production is the only source of examples that is not your own simulation of the problem. The
useful posture is to expect breakage, keep the machinery that turns each break into a new test
case, and stop treating the held-out set as a finish line.</p>
<hr>
<h2>Would Reinforcement Learning have helped?</h2>
<p>There are two broad ways to improve a model. <strong>Supervised fine-tuning</strong> — everything above — shows
it the right answer and says "produce this." <strong>Reinforcement learning</strong> never shows it an answer:
it lets the model try, scores the attempts, and pushes it towards whatever scored better. RL is
the glamorous one, and I wanted it to work. I tried it on all three tasks. It never once helped,
and eventually I understood why.</p>
<blockquote>
<p><em>Reinforcement learning can only strengthen behaviour the model already produces some of the</em>
<em>time. It shifts the odds between things the model already does. It cannot add something that</em>
<em>was never there.</em></p>
</blockquote>
<p>The algorithm I used, <a href="https://arxiv.org/abs/2402.03300" target="_blank" rel="noopener">GRPO</a> <em>(DeepSeek fame)</em>, makes this very concrete. For one question it generates several
answers, scores them all, and works out how much better or worse each was <em>than the average of
that group</em>. That comparison is the entire learning signal — and if all the answers in a group
score the same, there is nothing to compare, the signal is zero, and that question teaches the
model nothing at all.</p>
<p>So before spending anything, ask the untrained model one question: generate eight answers to each
prompt and sort every prompt into three piles.</p>
<pre><code>never solved      all 8 attempts fail       → nothing to reinforce
always solved     all 8 attempts succeed    → nothing to improve
mixed             some succeed, some fail   → THE ONLY USEFUL PILE
</code></pre>
<p>That middle pile is the entire fuel supply. It can be empty for two opposite reasons: the task is
too easy and the model always succeeds, in which case you need harder examples; or the task is
too hard and it always fails, in which case you need supervised fine-tuning first, to get the
model to the point where it sometimes succeeds.</p>
<p>There is a companion measurement called <strong>pass@k</strong>: let the model try k times and count the
question as solved if <em>any</em> attempt worked. Comparing one attempt against eight tells you how
much ability is in there but coming out unreliably.</p>
<figure class="chart">
<svg style="overflow:visible;width:100%;height:auto" viewBox="0 0 720 190" role="img" aria-label="Base model on the address task: pass@1 is 0.346, pass@8 is 0.616 — a gap of 27 points.">
  <text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="118" y="48" text-anchor="end">pass@1</text><rect style="fill:#f1f4f5" class="track" x="132" y="30" width="400" height="26" rx="4"/><rect style="fill:#00897b" class="s1f" x="132" y="30" width="138.4" height="26" rx="4"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="282.4" y="48">0.35</text><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95" class="tick" x="118" y="106" text-anchor="end">pass@8</text><rect style="fill:#f1f4f5" class="track" x="132" y="88" width="400" height="26" rx="4"/><rect style="fill:#00897b" class="s1f" x="132" y="88" width="246.4" height="26" rx="4"/><text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f" class="val" x="390.4" y="106">0.62</text>
  <line style="stroke:#7d8b95;stroke-width:1;stroke-dasharray:3 3" class="marker" x1="270.4" y1="24" x2="270.4" y2="140"/>
  <line style="stroke:#7d8b95;stroke-width:1;stroke-dasharray:3 3" class="marker" x1="378.4" y1="24" x2="378.4" y2="140"/>
  <text style="font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;fill:#7d8b95;fill:#55646f;font-style:italic" class="note" x="324.4" y="158" text-anchor="middle">27 points of latent ability</text>
</svg>
<figcaption>The gap between one sample and eight is the room RL has to work in: the model can already get these right, it just does not do so reliably. No gap, nothing to reinforce.</figcaption>
</figure>

<p>Twenty-seven points of gap looks like a green light. It is not, and the difference is subtle but
decisive: pass@8 counts a question as solved if any of eight attempts worked <em>anywhere across the
whole set</em>. GRPO needs something stricter — a single question where some attempts succeed and
others fail. Measured that way, on the address task:</p>
<pre><code>8 attempts each, across 200 prompts

never solved     0.995      every attempt failed
always solved    0.000      every attempt succeeded
mixed            0.005      1 prompt in 200
</code></pre>
<p>One prompt in two hundred had anything to learn from. Dead on arrival — and I knew it for the
price of one round of generating answers, rather than a training run. Fine-tuning first raised
that to one in a hundred. Still nothing.</p>
<p>Text-to-PromQL is where I stopped arguing with the result, because there I ran RL anyway, twice, and kept every version:</p>
<pre><code>                       valid queries    correct results
base model                  0.33             0.00
RL on the base model        0.33             0.00      ← nothing moved
after fine-tuning           1.00             0.22
RL after fine-tuning        1.00             0.22      ← nothing moved
</code></pre>
<p>Two runs, and both landed exactly where they started. From the base model there was no correct
behaviour to strengthen. From the fine-tuned model the behaviour was already consistent, so every
group of attempts scored alike and there was nothing to compare. The only thing that ever moved
these numbers was showing the model the answer.</p>
<p>RL clearly does work — it is behind a great deal of recent progress. But the conditions it needs
are specific: a task the model can genuinely already do and merely does unreliably, plus a scorer
you trust completely. On small models and narrow, well-specified jobs, I did not find that
situation. Supervised fine-tuning was sitting right there, and it worked.</p>
<hr>
<h2>What this was actually about</h2>
<p>The hypothesis half held up. On addresses, a 0.8-billion-parameter model went from 0.51 to 0.63
and kept most of that on states it had never seen. On PromQL, fine-tuning took a model that
produced valid queries a third of the time to one that produced them every time, and took correct
results from zero to a fifth. Both for an hour on a rented GPU, at a running cost nowhere near a
frontier API call. Screenshot-to-HTML I never got past the starting line, probably due to every model being very good at coding tasks these days. So there I have a measurement rather than a result.</p>
<p>But almost nothing I learned was about training. Training is a script and an hour of rented
compute — a few hundred lines, and the least interesting part of the project.</p>
<p>Every expensive mistake was in the data or the measurement, and every one was silent. Nothing
crashed. The curves looked healthy. The numbers looked plausible. A scorer that could not score a
perfect answer as perfect. A dataset where the key lesson appeared in 4% of rows. A test set that
could only ever measure my own simulation. Not one of
them announced itself; each was found only on debugging a bad model output.</p>
<p>Which is the finding, and it is not a subtle one:</p>
<blockquote>
<p>In fine-tuning, <strong>the datasets are the work</strong>. The training data decides what the model <em>can learn</em>,
and the evaluation data decides whether you are entitled to <em>believe</em> it. Everything else is
plumbing !</p>
</blockquote>]]></content:encoded>
  </item>
  <item>
    <title>Mindblowing GenAI</title>
    <link>https://raahulpradeep.com/blog/weekend-experiments-with-generative-ai-part-1</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/weekend-experiments-with-generative-ai-part-1</guid>
    <pubDate>Mon, 29 May 2023 12:00:00 +0000</pubDate>
    <description>Generative AI has been blowing everyone’s mind. I am no exception. Like many others, I do my daily conversations with ChatGPT or Bard. They wrote me…</description>
    <content:encoded><![CDATA[<p>Generative AI has been blowing everyone’s mind. I am no exception. Like many others, I do my daily conversations with ChatGPT or Bard. They wrote me poems, stories and code. From “meaning of life” to “how do you combine general relativity and quantum mechanics”, I have asked them everything. For the most part, the answers were satisfactory. For some, it made things up (<a href="https://en.wikipedia.org/wiki/Hallucination_(artificial_intelligence)" target="_blank" rel="noopener">hallucination</a>). Like humans, AI can make mistakes too! This remarkable progress in AI has got me excited, worried and confused all at the same time. I truly think this is going to disrupt the tech industry.</p>
<p>In this series of blog posts, I will jot down my learnings and thoughts from the various experiments I will be doing in the quest to understand GenAI deeply.</p>
<p>I have been playing around with <a href="https://python.langchain.com/en/latest/index.html" target="_blank" rel="noopener">LangChain</a> this weekend. I asked my friend Bard to describe LangChain in about 50 words.</p>
<blockquote>
<p>LangChain is a software development framework that simplifies the creation of applications using large language models. It provides a standard interface for LLMs, a selection of LLMs to choose from, and examples of end-to-end applications.</p>
</blockquote>
<p>The one abstraction that stood out for me was Agents. Agents are essentially LLMs which are taught to think step-by-step and reason and act at each step. This prompting framework is <a href="https://www.promptingguide.ai/techniques/react" target="_blank" rel="noopener">ReAct framework</a>. For a question, LLMs are prompted to think step-by-step and tries to reason about the next action to take. Agents are given access to a bunch of tools (e.g Google search, Calculator, Code Generator, etc) which lets them actually take those actions. The outputs of the actions taken are observed and the LLM reasons about the next action to take from those observations. This though-action-observation loop continues and it stops when there is no action to take and the question is answered.</p>
<p>To play around with this, I created a simple app based on LangChain agents which loads your CSV data and lets you chat with it. The agent used Pandas as the tool to do operations on the dataset. I used the Kaggle <a href="https://www.kaggle.com/datasets/loveall/clicks-conversion-tracking" target="_blank" rel="noopener">sale conversion optimization dataset</a> for this experiment.</p>
<p>Here are some of the snippets of the chats I did with this agent. The verbose logs depicts how the agent arrives at the result.</p>
<p><img alt="chat asking for the overall click through rate among males, answered as 0.0014" src="https://raahulpradeep.com/assets/medium/c41aa27ae6be.png"><img alt="verbose agent log reasoning step by step to a pandas expression for male click through rate" src="https://raahulpradeep.com/assets/medium/d25b354fafde.png"></p>
<p>Here, the agent was able to understand what a <em>click through rate</em> means. This is sort of expected since it is a pretty common term and the LLM would have an understanding for this.</p>
<p><img alt="chat asking for the top 5 fb campaigns for males by conversion rate, answered with five campaign ids" src="https://raahulpradeep.com/assets/medium/1ec6bdf4963d.png"><img alt="agent log grouping by campaign and taking the mean of conversions rather than a conversion rate" src="https://raahulpradeep.com/assets/medium/ab61cdaf397f.png"></p>
<p>The agent wasn’t able to understand what <em>conversion rates</em> meant. It instead did all the calculations based on <em>conversions</em>. An honest mistake I guess !</p>
<p><img alt="chat asking for the top 5 fb campaigns by click through rate, answered with ids and their CTRs" src="https://raahulpradeep.com/assets/medium/de1054ba597a.png"><img alt="agent log building the pandas CTR query step by step as clicks divided by impressions" src="https://raahulpradeep.com/assets/medium/0b69052bb7da.png"></p>
<p>It is fascinating how the agent built up the Pandas query step by step. By this point, I am not at all surprised that the LLM understood that <em>CTR</em> and <em>click through rate</em> meant the same.</p>
<p>It seems prompting smartly makes an LLM smarter. Ideas like chain-of-thought reasoning forms the basis of ReAct. I also stumbled upon this — <a href="https://www.promptingguide.ai/" target="_blank" rel="noopener">https://www.promptingguide.ai/</a> , which is a great guide into explaining some of these concepts with examples.</p>]]></content:encoded>
  </item>
  <item>
    <title>Capacity estimation for compute heavy web applications</title>
    <link>https://raahulpradeep.com/blog/capacity-estimation-for-compute-heavy-web-applications</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/capacity-estimation-for-compute-heavy-web-applications</guid>
    <pubDate>Fri, 29 Jul 2022 12:00:00 +0000</pubDate>
    <description>Estimating the number of cores, memory and disk your application needs is pivotal to ensuring optimal resource usage and minimising cost. To understand…</description>
    <content:encoded><![CDATA[<p>Estimating the number of cores, memory and disk your application needs is pivotal to ensuring optimal resource usage and minimising cost. To understand how capacity estimation can be done, we need to understand the kind of application we are dealing with. Broadly an application can be either compute heavy or I/O heavy. In this post, I will cover the key things to keep in mind while estimating capacity for compute heavy applications.</p>
<h2>Compute Heavy Applications</h2>
<p>Web applications which primarily deals with doing expensive computations with minimal or no I/O calls are classified as compute heavy. For example — given a user and their shopping cart, compute the total cart value, given a user and recommended products, score using an ML model and rank them, etc.</p>
<p>In such kind of applications, the most important physical resource are the CPU cores. So, how do you come up with the number of CPU cores required for your application ? Well, we need to look at the requirements.</p>
<p>Let’s say we need to build an e-commerce mobile app which needs to display a ranked list of recommended products to the user on its homepage. We are particularly interested in estimating capacity for the Ranking Service which takes a user and a set of products he recently interacted with as input and outputs a ranked list of products based on scoring and ranking the products using an ML model. Assuming that the ML model is stored in memory and there is no extra I/O required, this service is a compute heavy service. Let’s answer a few basic questions to understand the scale of the system.</p>
<blockquote>
<p><strong><em>What is the maximum no of concurrent users accessing the homepage?</em></strong>Around 100K users at peak.</p>
<p><strong><em>What is the expected latency of a single homepage render for an optimal user experience?</em></strong>Latency should be 500 ms or better.</p>
</blockquote>
<p>As per the requirement, 100K users will be concurrently opening the homepage. So, our application should be able to support 100K concurrency. To enable this, we need to have 100K application threads in our service. Here, a single application thread will independently handle a user at peak load. But how does application threads equate to physical cores?</p>
<p><img alt="four physical cores each running one user's thread, so concurrency equals parallelism" src="https://raahulpradeep.com/assets/medium/7af02760abe2.png"></p>
<p>4 physical cores handling 4 concurrent users. Here, concurrency == parallelism</p>
<p>In a compute heavy application, all the work happens in the CPU core. This means, all of what a single application thread does in our example, will happen in a CPU core. So, can we allocate 100K physical cores to our application? Of course we can ! Would it be optimal? Well, depends on the compute workload. Let’s answer a few more questions.</p>
<blockquote>
<p><strong>What is the on-CPU time for the ranking computation?</strong>Worst case, it can take around 300 ms of CPU time.</p>
</blockquote>
<p>Since our compute takes at most 300 ms on-CPU time, we still have around 200 ms to spare as our target homepage render latencies are around 500 ms. So, if we give 100K physical cores, each core will handle a single user request and the response will be returned in 300 ms. Since we have an extra 200 ms at hand, can we use lesser number of physical cores by trading off latencies? Of course, we can! So, how many physical cores can we save? Here is where it gets tricky.</p>
<h3>Concurrency != Parallelism</h3>
<p>Often we confuse between concurrency and parallelism. In our Ranking Service example, if we had allocated 100K cores to support 100K concurrency, we would have used a parallelism of 100K, by virtue of 100K cores, to support 100K concurrency. While this is totally acceptable, it may not be very optimal.</p>
<p>To understand this better, let’s refresh our memory on how threads are scheduled on CPU. In a typical OS, threads are given a time-slice of the CPU in a round robin manner. Every time a thread is taken out of the CPU, the context (operating data, instructions, etc) is saved so that it can be re-used the next time it is scheduled on the CPU. This is called thread context switch. There is a cost associated with a context switch and it often depends on the context itself.</p>
<p><img alt="two physical cores time-slicing between four user threads, so concurrency exceeds parallelism" src="https://raahulpradeep.com/assets/medium/1f5ca3a08445.png"></p>
<p>2 physical cores handling 4 concurrent users. . Here, concurrency != parallelism</p>
<p>Enough of OS theory ! So, how many cores should the Ranking Service use? The answer is — the number of cores at which off-CPU times (includes context switch times) of a thread aren’t exceeding 200 ms in a 500 ms window. That sounds complicated ! An easy way to determine the number is by performing a load test. We can perform a load test by iterating on the number of cores until your max latencies are 500 ms and concurrency supported is 100K. More often, this will be the limit of your system and CPU utilisation will be very high. Let’s say we performed the load test and the optimal number of cores came out to be 70K. So, we were able to support a concurrency of 100K using a parallelism of 70K.</p>
<h3>Load Distribution</h3>
<p>In our Ranking Service example, based on our load test, we can support a peak concurrency of 100K with 70K physical cores with a max (or 99th percentile) latency of 500 ms. What if the peak load happens only for 1 hour in a day and rest of the 23 hours, the peak never exceeds 50K? This means, for 23 hours in a day, most of our cores will be idle. This is never a good thing since we will paying for that compute unnecessarily.</p>
<p><img alt="user load over time rising to a sharp peak then settling at a much lower steady level" src="https://raahulpradeep.com/assets/medium/45d109ead2fd.png"></p>
<p>If we know the times of the day when the peak load happens, we can spawn additional cores just before the peak hours and destroy the cores after that. This can even be done on demand when you detect extra load. These capabilities of auto / on-demand scaling are very common in pretty much every cloud service provider these days. This way, we can further decrease the overall physical cores footprint of our application.</p>]]></content:encoded>
  </item>
  <item>
    <title>Spark Streaming — the Good, the Bad and the Ugly</title>
    <link>https://raahulpradeep.com/blog/spark-streaming-the-good-the-bad-and-the-ugly</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/spark-streaming-the-good-the-bad-and-the-ugly</guid>
    <pubDate>Mon, 25 Oct 2021 12:00:00 +0000</pubDate>
    <description>Stream processing systems have become increasingly popular in the last decade. It found its usage in a variety of real time systems across the industry.…</description>
    <content:encoded><![CDATA[<p>Stream processing systems have become increasingly popular in the last decade. It found its usage in a variety of real time systems across the industry. There are mainly two ways to implementing a stream processing system — Continuous Streaming and Micro-batches.</p>
<h2>Continuous <strong>streaming</strong></h2>
<p>The likes of Apache Storm and Apache Flink implements this approach. The idea is that the processing happens at the granularity of a single event in the stream. As soon as the event appears in the stream, the system reads it, processes it and moves it forward. Single event processing latencies are very low in this approach.</p>
<h2>Micro-batches</h2>
<p>This approach is born out of fitting a batch processing system into the streaming world. The key idea is to form batches of a small number of events and process them like a batch processing system would do. The system waits a certain duration to collect a batch of events before starting to process them.</p>
<p>Micro-batching approach is a natural evolution of a batch processing system. This extension was elegant and has a lot of positives going for it. But in certain scenarios, it starts to show weaknesses. For the remainder of the post, we will gloss over the good bit (because that is not interesting) and cover the bad bits (the interesting ones) fairly in detail.</p>
<h2>The Good</h2>
<h3>High throughput</h3>
<p>In most of the real life use cases like real time data processing systems, etc micro-batch based stream processing is good enough since we don’t really need the milli-second latencies. For real time data processing systems, we want higher throughput i.e maximum number of events passing through the system in a given time. But if we have a real time chat delivery, it might not be the best idea to implement it in Apache Spark.</p>
<h3>Interoperability with batch processing code</h3>
<p>With many minor changes, you could change your Spark stream processing workloads to batch processing and vice versa. All the key features available in the Spark batch processing world is also available in Spark streaming.</p>
<h2>The Bad and the Ugly</h2>
<p>While most of the drawbacks mentioned below are specifically based on my experience in Spark streaming but it should be equally true for any micro-batch based streaming system.</p>
<h3>Starvation while consuming from multiple stream sources</h3>
<p>Spark streaming allows you to write streaming applications which consumes from multiple streaming sources. For ex — a streaming application to generate real time recommendations can listen to <em>order</em> events and <em>product-view</em> events. While this can seem to be a good thing, it has a bunch of negatives which are hard to ignore. To understand this, let’s take a step back and understand how a Spark streaming application works when you have multiple streams involved.</p>
<p><img alt="two stream sources feeding one micro-batch pipeline that processes, acknowledges, then writes to a sink" src="https://raahulpradeep.com/assets/medium/4173f8bf78fd.jpg"></p>
<p>If computation of the events in one of the streams is more expensive than the other, the throughput of the system with respect to the computationally cheaper stream suffers.</p>
<p>To workaround this, we will have to split this application into two, each consuming from one stream. This too has limitations.</p>
<ul>
<li>Splitting application based on stream sources can only be done if there are interactions between streams.</li>
<li>Additional overhead of managing and maintaining two applications instead of one even though the business logic is very similar.</li>
</ul>
<h3>Plethora of jobs scheduled for one micro-batch</h3>
<p>In spark, every <a href="https://spark.apache.org/docs/latest/rdd-programming-guide.html#actions" target="_blank" rel="noopener"><em>action</em></a> results in a job getting scheduled for the micro-batch. Suppose your streaming application does the following:</p>
<pre><code>// pseudo code for generating popular products and user recommendations
</code></pre>
<pre><code>// read events from stream sources  
product_view_events = sparkContext.readStream('product_view');  
order_events = sparkContext.readStream('product_view');
</code></pre>
<pre><code>// group events for a user  
grouped_product_view_events =    product_view_events.map(_business_logic_function_).groupBy(user_id);
</code></pre>
<pre><code>// group product view events for a product id  
pid_grouped_product_view_events =    product_view_events.map(_business_logic_function_).groupBy(product_id);  
pid_grouped_product_view_events.foreach(_update_view_count_per_day_);
</code></pre>
<pre><code>grouped_order_events =    order_events.map(_business_logic_function_).groupBy(user_id);
</code></pre>
<pre><code>// update recommendations for a user in a database  
grouped_product_view_events.foreach(_update_user_recommendations_);  
grouped_order_events.foreach(_update_user_recommendations_);
</code></pre>
<p>The above application will result in three jobs being scheduled, one for every action (calls to foreach function).</p>
<p>By default, Spark schedules these jobs sequentially. Which means, if the job corresponding to ‘<em>popular products</em>’ take a lot of time, user recommendations would be affected. Overall time taken for a single micro-batch to complete would be the sum of time taken by all the three jobs. Here, the total number of cores needed for the application will be the maximum required cores of all the jobs. But since job executions are sequential, some jobs which require lesser cores will bring down the utilisation of the total cores allocation. This isn’t very efficient.</p>
<p>There are ways to fix this, but it comes with its own challenges and drawbacks. Some of them are:</p>
<h3><strong><em>Running jobs within a micro-batch concurrently</em></strong></h3>
<p>There is a setting in spark which allows us to schedule and execute jobs within a micro-batch concurrently. Because of higher concurrency demand, we would need to allocate more cores to see the real benefit.</p>
<p>In our previous example, there are two jobs which originate from ‘product_view’ events. Running them concurrently would mean that there are multiple reads from the stream source and most of the compute is duplicated. In a sequential execution mode, we can eliminate this concern by <a href="https://spark.apache.org/docs/latest/rdd-programming-guide.html#rdd-persistence" target="_blank" rel="noopener">caching</a> intermediate results.</p>
<h3>Breaking jobs into multiple applications</h3>
<p>Breaking applications based on stream number of jobs adds an additional overhead of managing the code and deployment for each of these applications. This particularly becomes painful when the multiplexing factor between stream source and dependent jobs is high.</p>
<p>Similar to concurrent job executions, this also has the issue of compute duplication.</p>
<h3>No HA during application upgrades</h3>
<p>When you deal with real-time applications, you <strong>cannot</strong> compromise on its availability. In case of simple web service where you have many machines running the same application code, any deployment strategy supporting high availability would have some machine up and running at all times. This ensures a smooth upgrade of the application.</p>
<p>In a real time streaming application based on Spark streaming, this is not really possible out of the box. This is because there is one component — spark driver which is responsible for determining and schedule the next micro-batch. The driver is really a single point of failure. So, any downtime of driver causes a downtime of the application. But Spark master limits the damage by supervising it. So, any driver failure will be noticed by the Spark master and it restarts it.</p>
<h2>Light at the end of the tunnel</h2>
<p>Some of the drawbacks of enabling concurrent job executions like higher resource demand and inability to use caching is a necessary evil. If the re-compute cost is fairly small and you have enough capacity for the higher resource demand, then simply enabling concurrent job executions in Spark streaming can improve your application’s overall performance.</p>
<p>Continuous streaming approach doesn’t have a single driver responsible for cutting a micro-batch. This means that every node / thread in your distributed continuous streaming application will independently be consuming and processing events. This approach seems to eliminate some of the concerns we have with micro-batch based streaming. Fortunately, Apache Spark has an experimental <a href="https://spark.apache.org/docs/latest/structured-streaming-programming-guide.html#continuous-processing" target="_blank" rel="noopener">continuous streaming mode</a>, which do seem like a more suitable approach for real time streaming applications.</p>]]></content:encoded>
  </item>
  <item>
    <title>The “I” in ACID — Weak Isolation Levels</title>
    <link>https://raahulpradeep.com/blog/the-i-in-acid-weak-isolation-levels</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/the-i-in-acid-weak-isolation-levels</guid>
    <pubDate>Wed, 20 Oct 2021 12:00:00 +0000</pubDate>
    <description>ACID is an acronym for Atomicity, Consistency, Isolation and Durability. ACID compliant databases provides</description>
    <content:encoded><![CDATA[<p>ACID is an acronym for <strong>A</strong>tomicity, <strong>C</strong>onsistency, <strong>I</strong>solation and <strong>D</strong>urability. ACID compliant databases provides</p>
<ul>
<li><strong>Atomicity</strong>, if it has the ability to group multiple statements together and execute it as if it were executing a single atomic statement. There can only be two outcomes for such an execution, either all the statements execute successfully or all statements fail. This atomic execution is called a <em>transaction</em>.</li>
<li><strong>Consistency</strong>, if it can uphold your data invariants. This is highly dependent on the data modelling. If the modelling takes care of defining these invariants using the integrity constraint mechanisms that the database provides, a consistent (as per ACID) database ensures that those invariants hold true always.</li>
<li><strong>Isolation</strong>, if the database guarantees that concurrent transactions don’t affect each other.</li>
<li><strong>Durability</strong>, if the database ensures that every transaction that is committed are not lost. Absolute durability is not possible of course (total failure scenarios), but a durable database ensures that it safeguards against known patterns of failures.</li>
</ul>
<p>In this post, we will pivot around <strong>Isolation</strong>. Absolute isolation is possible if all transactions are <strong><em>serializable</em></strong> i.e the final outcome will be the same as if all transactions were executed sequentially. The simplest way to achieve serializability is to force all transactions to execute on a single thread. This would, of course, incur a performance penalty and wouldn’t be suitable for real world applications. Before we look at better ways to achieve serializability, we will look at some of the weaker isolation levels that databases provide.</p>
<h2>Why do we need isolation at all ?</h2>
<p><img alt="user 2 reads the new email but sees an unread count of 0 before user 1's update commits" src="https://raahulpradeep.com/assets/medium/93a18463b2ec.png"></p>
<p>Dirty reads. Ref: “Designing Data-Intensive Applications” by Martin Kleppmann</p>
<p>Here, user 1 sends an email to user 2. Sending an email involves adding an entry for the new email for user 2 and incrementing the unread count for user 2. If there were no isolation at all, there is a possibility that user 2 would see stale unread count even after receiving the new email.</p>
<p><img alt="interleaved writes from Alice and Bob leave the listing owned by Bob and the invoice by Alice" src="https://raahulpradeep.com/assets/medium/910ae196b05c.png"></p>
<p>Dirty writes. Ref: ‘Designing Data Intensive Systems’ by Martin Kleppman</p>
<p>This could happen because reads of un-committed records are possible. These reads are called <strong><em>dirty reads</em></strong>. Similar to dirty reads, there could be <strong><em>dirty writes</em></strong> as well, if a transaction overwrites an uncommitted value.</p>
<h2>Read Committed Isolation</h2>
<p>In a read-committed isolation level, dirty reads are prevented by making sure the database only allows reading committed entries.</p>
<p><img alt="user 2 keeps reading x as 2 until user 1 commits, and only then sees 3" src="https://raahulpradeep.com/assets/medium/c781d87b6a3c.png"></p>
<p>No dirty reads. Ref: “Designing Data-Intensive Applications” by Martin Kleppmann</p>
<p>To prevent dirty writes, every transaction can take a row level lock which will prevent any other transaction to overwrite an uncommitted value.</p>
<p>Dirty reads can also be prevented by having the read transaction also taking the same lock but this could really hamper performance of read heavy systems. Any long running write transaction could block the read transactions for a long time. Ideally we don’t want writers to block readers or readers to block writers.</p>
<p>For every row, if we store both committed as well as uncommitted versions, any reader can just use the committed version while the writer continues its transaction by working on the uncommitted version. Once write transaction is complete, the previous committed version if replaced by the new one, seemingly atomically.</p>
<p>Although, there is a slight overhead of storing two version of every row, it pays off because of the significant improvement in performance.</p>
<h2>Snapshot Isolation</h2>
<p><img alt="Alice reads account 1 before a transfer and account 2 after it, so her total looks like 900" src="https://raahulpradeep.com/assets/medium/84ee6d19cba1.png"></p>
<p>Read skew in read-committed. Ref: “Designing Data-Intensive Applications” by Martin Kleppmann</p>
<p>This is a perfectly valid scenario in read-committed isolation. But from a user point of view, Alice sees a total of 900 in her bank accounts while she was expecting 1000. This expectation mismatch in Alice’s read query is called read skew or non-repeatable read. If Alice repeats her first read query, she will see 600 and a total of 1000 in her two bank accounts, which is consistent. The fact that Alice found the database to be in an inconsistent state needs to be fixed nevertheless.</p>
<p>We want the data to not change within a transaction i.e repeatable read. To achieve this, database provides a higher isolation level called <strong><em>Snapshot Isolation</em></strong>.</p>
<p>To achieve repeatable read, we have to ensure that read queries return the data which was last committed throughout the duration of a read transaction. From the read-committed implementation, we have the last committed data already which we can use here. But if we have a long running read transaction, we would need more than one version of committed data. This is because there could be multiple write transactions that could complete while the read transaction is in progress.</p>
<h3>Multi-version Concurrency Control (MVCC)</h3>
<p>Snapshot isolation is implemented using MVCC, where we have multiple versions of a row. Every new write, carries with it, the transaction id. For every read transaction, all reads will happen on the version of the data belonging to the latest transaction lower than the current read transaction id.</p>
<p><img alt="MVCC rows tagged with created-by and deleted-by transaction ids, so txid 12 reads the older versions" src="https://raahulpradeep.com/assets/medium/b0cbd37bbac8.png"></p>
<p>MVCC in action. Ref: “Designing Data-Intensive Applications” by Martin Kleppmann</p>
<p>Snapshot isolation did solve a lot of our issues but there are still many scenarios where it can lead to erroneous outcomes.</p>
<p><img alt="two users both read the counter as 42 and write 43, so one increment is lost" src="https://raahulpradeep.com/assets/medium/9605d3ac1908.png"></p>
<p>Concurrent read-modify-write operations tend to lose updates even in a snapshot-isolation mode. Ref: “Designing Data-Intensive Applications” by Martin Kleppmann</p>
<p>Here, the final counter value should be 44, but we lost one of the update. To achieve this, we need to make sure both these transactions happen serially. We will discuss <strong><em>Serializability</em></strong> in a future blog post.</p>
<h2>References</h2>
<ul>
<li>“Designing Data-Intensive Applications” by Martin Kleppmann</li>
</ul>]]></content:encoded>
  </item>
  <item>
    <title>Amazon Dynamo — a conceptual overview (Part 3)</title>
    <link>https://raahulpradeep.com/blog/amazon-dynamo-a-conceptual-overview-part-3</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/amazon-dynamo-a-conceptual-overview-part-3</guid>
    <pubDate>Wed, 07 Jul 2021 12:00:00 +0000</pubDate>
    <description>In Part 1 and Part 2 of the series, I talked about all the cool concepts used in Amazon Dynamo. The key aspect of the system is the decentralised nature…</description>
    <content:encoded><![CDATA[<p>In <a href="https://rahulpradeep.medium.com/amazon-dynamo-a-conceptual-overview-part-1-3bdb2e78f08b" target="_blank" rel="noopener">Part 1</a> and <a href="https://rahulpradeep.medium.com/amazon-dynamo-a-conceptual-overview-part-2-a9c88d6870" target="_blank" rel="noopener">Part 2</a> of the series, I talked about all the cool concepts used in Amazon Dynamo. The key aspect of the system is the decentralised nature of its architecture. This enables it to be incrementally scalable. This architecture also brings in challenges in terms of choosing the nodes where the replicas will lie, how would cluster wide changes be propagated to all nodes, etc.</p>
<p>We will first look at how the data placement is done using Consistent Hashing and how this scheme drives the logical topology of the cluster. We will also see how cluster wide knowledge is made available to all nodes in the cluster in the absence of a centralised brain.</p>
<h2>Consistent Hashing</h2>
<p>Any get() or put() request for a key needs to be routed to the node which hold the partition which contains the key. But what is a <strong><em>partition</em></strong> exactly? A partition is a set of keys. As the dataset outgrows the capacity of a single machine, it is partitioned and spread across a number of machines. In this scheme, it is evident that for every request for a key, we need to be able to determine the partition and in-turn the node which holds the partition.</p>
<p>I will shamelessly plug in <a href="https://rahulpradeep.medium.com/consistent-hashing-6583836463c0" target="_blank" rel="noopener">another post of mine describing the concept of consistent hashing</a>. As it is shown in this post, consistent hashing requires the nodes to be laid out logically as a ring. In fact, it won’t be outrageous to think that the ring topology in Amazon Dynamo is more inspired by this than anything else.</p>
<h2>Preference List</h2>
<p>In the consistent hashing algorithm, once the point on the ring is determined by hashing the key, the next node in the clockwise direction in the ring is the node which holds the desired partition.</p>
<p>In a system where there is one master replica and a few followers for a partition, usually the master replica will be the one that is determined from the consistent hashing algorithm. The followers are chosen such that those nodes lie in a different physical machine, rack or even data centre.</p>
<p><img alt="a consistent hashing ring where key K falls between A and B, so nodes B, C and D store it" src="https://raahulpradeep.com/assets/medium/31ba145d9908.png"></p>
<p>In a master-less system like Amazon Dynamo, all the N replicas are determined using the Consistent Hashing algorithm. The next N nodes that lie in the clockwise direction in the ring will hold the replicas for the key range.</p>
<p>This list of N nodes found using the Consistent Hashing scheme are called the <strong><em>preference list.</em></strong></p>
<h2>Gossip Protocol</h2>
<p>In the absence of a centralised brain, how are cluster wide knowledge, like cluster membership changes (node in or node out), etc notified to all nodes ?</p>
<p>Amazon Dynamo uses a protocol called <strong><em>gossip protocol</em></strong> to make information available across all nodes in the cluster. A node randomly chooses another peer node and propagates the information about the changes and this goes on until all the nodes are visited.</p>
<h2>References</h2>
<ul>
<li><a href="https://www.allthingsdistributed.com/2007/10/amazons_dynamo.html" target="_blank" rel="noopener">https://www.allthingsdistributed.com/2007/10/amazons_dynamo.html</a></li>
</ul>]]></content:encoded>
  </item>
  <item>
    <title>Amazon Dynamo — a conceptual overview (Part 2)</title>
    <link>https://raahulpradeep.com/blog/amazon-dynamo-a-conceptual-overview-part-2</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/amazon-dynamo-a-conceptual-overview-part-2</guid>
    <pubDate>Tue, 06 Jul 2021 12:00:00 +0000</pubDate>
    <description>In Part 1 of this series, we saw the various techniques that Amazon Dynamo employs to achieve high availability for write requests. In this post, we will…</description>
    <content:encoded><![CDATA[<p>In <a href="https://rahulpradeep.medium.com/amazon-dynamo-a-conceptual-overview-part-1-3bdb2e78f08b" target="_blank" rel="noopener">Part 1</a> of this series, we saw the various techniques that Amazon Dynamo employs to achieve high availability for write requests. In this post, we will deep dive into the different failure handling techniques used by Dynamo.</p>
<h2>Quorum Failure</h2>
<p>In <a href="https://rahulpradeep.medium.com/amazon-dynamo-a-conceptual-overview-part-1-3bdb2e78f08b" target="_blank" rel="noopener">Part 1</a>, we saw how we can configure the values of R and W to achieve different levels of consistency in the system.</p>
<p>To achieve strong consistency, R and W values are configured such that R+W&gt;N, where N is the number of the replicas for a given partition. For example, let N = 5, R=3 and W=3. In this case, if there is a network partition causing 3 of the nodes to be unreachable, the system becomes unavailable for both reads and writes for the keys in that partition.</p>
<p>In an eventual consistency configuration, a reasonable configuration would be N=5, R=1 and W=3. In this mode, R+W≤N and hence there could be replicas with stale values. Even in this mode, writes can fail if a network partition causes 3 of the nodes to be unreachable.</p>
<p><img alt="a write failing because two of the replica nodes in the distributed data store are unreachable" src="https://raahulpradeep.com/assets/medium/b3e715cad124.jpg"></p>
<p>Didn’t we emphatically described Amazon Dynamo as an <strong><em>always writeable store</em></strong> ?</p>
<p>It sure is ! If majority of the replicas are unreachable, the system seeks help from the other live nodes. It uses a technique called <strong>hinted handoffs</strong>.</p>
<h2>Hinted Handoffs</h2>
<p>If nodes holding some of the replicas are unreachable, then Dynamo asks other live nodes to hold the data on behalf of the unreachable ones.</p>
<p>Let us suppose there are N replicas and 1 of the replicas are unreachable. In this case, when write arrives at one of the N-1 live nodes, it persists in its local disk and sends off the data to the other N-2 live replica nodes. It also sends off this data to 1 of the live nodes in the cluster, which is not the assigned replica for the partition. Along with the data, a context is also sent. This context contains information about the actual unreachable node. Since this node is not the assigned replica for the partition, it writes this in a separate location in its disk and periodically checks if the actual node is back online. Once that node is back online, it transfers the replicated data and deletes it from its local disk. This technique is called <strong><em>hinted handoff</em></strong>.</p>
<p><img alt="sequence diagram of a hinted handoff where a live node stores a write on behalf of a dead replica" src="https://raahulpradeep.com/assets/medium/2519bded2399.png"></p>
<h3>Hinted Replica Failure</h3>
<p>In the above example, Node 3 takes the hint and persists in its local temp storage. By the time Node 2 comes back online, Node 3 fails permanently. In this scenario, Node 2 replica will go out of sync with other replicas (Node 0 and Node 1) permanently.</p>
<p>Hence hinted handoff is not sufficient to handle such permanent failures. We would need a mechanism to synchronise the replicas periodically. One way to do it would be to exchange a hash value of the entire replica and in case of a mismatch, synchronise it by sending all the data from one node to another. The node which has the maximum number of writes (although it is not clear from the paper) can be the one who initiates the data transfer in case of a hash mismatch.</p>
<p><img alt="two nodes comparing a whole-replica hash and syncing by shipping all the data in batches" src="https://raahulpradeep.com/assets/medium/8a43a255d0f6.png"></p>
<p>Since this involves a large amount of data to be transferred over network, this is not desired. Another strategy would be to do these round trips for every key in the replica. This is clearly expensive and not desired. Both of these strategies require a linear scan on all the keys in the replica to determine the mismatches.</p>
<p>Amazon Dynamo synchronises replicas by storing the hashes of each record in a data structure known as Merkle Trees.</p>
<h2>Replica Synchronisation using Merkle Trees</h2>
<p><img alt="a Merkle tree where every parent holds the hash of its children and leaves hash the data blocks" src="https://raahulpradeep.com/assets/medium/387371ee96fb.png"></p>
<p>In Merkle Trees, each node contains the cryptographic hash of its children. Leaf nodes in the tree corresponds to the key:values pairs in the replica.</p>
<p><img alt="a Merkle tree where only the subtree containing the mismatched key k4 differs" src="https://raahulpradeep.com/assets/medium/2d4c90952e75.jpg"></p>
<p>k4:v4 is a mismatch and it is found in log(n) time complexity</p>
<p>Using this data structure, mismatches can be found in logarithmic time complexity and hence it requires lesser data transfers between nodes.</p>
<p>Key set held by a node changes when the number of nodes in the cluster changes. This would require a re-calculation of Merkle Trees in those nodes.</p>
<h2>References</h2>
<ul>
<li><a href="https://www.allthingsdistributed.com/2007/10/amazons_dynamo.html" target="_blank" rel="noopener">https://www.allthingsdistributed.com/2007/10/amazons_dynamo.html</a></li>
<li><a href="https://en.wikipedia.org/wiki/Merkle_tree" target="_blank" rel="noopener">https://en.wikipedia.org/wiki/Merkle_tree</a></li>
</ul>]]></content:encoded>
  </item>
  <item>
    <title>Amazon Dynamo — a conceptual overview (Part 1)</title>
    <link>https://raahulpradeep.com/blog/amazon-dynamo-a-conceptual-overview-part-1</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/amazon-dynamo-a-conceptual-overview-part-1</guid>
    <pubDate>Mon, 05 Jul 2021 12:00:00 +0000</pubDate>
    <description>I happened to read the Amazon Dynamo paper recently. I have read it before but not nearly as intently. Its architecture manages to bring in multiple…</description>
    <content:encoded><![CDATA[<p>I happened to read the Amazon Dynamo paper recently. I have read it before but not nearly as intently. Its architecture manages to bring in multiple computer science concepts together and make it work in tandem to achieve the desired goals of the system.</p>
<p><img alt="the Amazon DynamoDB logo" src="https://raahulpradeep.com/assets/medium/b7a2c73672eb.png"></p>
<p>Ref: <a href="https://en.wikipedia.org/wiki/Amazon_DynamoDB" target="_blank" rel="noopener">https://en.wikipedia.org/wiki/Amazon_DynamoDB</a></p>
<p>This will be a three part series of posts where each part will focus on a set of concepts used in the architecture of Amazon Dynamo.</p>
<ul>
<li><a href="https://rahulpradeep.medium.com/amazon-dynamo-a-conceptual-overview-part-1-3bdb2e78f08b" target="_blank" rel="noopener">Part 1</a> will focus on the challenges of building an <strong><em>always writeable store.</em></strong> We will discuss the concepts to overcome those challenges.</li>
<li><a href="https://rahulpradeep.medium.com/amazon-dynamo-a-conceptual-overview-part-2-a9c88d6870" target="_blank" rel="noopener">Part 2</a> will mainly focus on the failure handling techniques like <strong><em>hinted handoffs</em></strong> and <strong><em>replica synchronisation using Merkle Trees.</em></strong></li>
<li>Part 3 will deep dive into the <strong><em>ring topology</em></strong> in Amazon Dynamo architecture.</li>
</ul>
<h2>An always writeable store !</h2>
<p>Writes are pristine in the world of an always writeable store. They should never go wrong. A typical e-commerce example that the paper mentions is of a shopping cart. Any cart edits done by the user should surely happen. The potential revenue impact of a failure in doing so can be huge in Amazon’s scale.</p>
<p>To understand what an always writeable store mean, let’s look at simple distributed data storage system where every record (key:value pair) is replicated across the cluster. This redundancy is mainly for fault tolerance as it lets us recover from a node failure by re-using one of the replicas. Redundancy also improves read scalability. We can scatter read requests for a key to all the replicas instead of overloading only one. One of the replicas is assigned master and other followers. Master replica is responsible for handling write requests and followers are updated either synchronously or asynchronously depending on the consistency mode we choose in the system.</p>
<p><img alt="client writes going to the master replica of a partition while reads spread across the replicas" src="https://raahulpradeep.com/assets/medium/94879401eecf.jpg"></p>
<p>In this architecture, let us examine if it is an ‘<em>always writeable store</em>’ by looking at some of the failure scenarios.</p>
<h3>Master replica failure</h3>
<p>Since there is a single node (the master) handling writes for a subset of records, this node becomes a single point of failure. Failure of this node can make our system unavailable to write requests. We can surely do better !</p>
<p>We can promote one of the follower replicas to be the new master and also bring up another follower replica to cover the loss. Is that enough ? Not quite! All the clients need to be notified of this change so that they update their local mappings (record to node mappings) and send all the future write requests to the new master instead of the old one which failed.</p>
<p>Till now, this system seems to be tolerant of failures towards write requests. What about the in-flight requests just moments before the master replica failure ? Those is inevitably fail and client would need to employ retries to and hope that before all the retries are exhausted, a new fully functional master comes up and client mappings are updated.</p>
<p>While such a system is fault tolerant towards node failures, there can still be some write request failures while the system is recovering. Retries could take a long time and can impact SLAs. This isn’t an always writeable system yet !</p>
<h3>Eliminating The Master !</h3>
<p><img alt="" src="https://raahulpradeep.com/assets/medium/4b9302493b3e.gif"></p>
<p>If there are no master replicas, who actually assumes the responsibility to handle write requests. The answer is — <em>everyone</em> !</p>
<p>This definitely invites more trouble. If multiple nodes handle writes, our data can go inconsistent pretty quickly. This might not be as bad as you think, if we are able to resolve the conflicts efficiently!</p>
<p>Either the server or the client can take the responsibility to resolve this conflict. Amazon Dynamo chooses to do this at client side. Clients will have the flexibility to have any strategy for conflict resolution. It could be as simple as ‘<em>last write wins</em>’ or a complex business logic.</p>
<p>Since every replica can handle writes, we will end up with multiple versions of the same record. We don’t necessarily need to keep around all versions. If we are able to establish causality relations between two versions of the same record, we can simply overwrite the ancestor with the child. The key to this is how we represent versions.</p>
<h3>Vector Clocks</h3>
<p>In a single machine, it is easy to establish causality relations if we use local time of the machine as the version. When we have multiple machines like in a typical distributed system, it becomes difficult to establish a causal relation between two events as local clocks might not be consistent with each other.</p>
<p>Vector clock is simply a vector of local clocks, where each of the machine in the cluster having its own local clock. Every source machine sends its own view of the vector clock at that point along with message to the destination machine. When the destination machine receives this message, it updates its vector clock using the following algorithm.</p>
<pre><code>for each clock in destination_vector_clock:  
   destination_vector_clock[i] =   
          max(destination_vector_clock[i], source_vector_clock[i])
</code></pre>
<pre><code>// local clock of the destination machine   
// corresponds to destination_vector_clock[k]
</code></pre>
<pre><code>destination_vector_clock[k] = destination_vector_clock[k] + 1
</code></pre>
<p>Amazon Dynamo uses vector clocks to represent versions of every record. If there is a causal relationship between two updates, then the older version is overridden by the later one. Otherwise, both the versions are persisted.</p>
<p><img alt="version history of one record as vector clocks branch across nodes Sx, Sy and Sz, then reconcile" src="https://raahulpradeep.com/assets/medium/c315524cc3d1.png"></p>
<p>Version change over time. Ref : <a href="https://www.allthingsdistributed.com/2007/10/amazons_dynamo.html" target="_blank" rel="noopener">https://www.allthingsdistributed.com/2007/10/amazons_dynamo.html</a></p>
<h2>Quorum reads and writes</h2>
<p>As we have seen above, to make the system highly available for writes, we need multiple nodes to participate in the write request. To offer sufficient redundancy, Amazon Dynamo allows us to configure the minimum number of nodes, <strong>W</strong>, that should accept a write request before it is deemed successful.</p>
<p>The same goes for read requests as well. Any read request is deemed successful only if at least <strong>R</strong> nodes return a valid response. If we set R+W&gt;N where N is the total number of replicas, it mimics a quorum like system. This is because if R+W&gt;N, then at least one node will be common between the read and the write. This gives us a strongly consistent system (CP).</p>
<p><img alt="sequence diagram of a read-modify-write across three nodes with R=2 and W=2" src="https://raahulpradeep.com/assets/medium/53b60b26d80d.png"></p>
<p>R=2, W=2 and N=3</p>
<h2>Tuneable consistency and durability semantics</h2>
<p>Amazon Dynamo was built keeping in mind the need for flexibility. As per the <a href="https://en.wikipedia.org/wiki/CAP_theorem" target="_blank" rel="noopener">CAP theorem</a>, we can choose <em>consistency</em> or <em>availability</em> in the event of a <em>network partition</em> in a distributed system.</p>
<p>As we have seen in the above section we can make Amazon Dynamo behave as a strongly consistent system (CP) by configuring the values of <em>R</em> and <em>W</em> such that <strong><em>R+W&gt;N</em></strong>.</p>
<h3>Eventual Consistency</h3>
<p>We can achieve eventual consistency if we keep a low value of <strong><em>R,</em></strong> say R=1 and R+W≤N. In this case, read request for a key will go to one of the N replicas but that replica could still hold a stale version of the key.</p>
<h3>Durable writes</h3>
<p>If <strong><em>W=1,</em></strong> writes will return as soon as it is persisted in one of the nodes. If that node goes down before replication is completed in at least one of the remaining replicas, we would lose that write. To make it durable, we should have <strong><em>W&gt;1</em></strong>.</p>
<h2>References</h2>
<ul>
<li><a href="https://en.wikipedia.org/wiki/CAP_theorem" target="_blank" rel="noopener">https://en.wikipedia.org/wiki/CAP_theorem</a></li>
<li><a href="https://en.wikipedia.org/wiki/Amazon_DynamoDB" target="_blank" rel="noopener">https://en.wikipedia.org/wiki/Amazon_DynamoDB</a></li>
<li><a href="https://www.allthingsdistributed.com/2007/10/amazons_dynamo.html" target="_blank" rel="noopener">https://www.allthingsdistributed.com/2007/10/amazons_dynamo.html</a></li>
<li><a href="https://en.wikipedia.org/wiki/Vector_clock" target="_blank" rel="noopener">https://en.wikipedia.org/wiki/Vector_clock</a></li>
<li><a href="https://en.wikipedia.org/wiki/Quorum_(distributed_computing)" target="_blank" rel="noopener">https://en.wikipedia.org/wiki/Quorum_(distributed_computing)</a></li>
</ul>]]></content:encoded>
  </item>
  <item>
    <title>Thundering Herd and Stale Set Operations</title>
    <link>https://raahulpradeep.com/blog/thundering-herd-and-stale-set-operations</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/thundering-herd-and-stale-set-operations</guid>
    <pubDate>Tue, 22 Jun 2021 12:00:00 +0000</pubDate>
    <description>In the Internet, a lot of content is consumed on a daily basis. For global services like Google, Facebook, Netflix, etc serving this data to the end users…</description>
    <content:encoded><![CDATA[<p>In the Internet, a lot of content is consumed on a daily basis. For global services like Google, Facebook, Netflix, etc serving this data to the end users in a seamless manner becomes a priority. To design a web service to satisfy these requirements, we need to be able to retrieve this data very fast at a very high scale. In this post, we will look at how <strong><em>caching</em></strong> can solve this problem and also understand the various problems that comes with it. We will also dig deep into two specific problems that come with caching — <strong><em>thundering herd</em></strong> and <strong><em>stale-sets</em></strong>.</p>
<h2>Demand-filled Look Aside Caches</h2>
<p>Web services which serves data from a database often use a cache on the side to prevent load on their DBs. Data in databases are usually stored in disk and serves as the sources of truth for the data.</p>
<p>As these databases are sources of truth, any modification to the data should be done directly in the DBs. There is escaping from writes. Write load needs to be handled by the DB itself. Can we do something about the read loads ?</p>
<p>Disk reads are typically slower than in-memory reads. This is because commodity disk hardware is made up of rotating disks, which involves position the head on the circular disk and then reading blocks of data. Solid-state drives offer random access but is way more expensive. RAMs, obviously, offer random-access and are way faster. Since RAMs are also expensive, we can cache a copy of the hot-data in it for faster access. Hot-data can be defined based on your use-case. Typically, we want to cache that subset of data which has the most probability of being read in the near future. A reasonable way to determine this subset is by simply caching recently read data.</p>
<p>Read requests in a web service can be made faster if we cache some subset of the data in RAM and always look into the RAM before querying the DB. This is called <strong><em>look-aside caching</em></strong>. What happens when the data changes ?</p>
<p>On any modification of the data, we can simply delete it from the cache. Any subsequent reads won’t find it in the RAM and fill fetch it from the DB. Since we want subsequent reads for this data to not go into the DB, we can fill the cache with this data to benefit in the near future. This type of a cache where the cache is filled on demand for making future reads faster, is called <strong><em>demand-filled look-aside caches</em></strong>.</p>
<p><img alt="a web service missing the in-memory cache, reading the persistent DB, then filling the cache" src="https://raahulpradeep.com/assets/medium/9250c1b6bb19.jpg"></p>
<h2>Caching is powerful, but demands great responsibility !</h2>
<h3>Permanent in-consistency</h3>
<p>When multiple concurrent clients observe a cache miss for a key and both of them try to fill the cache, the cached data could become permanently inconsistent with the source of truth database.</p>
<p><img alt="timeline where a late set from client A overwrites a newer value, leaving the cache permanently stale" src="https://raahulpradeep.com/assets/medium/f40894e2daea.jpg"></p>
<p>The above diagram illustrates a scenario where we could end up with a permanently inconsistent cache. This scenario is called <strong><em>stale sets</em></strong>.</p>
<h3>Thundering Herd</h3>
<p>The above diagram also highlights another problem. Client A and B, both observe a cache miss and try to fill the cache with the latest data from the DB. In a highly concurrent system, many such clients could read from the DB for the same key in a very short span of time. This can create hot-spotting for the key and can bring down our database.</p>
<h2>Leases</h2>
<p><img alt="the same timeline with the stale set operation from client A discarded" src="https://raahulpradeep.com/assets/medium/f1ec77a1ae5a.jpg"></p>
<p>To fix the stale-sets problem, we need to be able to discard the set operation initiated by Client A.</p>
<p>To do this, every time a client (A) observes cache miss for a key (k), our in-memory cache could be in one of these two states</p>
<ul>
<li>Client A is the first client to see this cache miss. In this case, the cache grants a <strong><em>lease</em></strong> to Client A. This lease tells A to go ahead and query the DB for the latest data and set it in the cache.</li>
<li>Another client already has an <strong><em>active lease</em></strong>. In this case, the cache responds with ‘cache-miss’ but also telling the client to try after after some time.</li>
</ul>
<p>This will make sure only one client goes to the database at a time. This will definitely solve the <strong>thundering herd</strong> problem.</p>
<p>But this still doesn’t solve the <strong><em>stale sets</em></strong> problem. In-memory cache should identify a set operation as stale or not-stale.</p>
<p>In line with our demand-filled model, we delete the key if it is updated in the DB. When we delete the key, we also expire the <strong><em>active lease</em></strong>, if any, for this key. Now, if we modify our set operation to carry the client’s lease along with the payload, our in-memory cache will be fully equipped to discard any set operation with an <strong><em>expired lease</em></strong>.</p>
<p><img alt="the same timeline with leases, where deleting the key expires A's lease and its set is rejected" src="https://raahulpradeep.com/assets/medium/77d42f840107.jpg"></p>
<h2>References</h2>
<ul>
<li><a href="https://research.fb.com/wp-content/uploads/2016/11/scaling-memcache-at-facebook.pdf" target="_blank" rel="noopener">https://research.fb.com/wp-content/uploads/2016/11/scaling-memcache-at-facebook.pdf</a></li>
<li><a href="https://instagram-engineering.com/thundering-herds-promises-82191c8af57d" target="_blank" rel="noopener">https://instagram-engineering.com/thundering-herds-promises-82191c8af57d</a></li>
</ul>]]></content:encoded>
  </item>
  <item>
    <title>Materialising de-normalised views from a relational dataset</title>
    <link>https://raahulpradeep.com/blog/materialising-de-normalised-views-from-a-relational-dataset</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/materialising-de-normalised-views-from-a-relational-dataset</guid>
    <pubDate>Tue, 22 Jun 2021 12:00:00 +0000</pubDate>
    <description>As the name suggests, Relation Databases are used to store entities and the relationships between them in a normalised manner. Addition to this, they also…</description>
    <content:encoded><![CDATA[<p>As the name suggests, Relation Databases are used to store entities and the relationships between them in a normalised manner. Addition to this, they also provide transactional support and indexing. There are many use cases in the industry where we would want to use the same set of relational data in multiple systems. Each of these system can be thought of as a web service responding to a particular kind of user request. Each of this request can be formulated as a query to the dataset. Let’s look at various ways of solving this problem.</p>
<h2>Stateless Service backed by a DB</h2>
<p>Every request to this service is re-formulated into a database query (SQL query) and these queries are executed remotely in the database. The service thread waits for the query to be completed in the database. Once the database is done processing the query, it responds back to the service with the ResultSet. This result set is then transformed to a response and sent back to the client.</p>
<p><img alt="a client service reading from a slave relational DB that replicates from the master" src="https://raahulpradeep.com/assets/medium/0afea94f6997.png"></p>
<p>Database queries can be expensive, especially, if it involves processing a large amount of data using operations like joins, unions, groupBy, etc. This can negatively impact the throughout and the latency of the system. One easy way to scale our service would be to cache data in-memory which are frequently accessed. This will improve the throughput of the system. For cache-misses, requests will still take a long time to complete. To improve latency, we might want to store all the data from the relational database into a low latency data store.</p>
<h2>Storing Materialised Views</h2>
<p>Every service will be interested in a subset of entities that exists in our database. Each of these entities could be dependent on multiple other entities. So, we need to make sure that when we cache the data, we cache a de-normalised view of the entity. This will enable us to reduce the number of calls made from the service to our low latency data store. This de-normalised view is also called a <strong><em>materialised view</em></strong>.</p>
<p><img alt="the same client service now reading straight from a materialised view sink" src="https://raahulpradeep.com/assets/medium/3705b8791643.png"></p>
<h2>Change Data Capture (CDC)</h2>
<p>Relational databases like MySQL, PostgresDB, etc has <strong><em>binary logging</em></strong>. Every row changes are logged in a file, commonly known as <strong><em>binlog</em></strong>. To materialise views, we would need to listen to these log events and process it in real time.</p>
<p><img alt="a bin log processor polling the slave DB for log events and writing views into the sink" src="https://raahulpradeep.com/assets/medium/0059a4ee9cee.png"></p>
<p>In the above design, we have a simple <em>Bin Log Processor</em>, which is responsible for doing two things</p>
<ul>
<li>One thread, say the <strong><em>I/O thread</em></strong>, which continuously polls for new events in the <em>binlog</em>. Every new event is handed over to the <strong><em>processing thread</em></strong>.</li>
<li>Processing thread, materialises views and writes them to the sink. Every view in the sink corresponds to a row in the sink. Writes to sink are always atomic for a single row.</li>
</ul>
<p>Please note that our <em>Bin Log Processor</em> is stateless.</p>
<h2>Computing Materialised View</h2>
<p><img alt="a view depending on one entity, moving from version 0 through an intermediate state to version 1" src="https://raahulpradeep.com/assets/medium/089d33930362.png"></p>
<p>In the above example, the materialised view <strong><em>View</em></strong> is dependent only on one entity <strong><em>Entity_1</em></strong>. One entity object of View depends on one row of Entity_1 table. To compute View using our CDC design, we would need the <em>I/O thread</em> to hand over every row read/modify/update event to the <em>processing thread</em>. The processing thread then created the View in-memory and persist it in the sink. It can be reasoned that system will remain in consistent state in any kind of failure scenarios. Let’s look at some of the failure scenarios in detail to understand this better.</p>
<ul>
<li><em>I/O thread receives event but hand-over to processing thread failed</em>. The sink will have a stale version (version 0) of our <strong><em>View</em></strong> entity. The view of the entity is delayed with respect to the DB but not wrong.</li>
<li><em>Processing thread fails processing mid way</em>. Since write to sink are always atomic for a single row, this failure can never leave the view entity in a wrong state. View version will be either 0 or 1.</li>
</ul>
<p>Let’s complicate things a bit now !</p>
<p><img alt="a view depending on two entities, both of which must update before the view reaches version 1" src="https://raahulpradeep.com/assets/medium/4aaa8ac2f83e.png"></p>
<p>In the above example, <strong><em>View</em></strong> update is complete only when both <strong><em>Entity_1</em></strong> and <strong><em>Entity_2</em></strong> are updated. A single transaction updates both the entities together.</p>
<p>Since our <em>Bin Log Processor</em> is stateless, single row event processing wouldn’t work in this case. We have to make <em>Bin Log Processor</em> transaction aware. We can do this by listening to two more events apart from the row events — START_TRANSACTION (TX_ID) event and COMMIT_TRANSACTION (TX_ID) event. When we encounter the former, we create a <strong><em>transaction</em></strong> <strong><em>event buffer</em></strong> for the TX_ID and start buffering all the events until we see a COMMIT_TRANSACTION (TX_ID) event. Once the processor sees this event, it can now start materialising the <strong><em>View</em></strong> entity.</p>
<p><img alt="bin log processor with an external transaction event buffer, between the slave DB and the view sink" src="https://raahulpradeep.com/assets/medium/3af8d84af4ed.png"></p>
<p>The <strong><em>transaction event buffer</em></strong> is external to <em>Bin Log Processor</em> to keep the processing component stateless which helps in fault tolerance scenarios.</p>]]></content:encoded>
  </item>
  <item>
    <title>SSTables and LSM Trees</title>
    <link>https://raahulpradeep.com/blog/sstables-and-lsm-trees</link>
    <guid isPermaLink="true">https://raahulpradeep.com/blog/sstables-and-lsm-trees</guid>
    <pubDate>Mon, 21 Jun 2021 12:00:00 +0000</pubDate>
    <description>SSTables and LSM-Trees are techniques which are used to efficiently store and retrieve data. In this post, we will limit our discussion to data retrieval…</description>
    <content:encoded><![CDATA[<p>SSTables and LSM-Trees are techniques which are used to efficiently store and retrieve data. In this post, we will limit our discussion to data retrieval based on a primary key. Before diving deeper into these techniques, let us understand a simplified storage engine, where the keys are stored in memory and values are stored in disk. We will understand the pros and cons of this engine and gradually work towards solving the cons of this approach by introducing SSTables and LSM-Trees.</p>
<p><img alt="in-memory hash map holding byte offsets into an append-only log file on disk" src="https://raahulpradeep.com/assets/medium/669a09c1e6ee.png"></p>
<p>There are two primary components to this storage engine — <strong><em>hashTable</em></strong> and a <strong><em>log</em></strong> (append-only file). Let’s see how various operations work in this database</p>
<ul>
<li><strong><em>write</em></strong><em>(key, data)</em> : The data will be appended in the <strong>log</strong> at the latest offset, say, <em>offset1</em>. The first four bytes of the data will indicate the size of the data to be read. The key will be updated in the hashTable with the value in the <strong>hashTable</strong> being the offset to the data in the log.</li>
<li><strong><em>read</em></strong><em>(key)</em>: Offset to the data in <strong>log</strong> is found by doing a lookup in the <strong>hashTable</strong>. Once the offset is found, we seek to that offset in the file and start reading the data. Since the first four bytes indicate the size of the data, we would know when to stop reading.</li>
<li><strong><em>update</em></strong><em>(key, data)</em> : Since we are using <strong>log</strong>, we will never go and update the data for an already existing key. If we were to do that, we will have to possibly re-write all data from the offset. So, we will just append this to the log just like the write operation. And update the offset in our in-memory <strong>hashTable</strong>. There would be old data lying around in the old offset, but let’s worry about that later.</li>
<li><strong><em>delete</em></strong><em>(key)</em> : Delete is trickier. We will append a special record in the <strong>log</strong>, also called a <strong><em>tombstone</em></strong> in the log indicating that we need to delete data belonging to the specified offset. We will also remove the entry for the key from the <strong>hashTable</strong>.</li>
</ul>
<p>So far, so good. All the operations run in constant time complexity. What about the data growth in our log ?</p>
<h2>Log-structured Storage Segments</h2>
<p>Data in our log is ever growing, since we are never really deleting anything. We will soon run into disk issues. One way to keep this data growth in check would be to have a background thread do the compaction of the log file. This will work but we cannot serve any requests while this is happening. This will compromise the availability of our system.</p>
<p>To make our system available for serving online requests, we can cut a snapshot of our current log file and create a new one instead of that by ignoring old and deleted data. These snapshots are also called <strong><em>segments</em></strong> While this is happening, another log file and hashTable can accept online requests. How would our write and read requests change in this approach ?</p>
<ul>
<li><strong><em>write</em></strong><em>(key, data)</em>: Same as before except that we will append the data to the latest log file. The in-memory hashTable will have to store log file path along with the offset for a key as there can be multiple log files at a time in our system.</li>
<li><strong><em>read</em></strong><em>(key)</em> : Determine the log file path and the offset for the given key and read the data in the same way we did in our previous approach.</li>
</ul>
<p>To make sure this background cleanup operation happens more frequently we would need to limit our segment size. We can create these segments after a threshold size and do the <strong>compaction and merging</strong> in a background thread periodically.</p>
<p><img alt="two log segments of key-value pairs compacted and merged into one, keeping only the latest value per key" src="https://raahulpradeep.com/assets/medium/efb04bf11ab9.png"></p>
<p>This approach seems to have kept our data size on disk in check. The Achilles heal in our design is really the hashTable now.</p>
<ul>
<li><em>Fault-tolerance</em> : In case of node failures, we will lose our hashTable. To fix this, we can persist our hashTable every time we cut a log segment. To recover from this fault, we can simply read the latest persisted hashTable and re-build our in-memory hashTable.</li>
<li><em>Large number of keys</em>: After a point our keys wouldn’t fit in memory. To maintain a hashTable on disk is possible but complicated and not very performant.</li>
<li><em>Range queries</em> : Scanning all keys between a given range will require us to look at all keys.</li>
</ul>
<h2>Sorted String Tables (SSTables)</h2>
<p>Sorting is a powerful technique. Sorting all the keys allows us to reduce the size of our <strong>hashTable</strong>. Extending our previous idea of diving our file into multiple segments, we do the same here, only difference being that each of the file contains keys in a sorted manner.</p>
<pre><code>Segment-0: [&quot;aaabc&quot;:0, &quot;aabc&quot;:980, &quot;bbcd&quot;:1500]  
Segment-1: [&quot;bcd&quot;:2000, &quot;ccc&quot;;2200, &quot;ccd&quot;:2500]  
Segment-2: [&quot;cde&quot;:3000]
</code></pre>
<p>In this approach , we can limit the the in-memory hashTable to have only some of the keys. This is called a <strong><em>sparse-index</em></strong>. For example, sparse index will contains the following keys. <strong>“aaabc”:0, “bcd”:2000, “cde”:3000</strong>. Each of the keys in this index will be start of a <strong>segment file.</strong> This segment file is called <strong>SSTable</strong>. Any read operation for a key will require us to first determine the segment where the key would lie. This can be done by a binary search on the sparse-index. Once we know the segment, we simply read the segment sequentially from disk and return the data once we find the matching key.</p>
<p>We still need to scan through all the keys in a segment, which could make reads slow if segments are big enough. One possibility is to make the segments smaller. The con of this is that there would be far too many small segment files, which could be detrimental in our <strong>compaction and merging</strong> phase. If we make our segments larger, our sparse index becomes way smaller. In practise, we should be able to use more memory, if we remove the coupling of keys in the sparse-index to our segments. We can have our sparse-index have one key for every <em>X</em> KB of data. Scanning through few KBs of data is fast enough.</p>
<p><img alt="three sorted segments merged into one, duplicate keys resolved and sort order preserved" src="https://raahulpradeep.com/assets/medium/387167dd3ccd.png"></p>
<h2>Keeping it Sorted !</h2>
<p>The million dollar question is how do we keep the keys sorted on disk efficiently. To maintain keys sorted in-memory can be done using any of the balanced binary search tree implementations (AVL trees, RB trees, etc). All writes can be done in memory until a certain threshold of size is reached. After which, we can flush it onto disk. This in-memory data structure is called a <strong><em>memTable</em></strong>.</p>
<p>Keeping it sorted in memory is easy and flushing it periodically onto disk seems efficient. But how do we handle fault tolerance here. Any node failure can cause all un-flushed data to be lost forever. To prevent this, we can use a <strong><em>write-ahead-log (WAL)</em></strong>. Any write operation, first appends the operation into the log and then written into memTable. The WAL writes are very fast as they are sequential writes. We can recover from any fault by building our memTable by scanning this WAL. We can limit the size of WALs by periodically rolling it since already flushed data needn’t be maintained in the WAL.</p>
<p>Every time a memTable is flushed onto disk, it creates a new segment file. Over time this can create many segment files. Again, borrowing ideas from our simplified storage engine, we can have a background thread to periodically do <strong><em>compaction and merging</em></strong>.</p>
<h2>Log Structured Merge Trees (LSM Trees)</h2>
<p>SSTables are periodically compacted and merged into larger SSTables. The idea is to keep a small number SSTable. We will see how small number of SSTables are key to having a good read performance. This cascading list if SSTables is the key idea of LSMTrees.</p>
<p>For each of the SSTable file, we have a sparse in-memory index. And each of these SSTable file can be ordered from oldest to latest. With this in mind, let us see how read and write operations work.</p>
<ul>
<li><strong><em>write(key, data)</em></strong> : Writes the key:data into WAL and then writes it into the memTable.</li>
<li><strong><em>read(key)</em></strong><em>:</em> First look at the sparse-index for the memTable. If the key doesn’t exist, it could mean two things</li>
<li>The key was updated/written long time back and could be present in an older SSTable file. In this case, we will have to go through all SSTables from latest to oldest. We return the data as soon as we find the key.</li>
<li>The key is deleted. We can only say that a key is deleted after going through all the SSTables.</li>
</ul>
<h2>Performance of LSMTrees</h2>
<p>As the writes involve one sequential write to <strong>WAL</strong> and an in-memory update to <strong>memTable</strong>, LSMTrees can give a very high write throughput.</p>
<p>For reads, we have to go through all the SSTables in the worst case. To get a high read throughput, it is important to keep a low number of SSTables. We can do this by doing a size based compaction. In this, newer SSTables are merged into older and larger SSTables.</p>
<p>For deleted keys, it still takes scanning all of SSTables to even determine if that key is deleted. One optimisation we can do is using a <a href="https://en.wikipedia.org/wiki/Bloom_filter" target="_blank" rel="noopener">BloomFilter</a>. BloomFilter is an in-memory data structure which approximates the contents of a set. It can tell you with 100% certainty if a key isn’t present in the set.</p>
<h2>Range Queries</h2>
<p>One advantage of storing keys in a sorted fashion is that it enables us to do range queries efficiently. For any range of keys <strong><em>[key_x, key_y]</em></strong>, we can determine the exact SSTables to look for and hence limiting the key space to scan for.</p>
<h2>Conclusions</h2>
<p>This approach of storage and retrieval is used in several database systems like Google BigTable, Apache HBase, Apache Cassandra, etc.</p>
<h2>References</h2>
<ul>
<li><a href="https://dataintensive.net/" target="_blank" rel="noopener">https://dataintensive.net/</a></li>
<li><a href="https://hbase.apache.org/book.html#arch.overview" target="_blank" rel="noopener">https://hbase.apache.org/book.html#arch.overview</a></li>
</ul>]]></content:encoded>
  </item>
</channel>
</rss>
