<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pujun Lun</title>
    <description>The latest articles on DEV Community by Pujun Lun (@lun0522).</description>
    <link>https://dev.to/lun0522</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4172227%2Fbe83a25a-088f-414e-9fe1-60f83cbb5069.png</url>
      <title>DEV Community: Pujun Lun</title>
      <link>https://dev.to/lun0522</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lun0522"/>
    <language>en</language>
    <item>
      <title>When "Just Enable the CUDA Feature" Met Two Free GPUs</title>
      <dc:creator>Pujun Lun</dc:creator>
      <pubDate>Sat, 31 Oct 2026 21:51:02 +0000</pubDate>
      <link>https://dev.to/lun0522/when-just-enable-the-cuda-feature-met-two-free-gpus-35l9</link>
      <guid>https://dev.to/lun0522/when-just-enable-the-cuda-feature-met-two-free-gpus-35l9</guid>
      <description>&lt;p&gt;If a Rust LLM inference engine already runs on CPU and Apple Metal, and its tensor framework supports CUDA, how hard could it be to run it on NVIDIA GPUs?&lt;/p&gt;

&lt;p&gt;I expected a simple recompile. Instead, moving &lt;a href="https://github.com/lun0522/mini-vllm-rs" rel="noopener noreferrer"&gt;&lt;code&gt;mini-vllm-rs&lt;/code&gt;&lt;/a&gt;, written in Rust and built on top of &lt;a href="https://github.com/huggingface/candle" rel="noopener noreferrer"&gt;Candle&lt;/a&gt;, from my M2 Mac mini to Linux and CUDA exposed two separate F16 failures on the GPU, a profiler return value that broke my scope guards, a Mac-shaped assumption in my CLI, and a CPU optimization that had been silently leaking onto the GPU.&lt;/p&gt;

&lt;p&gt;My motivation for the move was simple: Kaggle Notebooks give you two free NVIDIA Tesla T4 GPUs. On my Mac mini, heterogeneous serving was limited to one CPU worker and one Metal GPU worker (see my &lt;a href="https://dev.to/lun0522/when-llm-inference-on-cpu-beats-waiting-for-the-gpu-22ff"&gt;previous post&lt;/a&gt;). With a dual-T4 notebook, I could test two GPU workers concurrently, or combine them with a CPU worker into a larger heterogeneous system.&lt;/p&gt;

&lt;p&gt;Before I could test three workers together, however, a single CUDA worker had to pass my end-to-end smoke test.&lt;/p&gt;

&lt;h2&gt;
  
  
  F16 failed twice on CUDA: first loudly, then silently
&lt;/h2&gt;

&lt;p&gt;The smoke test requests F16 activations. On macOS, Candle's quantized Metal matmul does not support F16 activations, so GPU inference fell back to F32 while CPU inference used F16. On CUDA, I initially let F16 run through.&lt;/p&gt;

&lt;p&gt;It failed during model loading with &lt;code&gt;unsupported dtype for dequantize F32&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Even in a 4-bit GGUF model, small tensors such as normalization weights are typically left unquantized, so their stored type may be F32, F16, or BF16 depending on the source model and conversion.&lt;/p&gt;

&lt;p&gt;When converting weights to match F16 activations, Candle 0.11.0 takes different paths by device: non-CUDA backends dequantize to F32 and cast to F16, whereas CUDA dispatches to a specialized kernel table that handles a subset of quantized formats and rejects the rest, including unquantized F32 weights.&lt;/p&gt;

&lt;p&gt;I simply added a check to route F32 weights through the generic dequantize-then-cast path. That got the model past initialization, and Qwen2.5 0.5B started producing the right results. Hooray! That was the first victory.&lt;/p&gt;

&lt;p&gt;However, when I tried a larger model, Qwen2.5 7B, it failed the smoke test in a worse way: F16 activations produced corrupted text on CUDA. The prompt asked why leaves change color in autumn, and the output was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;![](&lt;a href="https://www.google.com/url?q=https%3A%2F%2Fi.imgur.com%21v%2F6QZ2p.jpg" rel="noopener noreferrer"&gt;https://i.imgur.com!v/6QZ2p.jpg&lt;/a&gt;) ![](!&lt;a href="https://www.google.com/url?q=https%3A%2F%2Fi%21imgur.com%21v%21%2F5Xt4K.jpg" rel="noopener noreferrer"&gt;https://i!imgur.com!v!/5Xt4K.jpg&lt;/a&gt;) In!!! the!!!!!! autumn! season!! !,!!!!!!!! leaves!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !change!!!!!! !!their!! !!colors!! !from!! !green!! !!to!!!! !!!yellow! !,! !!orange! !,! !and!!! !red! !. !!This!! !is!!!!!!!!! !!!!!!!!!a!!! !wonder!! !!of!!!!! !!!!nature!! !!!that!! !!happens!!!!! !!!!!!!!!!! !!!!!!!!! !!!! !! !! !! [...]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Beneath the exclamation marks, the model was still emitting relevant words, which pointed to numerical errors rather than a broken tokenizer or weight mapping. When I fell back to F32 activations on CUDA, the output returned to normal immediately, and both Llama 3.1 8B and Llama 3.2 1B worked out of the box as well.&lt;/p&gt;

&lt;p&gt;Given more time to evolve this engine, I would dig further into that numerical issue and test more data formats. For now, scoping CUDA GPU backends to fall back F32 with a warning got end-to-end inference working reliably, while F16 is still usable on the CPU backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why my profiler ranges popped as soon as they were pushed
&lt;/h2&gt;

&lt;p&gt;On CPU and Metal, specifying &lt;code&gt;--trace-directory&lt;/code&gt; exports Chrome traces for Perfetto. On CUDA, Chrome traces show host-side scopes, but not GPU activity such as kernel execution.&lt;/p&gt;

&lt;p&gt;To see both without duplicating instrumentation, I hooked NVTX ranges into the same &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;layer&lt;/code&gt;, &lt;code&gt;attn&lt;/code&gt;, &lt;code&gt;mlp&lt;/code&gt;, and &lt;code&gt;qmatmul&lt;/code&gt; trace scopes, using an RAII guard to end each range automatically.&lt;/p&gt;

&lt;p&gt;In the first capture, NVTX labels such as &lt;code&gt;qmatmul operation=attention-query&lt;/code&gt; appeared on the timeline, but each range lasted only up to 600 nanoseconds, and even the longest ones never covered the corresponding CUDA kernel launch call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmkwvllub6kn2j66om0j5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmkwvllub6kn2j66om0j5.png" alt="GPU trace with NVTX ranges" width="799" height="272"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My first suspicion was that Candle handed the CUDA API work off to another host thread, so the launches were happening outside the thread-local NVTX range. My coding agent chased the &lt;code&gt;QMatMul&lt;/code&gt; call path into Candle, but found no evidence of a thread handoff.&lt;/p&gt;

&lt;p&gt;The agent next suggested making the thread sleep briefly after the range starts, to see whether the range would stretch to cover the CUDA dispatch.&lt;/p&gt;

&lt;p&gt;Instead of trying that, my intuition told me something was wrong with our RAII guard: the range always closed &lt;em&gt;right before&lt;/em&gt; the CUDA dispatch, which looked too deterministic to be a race condition. So I went back to check our code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;NvtxRangeGuard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;enter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;cudarc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;nvtx&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;result&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;range_push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.then_some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="nb"&gt;Drop&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;NvtxRangeGuard&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;drop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;cudarc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;nvtx&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;result&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;range_pop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I asked the agent: &lt;em&gt;would &lt;code&gt;enter()&lt;/code&gt; ever return &lt;code&gt;None&lt;/code&gt;?&lt;/em&gt; Checking the &lt;a href="https://docs.nvidia.com/cuda/profiler-users-guide/#nvtx-api-overview" rel="noopener noreferrer"&gt;CUDA Profiler documentation&lt;/a&gt; turned up this warning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Some of the NVTX functions are defined to have return values. For example, the &lt;code&gt;nvtxRangeStart()&lt;/code&gt; function returns a unique range identifier and &lt;code&gt;nvtxRangePush()&lt;/code&gt; function outputs the current stack level. It is recommended not to use the returned values as part of conditional code in the instrumented application. The returned values can differ between various implementations of the NVTX library and, consequently, having added dependencies on the return values might work with one tool, but may fail with another.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent had written that check, and I had missed it during review. When we logged the return value under Nsight Systems, it was &lt;code&gt;-1&lt;/code&gt; on every call while still recording the range, matching &lt;a href="https://forums.developer.nvidia.com/t/nvtxrangepusha-and-nvtxrangepop-returns-2/255993" rel="noopener noreferrer"&gt;reports from other developers&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Why did that return value produce a tiny range instead of leaving the range open forever?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;range_push()&lt;/code&gt; recorded the start of the range in Nsight Systems and returned &lt;code&gt;-1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Because the &lt;code&gt;.then_some(...)&lt;/code&gt; helper evaluates its argument eagerly, it constructed the &lt;code&gt;NvtxRangeGuard&lt;/code&gt; before checking whether &lt;code&gt;range_push(name) &amp;gt;= 0&lt;/code&gt; was true.&lt;/li&gt;
&lt;li&gt;Because &lt;code&gt;-1 &amp;gt;= 0&lt;/code&gt; was false, &lt;code&gt;.then_some(...)&lt;/code&gt; discarded that newly created guard and returned &lt;code&gt;None&lt;/code&gt;, running &lt;code&gt;Drop&lt;/code&gt; immediately inside &lt;code&gt;enter()&lt;/code&gt; and calling &lt;code&gt;range_pop()&lt;/code&gt; a few hundred nanoseconds after the push.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The range wasn't ending early because of async GPU work. It was being pushed and popped inside &lt;code&gt;enter()&lt;/code&gt; before the caller even received the guard.&lt;/p&gt;

&lt;p&gt;As the agent put it in its retrospective:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I treated the push return value as a success flag: nonnegative meant “push succeeded, so create a guard”; negative meant “don’t create one.” I hadn’t verified that assumption against the NVTX implementation we were using. It was a mistake to make range behavior depend on that value.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is a mistake I could easily have made myself, and a good reminder to be careful with external APIs.&lt;/p&gt;

&lt;p&gt;Keeping the guard alive regardless of the return value and assigning a distinct color to each trace scope produced the nested hierarchy below: the blue &lt;code&gt;model&lt;/code&gt; span breaks down into orange &lt;code&gt;layer&lt;/code&gt; blocks, green &lt;code&gt;attn&lt;/code&gt; and &lt;code&gt;mlp&lt;/code&gt; phases, and individual &lt;code&gt;qmatmul&lt;/code&gt; and attention operations right above the CUDA API launches:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ww4kdgth98glihi3vnc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ww4kdgth98glihi3vnc.png" alt="GPU trace with NVTX labels" width="799" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  From "mixed" mode to one CPU and two GPUs
&lt;/h2&gt;

&lt;p&gt;Alongside the CUDA fixes, I made a few other changes for the multi-GPU environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make &lt;code&gt;--inference-device&lt;/code&gt; flag repeatable:&lt;/strong&gt; My old &lt;code&gt;mixed&lt;/code&gt; inference mode assumed one CPU plus Metal GPU &lt;code&gt;0&lt;/code&gt;, each managing their own KV cache and scheduler. Replacing it with a repeatable &lt;code&gt;--inference-device&lt;/code&gt; flag lets each &lt;code&gt;gpu&lt;/code&gt; entry claim the next accelerator index (&lt;code&gt;0&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt;, and so on), supporting one CPU plus multiple GPU workers without changing the orchestrator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix a leaked CPU optimization:&lt;/strong&gt; While chasing down CUDA dispatch, my coding agent noticed that the contiguous attention path enabled a CPU grouped-query matmul layout by default without checking whether the tensor actually lived on the CPU, so this leaked onto the GPU path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix model cache:&lt;/strong&gt; In my Kaggle sessions, the model downloader used Hugging Face Hub’s default cache path (&lt;code&gt;~/.cache/huggingface/hub&lt;/code&gt;), which wasn’t retained between sessions. I updated it to honor the environment variable &lt;code&gt;HF_HOME&lt;/code&gt;, to choose where the cache lives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once those were in place, I ran a smoke test sending 3 requests to a heterogeneous setup of one CPU worker and two GPU workers, with each worker running a copy of Qwen2.5 0.5B and round-robin routing sending one request to each backend:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Backend&lt;/th&gt;
&lt;th&gt;Input tokens&lt;/th&gt;
&lt;th&gt;Output tokens&lt;/th&gt;
&lt;th&gt;Prefill throughput (tok/s)&lt;/th&gt;
&lt;th&gt;Decode throughput (tok/s)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPU &lt;code&gt;0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1,068&lt;/td&gt;
&lt;td&gt;873&lt;/td&gt;
&lt;td&gt;2,067.39&lt;/td&gt;
&lt;td&gt;14.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU &lt;code&gt;1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;998&lt;/td&gt;
&lt;td&gt;1,024&lt;/td&gt;
&lt;td&gt;2,485.12&lt;/td&gt;
&lt;td&gt;15.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;1,047&lt;/td&gt;
&lt;td&gt;1,024&lt;/td&gt;
&lt;td&gt;3.94&lt;/td&gt;
&lt;td&gt;2.87&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The CPU backend was definitely much slower than the GPUs, as expected, but all three workers processed their requests concurrently end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Moving from a Mac mini to dual T4s tested every boundary I had drawn earlier. The process split and per-backend threads scaled cleanly to a larger heterogeneous system, while the bugs showed where single-device and Metal-specific assumptions had been hiding in plain sight.&lt;/p&gt;

&lt;p&gt;There are definitely more optimizations to do. For example, when the paged KV cache is used, my current attention implementation concatenates cache pages before self-attention, which was tolerable on my Mac mini's unified memory but more significantly hurt decode performance on the T4s (smoke-tested with Qwen2.5 7B):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;KV cache&lt;/th&gt;
&lt;th&gt;Input tokens&lt;/th&gt;
&lt;th&gt;Output tokens&lt;/th&gt;
&lt;th&gt;Prefill throughput (tok/s)&lt;/th&gt;
&lt;th&gt;Decode throughput (tok/s)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Contiguous&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;421&lt;/td&gt;
&lt;td&gt;482.1&lt;/td&gt;
&lt;td&gt;63.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paged&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;479&lt;/td&gt;
&lt;td&gt;501.9&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;38.6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dedicated GPU memory also opens the door to larger models: on my Mac mini the CPU and GPU share 16 GB of RAM, whereas each T4 GPU has its own 16 GB of VRAM. The orchestration and scheduling algorithms will need to be recalibrated for multi-GPU setups as well.&lt;/p&gt;

&lt;p&gt;There is always more work to do, but this marks a steady first step for my inference engine in proving its cross-platform support.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>showdev</category>
      <category>gpu</category>
    </item>
    <item>
      <title>What I Gave Up to Make Speculative Decoding Faster</title>
      <dc:creator>Pujun Lun</dc:creator>
      <pubDate>Wed, 28 Oct 2026 00:29:36 +0000</pubDate>
      <link>https://dev.to/lun0522/what-i-gave-up-to-make-speculative-decoding-faster-1h87</link>
      <guid>https://dev.to/lun0522/what-i-gave-up-to-make-speculative-decoding-faster-1h87</guid>
      <description>&lt;p&gt;On a creative-writing prompt, my target LLM rejected most tokens proposed by the smaller draft model. Speculative decoding was slower than running the target alone. I wanted to recover that wasted work. How much faster could generation become if I let the draft have more influence over the output?&lt;/p&gt;

&lt;p&gt;Lossless speculative sampling preserves the target model’s output distribution. Relaxing that requirement might improve acceptance, but the resulting text would need a closer look.&lt;/p&gt;

&lt;p&gt;I explored this tradeoff with mentored decoding in &lt;a href="https://github.com/lun0522/mini-vllm-rs" rel="noopener noreferrer"&gt;mini-vllm-rs&lt;/a&gt;, my Rust inference engine for macOS. Using quantized Qwen2.5 7B as the target and 0.5B as the draft on an Apple M2, I eventually found a faster configuration. Deciding when I would use it turned out to be a different question.&lt;/p&gt;

&lt;h2&gt;
  
  
  A knob for accepting more draft tokens
&lt;/h2&gt;

&lt;p&gt;In an &lt;a href="https://github.com/lun0522/mini-vllm-rs/blob/main/benchmarks/speculative_decoding.md" rel="noopener noreferrer"&gt;earlier benchmark&lt;/a&gt;, a code-refactoring prompt worked well under greedy decoding. A policy that adaptively adjusted the draft token count usually selected 8-token proposals, achieved 98.5% acceptance, and raised decode throughput by 38.3% over target-only. However, on the creative-writing prompt, the same policy delivered 36.8% lower decode throughput than target-only, also under greedy decoding.&lt;/p&gt;

&lt;p&gt;I then explored the creative-writing prompt with multinomial sampling at temperature 0.7 (&lt;a href="https://github.com/lun0522/mini-vllm-rs/blob/main/benchmarks/speculative_sampling_tradeoff.md" rel="noopener noreferrer"&gt;sampling benchmark&lt;/a&gt;). Lossless speculative sampling with a fixed draft token count of 4 achieved only 28.6% acceptance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://arxiv.org/abs/2609.30474" rel="noopener noreferrer"&gt;Mentored decoding&lt;/a&gt; allows a bounded shift from the target distribution toward the draft. I implemented its linear-interpolation construction.&lt;/p&gt;

&lt;p&gt;Let 

&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;p&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 be the target distribution and 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;q&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 the draft distribution, both after temperature and repetition penalties. Their total-variation distance is:&lt;/p&gt;


&lt;div class="katex-element"&gt;
  &lt;span class="katex-display"&gt;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;d&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mop"&gt;&lt;span class="mord mathrm"&gt;TV&lt;/span&gt;&lt;/span&gt;&lt;span class="mopen"&gt;(&lt;/span&gt;&lt;span class="mord mathnormal"&gt;p&lt;/span&gt;&lt;span class="mpunct"&gt;,&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;q&lt;/span&gt;&lt;span class="mclose"&gt;)&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mopen nulldelimiter"&gt;&lt;/span&gt;&lt;span class="mfrac"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord"&gt;2&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="frac-line"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord"&gt;1&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose nulldelimiter"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mop op-limits"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="sizing reset-size6 size3 mtight"&gt;&lt;span class="mord mathnormal mtight"&gt;x&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="mop op-symbol large-op"&gt;∑&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord"&gt;∣&lt;/span&gt;&lt;span class="mord mathnormal"&gt;p&lt;/span&gt;&lt;span class="mopen"&gt;(&lt;/span&gt;&lt;span class="mord mathnormal"&gt;x&lt;/span&gt;&lt;span class="mclose"&gt;)&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;−&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;q&lt;/span&gt;&lt;span class="mopen"&gt;(&lt;/span&gt;&lt;span class="mord mathnormal"&gt;x&lt;/span&gt;&lt;span class="mclose"&gt;)&lt;/span&gt;&lt;span class="mord"&gt;∣&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/div&gt;


&lt;p&gt;Given a TV budget 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;D&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
, the interpolation weight is:&lt;/p&gt;


&lt;div class="katex-element"&gt;
  &lt;span class="katex-display"&gt;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;θ&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mop"&gt;min&lt;/span&gt;&lt;span class="mopen"&gt;(&lt;/span&gt;&lt;span class="mord"&gt;1&lt;/span&gt;&lt;span class="mpunct"&gt;,&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;D&lt;/span&gt;&lt;span class="mord"&gt;/&lt;/span&gt;&lt;span class="mord mathnormal"&gt;d&lt;/span&gt;&lt;span class="mclose"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/div&gt;


&lt;p&gt;If 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;d&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;0&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
, the distributions match exactly, and we take 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;θ&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;1&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
. The resulting output distribution is:&lt;/p&gt;


&lt;div class="katex-element"&gt;
  &lt;span class="katex-display"&gt;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;π&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mopen"&gt;(&lt;/span&gt;&lt;span class="mord"&gt;1&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;−&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;θ&lt;/span&gt;&lt;span class="mclose"&gt;)&lt;/span&gt;&lt;span class="mord mathnormal"&gt;p&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;+&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;θq&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/div&gt;


&lt;p&gt;A draft token 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;x&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 sampled from 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;q&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 is accepted with probability:&lt;/p&gt;


&lt;div class="katex-element"&gt;
  &lt;span class="katex-display"&gt;&lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;a&lt;/span&gt;&lt;span class="mopen"&gt;(&lt;/span&gt;&lt;span class="mord mathnormal"&gt;x&lt;/span&gt;&lt;span class="mclose"&gt;)&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mop"&gt;min&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="minner"&gt;&lt;span class="mopen delimcenter"&gt;&lt;span class="delimsizing size3"&gt;(&lt;/span&gt;&lt;/span&gt;&lt;span class="mord"&gt;1&lt;/span&gt;&lt;span class="mpunct"&gt;,&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mopen nulldelimiter"&gt;&lt;/span&gt;&lt;span class="mfrac"&gt;&lt;span class="vlist-t vlist-t2"&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;q&lt;/span&gt;&lt;span class="mopen"&gt;(&lt;/span&gt;&lt;span class="mord mathnormal"&gt;x&lt;/span&gt;&lt;span class="mclose"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="frac-line"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;&lt;span class="pstrut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;&lt;span class="mord mathnormal"&gt;π&lt;/span&gt;&lt;span class="mopen"&gt;(&lt;/span&gt;&lt;span class="mord mathnormal"&gt;x&lt;/span&gt;&lt;span class="mclose"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-s"&gt;​&lt;/span&gt;&lt;/span&gt;&lt;span class="vlist-r"&gt;&lt;span class="vlist"&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose nulldelimiter"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="mclose delimcenter"&gt;&lt;span class="delimsizing size3"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/div&gt;


&lt;p&gt;After a rejection, the sampler draws a replacement from the normalized positive residual 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mop"&gt;max&lt;/span&gt;&lt;span class="mopen"&gt;(&lt;/span&gt;&lt;span class="mord mathnormal"&gt;p&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mbin"&gt;−&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;q&lt;/span&gt;&lt;span class="mpunct"&gt;,&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mord"&gt;0&lt;/span&gt;&lt;span class="mclose"&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
. Together, acceptance and correction produce the distribution 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;π&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
.&lt;/p&gt;

&lt;p&gt;At TV budget 0, this preserves the target distribution. At TV budget 1, the budget covers every possible TV distance, so 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;θ&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord"&gt;1&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
 and 
&lt;span class="katex-element"&gt;
  &lt;span class="katex"&gt;&lt;span class="katex-mathml"&gt;&lt;/span&gt;&lt;span class="katex-html"&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;π&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;span class="mrel"&gt;=&lt;/span&gt;&lt;span class="mspace"&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="base"&gt;&lt;span class="strut"&gt;&lt;/span&gt;&lt;span class="mord mathnormal"&gt;q&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;/span&gt;
. Every draft token is then accepted.&lt;/p&gt;

&lt;p&gt;Intermediate budgets limit the per-step distributional deviation from the target. They do not measure how much semantic quality the output loses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Even full acceptance did not buy a speedup
&lt;/h2&gt;

&lt;p&gt;I first set the TV budget to 1, so every draft token was accepted. Even then, fixed-4 generation took 24.425 seconds, versus 20.971 seconds for target-only.&lt;/p&gt;

&lt;p&gt;Rejection was gone, but execution cost remained. Each round still generated 4 draft tokens autoregressively, verified their positions with the target, sampled, and updated both models’ cache state. My implementation emits no extra target bonus token after a fully accepted proposal, so a successful round produced only 4 output tokens.&lt;/p&gt;

&lt;p&gt;The draft-only control took 3.372 seconds. At TV=1, target verification was an expensive way to follow the draft distribution. That endpoint helped diagnose the remaining cost, but I wanted a speedup while retaining more of the target distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the break-even point
&lt;/h2&gt;

&lt;p&gt;To find where speculation could beat target-only E2E latency, I tested fixed draft token counts of 6 and 8 at full acceptance. I then lowered the TV budget to find the smallest tested value that still produced a clear speedup (&lt;a href="https://github.com/lun0522/mini-vllm-rs/blob/main/benchmarks/speculative_sampling_proposal_breakeven.md" rel="noopener noreferrer"&gt;benchmark report&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The prompt and temperature stayed fixed. Every request generated 256 tokens with EOS ignored. After warm-up, each setting had 3 independent untraced timing invocations, each covering 3 random seeds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Acceptance&lt;/th&gt;
&lt;th&gt;Avg. rounds/request&lt;/th&gt;
&lt;th&gt;Median E2E latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Target-only&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;20.971 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed-4, TV=1&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;24.425 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed-6, TV=1&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;td&gt;20.587 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed-8, TV=1&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;18.508 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed-8, TV=0.75&lt;/td&gt;
&lt;td&gt;93.4%&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;18.820 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed-8, TV=0.5&lt;/td&gt;
&lt;td&gt;63.5%&lt;/td&gt;
&lt;td&gt;46&lt;/td&gt;
&lt;td&gt;22.900 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fixed-6 was approximately equal to target-only within observed variability. Fixed-8 clearly crossed the baseline. At full acceptance, it needed 32 verification rounds per request instead of fixed-4’s 64, allowing more output to share each verification call.&lt;/p&gt;

&lt;p&gt;Verification also crossed a kernel-dispatch boundary, as in my &lt;a href="https://dev.to/lun0522/why-continuous-batching-made-my-llm-inference-engine-slower-3pjb"&gt;continuous-batching experiments&lt;/a&gt;. Here, 4-row verification used row-wise GEMV, while full 6- and 8-row proposals used regular quantized matmul. Proposal length and kernel dispatch changed together, so these measurements do not isolate their individual contributions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did I get in exchange?
&lt;/h2&gt;

&lt;p&gt;For fixed-8, increasing the budget from 0.5 to 0.75 reduced average verification rounds from 46 to 34. Generation moved from 9.2% slower than target-only to 10.3% sooner. Each seed’s median improved by roughly 8–10% at TV=0.75. To judge what that speedup bought me, I inspected the creative-writing output.&lt;/p&gt;

&lt;p&gt;The prompt places Mara, a mechanic, and Ilyan, an archivist, in a lunar greenhouse abandoned for 20 years to investigate a distress signal. It specifies fresh condensation despite the lack of power, and patches of blue light reflected from Earth.&lt;/p&gt;

&lt;p&gt;In one target-only sample, the characters treat the condensation as evidence that someone may have visited or something is changing. In the TV=0.75 output using the same seed, the roof reveals:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“a thin sliver of blue light streaming through—Earth’s infrared, indicating that it was not the end of this system but only an interruption in its normal operation.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Infrared is not visible blue light. The passage also jumps from that light to a diagnosis of the system without explaining the connection.&lt;/p&gt;

&lt;p&gt;At TV=1, the same seed produced an identity error. Immediately after Mara explained the greenhouse, the text continued:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“I'm Ilyan Sato, the archivist from the station,” she added.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Mara had taken the other character’s name and role.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/lun0522/mini-vllm-rs/blob/main/benchmarks/speculative_sampling_proposal_breakeven.md#appendix-complete-prompt-and-output-samples" rel="noopener noreferrer"&gt;complete prompt and samples&lt;/a&gt; show different adherence or continuity problems across all 3 TV=0.75 outputs. These are qualitative observations from 1 prompt, and the 256-token cutoff limits judgments about plot development.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;I started by trying to recover rejected draft work. Raising acceptance helped, but even 100% acceptance was insufficient with 4-token proposals. Longer proposals finally crossed the latency baseline, and reducing the TV budget showed that complete acceptance was unnecessary.&lt;/p&gt;

&lt;p&gt;The faster setting also changed the output in ways that the timing table could not capture. The TV budget controlled a mathematical distance, while the text exposed physical inconsistencies and broken reasoning. What I gave up was the guarantee of preserving the target distribution. Whether the time saved justified that change required reading the result, not just measuring how quickly it arrived.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>When LLM Inference on CPU Beats Waiting for the GPU</title>
      <dc:creator>Pujun Lun</dc:creator>
      <pubDate>Sat, 24 Oct 2026 05:52:05 +0000</pubDate>
      <link>https://dev.to/lun0522/when-llm-inference-on-cpu-beats-waiting-for-the-gpu-22ff</link>
      <guid>https://dev.to/lun0522/when-llm-inference-on-cpu-beats-waiting-for-the-gpu-22ff</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/lun0522/mini-vllm-rs" rel="noopener noreferrer"&gt;&lt;code&gt;mini-vllm-rs&lt;/code&gt;&lt;/a&gt; is a single-node LLM inference engine I built in Rust on top of &lt;a href="https://github.com/huggingface/candle" rel="noopener noreferrer"&gt;Candle&lt;/a&gt; for macOS. It can run CPU and Metal GPU backends concurrently, routing whole requests between them. My &lt;a href="https://github.com/lun0522/mini-vllm-rs/blob/bb0bb476359311ecf306fc79fce23a504520942d/benchmarks/request_routing_policies.md" rel="noopener noreferrer"&gt;first request routing benchmark&lt;/a&gt; favored sending almost everything to GPU: the fastest policies assigned it 11 of 12 requests.&lt;/p&gt;

&lt;p&gt;That left a more useful question unanswered: could selective CPU offload improve completion time compared with serving the entire workload on GPU alone? Separating prefill from decode suggested where to look: requests with warm prefixes, arriving behind a growing GPU backlog.&lt;/p&gt;

&lt;p&gt;A follow-up experiment tested that idea. Routing 1 of 8 requests to CPU reduced the burst's completion time by &lt;strong&gt;9.8%&lt;/strong&gt; compared with forcing all 8 onto GPU. With 48 requests, offloading 6 to CPU increased aggregate output throughput by &lt;strong&gt;12.4%&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The phase gap suggested the next experiment
&lt;/h2&gt;

&lt;p&gt;The experiments used a quantized Qwen2.5 0.5B model with F32 activations on an Apple M2. For a 512-input/128-output request, isolated backend profiles showed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;th&gt;Metal GPU&lt;/th&gt;
&lt;th&gt;GPU advantage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prefill&lt;/td&gt;
&lt;td&gt;73 tok/s&lt;/td&gt;
&lt;td&gt;2,551 tok/s&lt;/td&gt;
&lt;td&gt;About 35×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decode&lt;/td&gt;
&lt;td&gt;45 tok/s&lt;/td&gt;
&lt;td&gt;293 tok/s&lt;/td&gt;
&lt;td&gt;About 6.5×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A long, uncached prompt exposed the CPU's largest disadvantage. Even when GPU already had work, avoiding CPU prefill could justify waiting for it. A warm prefix could remove most of that cost, leaving the smaller decode disadvantage for GPU backlog to outweigh.&lt;/p&gt;

&lt;p&gt;Each backend owns its own scheduler and KV cache, and a request stays on the backend selected for it. I seeded an approximately 512-token prefix into both caches. Each measured request used the same 516-token prompt and generated exactly 128 tokens. Warming both sides gave CPU a chance to avoid expensive prefill.&lt;/p&gt;

&lt;p&gt;I compared &lt;strong&gt;forced-GPU routing&lt;/strong&gt; against &lt;strong&gt;mixed routing&lt;/strong&gt;, where the router chose between CPU and Metal using estimated pending and incoming work. Both cases used the same scheduler settings, with up to 4 active requests per backend. Requests arrived 50 ms apart without waiting for earlier responses. Each case started with a fresh server and seeded caches, and ran 3 times.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed between requests 7 and 8?
&lt;/h2&gt;

&lt;p&gt;The router priced prefill and decode separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score = remaining prefill tokens      / prefill rate
      + remaining decode-token budget / decode rate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The totals included existing work plus the incoming request. Decode work was approximated using each existing request's unused &lt;code&gt;max_new_tokens&lt;/code&gt; budget and the incoming request's full budget, so the router did not predict actual output lengths or early stopping. The rates came from isolated backend profiles, making this a work-based ranking rather than an exact prediction of batched completion time.&lt;/p&gt;

&lt;p&gt;The candidate diagnostic logs show the boundary in run 1:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Incoming request&lt;/th&gt;
&lt;th&gt;CPU active / queued&lt;/th&gt;
&lt;th&gt;GPU active / queued&lt;/th&gt;
&lt;th&gt;CPU score&lt;/th&gt;
&lt;th&gt;GPU score&lt;/th&gt;
&lt;th&gt;Selection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;td&gt;4 / 2&lt;/td&gt;
&lt;td&gt;3.118 s&lt;/td&gt;
&lt;td&gt;3.027 s&lt;/td&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;td&gt;4 / 3&lt;/td&gt;
&lt;td&gt;3.118 s&lt;/td&gt;
&lt;td&gt;3.444 s&lt;/td&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Estimated routing costs from mixed-routing run 1. All 3 runs made the same choices, with slightly different GPU scores.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Both candidates advertised a 496-token reusable prefix for the incoming 516-token prompt. The idle CPU therefore had only 20 estimated prefill tokens and a 128-token decode budget to account for. Its score stayed at &lt;code&gt;20/73 + 128/45 = 3.118 seconds&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At request 7, GPU already had 4 active requests and 2 queued requests. Each queued prompt also carried a 496-token cache estimate, leaving 20 prefill tokens apiece. Including the incoming request, GPU's estimated prefill work was therefore &lt;code&gt;2 × 20 + 20 = 60&lt;/code&gt; tokens. Its decode budget was &lt;code&gt;256 queued + 496 active + 128 incoming = 880&lt;/code&gt; tokens. The score was &lt;code&gt;60/2551 + 880/293 = 3.027 seconds&lt;/code&gt;, still below CPU's. Occupied GPU slots alone were not enough to justify offload.&lt;/p&gt;

&lt;p&gt;About 50 ms later, request 8 reached the router. Request 7 had joined the GPU queue, while the active requests’ published remaining decode budget had fallen by 8 tokens. Including the incoming request, the GPU now had 80 estimated prefill tokens and a combined decode budget of &lt;code&gt;384 queued + 488 active + 128 incoming = 1,000&lt;/code&gt; tokens. Its score rose to &lt;strong&gt;3.444 seconds&lt;/strong&gt;, above the idle CPU’s unchanged &lt;strong&gt;3.118 seconds&lt;/strong&gt;, so the router chose CPU. The warm prefixes kept prefill work small, and the accumulated decode work tipped the decision.&lt;/p&gt;

&lt;p&gt;While helping me organize the earlier benchmark data, AI surfaced an accounting bug: queued requests lost their cache discount, inflating GPU prefill work. I decided to &lt;a href="https://github.com/lun0522/mini-vllm-rs/commit/cacfaef7109e8b7ea76d0fb8544a0f9d39cc0503" rel="noopener noreferrer"&gt;fix it&lt;/a&gt; and rerun the benchmark. The first CPU assignment moved from request 7 to request 8, and the capacity results here use the corrected runs. Queued requests now retain their routing-time cache estimates, though actual reuse may decrease or increase as the cache changes before execution.&lt;/p&gt;

&lt;p&gt;Output-length estimation remains unfinished. For comparison, &lt;a href="https://github.com/sgl-project/sglang/blob/main/python/sglang/srt/managers/schedule_policy.py" rel="noopener noreferrer"&gt;SGLang's scheduler&lt;/a&gt; scales remaining token budgets with an adjustable &lt;code&gt;new_token_ratio&lt;/code&gt; to estimate future KV-cache demand for running requests, a heuristic rather than an exact output-length prediction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Did the decision help in practice?
&lt;/h2&gt;

&lt;p&gt;Run 1 produced the following measurements for request 8 and the burst as a whole:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measured outcome&lt;/th&gt;
&lt;th&gt;Forced-GPU control&lt;/th&gt;
&lt;th&gt;Mixed routing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Request 8: time to first token&lt;/td&gt;
&lt;td&gt;5.561 s&lt;/td&gt;
&lt;td&gt;0.339 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request 8: end-to-end latency&lt;/td&gt;
&lt;td&gt;11.116 s&lt;/td&gt;
&lt;td&gt;3.681 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whole-burst completion time&lt;/td&gt;
&lt;td&gt;11.486 s&lt;/td&gt;
&lt;td&gt;10.361 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Whole-burst time starts at the first submission, while request latency starts at that request's submission.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The offloaded request finished well before the mixed burst ended. CPU could start useful work while GPU continued serving its assigned requests, and the entire burst also finished sooner. Across all 3 runs, mean completion time fell from &lt;strong&gt;11.47 to 10.35 seconds&lt;/strong&gt;, a &lt;strong&gt;9.8% reduction&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffalq5rwgfd3xon01rtti.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffalq5rwgfd3xon01rtti.png" alt="Completion time for forced-GPU and mixed routing at 4, 8, and 48 requests" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Mixed routing assigned 0, 1, and 6 requests to CPU, respectively. Wall time excludes startup and prefix seeding.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The benefit continued under a longer GPU backlog. In a 48-request burst, CPU received just 6 requests in every run. Mean completion time fell from &lt;strong&gt;68.43 to 60.88 seconds&lt;/strong&gt;. With the same total output, this &lt;strong&gt;11.0%&lt;/strong&gt; reduction in completion time translated into &lt;strong&gt;12.4%&lt;/strong&gt; higher aggregate output throughput.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmzlykm0hdftpz4qr3gf4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmzlykm0hdftpz4qr3gf4.png" alt="Routing decisions for all 48 requests in saturated mixed-routing run 1" width="800" height="956"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cells show routing estimates, not measured latencies.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Latency gains were not uniform. In the 8-request case, median TTFT improved, but p95 TTFT increased by 3.5%. Under the longer 48-request backlog, both median and p95 TTFT improved, alongside median and p95 end-to-end latency. Selective offload helped capacity without guaranteeing a win for every latency statistic.&lt;/p&gt;

&lt;p&gt;A GPU concurrency sweep remains the next baseline check, since these gains were measured against the fixed 4-active-request GPU configuration. Sustained arrivals and varied cache-hit rates would test how far the result carries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the experiments changed
&lt;/h2&gt;

&lt;p&gt;The first benchmark suggested where to look: the CPU’s disadvantage was much smaller in decode than in prefill. The follow-up showed that warm prefixes and GPU backlog could make selective CPU offload useful. Backend speed alone did not determine placement: the router also needed to account for the work already waiting and the prefill that cache reuse could avoid. For a broader discussion of the tradeoffs behind heterogeneous execution, see &lt;a href="https://pujun-ml-sys-blogs.hashnode.dev/when-does-heterogeneous-inference-pay-off" rel="noopener noreferrer"&gt;When Does Heterogeneous Inference Pay Off?&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/lun0522/mini-vllm-rs/blob/bb0bb476359311ecf306fc79fce23a504520942d/benchmarks/request_orchestration_capacity.md" rel="noopener noreferrer"&gt;benchmark report&lt;/a&gt; contains the complete aggregate measurements and reproduction details.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why My Prefix Cache Missed Identical Requests</title>
      <dc:creator>Pujun Lun</dc:creator>
      <pubDate>Fri, 16 Oct 2026 18:44:13 +0000</pubDate>
      <link>https://dev.to/lun0522/why-my-prefix-cache-missed-identical-requests-4imn</link>
      <guid>https://dev.to/lun0522/why-my-prefix-cache-missed-identical-requests-4imn</guid>
      <description>&lt;p&gt;I enabled prefix caching in &lt;a href="https://github.com/lun0522/mini-vllm-rs" rel="noopener noreferrer"&gt;mini-vllm-rs&lt;/a&gt;, my Rust LLM inference engine for macOS, and submitted two identical requests concurrently to a cold cache.&lt;/p&gt;

&lt;p&gt;Neither reused a single token. Both computed the same prompt.&lt;/p&gt;

&lt;p&gt;Why? My cache published reusable pages only when a request finished. Both requests arrived before either had published anything, so neither found a prefix to reuse. Later requests reused completed prefixes correctly.&lt;/p&gt;

&lt;p&gt;Supporting concurrent reuse required more decisions: when should cached pages become visible, should another request wait for them, and when should that wait end?&lt;/p&gt;

&lt;p&gt;Benchmarks led me to add incremental publication and single-flight coordination. Deciding when to release waiting requests then exposed another problem: an increasingly complicated AI-generated state machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  A warm-cache win left a cold-cache problem
&lt;/h2&gt;

&lt;p&gt;I tested a 4-bit quantized Qwen2.5 0.5B model on my Mac mini's Metal GPU, with 16-token cache pages and 3 repeated runs. My &lt;a href="https://github.com/lun0522/mini-vllm-rs/blob/main/benchmarks/prefix_cache_reuse.md" rel="noopener noreferrer"&gt;first reuse benchmark&lt;/a&gt; compared warm reuse with simultaneous cold arrivals:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reuse a warm document prefix&lt;/td&gt;
&lt;td&gt;Restored 880 of 904 input tokens, reducing time to first token (TTFT) by &lt;strong&gt;96.2%&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 identical requests arriving to a cold cache&lt;/td&gt;
&lt;td&gt;All restored zero tokens and performed full prefill.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That cold-cache result was the problem I encountered at the start of this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publish pages while their owner is still generating
&lt;/h2&gt;

&lt;p&gt;I added &lt;strong&gt;incremental publication&lt;/strong&gt;. After each successful batched model forward, the engine immediately publishes any newly completed pages containing committed tokens, so they become available for reuse before this request finishes.&lt;/p&gt;

&lt;p&gt;To test this change, I started one request and waited until it returned its first output token. While it continued generating the rest of its 128-token output, I submitted more requests with the same prompt to see whether they could reuse the cached prefix.&lt;/p&gt;

&lt;p&gt;Incremental publication let each follower restore 480 of its 493 prompt tokens. Across groups of 2, 4, and 8 concurrent requests, mean TTFT fell by &lt;strong&gt;48–93%&lt;/strong&gt; compared with completion-only publication. That average includes every request's wait from submission to first streamed output. For detailed measurements, see the &lt;a href="https://github.com/lun0522/mini-vllm-rs/blob/main/benchmarks/prefix_cache_concurrent_population.md" rel="noopener noreferrer"&gt;follow-up benchmark report&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;However, incremental publication did not eliminate duplicate work for simultaneous cold arrivals. A request can reuse complete prefix pages already published when it starts execution, even if the other request is still prefilling. But it may find only part of the prompt, or nothing yet, and compute the rest itself. Reuse therefore depends on execution order and publication timing.&lt;/p&gt;

&lt;p&gt;Avoiding that race required deciding who should compute the prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let one request compute the prefix, but how long should others wait?
&lt;/h2&gt;

&lt;p&gt;I added &lt;strong&gt;exact-input single flight&lt;/strong&gt;. One request becomes the leader, while other requests with identical input tokens wait as followers. This coordinates prompt computation, not answers. Every follower still generates its own output, as expected: identical prompts can use different generation settings, such as temperature.&lt;/p&gt;

&lt;p&gt;My initial implementation made followers wait until the leader's entire request finished. The cache reuse became predictable, but the policy also made them wait through decode.&lt;/p&gt;

&lt;p&gt;With 8 simultaneous requests each generating a 128-token output, this policy reduced both mean TTFT and the slowest request's completion latency compared with independent execution. But with only 2 such requests, &lt;strong&gt;mean TTFT became worse&lt;/strong&gt; than independent execution, even though completion latency improved. Avoiding duplicate computation had introduced a new waiting cost.&lt;/p&gt;

&lt;p&gt;Why wait through decode when the prompt cache was already ready? I added a second release policy. Once the leader finishes prefill, the engine publishes its completed prefix pages, then makes followers eligible for admission:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant S as Scheduler
    participant C as Prefix cache
    participant L as Leader
    participant F as Followers
    S-&amp;gt;&amp;gt;L: Admit leader
    S-&amp;gt;&amp;gt;S: Block identical followers
    L-&amp;gt;&amp;gt;L: Finish prompt prefill
    L-&amp;gt;&amp;gt;C: Publish completed prefix pages
    L-&amp;gt;&amp;gt;S: Report successful publication
    S-&amp;gt;&amp;gt;S: Unblock followers
    S-&amp;gt;&amp;gt;F: Admit as capacity allows
    F-&amp;gt;&amp;gt;C: Look up matching prefix
    C--&amp;gt;&amp;gt;F: Return shared KV page references
    par Independent generation
        L-&amp;gt;&amp;gt;L: Continue decode
    and Follower generation
        F-&amp;gt;&amp;gt;F: Generate their own outputs
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;For simultaneous identical requests generating 128 output tokens each, both policies restored the same number of cached tokens, while releasing followers after prefill reduced mean TTFT:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concurrent requests&lt;/th&gt;
&lt;th&gt;Release at request completion&lt;/th&gt;
&lt;th&gt;Release after prefill&lt;/th&gt;
&lt;th&gt;TTFT reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1,357.5 ms&lt;/td&gt;
&lt;td&gt;584.7 ms&lt;/td&gt;
&lt;td&gt;56.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1,757.9 ms&lt;/td&gt;
&lt;td&gt;617.1 ms&lt;/td&gt;
&lt;td&gt;64.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1,984.4 ms&lt;/td&gt;
&lt;td&gt;646.7 ms&lt;/td&gt;
&lt;td&gt;67.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To compare both policies with independent execution, the table below focuses on the 8-request burst. Each request had a 532-token prompt and a 128-token output:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Prompt tokens restored per follower&lt;/th&gt;
&lt;th&gt;Mean TTFT change&lt;/th&gt;
&lt;th&gt;Slowest-request completion-time change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Independent execution, publish at completion&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single flight, release at request completion&lt;/td&gt;
&lt;td&gt;528&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47.5% lower&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25.8% lower&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single flight, release after prompt publication&lt;/td&gt;
&lt;td&gt;528&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;82.9% lower&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;32.4% lower&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Regular incremental publication is disabled in all rows. The early-release policy explicitly publishes the prompt prefix when prefill finishes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;However, earlier release did not improve every metric. In a separate test, a second request arrived right after the original request entered decode phase. Compared with release-at-completion single flight, early release reduced mean TTFT by &lt;strong&gt;72%&lt;/strong&gt;, but increased the slowest request's completion latency by &lt;strong&gt;14%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Why? The original flight had already closed, so the second request started a new flight and reused the warm prefix. The two requests then decoded concurrently rather than sequentially, changing the active batch. That may have contributed to the longer completion time.&lt;/p&gt;

&lt;p&gt;The earlier-release policy publishes the required checkpoint itself. Extra incremental publications added little for these coordinated exact-input workloads. They remain useful without coordination, including requests that share only part of a prefix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publication decides when data becomes reusable. Coordination decides who waits, and for how long.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this small implementation leaves open
&lt;/h2&gt;

&lt;p&gt;These are synthetic workloads on one backend with a small model. Exact-input coordination showed no meaningful completion-time regression for distinct inputs in this control, but the benchmark does not establish behavior under production traffic. Some future directions are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Partial-prefix coordination.&lt;/strong&gt; Requests may share a long document but end with different questions. How much overlap justifies waiting?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait or recompute.&lt;/strong&gt; A follower's latency goal and the leader's remaining prefill can change that choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different attention states.&lt;/strong&gt; Sliding-window and hybrid models change retention and reuse. &lt;a href="https://docs.vllm.ai/en/latest/design/hybrid_kv_cache_manager/" rel="noopener noreferrer"&gt;vLLM's hybrid KV cache manager&lt;/a&gt; explores those constraints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse across backends and memory tiers.&lt;/strong&gt; Routing must weigh prefix locality against speed and load. &lt;a href="https://docs.sglang.ai/advanced_features/hicache_best_practices.html" rel="noopener noreferrer"&gt;SGLang HiCache&lt;/a&gt; extends caching across GPU, host, and external storage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The release change also exposed a design problem in the coordinator itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the error, then questioning the state machine
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2aygrnxhkq1nrmr2m17o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2aygrnxhkq1nrmr2m17o.png" alt="An AI robot adds gears to a complicated machine while an engineer sketches a simpler design separating active flights from released request IDs." width="799" height="335"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AI helped implement these features. As in &lt;a href="https://pujun-ml-sys-blogs.hashnode.dev/building-an-llm-inference-engine-with-ai-the-code-was-the-easy-part" rel="noopener noreferrer"&gt;my first article&lt;/a&gt;, reviewing its designs was part of building a system I could understand and maintain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Initially, AI deleted a flight when it released the followers,&lt;/strong&gt; which seemed reasonable because the waiting had ended.&lt;/p&gt;

&lt;p&gt;But the first single-flight benchmark exposed a problem: followers later completed and notified the coordinator that no longer recognized their IDs. AI proposed removing the unknown-request check.&lt;/p&gt;

&lt;p&gt;I rejected that fix because it would also hide termination notifications carrying genuinely invalid request IDs. The original design had no &lt;code&gt;FlightState&lt;/code&gt;. To preserve validation, we added &lt;code&gt;Active&lt;/code&gt; and &lt;code&gt;Draining&lt;/code&gt; states and tracked released followers until they terminated. Here is that first revision, with methods omitted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;FlightState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Active&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;leader_request_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;Draining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Flight&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;input_token_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FlightState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;follower_request_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;VecDeque&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leader had already finished, so &lt;code&gt;Draining&lt;/code&gt; tracked only the released followers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Earlier release made that representation more complicated.&lt;/strong&gt; The leader could now still be decoding after followers were released. AI retained the same &lt;code&gt;Flight&lt;/code&gt; fields but changed the enum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;FlightState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Active&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;leader_request_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;Draining&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;leader_request_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The optional leader made me pause. Different release policies required different combinations. If a policy should not retain a leader but the state contained one, would we need another check?&lt;/p&gt;

&lt;p&gt;I took a step back: did we really need a &lt;code&gt;Draining&lt;/code&gt; state? We had added it to recognize released requests when they eventually terminated. That required remembering their IDs, but not preserving their former flight membership. Why not collect those IDs separately across flights?&lt;/p&gt;

&lt;p&gt;I asked AI to separate those responsibilities. We removed &lt;code&gt;FlightState&lt;/code&gt; and tracked released request IDs separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;PrefixCacheFlightCoordinator&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;flights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FlightId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Flight&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;released_request_ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Other fields omitted.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On release, we remove the flight and its indexes, keeping only the IDs of requests that have not yet terminated. This preserves termination validation without retaining leader/follower relationships that no longer affect execution. Unknown request IDs still produce errors, and a new request with the same prompt starts a new flight.&lt;/p&gt;

&lt;p&gt;AI preserved validation once I challenged the deletion. Later, it kept extending that solution until I questioned the abstraction itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Human review meant both preserving an invariant and reconsidering the structure used to enforce it.&lt;/strong&gt; The benchmark showed when requests could stop waiting. Reviewing the code showed when the flight could stop existing.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why Continuous Batching Made My LLM Inference Engine Slower</title>
      <dc:creator>Pujun Lun</dc:creator>
      <pubDate>Sun, 11 Oct 2026 22:16:29 +0000</pubDate>
      <link>https://dev.to/lun0522/why-continuous-batching-made-my-llm-inference-engine-slower-3pjb</link>
      <guid>https://dev.to/lun0522/why-continuous-batching-made-my-llm-inference-engine-slower-3pjb</guid>
      <description>&lt;p&gt;I had been adding optimizations to &lt;a href="https://github.com/lun0522/mini-vllm-rs" rel="noopener noreferrer"&gt;mini-vllm-rs&lt;/a&gt;, my Rust LLM inference engine for macOS, including prefix caching and speculative decoding. Continuous batching looked like the next easy win. Process requests together, reuse model weights across more tokens, and watch throughput climb. What could go wrong?&lt;/p&gt;

&lt;p&gt;I tested two concurrent requests on my M2 Mac mini with 16 GB of unified memory, running a 4-bit quantized Qwen2.5 7B model. Each request generated 512 tokens, with speculative decoding disabled.&lt;/p&gt;

&lt;p&gt;Guess what? Boom. Aggregate throughput dropped from &lt;strong&gt;16.8 tokens/s unbatched to 8.7 tokens/s batched&lt;/strong&gt;, almost cut in half.&lt;/p&gt;

&lt;p&gt;Fixing that slowdown led to another surprise: the same kernel-selection threshold could also change generated text, even with greedy decoding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two rows, a different kernel
&lt;/h2&gt;

&lt;p&gt;The clue was in &lt;a href="https://github.com/huggingface/candle" rel="noopener noreferrer"&gt;Candle&lt;/a&gt;, the framework I use for backend execution. Its quantized Metal path contained this tiny decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Candle 0.11.0: candle-core/src/quantized/metal.rs, line 372&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;src_shape&lt;/span&gt;&lt;span class="nf"&gt;.dim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;D&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Minus2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="nf"&gt;.fwd_mv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self_shape&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Line 414 calls into call_quantized_matmul_mm_t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/huggingface/candle/blob/31f35b147389700ed2a178ee66a91c3cc25cc80d/candle-core/src/quantized/metal.rs#L372-L414" rel="noopener noreferrer"&gt;Source&lt;/a&gt;. The condition checks the number of input rows. One row takes the GEMV path, multiplying the weights by one vector. More than one row continues into GEMM, which handles multiple input vectors together.&lt;/p&gt;

&lt;p&gt;Packing two requests changed the input from one row to two. That was enough to switch kernels.&lt;/p&gt;

&lt;p&gt;GEMM can reuse weights across rows, but this implementation's overhead outweighed that benefit at such a small batch. My scheduler was doing what I wanted. The backend was choosing a slower way to execute it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqgpgf58zadj16237rbqd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqgpgf58zadj16237rbqd.png" alt="One input row takes the GEMV path, while two packed rows take GEMM and slow down in this small-batch test." width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the speed back
&lt;/h2&gt;

&lt;p&gt;I added a fallback: split small inputs into rows, run GEMV for each, then concatenate the results.&lt;/p&gt;

&lt;p&gt;At a batch size of two, the fallback brought throughput back to &lt;strong&gt;16.25 tokens/s&lt;/strong&gt;, compared with an unbatched baseline of &lt;strong&gt;16.15 tokens/s&lt;/strong&gt;. Relief! But would GEMV still win with larger batches?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concurrent requests&lt;/th&gt;
&lt;th&gt;Unbatched&lt;/th&gt;
&lt;th&gt;Batched GEMM&lt;/th&gt;
&lt;th&gt;Batched row-wise GEMV&lt;/th&gt;
&lt;th&gt;GEMV / GEMM throughput&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;17.41&lt;/td&gt;
&lt;td&gt;13.05&lt;/td&gt;
&lt;td&gt;17.52&lt;/td&gt;
&lt;td&gt;134.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;17.22&lt;/td&gt;
&lt;td&gt;16.55&lt;/td&gt;
&lt;td&gt;18.07&lt;/td&gt;
&lt;td&gt;109.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;17.44&lt;/td&gt;
&lt;td&gt;19.92&lt;/td&gt;
&lt;td&gt;18.06&lt;/td&gt;
&lt;td&gt;90.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Aggregate output throughput in tokens/s.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At five requests, the ratio fell below 100%, so GEMM pulled ahead. Separate dispatches and concatenation may have become more expensive than one GEMM call. I chose four rows as the default switching threshold for this workload. See the &lt;a href="https://github.com/lun0522/mini-vllm-rs/blob/main/benchmarks/metal_gemv_threshold.md" rel="noopener noreferrer"&gt;benchmark report&lt;/a&gt; for details.&lt;/p&gt;

&lt;p&gt;Problem solved? Not quite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same prompt, different story
&lt;/h2&gt;

&lt;p&gt;Later, I tested speculative decoding with two requests, using Qwen2.5 0.5B to propose four tokens at a time. Their overall draft acceptance rate was &lt;strong&gt;32.9% when processed one at a time, versus 37.1% when allowed to run concurrently&lt;/strong&gt;. Same requests. Same models. Same proposal policy. Greedy decoding in both runs. Why?&lt;/p&gt;

&lt;p&gt;A separate check made the numerical issue easier to see. I asked Qwen to write a science-fiction scene about a mechanic and an archivist investigating a distress signal in an abandoned greenhouse on the Moon. Both runs also used greedy decoding. I changed the threshold so that prefill used GEMM in one run and row-wise GEMV in the other.&lt;/p&gt;

&lt;p&gt;The first &lt;strong&gt;597 characters matched exactly&lt;/strong&gt;. Then the continuations split:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prefill kernel&lt;/th&gt;
&lt;th&gt;First different continuation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GEMM&lt;/td&gt;
&lt;td&gt;“Mara moved to the second door, where an emergency terminal stood…”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GEMV&lt;/td&gt;
&lt;td&gt;“Mara’s flashlight beam cut through the gloom, illuminating rows of dry hydroponic beds…”&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both responses were coherent and followed the same characters and setting. Nothing looked like a completely broken model. That made small numerical differences a plausible suspect.&lt;/p&gt;

&lt;p&gt;Tracing the speculative test gave me stronger evidence. At the first differing token, the drafter proposed the same token IDs in both runs, but two leading target scores had swapped order:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Execution&lt;/th&gt;
&lt;th&gt;Token 476&lt;/th&gt;
&lt;th&gt;Token 323&lt;/th&gt;
&lt;th&gt;Selected token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Serial admission&lt;/td&gt;
&lt;td&gt;25.571232&lt;/td&gt;
&lt;td&gt;25.565775&lt;/td&gt;
&lt;td&gt;476&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Continuous batching&lt;/td&gt;
&lt;td&gt;25.549618&lt;/td&gt;
&lt;td&gt;25.562220&lt;/td&gt;
&lt;td&gt;323&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That tiny change was enough to flip the greedy choice. Every later token then followed a different prefix, which could change draft acceptance too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftc6gf1fvmwad7nuaeytj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftc6gf1fvmwad7nuaeytj.png" alt="The same lunar-greenhouse prompt produces two coherent continuations: Mara approaches a terminal in one and explores with a flashlight in the other." width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The other request had already finished
&lt;/h2&gt;

&lt;p&gt;Here was the part that caught me off guard: when the token choice flipped, the other request was already gone.&lt;/p&gt;

&lt;p&gt;So why did its earlier presence still matter?&lt;/p&gt;

&lt;p&gt;Here, rows count token positions, not requests. An earlier batch had combined 120 prefill tokens from the new request with four verification tokens from the existing request. Those 124 rows crossed my threshold and selected GEMM instead of GEMV, introducing small numerical differences into the cached state.&lt;/p&gt;

&lt;p&gt;By the time the output diverged, the other request had finished, and the remaining request's four-token verification was back on the GEMV path. But switching back did not undo the differences already stored in its KV cache.&lt;/p&gt;

&lt;p&gt;Looking only at the iteration where the text diverged would have missed the cause.&lt;/p&gt;

&lt;p&gt;I forced all relevant inputs through GEMV in both execution modes. In the shortened comparison, output hashes and acceptance totals matched. This did not establish which kernel was more accurate, but it supported the explanation. The &lt;a href="https://github.com/lun0522/mini-vllm-rs/blob/main/benchmarks/metal_gemv_numerical_differences.md" rel="noopener noreferrer"&gt;numerical report&lt;/a&gt; contains the investigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A batching decision changed the kernel selection, and its effects survived in cached state long after the batch changed again.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My &lt;a href="https://pujun-ml-sys-blogs.hashnode.dev/building-an-llm-inference-engine-with-ai-the-code-was-the-easy-part" rel="noopener noreferrer"&gt;longer article&lt;/a&gt; follows how a generation loop grew into a serving engine with clear boundaries between scheduling, model execution, and request state. It also explores how AI helped with architecture, coding, and performance optimization, and where I had to challenge its decisions.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
    <item>
      <title>From a Rust Inference Engine to Heterogeneous Serving</title>
      <dc:creator>Pujun Lun</dc:creator>
      <pubDate>Fri, 09 Oct 2026 16:00:00 +0000</pubDate>
      <link>https://dev.to/lun0522/from-a-rust-inference-engine-to-heterogeneous-serving-gj1</link>
      <guid>https://dev.to/lun0522/from-a-rust-inference-engine-to-heterogeneous-serving-gj1</guid>
      <description>&lt;p&gt;My background spans ML accelerator compilers and OS virtualization, and I now work on accelerator fleet efficiency.&lt;/p&gt;

&lt;p&gt;To explore how compilers, runtimes, and hardware interact in LLM serving, I built &lt;a href="https://github.com/lun0522/mini-vllm-rs" rel="noopener noreferrer"&gt;mini-vllm-rs&lt;/a&gt;, a single-node inference engine written entirely in Rust for macOS. It supports continuous batching, paged KV caching, prefix caching, and speculative decoding. CPU and Metal GPU workers can run concurrently, each handling complete requests.&lt;/p&gt;

&lt;p&gt;The project and two articles document that learning process (with more to come):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/lun0522/mini-vllm-rs" rel="noopener noreferrer"&gt;mini-vllm-rs: code, setup, and benchmarks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pujun-ml-sys-blogs.hashnode.dev/building-an-llm-inference-engine-with-ai-the-code-was-the-easy-part" rel="noopener noreferrer"&gt;Building an LLM Inference Engine with AI: The Code Was the Easy Part&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pujun-ml-sys-blogs.hashnode.dev/when-does-heterogeneous-inference-pay-off" rel="noopener noreferrer"&gt;When Does Heterogeneous Inference Pay Off?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What building the engine taught me
&lt;/h2&gt;

&lt;p&gt;To give you a glimpse of the problems I encountered, two requests generating 512 tokens each achieved an aggregate throughput of 16.8 output tokens/s when run sequentially on an Apple GPU. However, continuous batching reduced it to 8.7 tokens/s.&lt;/p&gt;

&lt;p&gt;Why? Batching changed the matrix shape, making &lt;a href="https://github.com/huggingface/candle" rel="noopener noreferrer"&gt;Candle&lt;/a&gt;, the framework I use for backend execution, switch from a GEMV kernel to a GEMM kernel. GEMM was slower for this small batch, so I added a GEMV fallback for small batches and &lt;a href="https://github.com/lun0522/mini-vllm-rs/blob/main/benchmarks/metal_gemv_threshold.md" rel="noopener noreferrer"&gt;benchmarked the crossover point&lt;/a&gt; to choose the switching threshold.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pujun-ml-sys-blogs.hashnode.dev/building-an-llm-inference-engine-with-ai-the-code-was-the-easy-part" rel="noopener noreferrer"&gt;The first article&lt;/a&gt; follows how a simple generation loop grew into an engine with clear boundaries between scheduling, model execution, and request state. It examines how continuous batching required each request’s KV state to be managed independently of model execution, and how prefix caching complicated when that memory could be reused or released. Those boundaries later made adding concurrent CPU and GPU workers possible without a major redesign.&lt;/p&gt;

&lt;p&gt;It also reflects on using AI to implement the engine, and the design reviews and experiments I needed to decide which changes to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why split work when a GPU can run the whole model?
&lt;/h2&gt;

&lt;p&gt;In my project, I considered GPU prefill with CPU decode, or a GPU target model with a CPU drafter. Apple Silicon's shared memory looked helpful, but Candle's device-based memory model complicated cross-device state access. Synchronization and low request concurrency also made keeping both devices busy difficult. Hence, I implemented neither split.&lt;/p&gt;

&lt;p&gt;Meanwhile, disaggregation is being explored at a much larger scale: NVIDIA has announced configurations that split inference work between Vera Rubin GPUs and Groq LPX, while AWS has announced prefill on Trainium paired with decode on Cerebras.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pujun-ml-sys-blogs.hashnode.dev/when-does-heterogeneous-inference-pay-off" rel="noopener noreferrer"&gt;The second article&lt;/a&gt; asks when specialization can repay communication costs. In one simplified prefill/decode example, transferring 2 GiB of KV cache at 25 GB/s takes about 86 ms. Without compute–communication overlap, specialized accelerator decode must beat GPU decode by more than 0.67 ms/token for a 128-token output to repay that transfer. For a 512-token output, the threshold falls to 0.17 ms/token. The same accelerator could make a long answer faster but a short one slower.&lt;/p&gt;

&lt;p&gt;Beyond this example, it compares prefill/decode, attention/FFN, and target/draft separation. It also asks why production might choose a slower path: more KV capacity, larger batches, and scaling each hardware pool independently can improve service capacity even when one request finishes later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8xp69npa9yjk3k4l1w2w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8xp69npa9yjk3k4l1w2w.png" alt="“What would you try?”" width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are coming to inference from another part of the stack, or mostly work with GPUs and want to understand the alternatives, you may be asking similar questions. I'm sharing the code, experiments, and articles as I learn, and I'd be interested to hear which trade-offs you've encountered.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
