<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Seng-Wei Chieh]]></title><description><![CDATA[Seng-Wei Chieh]]></description><link>https://seng-wei-chieh.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a0743dd73afc8875786660f/635556fd-2862-43c4-bde0-3ffbee1fa93c.png</url><title>Seng-Wei Chieh</title><link>https://seng-wei-chieh.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 09 Sep 2026 20:35:13 GMT</lastBuildDate><atom:link href="https://seng-wei-chieh.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Same GPU, Same Impossible Job: One Engine Crashed, the Other Slowed Down 8%]]></title><description><![CDATA[I gave two inference engines the same job on the same 8 GB GPU: 64 requests, each with a 2,000-word prompt. Neither had enough memory for it — the job needed about a third more cache than the card cou]]></description><link>https://seng-wei-chieh.hashnode.dev/same-gpu-same-impossible-job-one-engine-crashed-the-other-slowed-down-8</link><guid isPermaLink="true">https://seng-wei-chieh.hashnode.dev/same-gpu-same-impossible-job-one-engine-crashed-the-other-slowed-down-8</guid><category><![CDATA[vLLM]]></category><category><![CDATA[GPU]]></category><category><![CDATA[performance]]></category><category><![CDATA[llm]]></category><category><![CDATA[Machine Learning]]></category><dc:creator><![CDATA[schieh710]]></dc:creator><pubDate>Sat, 05 Sep 2026 18:13:04 GMT</pubDate><content:encoded><![CDATA[<p>I gave two inference engines the same job on the same 8 GB GPU: 64 requests, each with a 2,000-word prompt. Neither had enough memory for it — the job needed about a third more cache than the card could hold.</p>
<p>One returned nothing. It asked for 2.19 GB it didn't have, and stopped.</p>
<p>The other returned all 64 answers, complete, 8% slower than its own best pace.</p>
<p>That pairing is the most useful thing this benchmark produced, and it isn't really a story about speed. I'm building a paged memory manager for LLM inference and wanted a baseline I could defend first.</p>
<p><strong>Setup:</strong> Qwen2.5-1.5B on an RTX 4060 laptop GPU — 8 GB, measured bandwidth 219.8 GB/s. Same model and settings both sides, prompts fixed by seed so nothing could be cached and reused, median of three runs. Because this 105 W part overheats, <strong>every cell waits for the GPU to cool to 70 °C first</strong> — otherwise whichever engine ran second would be measured on a hot card.</p>
<hr />
<h2>1. At batch 1, the gap has nothing to do with memory</h2>
<table>
<thead>
<tr>
<th>Prompt length</th>
<th>HF</th>
<th>vLLM</th>
<th>vLLM/HF</th>
<th>HF first token</th>
<th>vLLM first token</th>
</tr>
</thead>
<tbody><tr>
<td>64 words</td>
<td>35.6 tok/s</td>
<td>66.8 tok/s</td>
<td>1.88×</td>
<td>31 ms</td>
<td>32 ms</td>
</tr>
<tr>
<td>512</td>
<td>36.1 tok/s</td>
<td>65.7 tok/s</td>
<td>1.82×</td>
<td>71 ms</td>
<td>80 ms</td>
</tr>
<tr>
<td>2048</td>
<td>33.6 tok/s</td>
<td>63.1 tok/s</td>
<td>1.88×</td>
<td>337 ms</td>
<td>340 ms</td>
</tr>
</tbody></table>
<p>I expected these to be close. With one request in flight there is no queue and nothing to schedule — yet vLLM is a stable <strong>1.86× faster</strong>. That gap is code quality, not memory management, which makes it the number to <strong>subtract</strong> before crediting anything clever:</p>
<table>
<thead>
<tr>
<th>Where the advantage comes from</th>
<th>Size</th>
<th>Shows up when</th>
</tr>
</thead>
<tbody><tr>
<td>Better code</td>
<td>~1.86×</td>
<td>Always, even at one request</td>
</tr>
<tr>
<td>Smarter memory + scheduling</td>
<td>~1.8× more</td>
<td>Only as requests pile up</td>
</tr>
<tr>
<td>Not dying under pressure</td>
<td>—</td>
<td>At 64 requests</td>
</tr>
</tbody></table>
<p>vLLM's <strong>first token is never faster</strong>, and is 13% slower mid-range — the throughput-for-latency trade, visible before any load.</p>
<hr />
<h2>2. Under load: where each one breaks</h2>
<table>
<thead>
<tr>
<th>Batch</th>
<th>HF tok/s</th>
<th>vLLM tok/s</th>
<th>vLLM/HF</th>
<th>HF peak VRAM</th>
<th>vLLM peak VRAM</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>31.4</td>
<td>61.4</td>
<td>1.96×</td>
<td>3484 MiB</td>
<td>7050 MiB</td>
</tr>
<tr>
<td>2</td>
<td>56.5</td>
<td>111.0</td>
<td>1.96×</td>
<td>3676 MiB</td>
<td>7260 MiB</td>
</tr>
<tr>
<td>4</td>
<td>88.8</td>
<td>192.4</td>
<td>2.17×</td>
<td>4094 MiB</td>
<td>7260 MiB</td>
</tr>
<tr>
<td>8</td>
<td>107.3</td>
<td>299.9</td>
<td>2.79×</td>
<td>4914 MiB</td>
<td>7260 MiB</td>
</tr>
<tr>
<td>16</td>
<td>122.5</td>
<td>413.9</td>
<td>3.38×</td>
<td>6826 MiB</td>
<td>7260 MiB</td>
</tr>
<tr>
<td>32</td>
<td><strong>81.6</strong></td>
<td>494.8</td>
<td>6.06×</td>
<td>7960 MiB</td>
<td>7260 MiB</td>
</tr>
<tr>
<td>64</td>
<td><strong>CUDA OOM</strong></td>
<td>454.4</td>
<td>—</td>
<td>—</td>
<td>7260 MiB</td>
</tr>
</tbody></table>
<p><strong>HF doesn't stop scaling — it reverses.</strong> Batch 32 is 33% <em>slower</em> than 16, at 7960 of 8188 MiB. Reproduced across four passes.</p>
<p><strong>At 64 the job fits in neither engine</strong> — 147,456 tokens of cache needed against a 108,000-token capacity. HF returns nothing; vLLM sets part aside, finishes the rest, rebuilds the discarded work, and delivers all 64 answers for an 8% cost. Running out of memory isn't what separates these engines. <strong>How they behave when they run out is.</strong></p>
<p><strong>The biggest surprise: vLLM is the <em>larger</em> memory consumer for most of the sweep</strong> — 2× HF's at batch 1, dropping below only at 32. Its flat 7260 MiB is a design choice, not thriftiness: it claims a fixed budget at startup and never asks for more, then hands out slots inside that wall. Static outside, dynamic inside. HF discovers its ceiling by hitting it. Had I written this up as "vLLM is more memory-efficient," my own table would have contradicted me.</p>
<hr />
<h2>3. The number most people quote is the wrong one</h2>
<p>Throughput above divides output words by the <em>whole</em> clock — including reading the prompt, up to 60% of the time here. Split the phases, and compare each against the hardware's physical limit:</p>
<table>
<thead>
<tr>
<th>Batch</th>
<th>HF generation</th>
<th>vs. limit</th>
<th>vLLM generation</th>
<th>vs. limit</th>
<th>vLLM/HF</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>32.3</td>
<td>46%</td>
<td>65.8</td>
<td><strong>94%</strong></td>
<td>2.04×</td>
</tr>
<tr>
<td>8</td>
<td>123.4</td>
<td>25%</td>
<td>462.6</td>
<td><strong>94%</strong></td>
<td>3.75×</td>
</tr>
<tr>
<td>16</td>
<td>146.3</td>
<td>17%</td>
<td>809.2</td>
<td><strong>93%</strong></td>
<td>5.53×</td>
</tr>
<tr>
<td>32</td>
<td>144.3</td>
<td>10%</td>
<td>1222.4</td>
<td><strong>87%</strong></td>
<td>8.47×</td>
</tr>
<tr>
<td>64</td>
<td>OOM</td>
<td>—</td>
<td>1014.1</td>
<td>49%</td>
<td>—</td>
</tr>
</tbody></table>
<p>A 5.53× gap could mean vLLM is exceptional <em>or</em> HF is poor. The absolute column settles it: <strong>vLLM runs at 87–94% of what the memory bus physically permits.</strong> That also bounds my own project — nobody meaningfully beats 94% of a ceiling, so <strong>the opportunity is in the phases that ceiling doesn't cover.</strong> More useful than any speedup, and it changed what I plan to build.</p>
<hr />
<h2>4. What actually broke at batch 32</h2>
<table>
<thead>
<tr>
<th>Batch</th>
<th>Total</th>
<th>Reading prompt</th>
<th>Share</th>
<th>Generating</th>
<th>Gen tok/s</th>
<th>Prompt-phase growth</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>8.2 s</td>
<td>0.25 s</td>
<td>3%</td>
<td>7.9 s</td>
<td>32.3</td>
<td>—</td>
</tr>
<tr>
<td>4</td>
<td>11.5 s</td>
<td>1.17 s</td>
<td>10%</td>
<td>10.4 s</td>
<td>98.5</td>
<td>2.24×</td>
</tr>
<tr>
<td>16</td>
<td>33.4 s</td>
<td>5.55 s</td>
<td>17%</td>
<td>27.9 s</td>
<td>146.3</td>
<td>2.17×</td>
</tr>
<tr>
<td>32</td>
<td>100.4 s</td>
<td><strong>43.89 s</strong></td>
<td><strong>44%</strong></td>
<td>56.6 s</td>
<td>144.3</td>
<td><strong>7.91×</strong></td>
</tr>
</tbody></table>
<p>Prompt reading scales cleanly at ~2.2× per doubling until 32, where it jumps <strong>7.91× for twice the work</strong> — while generation barely moves. Reading 32 prompts at once needs large temporary memory that hits the wall; generation produces one word per request per step and needs ~2,000× less. That names the failing phase rather than just reporting that memory ran out.</p>
<hr />
<h2>5. Where the memory actually goes</h2>
<p>Cost per token of context — derived from the architecture as 28.0 KiB, measured three ways:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Result</th>
<th>vs. derivation</th>
</tr>
</thead>
<tbody><tr>
<td>HF live-memory slope</td>
<td>31.32 KiB/token</td>
<td>+11.9%</td>
</tr>
<tr>
<td>HF reserved-memory slope</td>
<td>53.33 KiB/token</td>
<td>+90.5%</td>
</tr>
<tr>
<td>vLLM's own capacity accounting</td>
<td><strong>27.99 KiB/token</strong></td>
<td><strong>−0.03%</strong></td>
</tr>
</tbody></table>
<p>The gap between the first two is one prize: the naive allocator <strong>claims 1.70 KiB for every 1 KiB it stores.</strong> The other is padding — batches must be rectangular, so short requests are filled out to match the longest:</p>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td>Real tokens</td>
<td>13,184</td>
</tr>
<tr>
<td>Slots occupied (16 × 2048)</td>
<td>32,768</td>
</tr>
<tr>
<td><strong>Padding</strong></td>
<td><strong>59.8%</strong></td>
</tr>
<tr>
<td>Ragged batch</td>
<td>118.0 tok/s</td>
</tr>
<tr>
<td>Same real tokens, evenly sized</td>
<td>224.5 tok/s</td>
</tr>
<tr>
<td><strong>Cost of the padding</strong></td>
<td><strong>1.90×</strong></td>
</tr>
</tbody></table>
<p>Both batches carry identical real work; only the raggedness differs.</p>
<hr />
<h2>What I'd tell a team</h2>
<p><strong>Report the failure mode, not just the throughput.</strong> The decision-relevant number isn't 3.38× — it's that under identical impossible load, one engine returns nothing and the other returns everything 8% slower.</p>
<p><strong>Measure against the hardware limit, not just the competitor.</strong> "5.5× faster" and "94% of what the bus allows" are different claims; only the second says whether headroom remains.</p>
<p><strong>Check whether your own data contradicts the story you're about to tell.</strong> I nearly wrote that vLLM is more memory-efficient. It isn't — its advantage is a <em>predictable</em> budget, not a smaller one.</p>
<p><strong>Say which number not to quote.</strong> The 6.06× at batch 32 pairs vLLM's best cell against HF's collapsed one. Real arithmetic, misleading result; the honest figure is 3.38× at 16.</p>
<hr />
<h2>What's next</h2>
<p><strong>A continuous-batching scheduler.</strong> Section 4 showed static batching doesn't degrade gradually — it scales cleanly, then falls off a cliff at one specific batch size. A scheduler that adds and removes requests from a running batch never has to guess that number in advance.</p>
<p><strong>A paged memory manager in C++ behind Python bindings.</strong> Section 5 sized both prizes: 70% overhead from fragmentation, 59.8% from padding. Block table, free list, reference-counted blocks so shared prompt prefixes are reused rather than copied. A Python implementation of the same interface serves as correctness oracle <em>and</em> as the baseline for measuring what the C++ actually buys — including the language-boundary crossing cost, which is the only honest way to justify that boundary existing.</p>
<p>Then a streaming server to make it deployable, and a study of quantization — studied, not benchmarked, and I'll say so.</p>
<hr />
<h2>Limitations</h2>
<p>Absolute throughput is capped by a GPU that throttles; both engines ran the same thermal gate, so the comparison stands, but a desktop card posts higher numbers. vLLM repeats within ±0.8% across passes while HF scatters up to 8.6% — quote the trend, not neighbouring cells. One confound remains, vLLM's fused kernels versus HF's stock attention, bounded at ~1.86× by section 1. And "batch size" isn't the same quantity on both sides: HF's is a simultaneous batch, vLLM's a request set its scheduler may run in waves — precisely the thing under study.</p>
<hr />
<p>Everything here is reproducible from <a href="https://github.com/chieh006/paged-kv-engine"><strong>chieh006/paged-kv-engine</strong></a>:</p>
<ul>
<li><a href="https://github.com/chieh006/paged-kv-engine/tree/main/benchmarks"><code>benchmarks/</code></a> — the experiment suite, plus the commands to re-run it</li>
<li><a href="https://github.com/chieh006/paged-kv-engine/tree/main/benchmarks/results"><code>benchmarks/results/</code></a> — raw records behind every number above, with per-cell GPU telemetry</li>
<li><a href="https://github.com/chieh006/paged-kv-engine/blob/main/benchmarks/thermal.py"><code>benchmarks/thermal.py</code></a> — the 70 °C cooldown gate every measurement waits on</li>
<li><a href="https://github.com/chieh006/paged-kv-engine/blob/main/benchmarks/hwinfo.py"><code>benchmarks/hwinfo.py</code></a> — the machine fingerprint, including the measured 219.8 GB/s</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[5.1× From Overlapping, 11.6× From Processes, 0× From Fewer Bytes]]></title><description><![CDATA[TL;DR. I had to read 10,000 files out of object storage, and only 6% of each file was needed. The obvious win is to ask for just that 6%. Measured, it was 2% slower than downloading every file whole. ]]></description><link>https://seng-wei-chieh.hashnode.dev/5-1-from-overlapping-11-6-from-processes-0-from-fewer-bytes</link><guid isPermaLink="true">https://seng-wei-chieh.hashnode.dev/5-1-from-overlapping-11-6-from-processes-0-from-fewer-bytes</guid><dc:creator><![CDATA[schieh710]]></dc:creator><pubDate>Sat, 01 Aug 2026 21:42:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a0743dd73afc8875786660f/2e52871f-6a88-43df-a45f-aadffd8b2f82.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR.</strong> I had to read 10,000 files out of object storage, and only 6% of each file was needed. The obvious win is to ask for just that 6%. Measured, it was <strong>2% slower</strong> than downloading every file whole. Fetching <strong>17.8× fewer bytes bought nothing.</strong> What <em>did</em> work was <strong>overlapping the waiting (5.1×)</strong> and then <strong>spreading it over processes (11.6×)</strong>, with byte-identical output throughout. One fact explains all of it: on a local link, <strong>bytes are nearly free and round trips are not</strong>. The post includes the limit whose <em>removal</em> made things 22% slower, the run that hung for 40 minutes, and the scoring code that confidently graded three of my own hypotheses wrong.</p>
</blockquote>
<hr />
<h2>The setup</h2>
<p>The workload is dull on purpose. Ten thousand synthetic image files sit in an object store. Each has a small header at the front and a small footer at the end. I need the fields out of those two ends, for all 10,000 files, written into a single Parquet file. The pixels in the middle — the overwhelming bulk of every file — are never read.</p>
<p>The numbers matter for what follows:</p>
<table>
<thead>
<tr>
<th></th>
<th>medium corpus</th>
<th>small corpus</th>
</tr>
</thead>
<tbody><tr>
<td>Files</td>
<td>10,000</td>
<td>10,000</td>
</tr>
<tr>
<td>Size each</td>
<td>~1.06 MiB</td>
<td>~125 KiB</td>
</tr>
<tr>
<td>Total</td>
<td>10.35 GiB</td>
<td>1.19 GiB</td>
</tr>
<tr>
<td>Actually needed per file</td>
<td><strong>64 KiB</strong></td>
<td><strong>64 KiB</strong></td>
</tr>
</tbody></table>
<p>So on the medium corpus, <strong>94% of every byte is dead weight.</strong> Reading only the two ends should be a straightforward win. That was the whole premise.</p>
<p>I built four readers to test it:</p>
<table>
<thead>
<tr>
<th></th>
<th>How it reads</th>
<th>The bet</th>
</tr>
</thead>
<tbody><tr>
<td><strong>V1</strong></td>
<td>Opens the file and reads it like a local file — the library decides what crosses the wire</td>
<td>the naive baseline</td>
</tr>
<tr>
<td><strong>V2</strong></td>
<td>Asks the store for the size, then asks for each end separately</td>
<td>fewer bytes = faster</td>
</tr>
<tr>
<td><strong>V3</strong></td>
<td>Same two ends, but many files in flight at once, feeding one writer</td>
<td>overlap the waiting</td>
</tr>
<tr>
<td><strong>V5</strong></td>
<td>Four copies of V3, one per process, each on its own slice</td>
<td>use more than one core</td>
</tr>
</tbody></table>
<p>Six hypotheses were written down <strong>before</strong> anything was measured, and a scorecard grades each one PASS or FAIL directly from the raw result rows. That detail becomes the most embarrassing part of this post.</p>
<p><em>Stack: Python 3.12,</em> <code>s3fs</code> <em>+</em> <code>asyncio</code><em>,</em> <code>pyarrow</code> <em>for Parquet,</em> <code>polars</code> <em>for the reporting,</em> <code>pydantic</code> <em>v2 for every config object. Ceph RGW in a container as the store. WSL2 dev box, 16 logical cores — relevant throughout.</em></p>
<hr />
<h2>Act 1: the optimization that bought nothing</h2>
<p>Here is V1 against V2 on the medium corpus. Five runs each, medians.</p>
<table>
<thead>
<tr>
<th></th>
<th>Bytes per file</th>
<th>Requests per file</th>
<th>Files/sec</th>
<th>Wall</th>
</tr>
</thead>
<tbody><tr>
<td><strong>V1</strong> whole file</td>
<td>1,110,884</td>
<td>2.00</td>
<td>91.6</td>
<td>109.1 s</td>
</tr>
<tr>
<td><strong>V2</strong> just the ends</td>
<td><strong>62,308</strong></td>
<td>2.90</td>
<td>90.0</td>
<td>111.1 s</td>
</tr>
</tbody></table>
<p>V2 moved <strong>17.8× fewer bytes</strong> and finished <strong>two seconds later</strong>. On the small corpus it was 26% slower.</p>
<img src="https://raw.githubusercontent.com/chieh006/pipelined_ingestion/c3af8a2/results/figures/fig_a_bytes_per_file.png" alt="bytes per file, by reader and corpus" style="display:block;margin:0 auto" />

<p>The bar chart is the optimization working perfectly. The stopwatch is the optimization not mattering. Both are true, and the arithmetic reconciles them in two lines.</p>
<p><strong>What V2 saved.</strong> V1 drags 1.06 MiB per file across the wire. On this link that lands at roughly 270 MiB/s, so those bytes cost it about <strong>3.9 ms per file</strong>.</p>
<p><strong>What V2 spent.</strong> V2 pays 2.90 requests per file against V1's 2.00 — one to ask how big the file is, then one per end, and it re-reads a footer only where one exists. Each request costs about 3.5 ms here. That extra 0.90 of a request is about <strong>3.2 ms per file</strong>.</p>
<p>3.9 against 3.2. <strong>The saving and the cost very nearly cancel</strong>, and what's left is noise plus V2 reading its files strictly one at a time.</p>
<p>This is not a refutation of ranged reads. It is a statement about the link. The plan for this work assumed a 1 Gbps network, where 1.06 MiB costs about 8.5 ms and the trade flips hard in V2's favour. My store was a container on the same machine — a 0.033 ms round trip. On that link the byte saving is worth about as much as the round trip it costs, and no more.</p>
<p>So the honest verdict is not "V2 loses." It's <strong>"this hypothesis cannot be tested on this machine."</strong> The slow-network runs are the obvious next step and they have still never been done. I'd rather say that than quietly bank a FAIL I didn't earn.</p>
<hr />
<h2>Act 2: what actually paid, and why</h2>
<p>V3 reads the same two ends V2 does. It is <strong>5.1× faster than both</strong>. Two changes, and only one of them is the obvious one.</p>
<p>The obvious one: V3 keeps many files in flight at once instead of waiting for each in turn. The waiting overlaps.</p>
<p>The one I think is more interesting: <strong>V3 also stops asking how big the file is.</strong> It looks the offsets up in a manifest it already has. That drops it from V2's 2.90 requests per file to 2.00 — exactly V1's count. So V3 gets the byte saving <em>and</em> the round-trip count of the thing it's beating. V2 could only ever have one of the two.</p>
<img src="https://raw.githubusercontent.com/chieh006/pipelined_ingestion/c3af8a2/results/figures/fig_e_variant_throughput.png" alt="throughput by reader" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th></th>
<th>Files/sec</th>
<th>Wall</th>
<th>vs V1</th>
</tr>
</thead>
<tbody><tr>
<td>V1 whole file</td>
<td>91.6</td>
<td>109.1 s</td>
<td>1.00×</td>
</tr>
<tr>
<td>V2 just the ends</td>
<td>90.0</td>
<td>111.1 s</td>
<td>0.98×</td>
</tr>
<tr>
<td><strong>V3 overlapped</strong></td>
<td><strong>467.0</strong></td>
<td>21.4 s</td>
<td><strong>5.10×</strong></td>
</tr>
<tr>
<td><strong>V5 four processes</strong></td>
<td><strong>1065.5</strong></td>
<td>9.4 s</td>
<td><strong>11.63×</strong></td>
</tr>
</tbody></table>
<p><strong>The output is byte-identical across all four.</strong> One checksum per corpus, matching across every reader — and the small corpus checksum is the same one recorded the day before, on a completely different object store. Four read strategies, two stores, two campaigns, agreeing to the byte. Speed claims are worth nothing without that line, and it's the reason every run gets graded against rows rebuilt from the manifest before its timing is allowed to count.</p>
<p>One caveat on V5, stated plainly: four processes gave <strong>2.28×</strong> over V3, not 4×. The store is running on the same physical cores as the workers. Past about four workers they start stealing CPU from the process that has to answer them, and everything shares a ceiling near 1,300 files/s. A remote store would probably change that number. I haven't measured it, and a sweep over process count is still on the undone list.</p>
<hr />
<h2>Act 3: three things I did not predict</h2>
<h3>The knee is at 4, and my planned axis could not have found it</h3>
<p>V3's one real knob is how many files it keeps in flight. I swept it.</p>
<img src="https://raw.githubusercontent.com/chieh006/pipelined_ingestion/c3af8a2/results/figures/fig_b_knee.png" alt="throughput and worst-case wait vs files in flight" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th>In flight</th>
<th>1</th>
<th>4</th>
<th>8</th>
<th>16</th>
<th>32</th>
<th>64</th>
<th>128</th>
</tr>
</thead>
<tbody><tr>
<td>Files/sec</td>
<td>253</td>
<td>455</td>
<td>470</td>
<td>471</td>
<td>466</td>
<td>449</td>
<td>422</td>
</tr>
<tr>
<td>Slowest 1-in-100 request</td>
<td>4 ms</td>
<td>8 ms</td>
<td>14 ms</td>
<td>70 ms</td>
<td>104 ms</td>
<td>157 ms</td>
<td><strong>2,235 ms</strong></td>
</tr>
</tbody></table>
<p>One to four nearly doubles the speed. Four to eight adds 3%. After that it gets slowly <em>worse</em>. Meanwhile the unluckiest requests degrade the entire way — from 4 ms to 2.2 seconds, a factor of <strong>534</strong>.</p>
<p>The cause is not the store. It's my own program. The decoding and parsing run on one core, about 2.1 ms per file, and once four requests in flight keep that core busy, request number five just stands in line. The tell is that V3 hits 467 files/s on the medium corpus and 466 on the small one — <strong>0.1% apart across a 16× change in data volume.</strong> If the store were the limit, size would have mattered.</p>
<p><strong>Now the part that stings.</strong> The axis I planned to sweep was <code>1, 8, 32, 64, 128, 256, 512</code>. There is no point between 1 and 8. That sweep would have run cleanly, produced a tidy chart, and reported the knee at 8 — <strong>by construction</strong>, because 8 was the first place it was allowed to look. I only found the real answer because the top of the axis blew up and forced me to re-cut it.</p>
<p>A measurement design can be wrong in a way that produces no error, no warning, and a plausible number. That is a considerably worse failure mode than a crash.</p>
<h3>Removing a limit made it 22% slower</h3>
<p>Between the readers and the Parquet writer sits a small buffer. I wanted to know whether its size matters, so I deliberately slowed the writer down — 5 ms per row, which puts a hard 50-second floor under a 10,000-row job — and then ran it with a buffer of 100 and a buffer of 10,000.</p>
<p>My prediction was that the size wouldn't matter. The writer sets the pace either way; a bigger buffer should just hold more rows in memory.</p>
<table>
<thead>
<tr>
<th>Buffer</th>
<th>Files/sec</th>
<th>Wall</th>
<th>Peak rows held</th>
<th>Peak memory</th>
</tr>
</thead>
<tbody><tr>
<td><strong>100</strong></td>
<td><strong>173.8</strong></td>
<td>57.5 s</td>
<td>100 (pinned)</td>
<td>222 MiB</td>
</tr>
<tr>
<td><strong>10,000</strong></td>
<td><strong>139.8</strong></td>
<td>71.5 s</td>
<td>9,468</td>
<td>238 MiB</td>
</tr>
</tbody></table>
<p>A <strong>22% spread, with the bigger buffer the slower one.</strong></p>
<img src="https://raw.githubusercontent.com/chieh006/pipelined_ingestion/c3af8a2/results/figures/fig_c_backpressure.png" alt="queue depth and memory under a throttled writer" style="display:block;margin:0 auto" />

<p>The bottom panel shows the mechanism directly. With the big buffer the readers run flat out, pile up 9,468 rows by the 21-second mark, and finish. Only then does the writer start making real progress, alone, at 5 ms a row — about 47 seconds of pure drain stacked on top of the 21-second fill. That's the 71.5 seconds. With the small buffer, depth sits pinned at 100 the whole way: the readers are constantly forced to pause, the writer gets the core back, and the two stages interleave for 57.5 seconds.</p>
<p>Here's the bit worth keeping. Both stages share one core. <strong>The small buffer isn't saving memory, it's scheduling.</strong> It's the only thing making the readers yield to the writer. The big buffer lets the readers monopolize the core, which turns a pipeline into two sequential phases that don't overlap at all. Backpressure wasn't costing throughput — its <em>absence</em> was.</p>
<p>The memory half of this test was a dud, and I'll say so: the two runs are about 16 MiB apart. The rows are too small for a memory story to appear. To see that, this needs re-running with the heavier data format, which hasn't happened.</p>
<h3>The 40-minute hang</h3>
<p>I tried 256 files in flight. The warm-up pass finished — 40 seconds, correct output, but only 251 files/s, already half the plateau. Then the very next pass, same settings, same running program, <strong>never finished.</strong> Forty minutes later it was still going and I killed it.</p>
<p>The store was 3.7% busy at the time. Nothing was overloaded. But the client held <strong>2,244 connections in a half-closed state</strong> — the store had hung up, my process never cleaned them up — against 430 working ones, out of a pool of about 520.</p>
<p>The diagnostic detail is that the first pass worked and the second didn't. A real capacity ceiling would have hit both equally. Something accumulated <em>inside my process</em> between repetitions, and that's a leak, not a limit. I re-cut the axis and moved on, so this is one observation and not a reproduced failure. It's logged as an open bug. A program that hangs forever instead of slowing down or failing cleanly is fragile at any setting, not just at 256.</p>
<hr />
<h2>The part where my own scoring code was wrong</h2>
<p>The scorecard grades six hypotheses automatically from the raw rows. The point of that design is to stop me from grading my own homework.</p>
<p>The first time I ran it, it reported the knee at 1, failed a hypothesis that should have passed, and computed another one across two different corpora blended together. Three wrong verdicts in one table.</p>
<p>None of them were wrong about the data. They were wrong about <strong>which rows to compare.</strong> Each check was pulling in cells that differed from each other in more than the one variable under test — two corpus sizes in a single curve, the deliberately-throttled writer runs mixed into the normal sweep. Two of the six checks already grouped rows correctly. The other four didn't, and I hadn't noticed because their answers looked reasonable.</p>
<p>The fix was to make every check use the same grouping rule, then regenerate every number and figure. Everything in this post comes from the corrected version.</p>
<p>What I take from it: <strong>automating the analysis doesn't make it objective, it just moves where the mistake lives.</strong> A hand-written conclusion gets scrutinized. A generated table gets believed. I nearly published three confident, wrong verdicts in a nice monospace block.</p>
<hr />
<h2>What I'd tell another engineer</h2>
<ol>
<li><p><strong>A saving is worth exactly what the bottleneck says it's worth.</strong> Cutting 17.8× of the bytes bought zero, because bytes weren't what cost. Measure what binds before you optimize what's obvious.</p>
</li>
<li><p><strong>Count the round trips, not just the payload.</strong> V2's whole loss was 0.90 of an extra request per file. The winning reader got the same byte saving with <em>fewer</em> requests, by looking up what it already knew instead of asking.</p>
</li>
<li><p><strong>Check that your measurement can even see the answer.</strong> My planned sweep would have reported the knee at 8 with a straight face, because 8 was the first place it looked. Design the axis so it can prove you wrong.</p>
</li>
<li><p><strong>A limit can be a scheduler.</strong> Removing the buffer cap didn't just use more memory — it let one stage starve the other and cost 22% throughput. Backpressure is a coordination mechanism, not only a memory guard.</p>
</li>
<li><p><strong>"It got slower under load" and "it hung forever" are different bugs.</strong> The first is a ceiling. The second is a leak. Don't file them together.</p>
</li>
</ol>
<p>The headline is 11.6×. The actual deliverable is knowing <em>which</em> of the two optimizations produced it, being able to show that the other one produced nothing, and being able to name the exact condition — a slower network — that would flip that verdict.</p>
<hr />
<h2>Coda: the third time the same lesson has billed me</h2>
<p><a href="https://github.com/chieh006/streaming-feature-store">Week 1</a> ended on: <em>a cost doesn't tell you where it lands — the binding constraint does.</em> <a href="https://github.com/chieh006/streaming-feature-store">Week 2</a>: <em>a dependency is a cost you pay even when its benefit isn't the binding constraint.</em></p>
<p>Both of those are about costs. This one is the mirror image. <strong>A saving is worthless when it isn't the binding constraint either.</strong> Reading 17.8× less data is a real, correct, textbook optimization. It bought me exactly nothing, because on this machine moving data was never the expensive part. Same optimization, a slower network, and it becomes the best thing in the codebase.</p>
<p>The optimization didn't change. The constraint did.</p>
<hr />
<p><em>Everything here — the campaign results file, all five figures, the generated scorecard, and the full list of what deviated from the plan and why — is in the project's</em> <a href="https://github.com/chieh006/pipelined_ingestion"><em>engineering log on GitHub</em></a><em>. Every number is loopback, which is precisely the regime where the most interesting hypothesis can't be tested. That axis is next. Pushback welcome.</em></p>
]]></content:encoded></item><item><title><![CDATA[I Replaced a Flink Cluster With One Python Process]]></title><description><![CDATA[TL;DR. The canonical streaming-interview feature — "clicks in the last 5 minutes" — over a 3-broker Kafka cluster. I built it on PyFlink first: a JobManager, two TaskManagers, an Apache Beam portabili]]></description><link>https://seng-wei-chieh.hashnode.dev/i-replaced-a-flink-cluster-with-one-python-process</link><guid isPermaLink="true">https://seng-wei-chieh.hashnode.dev/i-replaced-a-flink-cluster-with-one-python-process</guid><dc:creator><![CDATA[schieh710]]></dc:creator><pubDate>Mon, 08 Jun 2026 01:13:35 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p><strong>TL;DR.</strong> The canonical streaming-interview feature — <em>"clicks in the last 5 minutes"</em> — over a 3-broker Kafka cluster. I built it on PyFlink first: a JobManager, two TaskManagers, an Apache Beam portability bridge, hand-pinned connector JARs, RocksDB, a checkpoint volume. The first smoke run died <strong>inside a Beam worker on a one-line Redis hostname typo</strong> — before a single window ever emitted. I threw the cluster away and rewrote it as <em>one Python process</em> with in-memory windows. It shipped. The lesson isn't "Flink bad." It's that every dependency is a liability you pay whether or not you ever collect its benefit — and at ~200 evt/s I was paying for six failure domains to get none of their payoff.</p>
</blockquote>
<hr />
<h2>The setup</h2>
<p>Same project as <a href="https://github.com/chieh006/streaming-feature-store">last time</a>: a streaming feature store. This PR is the headline feature — sliding-window aggregations per user: <code>clicks_5m</code>, <code>purchases_24h</code>, twelve features across three resolutions (5 min / 1 h / 24 h), each emitting on its own slide cadence. The "route" was never in doubt; sliding windows were the chosen approach from day one. The only open question was <strong>what computes them.</strong></p>
<p>The textbook answer is Apache Flink. So I started there.</p>
<p><em>Stack: Python 3.12, PyFlink DataStream API, a local Flink cluster on Docker Compose, Avro + Schema Registry, Redis as the online store. WSL2 dev box.</em></p>
<hr />
<h2>Act 1: the cluster that never emitted a window</h2>
<p>The Flink design was <em>correct</em>. Pane-based pre-aggregation for constant-state windows, bounded-out-of-orderness watermarks (5 s skew, 30 s idleness), allowed-lateness re-firing with an <code>emission_seq</code>, a dual sink contract (Redis hash + Kafka topic). I'm not knocking the semantics — they're standard streaming theory and they were right.</p>
<p>Then I ran it. The first smoke test died like this:</p>
<pre><code class="language-text">  File ".../apache_beam/runners/worker/sdk_worker.py", line ...
  File ".../apache_beam/runners/worker/operations.py", line ...
  ... eight more frames of Beam + JVM plumbing ...
ConnectionError: Error connecting to redis:6379
</code></pre>
<p>A <strong>Redis hostname</strong> was wrong. One line. The kind of bug you fix in fifteen seconds — <em>if you can see it</em>. Here it surfaced ten frames deep inside a Beam SDK worker, routed through the Python↔JVM portability bridge, underneath the Flink operator that called it. No window had emitted. I couldn't yet tell whether the <em>approach</em> worked, because the <strong>scaffolding</strong> hadn't even finished standing up.</p>
<p>That's the moment that mattered. Not "Flink is slow" — I never got far enough to measure speed. <strong>The failure was opaque, and it was in the foundation, not the feature.</strong> A bug in the foundation makes you question the whole approach. A bug in the glue is a one-line patch. I was staring at the wrong kind.</p>
<hr />
<h2>Act 2: count the failure domains</h2>
<p>I rewrote it as a plain <code>confluent-kafka</code> consumer that keeps the window state in ordinary Python dicts. Same windowing semantics — copied across <em>verbatim</em>, because the semantics were never the problem. The only thing that changed was the machine underneath them.</p>
<p>Here's the whole argument in one table — <strong>what has to stand up correctly before a single feature can be computed:</strong></p>
<table>
<thead>
<tr>
<th>Moving part</th>
<th>PyFlink build</th>
<th>Plain consumer</th>
</tr>
</thead>
<tbody><tr>
<td>JobManager</td>
<td>✅ required</td>
<td>—</td>
</tr>
<tr>
<td>TaskManagers (×2)</td>
<td>✅ required</td>
<td>—</td>
</tr>
<tr>
<td>JVM runtime</td>
<td>✅ required</td>
<td>—</td>
</tr>
<tr>
<td>Apache Beam portability bridge (Python↔JVM)</td>
<td>✅ required</td>
<td>—</td>
</tr>
<tr>
<td>Hand-pinned connector JARs</td>
<td>✅ required</td>
<td>—</td>
</tr>
<tr>
<td>RocksDB state backend + checkpoint volume</td>
<td>✅ required</td>
<td>—</td>
</tr>
<tr>
<td>Kafka client</td>
<td>(inside Flink)</td>
<td><code>confluent-kafka</code> — <em>proven in wk1</em></td>
</tr>
<tr>
<td>Online store client</td>
<td><code>redis-py</code> in a UDF</td>
<td><code>redis-py</code> — <em>proven in wk1</em></td>
</tr>
<tr>
<td>Window state</td>
<td>RocksDB</td>
<td>a Python <code>dict</code></td>
</tr>
<tr>
<td><strong>New, unproven failure domains</strong></td>
<td><strong>6+</strong></td>
<td><strong>0</strong></td>
</tr>
</tbody></table>
<p>The right column is built entirely from parts that already worked end-to-end the week before. The left column introduces <em>six independent things</em> that had never run together in this environment — and a feature can't emit until <strong>all six</strong> are healthy at once. That's not a throughput argument. It's a probability argument: the more independent parts that must simultaneously be correct, the more first runs you lose to scaffolding you can't see into.</p>
<hr />
<h2>The honest part: the rewrite also broke. Twice.</h2>
<p>If I stopped here it would read as "simple thing worked first try." It didn't. The plain consumer failed on <em>its</em> first two runs too:</p>
<ol>
<li><p><strong>A bootstrap hostname</strong> pointed at the Docker-internal broker name from a host shell — <code>Failed to resolve 'kafka-1:9092'</code>.</p>
</li>
<li><p><strong>A deserializer call</strong> passed <code>None</code> where the library now demands a <code>SerializationContext</code> — <code>TypeError</code> on the first message decoded.</p>
</li>
</ol>
<p>Two bugs. <em>Same class</em> as the one that killed the Flink build — a wrong address, a bad argument. The difference was the bill:</p>
<table>
<thead>
<tr>
<th></th>
<th>PyFlink</th>
<th>Plain consumer</th>
</tr>
</thead>
<tbody><tr>
<td>What failed</td>
<td>Redis hostname</td>
<td>Broker hostname; a <code>None</code> arg</td>
</tr>
<tr>
<td>How it surfaced</td>
<td>~10 frames of Beam/JVM</td>
<td>a 3-line Python traceback</td>
</tr>
<tr>
<td>Time to fix</td>
<td>(abandoned the approach)</td>
<td>one edit each, minutes</td>
</tr>
</tbody></table>
<p><strong>"It broke" is not the metric. "How much did the break cost" is.</strong> Opaque failures in a deep stack don't just take longer to fix — they make you distrust the whole design, because you can't localize the fault. Transparent failures in a shallow stack stay where they happen.</p>
<hr />
<h2>"But isn't the simple version slower?"</h2>
<p>The reasonable objection: surely you gave up performance. For <em>this</em> problem, no — not on the axis that matters.</p>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>PyFlink (promised)</th>
<th>Plain consumer (measured)</th>
</tr>
</thead>
<tbody><tr>
<td>Feature freshness (the sliding-window output)</td>
<td>5 s watermark + slide cadence</td>
<td><strong>identical</strong> — same 5 s + same slide</td>
</tr>
<tr>
<td>Steady-state throughput / worker</td>
<td>~10k evt/s/slot, via a Beam crossing</td>
<td>~11–14k evt/s/process, no crossing</td>
</tr>
<tr>
<td>Horizontal ceiling</td>
<td>12 (operator parallelism)</td>
<td>12 (consumer-group processes)</td>
</tr>
<tr>
<td>Restart recovery</td>
<td>instant (RocksDB checkpoints)</td>
<td>cold-start warm-up — <strong>Flink wins</strong></td>
</tr>
<tr>
<td>State beyond RAM</td>
<td>spills to disk — <strong>Flink wins</strong></td>
<td>heap; would OOM</td>
</tr>
</tbody></table>
<p>Feature freshness is governed by the watermark and the slide cadence, and the consumer copies both exactly — so "the 5 minutes ending now" is equally fresh either way. Throughput is a wash: both are GIL-bound in Python and both cap at 12 partitions, except the consumer <em>drops</em> the Python↔JVM crossing, so per worker it's equal-or-better. (PyFlink's only road to genuinely higher throughput was switching to <strong>Java</strong> — which the Flink design itself rejected.)</p>
<p>Flink genuinely wins two rows — restart recovery and state-beyond-RAM. But the feeder is ~200 evt/s, the whole state is tens of MB, and restarts are rare. <strong>Neither winning row binds at this scale.</strong> You don't pay six failure domains for a benefit your workload never triggers.</p>
<p>And the asymmetry in that table's column headers is the real punchline: PyFlink's numbers are <strong>promised</strong> — it never emitted a window, so they were never measured. The consumer's are the ones I actually clocked.</p>
<hr />
<h2>The correction I had to make (I was half-wrong)</h2>
<p>My first writeup of this had a tidy story: <em>"the rewrite was easy because the hard conceptual work — panes, watermarks, lateness — was already done in the Flink design; the second doc just inherited it."</em> A reviewer pushed back, and they were right.</p>
<p>That framing <strong>conflates two different questions:</strong></p>
<ul>
<li><p><em>Why was the rewrite cheap to produce?</em> → it reused already-specified, already-unit-tested semantics. True.</p>
</li>
<li><p><em>Why did the Flink build fail?</em> → <strong>dependencies.</strong> Full stop. Nothing to do with windowing semantics.</p>
</li>
</ul>
<p>I had answered the first question and quietly passed it off as the answer to the second. But the semantics were <em>never the risk</em> — they're standard streaming theory and the sliding-window route was decided before either design existed. If you had written Flink's exact semantics on the plain consumer's dependency footprint, <strong>it would have worked first try too.</strong> The failure was the six failure domains, not the math. Crediting the rewrite's <em>ease</em> for the original's <em>failure</em> points at the wrong cause.</p>
<hr />
<h2>What I'd tell another engineer</h2>
<ol>
<li><p><strong>Count failure domains before you count features.</strong> A part that must be healthy for anything to work is a liability you pay up front and a benefit you only sometimes collect.</p>
</li>
<li><p><strong>Match the tool's weight to the problem's scale.</strong> Flink earns its machinery at 100k+ evt/s and state beyond RAM. At 200 evt/s and tens of MB, that machinery is pure downside — cost with the benefit switched off.</p>
</li>
<li><p><strong>Opaqueness is a cost, and it's underrated.</strong> The <em>same one-line bug</em> was ten Beam/JVM frames in one stack and a three-line traceback in the other. Depth of stack sets the price of every future mistake.</p>
</li>
<li><p><strong>"It also broke" isn't the comparison — "how expensive was the break" is.</strong> The shallow version failed twice and shrugged it off in minutes.</p>
</li>
<li><p><strong>Don't confuse "why was the rewrite easy" with "why did the original fail."</strong> They have different answers. Mixing them hides the real cause.</p>
</li>
</ol>
<p>The headline is "I replaced a Flink cluster with one Python process." The actual deliverable is knowing <em>why</em> that was the right call with evidence — and being able to name the exact conditions (bigger-than-RAM state, sub-second SLAs, exactly-once with large state) that would send me straight back to Flink.</p>
<hr />
<h2>Coda: the same shape as last time</h2>
<p><a href="https://github.com/chieh006/streaming-feature-store">Last post</a> ended on: <em>a cost doesn't tell you where it lands — the binding constraint does.</em> This one is the same shape, one level up. <strong>A dependency is a cost you pay even when its benefit isn't the binding constraint.</strong> Flink's checkpointing, RocksDB spill, and JVM speed are real benefits — for a workload that triggers them. Mine didn't. So all that was left of them was the bill: six things that had to stand up before I could see whether one Redis hostname was wrong.</p>
<p>Account for what actually binds. Then buy exactly that, and nothing heavier.</p>
<hr />
<p><em>Full design docs — the superseded Flink version kept as an artifact, the plain-consumer version that shipped, and the fix-by-fix forensics — are in the project's</em> <a href="https://github.com/chieh006/streaming-feature-store"><em>engineering log on GitHub</em></a><em>. This is the narrative version. Pushback welcome — it's how the last section of this very post got written.</em></p>
]]></content:encoded></item><item><title><![CDATA[The GIL Fingerprint: Why My Kafka Producer Stalled at 14k events/sec]]></title><description><![CDATA[TL;DR. A Kafka load test needed to sustain 50,000+ events/sec. It plateaued at ~14k no matter what I tuned. The whole story collapses to one fact — CPython runs one process's Python on one core at a t]]></description><link>https://seng-wei-chieh.hashnode.dev/the-gil-fingerprint-why-my-kafka-producer-stalled-at-14k-events-sec</link><guid isPermaLink="true">https://seng-wei-chieh.hashnode.dev/the-gil-fingerprint-why-my-kafka-producer-stalled-at-14k-events-sec</guid><dc:creator><![CDATA[schieh710]]></dc:creator><pubDate>Sat, 16 May 2026 04:39:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a0743dd73afc8875786660f/d01c7604-e798-4c67-8613-4cadd6514817.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<blockquote>
<p><strong>TL;DR.</strong> A Kafka load test needed to sustain 50,000+ events/sec. It plateaued at ~14k no matter what I tuned. The whole story collapses to one fact — <em>CPython runs one process's Python on one core at a time</em> — and that single fact explains all three puzzles: why tuning didn't help, why five independent architecture changes all failed, and why the eventual multiprocessing fix had a <strong>counter-intuitive optimal thread count</strong> (2 per process beat both 1 and 3). The fix got ~5× to ~60k. The post includes the part where I was confidently wrong.</p>
</blockquote>
<hr />
<h2>The setup</h2>
<p>I was building the ingestion path for a streaming feature store: synthetic events, Avro-serialized, into a 3-broker Kafka cluster via <code>confluent-kafka</code>'s <code>SerializingProducer</code>, Pydantic for validation. The bar was concrete: <strong>sustain 60,000 evt/s, fail under 50,000</strong>, 10-second run, no dropped messages.</p>
<p>It sustained ~6,000. After tuning, ~14,500 — 3–4× short of the floor, and crucially with <strong>zero delivery failures</strong>. Nothing was breaking. It was hitting a <em>ceiling</em>. This is the hunt for that ceiling, and the one fact at the bottom of it.</p>
<p>*Stack: Python 3.12, <code>confluent-kafka</code> (librdkafka), <code>pydantic</code> v2, Avro</p>
<ul>
<li>Schema Registry, Kafka KRaft on Docker Compose, a custom multi-threaded load runner. WSL2 dev box — relevant later.*</li>
</ul>
<hr />
<h2>Act 1: the plateau, and the fix I got wrong</h2>
<p>First move: producer tuning — <code>linger.ms=20</code>, <code>lz4</code>, <code>acks=1</code> (dev only), 2 MB batches, generous queue caps. It worked <em>exactly as designed</em>: <strong>+18% throughput, p95/p99 latency roughly halved.</strong> And still 3.4× below the floor.</p>
<p>That's where a performance investigation gets interesting: the tuning did its job, but the bottleneck was somewhere else. So I kept going, one change per run (otherwise you can't attribute anything):</p>
<table>
<thead>
<tr>
<th>Change</th>
<th>Hypothesis</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>librdkafka tuning</td>
<td>broker round-trips dominate</td>
<td>✅ +18%, kept</td>
</tr>
<tr>
<td>One producer <strong>per worker thread</strong></td>
<td>shared serializer lock is the bottleneck</td>
<td>❌ flat throughput, latency 2–6× <em>worse</em></td>
</tr>
<tr>
<td>Verify ≥12 partitions</td>
<td>broker-side serialization</td>
<td>✅ no-op (already optimal)</td>
</tr>
<tr>
<td>Poll once per batch, not per event</td>
<td>per-event poll lock contention</td>
<td>❌ −34%</td>
</tr>
<tr>
<td>Dedicated callback-pump thread</td>
<td>workers waste time polling</td>
<td>❌ −32% (best p50 ever, though)</td>
</tr>
</tbody></table>
<p>Five smart, well-targeted hypotheses. Every one regressed or did nothing. <strong>That pattern is itself the clue</strong> — but first, the one I got wrong, because it's the most instructive data point in the whole investigation.</p>
<p>The library docstring says <em>"Not thread-safe — one instance per producing thread."</em> The serializer holds a schema-cache lock. Twelve threads, one lock — obviously the bottleneck. I shipped it without profiling. <strong>Throughput went flat; every latency percentile got 2–6× worse.</strong> Post-mortem: splitting one shared producer into twelve fragmented the broker-side batches ~12× (tiny frequent requests instead of one fat batched one), and the broker's <em>per-request</em> fixed cost swamped any lock saving. The lock was a microsecond dict lookup — 1–5% of per-event time, never the bottleneck. <em>"Not thread-safe" is a correctness statement, not a performance one.</em> A 5-minute <code>py-spy</code> run would have killed the idea before I wrote a line. I'm keeping this in because the engineer who hides their wrong turns is hiding the part where the reasoning actually happens.</p>
<hr />
<h2>Act 2: the fingerprint</h2>
<p>Step back and look at every configuration measured:</p>
<table>
<thead>
<tr>
<th>Configuration</th>
<th>Sustained evt/s</th>
</tr>
</thead>
<tbody><tr>
<td>Baseline</td>
<td>~12,250</td>
</tr>
<tr>
<td>+ librdkafka tuning</td>
<td>~14,500</td>
</tr>
<tr>
<td>+ per-worker producers</td>
<td>~14,400</td>
</tr>
<tr>
<td>+ per-batch poll</td>
<td>~9,600</td>
</tr>
<tr>
<td>+ dedicated pump thread</td>
<td>~9,800</td>
</tr>
<tr>
<td>tuning only (revert)</td>
<td>~14,800</td>
</tr>
</tbody></table>
<p><strong>Six independent architectures — different locks, topologies, polling strategies — all clustered in 9.5–14.8k.</strong> Each change moved <em>which inner constraint was binding</em> without ever moving the <em>outer</em> ceiling.</p>
<p>That is consistent with exactly one thing: a <strong>process-wide serialization point no in-process change can escape</strong>. In CPython there is precisely one — the Global Interpreter Lock. One thread runs Python bytecode at a time, regardless of thread or core count.</p>
<h3>Each failure is positive evidence</h3>
<p>The regressions aren't disappointments — each is a <em>falsification test</em> the GIL hypothesis survived:</p>
<ul>
<li><p><strong>Per-worker producers</strong> removed 12-way contention on <em>two</em> locks at once. If either bound throughput, it should have jumped. <strong>0% change</strong> — freeing inner locks does nothing when threads queue on the GIL anyway.</p>
</li>
<li><p><strong>Per-batch polling</strong> cut a lock's acquire frequency ~16,800×. If frequency were the cost, this recovers it. <strong>−34%</strong> — the dispatch <em>work</em> still happens; concentrating it just made GIL contention spikier.</p>
</li>
<li><p><strong>The pump thread</strong> is the <em>structurally correct</em> fix for the symptom (a profile showed 93% of worker time in <code>poll()</code>). It did its job — best p50 I ever measured. But <strong>throughput fell 32%</strong>. "The architecture is right" and "throughput regressed" are only both true if the work moved to a thread competing for the <em>same GIL</em>. With real parallelism the pump runs on its own core and throughput climbs. It didn't.</p>
</li>
</ul>
<p>Three changes that <em>should</em> have moved throughput if anything but the GIL were binding. None did.</p>
<h3>The arithmetic that closed it</h3>
<p>At ~14,500 evt/s, per-event Python work (Pydantic + serializer adapter</p>
<ul>
<li>bookkeeping + poll callbacks) is ~70 µs of GIL-held time:</li>
</ul>
<pre><code class="language-plaintext">14,500 evt/s × 70 µs/evt ≈ 1,015 ms of GIL-held time per second
1 CPU core                = 1,000 ms of execution per second
</code></pre>
<p>We were saturating ~100% of <strong>one core's worth of Python</strong> — the ceiling, from first principles, matching the measured plateau. No in-process multi-threaded design beats one core of Python, because the GIL serializes bytecode across all threads. Every architecture I tried just rearranged which thread held the one lock that matters.</p>
<hr />
<h2>Act 3: the same fact, billed twice more</h2>
<p>Here's the through-line. The GIL didn't only explain the plateau and the failed fixes — <em>the same one-core-per-process fact dictates how to escape it, and how to tune the escape.</em></p>
<p><strong>Escape:</strong> if the limit is one GIL per process, run more processes. Each gets its own interpreter, its own GIL, its own core. I built a separate multiprocessing harness (kept beside the threaded one — they serve different jobs): N producer processes, the proven single-process config in each, the parent aggregating results.</p>
<p><strong>The tuning law — billed a third time.</strong> The projection said 4 × <del>14.5k ≈ ~58k. The first auto-layout, <strong>4 processes × 3 workers</strong>, gave only <strong>34.5k</strong> — each process <em>slower</em> than one running alone. <strong>6 × 2</strong> (same 12 total threads, redistributed) gave **</del>60k** — same thread count, <strong>+76%</strong>. <strong>8 × 1</strong> ≈ 62k (tied, but 33% more processes for it). <strong>2 × 6</strong>: a dismal 15k.</p>
<p>Workers-per-process has an <em>optimum</em>, and it's the GIL again. Treat each process's GIL as a single-server queue you want ~100% utilized with no queue forming for it:</p>
<ul>
<li><p><strong>Too few (1/proc):</strong> the lone worker spends ~half its time parked in GIL-yielding waits (rate limiter, backpressure). The GIL sits <strong>idle</strong> — half the process's Python capacity wasted. <em>Why 8×1 ≯ 6×2.</em></p>
</li>
<li><p><strong>Too many (3+/proc):</strong> more threads than the GIL can serve; the surplus block in the handoff path and you pay serialization + handoff overhead. <em>Why 4×3 &lt; 6×2.</em></p>
</li>
</ul>
<p>The optimum is the fewest workers that keep the GIL continuously busy through the natural blocking gaps: empirically <code>W ≈ round(1/s)</code>, where <code>s</code> is the fraction of a worker's wall time <em>holding</em> the GIL. Here <code>s ≈ 0.5</code>, so <strong>W = 2</strong>. Two workers tag-team the GIL — one runs while the other is parked — while librdkafka's C sender threads (no GIL) do the network in parallel. That turns "grid-search every combination" into "profile once for <code>s</code>, compute the layout, confirm with one run."</p>
<table>
<thead>
<tr>
<th></th>
<th>Sustained evt/s</th>
<th>vs. 50k floor</th>
</tr>
</thead>
<tbody><tr>
<td>Single process (best, tuned)</td>
<td>~14,500</td>
<td>❌ 3.4× under</td>
</tr>
<tr>
<td>4 × 3 multiprocess</td>
<td>~34,500</td>
<td>❌ under</td>
</tr>
<tr>
<td><strong>6 × 2 multiprocess (kept)</strong></td>
<td><strong>~59–62k</strong></td>
<td>✅ <strong>clears it</strong></td>
</tr>
</tbody></table>
<p>~5× the single-process number, latency <em>also</em> better (p50 16 vs 28 ms; p95 47 vs 170 ms), zero failures.</p>
<p>One honest caveat, because error bars are part of the result: this was a WSL2 dev box, numbers drifted as broker/OS state warmed. The load-bearing claims aren't "60,000" — they're the <strong>~5× ratio</strong>, the <strong>mechanism</strong>, and the <code>W ≈ round(1/s)</code> <strong>model</strong>. Those reproduce; a laptop benchmark number doesn't.</p>
<hr />
<h2>What I'd tell another engineer</h2>
<ol>
<li><p><strong>Profile before optimizing.</strong> My one regression came from shipping a plausible hypothesis without the 5-minute profile that would have killed it. "Obviously slow" is not a measurement.</p>
</li>
<li><p><strong>% of time ≠ % wasted.</strong> 93% in <code>poll()</code> wasn't waste — it was <em>useful</em> callback work. Removing it relocated the work, not deleted it.</p>
</li>
<li><p><strong>"Not thread-safe" ≠ "this lock is hot."</strong> One is a correctness claim, the other an empirical one. Only measurement answers the second.</p>
</li>
<li><p><strong>Architecture changes are conservation games.</strong> Moving work between threads doesn't reduce it; it changes who waits. Real gains need less total work or escaping the serialization point.</p>
</li>
<li><p><strong>Out of in-process options? The next move is multiprocessing or a no-GIL build — not more thread tuning.</strong> And size it with <code>W ≈ round(1/s)</code>, not intuition.</p>
</li>
</ol>
<p>The point of writing this up isn't the 5×. It's that the entire investigation — a plateau, five dead ends, and a surprising thread count — reduces to <em>one</em> fact about CPython, provable with a falsification chain and a back-of-envelope. Shipping the speedup is the deliverable. Knowing <em>why</em>, with evidence, is the job.</p>
<hr />
<p><em>Full investigation — every run, the lock-stack diagram, the fix-by-fix forensics, the queueing-theory derivation of the worker law — is in the project's engineering log. This is the narrative version. Pushback welcome.</em></p>
]]></content:encoded></item></channel></rss>