<?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[Shopnil Writes]]></title><description><![CDATA[Shopnil Writes]]></description><link>https://namikazi25.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Shopnil Writes</title><link>https://namikazi25.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 20 Sep 2026 08:54:49 GMT</lastBuildDate><atom:link href="https://namikazi25.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I Got Qwen3.8-27B from 18.66 to 192.40 tok/s on One L40S]]></title><description><![CDATA[We recently deployed Qwen3.8-27B internally at work.
The setup was fairly straightforward: one NVIDIA L40S serving the model through vLLM, LiteLLM in front of it for authentication and per-user API ke]]></description><link>https://namikazi25.hashnode.dev/how-i-got-qwen3-8-27b-from-18-66-to-192-40-tok-s-on-one-l40s</link><guid isPermaLink="true">https://namikazi25.hashnode.dev/how-i-got-qwen3-8-27b-from-18-66-to-192-40-tok-s-on-one-l40s</guid><category><![CDATA[llm]]></category><category><![CDATA[vLLM]]></category><category><![CDATA[NVIDIA]]></category><category><![CDATA[inference]]></category><category><![CDATA[AI Engineering]]></category><dc:creator><![CDATA[Mir Nafis Sharear Shopnil]]></dc:creator><pubDate>Mon, 31 Aug 2026 15:31:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a957bc68b0ba9db306e7024/0db36d39-9c5e-4db9-b3c6-0170a200969c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We recently deployed Qwen3.8-27B internally at work.</p>
<p>The setup was fairly straightforward: one NVIDIA L40S serving the model through vLLM, LiteLLM in front of it for authentication and per-user API keys, and an OpenAI-compatible endpoint that people could plug into whatever client they wanted.</p>
<p>It worked.</p>
<p>The problem was that it was generating at around <strong>19 tokens per second</strong>.</p>
<p>On an L40S.</p>
<p>19 tok/s is usable. Nobody was sitting there wondering whether the server had died. But once you start using the model for coding or agentic workflows, it gets painful very quickly. An agent might call the model repeatedly, run a tool, come back to the model, run another tool, and so on. Slow generation gets multiplied across the entire loop.</p>
<p>So I started looking into why it was so slow.</p>
<p>A quick disclaimer before I get into it: <strong>I’m not an inference engineer.</strong> I don't work on inference performance for a living, and this was my first time properly digging into model quantization, kernels and speculative decoding like this. I did it because the system I was responsible for needed it, and I learned a lot along the way.</p>
<p>If you spot something wrong in my methodology or explanation, please leave a comment. I’d much rather correct the post than confidently keep a bad explanation in it.</p>
<hr />
<h2>First, was it actually slow?</h2>
<p>Before changing anything, I wanted a number I could reproduce.</p>
<p>Our original production deployment used the official Qwen3.8-27B FP8 checkpoint.</p>
<p>I used a deliberately simple serving benchmark:</p>
<ul>
<li><p>1,024 input tokens</p>
</li>
<li><p>1,024 output tokens</p>
</li>
<li><p>concurrency 1</p>
</li>
<li><p>temperature 0</p>
</li>
<li><p>forced generation with <code>--ignore-eos</code></p>
</li>
<li><p>three requests</p>
</li>
</ul>
<p>Later, when I was preparing this post, I reran the benchmark against the old production container, which was still alive on our second GPU for rollback.</p>
<p>It gave me:</p>
<p><strong>18.66 output tok/s</strong></p>
<p>with a mean TPOT of <strong>53.42 ms</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a957bc68b0ba9db306e7024/c6fbadd3-0477-420c-b8a4-39609bae1ebc.png" alt="" style="display:block;margin:0 auto" />

<p>So the ~19 tok/s we had been seeing wasn't just the UI feeling sluggish. The backend really was that slow under this test.</p>
<p>At this point I was starting to suspect we had bought a very expensive space heater.</p>
<hr />
<h2>Why was FP8 this slow?</h2>
<p>This was the first useful rabbit hole.</p>
<p>I had assumed that an FP8 model on an L40S should naturally be a good combination. The model fit comfortably in memory, the L40S supports FP8, and nothing looked obviously broken.</p>
<p>Then I started paying more attention to what vLLM was actually doing.</p>
<p>The official checkpoint was using block-scaled FP8. On our L40S, vLLM selected a Triton block-scaled FP8 matrix multiplication path. The startup logs were also warning that there wasn't a tuned L40S W8A8 configuration available for that path.</p>
<p>That made the next experiment pretty obvious: try a checkpoint that could use a better kernel on Ada.</p>
<p>I went with:</p>
<p><a href="https://huggingface.co/dbirks/Qwen3.8-27B-W4A16-AutoRound"><code>dbirks/Qwen3.8-27B-W4A16-AutoRound</code></a></p>
<p>Most of the big weight matrices are quantized to 4-bit while activations stay in BF16. Some parts of the model, including things like the MTP head and <code>lm_head</code>, remain BF16.</p>
<p>The interesting bit for our L40S was that vLLM could run the quantized linear layers through <strong>Marlin</strong>.</p>
<p>Same GPU. Still Qwen3.8-27B.</p>
<p>Throughput jumped to roughly <strong>43 tok/s</strong>.</p>
<p>That's more than twice what we were getting before, before touching speculative decoding at all.</p>
<p>The first big improvement came from changing the checkpoint and kernel path.</p>
<hr />
<h2>Then I started playing with MTP</h2>
<p>Qwen3.8 has native MTP support, so I wanted to see how far speculative decoding could take it.</p>
<p>The rough idea is simple: instead of having the target model move forward exactly one token at a time, you speculate several future tokens and then verify them. If enough guesses are accepted, generation gets a lot faster.</p>
<p>I started with three speculative tokens.</p>
<p>That jumped straight to around <strong>90.5 tok/s</strong>.</p>
<p>Which was already kind of ridiculous compared with where this started.</p>
<p>Then I did what any reasonable person would do after getting a 2× improvement.</p>
<p>I changed the number from 3 to 4.</p>
<p>Then 5.</p>
<p>Then 6.</p>
<p>Then 7.</p>
<h3>MTP sweep</h3>
<table>
<thead>
<tr>
<th>Setup</th>
<th>Output throughput</th>
<th>Approx. speedup vs W4A16 AR</th>
</tr>
</thead>
<tbody><tr>
<td>W4A16 autoregressive</td>
<td>~42.7 tok/s</td>
<td>1.00×</td>
</tr>
<tr>
<td>MTP3</td>
<td>~90.5 tok/s</td>
<td>~2.12×</td>
</tr>
<tr>
<td>MTP4</td>
<td>~101.2 tok/s</td>
<td>~2.37×</td>
</tr>
<tr>
<td>MTP5</td>
<td>~107.4 tok/s</td>
<td>~2.52×</td>
</tr>
<tr>
<td>MTP6</td>
<td>~112.2 tok/s</td>
<td>~2.63×</td>
</tr>
<tr>
<td>MTP7</td>
<td>~116.3 tok/s</td>
<td>~2.72×</td>
</tr>
</tbody></table>
<p>The gains were tapering off as I increased the speculative length, but every step still helped.</p>
<p>MTP7 got us into the <strong>116–118 tok/s</strong> range.</p>
<p>If I hadn't found DFlash2 shortly afterwards, that probably would have been the production configuration.</p>
<hr />
<h2>Unfortunately, I found DFlash2</h2>
<p>DFlash2 uses a separate draft model to propose a block of future tokens for the target model to verify.</p>
<p>There was already a Qwen3.8-27B DFlash2 drafter available:</p>
<p><a href="https://huggingface.co/incoai/Qwen3.8-27B-DFlash2"><code>incoai/Qwen3.8-27B-DFlash2</code></a></p>
<p>Great.</p>
<p>Except our version of vLLM didn't support the Qwen3 DFlash2 path.</p>
<p>The model card pointed to vLLM PR #52816, which meant I was now building a custom vLLM image.</p>
<p>This part took longer than the benchmark.</p>
<p>My first attempt was essentially a full source build. I let it compile for more than <strong>2,700 seconds</strong> before deciding I had probably proved my commitment to the cause and killing it.</p>
<p>I eventually got the PR build working using vLLM's precompiled path. The final image build took around two minutes.</p>
<p>Then I had to check that CUDA was working, that the Qwen3 DFlash2 speculator was actually present, that <code>DFlash2DraftModel</code> resolved correctly, and download another ~3.6 GB draft checkpoint.</p>
<p>At some point later, one of the benchmark environments also failed because it didn't have <code>pandas</code>.</p>
<p>Nothing says cutting-edge inference work like getting stopped by a dataframe library.</p>
<p>Eventually, though, it ran.</p>
<p>And the first warm results were around <strong>184 tok/s</strong>.</p>
<p>That was high enough that I didn't trust the comparison yet.</p>
<hr />
<h2>Making the comparison fairer</h2>
<p>There was an obvious problem with comparing that ~184 number directly with my earlier MTP results.</p>
<p>DFlash2 required the newer PR52816 vLLM build. The earlier experiments had been run on the older environment.</p>
<p>So I reran the configurations I actually cared about using the <strong>same vLLM runtime</strong>.</p>
<p>Same W4A16 target.</p>
<p>Same L40S.</p>
<p>Same 1K input / 1K output benchmark.</p>
<p>Same concurrency.</p>
<p>Three measured runs for each setup.</p>
<h3>Same-runtime comparison</h3>
<table>
<thead>
<tr>
<th>Configuration</th>
<th>Output throughput</th>
<th>Speedup vs W4A16 AR</th>
</tr>
</thead>
<tbody><tr>
<td>W4A16 autoregressive</td>
<td><strong>42.93 tok/s</strong></td>
<td>1.00×</td>
</tr>
<tr>
<td>W4A16 + MTP7</td>
<td><strong>118.27 tok/s</strong></td>
<td><strong>2.76×</strong></td>
</tr>
<tr>
<td>W4A16 + DFlash2-7</td>
<td><strong>191.47 tok/s</strong></td>
<td><strong>4.46×</strong></td>
</tr>
</tbody></table>
<p>DFlash2 was about <strong>61.9% faster than MTP7</strong> in this test.</p>
<p>That was the result that convinced me to take it seriously for production.</p>
<hr />
<h2>The whole journey</h2>
<p>There are two sets of numbers in this post that are easy to mix up, so here is everything together.</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Configuration</th>
<th>Output throughput</th>
</tr>
</thead>
<tbody><tr>
<td>Original production</td>
<td>Official Qwen3.8-27B FP8</td>
<td><strong>18.66 tok/s</strong></td>
</tr>
<tr>
<td>W4A16 baseline</td>
<td>W4A16 + Marlin, autoregressive</td>
<td><strong>42.93 tok/s</strong></td>
</tr>
<tr>
<td>MTP7</td>
<td>W4A16 + native MTP7</td>
<td><strong>118.27 tok/s</strong></td>
</tr>
<tr>
<td>DFlash2-7</td>
<td>W4A16 + DFlash2</td>
<td><strong>191.47 tok/s</strong></td>
</tr>
<tr>
<td>Fresh production-candidate retest</td>
<td>W4A16 + DFlash2</td>
<td><strong>192.40 tok/s</strong></td>
</tr>
</tbody></table>
<p>The headline <strong>18.66 → 192.40 tok/s</strong> covers everything we changed between the original deployment and the final one.</p>
<p>That includes the switch from FP8 to W4A16, the Marlin kernel path, a newer vLLM runtime and DFlash2.</p>
<p>If you specifically want to measure what DFlash2 did in a controlled comparison, use the same-runtime numbers:</p>
<p><strong>42.93 → 191.47 tok/s</strong></p>
<p>And if you want DFlash2 versus the best MTP configuration I tested:</p>
<p><strong>118.27 → 191.47 tok/s</strong></p>
<p>I think that distinction matters, especially when putting a big 10× number in the title or a tweet.</p>
<hr />
<h2>Before and after</h2>
<p>Before writing this post, I still had both deployments running.</p>
<p>The old FP8 backend was sitting on one L40S as a rollback option, while the new W4A16 + DFlash2 backend was running on the other.</p>
<p>That gave me a nice opportunity to rerun the exact same benchmark against both live containers.</p>
<h3>Live before/after test</h3>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Old FP8</th>
<th>W4A16 + DFlash2</th>
</tr>
</thead>
<tbody><tr>
<td>Output throughput</td>
<td><strong>18.66 tok/s</strong></td>
<td><strong>192.40 tok/s</strong></td>
</tr>
<tr>
<td>Mean TTFT</td>
<td>230.70 ms</td>
<td>324.37 ms</td>
</tr>
<tr>
<td>Mean TPOT</td>
<td><strong>53.42 ms</strong></td>
<td><strong>4.89 ms</strong></td>
</tr>
<tr>
<td>Speculative acceptance rate</td>
<td>—</td>
<td><strong>94.07%</strong></td>
</tr>
<tr>
<td>Mean acceptance length</td>
<td>—</td>
<td><strong>7.58</strong></td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/6a957bc68b0ba9db306e7024/f53ddf6b-cda6-4d1c-b047-ec2d498cc960.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/6a957bc68b0ba9db306e7024/210f3fb5-7942-4bdd-8d8c-38d84dd5e1e4.png" alt="" style="display:block;margin:0 auto" />

<p>The TTFT is actually worse on the new configuration:</p>
<p><strong>230.70 ms → 324.37 ms</strong></p>
<p>But once generation gets going, the difference is huge.</p>
<p>TPOT went from:</p>
<p><strong>53.42 ms → 4.89 ms</strong></p>
<p>And the DFlash2 run had a <strong>94.07% acceptance rate</strong> with an average acceptance length of <strong>7.58</strong>.</p>
<p>For this workload, the drafter was doing very well.</p>
<p>The fresh DFlash2 run also landed at <strong>192.40 tok/s</strong>, which was reassuringly close to the <strong>191.47 tok/s</strong> average from the earlier controlled experiment.</p>
<hr />
<h2>Then Hermes told me I was only getting 40 tok/s</h2>
<p>After all this, I added a little tokens-per-second indicator to the Hermes client I was using.</p>
<p>It started showing around <strong>38–42 tok/s</strong> during real agent sessions.</p>
<p>For a moment I thought something had gone horribly wrong again.</p>
<p>It hadn't.</p>
<p>The ~192 tok/s benchmark and the ~40 tok/s agent number measure very different things.</p>
<p>The serving benchmark is one long controlled generation.</p>
<p>A real agent turn can involve:</p>
<ul>
<li><p>model generation</p>
</li>
<li><p>time to first token</p>
</li>
<li><p>multiple separate API calls</p>
</li>
<li><p>shell commands</p>
</li>
<li><p>file reads and writes</p>
</li>
<li><p>searches</p>
</li>
<li><p>tool execution</p>
</li>
<li><p>network round trips</p>
</li>
<li><p>waiting between one agent step and the next</p>
</li>
</ul>
<p>One of my agent turns, for example, ran for roughly 66 seconds and generated around 2,700 output tokens across the whole process. That works out to around 41 tok/s end to end.</p>
<p>A large chunk of those 66 seconds isn't model decoding at all.</p>
<p>This also made me appreciate how painful the original backend must have been for agents.</p>
<p>At ~19 tok/s, every model call was slow. Then the agent would call a tool and hit the model again. Then again.</p>
<p>A slightly slow chatbot is annoying.</p>
<p>A slow model inside a long agent loop can make the whole thing feel broken.</p>
<p>That was ultimately why I cared about this in the first place.</p>
<hr />
<h2>I also tried benchmarking this more properly and accidentally benchmarked my patience</h2>
<p>The 1K → 1K benchmark is intentionally synthetic.</p>
<p>It's useful because I can hold the workload constant and compare inference configurations cleanly, but obviously people do not normally send 1,024 random tokens to a model and demand exactly another 1,024 tokens back.</p>
<p>So I wanted a larger real-prompt benchmark as well.</p>
<p>I tried NVIDIA SPEED-Bench at concurrency 1.</p>
<p>Eventually the baseline reached roughly:</p>
<p><strong>994 / 1,536 prompts</strong></p>
<p>after:</p>
<p><strong>22 hours</strong></p>
<p>It still wasn't done.</p>
<p>I killed it.</p>
<p>At some point your benchmark stops measuring the server and starts measuring your willingness to give up a GPU for several days.</p>
<p>So when I use <strong>192.40 tok/s</strong> in this post, I'm talking about this specific C1, 1,024-input / 1,024-output benchmark.</p>
<p>I would not expect 192 tok/s from every coding session, agent run or company chat.</p>
<p>Prompt length changes things. Reasoning changes things. Cache state matters. Concurrency matters. Speculative acceptance matters.</p>
<p>The number is useful because the conditions stayed fixed while I changed the inference setup.</p>
<hr />
<h2>Getting it into production</h2>
<p>A fast benchmark wasn't enough for me to swap the backend.</p>
<p>Our production configuration has a bunch of things the synthetic test deliberately ignores:</p>
<ul>
<li><p>131K context</p>
</li>
<li><p>FP8 KV cache</p>
</li>
<li><p>prefix caching</p>
</li>
<li><p>CPU KV offload</p>
</li>
<li><p>Qwen reasoning parsing</p>
</li>
<li><p>automatic tool calling</p>
</li>
<li><p>the Qwen coder tool parser</p>
</li>
<li><p>our normal chat template</p>
</li>
</ul>
<p>I also changed the default reasoning effort from <code>medium</code> to <code>xhigh</code>.</p>
<p>Generation had become fast enough that I was much happier spending some of that gain on better reasoning quality.</p>
<p>Before moving traffic, I tested the new backend directly.</p>
<p>Normal chat worked.</p>
<p><code>xhigh</code> reasoning worked.</p>
<p>The reasoning content stayed separate from the final answer.</p>
<p>Tool calling returned proper OpenAI-compatible <code>tool_calls</code>.</p>
<p>Structured JSON parsed successfully.</p>
<p>Multi-turn conversation worked.</p>
<p>Once I was happy with that, I brought the new backend up alongside the old one.</p>
<p>Our architecture already looked roughly like this:</p>
<pre><code class="language-text">Users
  ↓
Traefik
  ↓
LiteLLM
  ↓
vLLM
</code></pre>
<p>Users talk to LiteLLM using the same public model alias:</p>
<pre><code class="language-text">qwen3.8-27b
</code></pre>
<p>So the migration was pleasantly boring.</p>
<p>The old backend remained available for rollback.</p>
<p>The new backend came online separately.</p>
<p>Then LiteLLM's internal upstream changed from the old vLLM container to the new one.</p>
<p>Nobody needed a new API key.</p>
<p>Nobody needed a new model name.</p>
<p>Nobody needed to change Hermes or any other client.</p>
<p>That's probably the least exciting part of this entire experiment, which is exactly what I wanted from the production part.</p>
<hr />
<h2>A few things I didn't expect</h2>
<p>I went into this thinking I would probably find a configuration flag or two that we had set badly.</p>
<p>The first huge improvement came before speculative decoding was even involved.</p>
<p>Moving from the official FP8 checkpoint to W4A16 + Marlin took us from around <strong>19 tok/s to 43 tok/s</strong>.</p>
<p>That surprised me.</p>
<p>I had mentally put "FP8 model + datacenter GPU" into the bucket of things that should already be fast. I hadn't paid enough attention to the exact quantization format and which kernel vLLM was actually using on our hardware.</p>
<p>Then speculative decoding took over:</p>
<pre><code class="language-text">Official FP8                  18.66 tok/s

W4A16 + Marlin                42.93 tok/s

W4A16 + MTP7                 118.27 tok/s

W4A16 + DFlash2              191.47 tok/s

Fresh DFlash2 retest          192.40 tok/s
</code></pre>
<p>All on one L40S.</p>
<p>No H100.</p>
<p>No second GPU needed to make a single request faster.</p>
<p>Just a lot of experiments and quite a few wrong turns.</p>
<p>I suspect someone who actually does inference engineering every day could have arrived at some of these decisions much faster than I did.</p>
<p>But that was also the fun part of doing it for the first time. I now understand far more about what is actually happening between "I downloaded an FP8 model" and "tokens are coming out of the GPU."</p>
<p>And unfortunately, after all of this, I can no longer blame the L40S.</p>
<hr />
<h2>Reproducing the benchmark</h2>
<p>For anyone who wants to reproduce the basic controlled test, this was the shape of it:</p>
<pre><code class="language-bash">vllm bench serve \
  --backend openai \
  --base-url http://127.0.0.1:8000 \
  --endpoint /v1/completions \
  --model &lt;MODEL&gt; \
  --served-model-name qwen3.8-27b \
  --tokenizer &lt;MODEL&gt; \
  --dataset-name random \
  --random-input-len 1024 \
  --random-output-len 1024 \
  --num-prompts 3 \
  --max-concurrency 1 \
  --temperature 0 \
  --ignore-eos
</code></pre>
<p>For my autoregressive/MTP7/DFlash2 comparison, I kept the vLLM runtime, W4A16 target and benchmark conditions fixed.</p>
<p>If you run this on another GPU, another model, or even another kernel implementation, I would expect different results.</p>
<p>Which is why this whole thing started in the first place.</p>
<p>I assumed I knew roughly how fast our stack should be.</p>
<p>Then I measured it.</p>
]]></content:encoded></item></channel></rss>