<?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: xbill</title>
    <description>The latest articles on DEV Community by xbill (@xbill).</description>
    <link>https://dev.to/xbill</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%2F3490099%2Fc6a975d0-cd94-485d-82b1-14ed5b344fcf.jpg</url>
      <title>DEV Community: xbill</title>
      <link>https://dev.to/xbill</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/xbill"/>
    <language>en</language>
    <item>
      <title>Comparing Qwen and Gemma on the Low End: Qwen Is Faster Without Drafting, Gemma Has Better Quality</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Sun, 18 Oct 2026 20:20:20 +0000</pubDate>
      <link>https://dev.to/gde/comparing-qwen-and-gemma-on-the-low-end-qwen-is-faster-without-drafting-gemma-has-better-quality-30bo</link>
      <guid>https://dev.to/gde/comparing-qwen-and-gemma-on-the-low-end-qwen-is-faster-without-drafting-gemma-has-better-quality-30bo</guid>
      <description>&lt;p&gt;This article provides a step by step deployment guide for Qwen3.5-2B to a local, desktop and laptop hosted GPU enabled system, served by llama.cpp beside Gemma 4 E2B on the same cards. A suite of Python MCP tools is built to simplify management of the llama.cpp hosted deployment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/qwen-dev/tree/main/local-llamacpp-3050oem-qwen352b-q4_0" rel="noopener noreferrer"&gt;https://github.com/xbill9/qwen-dev/tree/main/local-llamacpp-3050oem-qwen352b-q4_0&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Qwen3.5-2B decodes 1.12x as fast as Gemma 4 E2B on an RTX 3050 and prefills 1.24–1.31x as fast. On three tasks, with thinking off for both models, Gemma answers better on every one: its 4-bit build leads Qwen's 4-bit build by 15.5 to 26.0 points and Qwen's unquantized bf16 by 10.0 to 19.5. With each model's drafter turned on, the speed lead disappears. The gaps survive scoring every cut-off or tool-less Qwen answer as right.&lt;/strong&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  What is this project trying to Do?
&lt;/h4&gt;

&lt;p&gt;The rigs in this repository serve Gemma 4 E2B from local hardware: an RTX 3050 OEM in a desktop and a GTX 1650 Ti in a laptop. Both run Google's quantization-aware-trained (QAT) 4-bit build, and both have measured scores for it on the same three tasks.&lt;/p&gt;

&lt;p&gt;The question here: what does a Qwen of the same size class give on the same cards? Qwen3.5-2B counts 1.943B parameters in its text GGUF. Everything below runs both models with the same flags and the same task code, and on one llama.cpp binary except where the Scope paragraph says otherwise.&lt;/p&gt;




&lt;h4&gt;
  
  
  At This Point You Should Have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;An NVIDIA GPU with a CUDA driver — here an RTX 3050 OEM (8 GB, Ampere, driver 615.78.08) and a GTX 1650 Ti (4 GB, Turing)&lt;/li&gt;
&lt;li&gt;llama.cpp built with CUDA — commit &lt;code&gt;7182ef71e&lt;/code&gt; here&lt;/li&gt;
&lt;li&gt;Python 3 for the converter and the MCP server — the system &lt;code&gt;python3&lt;/code&gt;, no virtualenv&lt;/li&gt;
&lt;li&gt;The repository cloned, and &lt;code&gt;local-llamacpp-3050oem-qwen352b-q4_0/&lt;/code&gt; as your working directory&lt;/li&gt;
&lt;li&gt;Claude Code, or any MCP client that speaks stdio&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  The Two Models
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Qwen3.5-2B&lt;/th&gt;
&lt;th&gt;Gemma 4 E2B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Layers&lt;/td&gt;
&lt;td&gt;24: three of every four Gated DeltaNet (linear attention), every fourth full attention&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parameters, as llama-bench counts them&lt;/td&gt;
&lt;td&gt;1.943B&lt;/td&gt;
&lt;td&gt;4.629B, most of it per-layer embeddings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vocabulary&lt;/td&gt;
&lt;td&gt;248,320, tied input and output&lt;/td&gt;
&lt;td&gt;262,144&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-bit release&lt;/td&gt;
&lt;td&gt;none; no QAT checkpoint and no GGUF published&lt;/td&gt;
&lt;td&gt;Google's QAT Q4_0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drafter&lt;/td&gt;
&lt;td&gt;an MTP layer inside the checkpoint&lt;/td&gt;
&lt;td&gt;a separate 4-layer assistant model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only Qwen's 6 full-attention layers hold a KV cache. The 18 DeltaNet layers keep a fixed-size state per request, so Qwen's prefill cost grows more slowly with prompt length.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Build the Qwen Q4_0 File
&lt;/h4&gt;

&lt;p&gt;Qwen publishes bf16 weights only, so the rig builds its own 4-bit file. &lt;code&gt;make quantize&lt;/code&gt; converts the Hugging Face checkout to a bf16 GGUF and runs the standard &lt;code&gt;llama-quantize&lt;/code&gt; Q4_0, with no importance matrix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 ~/llama.cpp/convert_hf_to_gguf.py ~/models/Qwen3.5-2B &lt;span class="nt"&gt;--outtype&lt;/span&gt; bf16 &lt;span class="nt"&gt;--outfile&lt;/span&gt; Qwen3.5-2B-bf16.gguf
~/llama.cpp/build/bin/llama-quantize Qwen3.5-2B-bf16.gguf Qwen3.5-2B-Q4_0.gguf Q4_0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result, from the rig's &lt;code&gt;tpu.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#   convert_hf_to_gguf.py --outtype bf16   -&amp;gt; 3,897,388,000 B, 335 tensors
#   llama-quantize ... Q4_0 (no imatrix)   -&amp;gt; 1,236,741,088 B
# llama-quantize's standard Q4_0 mix: 194 matrices Q4_0 (806.4 MB), token_embd
# Q6_K (417.2 MB, which is also the output head since it is tied), 140 F32 norms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This file is plain rounding from bf16. Gemma's Q4_0 file holds weights Google trained to sit on the 4-bit grid. Step 7 measures what that difference is worth.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Serve It
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;make serve&lt;/code&gt; runs llama-server with the Gemma rig's flags, so the two serve the same 131,072-token window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; Qwen3.5-2B-Q4_0.gguf &lt;span class="nt"&gt;--host&lt;/span&gt; 0.0.0.0 &lt;span class="nt"&gt;--port&lt;/span&gt; 8080 &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99 &lt;span class="nt"&gt;-c&lt;/span&gt; 131072 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-ctk&lt;/span&gt; f16 &lt;span class="nt"&gt;-ctv&lt;/span&gt; f16 &lt;span class="nt"&gt;-fa&lt;/span&gt; 1 &lt;span class="nt"&gt;-t&lt;/span&gt; 6 &lt;span class="nt"&gt;-tb&lt;/span&gt; 12 &lt;span class="nt"&gt;--parallel&lt;/span&gt; 1 &lt;span class="nt"&gt;--reasoning&lt;/span&gt; off &lt;span class="nt"&gt;--metrics&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Qwen3.5 thinks by default. &lt;code&gt;--reasoning off&lt;/code&gt; puts answers in &lt;code&gt;content&lt;/code&gt;, as on the Gemma rig; a request can still turn thinking on with &lt;code&gt;"chat_template_kwargs": {"enable_thinking": true}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At the full context it uses &lt;strong&gt;2995 MiB of the 3050's 8192&lt;/strong&gt;, and 2951 MiB of the 1650 Ti's 4096. The server log shows the MTP layer loaded and left idle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.01.266.683 W model has unused tensor blk.24.attn_norm.weight (size = 8192 bytes) -- ignoring
0.01.266.690 W model has unused tensor blk.24.post_attention_norm.weight (size = 8192 bytes) -- ignoring
0.01.266.699 W model has unused tensor blk.24.attn_q.weight (size = 4718592 bytes) -- ignoring
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 3 — Check That llama.cpp Runs Qwen Correctly
&lt;/h4&gt;

&lt;p&gt;A low score could come from the runtime. Three checks cover the prompt going in, the probabilities coming out and the tool calls Qwen writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompts.&lt;/strong&gt; Every prompt was rendered through llama-server's &lt;code&gt;/apply-template&lt;/code&gt; and through transformers' &lt;code&gt;apply_chat_template&lt;/code&gt;. All 400 tool-calling prompts, with their tools, and 100 math prompts came out byte-identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next-token probabilities.&lt;/strong&gt; The reference is transformers in float32 on the CPU over Qwen's bf16 weights, on 65 prompts from the quality suite:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Against transformers fp32&lt;/th&gt;
&lt;th&gt;Same chosen answer&lt;/th&gt;
&lt;th&gt;Answer-probability difference, median&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;transformers bf16, GPU&lt;/td&gt;
&lt;td&gt;61 of 65&lt;/td&gt;
&lt;td&gt;0.0128&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;llama.cpp bf16 GGUF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;62 of 65&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.0027&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp Q4_0 GGUF&lt;/td&gt;
&lt;td&gt;43 of 65&lt;/td&gt;
&lt;td&gt;0.1136&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;llama.cpp's bf16 file sits closer to the float32 reference than transformers' own bf16 path does. The drift comes from the 4-bit rounding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool calls.&lt;/strong&gt; Every BFCL reply that made no tool call was searched for text that looks like a call the parser could have missed (&lt;code&gt;tool_call&lt;/code&gt;, &lt;code&gt;&amp;lt;function&lt;/code&gt;, a JSON object): 0 of 79 at Q4_0 and 0 of 48 at bf16. Those replies solve the problem in prose, such as working the Pythagorean theorem step by step.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Measure Speed on One Binary
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;llama-bench&lt;/code&gt; ran both models on the same build, card and hour, five cells alternating between models, each started with the GPU at 40 °C or below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-bench &lt;span class="nt"&gt;-p&lt;/span&gt; 512,2048 &lt;span class="nt"&gt;-n&lt;/span&gt; 128 &lt;span class="nt"&gt;-r&lt;/span&gt; 5 &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99 &lt;span class="nt"&gt;-fa&lt;/span&gt; 1 &lt;span class="nt"&gt;-t&lt;/span&gt; 6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The summary script's output on the RTX 3050:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pp512: qwen 5193.44  gemma 4203.68  qwen/gemma 1.235x  qwen fa1/fa0 1.042x  qwen rep/first 0.993x  gemma rep/first 0.997x
pp2048: qwen 5316.13  gemma 4050.45  qwen/gemma 1.312x  qwen fa1/fa0 1.078x  qwen rep/first 0.996x  gemma rep/first 0.999x
tg128: qwen 132.84  gemma 118.30  qwen/gemma 1.123x  qwen fa1/fa0 1.012x  qwen rep/first 1.001x  gemma rep/first 1.002x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;RTX 3050&lt;/th&gt;
&lt;th&gt;GTX 1650 Ti&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen decode, tok/s&lt;/td&gt;
&lt;td&gt;132.84&lt;/td&gt;
&lt;td&gt;85.26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma decode, tok/s&lt;/td&gt;
&lt;td&gt;118.30&lt;/td&gt;
&lt;td&gt;81.17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen / Gemma, decode&lt;/td&gt;
&lt;td&gt;1.12x&lt;/td&gt;
&lt;td&gt;1.05x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen / Gemma, prefill at 512 / 2,048 tokens&lt;/td&gt;
&lt;td&gt;1.24x / 1.31x&lt;/td&gt;
&lt;td&gt;1.43x / 1.51x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Qwen's prefill rises with prompt length (5193 to 5316 t/s) while Gemma's falls (4204 to 4050). That fits the DeltaNet layers, whose cost does not grow with the prompt.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Measure Answer Quality
&lt;/h4&gt;

&lt;p&gt;Three tasks, the same task code and settings as the Gemma runs, 8 requests at a time, greedy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Suite&lt;/strong&gt;: 13 classification sets, 3,880 questions, scored from the next-token probabilities&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GSM8K&lt;/strong&gt;: 1,319 math word problems, 2,048-token limit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BFCL simple&lt;/strong&gt;: 400 tool-calling requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Qwen's suite prompts end in Qwen's own chat template with thinking closed, and its answers are read from Qwen's own tokens. Every pair of runs is compared question by question, with a 95% range from resampling.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RTX 3050&lt;/th&gt;
&lt;th&gt;Gemma E2B QAT Q4&lt;/th&gt;
&lt;th&gt;Qwen Q4_0&lt;/th&gt;
&lt;th&gt;Qwen − Gemma&lt;/th&gt;
&lt;th&gt;95% range&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Suite&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.683&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;0.422&lt;/td&gt;
&lt;td&gt;−0.260&lt;/td&gt;
&lt;td&gt;−0.281 to −0.240&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.898&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;0.743&lt;/td&gt;
&lt;td&gt;−0.155&lt;/td&gt;
&lt;td&gt;−0.177 to −0.132&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFCL simple&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.925&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;0.710&lt;/td&gt;
&lt;td&gt;−0.215&lt;/td&gt;
&lt;td&gt;−0.265 to −0.165&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every range is far from zero. On the suite, Qwen is ahead only on boolq and squad2.&lt;/p&gt;

&lt;p&gt;Where Qwen loses points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Option letters.&lt;/strong&gt; Asked for &lt;code&gt;A&lt;/code&gt;, &lt;code&gt;B&lt;/code&gt; or &lt;code&gt;C&lt;/code&gt;, it often writes the option's name instead. On multinli it answers &lt;code&gt;neutral&lt;/code&gt; on 298 of 299 questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-sided answers.&lt;/strong&gt; It calls 248 of 300 comments toxic, where the gold labels mark 32.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long math answers.&lt;/strong&gt; 100 GSM8K replies hit the 2,048-token limit, against 5 for Gemma. With thinking off it still deliberates inside the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prose instead of tool calls.&lt;/strong&gt; 79 of its 116 wrong BFCL answers made no tool call, mostly by solving the problem in text. Gemma skipped the tool 6 times.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 6 — Score Qwen Unquantized
&lt;/h4&gt;

&lt;p&gt;Qwen's 4-bit file is plain rounding, so part of the gap may be the rounding. The bf16 GGUF from Step 1 ran through the same three tasks on the same binary, the same day:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RTX 3050&lt;/th&gt;
&lt;th&gt;Qwen Q4_0&lt;/th&gt;
&lt;th&gt;Qwen bf16&lt;/th&gt;
&lt;th&gt;Gemma E2B QAT Q4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Suite&lt;/td&gt;
&lt;td&gt;0.422&lt;/td&gt;
&lt;td&gt;0.488&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.683&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K&lt;/td&gt;
&lt;td&gt;0.743&lt;/td&gt;
&lt;td&gt;0.798&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.898&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFCL simple&lt;/td&gt;
&lt;td&gt;0.710&lt;/td&gt;
&lt;td&gt;0.765&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.925&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rounding to Q4_0 costs Qwen 5.5 to 6.5 points per task&lt;/strong&gt;, every range clear of zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma's 4-bit build still leads unquantized Qwen by 19.5, 10.0 and 16.0 points.&lt;/strong&gt; The rounding accounts for a quarter to a third of the gap; the rest is the model.&lt;/li&gt;
&lt;li&gt;The same habits show at bf16: most comments called toxic, 48 BFCL replies with no tool call, 52 GSM8K replies at the length limit.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 7 — Measure What QAT Is Worth to Gemma
&lt;/h4&gt;

&lt;p&gt;Gemma's 4-bit file comes from quantization-aware training; Qwen's comes from rounding. To compare like with like, Gemma E2B's ordinary bf16 release was rounded to Q4_0 with the exact recipe used for Qwen, and scored on the GTX 1650 Ti beside the other three:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GTX 1650 Ti&lt;/th&gt;
&lt;th&gt;Gemma QAT Q4_0&lt;/th&gt;
&lt;th&gt;Gemma rounded Q4_0&lt;/th&gt;
&lt;th&gt;Qwen bf16&lt;/th&gt;
&lt;th&gt;Qwen Q4_0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Suite&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.681&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;0.632 🥈&lt;/td&gt;
&lt;td&gt;0.488&lt;/td&gt;
&lt;td&gt;0.430&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.901&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;0.881 🥈&lt;/td&gt;
&lt;td&gt;0.807&lt;/td&gt;
&lt;td&gt;0.734&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFCL simple&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.917&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;0.895 🥈&lt;/td&gt;
&lt;td&gt;0.762&lt;/td&gt;
&lt;td&gt;0.688&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Share of Qwen Q4_0's gap to Gemma QAT&lt;/th&gt;
&lt;th&gt;Suite&lt;/th&gt;
&lt;th&gt;GSM8K&lt;/th&gt;
&lt;th&gt;BFCL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Whole gap&lt;/td&gt;
&lt;td&gt;−0.251&lt;/td&gt;
&lt;td&gt;−0.167&lt;/td&gt;
&lt;td&gt;−0.230&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QAT's part&lt;/td&gt;
&lt;td&gt;−0.048&lt;/td&gt;
&lt;td&gt;−0.020&lt;/td&gt;
&lt;td&gt;−0.022&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;QAT's share&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;QAT is worth 2 to 5 points to Gemma at 4 bits. &lt;strong&gt;Rounded Gemma still beats unquantized Qwen on all three tasks&lt;/strong&gt;, by 14.4, 7.4 and 13.3 points.&lt;/p&gt;




&lt;h4&gt;
  
  
  Is the Comparison Fair to Qwen?
&lt;/h4&gt;

&lt;p&gt;Four objections a Qwen user will raise, each checked against the per-question results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma is the bigger model.&lt;/strong&gt; llama-bench counts 4.629B parameters for Gemma E2B against 1.943B for Qwen, most of Gemma's in per-layer embeddings. By file size, Qwen's bf16 GGUF is 3.90 GB and Gemma's QAT Q4_0 is 2.61 GB, and the larger Qwen file loses every task to the smaller Gemma one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The token limit cut Qwen's math answers off.&lt;/strong&gt; Scoring every GSM8K reply that hit the 2,048-token limit as right:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RTX 3050, GSM8K&lt;/th&gt;
&lt;th&gt;Scored&lt;/th&gt;
&lt;th&gt;Every cut-off reply right&lt;/th&gt;
&lt;th&gt;Gemma E2B QAT Q4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen Q4_0&lt;/td&gt;
&lt;td&gt;0.743&lt;/td&gt;
&lt;td&gt;0.812&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.898&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen bf16&lt;/td&gt;
&lt;td&gt;0.798&lt;/td&gt;
&lt;td&gt;0.836&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.898&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The tool-calling task punishes prose answers.&lt;/strong&gt; Scoring every BFCL reply with no tool call as right:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RTX 3050, BFCL simple&lt;/th&gt;
&lt;th&gt;Scored&lt;/th&gt;
&lt;th&gt;Every tool-less reply right&lt;/th&gt;
&lt;th&gt;Gemma E2B QAT Q4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen Q4_0&lt;/td&gt;
&lt;td&gt;0.710&lt;/td&gt;
&lt;td&gt;0.908&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.925&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen bf16&lt;/td&gt;
&lt;td&gt;0.765&lt;/td&gt;
&lt;td&gt;0.885&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.925&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The suite scores answer format.&lt;/strong&gt; Partly right. On three subsets Qwen gives one answer almost every time or writes the option name in place of the letter: civil_comments, summeval-consistency and multinli. With those removed, the bf16 gap to Gemma on the GTX 1650 Ti shrinks from 19.3 points to 11.2. The suite is the most format-sensitive of the three results; Gemma still leads on it.&lt;/p&gt;

&lt;p&gt;Q4_K_M and importance-matrix builds of Qwen were not scored. The bf16 file is the most any 4-bit build can recover, and Gemma leads it on every task.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — Run the Bf16 File on a 4 GB Card
&lt;/h4&gt;

&lt;p&gt;Qwen's bf16 GGUF holds 3.62 GiB of weights, and the 1650 Ti has 4 GiB. With every layer on the card, the 501.52 MiB compute buffer fails to allocate. Moving the last 6 of 24 blocks to the CPU is the fewest that fits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; Qwen3.5-2B-bf16.gguf &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99 &lt;span class="nt"&gt;-ot&lt;/span&gt; &lt;span class="s1"&gt;'blk\.(18|19|20|21|22|23)\.=CPU'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; 8192 &lt;span class="nt"&gt;-ctk&lt;/span&gt; f16 &lt;span class="nt"&gt;-ctv&lt;/span&gt; f16 &lt;span class="nt"&gt;-fa&lt;/span&gt; 1 &lt;span class="nt"&gt;-t&lt;/span&gt; 6 &lt;span class="nt"&gt;-tb&lt;/span&gt; 12 &lt;span class="nt"&gt;--parallel&lt;/span&gt; 1 &lt;span class="nt"&gt;--reasoning&lt;/span&gt; off &lt;span class="nt"&gt;--metrics&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GTX 1650 Ti&lt;/th&gt;
&lt;th&gt;Decode, tok/s&lt;/th&gt;
&lt;th&gt;Prefill at 512, t/s&lt;/th&gt;
&lt;th&gt;GPU memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Q4_0, all on the card&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;85.11&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;511.43&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;2951 MiB at 131k context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bf16, 6 blocks on the CPU&lt;/td&gt;
&lt;td&gt;20.22&lt;/td&gt;
&lt;td&gt;490.87&lt;/td&gt;
&lt;td&gt;3207 MiB at 8k context&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Decode drops to 0.24x and prefill holds at 0.96x. The split costs no score: bf16 on this card scored 0.488, 0.807 and 0.762, level with the 3050.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 9 — Draft With Qwen's MTP Head
&lt;/h4&gt;

&lt;p&gt;The Q4_0 file already carries Qwen's multi-token-prediction layer. llama.cpp drafts from it with &lt;code&gt;--spec-type draft-mtp&lt;/code&gt; and no extra file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; Qwen3.5-2B-Q4_0.gguf &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99 &lt;span class="nt"&gt;-c&lt;/span&gt; 8192 &lt;span class="nt"&gt;-fa&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--spec-type&lt;/span&gt; draft-mtp &lt;span class="nt"&gt;--spec-draft-n-max&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The log confirms it and reports acceptance per request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.02.045.088 I common_speculative_init_result: creating MTP draft context against the target model '/home/xbill/models/Qwen3.5-2B-gguf/Qwen3.5-2B-Q4_0.gguf'
0.04.830.818 I slot print_timing: id  0 | task 8 | draft acceptance = 0.91729 (  122 accepted /   133 generated), mean len =  1.92
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six prompts (code, chat, story, explain, facts, summary), one request at a time, geometric mean against no drafting on the RTX 3050:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Drafted tokens&lt;/th&gt;
&lt;th&gt;1&lt;/th&gt;
&lt;th&gt;2&lt;/th&gt;
&lt;th&gt;3&lt;/th&gt;
&lt;th&gt;4&lt;/th&gt;
&lt;th&gt;6&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Greedy&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.149x&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;1.145x&lt;/td&gt;
&lt;td&gt;0.998x&lt;/td&gt;
&lt;td&gt;0.852x&lt;/td&gt;
&lt;td&gt;0.647x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sampled, temperature 0.8&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.086x&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;1.047x&lt;/td&gt;
&lt;td&gt;0.920x&lt;/td&gt;
&lt;td&gt;0.782x&lt;/td&gt;
&lt;td&gt;0.596x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 drafted token is the setting to serve.&lt;/strong&gt; Code gains most (1.25x greedy); story least (1.09x).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Greedy replies stay byte-identical to no drafting up to 3 drafted tokens&lt;/strong&gt; on the 3050.&lt;/li&gt;
&lt;li&gt;On the same card, Gemma's separate drafter gave 1.39x at 2 tokens.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 10 — Race the Two Drafters on One Card
&lt;/h4&gt;

&lt;p&gt;On the GTX 1650 Ti, both models on one binary, one request at a time, each with its best drafter setting of 1 token:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GTX 1650 Ti, greedy tok/s&lt;/th&gt;
&lt;th&gt;No drafter&lt;/th&gt;
&lt;th&gt;1 drafted token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen Q4_0, own MTP head&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;82.2&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;91.0 (1.107x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma E2B, QAT drafter&lt;/td&gt;
&lt;td&gt;77.9&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;92.1 (1.181x)&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen / Gemma&lt;/td&gt;
&lt;td&gt;1.054&lt;/td&gt;
&lt;td&gt;0.988&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sampled at temperature 0.8 the result is the same: 86.1 against 90.7 tok/s. Gemma's drafter gains more, so the two tie.&lt;/p&gt;

&lt;p&gt;Drafting leaves Qwen's answers level: GSM8K 0.748 and BFCL 0.698 with the MTP head, against 0.734 and 0.688 without. At 8 requests at once, each task took 1.08–1.09x as long with drafting.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Pass Qwen's Tokenizer to Probability-Scored Tasks
&lt;/h4&gt;

&lt;p&gt;A task that scores answers from next-token probabilities has to look up the answer labels in the model's own vocabulary. Qwen's differs from Gemma's, so give the scorer &lt;code&gt;--tokenizer Qwen/Qwen3.5-2B&lt;/code&gt;; the default Gemma ids would read the wrong tokens.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Two Hosts, Two Different Hashes
&lt;/h4&gt;

&lt;p&gt;The 3050 host and the 1650 Ti host built Qwen's Q4_0 from the same revision on the same llama.cpp commit, and got files of the same size with different SHA-256s (&lt;code&gt;1c27608b…&lt;/code&gt; and &lt;code&gt;bd99d5ae…&lt;/code&gt;). The bf16 files differ too, so the change enters at conversion. They pick the same answer on 62 of 65 prompts and score within 2.2 points on every task. Pin a hash per host, and keep one file if you need byte-identical results across machines.&lt;/p&gt;




&lt;h4&gt;
  
  
  Compare and Contrast
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Qwen3.5-2B Q4_0&lt;/th&gt;
&lt;th&gt;Gemma 4 E2B QAT Q4_0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Decode, RTX 3050&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;132.84 tok/s&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;118.30 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decode with drafter, GTX 1650 Ti&lt;/td&gt;
&lt;td&gt;91.0 tok/s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;92.1 tok/s&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suite / GSM8K / BFCL, RTX 3050&lt;/td&gt;
&lt;td&gt;0.422 / 0.743 / 0.710&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.683 / 0.898 / 0.925&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-bit build&lt;/td&gt;
&lt;td&gt;rounded locally&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;QAT, published by Google&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drafter&lt;/td&gt;
&lt;td&gt;built in, no extra file&lt;/td&gt;
&lt;td&gt;separate 59 MB file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  So, Which One?
&lt;/h4&gt;

&lt;p&gt;Gemma 4 E2B. Qwen3.5-2B decodes faster without a drafter and prefills faster on prompts up to 2,048 tokens. Gemma answers better on all three tasks measured, at 4 bits against Qwen's bf16, and with both drafters on the speed difference is gone.&lt;/p&gt;

&lt;p&gt;Qwen's built-in MTP head is a convenience: drafting with no extra file, and greedy text unchanged at 1 drafted token. Choose Qwen when prefill speed matters more than the answers; how its lead grows past 2,048-token prompts is unmeasured here.&lt;/p&gt;




&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to serve Qwen3.5-2B with llama.cpp beside Gemma 4 E2B on two budget GPUs and compare them on speed and answer quality. The key to the solution was running both models on one llama.cpp binary with the same flags and the same task code, and scoring Qwen at bf16 and Gemma rounded the way Qwen's file is built. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 &lt;strong&gt;llama.cpp runs Qwen3.5-2B correctly&lt;/strong&gt;: identical prompts to Qwen's template, and its bf16 probabilities sit closer to float32 than transformers' bf16 does.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;Qwen is faster without drafting&lt;/strong&gt;: 1.12x decode and 1.24–1.31x prefill on the RTX 3050, 1.05x and 1.43–1.51x on the GTX 1650 Ti.&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Qwen scores lower on all three tasks&lt;/strong&gt;: 15.5 to 26.0 points behind Gemma at 4 bits, 10.0 to 19.5 behind at bf16.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;The gaps hold in Qwen's favour&lt;/strong&gt;: scoring every cut-off GSM8K reply and every tool-less BFCL reply as right still leaves Qwen behind.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;Rounding is a quarter to a third of Qwen's gap&lt;/strong&gt;, and QAT is a tenth to a fifth of Gemma's lead.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;Qwen's MTP head drafts with no extra file&lt;/strong&gt;: 1.15x greedy at 1 token, greedy text unchanged.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;With both drafters on, the two tie on speed&lt;/strong&gt;: 91.0 against 92.1 tok/s on the GTX 1650 Ti.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one RTX 3050 OEM (8 GB) and one GTX 1650 Ti (4 GB), llama.cpp &lt;code&gt;7182ef71e&lt;/code&gt; for every Qwen run and for Gemma on the 1650 Ti, measured 2026-10-16 and 2026-10-17. The Gemma quality figures on the 3050 come from 2026-10-13 on llama.cpp &lt;code&gt;77c4540ce&lt;/code&gt;; Gemma's runs on the 1650 Ti on &lt;code&gt;7182ef71e&lt;/code&gt; scored level with them. Speed cells ran 3 to 5 repeats; each quality task ran once per file. Both models ran with thinking off and greedy decoding throughout; Qwen thinks by default, and thinking on was not run for either. The three tasks are classification, grade-school math and simple tool calling: no coding, Chinese, instruction-following or long-context task. Prefill was measured up to 2,048 tokens and every Qwen run served one request at a time, except the 8-request quality runs. No importance-matrix, Q4_K_M or Q8_0 build of either model was scored. The scoring code and the two cards were set up for this repository's Gemma work.&lt;/p&gt;

&lt;p&gt;The strategy for using MCP for serving and comparing Qwen3.5-2B and Gemma 4 E2B on local GPUs was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/qwen-dev/tree/main/local-llamacpp-3050oem-qwen352b-q4_0" rel="noopener noreferrer"&gt;local-llamacpp-3050oem-qwen352b-q4_0 | GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/qwen-dev/tree/main/local-llamacpp-1650ti-qwen352b-q4_0" rel="noopener noreferrer"&gt;local-llamacpp-1650ti-qwen352b-q4_0 | GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/Qwen/Qwen3.5-2B" rel="noopener noreferrer"&gt;Qwen/Qwen3.5-2B | Hugging Face&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-E2B-it-qat-q4_0-exact-gguf | Hugging Face&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/google/gemma-4-E2B-it" rel="noopener noreferrer"&gt;google/gemma-4-E2B-it | Hugging Face&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/gde/gemma-4s-mtp-drafter-explained-speculative-decoding-and-a-4-bit-drafter-at-60-of-the-size-1oc0"&gt;Gemma 4's MTP Drafter, Explained | dev.to&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ggml-org/llama.cpp" rel="noopener noreferrer"&gt;llama.cpp | GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gemma</category>
      <category>qwen</category>
      <category>llamacpp</category>
      <category>cuda</category>
    </item>
    <item>
      <title>Can a QAT 12B Fit an 8 GB GPU? Gemma 4 12B on an RTX 3050</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Sun, 18 Oct 2026 14:10:29 +0000</pubDate>
      <link>https://dev.to/gde/can-a-qat-12b-fit-an-8-gb-gpu-gemma-4-12b-on-an-rtx-3050-1267</link>
      <guid>https://dev.to/gde/can-a-qat-12b-fit-an-8-gb-gpu-gemma-4-12b-on-an-rtx-3050-1267</guid>
      <description>&lt;p&gt;This article provides a step by step guide to serving Gemma 4 12B, Google's quantization-aware trained (QAT) build, on an 8 GB NVIDIA RTX 3050 with llama.cpp, with every layer on the GPU and Google's multi-token prediction (MTP) drafter for speculative decoding. A suite of Python MCP tools is built to simplify management of the llama.cpp hosted deployment.&lt;/p&gt;

&lt;p&gt;This is the third part of a short series. &lt;a href="https://dev.to/gde/gemma-4s-mtp-drafter-explained-speculative-decoding-and-a-4-bit-drafter-at-60-of-the-size-1oc0"&gt;Part 1, Gemma 4's MTP Drafter, Explained&lt;/a&gt;, covers how the drafter works and how the exact Q4_0 repack is built. &lt;a href="https://dev.to/gde/do-drafters-work-on-small-models-gemma-4-e2b-and-e4b-on-two-small-gpus-1b4g"&gt;Part 2, Do Drafters Work on Small Models?&lt;/a&gt;, measures E2B and E4B on this card and a GTX 1650 Ti. This part moves up to the 12B.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/gemma4-dev" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can a QAT 12B fit an 8 GB GPU? Yes&lt;/strong&gt;, with every layer on the card and room for a working context:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RTX 3050 OEM, 8 GB&lt;/th&gt;
&lt;th&gt;Google's 12B Q4_0 GGUF&lt;/th&gt;
&lt;th&gt;exact Q4_0 rebuild&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File size&lt;/td&gt;
&lt;td&gt;6.98 GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.72 GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Most context that loads&lt;/td&gt;
&lt;td&gt;28,672 tokens&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40,960 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation&lt;/td&gt;
&lt;td&gt;27.7 tok/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28.6 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With the 12B drafter&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56.4 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Most context with the drafter, 4-bit cache&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;81,920 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rebuild stores every weight on the 4-bit grid QAT trained, including the embedding table that Google's file keeps at 6 bits. That one table frees 247.5 MiB of the card, which buys 12,288 more tokens of context. Google's 12B drafter, rebuilt the same way, doubles generation, and a 4-bit context cache gives it 81,920 tokens of context with no change in answers.&lt;/p&gt;




&lt;h4&gt;
  
  
  What Is Being Measured?
&lt;/h4&gt;

&lt;p&gt;Gemma 4 12B is a dense model with 48 layers and 11.9 billion parameters. Google publishes it already trained for 4-bit weights, as safetensors (&lt;code&gt;google/gemma-4-12B-it-qat-q4_0-unquantized&lt;/code&gt;) and as a llama.cpp GGUF (&lt;code&gt;google/gemma-4-12B-it-qat-q4_0-gguf&lt;/code&gt;). At 4 bits its weights take about 6.7 GB, which leaves an 8 GB card very little for anything else.&lt;/p&gt;

&lt;p&gt;The questions are whether the whole model fits on the card, how much conversation fits beside it, how fast it generates, and what Google's 12B drafter adds. The card is the same RTX 3050 OEM that Part 2 measured with E2B and E4B.&lt;/p&gt;




&lt;h4&gt;
  
  
  At This Point You Should Have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;An NVIDIA GPU with 8 GB and a CUDA driver — here an RTX 3050 OEM, driver 615.78.08&lt;/li&gt;
&lt;li&gt;The CUDA toolkit, 13.4 here, and &lt;code&gt;cmake&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;llama.cpp at a commit with Gemma 4 and &lt;code&gt;--spec-type draft-mtp&lt;/code&gt; — every number here is from &lt;code&gt;7182ef71e&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Python 3.10 or newer, the system &lt;code&gt;python3&lt;/code&gt;, no virtualenv, with &lt;code&gt;numpy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;About 40 GB of free disk for the source checkpoint and the build&lt;/li&gt;
&lt;li&gt;The repository cloned, with the rig &lt;code&gt;local-llamacpp-3050oem-12b-q4_0/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Claude Code, or any MCP client that speaks stdio&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  How the 12B Differs From E2B and E4B
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;E4B&lt;/th&gt;
&lt;th&gt;12B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Layers&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hidden size&lt;/td&gt;
&lt;td&gt;2,560&lt;/td&gt;
&lt;td&gt;3,840&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-layer embedding table&lt;/td&gt;
&lt;td&gt;yes, read from disk&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;none&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layers that own a KV cache&lt;/td&gt;
&lt;td&gt;24 of 42&lt;/td&gt;
&lt;td&gt;all 48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sliding window&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;1,024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation on the 3050, no drafter&lt;/td&gt;
&lt;td&gt;61.2 tok/s&lt;/td&gt;
&lt;td&gt;28.6 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;E2B and E4B carry a large per-layer embedding table that llama.cpp reads a few rows at a time from the memory-mapped file, so much of their file never reaches the card. The 12B has no such table: almost the whole file goes to the GPU. Its embedding table doubles as the output layer, so it sits on the card as well.&lt;/p&gt;

&lt;p&gt;The 12B generates at 0.47x E4B's speed on the same card and llama.cpp build.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Size the Context Cache Before Downloading
&lt;/h4&gt;

&lt;p&gt;The context cache (KV cache) decides how much conversation fits. Gemma 4 12B mixes two kinds of attention layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;8 full-attention layers&lt;/strong&gt;, each with one key/value head of width 512. At 16-bit precision that is 8 × 512 × 2 (key and value) × 2 bytes = &lt;strong&gt;16 KiB per token&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;40 sliding-window layers&lt;/strong&gt;, each with 8 key/value heads of width 256, which only look back 1,024 tokens. llama.cpp holds a fixed 1,536 positions for them (the window plus one 512-token batch): &lt;strong&gt;480 MiB&lt;/strong&gt;, whatever the context.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So 40,960 tokens of context cost 640 + 480 MiB. Those are the figures llama.cpp's allocation log prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I load_tensors:        CUDA0 model buffer size =  6390.19 MiB
I llama_kv_cache:      CUDA0 KV buffer size =   640.00 MiB
I llama_kv_cache:      CUDA0 KV buffer size =   480.00 MiB
I sched_reserve:      CUDA0 compute buffer size =   161.52 MiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With 6.4 GB of weights on an 8 GB card, every 247 MiB saved on the weights is about 15,800 tokens of context (arithmetic, at 16 KiB per token).&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Rebuild the GGUF on Its Trained Grid
&lt;/h4&gt;

&lt;p&gt;Google's 12B GGUF is 6.131 GB of Q4_0 and 0.826 GB of Q6_K. The Q6_K is a single tensor, &lt;code&gt;token_embd&lt;/code&gt;, the 262,144 × 3,840 embedding table, at 787.5 MiB.&lt;/p&gt;

&lt;p&gt;QAT trained that table on a 4-bit grid like everything else: in each block of 32 values, every value is a block step times an integer from −8 to 7. Q4_0 stores one fp16 step and a 4-bit level per value, the same shape. &lt;code&gt;gguf_exact.py&lt;/code&gt; reads each block's trained step off its own values (the largest magnitude divided by 8, 7, … 1, whichever puts every value on an integer level, refined by least squares) and writes every weight matrix as Q4_0. It copies Google's metadata, tokenizer and chat template byte for byte.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hf download google/gemma-4-12B-it-qat-q4_0-unquantized &lt;span class="nt"&gt;--revision&lt;/span&gt; b6ed862 &lt;span class="nt"&gt;--local-dir&lt;/span&gt; src
hf download google/gemma-4-12B-it-qat-q4_0-gguf gemma-4-12b-it-qat-q4_0.gguf &lt;span class="nt"&gt;--revision&lt;/span&gt; 29d0977 &lt;span class="nt"&gt;--local-dir&lt;/span&gt; gguf
python3 gguf_exact.py src gguf/gemma-4-12b-it-qat-q4_0.gguf gemma-4-12B-it-q4_0-exact.gguf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is the same script that built the E2B and E4B files, unchanged: the 12B's tensor names already match. It rebuilt 329 tensors in 17 minutes, and every block landed on the grid. Per tensor kind, 96.77–96.88% of values match Google's bf16 weights bit for bit once rounded back to bf16; the rest differ by the fp16 rounding of the step.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Google's GGUF&lt;/th&gt;
&lt;th&gt;exact rebuild&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File&lt;/td&gt;
&lt;td&gt;6,975,879,296 B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6,716,356,736 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;token_embd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Q6_K, 787.5 MiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Q4_0, 540.0 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weights on the card&lt;/td&gt;
&lt;td&gt;6,637.69 MiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6,390.19 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Google's 12B GGUF Is Already Text Only
&lt;/h4&gt;

&lt;p&gt;The vision encoder ships as a separate file, &lt;code&gt;mmproj-gemma-4-12b-it-qat-q4_0.gguf&lt;/code&gt; (167 MiB). Leave it out and the model is text only, as the rebuild is. On an 8 GB card those 167 MiB are another 10,700 tokens of context (arithmetic).&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Fetch the Published Files
&lt;/h4&gt;

&lt;p&gt;Both rebuilds are on Hugging Face:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/xbill9/gemma-4-12B-it-qat-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-12B-it-qat-q4_0-exact-gguf&lt;/a&gt;, file &lt;code&gt;gemma-4-12B-it-q4_0-exact.gguf&lt;/code&gt;, SHA-256 &lt;code&gt;038337ee0375f602…&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/xbill9/gemma-4-12B-it-qat-assistant-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-12B-it-qat-assistant-q4_0-exact-gguf&lt;/a&gt;, the drafter, file &lt;code&gt;gemma-4-12B-it-qat-assistant-q4_0-exact.gguf&lt;/code&gt;, SHA-256 &lt;code&gt;f22f6366ca76102a…&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rig's &lt;code&gt;make download&lt;/code&gt; and &lt;code&gt;make draft&lt;/code&gt; fetch them and check the hashes in &lt;code&gt;tpu.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;make download
make draft
/home/xbill/models/gemma-4-12B-it-qat-assistant/gemma-4-12B-it-qat-assistant-q4_0-exact.gguf: OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 4 — Find the Most Context That Fits
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;fit.sh&lt;/code&gt; starts &lt;code&gt;llama-server -ngl 99 -fa 1&lt;/code&gt; at rising context sizes and records the largest that loads, with the card otherwise idle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RTX 3050, 8,192 MiB&lt;/th&gt;
&lt;th&gt;16-bit cache&lt;/th&gt;
&lt;th&gt;8-bit cache (&lt;code&gt;q8_0&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;4-bit cache (&lt;code&gt;q4_0&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google's GGUF&lt;/td&gt;
&lt;td&gt;28,672 tokens&lt;/td&gt;
&lt;td&gt;not tested&lt;/td&gt;
&lt;td&gt;not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exact rebuild&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40,960 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;65,536 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;131,072 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exact rebuild + drafter&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24,576 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;49,152 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;81,920 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Google's file fails at 32,768 tokens; the rebuild loads there with 556 MiB to spare and fails at 49,152. The 8-bit cache halves every cache figure, sliding layers included, and the 4-bit cache halves it again. Step 10 measures what that costs.&lt;/p&gt;

&lt;p&gt;The model's own limit is 131,072 tokens. Without the drafter, the 4-bit cache reaches it on this card.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Measure Speed Against Google's File
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;bench.sh&lt;/code&gt; runs &lt;code&gt;llama-bench&lt;/code&gt;, five repetitions, in four passes that alternate which file goes first, with the card cooled to 40 °C before each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;pass&lt;/th&gt;
&lt;th&gt;file&lt;/th&gt;
&lt;th&gt;prompt, 512 tokens&lt;/th&gt;
&lt;th&gt;generation, 128 tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;981.57 tok/s&lt;/td&gt;
&lt;td&gt;27.68 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;exact&lt;/td&gt;
&lt;td&gt;987.49 tok/s&lt;/td&gt;
&lt;td&gt;28.64 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;exact&lt;/td&gt;
&lt;td&gt;986.97 tok/s&lt;/td&gt;
&lt;td&gt;28.64 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;977.83 tok/s&lt;/td&gt;
&lt;td&gt;27.68 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The rebuild generates 1.035x faster&lt;/strong&gt;, the same in both passes of each file, and reads prompts at the same speed. Every generated token reads the output layer in full, and the rebuild's output layer is 247.5 MiB smaller. The gain is smaller than E4B's 1.06x and E2B's 1.11x because the output layer is a smaller share of what a 12B reads per token (inferred, not measured).&lt;/p&gt;

&lt;p&gt;Generation slows as the conversation grows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tokens already in context&lt;/th&gt;
&lt;th&gt;0&lt;/th&gt;
&lt;th&gt;8,192&lt;/th&gt;
&lt;th&gt;16,384&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;prompt, 512 tokens&lt;/td&gt;
&lt;td&gt;981.09 tok/s&lt;/td&gt;
&lt;td&gt;801.58 tok/s&lt;/td&gt;
&lt;td&gt;692.32 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;generation&lt;/td&gt;
&lt;td&gt;28.63 tok/s&lt;/td&gt;
&lt;td&gt;26.81 tok/s&lt;/td&gt;
&lt;td&gt;26.33 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  Step 6 — Check How Close It Stays to bf16
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;run_kld.sh&lt;/code&gt; scores the same text with Google's bf16 weights and with each 4-bit file, and compares the predicted next-token distributions (&lt;code&gt;llama-perplexity --kl-divergence&lt;/code&gt;, wikitext-2, 16 chunks of 512 tokens, on the CPU):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;against bf16&lt;/th&gt;
&lt;th&gt;Google's GGUF&lt;/th&gt;
&lt;th&gt;exact rebuild&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mean KL divergence&lt;/td&gt;
&lt;td&gt;0.543&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.125&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99th percentile KL divergence&lt;/td&gt;
&lt;td&gt;6.217&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.915&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same top token&lt;/td&gt;
&lt;td&gt;71.77%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;89.14%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perplexity, relative to bf16&lt;/td&gt;
&lt;td&gt;1.386&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.257&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The rebuild is 4.4x closer to bf16 by mean divergence&lt;/strong&gt;, and picks the same top token 89% of the time against 72%.&lt;/p&gt;

&lt;p&gt;Compare the two columns with each other only. The 12B instruction-tuned model reads raw Wikipedia text poorly, at a bf16 perplexity of 312 against E4B's 40 on the same test, and every divergence is larger in that regime. E4B's rebuild scores 0.0010 on this test.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — Rebuild the 12B Drafter
&lt;/h4&gt;

&lt;p&gt;Google's drafter for this model is &lt;a href="https://huggingface.co/google/gemma-4-12B-it-qat-q4_0-unquantized-assistant" rel="noopener noreferrer"&gt;google/gemma-4-12B-it-qat-q4_0-unquantized-assistant&lt;/a&gt;: 4 layers of width 1,024 with queries only, reading the 12B's own KV cache, and a tied 262,144-token embedding. It ships as bf16 on a 4-bit grid.&lt;/p&gt;

&lt;p&gt;llama.cpp's converter writes a Q8_0 file, which the drafter version of &lt;code&gt;gguf_exact.py&lt;/code&gt; uses for metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 convert_hf_to_gguf.py hf &lt;span class="nt"&gt;--outtype&lt;/span&gt; q8_0 &lt;span class="nt"&gt;--outfile&lt;/span&gt; gemma-4-12B-it-qat-assistant-q8_0.gguf
python3 gguf_exact.py hf gemma-4-12B-it-qat-assistant-q8_0.gguf gemma-4-12B-it-qat-assistant-q4_0-exact.gguf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every block landed on the grid. The rebuild is 253,708,960 bytes against 465,128,096 for Q8_0, and its weights take 226.90 MiB on the card against 428.53 MiB. This drafter is 4.7x the size of the E2B and E4B drafters.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — Measure the Drafter
&lt;/h4&gt;

&lt;p&gt;The rig serves with four extra flags for the drafter, and a 4-bit context cache (Step 10):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; gemma-4-12B-it-q4_0-exact.gguf &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99 &lt;span class="nt"&gt;-c&lt;/span&gt; 81920 &lt;span class="nt"&gt;-ctk&lt;/span&gt; q4_0 &lt;span class="nt"&gt;-ctv&lt;/span&gt; q4_0 &lt;span class="nt"&gt;-fa&lt;/span&gt; 1 &lt;span class="nt"&gt;--reasoning&lt;/span&gt; off &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-md&lt;/span&gt; gemma-4-12B-it-qat-assistant-q4_0-exact.gguf &lt;span class="nt"&gt;--spec-type&lt;/span&gt; draft-mtp &lt;span class="nt"&gt;-ngld&lt;/span&gt; all &lt;span class="nt"&gt;--spec-draft-n-max&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;spec_ab.py&lt;/code&gt; runs six prompts (code, advice, a story, an explanation, a list, a summary), 256 tokens each, three greedy repeats plus one sampled pass, one server per setting at &lt;code&gt;-c 8192&lt;/code&gt;, the card cooled before each. Serving without a drafter ran first and last, at 28.1 tok/s both times.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;greedy, geometric mean of 6 prompts&lt;/th&gt;
&lt;th&gt;tok/s&lt;/th&gt;
&lt;th&gt;gain&lt;/th&gt;
&lt;th&gt;drafted tokens kept&lt;/th&gt;
&lt;th&gt;memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no drafter&lt;/td&gt;
&lt;td&gt;28.1&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;7,255 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q8_0 drafter, 2 tokens&lt;/td&gt;
&lt;td&gt;53.4&lt;/td&gt;
&lt;td&gt;1.90x&lt;/td&gt;
&lt;td&gt;0.661&lt;/td&gt;
&lt;td&gt;7,771 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;exact Q4_0 drafter, 2 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56.4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.01x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.658&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7,569 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exact Q4_0 drafter, 1 token&lt;/td&gt;
&lt;td&gt;45.6&lt;/td&gt;
&lt;td&gt;1.62x&lt;/td&gt;
&lt;td&gt;0.759&lt;/td&gt;
&lt;td&gt;7,569 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exact Q4_0 drafter, 3 tokens&lt;/td&gt;
&lt;td&gt;56.7&lt;/td&gt;
&lt;td&gt;2.02x&lt;/td&gt;
&lt;td&gt;0.561&lt;/td&gt;
&lt;td&gt;7,569 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Per prompt at 2 tokens: code 2.40x, a list of facts 2.30x, advice 1.94x, an explanation 1.93x, a summary 1.82x, a story 1.75x. Sampled at temperature 0.8: 1.90x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The drafter doubles the 12B's generation speed.&lt;/strong&gt; The gain is larger than E4B's 1.72x on this card in Part 2: the 12B keeps 66% of drafted tokens against E4B's 48%.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: For the 12B, the 4-Bit Drafter Is Faster Than the 8-Bit One
&lt;/h4&gt;

&lt;p&gt;For E2B and E4B the Q4_0 and Q8_0 drafters ran level. For the 12B the Q4_0 drafter is 1.06x faster (1.04–1.07x on each prompt), at the same share of tokens kept, and uses 202 MiB less memory. This drafter is 4.7x larger, so its own weight reads are likely a larger share of each drafted step (inferred, not measured). On an 8 GB card the 202 MiB is also about 12,900 tokens of context (arithmetic).&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Greedy Text Changes With the Drafter
&lt;/h4&gt;

&lt;p&gt;With the drafter on, greedy replies repeat exactly from run to run, but differ from the replies without a drafter in all 18 at 2 drafted tokens and 9 of 18 at 1. Checking several tokens in one pass runs different GPU kernels, which can change a near-tie word. Step 9 checks whether the answers change.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 9 — Check the Answers
&lt;/h4&gt;

&lt;p&gt;The rig's own settings, one request at a time: GSM8K (1,319 grade-school math questions, up to 2,048 tokens per reply) and BFCL simple (400 single function calls), with the drafter and without. E4B's scores are from Part 2's run of the same tests on the same card.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;one request at a time&lt;/th&gt;
&lt;th&gt;12B, drafter&lt;/th&gt;
&lt;th&gt;12B, no drafter&lt;/th&gt;
&lt;th&gt;E4B, drafter&lt;/th&gt;
&lt;th&gt;E4B, no drafter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.960&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.963&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.930&lt;/td&gt;
&lt;td&gt;0.931&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFCL simple&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.950&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.948&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.913&lt;/td&gt;
&lt;td&gt;0.913&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The 12B answers more questions than E4B on both tests.&lt;/strong&gt; Paired question by question, it is 3.0 points ahead on GSM8K (95% range 1.8 to 4.3) and 3.8 points ahead on BFCL (1.0 to 6.5), both with the drafter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The drafter changes no score.&lt;/strong&gt; On GSM8K the runs with and without it differ by 0.3 points (95% range −0.8 to +0.2), on BFCL by one question of 400. The greedy text differs, as in Step 8: 332 of 1,319 GSM8K replies match word for word. Without the drafter GSM8K took 2.34x as long, 4 h 16 min against 1 h 49 min.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 10 — Choose the Context Cache
&lt;/h4&gt;

&lt;p&gt;The rig serves one long conversation at a time, so the context it holds and its speed with that context matter most. &lt;code&gt;kv_sweep.py&lt;/code&gt; loads each cache type at its largest context, with and without the drafter, puts 0 to 98,304 tokens of a long document ahead of three questions, and measures generation (256 greedy tokens, geometric mean of the three):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tokens already in context&lt;/th&gt;
&lt;th&gt;0&lt;/th&gt;
&lt;th&gt;16,384&lt;/th&gt;
&lt;th&gt;32,768&lt;/th&gt;
&lt;th&gt;65,536&lt;/th&gt;
&lt;th&gt;98,304&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16-bit cache, drafter (24,576)&lt;/td&gt;
&lt;td&gt;57.5 tok/s&lt;/td&gt;
&lt;td&gt;49.8&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8-bit cache, drafter (49,152)&lt;/td&gt;
&lt;td&gt;55.1&lt;/td&gt;
&lt;td&gt;43.3&lt;/td&gt;
&lt;td&gt;39.3&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;4-bit cache, drafter (81,920)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;55.1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;42.4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;37.8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30.8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-bit cache, no drafter (131,072)&lt;/td&gt;
&lt;td&gt;27.5&lt;/td&gt;
&lt;td&gt;24.1&lt;/td&gt;
&lt;td&gt;22.2&lt;/td&gt;
&lt;td&gt;19.2&lt;/td&gt;
&lt;td&gt;16.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The drafter keeps paying with a long conversation held&lt;/strong&gt;: 2.00x at 0 tokens, 1.70x at 32,768, 1.60x at 65,536. A smaller cache costs the drafter more than it costs plain generation: at 16,384 tokens the 4-bit cache runs at 0.85x the 16-bit one with the drafter and 0.94x without. Prompt reading is level across cache types, about 720 tok/s at 16,384 tokens and 408 at 65,536.&lt;/p&gt;

&lt;p&gt;The same GSM8K and BFCL runs as Step 9, at each cache's largest context, paired question by question against the 16-bit cache:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;one request at a time&lt;/th&gt;
&lt;th&gt;GSM8K&lt;/th&gt;
&lt;th&gt;BFCL simple&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16-bit cache, drafter&lt;/td&gt;
&lt;td&gt;0.960&lt;/td&gt;
&lt;td&gt;0.950&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8-bit cache, drafter&lt;/td&gt;
&lt;td&gt;0.960&lt;/td&gt;
&lt;td&gt;0.948&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;4-bit cache, drafter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.961&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.950&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-bit cache, no drafter&lt;/td&gt;
&lt;td&gt;0.962&lt;/td&gt;
&lt;td&gt;0.950&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The cache type changes no score&lt;/strong&gt;: every paired range includes zero. A recall test plants eight six-digit codes through the document and asks for all of them: the 16-bit, 8-bit and 4-bit caches return the same share, about 85% at 16,384 tokens and 70% at 32,768, and the drafter changes neither.&lt;/p&gt;

&lt;p&gt;3 drafted tokens ran level with 2 at every depth and 1 ran well behind, so 2 stays. Larger prompt batches (&lt;code&gt;-ub 1024&lt;/code&gt;) do not load beside the drafter.&lt;/p&gt;




&lt;h4&gt;
  
  
  Compare and Contrast
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RTX 3050 OEM, 8 GB&lt;/th&gt;
&lt;th&gt;🥇 Gemma 4 12B&lt;/th&gt;
&lt;th&gt;🥈 Gemma 4 E4B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File&lt;/td&gt;
&lt;td&gt;6.72 GB&lt;/td&gt;
&lt;td&gt;4.22 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation, no drafter&lt;/td&gt;
&lt;td&gt;28.6 tok/s&lt;/td&gt;
&lt;td&gt;61.2 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation, 2-token drafter&lt;/td&gt;
&lt;td&gt;56.4 tok/s&lt;/td&gt;
&lt;td&gt;100.0 tok/s (Part 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drafter gain&lt;/td&gt;
&lt;td&gt;2.01x&lt;/td&gt;
&lt;td&gt;1.72x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context with the drafter, 16-bit cache&lt;/td&gt;
&lt;td&gt;24,576 tokens&lt;/td&gt;
&lt;td&gt;131,072 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context with the drafter, 4-bit cache&lt;/td&gt;
&lt;td&gt;81,920 tokens&lt;/td&gt;
&lt;td&gt;not measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K, with the drafter&lt;/td&gt;
&lt;td&gt;0.960&lt;/td&gt;
&lt;td&gt;0.930&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFCL simple, with the drafter&lt;/td&gt;
&lt;td&gt;0.950&lt;/td&gt;
&lt;td&gt;0.913&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;E4B's numbers are from llama.cpp &lt;code&gt;77c4540ce&lt;/code&gt; (Part 2) except the no-drafter generation, measured beside the 12B on &lt;code&gt;7182ef71e&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  So, Which One?
&lt;/h4&gt;

&lt;p&gt;For answers, the 12B. It scores 3 to 4 points higher than E4B on math and on function calls, and with the drafter it generates at 56.4 tok/s, near E4B's 61.2 without one.&lt;/p&gt;

&lt;p&gt;For one long conversation, the 12B with a 4-bit cache and the drafter: 81,920 tokens at 55 tok/s with nothing held and 31 tok/s at 65,536, with the same answers as the 16-bit cache. Past 81,920 tokens, drop the drafter for the full 131,072 at about half the speed.&lt;/p&gt;

&lt;p&gt;For speed, E4B. It generates at 100 tok/s with its drafter and holds the full 131,072-token context with a 16-bit cache.&lt;/p&gt;

&lt;p&gt;Either way, serve with the drafter: it doubles the 12B's speed and costs no answers.&lt;/p&gt;




&lt;h4&gt;
  
  
  What Comes Next
&lt;/h4&gt;

&lt;p&gt;Google publishes the same QAT drafters for 26B-A4B and 31B, both too large for this card. The next measurements are the 12B under several requests at once, which share the 81,920 tokens, and the 12B on a 16 GB card with its full context and the drafter.&lt;/p&gt;




&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to find out whether Gemma 4 12B, in Google's QAT build, runs on an 8 GB RTX 3050 with every layer on the GPU. It does. The key to the solution was rebuilding Google's GGUF with every weight on the 4-bit grid QAT trained, which frees the memory a working context needs, and rebuilding the 12B drafter the same way. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 The exact Q4_0 rebuild fits with every layer on the card at up to 40,960 tokens of context, against 28,672 for Google's file&lt;/li&gt;
&lt;li&gt;🟢 It generates 1.035x faster than Google's file and stays 4.4x closer to bf16 by mean divergence&lt;/li&gt;
&lt;li&gt;🟢 Google's 12B drafter, rebuilt as exact Q4_0, doubles generation to 56.4 tok/s, and runs 1.06x faster than the Q8_0 conversion&lt;/li&gt;
&lt;li&gt;🟢 The 12B answers more questions than E4B on the same card: GSM8K 0.960 against 0.930, BFCL 0.950 against 0.913, and the drafter changes neither score&lt;/li&gt;
&lt;li&gt;🟢 A 4-bit context cache holds 81,920 tokens with the drafter, 3.3x the 16-bit cache, with no change in GSM8K, BFCL or long-document recall&lt;/li&gt;
&lt;li&gt;⚠️ With the drafter, the 4-bit cache generates at 0.85x the 16-bit cache once 16,384 tokens are held&lt;/li&gt;
&lt;li&gt;⚠️ The 12B generates at 0.47x E4B's speed on the same card&lt;/li&gt;
&lt;li&gt;⚠️ The full 131,072-token context fits only without the drafter, at about half the speed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: Gemma 4 12B-it QAT only, text only, one RTX 3050 OEM (8 GB) in an i7-5820K desktop, llama.cpp &lt;code&gt;7182ef71e&lt;/code&gt;, Google's 12B Q4_0 GGUF against the exact Q4_0 rebuild, Google's 12B QAT drafter as exact Q4_0 and Q8_0, six short prompts for speed, divergence on 8,192 tokens of wikitext-2 on the CPU, GSM8K and BFCL simple one request at a time, 16-bit, 8-bit and 4-bit context caches with 0 to 98,304 tokens of one document in context, one request at a time throughout. E4B comparisons from Part 2 used llama.cpp &lt;code&gt;77c4540ce&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The strategy for using MCP for local accelerator comparison was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Part 1, Gemma 4's MTP Drafter, Explained: &lt;a href="https://dev.to/gde/gemma-4s-mtp-drafter-explained-speculative-decoding-and-a-4-bit-drafter-at-60-of-the-size-1oc0"&gt;https://dev.to/gde/gemma-4s-mtp-drafter-explained-speculative-decoding-and-a-4-bit-drafter-at-60-of-the-size-1oc0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Part 2, Do Drafters Work on Small Models?: &lt;a href="https://dev.to/gde/do-drafters-work-on-small-models-gemma-4-e2b-and-e4b-on-two-small-gpus-1b4g"&gt;https://dev.to/gde/do-drafters-work-on-small-models-gemma-4-e2b-and-e4b-on-two-small-gpus-1b4g&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/xbill9/gemma4-dev" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;12B rig: &lt;a href="https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-12b-q4_0" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-12b-q4_0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The rebuild and divergence: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-12b-q4_0/benchmarks/runs/2026-10-16-exact-gguf-12b-3050oem/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-12b-q4_0/benchmarks/runs/2026-10-16-exact-gguf-12b-3050oem/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Memory and speed: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-12b-q4_0/benchmarks/runs/2026-10-16-first-light-3050oem/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-12b-q4_0/benchmarks/runs/2026-10-16-first-light-3050oem/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The drafter: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-12b-q4_0/benchmarks/runs/2026-10-16-drafter-q4exact-12b-3050oem/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-12b-q4_0/benchmarks/runs/2026-10-16-drafter-q4exact-12b-3050oem/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Answers: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-12b-q4_0/benchmarks/runs/2026-10-16-accuracy-12b-3050oem/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-12b-q4_0/benchmarks/runs/2026-10-16-accuracy-12b-3050oem/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Context cache: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-12b-q4_0/benchmarks/runs/2026-10-17-kvcache-12b-3050oem/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-12b-q4_0/benchmarks/runs/2026-10-17-kvcache-12b-3050oem/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;12B GGUF: &lt;a href="https://huggingface.co/xbill9/gemma-4-12B-it-qat-q4_0-exact-gguf" rel="noopener noreferrer"&gt;https://huggingface.co/xbill9/gemma-4-12B-it-qat-q4_0-exact-gguf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;12B drafter: &lt;a href="https://huggingface.co/xbill9/gemma-4-12B-it-qat-assistant-q4_0-exact-gguf" rel="noopener noreferrer"&gt;https://huggingface.co/xbill9/gemma-4-12B-it-qat-assistant-q4_0-exact-gguf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google's 12B QAT GGUF: &lt;a href="https://huggingface.co/google/gemma-4-12B-it-qat-q4_0-gguf" rel="noopener noreferrer"&gt;https://huggingface.co/google/gemma-4-12B-it-qat-q4_0-gguf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;llama.cpp: &lt;a href="https://github.com/ggml-org/llama.cpp" rel="noopener noreferrer"&gt;https://github.com/ggml-org/llama.cpp&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gemma</category>
      <category>llamacpp</category>
      <category>cuda</category>
      <category>benchmarking</category>
    </item>
    <item>
      <title>Do Drafters Work on Small Models? Gemma 4 E2B and E4B on Two Small GPUs</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Fri, 16 Oct 2026 17:30:10 +0000</pubDate>
      <link>https://dev.to/gde/do-drafters-work-on-small-models-gemma-4-e2b-and-e4b-on-two-small-gpus-1b4g</link>
      <guid>https://dev.to/gde/do-drafters-work-on-small-models-gemma-4-e2b-and-e4b-on-two-small-gpus-1b4g</guid>
      <description>&lt;p&gt;This article provides a step by step guide to serving Gemma 4 E2B and E4B with Google's QAT assistant drafters through llama.cpp's speculative decoding, and measuring what the drafter is worth on two small NVIDIA cards: a desktop RTX 3050 OEM and a laptop GTX 1650 Ti. A suite of Python MCP tools is built to simplify management of the llama.cpp hosted deployment.&lt;/p&gt;

&lt;p&gt;This is the second part of a short series. &lt;a href="https://dev.to/gde/gemma-4s-mtp-drafter-explained-speculative-decoding-and-a-4-bit-drafter-at-60-of-the-size-1oc0"&gt;Part 1, Gemma 4's MTP Drafter, Explained&lt;/a&gt;, covers how speculative decoding and Gemma 4's multi-token prediction (MTP) drafter work and how the exact Q4_0 repack is built. This part has the measurements.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/gemma4-dev" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A drafter is the guesser in speculative decoding: it proposes the next few tokens, and the main model checks them all in one pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do drafters work on small models? Yes.&lt;/strong&gt; Gemma 4 E2B and E4B are already small, and their drafter is a 4-layer model of 59 MB. Served one request at a time, it makes both models faster on both cards, and GSM8K and BFCL scores stay the same:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;one request at a time&lt;/th&gt;
&lt;th&gt;RTX 3050&lt;/th&gt;
&lt;th&gt;GTX 1650 Ti&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;E4B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.72x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.49x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.40x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.18x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer quality&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The smaller model gains less. E2B is already fast per token, so each drafted step costs a larger share of what it saves (inferred from the draft-length results below, not measured directly). Both cards keep the same share of the drafted tokens, within 0.03, so the gap between the cards comes from the cards.&lt;/p&gt;

&lt;p&gt;The gain has limits. With several requests at once it shrinks on both cards, and on the 1650 Ti it turns into a loss from 4 requests (E4B) or 8 (E2B). On the 1650 Ti, E2B's gain is gone by about 32k tokens of conversation. Too many drafted tokens also cost speed on the slower card.&lt;/p&gt;

&lt;p&gt;The drafter here is an exact Q4_0 repack of Google's weights. It matches the converter's Q8_0 file on speed and answers at 60% of its size, and beats Google's bf16 weights on speed on every card and model.&lt;/p&gt;




&lt;h4&gt;
  
  
  What Is Being Measured?
&lt;/h4&gt;

&lt;p&gt;A language model writes one token per forward pass, and every pass reads every weight. For one user on a small GPU, each pass spends most of its time moving weights, which leaves compute to spare. Speculative decoding uses it: a small drafter guesses the next few tokens, and the main model checks all of them in one pass. It keeps the guesses up to the first one it disagrees with and adds one token of its own, so the output is the main model's either way. When most guesses are kept, one pass yields several tokens instead of one.&lt;/p&gt;

&lt;p&gt;Gemma 4's drafter uses multi-token prediction (MTP). It is a 4-layer model that reads the main model's hidden state and reuses its KV cache, so it keeps no cache of its own and costs little more than its weights.&lt;/p&gt;

&lt;p&gt;MTP is one of several ways to do speculative decoding. llama.cpp &lt;code&gt;77c4540ce&lt;/code&gt; offers ten through &lt;code&gt;--spec-type&lt;/code&gt;: a separate small draft model (&lt;code&gt;draft-simple&lt;/code&gt;), &lt;code&gt;draft-eagle3&lt;/code&gt;, &lt;code&gt;draft-mtp&lt;/code&gt;, &lt;code&gt;draft-dflash&lt;/code&gt;, &lt;code&gt;draft-dspark&lt;/code&gt;, and five n-gram methods. MTP is the one Google builds drafters for: E2B, E4B, 12B, 26B-A4B and 31B each have a published assistant checkpoint (E2B's config names it &lt;code&gt;Gemma4AssistantForCausalLM&lt;/code&gt;), which llama.cpp runs as &lt;code&gt;draft-mtp&lt;/code&gt; and vLLM as &lt;code&gt;"method":"mtp"&lt;/code&gt;. This article measures MTP only. &lt;a href="https://dev.to/gde/gemma-4s-mtp-drafter-explained-speculative-decoding-and-a-4-bit-drafter-at-60-of-the-size-1oc0"&gt;Part 1&lt;/a&gt; explains this in full.&lt;/p&gt;

&lt;p&gt;Google publishes an assistant drafter for each Gemma 4 QAT checkpoint. Both cards here serve the exact Q4_0 GGUFs of &lt;code&gt;gemma-4-E2B-it&lt;/code&gt; and &lt;code&gt;gemma-4-E4B-it&lt;/code&gt; with the drafters stored back as Q4_0 on their trained 4-bit grid, 59 MB each. The question is what the drafter is worth on each card, and why the two cards differ.&lt;/p&gt;




&lt;h4&gt;
  
  
  At This Point You Should Have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;An NVIDIA GPU with a CUDA driver — here an RTX 3050 OEM (driver 615.78.08) and a GTX 1650 Ti with Max-Q Design (driver 615.71.09)&lt;/li&gt;
&lt;li&gt;The CUDA toolkit, 13.4 here, and &lt;code&gt;cmake&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;llama.cpp at a commit with &lt;code&gt;--spec-type draft-mtp&lt;/code&gt; — both cards ran &lt;code&gt;77c4540ce&lt;/code&gt; for every comparison below&lt;/li&gt;
&lt;li&gt;Python 3.10 or newer, the system &lt;code&gt;python3&lt;/code&gt;, no virtualenv&lt;/li&gt;
&lt;li&gt;The repository cloned, with the four rigs &lt;code&gt;local-llamacpp-{3050oem,1650ti}-{2b,4b}-q4_0/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Claude Code, or any MCP client that speaks stdio&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  The Two Cards
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;RTX 3050 OEM&lt;/th&gt;
&lt;th&gt;GTX 1650 Ti Max-Q&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;i7-5820K desktop, 46 GiB&lt;/td&gt;
&lt;td&gt;i7-10750H laptop (Lenovo Yoga 9), 15 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;8192 MiB&lt;/td&gt;
&lt;td&gt;4096 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Architecture&lt;/td&gt;
&lt;td&gt;Ampere, compute capability 8.6, tensor cores&lt;/td&gt;
&lt;td&gt;Turing TU117, compute capability 7.5, no tensor cores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power limit&lt;/td&gt;
&lt;td&gt;120 W&lt;/td&gt;
&lt;td&gt;about 40 W&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2B, no drafter&lt;/td&gt;
&lt;td&gt;108.2 tok/s&lt;/td&gt;
&lt;td&gt;76.5 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E4B, no drafter&lt;/td&gt;
&lt;td&gt;58.3 tok/s&lt;/td&gt;
&lt;td&gt;38.0 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without a drafter the 3050 generates 1.41x faster on E2B and 1.53x on E4B. Different hosts, so treat ratios between the cards as indicative.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Repack the Drafter on Its Trained Grid
&lt;/h4&gt;

&lt;p&gt;Google publishes the QAT assistant drafters as &lt;a href="https://huggingface.co/google/gemma-4-E2B-it-qat-q4_0-unquantized-assistant" rel="noopener noreferrer"&gt;google/gemma-4-E2B-it-qat-q4_0-unquantized-assistant&lt;/a&gt; and &lt;a href="https://huggingface.co/google/gemma-4-E4B-it-qat-q4_0-unquantized-assistant" rel="noopener noreferrer"&gt;google/gemma-4-E4B-it-qat-q4_0-unquantized-assistant&lt;/a&gt;. Each is a 4-layer model that reads the main model's KV cache, stored as bf16 safetensors.&lt;/p&gt;

&lt;p&gt;Quantization-aware training already put the weights on a 4-bit grid: in every group of 32 values there are at most 16 distinct levels, evenly spaced. bf16 spends 16 bits storing each of those levels.&lt;/p&gt;

&lt;p&gt;llama.cpp's &lt;code&gt;convert_hf_to_gguf.py&lt;/code&gt; has no way to know that, so it writes Q8_0: 8.5 bits per weight, re-rounded onto an 8-bit grid of its own. The file shrinks, and the values move. Only 11.7% of the drafter's weights come back bit-identical.&lt;/p&gt;

&lt;p&gt;Q4_0 is 4.5 bits per weight: one fp16 step per group of 32, and a 4-bit level for each value. That is enough to hold the trained grid, provided each group's step is read off its own values. &lt;code&gt;gguf_exact.py&lt;/code&gt; does this for each group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;amax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&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="n"&gt;keepdims&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                      &lt;span class="c1"&gt;# try steps amax/1 .. amax/8
&lt;/span&gt;    &lt;span class="n"&gt;cand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;amax&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;good&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;cand&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&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="n"&gt;keepdims&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;amax&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**-&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first step that puts every value in the group on an integer level wins. A least-squares fit then refines it, and it is rounded to fp16, Q4_0's step type. A group that fits no step stops the build, since nothing is re-rounded. On both drafters no group was off the grid.&lt;/p&gt;

&lt;p&gt;The script takes Google's safetensors and the converter's Q8_0 file, copies the Q8_0 file's metadata byte for byte (tokenizer, chat template, settings), and writes every weight matrix as Q4_0. That covers 23 tensors per drafter, including the token embedding, which holds 86–87% of the values rewritten:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 gguf_exact.py &amp;lt;google-snapshot-dir&amp;gt; gemma-4-E2B-it-qat-assistant-q8_0.gguf gemma-4-E2B-it-qat-assistant-q4_0-exact.gguf
python3 gguf_exact.py &amp;lt;google-snapshot-dir&amp;gt; gemma-4-E2B-it-qat-assistant-q8_0.gguf gemma-4-E2B-it-qat-assistant-bf16.gguf &lt;span class="nt"&gt;--bf16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--bf16&lt;/code&gt; writes Google's weights unchanged into the same layout, so all three formats can be compared with identical metadata.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;E2B drafter&lt;/th&gt;
&lt;th&gt;Q8_0 (converter)&lt;/th&gt;
&lt;th&gt;exact Q4_0 (repack)&lt;/th&gt;
&lt;th&gt;bf16 (Google's weights)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File size&lt;/td&gt;
&lt;td&gt;97.8 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;59.2 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;170.2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bits per weight&lt;/td&gt;
&lt;td&gt;8.5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error against Google's weights&lt;/td&gt;
&lt;td&gt;0.00494&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.00138&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Values bit-identical, read back in fp32&lt;/td&gt;
&lt;td&gt;11.7%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Values bit-identical, rounded to bf16&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96.5–97.0% per tensor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The repack is 60% of the Q8_0 file and its error is 3.6x lower. Rounded back to bf16, the format Google stored, 97% of its values match Google's bit for bit. The E4B drafter gives the same picture: 59.7 MB, error 0.00138, 96.4–97.1% per tensor.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: The Converter Drops the Masked Embedding
&lt;/h4&gt;

&lt;p&gt;llama.cpp's converter leaves out the checkpoint's &lt;code&gt;masked_embedding&lt;/code&gt; tensors, and the repack keeps its tensor list. All three drafter files compared here lack them, so the comparison is fair. The files work with &lt;code&gt;--spec-type draft-mtp&lt;/code&gt; as they are.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Fetch the Published Drafters
&lt;/h4&gt;

&lt;p&gt;Both repacks are on Hugging Face:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-assistant-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-E2B-it-qat-assistant-q4_0-exact-gguf&lt;/a&gt;, file &lt;code&gt;gemma-4-E2B-it-qat-assistant-q4_0-exact.gguf&lt;/code&gt;, SHA-256 &lt;code&gt;060f5a0158f8120d…&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/xbill9/gemma-4-E4B-it-qat-assistant-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-E4B-it-qat-assistant-q4_0-exact-gguf&lt;/a&gt;, file &lt;code&gt;gemma-4-E4B-it-qat-assistant-q4_0-exact.gguf&lt;/code&gt;, SHA-256 &lt;code&gt;dc965cb963e56b86…&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They pair with the main models' exact Q4_0 GGUFs, built the same way: &lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-E2B-it-qat-q4_0-exact-gguf&lt;/a&gt; and &lt;a href="https://huggingface.co/xbill9/gemma-4-E4B-it-qat-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-E4B-it-qat-q4_0-exact-gguf&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Each rig's &lt;code&gt;make draft&lt;/code&gt; downloads its drafter at a pinned revision and checks the hash from &lt;code&gt;tpu.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;SPEC_DRAFT_MODEL&lt;span class="si"&gt;)&lt;/span&gt;.part https://huggingface.co/&lt;span class="si"&gt;$(&lt;/span&gt;SPEC_DRAFT_HF&lt;span class="si"&gt;)&lt;/span&gt;/resolve/&lt;span class="si"&gt;$(&lt;/span&gt;SPEC_DRAFT_HF_REVISION&lt;span class="si"&gt;)&lt;/span&gt;/&lt;span class="si"&gt;$(&lt;/span&gt;notdir &lt;span class="si"&gt;$(&lt;/span&gt;SPEC_DRAFT_MODEL&lt;span class="si"&gt;))&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;SPEC_DRAFT_SHA256&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;SPEC_DRAFT_MODEL&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sha256sum&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both cards loaded the same two files, checked by hash.&lt;/p&gt;




&lt;h4&gt;
  
  
  How the Repack Compares on Both Cards
&lt;/h4&gt;

&lt;p&gt;Each format was served on both cards at 2 drafted tokens, six prompts, greedy. Gain against no drafter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;2 drafted tokens&lt;/th&gt;
&lt;th&gt;3050 E2B&lt;/th&gt;
&lt;th&gt;1650 Ti E2B&lt;/th&gt;
&lt;th&gt;3050 E4B&lt;/th&gt;
&lt;th&gt;1650 Ti E4B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Q8_0&lt;/td&gt;
&lt;td&gt;1.39x&lt;/td&gt;
&lt;td&gt;1.09x&lt;/td&gt;
&lt;td&gt;1.72x&lt;/td&gt;
&lt;td&gt;1.40x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;exact Q4_0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.40x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.11x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.72x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.42x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bf16&lt;/td&gt;
&lt;td&gt;1.23x&lt;/td&gt;
&lt;td&gt;0.96x&lt;/td&gt;
&lt;td&gt;1.59x&lt;/td&gt;
&lt;td&gt;1.30x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Memory with each drafter loaded, at &lt;code&gt;-c 8192&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MiB&lt;/th&gt;
&lt;th&gt;no drafter&lt;/th&gt;
&lt;th&gt;Q8_0&lt;/th&gt;
&lt;th&gt;exact Q4_0&lt;/th&gt;
&lt;th&gt;bf16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3050 E2B&lt;/td&gt;
&lt;td&gt;2,795&lt;/td&gt;
&lt;td&gt;2,915&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,875&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2,983&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3050 E4B&lt;/td&gt;
&lt;td&gt;4,415&lt;/td&gt;
&lt;td&gt;4,537&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,499&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4,607&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1650 Ti E2B&lt;/td&gt;
&lt;td&gt;1,485&lt;/td&gt;
&lt;td&gt;1,595&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,557&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,663&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1650 Ti E4B&lt;/td&gt;
&lt;td&gt;2,853&lt;/td&gt;
&lt;td&gt;2,965&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,927&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3,035&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The repack is level with Q8_0 on speed on both cards&lt;/strong&gt; and uses 38–40 MiB less memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bf16 is slowest everywhere.&lt;/strong&gt; On the 1650 Ti it makes E2B slower than serving without a drafter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The share of drafted tokens kept is the same for all three formats&lt;/strong&gt;, within 0.012 on both cards. The drafter's precision changes what each drafted step costs, never which tokens get kept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Greedy text depends on drafting, never on the drafter's format.&lt;/strong&gt; At a given draft length, Q8_0, the repack and bf16 give the same 18 greedy replies, on the 3050 for both models and on the 1650 Ti for E2B (the 1650 Ti's E4B text does not repeat on &lt;code&gt;f95b0d9&lt;/code&gt;, so it cannot be compared there).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Answer quality on the 3050, GSM8K (1,319 problems) and BFCL simple (400 function calls), 8 requests at a time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;no drafter&lt;/th&gt;
&lt;th&gt;Q8_0&lt;/th&gt;
&lt;th&gt;exact Q4_0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;E2B GSM8K&lt;/td&gt;
&lt;td&gt;0.8984&lt;/td&gt;
&lt;td&gt;0.8992&lt;/td&gt;
&lt;td&gt;0.9022&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2B BFCL&lt;/td&gt;
&lt;td&gt;0.9250&lt;/td&gt;
&lt;td&gt;0.9225&lt;/td&gt;
&lt;td&gt;0.9225&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E4B GSM8K&lt;/td&gt;
&lt;td&gt;0.9340&lt;/td&gt;
&lt;td&gt;0.9310&lt;/td&gt;
&lt;td&gt;0.9325&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E4B BFCL&lt;/td&gt;
&lt;td&gt;0.9100&lt;/td&gt;
&lt;td&gt;0.9150&lt;/td&gt;
&lt;td&gt;0.9150&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every drafted score is level with the same day's no-drafter run, and the repack is level with Q8_0: E2B GSM8K +0.0030 (95% range −0.0061 to +0.0129), identical BFCL answers on E2B.&lt;/p&gt;

&lt;p&gt;Google also publishes non-QAT drafters, &lt;a href="https://huggingface.co/google/gemma-4-E2B-it-assistant" rel="noopener noreferrer"&gt;google/gemma-4-E2B-it-assistant&lt;/a&gt; and &lt;a href="https://huggingface.co/google/gemma-4-E4B-it-assistant" rel="noopener noreferrer"&gt;google/gemma-4-E4B-it-assistant&lt;/a&gt;. Their weights are on no 4-bit grid, so they were converted to Q8_0. Against the QAT targets they keep fewer drafted tokens on E2B and are level or behind at every draft length on both models: E2B 1.34x against the repack's 1.40x on the 3050, and 1.16x against 1.18x on the 1650 Ti.&lt;/p&gt;

&lt;p&gt;The 1650 Ti's format run used llama.cpp &lt;code&gt;f95b0d9&lt;/code&gt;, the build its rigs serve; every other comparison here uses &lt;code&gt;77c4540ce&lt;/code&gt; on both cards.&lt;/p&gt;




&lt;h4&gt;
  
  
  Is the Repack Worth It?
&lt;/h4&gt;

&lt;p&gt;Yes, as the drafter file to ship. Against the two files a user would otherwise serve:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;exact Q4_0 repack against&lt;/th&gt;
&lt;th&gt;Q8_0 (converter)&lt;/th&gt;
&lt;th&gt;bf16 (Google's weights)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;level on both cards&lt;/td&gt;
&lt;td&gt;faster on every card and model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share of drafted tokens kept&lt;/td&gt;
&lt;td&gt;level, within 0.012&lt;/td&gt;
&lt;td&gt;level, within 0.012&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K and BFCL&lt;/td&gt;
&lt;td&gt;level&lt;/td&gt;
&lt;td&gt;not scored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File size&lt;/td&gt;
&lt;td&gt;60%&lt;/td&gt;
&lt;td&gt;35%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Card memory&lt;/td&gt;
&lt;td&gt;38–40 MiB less&lt;/td&gt;
&lt;td&gt;106–108 MiB less&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Match to Google's weights&lt;/td&gt;
&lt;td&gt;3.6x lower error&lt;/td&gt;
&lt;td&gt;97% bit-identical in bf16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;🟢 &lt;strong&gt;Against bf16 it is the clear choice.&lt;/strong&gt; It is faster everywhere, and on the 1650 Ti bf16 makes E2B slower than serving without a drafter.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;Against Q8_0 it is a free improvement&lt;/strong&gt;: smaller, closer to the trained weights, and level on speed and answers.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;The memory it saves over Q8_0 is under 1% of either card.&lt;/strong&gt; It counts only on a 4 GB card running near its limit.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;Speed stays level with Q8_0&lt;/strong&gt; because the drafter's precision leaves the kept tokens unchanged. Every format keeps the same share, so a more exact drafter guesses as well as a rounded one and gains its speed from size alone.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 3 — Serve With the Drafter
&lt;/h4&gt;

&lt;p&gt;The drafter adds four flags to the rig's normal command line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; gemma-4-E4B-it-q4_0-exact.gguf &lt;span class="nt"&gt;--host&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;--port&lt;/span&gt; 8080 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99 &lt;span class="nt"&gt;-c&lt;/span&gt; 8192 &lt;span class="nt"&gt;-ctk&lt;/span&gt; f16 &lt;span class="nt"&gt;-ctv&lt;/span&gt; f16 &lt;span class="nt"&gt;-fa&lt;/span&gt; 1 &lt;span class="nt"&gt;--reasoning&lt;/span&gt; off &lt;span class="nt"&gt;--metrics&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-md&lt;/span&gt; gemma-4-E4B-it-qat-assistant-q4_0-exact.gguf &lt;span class="nt"&gt;--spec-type&lt;/span&gt; draft-mtp &lt;span class="nt"&gt;-ngld&lt;/span&gt; all &lt;span class="nt"&gt;--spec-draft-n-max&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--spec-draft-n-max&lt;/code&gt; is the number of tokens drafted per step. Each rig's &lt;code&gt;make serve&lt;/code&gt; builds this line from &lt;code&gt;tpu.env&lt;/code&gt;, and an empty &lt;code&gt;SPEC_DRAFT_MODEL&lt;/code&gt; serves without the drafter. The drafter costs 72–84 MiB of memory on either card.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Measure One Request at a Time
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;speed.py&lt;/code&gt; runs six prompts (code, advice, a story, an explanation, a list, a summary), 256 tokens each, three greedy repeats plus one sampled pass at temperature 0.8, with the prompt cache off. Each draft length gets its own server, the card cools before each one, and the first drafted setting runs again at the end to check for drift. The same script, prompts and llama.cpp build ran on both cards.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 speed.py &amp;lt;rig&amp;gt; &amp;lt;out&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  The Result
&lt;/h4&gt;

&lt;p&gt;Greedy, geometric mean over the six prompts. "Kept" is the share of drafted tokens the main model accepted.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;3050 tok/s&lt;/th&gt;
&lt;th&gt;3050 gain&lt;/th&gt;
&lt;th&gt;3050 kept&lt;/th&gt;
&lt;th&gt;1650 Ti tok/s&lt;/th&gt;
&lt;th&gt;1650 Ti gain&lt;/th&gt;
&lt;th&gt;1650 Ti kept&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;E2B, no drafter&lt;/td&gt;
&lt;td&gt;108.2&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;76.5&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2B, 1 token&lt;/td&gt;
&lt;td&gt;148.9&lt;/td&gt;
&lt;td&gt;1.38x&lt;/td&gt;
&lt;td&gt;0.609&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90.3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.18x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.602&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2B, 2 tokens&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;151.2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.40x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.475&lt;/td&gt;
&lt;td&gt;83.6&lt;/td&gt;
&lt;td&gt;1.09x&lt;/td&gt;
&lt;td&gt;0.469&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2B, 3 tokens&lt;/td&gt;
&lt;td&gt;143.0&lt;/td&gt;
&lt;td&gt;1.32x&lt;/td&gt;
&lt;td&gt;0.384&lt;/td&gt;
&lt;td&gt;77.3&lt;/td&gt;
&lt;td&gt;1.01x&lt;/td&gt;
&lt;td&gt;0.378&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E4B, no drafter&lt;/td&gt;
&lt;td&gt;58.3&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;38.0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E4B, 1 token&lt;/td&gt;
&lt;td&gt;89.8&lt;/td&gt;
&lt;td&gt;1.54x&lt;/td&gt;
&lt;td&gt;0.624&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.49x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.635&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E4B, 2 tokens&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.72x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.481&lt;/td&gt;
&lt;td&gt;54.5&lt;/td&gt;
&lt;td&gt;1.44x&lt;/td&gt;
&lt;td&gt;0.506&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E4B, 3 tokens&lt;/td&gt;
&lt;td&gt;99.3&lt;/td&gt;
&lt;td&gt;1.70x&lt;/td&gt;
&lt;td&gt;0.398&lt;/td&gt;
&lt;td&gt;50.9&lt;/td&gt;
&lt;td&gt;1.34x&lt;/td&gt;
&lt;td&gt;0.414&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The share of tokens kept matches across the cards at every length. What differs is how much each extra drafted token costs: on the 3050 E4B gains from 1 token to 2 and barely loses at 3, while on the 1650 Ti every extra token makes it slower. The likely reason is that each drafted step costs a larger share of a main-model step on the older card (inferred, not measured).&lt;/p&gt;

&lt;p&gt;The repeated setting landed within 0.3% (3050) and 2.0% (1650 Ti) of its first run.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Pick the Draft Length Per Card
&lt;/h4&gt;

&lt;p&gt;The best draft length is a property of the card as much as the model. The 3050 rigs serve 2 tokens for both models. On the 1650 Ti, 1 token leads for both models on this build, and E2B at 3 tokens is no faster than serving without a drafter. A setting copied from a faster card loses most of the gain on a slower one.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: The Same Pattern on a Tesla T4
&lt;/h4&gt;

&lt;p&gt;A Tesla T4 (Turing, 15,360 MiB) on Google Cloud shows the same answer under a different engine. With vLLM 0.29, the W4A16 builds of E2B and E4B and Google's bf16 drafters, the same six prompts one request at a time speed up E2B 1.29x and E4B 1.57x at 3 drafted tokens, and 1.31x and 1.62x at 4. There the gain keeps rising to 4 drafted tokens, against 1 on the 1650 Ti, another case of the draft length belonging to the card. vLLM cannot load the GGUF repack, and engine, target files and drafter format all differ, so the T4 numbers stay out of the tables here (&lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/gpu-vllm-t4-2b-w4a16/benchmarks/runs/2026-10-15-drafter-sweep-t4/REPORT.md" rel="noopener noreferrer"&gt;T4 drafter run&lt;/a&gt;).&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Measure With Long Text Ahead
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;longctx.py&lt;/code&gt; puts the first 0 to 120,000 tokens of the NVIDIA driver README ahead of a story, a code request or a summary of the text, and measures generation speed alone. Same document, by hash, on both cards. The 1650 Ti's E4B stops at 32,768 tokens, the most that fits in 4 GiB with a drafter loaded.&lt;/p&gt;

&lt;p&gt;Gain at 1 drafted token, mean over the three requests:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tokens ahead&lt;/th&gt;
&lt;th&gt;3050 E2B&lt;/th&gt;
&lt;th&gt;1650 Ti E2B&lt;/th&gt;
&lt;th&gt;3050 E4B&lt;/th&gt;
&lt;th&gt;1650 Ti E4B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1.41x&lt;/td&gt;
&lt;td&gt;1.27x&lt;/td&gt;
&lt;td&gt;1.61x&lt;/td&gt;
&lt;td&gt;1.52x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8,192&lt;/td&gt;
&lt;td&gt;1.38x&lt;/td&gt;
&lt;td&gt;1.18x&lt;/td&gt;
&lt;td&gt;1.55x&lt;/td&gt;
&lt;td&gt;1.53x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32,768&lt;/td&gt;
&lt;td&gt;1.33x&lt;/td&gt;
&lt;td&gt;1.03x&lt;/td&gt;
&lt;td&gt;1.48x&lt;/td&gt;
&lt;td&gt;1.45x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;65,536&lt;/td&gt;
&lt;td&gt;1.27x&lt;/td&gt;
&lt;td&gt;0.97x&lt;/td&gt;
&lt;td&gt;1.46x&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;120,000&lt;/td&gt;
&lt;td&gt;1.23x&lt;/td&gt;
&lt;td&gt;0.89x&lt;/td&gt;
&lt;td&gt;1.41x&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the 3050 the drafter pays at every depth for both models; at 2 tokens it does better still, E4B 1.86x with nothing ahead and 1.55x at 120k. On the 1650 Ti E2B's gain is gone by 32k tokens and turns into a loss beyond. The share of tokens kept holds steady as the text grows on both cards, so the drop comes from each checking step getting dearer with a long context.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — Measure Several Requests at Once
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;sweep.py&lt;/code&gt; sends 1 to 16 requests at once, each with a unique prompt, in two shapes: 512 tokens in and 128 out, and 128 in and 512 out. Both cards ran it with one shared KV cache (&lt;code&gt;-kvu&lt;/code&gt;); the 3050 at &lt;code&gt;-c 65536 --parallel 64&lt;/code&gt;, the 1650 Ti at &lt;code&gt;-c 16384 --parallel 16&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Total output with the drafter divided by without:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;requests at once&lt;/th&gt;
&lt;th&gt;1&lt;/th&gt;
&lt;th&gt;2&lt;/th&gt;
&lt;th&gt;4&lt;/th&gt;
&lt;th&gt;8&lt;/th&gt;
&lt;th&gt;16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3050 E2B, 512 / 128&lt;/td&gt;
&lt;td&gt;1.56&lt;/td&gt;
&lt;td&gt;1.17&lt;/td&gt;
&lt;td&gt;1.28&lt;/td&gt;
&lt;td&gt;1.28&lt;/td&gt;
&lt;td&gt;1.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1650 Ti E2B, 512 / 128&lt;/td&gt;
&lt;td&gt;1.15&lt;/td&gt;
&lt;td&gt;1.04&lt;/td&gt;
&lt;td&gt;1.03&lt;/td&gt;
&lt;td&gt;0.95&lt;/td&gt;
&lt;td&gt;0.97&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3050 E2B, 128 / 512&lt;/td&gt;
&lt;td&gt;1.79&lt;/td&gt;
&lt;td&gt;1.28&lt;/td&gt;
&lt;td&gt;1.43&lt;/td&gt;
&lt;td&gt;1.54&lt;/td&gt;
&lt;td&gt;1.18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1650 Ti E2B, 128 / 512&lt;/td&gt;
&lt;td&gt;1.33&lt;/td&gt;
&lt;td&gt;1.12&lt;/td&gt;
&lt;td&gt;1.09&lt;/td&gt;
&lt;td&gt;0.91&lt;/td&gt;
&lt;td&gt;0.96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3050 E4B, 512 / 128&lt;/td&gt;
&lt;td&gt;1.86&lt;/td&gt;
&lt;td&gt;1.38&lt;/td&gt;
&lt;td&gt;1.32&lt;/td&gt;
&lt;td&gt;1.38&lt;/td&gt;
&lt;td&gt;1.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1650 Ti E4B, 512 / 128&lt;/td&gt;
&lt;td&gt;1.28&lt;/td&gt;
&lt;td&gt;1.07&lt;/td&gt;
&lt;td&gt;0.90&lt;/td&gt;
&lt;td&gt;0.93&lt;/td&gt;
&lt;td&gt;0.92&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3050 E4B, 128 / 512&lt;/td&gt;
&lt;td&gt;2.05&lt;/td&gt;
&lt;td&gt;1.44&lt;/td&gt;
&lt;td&gt;1.41&lt;/td&gt;
&lt;td&gt;1.60&lt;/td&gt;
&lt;td&gt;1.26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1650 Ti E4B, 128 / 512&lt;/td&gt;
&lt;td&gt;1.65&lt;/td&gt;
&lt;td&gt;1.18&lt;/td&gt;
&lt;td&gt;0.79&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;sweep.py&lt;/code&gt;'s prompt repeats one paragraph, which the drafter guesses unusually well, so these ratios run higher than on everyday chat. On mixed chat prompts the 3050 measured E4B 1.08–1.24x and E2B 0.92–1.17x at 2–8 requests, and a loss at 32–64 for both. Read the table for the trend: the 3050 keeps a gain through 16 requests here, and the 1650 Ti loses from 4 or 8.&lt;/p&gt;

&lt;p&gt;With long prompts the drafter pays further. &lt;code&gt;longmixed.py&lt;/code&gt; puts a different 1,000–3,500-token stretch of the driver README ahead of each of six tasks (a summary, a bash script, a quiz and so on), 256 tokens out, greedy, at 1 to 32 requests on the 3050 (&lt;code&gt;-c 131072 --parallel 32&lt;/code&gt;), in two passes run in opposite order. The drafter keeps 0.56–0.62 of its tokens with a document in context, against 0.43–0.50 on short chat. Total output with the drafter divided by without, both passes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;requests at once&lt;/th&gt;
&lt;th&gt;1&lt;/th&gt;
&lt;th&gt;2–16&lt;/th&gt;
&lt;th&gt;32&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3050 E2B, per-slot cache&lt;/td&gt;
&lt;td&gt;1.34&lt;/td&gt;
&lt;td&gt;1.02–1.14&lt;/td&gt;
&lt;td&gt;1.02–1.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3050 E2B, shared cache&lt;/td&gt;
&lt;td&gt;1.26&lt;/td&gt;
&lt;td&gt;1.04–1.13&lt;/td&gt;
&lt;td&gt;0.96–0.97&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3050 E4B, per-slot cache&lt;/td&gt;
&lt;td&gt;1.54&lt;/td&gt;
&lt;td&gt;1.08–1.16&lt;/td&gt;
&lt;td&gt;1.03–1.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3050 E4B, shared cache&lt;/td&gt;
&lt;td&gt;1.61&lt;/td&gt;
&lt;td&gt;1.08–1.18&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The drafter pays at every level up to 16 requests and breaks even at 32. This run covers the 3050 only.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: The Shared KV Cache Has a Price on Both Cards
&lt;/h4&gt;

&lt;p&gt;llama-server gives each slot its own KV cache by default. On llama.cpp &lt;code&gt;77c4540ce&lt;/code&gt; that layout halved generation speed at random with several requests in flight, on both cards: per-slot repeats spread 28–36% at 2 requests on the 1650 Ti. &lt;code&gt;-kvu&lt;/code&gt; gives all slots one shared cache and the spread drops to about 1%. It also costs output as the total text held grows. At 16 requests in &lt;code&gt;sweep.py&lt;/code&gt; the shared cache delivered 0.86–0.94x of per-slot output on the 3050 and 0.59–0.81x on the 1650 Ti. The drafter's ratio is nearly the same under either layout. With 1,000–3,500-token prompts on the 3050 the per-slot cache is the faster layout and showed no random slowdown: it beat the shared cache in 204 of 232 paired batches, and the shared cache gave 0.68–0.82x of its output at 16 requests and 0.49–0.66x at 32. All four rigs serve one request at a time, where the two layouts are identical.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — Check the Answers
&lt;/h4&gt;

&lt;p&gt;GSM8K (1,319 problems) and BFCL simple (400 function calls), one request at a time, with and without the drafter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;3050 none&lt;/th&gt;
&lt;th&gt;3050 drafter&lt;/th&gt;
&lt;th&gt;1650 Ti none&lt;/th&gt;
&lt;th&gt;1650 Ti drafter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;E2B GSM8K&lt;/td&gt;
&lt;td&gt;0.9014&lt;/td&gt;
&lt;td&gt;0.8969&lt;/td&gt;
&lt;td&gt;0.8939&lt;/td&gt;
&lt;td&gt;0.8969&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2B BFCL&lt;/td&gt;
&lt;td&gt;0.9225&lt;/td&gt;
&lt;td&gt;0.9250&lt;/td&gt;
&lt;td&gt;0.9250&lt;/td&gt;
&lt;td&gt;0.9225&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E4B GSM8K&lt;/td&gt;
&lt;td&gt;0.9310&lt;/td&gt;
&lt;td&gt;0.9295&lt;/td&gt;
&lt;td&gt;0.9363&lt;/td&gt;
&lt;td&gt;0.9310&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E4B BFCL&lt;/td&gt;
&lt;td&gt;0.9125&lt;/td&gt;
&lt;td&gt;0.9125&lt;/td&gt;
&lt;td&gt;0.9125&lt;/td&gt;
&lt;td&gt;0.9125&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every difference has a 95% range that includes zero. The drafter finishes GSM8K 1.76x (E2B) and 2.23x (E4B) faster on the 3050 and 1.34x and 1.78x faster on the 1650 Ti. The 3050 ran at &lt;code&gt;-c 131072&lt;/code&gt; with 2 drafted tokens for both models; the 1650 Ti at &lt;code&gt;-c 8192&lt;/code&gt; with each rig's setting, 1 token for E2B and 2 for E4B.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Greedy Text Can Change With the Drafter
&lt;/h4&gt;

&lt;p&gt;At temperature 0 a drafted reply can differ from the undrafted one at a near-tie word, because checking several tokens at once runs different GPU kernels. On both cards E2B's drafted text differs from its undrafted text on every prompt at 2 tokens. E4B at 1 token matches on all 18 short replies on both cards. On the 3050 E4B also matches at 2 tokens for short prompts and starts to differ from about 2,000 tokens of prompt. The prompt cache changes greedy text by itself as well. A test that needs identical text must serve without the drafter and send &lt;code&gt;cache_prompt: false&lt;/code&gt;. The scores above show that the changed words do not change the answers.&lt;/p&gt;




&lt;h4&gt;
  
  
  Compare and Contrast
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;🥇 RTX 3050 OEM&lt;/th&gt;
&lt;th&gt;🥈 GTX 1650 Ti Max-Q&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Best gain, E2B, one request&lt;/td&gt;
&lt;td&gt;1.40x at 2 tokens&lt;/td&gt;
&lt;td&gt;1.18x at 1 token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best gain, E4B, one request&lt;/td&gt;
&lt;td&gt;1.72x at 2 tokens&lt;/td&gt;
&lt;td&gt;1.49x at 1 token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastest drafted E4B&lt;/td&gt;
&lt;td&gt;100.0 tok/s&lt;/td&gt;
&lt;td&gt;56.5 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share of tokens kept, 1 token&lt;/td&gt;
&lt;td&gt;0.61–0.62&lt;/td&gt;
&lt;td&gt;0.60–0.64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2B gain with 120k tokens ahead&lt;/td&gt;
&lt;td&gt;1.23x&lt;/td&gt;
&lt;td&gt;0.89x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gain at 8 requests at once (&lt;code&gt;sweep.py&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;1.28–1.60x&lt;/td&gt;
&lt;td&gt;0.83–0.95x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer quality&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K faster with the drafter&lt;/td&gt;
&lt;td&gt;1.76x / 2.23x&lt;/td&gt;
&lt;td&gt;1.34x / 1.78x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  So, Which One?
&lt;/h4&gt;

&lt;p&gt;Drafters work on small models. Turn the drafter on for both cards when they serve one user. It costs no answers and 72–84 MiB of memory, and E4B gains the most on both: 1.72x on the 3050 and 1.49x on the 1650 Ti. Set the draft length per card, 2 tokens on the 3050 and 1 on the 1650 Ti.&lt;/p&gt;

&lt;p&gt;On the 1650 Ti, leave E2B's drafter off for long conversations past about 32k tokens and for several users at once. On the 3050, the drafter keeps paying with long text ahead, and with long prompts it pays up to 16 users at once and breaks even at 32.&lt;/p&gt;




&lt;h4&gt;
  
  
  What Comes Next
&lt;/h4&gt;

&lt;p&gt;Google publishes the same two drafters, QAT and non-QAT, for the larger Gemma 4 models: &lt;a href="https://huggingface.co/google/gemma-4-12B-it-qat-q4_0-unquantized-assistant" rel="noopener noreferrer"&gt;12B&lt;/a&gt;, &lt;a href="https://huggingface.co/google/gemma-4-26B-A4B-it-qat-q4_0-unquantized-assistant" rel="noopener noreferrer"&gt;26B-A4B&lt;/a&gt; and &lt;a href="https://huggingface.co/google/gemma-4-31B-it-qat-q4_0-unquantized-assistant" rel="noopener noreferrer"&gt;31B&lt;/a&gt;. None of them has been measured here yet, and whether they fit beside a drafter on these two cards is unchecked. The next measurements are the larger models with their drafters, and the NVIDIA L4 (Ada, compute capability 8.9, 24 GB), a generation newer than the 3050.&lt;/p&gt;




&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to find out whether Google's Gemma 4 assistant drafters speed up small models on small NVIDIA cards served by llama.cpp. They do. The key to the solution was running the same llama.cpp build, scripts, prompts and drafter files on both cards, one setting per server, and checking answer quality alongside speed. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 One request at a time the drafter speeds up E4B 1.72x on the RTX 3050 and 1.49x on the GTX 1650 Ti, and E2B 1.40x and 1.18x&lt;/li&gt;
&lt;li&gt;🟢 Both cards keep the same share of drafted tokens, within 0.03 at every draft length&lt;/li&gt;
&lt;li&gt;🟢 GSM8K and BFCL scores are unchanged with the drafter on both cards&lt;/li&gt;
&lt;li&gt;🟢 The exact Q4_0 repack of the drafter is 60% of the Q8_0 file and level with it on speed on both cards; Google's bf16 weights are slowest on both&lt;/li&gt;
&lt;li&gt;⚠️ The best draft length differs: 2 tokens on the 3050, 1 on the 1650 Ti&lt;/li&gt;
&lt;li&gt;⚠️ With long text ahead, E2B's gain on the 1650 Ti is gone by 32k tokens; on the 3050 it holds at 1.23x at 120k&lt;/li&gt;
&lt;li&gt;🟢 With 1,000–3,500-token prompts on the 3050 the drafter pays at every level up to 16 requests at once&lt;/li&gt;
&lt;li&gt;❌ With 4–8 or more requests at once the drafter costs output on the 1650 Ti&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: Gemma 4 E2B and E4B only, MTP speculative decoding only, two cards on two hosts, llama.cpp &lt;code&gt;77c4540ce&lt;/code&gt; on both, the exact Q4_0 E2B and E4B GGUFs with Google's QAT assistant drafters as exact Q4_0 (plus Q8_0 and bf16 for the format comparison), six short prompts for speed, one 120k-token document for long text, &lt;code&gt;sweep.py&lt;/code&gt; at 1–16 requests with a shared KV cache, six long-prompt tasks at 1–32 requests on the 3050 with both cache layouts, GSM8K and BFCL simple one request at a time, each setting run once with a repeat check. The T4 tip comes from a separate vLLM run.&lt;/p&gt;

&lt;p&gt;The strategy for using MCP for local accelerator comparison was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Part 1, Gemma 4's MTP Drafter, Explained: &lt;a href="https://dev.to/gde/gemma-4s-mtp-drafter-explained-speculative-decoding-and-a-4-bit-drafter-at-60-of-the-size-1oc0"&gt;https://dev.to/gde/gemma-4s-mtp-drafter-explained-speculative-decoding-and-a-4-bit-drafter-at-60-of-the-size-1oc0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/xbill9/gemma4-dev" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;RTX 3050 rigs: &lt;a href="https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-2b-q4_0" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-2b-q4_0&lt;/a&gt; and &lt;a href="https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-4b-q4_0" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-4b-q4_0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GTX 1650 Ti rigs: &lt;a href="https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-1650ti-2b-q4_0" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-1650ti-2b-q4_0&lt;/a&gt; and &lt;a href="https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-1650ti-4b-q4_0" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-1650ti-4b-q4_0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Drafter repack, 3050: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-2b-q4_0/benchmarks/runs/2026-10-14-drafter-q4exact-3050oem/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-2b-q4_0/benchmarks/runs/2026-10-14-drafter-q4exact-3050oem/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Drafter formats, 1650 Ti: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-1650ti-2b-q4_0/benchmarks/runs/2026-10-14-drafter-formats-1650ti/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-1650ti-2b-q4_0/benchmarks/runs/2026-10-14-drafter-formats-1650ti/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;3050 draft length and long text: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-2b-q4_0/benchmarks/runs/2026-10-14-drafter-gaps-3050oem/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-2b-q4_0/benchmarks/runs/2026-10-14-drafter-gaps-3050oem/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;1650 Ti draft length and long text: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-1650ti-2b-q4_0/benchmarks/runs/2026-10-14-drafter-gaps-1650ti/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-1650ti-2b-q4_0/benchmarks/runs/2026-10-14-drafter-gaps-1650ti/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;3050 several requests at once: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-2b-q4_0/benchmarks/runs/2026-10-15-concurrency-drafter-kvu-3050oem/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-2b-q4_0/benchmarks/runs/2026-10-15-concurrency-drafter-kvu-3050oem/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;3050 several requests at once, long prompts: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-2b-q4_0/benchmarks/runs/2026-10-15-concurrency-longmixed-3050oem/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-2b-q4_0/benchmarks/runs/2026-10-15-concurrency-longmixed-3050oem/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;1650 Ti several requests at once and answers: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-1650ti-2b-q4_0/benchmarks/runs/2026-10-15-drafter-load-1650ti/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-1650ti-2b-q4_0/benchmarks/runs/2026-10-15-drafter-load-1650ti/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tesla T4 drafters under vLLM: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/gpu-vllm-t4-2b-w4a16/benchmarks/runs/2026-10-15-drafter-sweep-t4/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/gpu-vllm-t4-2b-w4a16/benchmarks/runs/2026-10-15-drafter-sweep-t4/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;3050 answers: &lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-2b-q4_0/benchmarks/runs/2026-10-14-accuracy-fullctx-3050oem/REPORT.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/blob/main/local-llamacpp-3050oem-2b-q4_0/benchmarks/runs/2026-10-14-accuracy-fullctx-3050oem/REPORT.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Drafters: &lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-assistant-q4_0-exact-gguf" rel="noopener noreferrer"&gt;https://huggingface.co/xbill9/gemma-4-E2B-it-qat-assistant-q4_0-exact-gguf&lt;/a&gt; and &lt;a href="https://huggingface.co/xbill9/gemma-4-E4B-it-qat-assistant-q4_0-exact-gguf" rel="noopener noreferrer"&gt;https://huggingface.co/xbill9/gemma-4-E4B-it-qat-assistant-q4_0-exact-gguf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;llama.cpp: &lt;a href="https://github.com/ggml-org/llama.cpp" rel="noopener noreferrer"&gt;https://github.com/ggml-org/llama.cpp&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gemma</category>
      <category>llamacpp</category>
      <category>cuda</category>
      <category>benchmarking</category>
    </item>
    <item>
      <title>Renting Out One T4 to Three Customers: Gemma 4, vLLM Multi-LoRA and a Rust Gateway</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Fri, 16 Oct 2026 16:48:42 +0000</pubDate>
      <link>https://dev.to/gde/renting-out-one-t4-to-three-customers-gemma-4-vllm-multi-lora-and-a-rust-gateway-3322</link>
      <guid>https://dev.to/gde/renting-out-one-t4-to-three-customers-gemma-4-vllm-multi-lora-and-a-rust-gateway-3322</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This article provides a step by step guide to renting out one NVIDIA T4 on a Google Cloud VM to several small customers at once, with Gemma 4 E2B served by vLLM and a small Rust gateway in front. Every log, script and number is committed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/t4-timeshare" rel="noopener noreferrer"&gt;https://github.com/xbill9/t4-timeshare&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One customer sending 4,000-token prompts pushes everyone else's first token from 0.28 s to 29.90 s on a shared T4. vLLM's priority scheduling brings that back to 6.52 s. The gateway adds what turns a shared GPU into a business: keys, a fine-tune per customer that no one else can call, a prefix cache per customer, and a ledger that matched vLLM's own token counters exactly. Billed by GPU time, the long-prompt customer pays 27.2% of the VM for 22.0% of the tokens.&lt;/strong&gt;&lt;/p&gt;




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

&lt;p&gt;A small AI job costs too much either way. A GPU of your own sits idle most of the day. A per-token API bill grows with every customer you add, and every prompt leaves your building.&lt;/p&gt;

&lt;p&gt;The side hustle is the middle: one person rents one T4, and sells shares of it to a few small businesses at a flat monthly price. Each customer gets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an API key that works with any OpenAI client&lt;/li&gt;
&lt;li&gt;its own fine-tuned adapter on the shared base model, callable by that customer only&lt;/li&gt;
&lt;li&gt;a prefix cache that other customers cannot read through response timing&lt;/li&gt;
&lt;li&gt;a bill computed from metered tokens, weighted by what each token costs the GPU&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The operator gets one config file and a ledger.&lt;/p&gt;

&lt;p&gt;The three demo tenants are picked so each one loads the T4 differently:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tenant&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;Summarise long daily logs&lt;/td&gt;
&lt;td&gt;Few requests, 4,096-token prompts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;Triage short support emails&lt;/td&gt;
&lt;td&gt;One 512-token request every 2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Tag product rows, with its own adapter&lt;/td&gt;
&lt;td&gt;One 512-token request every 2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;The gateway listens on port 8080 and speaks the OpenAI chat API. &lt;code&gt;day3/demo.sh&lt;/code&gt;, run with &lt;code&gt;bash -v&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -s localhost:8080/v1/chat/completions -H "Authorization: Bearer demo-key-B" \
  -d '{"messages":[{"role":"user","content":"Label this email: my parcel arrived broken"}],"max_tokens":20}' \
  | jq -c '{model, usage: .usage.total_tokens}'
{"model":"xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4","usage":37}
curl -s localhost:8080/v1/chat/completions -H "Authorization: Bearer demo-key-C" \
  -d '{"messages":[{"role":"user","content":"Label this email: my parcel arrived broken"}],"max_tokens":20}' \
  | jq -c '{model, usage: .usage.total_tokens}'
{"model":"rand","usage":26}
curl -s localhost:8080/v1/chat/completions -H "Authorization: Bearer demo-key-B" \
  -d '{"model":"rand","messages":[{"role":"user","content":"hi"}]}' -w ' %{http_code}\n'
{"error":"adapter belongs to another tenant"} 403
curl -s localhost:8080/v1/chat/completions -d '{"messages":[{"role":"user","content":"hi"}]}' -w ' %{http_code}\n'
{"error":"missing or unknown key"} 401
tail -4 ledger.jsonl
{"completion_tokens":20,"model":"xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4","prompt_tokens":17,"queue_ms":0.0006799999999999999,"status":200,"tenant":"B","total_ms":355.14126200000004,"ts":1792165075.2638927}
{"completion_tokens":9,"model":"rand","prompt_tokens":17,"queue_ms":0.000778,"status":200,"tenant":"C","total_ms":498.805413,"ts":1792165075.7809048}
{"refused":"adapter belongs to another tenant","status":403,"tenant":"B","ts":1792165075.7979941}
{"refused":"missing or unknown key","status":401,"tenant":null,"ts":1792165075.8125255}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same request from B and C reaches two different models: B gets the base model, C gets its adapter without naming it. B asking for C's adapter is refused, and so is a request with no key. Every one of the four lands in the ledger.&lt;/p&gt;




&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/t4-timeshare" rel="noopener noreferrer"&gt;https://github.com/xbill9/t4-timeshare&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gateway/src/main.rs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The gateway, 245 lines of Rust (axum, reqwest, tokio)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gateway/tenants.toml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keys, adapters, priorities and in-flight caps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;day1/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Checks on the T4 before building: LoRA, cache salt, streamed usage, priority, prefill cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;day2/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The noisy-neighbour test straight to vLLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;day3/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The same test through the gateway, isolation checks, the bill and VM pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;h4&gt;
  
  
  At This Point You Should Have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;A Google Cloud VM with one NVIDIA T4. This one is an &lt;code&gt;n1-standard-2&lt;/code&gt; in &lt;code&gt;us-west2-b&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;vLLM 0.29.0 serving &lt;code&gt;xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4&lt;/code&gt;, Gemma 4 E2B with Google's quantization-aware 4-bit weights, fp16, at most 8 sequences at once&lt;/li&gt;
&lt;li&gt;Rust, for the gateway&lt;/li&gt;
&lt;li&gt;A clone: &lt;code&gt;git clone https://github.com/xbill9/t4-timeshare&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 1 — Check the Engine Can Do Multi-Tenant Work
&lt;/h4&gt;

&lt;p&gt;Four features decide whether one vLLM can serve several paying customers. Each was checked on the T4 before any gateway code was written (&lt;code&gt;day1/RESULTS.md&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&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;LoRA adapters on the 4-bit weights, Turing GPU&lt;/td&gt;
&lt;td&gt;🟢 A zero adapter returns the base model's output, 40 of 40 tokens. A random adapter differs from token 1. 6 of 6 requests in one mixed batch match their solo output.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-request prefix-cache salt&lt;/td&gt;
&lt;td&gt;🟢 Same salt, repeat: 1,216 of 1,217 tokens from cache. Different salt: 0.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token usage on streamed responses&lt;/td&gt;
&lt;td&gt;🟢 Present with &lt;code&gt;stream_options.include_usage&lt;/code&gt;, absent without it. The gateway sets it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Priority scheduling&lt;/td&gt;
&lt;td&gt;🟢 With &lt;code&gt;--scheduling-policy priority&lt;/code&gt;, a high-priority request queued last finished first.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 6 of 6 is what makes per-customer fine-tunes cheap: requests for different adapters share one batch on one GPU, and each still gets its own model's answer.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Measure What Hurts on a T4
&lt;/h4&gt;

&lt;p&gt;A customer's request costs the GPU in two parts: reading the prompt (prefill) and writing the answer (decode). &lt;code&gt;day1/prefill_cost.py&lt;/code&gt; times prefill alone, one request at a time, 5 per length, with the prefix cache unable to help:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prompt ~  256: median    106.9 ms  min    103.4  max    108.7
prompt ~  512: median    262.0 ms  min    258.9  max    267.9
prompt ~ 1024: median    730.8 ms  min    717.0  max    737.4
prompt ~ 2048: median   2180.8 ms  min   2149.6  max   2223.6
prompt ~ 4096: median   7122.4 ms  min   7050.4  max   7363.7
prompt ~ 8192: median  25364.0 ms  min  25261.6  max  25697.8
prefill fit over 30 requests: -12.8 ms + 374.32 us x n + 336.9989 ns x n^2; worst error 2.1%
decode: median 9.34 ms per output token over 5 pairs (256 tokens minus 1 token, same length prompt)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefill grows with the square of the prompt. One 4,096-token prompt costs as much GPU time as 767.8 output tokens. On a T4 the customer who hurts the others is the one with long prompts, more than the one with many requests.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Watch One Customer Stall the Others
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;day2/m1_direct.py&lt;/code&gt; runs the three tenants straight against vLLM. B and C each send one 512-token request every 2 seconds and stream 64 tokens back. For 180 seconds, A keeps four 4,096-token summaries in flight. Every prompt starts with a random tag, so nothing comes from cache.&lt;/p&gt;

&lt;p&gt;First come, first served, every request in the same queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[quiet] B: 90/90 ok  first token median    0.29 s  95th    0.29 s  max    0.34 s | total median    0.98 s  95th    0.99 s
[flood] A: 16/16 ok  first token median   30.82 s  95th   45.37 s  max   46.12 s | total median   50.08 s  95th   54.03 s
[flood] B: 90/90 ok  first token median   29.78 s  95th   44.47 s  max   47.81 s | total median   35.07 s  95th   48.91 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A support-email triage that answered in a third of a second now waits half a minute for its first word. B pays for A's prompts.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Put the Gateway in Front
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;gateway/src/main.rs&lt;/code&gt; does six things per request, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Key to tenant.&lt;/strong&gt; &lt;code&gt;Authorization: Bearer &amp;lt;key&amp;gt;&lt;/code&gt; looks the tenant up in &lt;code&gt;tenants.toml&lt;/code&gt;. No key or an unknown one: 401.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model check.&lt;/strong&gt; The tenant gets its own adapter by default, or the base model on request. Another tenant's adapter: 403.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Priority.&lt;/strong&gt; The tenant's priority goes into the request body, overwriting whatever the client sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache salt.&lt;/strong&gt; &lt;code&gt;cache_salt&lt;/code&gt; is set to the tenant's name, so cached prompts are shared within a tenant and never across tenants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-flight cap.&lt;/strong&gt; The request waits for one of the tenant's slots, a &lt;code&gt;tokio&lt;/code&gt; semaphore per tenant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ledger.&lt;/strong&gt; When the response ends, or the client disconnects, one JSON line is written with the tenant, the model, the status, vLLM's own prompt and output token counts, the time spent waiting for a slot and the total time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The demo tenants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[tenant]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"A"&lt;/span&gt;
&lt;span class="py"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"demo-key-A"&lt;/span&gt;
&lt;span class="py"&gt;max_inflight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;priority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;

&lt;span class="nn"&gt;[[tenant]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt;
&lt;span class="py"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"demo-key-C"&lt;/span&gt;
&lt;span class="py"&gt;adapter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"rand"&lt;/span&gt;
&lt;span class="py"&gt;max_inflight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
&lt;span class="py"&gt;priority&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A has one slot and runs last. B and C have 8 slots each, the most vLLM runs at once. C's &lt;code&gt;rand&lt;/code&gt; adapter is a random rank-8 LoRA from &lt;code&gt;day1/make_adapters.py&lt;/code&gt;, standing in for a customer's fine-tune.&lt;/p&gt;

&lt;p&gt;Streaming responses pass straight through. The gateway watches the stream for the one chunk that carries &lt;code&gt;usage&lt;/code&gt; and records it when the stream ends. A request that dies halfway is still in the ledger, and its slot is released at that moment.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Check Isolation
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 day3/isolation.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS  no key: 401
PASS  unknown key: 401
PASS  B asks for C's adapter: 403
PASS  B asks for a model that is not served: 404
PASS  B gets the base model: xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4
PASS  C gets its own adapter by default: rand
PASS  C's answer differs from the base model's: 'Here are a few examples of how a spare G' vs 'Here are three common uses for a spare G'
PASS  B's repeat is served from B's cache: 2400 hit tokens (first send 0)
PASS  A sending B's prompt gets none of B's cache: 0 hit tokens
9 of 9 pass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last two lines are the privacy check. When B sends a long prompt twice, the second send is served from cache. A sends the identical prompt and gets nothing from B's cache, so A cannot tell from response time whether B ever sent it. The hit counts come from vLLM's &lt;code&gt;/metrics&lt;/code&gt;, not from timing.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — Rerun the Noisy Neighbour Through the Gateway
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;day3/run_m1.sh
python3 day3/compare.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;run                                   B+C first token median    95th     max  B+C done  A done
quiet, no A (direct)                                  0.28 s  0.29 s  0.37 s       180       0
direct, first come first served                      29.90 s 44.49 s 47.81 s       180      16
direct, A at low priority                             6.52 s 14.71 s 16.31 s       180      16
gateway: priority + A capped at 1                     7.06 s 12.11 s 13.55 s       180       7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Priority does most of the work: B and C's median wait falls from 29.90 s to 6.52 s. Capping A at one request shortens the worst waits, from 14.71 s to 12.11 s at the 95th percentile. A pays for it, finishing 7 summaries in the window against 16.&lt;/p&gt;

&lt;p&gt;B and C still wait about 7 s, close to one 4,096-token prefill (7.1 s in Step 2). A request that arrives while A's prompt is being read waits for that read to finish.&lt;/p&gt;

&lt;p&gt;🔎 &lt;strong&gt;Tip: size the cap above what the tenant needs.&lt;/strong&gt; B sends a request every 2 seconds and each one spends about 12 seconds on the GPU under load, so B needs about 6 slots. With B capped at 4, the same run gave B a median time inside vLLM of 11.61 s and a median wait for a gateway slot of 37.77 s (&lt;code&gt;day3/cap4/&lt;/code&gt;). A cap is for the customer you want to slow down.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — Check the Ledger Against vLLM
&lt;/h4&gt;

&lt;p&gt;A bill is only as good as its meter. &lt;code&gt;day3/bill.py&lt;/code&gt; adds up the ledger and compares it with vLLM's own &lt;code&gt;prompt_tokens_total&lt;/code&gt; and &lt;code&gt;generation_tokens_total&lt;/code&gt; counters, read before and after the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ledger vs vLLM counters: prompt 122,442 vs 122,442, output 12,416 vs 12,416; match: True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every token vLLM processed in the run is attributed to a tenant, and no tenant is charged for a token vLLM did not process.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — Send the Bill
&lt;/h4&gt;

&lt;p&gt;The VM's on-demand price comes from the Cloud Billing Catalog API, fetched during the build (&lt;code&gt;day3/pricing.us-west2.txt&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T4 0.410000 + 2 vCPU x 0.037970 + 7.5 GiB x 0.005089 = 0.5241 USD/hour
x 730 hours = 382.60 USD/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bill.py&lt;/code&gt; prices each request in GPU seconds, using the prefill fit from Step 2 plus 9.34 ms per output token, and splits the month by each tenant's share. Over the gateway run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tenant   requests refused  prompt tok  output tok token share     GPU s GPU share  bill USD
A               7       0      28,718         896       22.0%      58.7     27.2%    104.22
B              90       0      46,803       5,760       39.0%      78.4     36.4%    139.11
C              90       0      46,921       5,760       39.1%      78.5     36.4%    139.26
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sent 22.0% of the tokens and used 27.2% of the GPU. A flat price per token would undercharge the long-prompt customer and pass the difference on to the other two. Weighting prompt tokens by their measured prefill cost puts it back.&lt;/p&gt;




&lt;h4&gt;
  
  
  Compare and Contrast
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Straight to vLLM&lt;/th&gt;
&lt;th&gt;vLLM with priority&lt;/th&gt;
&lt;th&gt;Through the gateway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;B and C, median first token under load&lt;/td&gt;
&lt;td&gt;29.90 s&lt;/td&gt;
&lt;td&gt;🥈 6.52 s&lt;/td&gt;
&lt;td&gt;🥇 7.06 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B and C, 95th percentile&lt;/td&gt;
&lt;td&gt;44.49 s&lt;/td&gt;
&lt;td&gt;🥈 14.71 s&lt;/td&gt;
&lt;td&gt;🥇 12.11 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keys per customer&lt;/td&gt;
&lt;td&gt;❌ no&lt;/td&gt;
&lt;td&gt;❌ no&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adapter only its owner can call&lt;/td&gt;
&lt;td&gt;❌ no&lt;/td&gt;
&lt;td&gt;❌ no&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefix cache per customer&lt;/td&gt;
&lt;td&gt;❌ no&lt;/td&gt;
&lt;td&gt;❌ no&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-customer bill, checked against vLLM&lt;/td&gt;
&lt;td&gt;❌ no&lt;/td&gt;
&lt;td&gt;❌ no&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  So, Which One?
&lt;/h4&gt;

&lt;p&gt;Turn on &lt;code&gt;--scheduling-policy priority&lt;/code&gt; for any shared vLLM: on these runs it is the single biggest change, and it costs one flag. Selling the GPU takes the gateway, because priority alone still lets any client claim any priority, call any adapter and read any cache. The gateway sets all three from the key.&lt;/p&gt;




&lt;h4&gt;
  
  
  Prior Work
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The T4 rig&lt;/strong&gt;: the VM, vLLM 0.29.0 install, Turing patch and the &lt;code&gt;vllm-t4&lt;/code&gt; script come from my earlier &lt;code&gt;gemma4-dev&lt;/code&gt; T4 serving work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/xbill9/gemma-rust" rel="noopener noreferrer"&gt;gemma-rust&lt;/a&gt;&lt;/strong&gt;: reqwest client patterns for the OpenAI-compatible endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/xbill9/dev-hacktoberfest" rel="noopener noreferrer"&gt;dev-hacktoberfest&lt;/a&gt;&lt;/strong&gt; (Week 1): every result checked by code that did not produce it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open source used&lt;/strong&gt;: &lt;a href="https://github.com/vllm-project/vllm" rel="noopener noreferrer"&gt;vLLM&lt;/a&gt; (Apache 2.0), Gemma 4 E2B (Gemma terms), &lt;a href="https://github.com/tokio-rs/axum" rel="noopener noreferrer"&gt;axum&lt;/a&gt;, &lt;a href="https://github.com/seanmonstar/reqwest" rel="noopener noreferrer"&gt;reqwest&lt;/a&gt;, &lt;a href="https://github.com/tokio-rs/tokio" rel="noopener noreferrer"&gt;tokio&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Every part of this business needs the weights.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The model runs on hardware the operator controls.&lt;/strong&gt; That is possible only with downloadable weights, and it is what lets the operator promise a customer that its emails never reach a model vendor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One base model, one small adapter per customer.&lt;/strong&gt; Per-customer fine-tunes that share one GPU and one batch need the weights and an engine that serves adapters side by side. vLLM's multi-LoRA does that on a 2018-era T4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cost is flat.&lt;/strong&gt; The operator pays 382.60 USD a month whatever the customers send. A heavy customer costs the others waiting time, which the scheduler controls, and costs the operator nothing extra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything can be checked.&lt;/strong&gt; Engine, gateway and model are open, so the cache isolation and the bill are tested against vLLM's own counters, and a customer can read the code that bills them.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gemma&lt;/strong&gt;: Gemma 4 E2B, served locally on a T4 with vLLM, with per-customer LoRA adapters on Google's quantization-aware 4-bit weights.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The goal of this project was to sell shares of one T4 to several small customers without one customer ruining the service for the others. The key to the solution was vLLM's priority scheduling, adapters and cache salt, controlled per customer by a gateway that holds the keys. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 A long-prompt customer pushed other customers' first token from 0.28 s to 29.90 s. Priority brought it to 6.52 s; the gateway's cap on that customer brought the 95th percentile from 14.71 s to 12.11 s.&lt;/li&gt;
&lt;li&gt;🟢 9 of 9 isolation checks pass: keys, adapter ownership and a prefix cache per customer.&lt;/li&gt;
&lt;li&gt;🟢 The ledger matched vLLM's token counters exactly: 122,442 prompt and 12,416 output tokens.&lt;/li&gt;
&lt;li&gt;🟢 A per-adapter customer runs in the same batch as base-model customers, at about the same speed.&lt;/li&gt;
&lt;li&gt;⚠️ B and C still wait about one long prefill, around 7 s, while A is active. Chunking long prompts further, or giving A its own hours, are the next steps.&lt;/li&gt;
&lt;li&gt;❌ A's throughput falls from 16 summaries to 7 in the window. That is the price A pays for a cheaper share.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one T4 on one Google Cloud VM, vLLM 0.29.0, one 180-second run per configuration. The two direct runs and the gateway run used separate server starts with the same flags; in the direct runs C used the base model, through the gateway it used its adapter. The token bucket, the per-customer fair queue and a tenant-count sweep from &lt;code&gt;SPEC.md&lt;/code&gt; are not built. The VM price excludes disk and network.&lt;/p&gt;

&lt;p&gt;The strategy for renting out one T4 to several customers with Gemma 4 and vLLM was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge, Week 2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/t4-timeshare" rel="noopener noreferrer"&gt;t4-timeshare on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/vllm-project/vllm" rel="noopener noreferrer"&gt;vLLM&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4" rel="noopener noreferrer"&gt;Gemma 4 E2B, 4-bit build used here&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cloud.google.com/compute/gpus-pricing" rel="noopener noreferrer"&gt;Google Cloud GPU pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>rust</category>
    </item>
    <item>
      <title>Don't Assume Your Cloud T4 Runs at Spec: Gemma 4 Decode Falls 7% as the Card Warms</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Fri, 16 Oct 2026 15:14:10 +0000</pubDate>
      <link>https://dev.to/gde/dont-assume-your-cloud-t4-runs-at-spec-gemma-4-decode-falls-7-as-the-card-warms-3fd3</link>
      <guid>https://dev.to/gde/dont-assume-your-cloud-t4-runs-at-spec-gemma-4-decode-falls-7-as-the-card-warms-3fd3</guid>
      <description>&lt;p&gt;This article provides a step by step guide to measuring how a cloud GPU's temperature changes LLM decode speed, on the NVIDIA Tesla T4 of a Google Cloud VM serving Gemma 4 E2B with vLLM. Every log, script and number is committed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/t4-thermal-drift" rel="noopener noreferrer"&gt;https://github.com/xbill9/t4-thermal-drift&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same T4, on the same VM, serving the same model the same prompt, decoded at 115.3 tokens per second at 45 °C and 106.8 at its steady 77 °C: 7.4% slower, for as long as the load lasts. The card runs at its 70 W power limit the whole time. A hotter chip draws more power at the same clock, so the limit pulls the clock down from about 1,457 MHz to about 1,267. The thermal throttle never fires.&lt;/strong&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  What is this project trying to Do?
&lt;/h4&gt;

&lt;p&gt;A rented GPU is sold by name and by the hour. A T4 is a T4, and the spec sheet gives it one number: 8.1 TFLOPS of FP32, 65 TFLOPS of FP16. Benchmarks on it are run for a minute or two and quoted as the card's speed.&lt;/p&gt;

&lt;p&gt;This project asks whether that number holds when the card is hot. It logs the card's temperature, clock, power and throttle state every 100 ms while it decodes, and lines every request up with the card's state at the moment its tokens were generated.&lt;/p&gt;

&lt;p&gt;The answer shapes three things a customer cares about: what speed a T4 delivers under sustained load, how much a short benchmark overstates it, and whether an A/B test run back to back is fair.&lt;/p&gt;




&lt;h4&gt;
  
  
  At This Point You Should Have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;A Google Cloud VM with one NVIDIA T4 and root access, so &lt;code&gt;nvidia-smi -lgc&lt;/code&gt; can lock the clock&lt;/li&gt;
&lt;li&gt;vLLM serving a model on the card. This one runs vLLM 0.29.0 with &lt;code&gt;xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4&lt;/code&gt;, Gemma 4 E2B in 4-bit weights, fp16&lt;/li&gt;
&lt;li&gt;A clone: &lt;code&gt;git clone https://github.com/xbill9/t4-thermal-drift&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  The Hardware
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name, driver_version, memory.total [MiB], power.limit [W], persistence_mode
Tesla T4, 615.71.09, 15360 MiB, 70.00 W, Enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    Max Clocks
        Graphics                                       : 1590 MHz
        SM                                             : 1590 MHz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        Current Power Limit                            : 70.00 W
        Default Power Limit                            : 70.00 W
        Max Power Limit                                : 70.00 W
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VM&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;n1-standard-2&lt;/code&gt;, &lt;code&gt;us-west2-b&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;Tesla T4, passive cooling, 70 W limit, 1,590 MHz maximum clock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;vLLM 0.29.0, one request at a time, greedy, 256 output tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The T4 has no fan. It depends on the host's airflow, which the cloud provider controls. Google runs the card at the highest power limit it allows.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Log the Card Every 100 ms
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;thermal.py&lt;/code&gt; runs &lt;code&gt;nvidia-smi&lt;/code&gt; in the background for the whole of each run, writing eight fields every 100 ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timestamp,temperature.gpu,clocks.sm,clocks.mem,power.draw,clocks_event_reasons.active,utilization.gpu,pstate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each request records the wall time of every streamed token. &lt;code&gt;analyze.py&lt;/code&gt; takes the samples that fall between a request's first and last token, so each decode rate is matched to the temperature, clock and power while it was generated.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;clocks_event_reasons.active&lt;/code&gt; is the field that says why the clock is below maximum. The two values that matter here are &lt;code&gt;0x4&lt;/code&gt;, the software power cap, and &lt;code&gt;0x20&lt;/code&gt; / &lt;code&gt;0x40&lt;/code&gt;, the software and hardware thermal slowdowns.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Run a Heat Soak From a Cool Card
&lt;/h4&gt;

&lt;p&gt;The soak waits until the card is below 50 °C, starts the server, and sends one prompt back to back, greedy, 256 tokens each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 thermal.py soak runs/2026-10-15-first-t4/soak-unlocked-5min-1 &lt;span class="nt"&gt;--minutes&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;soak-start {'temp_c': 41}
10 114.0 (54, 1515)
20 112.3 (63, 1545)
30 110.7 (68, 1470)
40 108.6 (74, 1440)
50 105.7 (79, 1500)
60 106.8 (76, 1425)
70 107.4 (76, 1455)
80 106.4 (77, 1500)
90 106.7 (77, 1500)
100 107.1 (76, 1440)
110 106.9 (76, 1305)
120 106.9 (77, 1455)
soak-end {'requests': 125, 'temp_c': 77}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every tenth request prints its decode rate, then the card's temperature and SM clock read once, after the request. The speed falls as the temperature climbs, and both settle within about a minute. A single clock reading jumps by 200 MHz from one moment to the next, so Step 3 uses the 100 ms samples taken while each request decoded.&lt;/p&gt;

&lt;p&gt;A 20-minute soak, run the day before, shows the same shape from a 57 °C start.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Read Decode Speed by Temperature
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 bands.py runs/2026-10-15-first-t4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 5-minute soak, every request grouped by the card's mean temperature while it decoded:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Card temperature&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;Decode tok/s&lt;/th&gt;
&lt;th&gt;SM clock MHz&lt;/th&gt;
&lt;th&gt;Power W&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;45–49 °C&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;115.3&lt;/td&gt;
&lt;td&gt;1,457&lt;/td&gt;
&lt;td&gt;69.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;55–59 °C&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;113.5&lt;/td&gt;
&lt;td&gt;1,404&lt;/td&gt;
&lt;td&gt;68.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;65–69 °C&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;111.1&lt;/td&gt;
&lt;td&gt;1,350&lt;/td&gt;
&lt;td&gt;68.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70–74 °C&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;109.4&lt;/td&gt;
&lt;td&gt;1,316&lt;/td&gt;
&lt;td&gt;67.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75–79 °C&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;106.9&lt;/td&gt;
&lt;td&gt;1,267&lt;/td&gt;
&lt;td&gt;66.9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 20-minute soak:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Card temperature&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;Decode tok/s&lt;/th&gt;
&lt;th&gt;SM clock MHz&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;55–59 °C&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;112.7&lt;/td&gt;
&lt;td&gt;1,390&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;65–69 °C&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;110.8&lt;/td&gt;
&lt;td&gt;1,346&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70–74 °C&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;108.0&lt;/td&gt;
&lt;td&gt;1,296&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75–79 °C&lt;/td&gt;
&lt;td&gt;462&lt;/td&gt;
&lt;td&gt;105.6&lt;/td&gt;
&lt;td&gt;1,241&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== soak-unlocked-1: requests 486, below 95 tok/s 1
after 2 min: n 437, median 105.6 tok/s, trend +0.03 tok/s per 10 min, temp 76-78 C, SM median 1241 MHz
r(decode, temperature) -0.86  r(decode, SM clock) 0.80
== soak-unlocked-5min-1: requests 125, below 95 tok/s 0
after 2 min: n 73, median 106.8 tok/s, trend +1.01 tok/s per 10 min, temp 76-80 C, SM median 1266 MHz
r(decode, temperature) -0.97  r(decode, SM clock) 0.99
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One request in the 20-minute soak ran at 54.1 tokens per second, 36 seconds in, and is left out of the bands and correlations.&lt;/p&gt;

&lt;p&gt;Cool to hot costs 7.4% in one soak and 6.3% in the other, which started warmer. Once the card holds at 76–78 °C the rate stays flat for 18 minutes, so the loss follows temperature, and time on its own adds nothing. Per request, decode tracks the SM clock with a correlation of 0.99.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Find What Pulls the Clock Down
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;== clock event reasons seen, all runs (0x1 idle, 0x4 SW power cap, 0x8 HW slowdown, 0x20 SW thermal, 0x40 HW thermal)
soak-locked1200-1                             samples  13352 reasons ['0x1', '0x4'] max temp 81 C
soak-unlocked-1                               samples  13384 reasons ['0x1', '0x4'] max temp 81 C
soak-unlocked-5min-1                          samples   3509 reasons ['0x1', '0x4'] max temp 80 C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across every run, 154,815 samples between them, the card reported two reasons only: idle, and the software power cap. The thermal slowdowns never fired, and the hottest reading was 83 °C. During the unlocked soaks the power cap was active in every temperature band, from 45 °C up.&lt;/p&gt;

&lt;p&gt;So the card is at its 70 W limit from the first request. At 45 °C those 70 W buy 1,457 MHz. At 77 °C they buy 1,267.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Measure the Leakage at a Held Clock
&lt;/h4&gt;

&lt;p&gt;Hold the clock fixed and the only thing left to change the power is the chip's temperature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 thermal.py soak runs/2026-10-15-first-t4/soak-locked1200-1 &lt;span class="nt"&gt;--lock&lt;/span&gt; 1200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;power at a held 1200 MHz, not capped (6485 samples of 100 ms):
  66 C n=   16 median 62.0 W
  68 C n=   19 median 63.4 W
  70 C n=   22 median 64.0 W
  72 C n=   26 median 64.3 W
  74 C n=   23 median 65.1 W
  76 C n=  261 median 65.7 W
  78 C n=  271 median 66.0 W
  slope 0.26 W per C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same clock, same work, 4.0 W more at 78 °C than at 66 °C. A hotter chip leaks more current. NVIDIA's TensorRT documentation names the effect and names this card: &lt;em&gt;"higher GPU temperature also leads to more leakage current in the circuits, which increases the power consumed by the GPU at a specific clock frequency. Therefore, for GPUs more likely to be power throttled like NVIDIA T4, poor cooling can lead to lower stabilized clock frequency with power throttling and, thus, worse performance."&lt;/em&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — Lock the Clock and See What It Costs
&lt;/h4&gt;

&lt;p&gt;The same soak with the SM clock locked at 1,200 MHz:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Card temperature&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;Decode tok/s&lt;/th&gt;
&lt;th&gt;SM clock MHz&lt;/th&gt;
&lt;th&gt;Power-capped&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;65–69 °C&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;104.8&lt;/td&gt;
&lt;td&gt;1,200&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70–74 °C&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;104.5&lt;/td&gt;
&lt;td&gt;1,200&lt;/td&gt;
&lt;td&gt;1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75–79 °C&lt;/td&gt;
&lt;td&gt;460&lt;/td&gt;
&lt;td&gt;104.1&lt;/td&gt;
&lt;td&gt;1,198&lt;/td&gt;
&lt;td&gt;29%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Locking shrinks the drift from 7% to 0.7%. It does so by giving up speed: 104.1 tokens per second hot and locked, against 106.8 hot and unlocked and 115.3 cool. Even at 1,200 MHz the card reaches its power limit in 29% of samples once it is hot.&lt;/p&gt;

&lt;p&gt;NVIDIA's own guidance says the same of locked clocks: &lt;em&gt;"the average performance will not be as good as when the clock is floating."&lt;/em&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — Check What a Back-to-Back A/B Test Measures
&lt;/h4&gt;

&lt;p&gt;The project began with a speculative-decoding comparison: Gemma 4 E2B alone against Gemma 4 E2B with its 4-bit drafter, three draft tokens, each on its own server, six prompts, three requests each. Run order and cooling were varied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 order.py runs/2026-10-15-first-t4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;order-cool50-nonefirst                     none&amp;gt;qat-n3  none: 111.1 tok/s n=18 56-&amp;gt;71 C  qat-n3: 144.3 tok/s n=18 56-&amp;gt;70 C  speed-up 1.298x
order-cool50-qatfirst                      qat-n3&amp;gt;none  qat-n3: 144.6 tok/s n=18 56-&amp;gt;71 C  none: 111.5 tok/s n=18 55-&amp;gt;71 C  speed-up 1.297x
order-nocool-nonefirst                     none&amp;gt;qat-n3  none: 111.3 tok/s n=18 56-&amp;gt;71 C  qat-n3: 142.3 tok/s n=18 64-&amp;gt;76 C  speed-up 1.279x
order-nocool-qatfirst                      qat-n3&amp;gt;none  qat-n3: 145.1 tok/s n=18 56-&amp;gt;65 C  none: 108.5 tok/s n=18 63-&amp;gt;77 C  speed-up 1.337x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the card cooled to 50 °C before each server, the speed-up is 1.297x or 1.298x whichever runs first. Without the cool-down, the second server starts on a warm card and runs 1.9% to 2.5% slower than the same server run first, and the same speed-up reads anywhere from 1.279x to 1.337x.&lt;/p&gt;




&lt;h4&gt;
  
  
  What Do Google and NVIDIA Promise?
&lt;/h4&gt;

&lt;p&gt;NVIDIA's T4 datasheet lists, in its specifications table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Single-Precision       8.1 TFLOPS
Mixed-Precision        65 TFLOPS
(FP16/FP32)
INT8                   130 TOPS
Thermal Solution       Passive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No clock, no condition and no footnote is attached. Google's Compute Engine GPU page repeats 65, 130 and 260 TOPS for the T4, also without conditions. The Compute Engine SLA defines downtime as &lt;em&gt;"loss of external connectivity or persistent disk access"&lt;/em&gt;: it covers uptime and says nothing on speed.&lt;/p&gt;

&lt;p&gt;The published figure is a maximum-clock figure. FP32 throughput is 2,560 CUDA cores times two operations per clock times the clock, so 8.1 TFLOPS needs 1,582 MHz, the card's 1,590 MHz maximum. The same arithmetic at the clocks measured here:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SM clock&lt;/th&gt;
&lt;th&gt;FP32, by arithmetic&lt;/th&gt;
&lt;th&gt;Share of the spec clock&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,590 MHz, the maximum&lt;/td&gt;
&lt;td&gt;8.14 TFLOPS&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,430 MHz, cool, 44 °C&lt;/td&gt;
&lt;td&gt;7.32 TFLOPS&lt;/td&gt;
&lt;td&gt;89.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,241 MHz, hot, 77 °C&lt;/td&gt;
&lt;td&gt;6.35 TFLOPS&lt;/td&gt;
&lt;td&gt;78.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,200 MHz, locked&lt;/td&gt;
&lt;td&gt;6.14 TFLOPS&lt;/td&gt;
&lt;td&gt;75.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These rows are arithmetic from the clock, not measured TFLOPS. Decode fell less than the clock did, 7.4% against 13.0%, because decoding one token also reads every weight from memory, and the memory clock holds.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Log the Clock Event Reasons With Every Benchmark
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;nvidia-smi --query-gpu=clocks_event_reasons.active,clocks.sm,temperature.gpu,power.draw -lms 100&lt;/code&gt; costs nothing and turns "it seemed slower" into a reason code. A benchmark that quotes tokens per second without the clock it ran at leaves out the variable that moved the number by 7% here.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Cool to the Same Temperature Before Every Server
&lt;/h4&gt;

&lt;p&gt;Stopping the server lets a T4 in this host fall from 71 °C to 50 °C in under four minutes. Loaded with a model and idle, the same card sat at 72–76 °C for 30 minutes and never got there. Stop the server, wait for the temperature, then start the next one.&lt;/p&gt;




&lt;h4&gt;
  
  
  Compare and Contrast
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Cool, unlocked&lt;/th&gt;
&lt;th&gt;Hot, unlocked&lt;/th&gt;
&lt;th&gt;Hot, locked 1,200 MHz&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Card temperature&lt;/td&gt;
&lt;td&gt;45–49 °C&lt;/td&gt;
&lt;td&gt;75–79 °C&lt;/td&gt;
&lt;td&gt;75–79 °C&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decode&lt;/td&gt;
&lt;td&gt;🥇 115.3 tok/s&lt;/td&gt;
&lt;td&gt;🥈 106.8 tok/s&lt;/td&gt;
&lt;td&gt;🥉 104.1 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SM clock&lt;/td&gt;
&lt;td&gt;1,457 MHz&lt;/td&gt;
&lt;td&gt;1,267 MHz&lt;/td&gt;
&lt;td&gt;1,198 MHz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How long it lasts&lt;/td&gt;
&lt;td&gt;about a minute&lt;/td&gt;
&lt;td&gt;as long as the load&lt;/td&gt;
&lt;td&gt;as long as the load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drift cool to hot&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;7.4%&lt;/td&gt;
&lt;td&gt;0.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  So, Which One?
&lt;/h4&gt;

&lt;p&gt;Don't assume. Under sustained load this T4 delivers the hot number, 106.8 tokens per second on Gemma 4 E2B, and the cool number exists for the first minute. Quote the hot number, log the clock beside it, and cool every arm of an A/B test to the same temperature before it starts. Lock the clock when consistency matters more than speed, and count what it costs: 2.5% below hot and unlocked here.&lt;/p&gt;

&lt;p&gt;The customer pays the same hourly rate for 115 or 107 tokens per second, and the cooling that decides between them belongs to the provider.&lt;/p&gt;




&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to find whether a cloud T4's temperature changes LLM decode speed, and why. The key to the solution was logging the card's clock, power, temperature and throttle reason every 100 ms and matching each request to the samples taken while it decoded. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ Decode fell 7.4%, from 115.3 to 106.8 tokens per second, as the card warmed from 45 °C to its steady 77 °C, and 6.3% in a second soak that started warmer&lt;/li&gt;
&lt;li&gt;⚠️ The card sits at its 70 W limit from the first request. At a held 1,200 MHz it drew 4.0 W more at 78 °C than at 66 °C, so the limit pulls the clock down as it heats&lt;/li&gt;
&lt;li&gt;🟢 No thermal throttle fired in 154,815 samples, and the hottest reading was 83 °C&lt;/li&gt;
&lt;li&gt;⚠️ Locking the clock at 1,200 MHz cut the drift to 0.7% and cost 2.5% against hot and unlocked&lt;/li&gt;
&lt;li&gt;❌ Without a cool-down between servers, the same speculative-decoding speed-up read 1.279x to 1.337x depending on order. With one it read 1.297x and 1.298x&lt;/li&gt;
&lt;li&gt;⚠️ Google and NVIDIA publish the T4's throughput with no condition attached, and it corresponds to the 1,590 MHz maximum clock&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one Tesla T4 on one &lt;code&gt;n1-standard-2&lt;/code&gt; VM in &lt;code&gt;us-west2-b&lt;/code&gt;, driver 615.71.09, vLLM 0.29.0, Gemma 4 E2B in 4-bit weights, one request at a time, measured 2026-10-15 and 2026-10-16. Two unlocked soaks (20 and 5 minutes) and one locked soak at 1,200 MHz; each order condition ran once. Other T4 hosts, batched serving and other models were not run. Parts of the analysis and writing were done with AI assistance (Claude); every figure comes from the committed output files.&lt;/p&gt;

&lt;p&gt;The strategy for measuring thermal drift on a cloud T4 was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Code, logs and this article's evidence: &lt;a href="https://github.com/xbill9/t4-thermal-drift" rel="noopener noreferrer"&gt;https://github.com/xbill9/t4-thermal-drift&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;NVIDIA T4 datasheet: &lt;a href="https://www.nvidia.com/content/dam/en-zz/Solutions/Data-Center/tesla-t4/t4-tensor-core-datasheet-951643.pdf" rel="noopener noreferrer"&gt;https://www.nvidia.com/content/dam/en-zz/Solutions/Data-Center/tesla-t4/t4-tensor-core-datasheet-951643.pdf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;NVIDIA TensorRT, Hardware/Software Environment for Performance Measurements: &lt;a href="https://docs.nvidia.com/deeplearning/tensorrt/10.x.x/performance/hw-sw-environment.html" rel="noopener noreferrer"&gt;https://docs.nvidia.com/deeplearning/tensorrt/10.x.x/performance/hw-sw-environment.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Compute Engine GPUs: &lt;a href="https://docs.cloud.google.com/compute/docs/gpus" rel="noopener noreferrer"&gt;https://docs.cloud.google.com/compute/docs/gpus&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Compute Engine SLA: &lt;a href="https://cloud.google.com/compute/sla" rel="noopener noreferrer"&gt;https://cloud.google.com/compute/sla&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;nvidia-smi documentation: &lt;a href="https://docs.nvidia.com/deploy/nvidia-smi/index.html" rel="noopener noreferrer"&gt;https://docs.nvidia.com/deploy/nvidia-smi/index.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nvidia</category>
      <category>googlecloud</category>
      <category>llm</category>
      <category>performance</category>
    </item>
    <item>
      <title>EmbeddingGemma 2 on Cloud Run: Minimum Specs, and When an NVIDIA L4 Pays for Itself</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Thu, 15 Oct 2026 22:50:48 +0000</pubDate>
      <link>https://dev.to/gde/embeddinggemma-2-on-cloud-run-minimum-specs-and-when-an-nvidia-l4-pays-for-itself-5c1k</link>
      <guid>https://dev.to/gde/embeddinggemma-2-on-cloud-run-minimum-specs-and-when-an-nvidia-l4-pays-for-itself-5c1k</guid>
      <description>&lt;p&gt;This article provides a step by step sizing guide for EmbeddingGemma 2 text embeddings on Google Cloud Run. It measures the smallest CPU instance the model fits in, tests an int4 repack of the weights, and prices a burst of work on a 1 vCPU service against the same burst on an NVIDIA L4 GPU.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/embedding-cloudrun" rel="noopener noreferrer"&gt;https://github.com/xbill9/embedding-cloudrun&lt;/a&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  What is this project trying to Do?
&lt;/h4&gt;

&lt;p&gt;The first article in this series deployed EmbeddingGemma 2 to Cloud Run three ways and priced each per million documents: &lt;a href="https://dev.to/gde/embeddinggemma-2-on-cloud-run-an-int8-cpu-service-against-an-nvidia-l4-15ac"&gt;https://dev.to/gde/embeddinggemma-2-on-cloud-run-an-int8-cpu-service-against-an-nvidia-l4-15ac&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;EmbeddingGemma 2 is a compact model. With the vision and audio encoders dropped and the Linears stored as int8, the text model is a 203.4 MiB file. This follow-up asks the minimum-spec question: is a basic 1 vCPU instance good enough, and when does the L4 cost less?&lt;/p&gt;

&lt;p&gt;Two numbers decide it. How much memory the CPU service needs, and how much an L4 bills for a short burst of work, idle time included.&lt;/p&gt;




&lt;h4&gt;
  
  
  The Short Answer
&lt;/h4&gt;

&lt;p&gt;A basic 1 vCPU CPU service is good enough, and it is the cheapest option for most of what an embedding service does. Add the L4 only for big batch jobs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Minimum spec:&lt;/strong&gt; 1 vCPU, 2 GiB, int8 weights. Queries take a median 133 ms, a million documents cost USD 27.67, and an idle service costs nothing. 1 GiB is too small. Documents near 512 tokens need a batch size of 8 or less, or 4 GiB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The L4 bills its idle time.&lt;/strong&gt; Each isolated burst was billed about eight minutes of L4 time, USD 0.14, even when the work took seconds. The L4 wins once a single burst passes about 5,300 documents: a million documents cost USD 1.83 on it against USD 27.67 on the CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An L4 kept warm costs USD 764 a month.&lt;/strong&gt; The CPU service embeds 27.6 million documents for the same money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The int4 repack is slower on CPU.&lt;/strong&gt; The file is 28% smaller, but queries take 1.52x as long and it needs 3 GiB, because this server runs it in float32. Retrieval quality is about the same: SciFact nDCG@10 of 0.834 against 0.836 for int8.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice: serve queries and small indexing batches from the 1 vCPU int8 service. For a job of tens of thousands of documents, deploy the L4, run the job, and let it scale to zero.&lt;/p&gt;




&lt;h4&gt;
  
  
  Where do I start?
&lt;/h4&gt;

&lt;p&gt;The strategy is an incremental step by step approach, smallest instance first.&lt;/p&gt;

&lt;p&gt;The int8 repack is measured for memory on a laptop at one thread, the same thread count Cloud Run gives it. An int4 repack is built and deployed beside it. Then the L4 is swept for throughput, cold started repeatedly, and its billed seconds are read back from Cloud Monitoring. One script does the cost arithmetic from those files.&lt;/p&gt;




&lt;h4&gt;
  
  
  At This Point You Should Have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;The repository cloned and &lt;code&gt;models/&lt;/code&gt; staged with &lt;code&gt;scripts/prepare_models.sh&lt;/code&gt;, as in the first article&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;emb-cpu-repack&lt;/code&gt; service in &lt;code&gt;us-central1&lt;/code&gt; and &lt;code&gt;emb-l4-original&lt;/code&gt; in &lt;code&gt;us-east4&lt;/code&gt;, from &lt;code&gt;scripts/deploy.sh&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;gcloud&lt;/code&gt; CLI signed in, for the Cloud Monitoring query in Step 6&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 1 — How Little Memory Does the CPU Service Need?
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/cpu_memory.py&lt;/code&gt; loads the int8 repack at one thread, embeds 32 copies of a document at a given batch size, and prints the process's peak memory. A fresh process each time, documents of 135 tokens and of 512 tokens (the model's limit):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;n &lt;span class="k"&gt;in &lt;/span&gt;short long&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do for &lt;/span&gt;b &lt;span class="k"&gt;in &lt;/span&gt;1 4 8 16 32&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;python3 scripts/cpu_memory.py &lt;span class="nv"&gt;$b&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;batch  1 doc 135 tokens: peak RSS 1382 MiB
batch  4 doc 135 tokens: peak RSS 1369 MiB
batch  8 doc 135 tokens: peak RSS 1345 MiB
batch 16 doc 135 tokens: peak RSS 1468 MiB
batch 32 doc 135 tokens: peak RSS 1871 MiB
batch  1 doc 512 tokens: peak RSS 1385 MiB
batch  4 doc 512 tokens: peak RSS 1572 MiB
batch  8 doc 512 tokens: peak RSS 1893 MiB
batch 16 doc 512 tokens: peak RSS 2356 MiB
batch 32 doc 512 tokens: peak RSS 3551 MiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loaded model and PyTorch take about 1.35 GiB before any batch, so 1 GiB is too small at any setting. The service from the first article runs at 2 GiB with a batch of 32, which holds for 135-token documents at 1,871 MiB. A batch of 32 documents at 512 tokens peaks at 3,551 MiB.&lt;/p&gt;

&lt;p&gt;So the 2 GiB service needs batches of 8 or fewer for long documents, or 4 GiB at a batch of 32.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Make Batch Size a Request Field
&lt;/h4&gt;

&lt;p&gt;The server now takes &lt;code&gt;batch_size&lt;/code&gt; per request, 1 to 256, default 32:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmbedRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&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="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;prompt_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;768&lt;/span&gt;
    &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ge&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="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# texts per forward pass
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A client sending long documents to a 2 GiB instance asks for &lt;code&gt;"batch_size": 8&lt;/code&gt; and the instance stays inside its memory limit without a redeploy. &lt;code&gt;/health&lt;/code&gt; also reports &lt;code&gt;rss_mib&lt;/code&gt; and &lt;code&gt;peak_rss_mib&lt;/code&gt;, and on a GPU the GPU's total, allocated and peak memory, so the numbers in this article can be read from a live service.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Does an int4 Repack Shrink It Further?
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;repack/repack_text_w4a16_emb4.py&lt;/code&gt; stores every Linear in the text model as int4, in groups of 32 with a bf16 scale, in the compressed-tensors &lt;code&gt;pack-quantized&lt;/code&gt; layout. Activations stay in float. The file is 146.1 MiB against the int8 repack's 203.4 MiB.&lt;/p&gt;

&lt;p&gt;Quality on SciFact (300 queries over 5,183 abstracts, 768 dimensions) with &lt;code&gt;repack/eval_scifact.py&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;weights&lt;/th&gt;
&lt;th&gt;nDCG@10&lt;/th&gt;
&lt;th&gt;recall@10&lt;/th&gt;
&lt;th&gt;top 10 shared with float32&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥇 float32 original&lt;/td&gt;
&lt;td&gt;0.854&lt;/td&gt;
&lt;td&gt;0.944&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈 int8 repack&lt;/td&gt;
&lt;td&gt;0.836&lt;/td&gt;
&lt;td&gt;0.926&lt;/td&gt;
&lt;td&gt;85.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥉 int4 repack&lt;/td&gt;
&lt;td&gt;0.834&lt;/td&gt;
&lt;td&gt;0.934&lt;/td&gt;
&lt;td&gt;80.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both repacks keep about 98% of the original's nDCG@10: 97.9% for int8 and 97.6% for int4.&lt;/p&gt;

&lt;p&gt;Deployed as &lt;code&gt;cpu-w4a16&lt;/code&gt; on the same 1 vCPU shape, with three runs of each variant alternating on the same day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/cost.py results/runs/cpu-repack-2026-10-15&lt;span class="k"&gt;*&lt;/span&gt;.json results/runs/cpu-w4a16-&lt;span class="k"&gt;*&lt;/span&gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;variant        runs          query ms            docs/s  USD / 1M queries       USD / 1M docs
cpu-repack        3     133 (132-135)     1.0 (1.0-1.1)              4.25 27.67 (26.16-28.55)
cpu-w4a16         3     201 (190-216)     0.8 (0.8-0.8)              6.74 38.09 (37.20-39.43)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server unpacks the int4 weights to float32 once at load, so every matrix multiply runs in float32, while the int8 weights run on PyTorch's int8 CPU kernels. Queries take 1.52x as long on int4 and documents cost 38% more. The int4 service also needs 3 GiB: loading plus one 32-document batch peaks at 2,127 MiB locally, against 1,856 MiB for int8.&lt;/p&gt;

&lt;p&gt;The smaller file buys a smaller image, 580 MB compressed against 636 MB, and nothing else on a CPU. The int8 repack stays the minimum spec.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — How Fast Is the L4 From a Client?
&lt;/h4&gt;

&lt;p&gt;The first article timed the L4 inside the server: 331 documents per second, median of four runs. &lt;code&gt;scripts/sweep.py&lt;/code&gt; measures what a client receives, across request sizes and concurrent requests, with the request size also used as the batch size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/sweep.py l4-original &lt;span class="nv"&gt;$L4_URL&lt;/span&gt; &lt;span class="nt"&gt;--sizes&lt;/span&gt; 32,64,128,256 &lt;span class="nt"&gt;--concurrency&lt;/span&gt; 1,2,4 &lt;span class="nt"&gt;--requests&lt;/span&gt; 8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Documents per second as the client saw them, network and JSON included:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;documents per request&lt;/th&gt;
&lt;th&gt;1 at a time&lt;/th&gt;
&lt;th&gt;2 at a time&lt;/th&gt;
&lt;th&gt;4 at a time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;81.4&lt;/td&gt;
&lt;td&gt;148.1&lt;/td&gt;
&lt;td&gt;162.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;102.6&lt;/td&gt;
&lt;td&gt;🥇 171.8&lt;/td&gt;
&lt;td&gt;167.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;111.9&lt;/td&gt;
&lt;td&gt;166.1&lt;/td&gt;
&lt;td&gt;152.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;115.9&lt;/td&gt;
&lt;td&gt;155.8&lt;/td&gt;
&lt;td&gt;151.9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The best cell is 171.8 documents per second, at 64 documents per request and two requests at a time. The server's own timer on one request at a time gives 328.3 documents per second at a batch of 32 and falls to 192.5 at 256, so smaller batches suit this GPU.&lt;/p&gt;

&lt;p&gt;Peak GPU memory reached 14,956 MiB reserved by PyTorch at four requests of 256, out of 22,563 MiB on the L4. The model itself holds 516 MiB.&lt;/p&gt;

&lt;p&gt;Every response carries 768 numbers per document as JSON. That transfer is the likely limit on the client rate; this was not tested.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Cold Starts on the L4
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/coldstart_series.sh&lt;/code&gt; waits 20 minutes with no traffic, sends one request, then a second, and repeats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/coldstart_series.sh &lt;span class="nv"&gt;$L4_URL&lt;/span&gt; 8 1200 results/coldstart-l4-series/run
python3 scripts/coldstart_summary.py results/coldstart-l4-series/run-&lt;span class="k"&gt;*&lt;/span&gt;.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;file                                   variant       first request s  model load s  the rest s  warm s
run-1.txt                              original                 38.9           6.4        32.4    0.09
run-2.txt                              original                 17.1           4.8        12.3    0.11
run-3.txt                              original                 27.4           5.9        21.5    0.11
run-4.txt                              original                 17.3           4.7        12.7    0.10
run-5.txt                              original                 27.9           6.3        21.5    0.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five samples completed. The first request took 17.1 to 38.9 seconds, of which the model load was 4.7 to 6.4. A warm request took about 0.1 seconds.&lt;/p&gt;

&lt;p&gt;The 1 vCPU services took longer: 58.4 and 83.5 seconds in the first article, and 134.8 seconds for the int8 repack and 164.9 for int4 on 2026-10-15.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: A Cold Start Series Needs a Quiet Service
&lt;/h4&gt;

&lt;p&gt;Each sample is cold only if nothing else reaches the service during the wait. A health check, a test, or a sweep in that window starts an instance, and the next sample measures a warm request. Run the series alone, and leave the wait longer than the time Cloud Run takes to scale an idle instance to zero.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — What Does One Burst Cost on the L4?
&lt;/h4&gt;

&lt;p&gt;A GPU service on Cloud Run is billed for its instance: every second from start until it scales to zero, idle seconds included. Cloud Monitoring records that time as &lt;code&gt;run.googleapis.com/container/billable_instance_time&lt;/code&gt;. &lt;code&gt;scripts/billed_time.py&lt;/code&gt; reads it per minute for one service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/billed_time.py emb-l4-original us-east4 2026-10-15T18:15:00Z 2026-10-15T20:10:00Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;minute ending (UTC)     billed s  instances
2026-10-15T18:32:00         20.8  {'active': 1.0, 'idle': 0.0}
2026-10-15T18:33:00         60.0  {'active': 1.0, 'idle': 0.0}
...
2026-10-15T18:42:00         60.0  {'active': 1.0, 'idle': 0.0}
2026-10-15T18:53:00         24.7  {'active': 1.0, 'idle': 0.0}
...
total billed instance seconds 2731.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each cold cycle in Step 4 served two health requests and was billed 265 to 621 seconds, a mean of 474. At USD 1.0465 per hour, one isolated burst costs about USD 0.14 before it embeds anything.&lt;/p&gt;

&lt;p&gt;The 1 vCPU service uses request-based billing: it is charged only while a request is in flight, and an idle instance costs nothing.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — How Big Must a Burst Be?
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/tradeoff.py&lt;/code&gt; takes the billed seconds per burst from Step 5, the client rate from Step 3 and the int8 service's cost per document from Step 2, and does the arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/tradeoff.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L4 USD/hour 1.0465; billed s per isolated burst 265-621, mean 474
  one isolated burst costs USD 0.1379 (mean), the price of 4,982 documents on the CPU
L4 best end-to-end 171.8 docs/s -&amp;gt; USD 1.69 per 1M docs while busy; CPU USD 27.67

burst of N documents (one cold L4 cycle vs CPU)
         N   CPU USD    L4 USD
       100    0.0028    0.1380
     1,000    0.0277    0.1396
    10,000    0.2767    0.1548
   100,000    2.7670    0.3071
 1,000,000   27.6700    1.8302
break-even burst size 5,307 documents

L4 kept warm all month (traffic at least every ~8 min): USD 764/month, equal to 27.6M documents/month on the CPU
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below about 5,300 documents in a burst, the 1 vCPU service is cheaper. Above it, the L4 is cheaper, and at a million documents it costs USD 1.83 against USD 27.67.&lt;/p&gt;

&lt;p&gt;An L4 that receives traffic at least every few minutes never scales to zero and costs USD 764 a month. The 1 vCPU service embeds 27.6 million documents a month for the same money.&lt;/p&gt;




&lt;h4&gt;
  
  
  Compare and Contrast
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;1 vCPU int8&lt;/th&gt;
&lt;th&gt;1 vCPU int4&lt;/th&gt;
&lt;th&gt;NVIDIA L4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weights&lt;/td&gt;
&lt;td&gt;🥈 203.4 MiB&lt;/td&gt;
&lt;td&gt;🥇 146.1 MiB&lt;/td&gt;
&lt;td&gt;🥉 float32 original, run in bf16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory needed&lt;/td&gt;
&lt;td&gt;🥇 2 GiB, or 4 GiB for long documents at batch 32&lt;/td&gt;
&lt;td&gt;🥈 3 GiB&lt;/td&gt;
&lt;td&gt;🥉 16 GiB and a GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query, median&lt;/td&gt;
&lt;td&gt;🥈 133 ms&lt;/td&gt;
&lt;td&gt;🥉 201 ms&lt;/td&gt;
&lt;td&gt;🥇 57 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USD per 1M documents, busy&lt;/td&gt;
&lt;td&gt;🥈 27.67&lt;/td&gt;
&lt;td&gt;🥉 38.09&lt;/td&gt;
&lt;td&gt;🥇 1.69 from a client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One isolated burst of 1,000 documents&lt;/td&gt;
&lt;td&gt;🥇 USD 0.03&lt;/td&gt;
&lt;td&gt;🥈 USD 0.04&lt;/td&gt;
&lt;td&gt;🥉 USD 0.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;🥈 58–135 s&lt;/td&gt;
&lt;td&gt;🥉 165 s&lt;/td&gt;
&lt;td&gt;🥇 17–39 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SciFact nDCG@10&lt;/td&gt;
&lt;td&gt;🥈 0.836&lt;/td&gt;
&lt;td&gt;🥉 0.834&lt;/td&gt;
&lt;td&gt;🥇 0.854 for the float32 weights&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  So, Which One?
&lt;/h4&gt;

&lt;p&gt;For queries and for indexing in batches under about 5,300 documents, the 1 vCPU int8 service is good enough and is the cheapest. It answers a query in a median 133 ms, costs nothing between requests and needs no GPU quota. Give it 2 GiB, with &lt;code&gt;batch_size&lt;/code&gt; at 8 for documents near 512 tokens, or 4 GiB.&lt;/p&gt;

&lt;p&gt;For a batch job of tens of thousands of documents or more, deploy the L4, run the job, and let it scale to zero. One burst bills about eight minutes of L4 time, and past 5,307 documents that is cheaper than the CPU.&lt;/p&gt;

&lt;p&gt;The int4 repack is the smallest file and the slowest CPU service here, because this server runs it in float32. A runtime with int4 kernels may change that; none was tested.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — Teardown
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/down.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It deletes the services in both regions. All of them were deployed with &lt;code&gt;--min-instances 0&lt;/code&gt;, so an idle service costs nothing while it stays deployed.&lt;/p&gt;




&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to find the minimum Cloud Run spec for EmbeddingGemma 2 and the point where an NVIDIA L4 costs less. The key to the solution was measuring the L4's billed instance time per burst from Cloud Monitoring, idle time included, and pricing a burst on each service from that. The sizing results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 The int8 repack runs on 1 vCPU and 2 GiB, at a median 133 ms per query and USD 27.67 per million documents&lt;/li&gt;
&lt;li&gt;🟢 The L4 costs USD 1.69 per million documents at its best client rate of 171.8 documents per second&lt;/li&gt;
&lt;li&gt;🟢 The L4 is cheaper for bursts above 5,307 documents; the 1 vCPU service is cheaper below&lt;/li&gt;
&lt;li&gt;🟢 L4 cold starts took 17.1 to 38.9 seconds over five samples, with the model load under 7 seconds&lt;/li&gt;
&lt;li&gt;⚠️ Each isolated L4 burst was billed 265 to 621 seconds, a mean of 474, about USD 0.14&lt;/li&gt;
&lt;li&gt;⚠️ 512-token documents at a batch of 32 need 3,551 MiB; at 2 GiB they need a batch of 8 or fewer&lt;/li&gt;
&lt;li&gt;⚠️ An L4 kept warm all month costs USD 764, the 1 vCPU price of 27.6 million documents&lt;/li&gt;
&lt;li&gt;❌ The int4 repack is 28% smaller and 1.52x slower per query on CPU, and needs 3 GiB&lt;/li&gt;
&lt;li&gt;❌ 1 GiB is too small for any batch size&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one Google Cloud project, CPU services in &lt;code&gt;us-central1&lt;/code&gt; and the L4 in &lt;code&gt;us-east4&lt;/code&gt;, Cloud Run prices from the Billing Catalog on 2026-10-10, measured 2026-10-15. Memory was measured locally on a laptop at one thread, not inside Cloud Run. The int8 and int4 services ran three benchmark runs each, alternating. The L4 sweep ran once, with eight requests per cell, from one client outside Google Cloud. The cold start series stopped after five of eight samples, each cycle a single request after 20 minutes idle; the billed time per burst comes from those five cycles. The break-even assumes each burst runs at the sweep's best client rate.&lt;/p&gt;

&lt;p&gt;The strategy for sizing EmbeddingGemma 2 on Cloud Run was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/xbill9/embedding-cloudrun" rel="noopener noreferrer"&gt;https://github.com/xbill9/embedding-cloudrun&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;First article in this series: &lt;a href="https://dev.to/gde/embeddinggemma-2-on-cloud-run-an-int8-cpu-service-against-an-nvidia-l4-15ac"&gt;https://dev.to/gde/embeddinggemma-2-on-cloud-run-an-int8-cpu-service-against-an-nvidia-l4-15ac&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;EmbeddingGemma 2 on Hugging Face: &lt;a href="https://huggingface.co/google/embeddinggemma-2" rel="noopener noreferrer"&gt;https://huggingface.co/google/embeddinggemma-2&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;int8 repack: &lt;a href="https://huggingface.co/xbill9/embeddinggemma-2-w8a8-ct-text-emb4" rel="noopener noreferrer"&gt;https://huggingface.co/xbill9/embeddinggemma-2-w8a8-ct-text-emb4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;int4 repack: &lt;a href="https://huggingface.co/xbill9/embeddinggemma-2-w4a16-ct-text-emb4" rel="noopener noreferrer"&gt;https://huggingface.co/xbill9/embeddinggemma-2-w4a16-ct-text-emb4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloud Run GPU support: &lt;a href="https://cloud.google.com/run/docs/configuring/services/gpu" rel="noopener noreferrer"&gt;https://cloud.google.com/run/docs/configuring/services/gpu&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloud Run billing settings: &lt;a href="https://cloud.google.com/run/docs/configuring/billing-settings" rel="noopener noreferrer"&gt;https://cloud.google.com/run/docs/configuring/billing-settings&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloud Run pricing: &lt;a href="https://cloud.google.com/run/pricing" rel="noopener noreferrer"&gt;https://cloud.google.com/run/pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloud Run metrics: &lt;a href="https://cloud.google.com/monitoring/api/metrics_gcp_p_z#gcp-run" rel="noopener noreferrer"&gt;https://cloud.google.com/monitoring/api/metrics_gcp_p_z#gcp-run&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>googlecloud</category>
      <category>gemma</category>
      <category>cloudrun</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Gemma 4's MTP Drafter, Explained: Speculative Decoding and a 4-Bit Drafter at 60% of the Size</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Thu, 15 Oct 2026 01:18:39 +0000</pubDate>
      <link>https://dev.to/gde/gemma-4s-mtp-drafter-explained-speculative-decoding-and-a-4-bit-drafter-at-60-of-the-size-1oc0</link>
      <guid>https://dev.to/gde/gemma-4s-mtp-drafter-explained-speculative-decoding-and-a-4-bit-drafter-at-60-of-the-size-1oc0</guid>
      <description>&lt;p&gt;This article provides a step by step introduction to speculative decoding with Gemma 4's multi-token prediction (MTP) drafter, served by llama.cpp on a local, desktop hosted GPU enabled system. It is the first of a short series: this part explains what the drafter is and why you would use it, and the next part has the measurements.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-2b-q4_0" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-2b-q4_0&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemma 4 ships a small companion model for every size, a drafter that guesses the next few tokens so the main model can check them in one pass. Google's QAT drafters are trained onto a 4-bit grid; stored that way they shrink from 97.8 MB to 59.2 MB and are published on Hugging Face.&lt;/strong&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  What is this project trying to Do?
&lt;/h4&gt;

&lt;p&gt;The rigs in this repository serve Gemma 4 E2B and E4B from local hardware: an RTX 3050 OEM in a desktop and a GTX 1650 Ti in a laptop. Both already run the exact 4-bit QAT builds of the main models. Google also publishes a drafter for each Gemma 4 model, and llama.cpp can now run it.&lt;/p&gt;

&lt;p&gt;This part covers three things: how speculative decoding works, how Gemma 4's drafter differs from the usual draft model, and the exact Q4_0 repacks of Google's QAT drafters that these rigs serve.&lt;/p&gt;




&lt;h4&gt;
  
  
  Why Is Generating One Token at a Time Slow?
&lt;/h4&gt;

&lt;p&gt;A language model writes one token per forward pass, and every pass reads every weight. For one user on a small GPU, the arithmetic is light and the memory traffic is heavy: the card spends most of each step moving weights, not computing with them.&lt;/p&gt;

&lt;p&gt;That leaves spare compute on every step. Checking several tokens in one pass reads the same weights once and does a little more arithmetic, so it takes little more time than producing one. Speculative decoding exists to use that spare capacity.&lt;/p&gt;




&lt;h4&gt;
  
  
  How Does Speculative Decoding Work?
&lt;/h4&gt;

&lt;p&gt;Each step has two parts.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Draft.&lt;/strong&gt; A small, fast model guesses the next few tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify.&lt;/strong&gt; The main model, called the target, runs one forward pass over all the guesses at once and checks each one against what it would have produced.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The target accepts guesses from the front until the first one it disagrees with. It discards the rest and adds one token of its own at that point. A step therefore always produces at least one token, and as many as the number drafted plus one when every guess is right.&lt;/p&gt;

&lt;p&gt;The output is the target's: every token either matches what the target would have chosen or is the target's own replacement. The drafter only changes how many tokens each expensive pass produces.&lt;/p&gt;

&lt;p&gt;Google's model card puts it this way:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When used in a Speculative Decoding pipeline, the draft model predicts several tokens ahead, which the target model then verifies in parallel.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h4&gt;
  
  
  What Makes Gemma 4's Drafter Different?
&lt;/h4&gt;

&lt;p&gt;A classic draft model is a smaller copy of the same family, with its own layers, its own KV cache and its own pass over the whole conversation. Gemma 4's drafter is an add-on to the target. Google calls the approach multi-token prediction and describes the drafter as a lightweight 4-layer model that uses the target model's activations and KV cache.&lt;/p&gt;

&lt;p&gt;The E2B drafter's &lt;code&gt;config.json&lt;/code&gt; on Hugging Face shows how small it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"architectures"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Gemma4AssistantForCausalLM"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"backbone_hidden_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"hidden_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"num_hidden_layers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"num_kv_shared_layers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"layer_types"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sliding_attention"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sliding_attention"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sliding_attention"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"full_attention"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"vocab_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;262144&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"tie_word_embeddings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;backbone_hidden_size: 1536&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the drafter reads the E2B target's 1536-wide hidden state (2560 for E4B)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;hidden_size: 256&lt;/code&gt;, 4 layers&lt;/td&gt;
&lt;td&gt;a tiny transformer of its own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;num_kv_shared_layers: 4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;all four layers reuse the target's KV cache; the drafter computes queries only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vocab_size: 262144&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same tokenizer as the target, so its guesses are the target's tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two consequences follow. The drafter keeps no separate cache of the conversation, so its memory cost stays its weights and a small working buffer. And it starts each guess from what the target already worked out for the current token, which is why so small a model can guess well.&lt;/p&gt;

&lt;p&gt;The checkpoint also carries a clustered shortcut for its output layer (&lt;code&gt;num_centroids: 2048&lt;/code&gt;). llama.cpp's converter leaves that out and scores the full vocabulary.&lt;/p&gt;




&lt;h4&gt;
  
  
  Why Would You Use a Drafter?
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;🟢 &lt;strong&gt;Faster answers for one user.&lt;/strong&gt; On a GPU waiting on memory, each pass that verifies two or three tokens does the work of two or three passes. Google's card claims up to 3x.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;No retraining and no change to the main model.&lt;/strong&gt; The target file, its settings and its context stay as they are; the drafter is one more file and two server flags.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;Small memory cost.&lt;/strong&gt; The drafter shares the target's KV cache, so it costs its weights and a small working buffer.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;The gain depends on the text.&lt;/strong&gt; Predictable output such as code is guessed well; free-form stories are guessed less often, and each rejected guess is wasted work.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;The gain depends on load.&lt;/strong&gt; With many users at once the GPU is already busy, and spare capacity for checking guesses shrinks. Part 2 measures where it stops paying.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;Greedy text can change at near-ties.&lt;/strong&gt; Where two words score almost the same, checking several tokens in one batched pass can tip the choice the other way; batched arithmetic rounding differently is the likely cause, untested here. The answers stay the target's; part 2 shows the scores.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Which Drafter Goes With Which Model?
&lt;/h4&gt;

&lt;p&gt;Google publishes two drafters per Gemma 4 size: &lt;code&gt;-it-assistant&lt;/code&gt; for the bf16 model and &lt;code&gt;-it-qat-q4_0-unquantized-assistant&lt;/code&gt; for the QAT builds. Google's QAT model card is explicit about pairing them:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When using multi-token prediction (speculative decoding) with an assistant model alongside a QAT target model, the assistant model must also be a QAT checkpoint with the same precision to ensure compatibility.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;These rigs serve the exact Q4_0 QAT builds of E2B and E4B, so they take the QAT drafters.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Repack the QAT Drafter Exactly
&lt;/h4&gt;

&lt;p&gt;Google ships the QAT drafter as bf16, but the values already lie on a 4-bit grid: in every weight matrix, each block of 32 holds at most 16 distinct values. That is Q4_0's own layout, so the weights can be stored at 4 bits without rounding them again.&lt;/p&gt;

&lt;p&gt;llama.cpp's converter writes the drafter as Q8_0. &lt;code&gt;gguf_exact.py&lt;/code&gt;, the same builder behind the exact main-model GGUFs, finds each block's trained step and stores the drafter as Q4_0 instead. No block was off the grid.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;E2B drafter&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Error vs Google's bf16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bf16, Google's weights&lt;/td&gt;
&lt;td&gt;170.2 MB&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q8_0, llama.cpp converter&lt;/td&gt;
&lt;td&gt;97.8 MB&lt;/td&gt;
&lt;td&gt;0.00494&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Exact Q4_0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;59.2 MB&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.00138&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The exact Q4_0 file is 39% smaller than Q8_0 and 3.6x closer to Google's weights by relative error (arithmetic from the build reports). The E4B drafter comes out the same: 59.7 MB against 98.7 MB.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Download It From Hugging Face
&lt;/h4&gt;

&lt;p&gt;Both drafters are published, with their build script and evidence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;xbill9/gemma-4-E2B-it-qat-assistant-q4_0-exact-gguf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;xbill9/gemma-4-E4B-it-qat-assistant-q4_0-exact-gguf&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the rig, &lt;code&gt;make draft&lt;/code&gt; downloads the pinned revision and checks its hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;060f5a0158f8120d25137866b90e04e21cd0d2d23de1db7a9cc3b7f00e60bd11  gemma-4-E2B-it-qat-assistant-q4_0-exact.gguf
dc965cb963e56b86fbc04db8efec83163886085e88514574650a57fa55a2ee24  gemma-4-E4B-it-qat-assistant-q4_0-exact.gguf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 3 — Serve the Target With the Drafter
&lt;/h4&gt;

&lt;p&gt;llama.cpp runs Gemma 4's drafter as its own speculative type, &lt;code&gt;draft-mtp&lt;/code&gt;. Two flags added to the usual server command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; gemma-4-E2B-it-q4_0-exact.gguf &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99 &lt;span class="nt"&gt;-fa&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-md&lt;/span&gt; gemma-4-E2B-it-qat-assistant-q4_0-exact.gguf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--spec-type&lt;/span&gt; draft-mtp &lt;span class="nt"&gt;-ngld&lt;/span&gt; all &lt;span class="nt"&gt;--spec-draft-n-max&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the drafter file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--spec-type draft-mtp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;run it as a Gemma 4 MTP drafter on the target's state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-ngld all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;put the drafter on the GPU with the target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--spec-draft-n-max 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;guess at most two tokens per step&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The server log confirms the drafter loaded and reports, per request, how many guesses the target accepted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I common_speculative_init_result: loading draft model '/home/xbill/models/gemma-4-E2B-it-qat-assistant/gemma-4-E2B-it-qat-assistant-q4_0-exact.gguf'
I slot print_timing: id  0 | task 8 | draft acceptance = 0.84656 (  160 accepted /   189 generated), mean len =  2.68
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That request was a coding prompt: 160 of 189 guesses accepted, and each target pass produced 2.68 tokens on average.&lt;/p&gt;




&lt;h4&gt;
  
  
  How Much Memory Does the Drafter Cost?
&lt;/h4&gt;

&lt;p&gt;Measured on the RTX 3050 at 8,192 tokens of context, with the E2B target alone at 2,795 MiB:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Drafter added&lt;/th&gt;
&lt;th&gt;GPU memory&lt;/th&gt;
&lt;th&gt;Cost of the drafter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;2,795 MiB&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bf16&lt;/td&gt;
&lt;td&gt;2,983 MiB&lt;/td&gt;
&lt;td&gt;+188 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q8_0&lt;/td&gt;
&lt;td&gt;2,915 MiB&lt;/td&gt;
&lt;td&gt;+120 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Exact Q4_0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;2,875 MiB&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;+80 MiB&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The exact drafter costs a third less memory than the Q8_0 conversion. With E4B the pattern holds: +84 MiB against +122. On a 4 GB laptop card every one of those megabytes is context or headroom.&lt;/p&gt;

&lt;p&gt;The smaller file also leaves the guesses unchanged: Q4_0, Q8_0 and Google's bf16 drafter had the target accept the same share of drafted tokens. Part 2 covers what that means for speed.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Where the Drafter Runs
&lt;/h4&gt;

&lt;p&gt;llama.cpp's &lt;code&gt;draft-mtp&lt;/code&gt; is the route measured here. Two others exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hugging Face Transformers&lt;/strong&gt;: pass &lt;code&gt;assistant_model=&lt;/code&gt; to &lt;code&gt;generate()&lt;/code&gt;, which is the route Google documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;vLLM&lt;/strong&gt;: &lt;code&gt;--speculative-config '{"method":"mtp", ...}'&lt;/code&gt;, measured in this repository on a Cloud TPU v5e.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Start With Two Drafted Tokens
&lt;/h4&gt;

&lt;p&gt;Every extra guess must be right for the one after it to count. Two drafted tokens per step was the best setting on the RTX 3050 for both models, and one on the GTX 1650 Ti laptop card. Raise it only for highly predictable output such as code.&lt;/p&gt;




&lt;h4&gt;
  
  
  Compare and Contrast
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Classic draft model&lt;/th&gt;
&lt;th&gt;Gemma 4 MTP drafter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it reads&lt;/td&gt;
&lt;td&gt;the conversation, from scratch&lt;/td&gt;
&lt;td&gt;the target's hidden state and KV cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Own KV cache&lt;/td&gt;
&lt;td&gt;yes, grows with context&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;a smaller model of the family&lt;/td&gt;
&lt;td&gt;4 layers, 256 wide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokenizer&lt;/td&gt;
&lt;td&gt;must match&lt;/td&gt;
&lt;td&gt;same 262,144-token vocabulary&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;E2B QAT drafter&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;GPU memory added&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bf16&lt;/td&gt;
&lt;td&gt;170.2 MB&lt;/td&gt;
&lt;td&gt;+188 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q8_0&lt;/td&gt;
&lt;td&gt;97.8 MB&lt;/td&gt;
&lt;td&gt;+120 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exact Q4_0&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;59.2 MB&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;+80 MiB&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  So, Which One?
&lt;/h4&gt;

&lt;p&gt;For a Gemma 4 QAT target served by llama.cpp, the exact Q4_0 QAT drafter. It matches the target's precision as Google requires, it is the smallest file, the closest to Google's weights and the cheapest in memory, and the target accepts its guesses as often as it accepts the larger files'.&lt;/p&gt;

&lt;p&gt;Turn it on for one user, or a few, on a GPU that waits on memory. The next part measures how much faster that is, on which kinds of text, and where adding users makes the drafter stop paying.&lt;/p&gt;




&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to explain speculative decoding with Gemma 4's MTP drafter and to introduce exact 4-bit repacks of Google's QAT drafters. The key to the solution was Google's drafter design, a 4-layer model that reuses the target's state and KV cache, trained onto the same 4-bit grid as the QAT target. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 &lt;strong&gt;A drafter guesses, the target verifies&lt;/strong&gt; several tokens in one pass, and every token is still the target's.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;Gemma 4's drafter keeps no KV cache of its own&lt;/strong&gt;: 4 layers of width 256 that read the target's hidden state.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;Exact Q4_0 QAT drafters on Hugging Face&lt;/strong&gt;: 59.2 MB for E2B and 59.7 MB for E4B, 39–40% smaller than llama.cpp's Q8_0 conversion and 3.6x closer to Google's weights.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;+80 MiB of GPU memory for the E2B drafter&lt;/strong&gt;, against +120 MiB for Q8_0 and +188 MiB for bf16.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;The gain depends on the text and on the number of users&lt;/strong&gt;; part 2 has the measurements.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;Greedy text can change at near-ties&lt;/strong&gt; when verification runs in batches.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: Google's QAT drafters for Gemma 4 E2B and E4B, repacked with &lt;code&gt;gguf_exact.py&lt;/code&gt; and served by llama.cpp &lt;code&gt;77c4540ce&lt;/code&gt; on one RTX 3050 OEM (8 GB) at 8,192 tokens of context on 2026-10-14. The descriptions of the drafter come from Google's model cards, its MTP documentation and the checkpoints' &lt;code&gt;config.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The strategy for adding Gemma 4's MTP drafter to a local llama.cpp deployment was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-assistant-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-E2B-it-qat-assistant-q4_0-exact-gguf | Hugging Face&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/xbill9/gemma-4-E4B-it-qat-assistant-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-E4B-it-qat-assistant-q4_0-exact-gguf | Hugging Face&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/google/gemma-4-E2B-it-qat-q4_0-unquantized-assistant" rel="noopener noreferrer"&gt;google/gemma-4-E2B-it-qat-q4_0-unquantized-assistant | Hugging Face&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/google/gemma-4-E2B-it-assistant" rel="noopener noreferrer"&gt;google/gemma-4-E2B-it-assistant | Hugging Face&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-E2B-it-qat-q4_0-exact-gguf | Hugging Face&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-2b-q4_0" rel="noopener noreferrer"&gt;local-llamacpp-3050oem-2b-q4_0 | GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/gde/gemma-4-on-a-used-8-gb-rtx-3050-116-tokenss-for-one-user-618-for-64-k6h"&gt;Gemma 4 on a Used 8 GB RTX 3050 | dev.to&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ggml-org/llama.cpp" rel="noopener noreferrer"&gt;llama.cpp | GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gemma</category>
      <category>llamacpp</category>
      <category>huggingface</category>
      <category>ai</category>
    </item>
    <item>
      <title>Gemma 4 on a Used 8 GB RTX 3050: 116 Tokens/s for One User, 618 for 64</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Thu, 15 Oct 2026 00:46:07 +0000</pubDate>
      <link>https://dev.to/gde/gemma-4-on-a-used-8-gb-rtx-3050-116-tokenss-for-one-user-618-for-64-k6h</link>
      <guid>https://dev.to/gde/gemma-4-on-a-used-8-gb-rtx-3050-116-tokenss-for-one-user-618-for-64-k6h</guid>
      <description>&lt;p&gt;This article provides a step by step deployment guide for Gemma 4 E2B and E4B to a local, desktop hosted GPU enabled system: an RTX 3050 OEM with 8 GB, in a 2014-era Haswell-E desktop. A suite of Python MCP tools is built to simplify management of the llama.cpp hosted deployment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-2b-q4_0" rel="noopener noreferrer"&gt;https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-2b-q4_0&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A used RTX 3050 OEM, listed at $179.97, serves Gemma 4 E2B at 116 tokens per second for one user and 618 tokens per second across 64 users, with the model's full 131,072-token context in 3.6 GB of its 8 GB. Against an AWS T4 on demand, the card costs as much as 342 hours of rental.&lt;/strong&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  What is this project trying to Do?
&lt;/h4&gt;

&lt;p&gt;Earlier rigs in this repository served Gemma 4 from rented TPUs and GPUs, and from hardware already on the desk: a 2021 laptop with a 4 GB GTX 1650 Ti, its CPU, and two Snapdragon ARM laptops. This rig asks what the cheapest dedicated card adds. The RTX 3050 OEM is an 8 GB Ampere card with tensor cores, and used ones were listed on eBay at about $145 to $210 on 2026-10-14.&lt;/p&gt;

&lt;p&gt;The rig serves the same model file as the 1650 Ti rig, byte for byte, with the same server flags. The questions are single-user speed, how much context fits, how many users one card carries, whether the bigger E4B model is worth its cost, and what all of it costs against the alternatives.&lt;/p&gt;

&lt;p&gt;Speculative decoding with Google's drafter models is a separate article. Every figure here was measured without it.&lt;/p&gt;




&lt;h4&gt;
  
  
  At This Point You Should Have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;An Nvidia GPU with 8 GB of memory. This one is an RTX 3050 OEM: GA106, compute capability 8.6, tensor cores, 120 W power limit.&lt;/li&gt;
&lt;li&gt;The CUDA toolkit (13.4 here) and &lt;code&gt;cmake&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;About 7 GB of disk for both model files.&lt;/li&gt;
&lt;li&gt;A clone of &lt;code&gt;xbill9/gemma4-dev&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  The Hardware
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;power.limit [W], clocks.max.sm [MHz], clocks.max.memory [MHz]
120.00 W, 2100 MHz, 7001 MHz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;NVIDIA GeForce RTX 3050 OEM, PCI &lt;code&gt;10de:2508&lt;/code&gt; (GA106), compute capability 8.6, 8192 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver&lt;/td&gt;
&lt;td&gt;615.78.08&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;Intel i7-5820K, 6 cores / 12 threads, AVX2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Board / RAM&lt;/td&gt;
&lt;td&gt;ASUS X99-A, 46 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Debian sid, gcc 16.2.0, CUDA 13.4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The OEM 3050 came in prebuilt desktops and is a different configuration from the retail RTX 3050 8 GB. The host is a 2014-era X99 board; with every layer on the card, the CPU barely matters.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Build llama.cpp for the Card
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/local/cuda/bin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;
make build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target runs &lt;code&gt;cmake&lt;/code&gt; with &lt;code&gt;GGML_CUDA=ON&lt;/code&gt;, &lt;code&gt;CMAKE_CUDA_ARCHITECTURES=86&lt;/code&gt; and &lt;code&gt;GGML_NATIVE=ON&lt;/code&gt;, then builds &lt;code&gt;llama-server&lt;/code&gt; and &lt;code&gt;llama-bench&lt;/code&gt;. It printed the commit, &lt;code&gt;77c4540ce&lt;/code&gt;, which goes into &lt;code&gt;tpu.env&lt;/code&gt; as &lt;code&gt;LLAMA_CPP_COMMIT&lt;/code&gt;. The build took 10 minutes 24 seconds at &lt;code&gt;-j10&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Download the Exact QAT GGUF
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make download
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target downloads the file and checks it against &lt;code&gt;MODEL_SHA256&lt;/code&gt; in &lt;code&gt;tpu.env&lt;/code&gt;. The file is &lt;code&gt;xbill9/gemma-4-E2B-it-qat-q4_0-exact-gguf&lt;/code&gt;, SHA-256 &lt;code&gt;419db9a6bf3bc15d770c85ccf9216827aa88c64fe8f3d72fbe5674f48efe2dc8&lt;/code&gt;. It is Google's quantization-aware-trained (QAT) Gemma 4 E2B with every trained 4-bit value packed into Q4_0 exactly. On the 1650 Ti it measured 32x closer to bf16 by KL divergence than Google's own GGUF, and faster.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Configure the Server
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;tpu.env&lt;/code&gt; is the rig's configuration, and every value in it carries the measurement that set it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;N_GPU_LAYERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;99
&lt;span class="nv"&gt;CONTEXT_SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;131072
&lt;span class="nv"&gt;KV_CACHE_TYPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;f16
&lt;span class="nv"&gt;FLASH_ATTENTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nv"&gt;THREADS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;6
&lt;span class="nv"&gt;PARALLEL_SLOTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nv"&gt;REASONING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;off
&lt;span class="nv"&gt;OVERRIDE_TENSORS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;per_layer_token_embd&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="nv"&gt;weight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;CUDA0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-ngl 99&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every layer on the card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-c 131072&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the model's trained maximum; Step 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-fa 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;flash attention: 1.133x prefill, 1.071x decode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-ctk f16 -ctv f16&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;q8_0&lt;/code&gt; costs 7.4% of decode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-ot per_layer_token_embd…=CUDA0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the per-layer embedding table on the card; Step 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--reasoning off&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;answers start at once; a request can still ask for thinking&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  Step 4 — Start It and Check What Is Serving
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make serve
nvidia-smi &lt;span class="nt"&gt;--query-compute-apps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pid,process_name,used_memory &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pid, process_name, used_gpu_memory [MiB]
47321, /home/xbill/llama.cpp/build/bin/llama-server, 1510 MiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the first-light configuration: 8,192 tokens of context and the per-layer table left in host memory. The MCP server's attestation reads the device from the running process, since a CPU-only build would answer on the same port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;device=gpu · pid=47321 · -ngl 99 · mapped: ggml-cuda, libcublas, libcuda, libcudart · /home/xbill/llama.cpp/build/bin/llama-server
mismatch: None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One chat request at temperature 0, "In one sentence, what is a TPU?":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A TPU, or Tensor Processing Unit, is a specialized hardware accelerator designed to significantly speed up the training and inference of machine learning models, particularly those involving deep learning.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server's own timings for that request: 34 tokens at 102.56 tokens per second.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Measure Single-User Speed
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;llama-bench&lt;/code&gt;, a 512-token prompt and 128 generated tokens, five repetitions per cell, the card cooled to 40 °C before each cell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fa1-f16-t6 pp512=3859.96±111.73 tg128=116.14±0.25 77c4540ce
fa0-f16-t6 pp512=3408.19±110.65 tg128=108.46±0.22 77c4540ce
fa1-q8-t6 pp512=3708.48±118.09 tg128=107.57±0.23 77c4540ce
fa1-f16-t4 pp512=3838.78±111.16 tg128=115.87±0.22 77c4540ce
fa1-f16-t6-rep pp512=3835.54±102.85 tg128=115.90±0.22 77c4540ce
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;116.14 tokens per second generated, 3,860 tokens per second of prompt read.&lt;/strong&gt; The repeated last cell lands within 0.6% of the first, and the card held 1890 to 1905 MHz without passing 44 °C.&lt;/p&gt;

&lt;p&gt;On the 1650 Ti the same file generates at about 81.7 tokens per second and reads prompts at about 345. That makes this card about 1.42x faster at generating and 11x faster at reading. Generating one token reads every weight once, so it follows memory speed, where the two cards are closer. Reading a prompt is matrix work, which this card runs on tensor cores and the 1650 Ti has none of.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — Keep the Defaults That Measure Faster
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Prompt&lt;/th&gt;
&lt;th&gt;Generate&lt;/th&gt;
&lt;th&gt;Kept&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;flash attention off&lt;/td&gt;
&lt;td&gt;0.883x&lt;/td&gt;
&lt;td&gt;0.934x&lt;/td&gt;
&lt;td&gt;❌ on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;q8_0&lt;/code&gt; KV cache&lt;/td&gt;
&lt;td&gt;0.961x&lt;/td&gt;
&lt;td&gt;0.926x&lt;/td&gt;
&lt;td&gt;❌ f16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 CPU threads in place of 6&lt;/td&gt;
&lt;td&gt;0.995x&lt;/td&gt;
&lt;td&gt;0.998x&lt;/td&gt;
&lt;td&gt;⚠️ either&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The KV cache for E2B is small, so quantizing it saves memory the card has plenty of and costs speed. Thread count makes no difference because the CPU does no model work.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — Move the Per-Layer Embedding Table onto the Card
&lt;/h4&gt;

&lt;p&gt;The &lt;code&gt;E&lt;/code&gt; in E2B is a per-layer embedding table, &lt;code&gt;per_layer_token_embd&lt;/code&gt;, 1.32 GB and 51% of the file. llama.cpp leaves it memory-mapped in host RAM by default and reads one row per token. With 6 GB of the card unused, the rig moves it with &lt;code&gt;-ot&lt;/code&gt;. Card against host, mean of two passes each, order host, card, card, host:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;test&lt;/th&gt;
&lt;th&gt;host&lt;/th&gt;
&lt;th&gt;card&lt;/th&gt;
&lt;th&gt;card / host&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pp512&lt;/td&gt;
&lt;td&gt;3845.18&lt;/td&gt;
&lt;td&gt;3982.67&lt;/td&gt;
&lt;td&gt;1.036&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pp2048&lt;/td&gt;
&lt;td&gt;3759.36&lt;/td&gt;
&lt;td&gt;3848.42&lt;/td&gt;
&lt;td&gt;1.024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tg128&lt;/td&gt;
&lt;td&gt;115.91&lt;/td&gt;
&lt;td&gt;115.98&lt;/td&gt;
&lt;td&gt;1.001&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 2.4 to 3.6% gain on prompts, nothing on generation, for 1,260 MiB. Both card passes beat both host passes at both prompt lengths. The 1650 Ti measured no gain from the same move: this card reads prompts 11x faster, so fetching rows from host memory is a bigger share of its work.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — Serve the Full 131k Context
&lt;/h4&gt;

&lt;p&gt;Gemma 4 E2B was trained to 131,072 tokens. Only its 3 full-attention layers keep a cache that grows with context; the 12 sliding-window layers stay at 1,024 cells.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;-c&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;KV, full-attention&lt;/th&gt;
&lt;th&gt;KV, sliding&lt;/th&gt;
&lt;th&gt;process on the card&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8,192&lt;/td&gt;
&lt;td&gt;48 MiB&lt;/td&gt;
&lt;td&gt;12 MiB&lt;/td&gt;
&lt;td&gt;2770 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32,768&lt;/td&gt;
&lt;td&gt;192 MiB&lt;/td&gt;
&lt;td&gt;12 MiB&lt;/td&gt;
&lt;td&gt;2938 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;131,072&lt;/td&gt;
&lt;td&gt;768 MiB&lt;/td&gt;
&lt;td&gt;12 MiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3610 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The full context costs 840 MiB over 8,192 and leaves 4.5 GB of the card free. Short chats do not notice: 256 generated tokens ran at 108.30 tokens per second at 8,192 and 107.99 at 131,072.&lt;/p&gt;

&lt;p&gt;Long conversations do slow down, because every new token attends to everything before it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tokens already in the conversation&lt;/th&gt;
&lt;th&gt;prompt t/s&lt;/th&gt;
&lt;th&gt;generate tok/s&lt;/th&gt;
&lt;th&gt;vs empty&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3959.63&lt;/td&gt;
&lt;td&gt;116.02&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8,192&lt;/td&gt;
&lt;td&gt;2898.48&lt;/td&gt;
&lt;td&gt;107.06&lt;/td&gt;
&lt;td&gt;0.923&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32,768&lt;/td&gt;
&lt;td&gt;1690.19&lt;/td&gt;
&lt;td&gt;90.68&lt;/td&gt;
&lt;td&gt;0.782&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;65,536&lt;/td&gt;
&lt;td&gt;1079.92&lt;/td&gt;
&lt;td&gt;75.02&lt;/td&gt;
&lt;td&gt;0.647&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;120,000&lt;/td&gt;
&lt;td&gt;669.25&lt;/td&gt;
&lt;td&gt;58.14&lt;/td&gt;
&lt;td&gt;0.501&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Generation stays over 100 tokens per second to about 8k tokens of conversation and halves by 120k.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 9 — Serve Many Users at Once
&lt;/h4&gt;

&lt;p&gt;For the concurrency sweep the server ran 64 slots of 1,024 tokens (&lt;code&gt;-c 65536 --parallel 64&lt;/code&gt;), 2612 MiB on the card. &lt;code&gt;sweep.py&lt;/code&gt; sent 1 to 64 simultaneous requests, each with a unique prompt, five repeats per level, median reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;c=1      93.28 tok/s aggregate  TTFT    212.7 ms  TPOT   9.34 ms  per-stream 107.03
c=4     196.09 tok/s aggregate  TTFT    669.1 ms  TPOT  15.64 ms  per-stream  63.95
c=16    316.07 tok/s aggregate  TTFT   2702.4 ms  TPOT  30.12 ms  per-stream  33.20
c=64    358.05 tok/s aggregate  TTFT   8516.4 ms  TPOT 113.20 ms  per-stream   8.84
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 512 tokens in and 128 out. With 128 in and 512 out, chat-shaped traffic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;c=1     104.74 tok/s aggregate  TTFT    134.9 ms  TPOT   9.34 ms  per-stream 107.02
c=4     259.73 tok/s aggregate  TTFT    330.3 ms  TPOT  14.74 ms  per-stream  67.83
c=16    505.42 tok/s aggregate  TTFT   1114.3 ms  TPOT  29.32 ms  per-stream  34.10
c=64    618.12 tok/s aggregate  TTFT   3890.0 ms  TPOT  95.33 ms  per-stream  10.49
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3.84x and 5.90x the single-user output at 64 users.&lt;/strong&gt; Past 16 users the gain flattens while each user's speed keeps falling, so 16 is the practical ceiling: 34 tokens per second each, faster than anyone reads.&lt;/p&gt;

&lt;p&gt;llama.cpp still reads prompts one request at a time, so time to first token about doubles with each doubling of users. The 1650 Ti ran into that wall: at 512 in and 128 out its output reached only 1.47x single-user. This card reads prompts fast enough that batched generation shows through anyway.&lt;/p&gt;

&lt;p&gt;The card drew at most 110.93 W of its 120 W limit, held 1890 MHz in every busy sample and peaked at 68 °C. The server's prompt cache answered 0 tokens, so every prompt was read in full.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 10 — Bring Up E4B on the Same Card
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;local-llamacpp-3050oem-4b-q4_0&lt;/code&gt; is the E4B sister rig: same card, binary and flags, with the exact Q4_0 E4B file (SHA-256 &lt;code&gt;5462cc10…&lt;/code&gt;, 4.20 GB).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;at 131,072 context&lt;/th&gt;
&lt;th&gt;E2B&lt;/th&gt;
&lt;th&gt;E4B&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Process on the card&lt;/td&gt;
&lt;td&gt;3610 MiB&lt;/td&gt;
&lt;td&gt;6430 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KV cache&lt;/td&gt;
&lt;td&gt;780 MiB&lt;/td&gt;
&lt;td&gt;2,088 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generate, empty context&lt;/td&gt;
&lt;td&gt;116.02 tok/s&lt;/td&gt;
&lt;td&gt;60.41 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt, empty context&lt;/td&gt;
&lt;td&gt;3959.63 t/s&lt;/td&gt;
&lt;td&gt;2230.50 t/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generate, 120k in context&lt;/td&gt;
&lt;td&gt;58.14 tok/s&lt;/td&gt;
&lt;td&gt;30.04 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;E4B generates at 0.52x E2B at every conversation length and fits with 1.7 GB to spare. The two rigs share port 8080 and do not fit on the card together. On the 1650 Ti, E4B fits at 8,192 context with the per-layer table in host memory and generates at about 39.8 tokens per second; this card runs it 1.52x faster with 16x the context.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 11 — Measure Answer Quality
&lt;/h4&gt;

&lt;p&gt;Both files served one at a time with identical flags, no drafter, and three task sets: a 13-subset classification suite (3,880 questions), GSM8K math (1,319 problems) and BFCL simple tool calling (400 records), all greedy.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;task&lt;/th&gt;
&lt;th&gt;E2B&lt;/th&gt;
&lt;th&gt;E4B&lt;/th&gt;
&lt;th&gt;E4B − E2B&lt;/th&gt;
&lt;th&gt;95% range&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Classification suite&lt;/td&gt;
&lt;td&gt;0.683&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.730&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;+0.048&lt;/td&gt;
&lt;td&gt;+0.035 to +0.061&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K&lt;/td&gt;
&lt;td&gt;0.898&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.933&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;+0.035&lt;/td&gt;
&lt;td&gt;+0.020 to +0.050&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFCL simple&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.925&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.910&lt;/td&gt;
&lt;td&gt;−0.015&lt;/td&gt;
&lt;td&gt;−0.043 to +0.013&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;E4B is better at reading comprehension and math, and level on tool calling, where the range includes zero. Both 4-bit files score within the error ranges of their bf16 references measured on Cloud TPU: E2B suite 0.683 against bf16's 0.683, E4B suite level with its bf16 run. The quantization costs nothing measurable; the gap between the two is the model.&lt;/p&gt;

&lt;p&gt;The whole suite took 6.4 minutes for E2B and 11.2 for E4B on this card.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Spend the Spare Memory on Context
&lt;/h4&gt;

&lt;p&gt;On an 8 GB card E2B leaves more than half the memory free even at full context. Spend it on the 131k context and on the per-layer table, both measured gains or free. Quantizing the KV cache to &lt;code&gt;q8_0&lt;/code&gt; saves memory the card does not need and costs 7.4% of generation.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Size &lt;code&gt;--parallel&lt;/code&gt; Against &lt;code&gt;-c&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;llama.cpp splits &lt;code&gt;--ctx-size&lt;/code&gt; across &lt;code&gt;--parallel&lt;/code&gt; slots. &lt;code&gt;-c 131072 --parallel 16&lt;/code&gt; gives each user 8,192 tokens; &lt;code&gt;-c 16384 --parallel 16&lt;/code&gt; gives each 1,024. Set both together, from what each user needs.&lt;/p&gt;




&lt;h4&gt;
  
  
  Compare and Contrast: Local Hardware
&lt;/h4&gt;

&lt;p&gt;Gemma 4 E2B, the same exact Q4_0 file on every row except the last two, single user, &lt;code&gt;llama-bench&lt;/code&gt; generation speed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Machine&lt;/th&gt;
&lt;th&gt;Device&lt;/th&gt;
&lt;th&gt;Generate tok/s&lt;/th&gt;
&lt;th&gt;vs 3050&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Desktop, RTX 3050 OEM 8 GB&lt;/td&gt;
&lt;td&gt;GPU, 120 W limit&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;116.14&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lenovo Yoga 9, GTX 1650 Ti Max-Q 4 GB&lt;/td&gt;
&lt;td&gt;GPU, 40 W limit&lt;/td&gt;
&lt;td&gt;81.75 🥈&lt;/td&gt;
&lt;td&gt;0.70x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lenovo Yoga 9, i7-10750H&lt;/td&gt;
&lt;td&gt;CPU, 6 threads&lt;/td&gt;
&lt;td&gt;18.84 🥉&lt;/td&gt;
&lt;td&gt;0.16x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Samsung Galaxy Book Go, Snapdragon 7c Gen 2&lt;/td&gt;
&lt;td&gt;CPU, 2 cores&lt;/td&gt;
&lt;td&gt;8.33&lt;/td&gt;
&lt;td&gt;0.07x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lenovo Yoga C630, Snapdragon 850&lt;/td&gt;
&lt;td&gt;CPU, 4 cores&lt;/td&gt;
&lt;td&gt;5.00&lt;/td&gt;
&lt;td&gt;0.04x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;RTX 3050 OEM&lt;/th&gt;
&lt;th&gt;GTX 1650 Ti&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompt read, 512 tokens&lt;/td&gt;
&lt;td&gt;3,860 t/s 🥇&lt;/td&gt;
&lt;td&gt;about 345 t/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full 131k context, E2B&lt;/td&gt;
&lt;td&gt;🟢 3.6 GB of 8&lt;/td&gt;
&lt;td&gt;⚠️ not measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E4B&lt;/td&gt;
&lt;td&gt;🟢 131k context, 60 tok/s&lt;/td&gt;
&lt;td&gt;⚠️ 8k context, 40 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512 in / 128 out, best aggregate&lt;/td&gt;
&lt;td&gt;🟢 358 tok/s, 3.84x&lt;/td&gt;
&lt;td&gt;❌ 1.47x single user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power under load&lt;/td&gt;
&lt;td&gt;110.93 W peak&lt;/td&gt;
&lt;td&gt;40 W cap&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  Compare and Contrast: Cloud
&lt;/h4&gt;

&lt;p&gt;The two cheapest cloud deployments of Gemma 4 E2B measured in this repository serve bf16 weights through vLLM: an AWS &lt;code&gt;g4dn.xlarge&lt;/code&gt; with a T4, and one Google Cloud TPU v5e chip.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operating point&lt;/th&gt;
&lt;th&gt;RTX 3050 OEM&lt;/th&gt;
&lt;th&gt;T4, &lt;code&gt;g4dn.xlarge&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;TPU v5e-1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One user, output tok/s&lt;/td&gt;
&lt;td&gt;93.28 🥈&lt;/td&gt;
&lt;td&gt;42.36 🥉&lt;/td&gt;
&lt;td&gt;120 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 users, 512 in / 128 out&lt;/td&gt;
&lt;td&gt;224.18&lt;/td&gt;
&lt;td&gt;242.47&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best aggregate measured&lt;/td&gt;
&lt;td&gt;618.12 🥈&lt;/td&gt;
&lt;td&gt;243.93 🥉&lt;/td&gt;
&lt;td&gt;1,382 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;$179.97 once&lt;/td&gt;
&lt;td&gt;$0.526/hour&lt;/td&gt;
&lt;td&gt;$0.5779/hour, spot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one-user row compares like shapes for the 3050 and T4 (512 in, 128 out); the TPU figure is 128 in, 128 out. The TPU is faster at every point and serves 2.2x the 3050's best aggregate. The 3050 generates 2.2x the T4's speed for one user and lands within 8% of it at 8 users.&lt;/p&gt;




&lt;h4&gt;
  
  
  And Price/Performance?
&lt;/h4&gt;

&lt;p&gt;All of this is arithmetic from the measured speeds, the cloud rates recorded in those runs, a used-card listing captured on 2026-10-14, and the US Energy Information Administration's 2026 residential electricity forecast of 18.2 cents per kWh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The card pays for itself in two to three weeks of continuous rental.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Against&lt;/th&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;th&gt;Hours to $179.97&lt;/th&gt;
&lt;th&gt;Days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TPU v5e-1, spot&lt;/td&gt;
&lt;td&gt;$0.5779/h&lt;/td&gt;
&lt;td&gt;311&lt;/td&gt;
&lt;td&gt;13.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T4 &lt;code&gt;g4dn.xlarge&lt;/code&gt;, on-demand&lt;/td&gt;
&lt;td&gt;$0.526/h&lt;/td&gt;
&lt;td&gt;342&lt;/td&gt;
&lt;td&gt;14.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T4 &lt;code&gt;g4dn.xlarge&lt;/code&gt;, spot&lt;/td&gt;
&lt;td&gt;$0.3559/h&lt;/td&gt;
&lt;td&gt;506&lt;/td&gt;
&lt;td&gt;21.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Running it costs about two cents an hour.&lt;/strong&gt; At 110.93 W, the most the card drew in any sample, electricity is $0.0202 per hour against $0.526 for the T4.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Per million output tokens&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RTX 3050, 64 users, 128 in / 512 out, electricity at peak draw&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$0.009&lt;/strong&gt; 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTX 3050, 8 users, 512 in / 128 out, electricity at peak draw&lt;/td&gt;
&lt;td&gt;$0.025 🥈&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTX 3050, one user, electricity at 80 W&lt;/td&gt;
&lt;td&gt;$0.039 🥉&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TPU v5e-1, 64 users, spot&lt;/td&gt;
&lt;td&gt;$0.116&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T4, 8 users, on-demand&lt;/td&gt;
&lt;td&gt;$0.603&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TPU v5e-1, one user, spot&lt;/td&gt;
&lt;td&gt;$1.338&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T4, one user, on-demand&lt;/td&gt;
&lt;td&gt;$3.449&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The local rows leave out the card's purchase and the host it sits in; the cloud rows leave out storage and data transfer. A card already paid for wins by an order of magnitude on running cost. The TPU is the faster machine and the better buy only for a burst of work too big for one 3050, or for anyone without a desktop to put the card in.&lt;/p&gt;




&lt;h4&gt;
  
  
  So, Which One?
&lt;/h4&gt;

&lt;p&gt;For a single person or a small team running Gemma 4 E2B or E4B all day, the used RTX 3050 OEM. It is the only local card here with tensor cores and 8 GB, and both together are what it takes: E4B at full context fits, prompts read at thousands of tokens per second, and 16 users each still get 34 tokens per second.&lt;/p&gt;

&lt;p&gt;The 1650 Ti laptop remains the right answer for one user who already owns it. The ARM laptops serve E2B, slowly. Rent the TPU for bursts of work.&lt;/p&gt;

&lt;p&gt;Between the two models, E4B buys 4.8 points on the classification suite and 3.5 on math for half the generation speed. For tool calling, E2B is level and twice as fast.&lt;/p&gt;




&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to bring Gemma 4 E2B and E4B up on a used RTX 3050 OEM and to measure what it delivers for the money against the other local and cloud deployments in this repository. The key to the solution was the exact Q4_0 QAT files, which leave enough of the 8 GB card free for the full context and for many users at once. The measured results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 &lt;strong&gt;116.14 tok/s generated and 3,860 t/s of prompt read&lt;/strong&gt; for one user, E2B, 1.42x and 11x the 1650 Ti.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;The full 131,072-token context in 3610 MiB&lt;/strong&gt; for E2B and 6430 MiB for E4B, with no cost to short chats.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;618.12 tok/s aggregate at 64 users&lt;/strong&gt; (128 in / 512 out) and 358.05 at 512 in / 128 out; 34 tok/s per user at 16.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;4-bit quality matches bf16&lt;/strong&gt;: E2B suite 0.683 against 0.683; E4B 0.730.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;Pays for itself in 311 to 506 hours&lt;/strong&gt; against the cheapest cloud options, and runs for about $0.02 an hour in electricity (arithmetic).&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;E4B generates at 0.52x E2B&lt;/strong&gt; for +4.8 suite and +3.5 GSM8K points.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;Long conversations halve generation speed&lt;/strong&gt; by 120k tokens of context.&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;A &lt;code&gt;q8_0&lt;/code&gt; KV cache and flash attention off are both slower&lt;/strong&gt; on this card.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one RTX 3050 OEM in an i7-5820K desktop, llama.cpp &lt;code&gt;77c4540ce&lt;/code&gt; with CUDA 13.4, measured 2026-10-13, without speculative decoding. The 1650 Ti, i7-10750H and Snapdragon figures come from other hosts, llama.cpp commits and days with the same model file; the T4 and TPU figures come from vLLM serving bf16 weights under a different benchmark tool; so every cross-machine ratio is indicative. The card price is one used listing's asking price on 2026-10-14, and the electricity figures use the highest power draw sampled or the single-user benchmark's end-of-run reading.&lt;/p&gt;

&lt;p&gt;The strategy for running Gemma 4 locally on a used RTX 3050 OEM was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-2b-q4_0" rel="noopener noreferrer"&gt;local-llamacpp-3050oem-2b-q4_0 | GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/gemma4-dev/tree/main/local-llamacpp-3050oem-4b-q4_0" rel="noopener noreferrer"&gt;local-llamacpp-3050oem-4b-q4_0 | GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-E2B-it-qat-q4_0-exact-gguf | Hugging Face&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/xbill9/gemma-4-E4B-it-qat-q4_0-exact-gguf" rel="noopener noreferrer"&gt;xbill9/gemma-4-E4B-it-qat-q4_0-exact-gguf | Hugging Face&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.eia.gov/outlooks/steo/report/elec_coal_renew.php" rel="noopener noreferrer"&gt;Short-Term Energy Outlook, electricity | U.S. EIA&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ggml-org/llama.cpp" rel="noopener noreferrer"&gt;llama.cpp | GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gemma</category>
      <category>llamacpp</category>
      <category>cuda</category>
      <category>localllm</category>
    </item>
    <item>
      <title>One MCP Server for Six AWS Inference Backends: Bedrock, SageMaker, GPUs, Inferentia and Trainium</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Tue, 13 Oct 2026 03:04:09 +0000</pubDate>
      <link>https://dev.to/aws-builders/one-mcp-server-for-six-aws-inference-backends-bedrock-sagemaker-gpus-inferentia-and-trainium-3edb</link>
      <guid>https://dev.to/aws-builders/one-mcp-server-for-six-aws-inference-backends-bedrock-sagemaker-gpus-inferentia-and-trainium-3edb</guid>
      <description>&lt;p&gt;This article provides a step by step guide to the MCP server that operates a six-backend inference demo on AWS: Amazon Bedrock, a SageMaker endpoint, vLLM on EC2 g6 and g5g, and Gemma 4 on Inferentia2 and Trainium. A suite of eight Python MCP tools lists, launches, health-checks, reads the logs of, asks, and tears down every backend from one chat session.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/aws-inference-strands" rel="noopener noreferrer"&gt;https://github.com/xbill9/aws-inference-strands&lt;/a&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  What Is the Problem?
&lt;/h4&gt;

&lt;p&gt;The demo behind this server puts one Strands agent in front of six ways to serve a model on AWS. Running it means five billable resources in up to four regions, two of them on AWS Neuron chips, and none of the instances reachable from the internet.&lt;/p&gt;

&lt;p&gt;The repository already has shell scripts for all of it: &lt;code&gt;stage/up.sh&lt;/code&gt;, &lt;code&gt;stage/forward.sh&lt;/code&gt;, &lt;code&gt;stage/check.py&lt;/code&gt;, &lt;code&gt;stage/ask.py&lt;/code&gt; and &lt;code&gt;stage/down.sh&lt;/code&gt;. Each one prints for a person at a terminal.&lt;/p&gt;

&lt;p&gt;The MCP server gives the same operations to an agent. It answers "what is running and what is billing", "is inf2 serving yet", "show me the vLLM log on g6" and "tear it all down" with tools whose arithmetic and checks run in code, and it serves demo 1's counting tool to a Strands agent over the protocol itself.&lt;/p&gt;




&lt;h4&gt;
  
  
  At This Point You Should Have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;An AWS account with the AWS CLI configured, and Bedrock model access for Amazon Nova Micro in us-east-1&lt;/li&gt;
&lt;li&gt;Python 3 (3.14.7 here) with &lt;code&gt;mcp&lt;/code&gt;, &lt;code&gt;strands-agents[openai,sagemaker]&lt;/code&gt; and &lt;code&gt;boto3&lt;/code&gt; installed&lt;/li&gt;
&lt;li&gt;The instance profile and SSM-only security group &lt;code&gt;stage/up.sh&lt;/code&gt; uses (it creates the security group on first use)&lt;/li&gt;
&lt;li&gt;Claude Code, or any MCP client that can start a stdio server&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://dev.to/aws-builders/gemma-4-inference-on-aws-bedrock-sagemaker-gpus-inferentia-and-trainium-behind-one-strands-agent-1dm0"&gt;companion article&lt;/a&gt; for how each backend is built&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 1 — Register the Server
&lt;/h4&gt;

&lt;p&gt;The server is one file, &lt;code&gt;mcp_server.py&lt;/code&gt;, started over stdio. &lt;code&gt;.mcp.json&lt;/code&gt; in the repository root registers it for Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"strands-demo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"mcp_server.py"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any MCP client can start it the same way. Three lines of JSON-RPC on stdin are enough to see what it offers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"printf","version":"0"}}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{"jsonrpc":"2.0","method":"notifications/initialized"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":2,"method":"tools/list"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 mcp_server.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"protocolVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-11-25"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"capabilities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"listChanged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"prompts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"listChanged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                   &lt;/span&gt;&lt;span class="nl"&gt;"resources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"listChanged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"subscribe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"experimental"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"serverInfo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"strands-demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server runs on the MCP Python SDK 2.1.1 and its &lt;code&gt;MCPServer&lt;/code&gt; class, and negotiates protocol version &lt;code&gt;2025-11-25&lt;/code&gt;. &lt;code&gt;tools/list&lt;/code&gt; returns eight tools.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — One File Over the Demo's Own Code
&lt;/h4&gt;

&lt;p&gt;The server imports the two modules the demos already use and adds AWS calls around them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;           Claude Code  /  Strands MCPClient
                     │  stdio, JSON-RPC 2.0
                     ▼
 ┌──────────────────────── mcp_server.py ────────────────────────┐
 │ list_backends  backend_health  backend_logs   launch_log      │
 │ ask_backend    count_ids       launch_backends  teardown      │
 └──┬───────────────┬───────────────┬───────────────┬────────────┘
    │ boto3         │ backends.py   │ demo1_        │ subprocess
    │ EC2, SSM,     │ chat_once()   │ counting.py   │ stage/up.sh
    │ SageMaker     │               │ count()       │ stage/down.sh
    ▼               ▼               ▼               ▼
    4 US regions,   Bedrock,        the 11 ids,     detached,
    by tag          SageMaker,      in memory       output in
                    SSM forwards                    stage/*.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four design rules hold the file together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reuse, then wrap.&lt;/strong&gt; &lt;code&gt;ask_backend&lt;/code&gt; calls &lt;code&gt;backends.chat_once&lt;/code&gt;, the function &lt;code&gt;stage/ask.py&lt;/code&gt;, &lt;code&gt;stage/check.py&lt;/code&gt;, the benchmark and demo 2 call. &lt;code&gt;count_ids&lt;/code&gt; calls &lt;code&gt;demo1_counting.count&lt;/code&gt;, the function the in-process tool calls. A number from the server and a number from the scripts come from the same line of code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launch and teardown run the stage scripts.&lt;/strong&gt; &lt;code&gt;launch_backends&lt;/code&gt; starts &lt;code&gt;stage/up.sh&lt;/code&gt; and &lt;code&gt;teardown&lt;/code&gt; starts &lt;code&gt;stage/down.sh&lt;/code&gt;, so the MCP route and the documented route create and destroy the same resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No inbound ports.&lt;/strong&gt; Every instance sits in a security group with no inbound rules. Tools that need the box run a shell command on it through SSM Run Command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tag is the inventory.&lt;/strong&gt; &lt;code&gt;stage/up.sh&lt;/code&gt; tags every instance &lt;code&gt;ManagedBy=strands-demo-run&lt;/code&gt;, and every lookup in the server filters on that tag across &lt;code&gt;us-east-2&lt;/code&gt;, &lt;code&gt;us-east-1&lt;/code&gt;, &lt;code&gt;us-west-2&lt;/code&gt; and &lt;code&gt;us-west-1&lt;/code&gt;. A backend that fell over to another region on a capacity error is still found.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;boto3 is synchronous, so each call runs in a worker thread through &lt;code&gt;asyncio.to_thread&lt;/code&gt;. That lets one tool ask four regions at once with &lt;code&gt;asyncio.gather&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_instances&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;per_region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;_region_instances&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;REGIONS&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;per_region&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 3 — Read-Only, Write and Destructive
&lt;/h4&gt;

&lt;p&gt;Every tool carries MCP tool annotations, so a client can tell an inventory call from one that bills or one that deletes before it calls anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;READ_ONLY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readOnlyHint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotentHint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;WRITE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destructiveHint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;DESTRUCTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ToolAnnotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destructiveHint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What &lt;code&gt;tools/list&lt;/code&gt; returns, read back through the MCP client:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Annotation&lt;/th&gt;
&lt;th&gt;Arguments&lt;/th&gt;
&lt;th&gt;What it touches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_backends&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read-only&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;EC2, SSM and SageMaker describe calls in 4 regions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;backend_health&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read-only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;backend&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one chat request, on the box via SSM for EC2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;backend_logs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read-only&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;backend&lt;/code&gt;, &lt;code&gt;tail&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the serving log, via SSM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ask_backend&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read-only&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;backend&lt;/code&gt;, &lt;code&gt;prompt&lt;/code&gt;, &lt;code&gt;max_tokens&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;one chat request through the forwards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;count_ids&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read-only&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;op&lt;/code&gt;, &lt;code&gt;value&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;demo 1's ids, in memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;launch_log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read-only&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;which&lt;/code&gt;, &lt;code&gt;tail&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stage/up.log&lt;/code&gt; or &lt;code&gt;stage/down.log&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;launch_backends&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;write&lt;/td&gt;
&lt;td&gt;&lt;code&gt;names&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stage/up.sh&lt;/code&gt;; starts billing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;teardown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;destructive&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stage/down.sh&lt;/code&gt;; terminates everything&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of this article takes the tools in the order a demo day uses them.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — &lt;code&gt;list_backends&lt;/code&gt;: What Is Running and What Is Billing
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;list_backends&lt;/code&gt; searches the four regions for tagged instances, asks SSM whether each running one has checked in, asks SageMaker for the endpoint's status, and adds up what is billing. The counts are computed in the tool, so the agent quotes them.&lt;/p&gt;

&lt;p&gt;With nothing launched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"backends"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bedrock"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"where"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us-east-1 (managed)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"always on"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ssm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sagemaker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"where"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us-east-2 endpoint gemma-4-e2b-strands"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ssm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"g6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"where"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not launched"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ssm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"g5g"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"where"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not launched"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ssm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inf2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"where"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not launched"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ssm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"trn1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"where"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not launched"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ssm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"billing_now"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ec2_running"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sagemaker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ask_backend_configured"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bedrock"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"g6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"g5g"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sagemaker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inf2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"trn1"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One call took 0.78 seconds for five AWS describe calls: one per region for EC2 and one for SageMaker. Each running instance adds an SSM call.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ask_backend_configured&lt;/code&gt; lists all six backends while five are down. It names every backend with a URL in &lt;code&gt;demo.env&lt;/code&gt;, the file &lt;code&gt;stage/forward.sh&lt;/code&gt; writes into the &lt;code&gt;stage&lt;/code&gt; directory, and that file outlives the forwards. The tool's description says so, and Step 8 shows what a call to one of those backends returns.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — &lt;code&gt;launch_backends&lt;/code&gt; and &lt;code&gt;launch_log&lt;/code&gt;: Start Billing, Then Read the Launch
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;launch_backends&lt;/code&gt; takes any of &lt;code&gt;g6&lt;/code&gt;, &lt;code&gt;inf2&lt;/code&gt;, &lt;code&gt;trn1&lt;/code&gt;, &lt;code&gt;g5g&lt;/code&gt; and &lt;code&gt;sm&lt;/code&gt;, checks the names, and starts &lt;code&gt;stage/up.sh&lt;/code&gt; with them in the background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;launch_backends {"names": ["g6", "inf2"]}
started stage/up.sh g6 inf2 (pid 2362067); output in stage/up.log, read it with launch_log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The call returns at once. The script picks an AMI, tries each zone in turn, then the next region, and writes what it did to &lt;code&gt;stage/up.log&lt;/code&gt;. &lt;code&gt;launch_log&lt;/code&gt; reads the log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;strands-demo-g6: launched i-005ebaee08f191e7a   us-east-2a
strands-demo-inf2: no capacity in us-east-2 (subnet-0b0f0d29473b0a624), trying the next zone
strands-demo-inf2: launched i-0bf43d9b77b321ec3 us-east-2b

Next: stage/forward.sh (once the instances are up), then: source stage/demo.env &amp;amp;&amp;amp; python3 stage/check.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;inf2 had no capacity in the first zone and launched in the second. Half a minute later &lt;code&gt;list_backends&lt;/code&gt; showed both instances running and billing, before either had registered with SSM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inf2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"where"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us-east-2 i-0bf43d9b77b321ec3 inf2.xlarge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"running"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ssm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not registered"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"g6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"where"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us-east-2 i-005ebaee08f191e7a g6.xlarge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"running"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ssm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not registered"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"billing_now"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"ec2_running"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;









&lt;h4&gt;
  
  
  Step 6 — &lt;code&gt;backend_health&lt;/code&gt;: Ask on the Box
&lt;/h4&gt;

&lt;p&gt;For the four EC2 backends, &lt;code&gt;backend_health&lt;/code&gt; finds the instance by tag and sends one shell command through SSM Run Command. The command runs &lt;code&gt;curl&lt;/code&gt; against the server's port on the instance and prints the HTTP code, the time and the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl -s -m 90 -o /tmp/strands-health.json -w &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%{{http_code}} %{{time_total}}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
       &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-H &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;content-type: application/json&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; -d &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;shlex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
       &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;port&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v1/chat/completions; echo; cat /tmp/strands-health.json&lt;/span&gt;&lt;span class="sh"&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 request never leaves the instance, so the check needs no port forward and no inbound rule. Bedrock and SageMaker are called directly. SageMaker is checked for &lt;code&gt;InService&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;Polled once a minute from launch, the replies trace each instance coming up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;02:13:14Z g6   -&amp;gt; ❌ AWS InvalidInstanceId: Instances not in a valid state for account
02:14:21Z g6   -&amp;gt; ❌ RuntimeError: SSM Failed: 000 0.000140
...
02:24:36Z g6   -&amp;gt; ✅ g6: answered 'Paris' in 1.21s, on i-005ebaee08f191e7a (us-east-2) via SSM
02:26:11Z inf2 -&amp;gt; ❌ RuntimeError: SSM Failed: 000 90.000889
...
02:32:44Z inf2 -&amp;gt; ✅ inf2: answered 'Paris' in 0.27s, on i-0bf43d9b77b321ec3 (us-east-2) via SSM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each failure names a different stage:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reply&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;InvalidInstanceId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the SSM agent has not registered yet; nothing can run on the box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;000&lt;/code&gt; in under a millisecond&lt;/td&gt;
&lt;td&gt;the box answers, the serving port is closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;000&lt;/code&gt; after 90.0 s&lt;/td&gt;
&lt;td&gt;the port is open and the request waited out &lt;code&gt;curl -m 90&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;✅ &lt;code&gt;'Paris'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the model is serving and answers correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 90-second replies came from inf2 only. Its server opens port 8080 while it still loads the compiled model onto the NeuronCores, and holds requests until it is ready.&lt;/p&gt;

&lt;p&gt;g6 first passed 12.2 minutes after the launch call and inf2 20.4 minutes after it, with the checks a minute apart. g6's first answer took 1.21 seconds and the next three took 0.02 to 0.03 seconds.&lt;/p&gt;







&lt;h4&gt;
  
  
  Step 7 — &lt;code&gt;backend_logs&lt;/code&gt;: The Serving Log Without a Shell
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;backend_logs&lt;/code&gt; runs the right log command for each backend through SSM: &lt;code&gt;docker logs&lt;/code&gt; for the vLLM container on g6 and the Neuron container on inf2 and trn1, &lt;code&gt;journalctl -u vllm.service&lt;/code&gt; on g5g. Until the container exists it falls back to the cloud-init log, which shows the image pull:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;g6 i-005ebaee08f191e7a (us-east-2)
Error response from daemon: No such container: vllm
--- serving log not available yet; cloud-init:
...
469e56fbf4cf: Pull complete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two minutes after g6's first health check passed, the same call returned vLLM's own log, with the health check's request in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(APIServer pid=1) INFO:     Application startup complete.
(EngineCore pid=108) WARNING 10-13 02:24:34 [jit_monitor.py:141] Triton kernel JIT compilation during inference: kernel_unified_attention. This causes a latency spike; consider extending warmup to cover this shape/config.
(APIServer pid=1) INFO:     172.17.0.1:34422 - "POST /v1/chat/completions HTTP/1.1" 200 OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JIT warning matches the 1.21-second first answer. On inf2 the log showed the load in progress, then the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP listening on :8080 — /health=503 'loading' until ready; queue max=8, gen timeout 120.0s
...
READY in 473.8s — serving on :8080 (SLIM host, peak RSS 14.45 GB)
[req] pt=21 ct=1 5.8tok/s 0.17s finish=stop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line explains the 90-second health check replies: the port opened before the model was ready.&lt;/p&gt;







&lt;h4&gt;
  
  
  Step 8 — &lt;code&gt;ask_backend&lt;/code&gt;: One Timed Request
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;ask_backend&lt;/code&gt; sends one chat request through &lt;code&gt;backends.chat_once&lt;/code&gt; and returns the text, the backend's own completion token count, and the time measured in the server. Tokens per second is that count over that time, so it includes the network and the prompt processing. Self-hosted backends are reached through the SSM port forwards &lt;code&gt;stage/forward.sh&lt;/code&gt; opens.&lt;/p&gt;

&lt;p&gt;Before the forwards, with &lt;code&gt;demo.env&lt;/code&gt; left over from an earlier session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ask_backend {"backend": "g6", "prompt": "hello"}
❌ URLError: &amp;lt;urlopen error [Errno 111] Connection refused&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;stage/forward.sh&lt;/code&gt; rewrote &lt;code&gt;demo.env&lt;/code&gt;, a new server process read it at start-up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"g6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vLLM on EC2 g6 (L4)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello! How can I help you today? 😊"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"tokens_per_second"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;78.6&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inf2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Inferentia2 (inf2, hand-ported)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hello! How can I help you today?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.423&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tokens_per_second"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;21.3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ask_backend {"backend": "trn1", "prompt": "hello"}
❌ trn1 is not configured here; configured: bedrock, g6, inf2. Run stage/forward.sh.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;list_backends&lt;/code&gt; now reported &lt;code&gt;ask_backend_configured: ["bedrock", "g6", "inf2"]&lt;/code&gt; and both instances &lt;code&gt;Online&lt;/code&gt; in SSM. Replies this short are mostly prompt processing and network, so these rates describe one request each; the &lt;a href="https://dev.to/aws-builders/gemma-4-inference-on-aws-bedrock-sagemaker-gpus-inferentia-and-trainium-behind-one-strands-agent-1dm0"&gt;companion article&lt;/a&gt; has the 100-token benchmark.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 9 — &lt;code&gt;count_ids&lt;/code&gt;: Demo 1's Engine Over MCP
&lt;/h4&gt;

&lt;p&gt;Demo 1 asks how many of eleven ids are 10 or more. The answer is 8. &lt;code&gt;count_ids&lt;/code&gt; takes a predicate and returns the exact count, minimum and maximum, and echoes the predicate it ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;count_ids {"op": "&amp;gt;=", "value": 10}  -&amp;gt;  {"where": "id &amp;gt;= 10", "count": 8, "min": 10, "max": 23}
count_ids {"op": "&amp;gt;",  "value": 10}  -&amp;gt;  {"where": "id &amp;gt; 10",  "count": 7, "min": 11, "max": 23}
count_ids {"op": "=&amp;gt;", "value": 10}  -&amp;gt;  {"error": "op must be one of ['&amp;lt;', '&amp;lt;=', '==', '&amp;gt;', '&amp;gt;=']"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second line returns an exact, sourced 7 for a predicate the question did not ask for. The &lt;code&gt;where&lt;/code&gt; field is in the response so the predicate can be checked along with the number.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;demo1_counting.py --tools mcp&lt;/code&gt; offers this tool to a Strands agent through the protocol. Strands' &lt;code&gt;MCPClient&lt;/code&gt; starts the server over stdio and a tool filter exposes &lt;code&gt;count_ids&lt;/code&gt; alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;MCPClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;stdio_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StdioServerParameters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;])),&lt;/span&gt;
          &lt;span class="n"&gt;tool_filters&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allowed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count_ids&lt;/span&gt;&lt;span class="sh"&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 tool runs in the server's process, so the demo cannot record calls as they happen. It reads them back from the agent's conversation instead, and grades them with the same function the in-process run uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calls_from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;toolUse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;toolUse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&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;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;toolUse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty runs each on Bedrock, the in-process tool and then the MCP tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BACKENDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;bedrock python3 demo1_counting.py &lt;span class="nt"&gt;--tools&lt;/span&gt; engine &lt;span class="nt"&gt;--runs&lt;/span&gt; 20
&lt;span class="nv"&gt;BACKENDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;bedrock python3 demo1_counting.py &lt;span class="nt"&gt;--tools&lt;/span&gt; mcp &lt;span class="nt"&gt;--runs&lt;/span&gt; 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;True answer: 8  tools: engine
...
backend     runs  correct  right-filter  errors  median s  filters sent
bedrock       20   20/20          20/20       0      1.07  id &amp;gt;= 10

True answer: 8  tools: mcp
...
backend     runs  correct  right-filter  errors  median s  filters sent
bedrock       20   20/20          20/20       0      1.07  id &amp;gt;= 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same answers, same filter, same median time. Starting the server costs once per demo run, outside the timed agent calls.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 10 — &lt;code&gt;teardown&lt;/code&gt;: Stop Everything That Bills
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;teardown&lt;/code&gt; takes no arguments and starts &lt;code&gt;stage/down.sh&lt;/code&gt; in the background. It is the one tool marked destructive. The script closes the forwards, terminates every instance tagged &lt;code&gt;ManagedBy=strands-demo-run&lt;/code&gt; in all four regions, deletes the SageMaker endpoint when it exists, and then lists anything still running in any of the four regions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;teardown {}
started stage/down.sh  (pid 2367066); output in stage/down.log, read it with launch_log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-one seconds later, &lt;code&gt;launch_log&lt;/code&gt; with &lt;code&gt;which&lt;/code&gt; set to &lt;code&gt;down&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;us-east-2: terminating i-0bf43d9b77b321ec3  i-005ebaee08f191e7a

Sweep (anything listed here is still billing):
(sweep done)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;list_backends&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"billing_now": 0,
"ec2_running": 0,
"sagemaker": "not found"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sweep and the inventory both read empty while the two instances were still in &lt;code&gt;shutting-down&lt;/code&gt;, because both look only at pending, running, stopping and stopped instances.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Return the Error as Text the Agent Can Read
&lt;/h4&gt;

&lt;p&gt;Every tool catches its exceptions and returns one line that starts with ❌ and names the cause: an AWS error code and message, a &lt;code&gt;ValueError&lt;/code&gt; for a bad backend name, a &lt;code&gt;RuntimeError&lt;/code&gt; naming the four regions searched.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;backend_health {"backend": "tpu"}  -&amp;gt;  ❌ ValueError: backend must be one of bedrock, sagemaker, g6, g5g, inf2, trn1
launch_backends {"names": ["p5"]}  -&amp;gt;  ❌ unknown backend(s) ['p5']; use g6 inf2 trn1 g5g sm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent reads the cause in the reply. The protocol-level &lt;code&gt;isError&lt;/code&gt; flag stays &lt;code&gt;false&lt;/code&gt; on all of them, so a client that branches on that flag treats a failed health check as a success. Set it where clients act on it.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Pass a Health Check on the Answer
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;backend_health&lt;/code&gt; asks "What is the capital of France? Answer in one word." at temperature 0 with 16 tokens, and passes only when the reply contains "paris". A non-empty reply proves the port is open. A broken build of this model family has answered with repeated text, and that reply is non-empty too.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Clamp Every Number the Model Sends
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;tail&lt;/code&gt; is clamped to 1 to 2000 lines and &lt;code&gt;max_tokens&lt;/code&gt; to 1 to 1024. &lt;code&gt;backend&lt;/code&gt; is checked against a fixed list before any AWS call, and &lt;code&gt;launch_backends&lt;/code&gt; rejects an unknown name before &lt;code&gt;stage/up.sh&lt;/code&gt; starts. A model that asks for a million log lines gets two thousand.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Run Long Jobs Detached and Hand Back a Log
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;stage/up.sh&lt;/code&gt; and &lt;code&gt;stage/down.sh&lt;/code&gt; each took under half a minute here, and the backends behind them take up to twenty minutes to serve. Both scripts run with &lt;code&gt;start_new_session=True&lt;/code&gt; and their output in &lt;code&gt;stage/up.log&lt;/code&gt; or &lt;code&gt;stage/down.log&lt;/code&gt;, so the tool call returns at once and the job survives the MCP session closing. &lt;code&gt;launch_log&lt;/code&gt; reads the log back.&lt;/p&gt;




&lt;h4&gt;
  
  
  Compare and Contrast
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;stage scripts&lt;/th&gt;
&lt;th&gt;MCP server&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Caller&lt;/td&gt;
&lt;td&gt;a person at a terminal&lt;/td&gt;
&lt;td&gt;any MCP client: Claude Code, a Strands agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Launch and teardown&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stage/up.sh&lt;/code&gt;, &lt;code&gt;stage/down.sh&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the same scripts, detached, read back with &lt;code&gt;launch_log&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inventory&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;aws ec2 describe-instances&lt;/code&gt; per region&lt;/td&gt;
&lt;td&gt;🥇 one call, four regions in parallel, billing count computed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stage/check.py&lt;/code&gt;, needs the forwards&lt;/td&gt;
&lt;td&gt;🥇 on the box via SSM, no forwards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;aws ssm start-session&lt;/code&gt;, then &lt;code&gt;docker logs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;🥇 &lt;code&gt;backend_logs&lt;/code&gt;, cloud-init fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One question&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stage/ask.py&lt;/code&gt;, needs the forwards&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ask_backend&lt;/code&gt;, needs the forwards&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safety signal&lt;/td&gt;
&lt;td&gt;the script's name&lt;/td&gt;
&lt;td&gt;read-only, write, destructive annotations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  So, Which One?
&lt;/h4&gt;

&lt;p&gt;🥇 &lt;strong&gt;The MCP server&lt;/strong&gt; to operate the demo from a chat session: inventory, health, logs and teardown in plain questions, with the billing count and the pass or fail computed in code.&lt;/p&gt;

&lt;p&gt;🥈 &lt;strong&gt;The stage scripts&lt;/strong&gt; for the talk itself and anything scripted, and for the port forwards, which &lt;code&gt;ask_backend&lt;/code&gt; and the demos need and the server does not open.&lt;/p&gt;

&lt;p&gt;Both launch and stop the same resources, because one calls the other.&lt;/p&gt;




&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to operate a six-backend AWS inference demo through MCP. The key to the solution was one server over the demo's own code, reaching instances through SSM Run Command and launching through the same scripts a person runs. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 &lt;strong&gt;Eight tools in one file over the demo's own code&lt;/strong&gt;: the same &lt;code&gt;chat_once&lt;/code&gt; and &lt;code&gt;count&lt;/code&gt; functions the scripts call, and the same &lt;code&gt;stage/up.sh&lt;/code&gt; and &lt;code&gt;stage/down.sh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;Every tool is marked read-only, write or destructive&lt;/strong&gt;, and &lt;code&gt;tools/list&lt;/code&gt; carries the marks to the client.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;Health checks and logs run on the instance through SSM Run Command&lt;/strong&gt;, with no inbound rules and no port forwards; g6 passed 12.2 minutes after launch and inf2 20.4 minutes after.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;A launch, health checks, logs, requests and a teardown ran from one session&lt;/strong&gt;, and &lt;code&gt;list_backends&lt;/code&gt; read &lt;code&gt;billing_now: 0&lt;/code&gt; 21 seconds after &lt;code&gt;teardown&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;🟢 &lt;strong&gt;Demo 1 over MCP matched demo 1 in-process on Bedrock&lt;/strong&gt;: 20 of 20 correct with the right filter both ways, median 1.07 seconds both ways.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;Failed calls return a ❌ line with &lt;code&gt;isError&lt;/code&gt; false&lt;/strong&gt;, so a client that branches on the protocol flag treats them as successes.&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;&lt;code&gt;ask_backend&lt;/code&gt; depends on &lt;code&gt;demo.env&lt;/code&gt;&lt;/strong&gt;, which outlives the forwards; it lists backends that are down until &lt;code&gt;stage/forward.sh&lt;/code&gt; runs again.&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;The server opens no port forwards&lt;/strong&gt;, so &lt;code&gt;ask_backend&lt;/code&gt; and the demos still need &lt;code&gt;stage/forward.sh&lt;/code&gt; from a terminal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one session on 2026-10-13 (UTC), us-east-2, two on-demand instances (g6.xlarge and inf2.xlarge) from launch to teardown, with Bedrock in us-east-1. trn1, g5g and the SageMaker endpoint were not launched, so their tools are shown only in their not-running replies. Health checks were one minute apart, so each time to ready is accurate to about a minute. Demo 1 ran 20 times per tool set on Bedrock only, with the MCP Python SDK 2.1.1, Strands Agents 1.55.0 and Python 3.14.7 on a laptop.&lt;/p&gt;

&lt;p&gt;The strategy for using MCP to operate Gemma 4 inference backends across AWS was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/aws-inference-strands" rel="noopener noreferrer"&gt;aws-inference-strands | GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/aws-builders/gemma-4-inference-on-aws-bedrock-sagemaker-gpus-inferentia-and-trainium-behind-one-strands-agent-1dm0"&gt;Gemma 4 Inference on AWS: Bedrock, SageMaker, GPUs, Inferentia and Trainium Behind One Strands Agent | DEV&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/server/tools" rel="noopener noreferrer"&gt;Model Context Protocol specification, tools | modelcontextprotocol.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/modelcontextprotocol/python-sdk" rel="noopener noreferrer"&gt;MCP Python SDK | GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://strandsagents.com/docs/user-guide/sdk/tools/mcp-tools/" rel="noopener noreferrer"&gt;Strands Agents, MCP tools | strandsagents.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/systems-manager/latest/userguide/run-command.html" rel="noopener noreferrer"&gt;AWS Systems Manager Run Command | AWS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>mcp</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>EmbeddingGemma 2 on Cloud Run: an int8 CPU Service Against an NVIDIA L4</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Tue, 13 Oct 2026 01:20:25 +0000</pubDate>
      <link>https://dev.to/gde/embeddinggemma-2-on-cloud-run-an-int8-cpu-service-against-an-nvidia-l4-15ac</link>
      <guid>https://dev.to/gde/embeddinggemma-2-on-cloud-run-an-int8-cpu-service-against-an-nvidia-l4-15ac</guid>
      <description>&lt;p&gt;This article provides a step by step deployment guide for EmbeddingGemma 2 text embeddings to Google Cloud Run, on a CPU-only instance and on an NVIDIA L4 GPU. Each service is tested, used in a small semantic search demo, benchmarked and priced per million queries and per million documents.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/embedding-cloudrun" rel="noopener noreferrer"&gt;https://github.com/xbill9/embedding-cloudrun&lt;/a&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  What is this project trying to Do?
&lt;/h4&gt;

&lt;p&gt;This project serves &lt;code&gt;google/embeddinggemma-2&lt;/code&gt; behind one HTTP endpoint on Cloud Run and answers a cost question: what does it take to embed text with it, on a 1 vCPU instance or on an L4?&lt;/p&gt;

&lt;p&gt;Cloud Run runs a container image as an HTTPS service. It starts instances when requests arrive, stops them when traffic stops, and needs no cluster, VM or load balancer. A CPU service can be billed per request: CPU and memory are charged only while a request is in flight. A GPU service is billed per instance: the L4 is charged for every second the instance is up.&lt;/p&gt;

&lt;p&gt;An L4 instance costs about ten times as much per hour as a 1 vCPU instance. So the comparison turns on two numbers: how much faster the L4 embeds, and how much of its time it spends busy.&lt;/p&gt;




&lt;h4&gt;
  
  
  Why Run Your Own Embeddings?
&lt;/h4&gt;

&lt;p&gt;Hosted decision APIs now sell work that embedding models have done for years. TypeSafe's Jev, launched on September 15, 2026, answers typed questions about a piece of text: pick one label from a list, place the text on a scale, or return the probability that a statement is true. It is priced at $0.042 per million input tokens, with access through a waitlist, OpenRouter and Vercel's AI Gateway.&lt;/p&gt;

&lt;p&gt;Ranking passages against a query and labelling text by its closest description are what an embedding model is built for. EmbeddingGemma 2 is an open model, so it can run in your own Google Cloud project, at Cloud Run's rates, with the text never leaving the project.&lt;/p&gt;

&lt;p&gt;This article prices that option per million input tokens, with the same unit Jev uses, and measures its speed and what it takes to deploy. Accuracy on the same items as Jev is not measured here; that head-to-head is the subject of a follow-up.&lt;/p&gt;




&lt;h4&gt;
  
  
  What is EmbeddingGemma 2?
&lt;/h4&gt;

&lt;p&gt;EmbeddingGemma 2 turns text into a 768-number vector, so that texts with similar meaning land close together. It also carries vision and audio encoders, which this project drops: only the text towers are loaded.&lt;/p&gt;

&lt;p&gt;The vectors are Matryoshka vectors. The first 128, 256 or 512 numbers of the 768 are themselves usable embeddings, so a search index can trade a little quality for a smaller footprint. The model also takes a prompt name: &lt;code&gt;SearchQuery&lt;/code&gt; for a question and &lt;code&gt;Document&lt;/code&gt; for a passage to be searched.&lt;/p&gt;




&lt;h4&gt;
  
  
  Three Services, One Dockerfile
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;service&lt;/th&gt;
&lt;th&gt;weights&lt;/th&gt;
&lt;th&gt;instance&lt;/th&gt;
&lt;th&gt;billing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;emb-cpu-repack&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text only, int8 Linears, int4 token table, 203 MiB&lt;/td&gt;
&lt;td&gt;1 vCPU, 2 GiB&lt;/td&gt;
&lt;td&gt;per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;emb-cpu-original&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text only, float32&lt;/td&gt;
&lt;td&gt;1 vCPU, 4 GiB&lt;/td&gt;
&lt;td&gt;per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;emb-l4-original&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text only, bf16&lt;/td&gt;
&lt;td&gt;4 vCPU, 16 GiB, 1 L4&lt;/td&gt;
&lt;td&gt;per instance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The repack is built by &lt;code&gt;repack/repack_text_w8a8_emb4.py&lt;/code&gt; in the repository: every Linear in the text model stored as int8 with one scale per output channel, and the 262,144-row token table as int4. The int8 Linears run on PyTorch's fbgemm kernels, which are CPU kernels, so the L4 serves the original weights in bf16.&lt;/p&gt;

&lt;p&gt;4 vCPU and 16 GiB is the smallest instance Cloud Run attaches an L4 to.&lt;/p&gt;




&lt;h4&gt;
  
  
  Where do I start?
&lt;/h4&gt;

&lt;p&gt;The strategy for starting the deployment is an incremental step by step approach.&lt;/p&gt;

&lt;p&gt;First, the model files are staged. Then each variant is built into an image, deployed, tested against its own contract and against the original weights, used for a search demo, timed, and priced from the Cloud Billing catalog.&lt;/p&gt;




&lt;h4&gt;
  
  
  At This Point You Should Have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;A Google Cloud project with billing, and the &lt;code&gt;gcloud&lt;/code&gt; CLI signed in&lt;/li&gt;
&lt;li&gt;The Cloud Run, Artifact Registry and Cloud Build APIs enabled&lt;/li&gt;
&lt;li&gt;Docker, for the CPU images&lt;/li&gt;
&lt;li&gt;Python 3.12 or newer with &lt;code&gt;torch&lt;/code&gt;, &lt;code&gt;transformers&lt;/code&gt; and &lt;code&gt;sentence-transformers&lt;/code&gt; for the local check and the repack&lt;/li&gt;
&lt;li&gt;For the L4: Cloud Run GPU quota, without zonal redundancy, in the region you deploy to&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Setup the Basic Environment
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/xbill9/embedding-cloudrun
&lt;span class="nb"&gt;cd &lt;/span&gt;embedding-cloudrun
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install &lt;/span&gt;&lt;span class="nv"&gt;torch&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;2.14.1 &lt;span class="nv"&gt;transformers&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;5.19.0 sentence-transformers&lt;span class="o"&gt;==&lt;/span&gt;6.1.0 huggingface_hub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scripts that talk to a deployed service (&lt;code&gt;test_service.py&lt;/code&gt;, &lt;code&gt;demo.py&lt;/code&gt;, &lt;code&gt;bench.py&lt;/code&gt;, &lt;code&gt;cost.py&lt;/code&gt;) use only the Python standard library.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Stage the Models
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/prepare_models.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script downloads a snapshot of &lt;code&gt;google/embeddinggemma-2&lt;/code&gt; into &lt;code&gt;models/original&lt;/code&gt;, then builds the repack from it into &lt;code&gt;models/repack&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dropped 473.4M tower parameters; checkpoint 203.4 MiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1.5G    models/original
235M    models/repack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repack takes about 8 seconds on a laptop CPU and is deterministic: a second build writes a &lt;code&gt;model.safetensors&lt;/code&gt; that is byte for byte the same. &lt;code&gt;models/&lt;/code&gt; is gitignored; the Dockerfile copies one of the two directories into the image.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — The Server
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;app/server.py&lt;/code&gt; is one FastAPI file with two routes. &lt;code&gt;MODEL_VARIANT&lt;/code&gt; picks the weights, and the model loads before uvicorn binds the port, so Cloud Run's startup time includes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/embed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EmbedRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dim must be 128, 256, 512 or 768&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;truncate_dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;convert_to_tensor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Normalize in float32: a bf16 model normalizes in bf16, leaving norms up to 0.25% off 1.
&lt;/span&gt;    &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;functional&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;dim&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embeddings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cpu&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every reply carries &lt;code&gt;seconds&lt;/code&gt;, the server's own time spent embedding. The benchmark and the cost model use that figure, so network distance between the client and the region does not enter the price.&lt;/p&gt;

&lt;p&gt;One request holds 1 to 256 texts. Vectors come back unit length, so a dot product is the cosine similarity.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Cloud Run Reserves Paths Ending in z
&lt;/h4&gt;

&lt;p&gt;The conventional health route is &lt;code&gt;/healthz&lt;/code&gt;. On Cloud Run a request for it never reaches the container: paths ending in &lt;code&gt;z&lt;/code&gt; are reserved, and the front end answers 404. The health route here is &lt;code&gt;/health&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Build and Deploy a CPU Service
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/deploy.sh cpu-repack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script builds the image locally, pushes it to Artifact Registry and deploys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run deploy emb-cpu-repack &lt;span class="nt"&gt;--image&lt;/span&gt; &lt;span class="nv"&gt;$IMAGE&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-unauthenticated&lt;/span&gt; &lt;span class="nt"&gt;--min-instances&lt;/span&gt; 0 &lt;span class="nt"&gt;--max-instances&lt;/span&gt; 1 &lt;span class="nt"&gt;--cpu-boost&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpu&lt;/span&gt; 1 &lt;span class="nt"&gt;--memory&lt;/span&gt; 2Gi &lt;span class="nt"&gt;--concurrency&lt;/span&gt; 1 &lt;span class="nt"&gt;--cpu-throttling&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set-env-vars&lt;/span&gt; &lt;span class="nv"&gt;TORCH_THREADS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Service [emb-cpu-original] revision [emb-cpu-original-00001-r96] has been deployed and is serving 100 percent of traffic.
Service URL: https://emb-cpu-original-1056842563084.us-central1.run.app

real    6m17.699s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output above is the float32 service, &lt;code&gt;scripts/deploy.sh cpu-original&lt;/code&gt;, from build to serving. The flags that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--cpu-throttling&lt;/code&gt; selects request-based billing: CPU is allocated, and charged, only during a request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--min-instances 0&lt;/code&gt; lets the service scale to zero, so an idle service costs nothing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--max-instances 1&lt;/code&gt; caps a runaway client at one instance.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--concurrency 1&lt;/code&gt; sends one request at a time to the single vCPU.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TORCH_THREADS=1&lt;/code&gt; matters because the container sees every core on the host while Cloud Run limits it to one vCPU by quota. PyTorch would otherwise start a thread per host core.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compressed in Artifact Registry, model included, the repack image is 636 MB and the float32 image 1270 MB.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Deploy the L4 Service
&lt;/h4&gt;

&lt;p&gt;The CUDA image is 4092 MB compressed in Artifact Registry, so &lt;code&gt;deploy.sh&lt;/code&gt; builds it on Cloud Build instead of on local disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SERVICE_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-east4 scripts/deploy.sh l4-original
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run deploy emb-l4-original &lt;span class="nt"&gt;--image&lt;/span&gt; &lt;span class="nv"&gt;$IMAGE&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; us-east4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-unauthenticated&lt;/span&gt; &lt;span class="nt"&gt;--min-instances&lt;/span&gt; 0 &lt;span class="nt"&gt;--max-instances&lt;/span&gt; 1 &lt;span class="nt"&gt;--cpu-boost&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpu&lt;/span&gt; 4 &lt;span class="nt"&gt;--memory&lt;/span&gt; 16Gi &lt;span class="nt"&gt;--gpu&lt;/span&gt; 1 &lt;span class="nt"&gt;--gpu-type&lt;/span&gt; nvidia-l4 &lt;span class="nt"&gt;--no-gpu-zonal-redundancy&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-cpu-throttling&lt;/span&gt; &lt;span class="nt"&gt;--concurrency&lt;/span&gt; 4 &lt;span class="nt"&gt;--set-env-vars&lt;/span&gt; &lt;span class="nv"&gt;TORCH_THREADS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Service [emb-l4-original] revision [emb-l4-original-00001-shk] has been deployed and is serving 100 percent of traffic.
Service URL: https://emb-l4-original-1056842563084.us-east4.run.app

real    2m30.320s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--no-gpu-zonal-redundancy&lt;/code&gt; gives up capacity reserved in a second zone for failover, at 0.0001867 USD per L4-second against 0.0002909 with it.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: GPU Quota Is Per Region
&lt;/h4&gt;

&lt;p&gt;The image lives in &lt;code&gt;us-central1&lt;/code&gt;, and the same deploy there was refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: (gcloud.run.deploy) spec.template.metadata.annotations[autoscaling.knative.dev/maxScale]: You do not have quota for using GPUs without zonal redundancy. Learn more about GPU zonal redundancy: g.co/cloudrun/gpu-redundancy-help
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quota is set per region and per redundancy mode. List it before choosing a region:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud beta quotas info describe NvidiaL4GpuAllocNoZonalRedundancyPerProjectRegion &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;run.googleapis.com &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{'region': 'europe-west1'} {'value': '3'}
{'region': 'europe-west4'} {'value': '3'}
{'region': 'us-central1'} {}
{'region': 'us-east4'} {'value': '3'}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A service in one region pulls an image from Artifact Registry in another, so only &lt;code&gt;SERVICE_REGION&lt;/code&gt; changes. The Cloud Billing catalog lists the same Cloud Run CPU, memory and L4 rates for &lt;code&gt;us-central1&lt;/code&gt; and &lt;code&gt;us-east4&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Test the Deployment
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/test_service.py&lt;/code&gt; checks one service against the contract the server promises: health, vector length and unit norm at every Matryoshka size, the 128-number vector being the first 128 of the 768, bad input rejected, and a relevant passage ranked above two unrelated ones.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/test_service.py https://emb-cpu-repack-fgasxpwzoq-uc.a.run.app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ health: {"variant": "repack", "device": "cpu", "threads": 1, "load_s": 9.03, "max_seq_length": 512}
✅ dim 128: length 128, norm 1.0000
✅ dim 256: length 256, norm 1.0000
✅ dim 512: length 512, norm 1.0000
✅ dim 768: length 768, norm 1.0000
✅ Matryoshka prefix: cosine(first 128 of 768, dim 128) = 1.00000
✅ rejects dim 100: {"error": "dim must be 128, 256, 512 or 768"}
✅ rejects empty texts: HTTP 422
✅ ranking: scores 0.776, 0.511, 0.642
9/9 checks passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ranking check asks "How do plants make food from sunlight?" against a passage on photosynthesis, one on the Treaty of Westphalia and one on sourdough. The float32 and L4 services pass the same nine checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ health: {"variant": "original", "device": "cuda:0", "threads": 4, "load_s": 6.87, "max_seq_length": 512}
✅ dim 128: length 128, norm 1.0000
...
9/9 checks passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  🔎 Tip: Normalize bf16 Embeddings in float32
&lt;/h4&gt;

&lt;p&gt;Sentence Transformers' &lt;code&gt;normalize_embeddings=True&lt;/code&gt; normalizes in the model's own precision. On the L4 the model runs in bf16, and vectors that should be unit length came back with norms of 0.9975 at 128 dimensions and 1.0015 at 512, so a dot product used as a cosine is off by the same amount. Loading the same weights in bf16 on a laptop CPU gives the same spread, 0.9977 to 1.0012.&lt;/p&gt;

&lt;p&gt;The server asks for the raw vectors and normalizes them in float32:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;truncate_dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;convert_to_tensor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;functional&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;dim&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that change every norm prints as 1.0000 on all three services.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — Does the Repack Match the Original?
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;--against&lt;/code&gt; embeds the same four texts on a second service and compares the vectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/test_service.py &lt;span class="nv"&gt;$REPACK_URL&lt;/span&gt; &lt;span class="nt"&gt;--against&lt;/span&gt; &lt;span class="nv"&gt;$ORIGINAL_URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ agrees with --against: cosine per text min 0.9888 max 0.9978
10/10 checks passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The L4 in bf16 and the CPU in float32 run the same original weights, and agree almost exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/test_service.py &lt;span class="nv"&gt;$L4_URL&lt;/span&gt; &lt;span class="nt"&gt;--against&lt;/span&gt; &lt;span class="nv"&gt;$ORIGINAL_URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ agrees with --against: cosine per text min 0.9999 max 1.0000
10/10 checks passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repack against the L4 measured 0.9858 to 0.9981. The repack's vectors shift slightly from one Cloud Run host to the next, because its int8 kernels depend on the host CPU, so the same check gives 0.986 to 0.998 across runs.&lt;/p&gt;

&lt;p&gt;A cosine near 0.99 says the vectors point the same way. Retrieval is the use that matters, so the repack was also scored on SciFact (300 queries over 5,183 scientific abstracts, 768 dimensions) with &lt;code&gt;repack/eval_scifact.py&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;weights&lt;/th&gt;
&lt;th&gt;nDCG@10&lt;/th&gt;
&lt;th&gt;recall@10&lt;/th&gt;
&lt;th&gt;top 10 shared with float32&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥇 float32 original&lt;/td&gt;
&lt;td&gt;0.854&lt;/td&gt;
&lt;td&gt;0.944&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈 int4 token table only&lt;/td&gt;
&lt;td&gt;0.850&lt;/td&gt;
&lt;td&gt;0.939&lt;/td&gt;
&lt;td&gt;92.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥉 repack (int8 + int4)&lt;/td&gt;
&lt;td&gt;0.836&lt;/td&gt;
&lt;td&gt;0.926&lt;/td&gt;
&lt;td&gt;85.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The repack keeps 97.9% of the original's nDCG@10.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — A Simple Demo: Semantic Search
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/demo.py&lt;/code&gt; embeds the sixteen passages in &lt;code&gt;docs/corpus.txt&lt;/code&gt; as documents and the question as a search query, then prints the closest three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/demo.py &lt;span class="nv"&gt;$REPACK_URL&lt;/span&gt; &lt;span class="s2"&gt;"Why does bread rise?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16 passages, dim 768, server 2.59 s, wall 2.99 s
0.853  Bread dough rises because yeast releases carbon dioxide that the gluten network traps.
0.698  A sourdough starter is a mixture of flour and water fermented by wild yeast and lactic acid bacteria.
0.638  The northern lights appear when charged particles from the sun excite gases in the upper atmosphere.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/demo.py &lt;span class="nv"&gt;$REPACK_URL&lt;/span&gt; &lt;span class="s2"&gt;"Which old document helped decode Egyptian writing?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16 passages, dim 768, server 2.63 s, wall 3.02 s
0.745  The Rosetta Stone carries the same decree in hieroglyphic, Demotic and Ancient Greek scripts.
0.631  Matryoshka embeddings can be truncated to a shorter prefix and still be used for retrieval.
0.595  A hash map stores key-value pairs and finds a key in constant time on average.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second question shares no keywords with its answer: "Rosetta Stone" and "hieroglyphic" appear only in the passage. The L4 returns the same ranking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/demo.py &lt;span class="nv"&gt;$L4_URL&lt;/span&gt; &lt;span class="s2"&gt;"Which old document helped decode Egyptian writing?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16 passages, dim 768, server 0.13 s, wall 0.42 s
0.749  The Rosetta Stone carries the same decree in hieroglyphic, Demotic and Ancient Greek scripts.
0.634  Matryoshka embeddings can be truncated to a shorter prefix and still be used for retrieval.
0.592  A hash map stores key-value pairs and finds a key in constant time on average.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three services put the same passage first for all three demo questions.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — Cold Start
&lt;/h4&gt;

&lt;p&gt;With &lt;code&gt;--min-instances 0&lt;/code&gt;, the first request after an idle period starts an instance: Cloud Run places it, pulls the image and starts the container, and the model loads before the port opens. &lt;code&gt;scripts/coldstart.sh&lt;/code&gt; times that first request, then a second one, after 25 minutes with no traffic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/coldstart.sh &lt;span class="nv"&gt;$L4_URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-12T17:46:53Z
{"variant":"original","device":"cuda:0","threads":4,"load_s":4.78,"max_seq_length":512}
http=200 total_s=17.271792
second http=200 total_s=0.098748
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scripts/coldstart_summary.py&lt;/code&gt; splits each first request into the server's model load and the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;file                                   variant    first request s  model load s  the rest s  warm s
coldstart-cpu-original.txt             original              48.0          15.5        32.5    0.13
coldstart-cpu-repack-2.txt             repack                58.4           9.5        48.9    0.11
coldstart-cpu-repack.txt               repack                83.5          13.8        69.7    0.13
coldstart-l4-original.txt              original              17.3           4.8        12.5    0.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model load is the smaller part on every service. The CPU services took 48 to 84 seconds to answer a first request, and the L4 took 17, with the largest image of the three. Cloud Run's request log for the float32 cold start shows the order: instance started at 17:08:08, first line from the container at 17:08:49, startup check passed at 17:08:56.&lt;/p&gt;

&lt;p&gt;A service that cannot wait that long can keep one instance warm with &lt;code&gt;--min-instances 1&lt;/code&gt;; Cloud Run pricing lists how idle instances are billed.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 9 — Benchmark
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/bench.py&lt;/code&gt; sends 50 single-query requests, then 10 requests of 32 documents of 141 tokens each (&lt;code&gt;scripts/count_tokens.py&lt;/code&gt;, prompt included), and records the server's own time for each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/bench.py cpu-repack &lt;span class="nv"&gt;$REPACK_URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each run is kept in &lt;code&gt;results/runs/&lt;/code&gt;. A 1 vCPU instance lands on whichever host Cloud Run picks, so each variant was run three times, each on a new revision and so a new instance. &lt;code&gt;cost.py&lt;/code&gt; reports the median run and the range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;variant        runs          query ms            docs/s  USD / 1M queries       USD / 1M docs
cpu-original      3     251 (227-251)     0.9 (0.7-0.9)              8.93 39.05 (38.37-47.72)
cpu-repack        3      121 (88-128)     1.1 (1.0-1.6)              3.91 25.87 (18.17-27.69)
l4-original       3        56 (54-59) 333.1 (322.7-339.1)             16.27    0.87 (0.86-0.90)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scripts/ratios.py&lt;/code&gt; turns the medians into ratios:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L4 docs/s over cpu-repack docs/s: 297x
cpu-original query time over cpu-repack query time: 2.08x
cpu-repack docs/s over cpu-original docs/s: 1.29x
SciFact nDCG@10, repack over float32: 97.9%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the same 1 vCPU, the repack answers a query in half the time of float32 and embeds documents 1.29x as fast, in half the memory. The L4 embeds 297 times as many documents per second as the repack.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 10 — Cost Analysis
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/fetch_prices.py&lt;/code&gt; reads the Cloud Run rates from the Cloud Billing Catalog API into &lt;code&gt;prices.json&lt;/code&gt;, and &lt;code&gt;scripts/cost.py&lt;/code&gt; does all the arithmetic from those rates and the benchmark runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 scripts/cost.py results/runs/&lt;span class="k"&gt;*&lt;/span&gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prices fetched 2026-10-10, us-central1

variant        billing    USD/hour up
cpu-repack     request         0.1044
cpu-original   request         0.1224
l4-original    instance        1.0465
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A per-request CPU service is charged its server seconds times its per-second rate, plus 0.40 USD per million requests after the first two million each month. An idle CPU service costs nothing. The L4 is charged for every second its instance is up, so its per-document figures in Step 9 hold only while it is kept busy.&lt;/p&gt;




&lt;h4&gt;
  
  
  And Price/Performance?
&lt;/h4&gt;

&lt;p&gt;An instance-billed L4 that is busy part of the time costs more per document in proportion. &lt;code&gt;cost.py&lt;/code&gt; prints the curve against the repack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L4 cost per 1M docs when the instance is busy part of the time it is up (vs cpu-repack, median runs):
  busy  L4 USD / 1M docs   cpu-repack USD / 1M docs
  100%              0.87                      25.87
   50%              1.75                      25.87
   25%              3.49                      25.87
   10%              8.73                      25.87
    5%             17.46                      25.87
    1%             87.28                      25.87
break-even: the L4 is cheaper per document while busy more than 3.4% of the time it is up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per million input tokens, the unit Jev is priced in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;USD per 1M input tokens, documents of 141 tokens (median runs):
cpu-original     0.2770
cpu-repack       0.1835
l4-original      0.0062  at 100% busy
l4-original      0.0619  at 10% busy
jev (hosted)     0.0420  list price
l4-original matches Jev's price per input token while busy 14.7% of the time it is up; at 100% busy Jev costs 6.8x as much
cpu-original costs 6.6x Jev's price per input token
cpu-repack costs 4.4x Jev's price per input token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A busy L4 embeds text for a sixth of Jev's list price per input token. The 1 vCPU repack costs 4.4 times Jev's price per token, and nothing at all while idle.&lt;/p&gt;




&lt;h4&gt;
  
  
  Compare and Contrast
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;cpu-repack&lt;/th&gt;
&lt;th&gt;cpu-original&lt;/th&gt;
&lt;th&gt;l4-original&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Query, median&lt;/td&gt;
&lt;td&gt;🥈 121 ms&lt;/td&gt;
&lt;td&gt;🥉 251 ms&lt;/td&gt;
&lt;td&gt;🥇 56 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documents per second&lt;/td&gt;
&lt;td&gt;🥈 1.1&lt;/td&gt;
&lt;td&gt;🥉 0.9&lt;/td&gt;
&lt;td&gt;🥇 333&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USD per 1M queries&lt;/td&gt;
&lt;td&gt;🥇 3.91&lt;/td&gt;
&lt;td&gt;🥈 8.93&lt;/td&gt;
&lt;td&gt;🥉 16.27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USD per 1M documents, busy&lt;/td&gt;
&lt;td&gt;🥈 25.87&lt;/td&gt;
&lt;td&gt;🥉 39.05&lt;/td&gt;
&lt;td&gt;🥇 0.87&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost while idle&lt;/td&gt;
&lt;td&gt;🥇 none&lt;/td&gt;
&lt;td&gt;🥇 none&lt;/td&gt;
&lt;td&gt;🥉 1.05 USD per hour up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;🥉 58–84 s&lt;/td&gt;
&lt;td&gt;🥈 48 s&lt;/td&gt;
&lt;td&gt;🥇 17 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU quota needed&lt;/td&gt;
&lt;td&gt;🥇 no&lt;/td&gt;
&lt;td&gt;🥇 no&lt;/td&gt;
&lt;td&gt;🥉 yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SciFact nDCG@10&lt;/td&gt;
&lt;td&gt;🥈 0.836&lt;/td&gt;
&lt;td&gt;🥇 0.854&lt;/td&gt;
&lt;td&gt;not run; cosine 0.9999 to float32&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  So, Which One?
&lt;/h4&gt;

&lt;p&gt;For search queries arriving through the day, use the CPU repack. It is the cheapest per query, costs nothing between requests, needs no GPU quota, and keeps 97.9% of the original's retrieval score.&lt;/p&gt;

&lt;p&gt;For indexing a corpus, use the L4. It embeds 297 times as fast and costs 0.87 USD per million documents while busy; it pays for itself over the repack once it is busy more than 3.4% of the time it is up. Deploy it for the job and let it scale to zero afterwards.&lt;/p&gt;

&lt;p&gt;The float32 CPU service is the reference that the other two are checked against.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 11 — Teardown
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/down.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It deletes the three services in both regions. The images stay in Artifact Registry until the repository is deleted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud artifacts repositories delete embedding-cloudrun &lt;span class="nt"&gt;--location&lt;/span&gt; us-central1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to deploy EmbeddingGemma 2 text embeddings to Cloud Run and price them against a hosted decision API. The key to the solution was one server and one Dockerfile for three services: an int8 repack on 1 vCPU with per-request billing, the float32 original on 1 vCPU as the reference, and the original in bf16 on an NVIDIA L4. The deployment results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 All three services pass the same nine checks, and the demo puts the same passage first on all three&lt;/li&gt;
&lt;li&gt;🟢 The repack matches float32 at a cosine of 0.986 to 0.998 per text and keeps 97.9% of its SciFact nDCG@10&lt;/li&gt;
&lt;li&gt;🟢 The repack answers a query in a median 121 ms on 1 vCPU, at 3.91 USD per million queries and nothing while idle&lt;/li&gt;
&lt;li&gt;🟢 The L4 embeds 333 documents per second, 0.87 USD per million documents while busy, 6.8 times below Jev's list price per input token&lt;/li&gt;
&lt;li&gt;⚠️ The L4 bills for every second it is up and is cheaper per document than the repack only while busy more than 3.4% of that time&lt;/li&gt;
&lt;li&gt;⚠️ Cold starts took 48 to 84 seconds on CPU and 17 on the L4&lt;/li&gt;
&lt;li&gt;⚠️ bf16 vectors need float32 normalization to come back unit length&lt;/li&gt;
&lt;li&gt;❌ No L4 quota without zonal redundancy in &lt;code&gt;us-central1&lt;/code&gt;; the L4 ran in &lt;code&gt;us-east4&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one Google Cloud project, CPU services in &lt;code&gt;us-central1&lt;/code&gt; and the L4 in &lt;code&gt;us-east4&lt;/code&gt;, one instance per service, Cloud Run prices from the Billing Catalog on 2026-10-10, measured 2026-10-11 and 2026-10-12. Three benchmark runs per variant, each on a new instance, with server-side timing, one document length (141 tokens) and one request at a time; the L4 was not tested with concurrent requests. Cold starts are single measurements. Jev's figure is its list price per input token; its accuracy against EmbeddingGemma 2 on the same items is not measured here.&lt;/p&gt;

&lt;p&gt;The strategy for deploying EmbeddingGemma 2 on Cloud Run was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/xbill9/embedding-cloudrun" rel="noopener noreferrer"&gt;https://github.com/xbill9/embedding-cloudrun&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;EmbeddingGemma 2 on Hugging Face: &lt;a href="https://huggingface.co/google/embeddinggemma-2" rel="noopener noreferrer"&gt;https://huggingface.co/google/embeddinggemma-2&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloud Run GPU support: &lt;a href="https://cloud.google.com/run/docs/configuring/services/gpu" rel="noopener noreferrer"&gt;https://cloud.google.com/run/docs/configuring/services/gpu&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloud Run billing settings: &lt;a href="https://cloud.google.com/run/docs/configuring/billing-settings" rel="noopener noreferrer"&gt;https://cloud.google.com/run/docs/configuring/billing-settings&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloud Run pricing: &lt;a href="https://cloud.google.com/run/pricing" rel="noopener noreferrer"&gt;https://cloud.google.com/run/pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Sentence Transformers: &lt;a href="https://sbert.net/" rel="noopener noreferrer"&gt;https://sbert.net/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SciFact in BEIR: &lt;a href="https://huggingface.co/datasets/mteb/scifact" rel="noopener noreferrer"&gt;https://huggingface.co/datasets/mteb/scifact&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TypeSafe Jev: &lt;a href="https://typesafe.ai" rel="noopener noreferrer"&gt;https://typesafe.ai&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Jev after eight days of independent tests: &lt;a href="https://dev.to/aws-builders/jev-after-eight-days-of-independent-tests-level-with-mid-price-llms-behind-the-frontier-1c60"&gt;https://dev.to/aws-builders/jev-after-eight-days-of-independent-tests-level-with-mid-price-llms-behind-the-frontier-1c60&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>googlecloud</category>
      <category>gemma</category>
      <category>cloudrun</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Debian on a Samsung Galaxy Book Go, Written on the Galaxy Book Go</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Tue, 13 Oct 2026 01:00:40 +0000</pubDate>
      <link>https://dev.to/xbill/debian-on-a-samsung-galaxy-book-go-written-on-the-galaxy-book-go-p99</link>
      <guid>https://dev.to/xbill/debian-on-a-samsung-galaxy-book-go-written-on-the-galaxy-book-go-p99</guid>
      <description>&lt;p&gt;This article provides a step by step guide to running Debian sid on a Samsung Galaxy Book Go, the Snapdragon 7c Gen 2 Windows-on-ARM laptop. A small set of POSIX shell scripts and one device tree patch turn a community draft into a machine that runs a daily desktop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/linux-arm-adventures" rel="noopener noreferrer"&gt;https://github.com/xbill9/linux-arm-adventures&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kernel 7.2.9, GNOME 50.5 on Wayland, GPU acceleration, Wi-Fi, Bluetooth, the speakers, the camera, the battery gauge and charging, the backlight, the keyboard, touchpad, power button, lid and suspend all work. The hardware video decoder works in mpv, and browsers decode in software. This article was written, fact-checked and built on the laptop it describes.&lt;/strong&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  What Is the Problem?
&lt;/h4&gt;

&lt;p&gt;The Galaxy Book Go has no upstream support. Mainline Linux has no device tree for it, and Debian's kernel ships only the Acer Aspire 1 tree for the same SoC (SC7180). The community device tree is a draft with Aspire 1 memory addresses and a keyboard interrupt its own comment calls wrong.&lt;/p&gt;

&lt;p&gt;ACPI is no way out. A generic ACPI kernel reboots the machine as soon as the SMMU driver loads, and without the SMMU there is no USB and no keyboard. So the laptop boots with a device tree, and every missing node in that tree is a missing device.&lt;/p&gt;

&lt;p&gt;Samsung publishes no driver pack for it either. The only copy of the signed firmware is the factory Windows install.&lt;/p&gt;




&lt;h4&gt;
  
  
  Drivers on ARM Take Boots
&lt;/h4&gt;

&lt;p&gt;On a PC the kernel finds the hardware. On this laptop the kernel knows only what the device tree tells it, and each device needs three things before its driver binds: a node in the tree, Samsung's firmware, and a board fact read out of the Windows side, such as an interrupt pin or a power pin. A driver setup that runs on the Acer Aspire 1, with the same SoC, is a starting guess here. The draft tree's keyboard interrupt, display bridge interrupt and audio memory region came from the Aspire 1, and so did the first guess at the speaker data line. All four were wrong on this board.&lt;/p&gt;

&lt;p&gt;Every guess is tested with a reboot. The installed system logged 26 boots over the two days of this bring-up, 16 on 2026-10-11 and 9 on 2026-10-12, plus UEFI shell boots that leave no journal:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subsystem&lt;/th&gt;
&lt;th&gt;Test boots&lt;/th&gt;
&lt;th&gt;What the boots settled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Speakers&lt;/td&gt;
&lt;td&gt;4, then 1 as default&lt;/td&gt;
&lt;td&gt;DSP memory region, DSP front ends, speaker data line, speaker supply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Battery, camera, backlight&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;the first one put the backlight on the wrong PWM channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyboard interrupt&lt;/td&gt;
&lt;td&gt;1, plus a UEFI shell boot&lt;/td&gt;
&lt;td&gt;the pin, read from Samsung's ACPI tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU firmware&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;the zap shader from the Windows driver store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Display bridge interrupt&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;the interrupt removed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each risky change goes in as a one-time boot with &lt;code&gt;grub-reboot&lt;/code&gt;, so a black screen costs one power cycle. Plan for several boots per device, and check every file the boot reads before each one.&lt;/p&gt;




&lt;h4&gt;
  
  
  The Dog Food Test
&lt;/h4&gt;

&lt;p&gt;A port that boots to a login screen proves the boot path. A port that the author uses to write about it proves the keyboard, the desktop, the network and the toolchain all at once, for hours.&lt;/p&gt;

&lt;p&gt;This article was drafted in GNOME Terminal on the Galaxy Book Go, by a native arm64 Claude Code running in the local Wayland session, with the repository on the internal UFS disk. The capture below was taken from that session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/class/dmi/id/sys_vendor /sys/class/dmi/id/product_name
SAMSUNG ELECTRONICS CO., LTD.
Galaxy Book Go &lt;span class="o"&gt;(&lt;/span&gt;Wi-Fi&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
7.2.9+deb14-arm64

&lt;span class="nv"&gt;$ &lt;/span&gt;loginctl show-session 2 &lt;span class="nt"&gt;-p&lt;/span&gt; Type &lt;span class="nt"&gt;-p&lt;/span&gt; Remote
&lt;span class="nv"&gt;Remote&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no
&lt;span class="nv"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wayland

&lt;span class="nv"&gt;$ &lt;/span&gt;file &lt;span class="nt"&gt;-bL&lt;/span&gt; ~/.local/bin/claude | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;, &lt;span class="nt"&gt;-f1-2&lt;/span&gt;
ELF 64-bit LSB executable, ARM aarch64

&lt;span class="nv"&gt;$ &lt;/span&gt;systemctl is-system-running
running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same capture also shows where the laptop strains. It has 3.4 GiB of usable memory, and 1.3 GiB was in swap while this was written, with a browser, GNOME and the assistant open together.&lt;/p&gt;




&lt;h4&gt;
  
  
  At This Point You Should Have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;A Galaxy Book Go (NP340XLA or NP345XLA), Secure Boot off.&lt;/li&gt;
&lt;li&gt;A Debian 13 installer stick for the Book Go that carries the community device tree, &lt;code&gt;sc7180-samsung-galaxy-book-go.dtb&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A USB-C hub with a USB keyboard and a USB Ethernet adapter. USB boot is unreliable straight into the port and reliable through a hub.&lt;/li&gt;
&lt;li&gt;The Windows &lt;code&gt;DriverStore\FileRepository&lt;/code&gt; from a Book Go, copied off before Windows was wiped.&lt;/li&gt;
&lt;li&gt;A second machine with SSH access, for the rescue path.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 1 — Put the Device Tree in the Boot Menu
&lt;/h4&gt;

&lt;p&gt;The installer writes a stock GRUB menu. It has no &lt;code&gt;devicetree&lt;/code&gt; line and no &lt;code&gt;clk_ignore_unused pd_ignore_unused&lt;/code&gt;, so the installed kernel boots through ACPI and the machine resets. The installed system had never booted: its journal was empty.&lt;/p&gt;

&lt;p&gt;The fix is a GRUB generator, &lt;code&gt;scripts/09_galaxy-book-go&lt;/code&gt;, installed as &lt;code&gt;/etc/grub.d/09_galaxy-book-go&lt;/code&gt; so it runs before &lt;code&gt;10_linux&lt;/code&gt;. Per kernel it writes three entries, each with the device tree and the clock options:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entry id&lt;/th&gt;
&lt;th&gt;What it is for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gbgo-&amp;lt;kver&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;normal boot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gbgo-safe-&amp;lt;kver&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no GPU or DSP drivers, known-good initramfs, verbose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gbgo-debug-&amp;lt;kver&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ignore_loglevel&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;GRUB_DEFAULT&lt;/code&gt; names an entry id, never a position, and the menu waits 10 seconds. Every risky change after this is tried once with &lt;code&gt;grub-reboot &amp;lt;id&amp;gt;&lt;/code&gt;, so a hang falls back to the default on the next power cycle.&lt;/p&gt;

&lt;p&gt;The first boot from the internal disk reached &lt;code&gt;running&lt;/code&gt; and the GDM login.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Make GNOME See the Keyboard
&lt;/h4&gt;

&lt;p&gt;The text console took key presses, and GNOME took none. The internal keyboard (&lt;code&gt;hid-over-i2c 0CF2:9028&lt;/code&gt;) reports a stray &lt;code&gt;ABS&lt;/code&gt; axis, so udev tags it &lt;code&gt;ID_INPUT_TABLET_PAD&lt;/code&gt;, and libinput hands it to GNOME as a tablet pad.&lt;/p&gt;

&lt;p&gt;A udev rule clears the tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;ACTION&lt;/span&gt;!=&lt;span class="s2"&gt;"remove"&lt;/span&gt;, &lt;span class="n"&gt;SUBSYSTEM&lt;/span&gt;==&lt;span class="s2"&gt;"input"&lt;/span&gt;, &lt;span class="n"&gt;ATTRS&lt;/span&gt;{&lt;span class="n"&gt;name&lt;/span&gt;}==&lt;span class="s2"&gt;"hid-over-i2c 0CF2:9028 Keyboard"&lt;/span&gt;, &lt;span class="n"&gt;ENV&lt;/span&gt;{&lt;span class="n"&gt;ID_INPUT_TABLET&lt;/span&gt;}=&lt;span class="s2"&gt;""&lt;/span&gt;, &lt;span class="n"&gt;ENV&lt;/span&gt;{&lt;span class="n"&gt;ID_INPUT_TABLET_PAD&lt;/span&gt;}=&lt;span class="s2"&gt;""&lt;/span&gt;, &lt;span class="n"&gt;ENV&lt;/span&gt;{&lt;span class="n"&gt;ID_INPUT_KEYBOARD&lt;/span&gt;}=&lt;span class="s2"&gt;"1"&lt;/span&gt;, &lt;span class="n"&gt;ENV&lt;/span&gt;{&lt;span class="n"&gt;ID_INPUT_KEY&lt;/span&gt;}=&lt;span class="s2"&gt;"1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It must match the &lt;code&gt;input0&lt;/code&gt; node as well as &lt;code&gt;event0&lt;/code&gt;. libinput reads the parent's tags too, so a rule on &lt;code&gt;event*&lt;/code&gt; alone leaves the device classified as a tablet pad. With both, &lt;code&gt;libinput list-devices&lt;/code&gt; reports &lt;code&gt;Capabilities: keyboard pointer&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Do Not Restart GDM With Someone Logged In
&lt;/h4&gt;

&lt;p&gt;A running GNOME keeps the old device classification until it restarts. Restarting &lt;code&gt;gdm3&lt;/code&gt; closes every open session. Log out instead.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Take the Firmware From Windows
&lt;/h4&gt;

&lt;p&gt;The device tree asks for four Samsung-signed files under &lt;code&gt;qcom/sc7180/samsung/galaxy-book-go/&lt;/code&gt;, and Wi-Fi needs a fifth. All five are in the Windows driver store. Where several driver versions sat side by side, the newest was taken:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qcdxkmsuc7180.mbn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GPU zap shader&lt;/td&gt;
&lt;td&gt;27.20.0910&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qcmpss7180_nm.mbn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;modem DSP, which hosts Wi-Fi&lt;/td&gt;
&lt;td&gt;1.0.0800.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wlanmdsp.mbn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wi-Fi firmware, served to the modem&lt;/td&gt;
&lt;td&gt;1.0.0970&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qcvss7180.mbn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;video decode and encode&lt;/td&gt;
&lt;td&gt;27.20.0910&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qcadsp7180.mbn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;audio DSP&lt;/td&gt;
&lt;td&gt;1.0.0750.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A bad firmware file can reset a Qualcomm SoC on every boot. So nothing went into &lt;code&gt;/usr/lib/firmware&lt;/code&gt; before it had run once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stage the files in &lt;code&gt;/root/gbgo-fw-staging/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Point &lt;code&gt;/sys/module/firmware_class/parameters/path&lt;/code&gt; at the staging directory. That lasts for the running boot only.&lt;/li&gt;
&lt;li&gt;Start each remote processor by hand and read the log.&lt;/li&gt;
&lt;li&gt;For the GPU, which retries only at boot, use a one-time GRUB entry with &lt;code&gt;firmware_class.path&lt;/code&gt; on the kernel command line.&lt;/li&gt;
&lt;li&gt;Install with &lt;code&gt;gbgo-firmware install&lt;/code&gt;, which also has &lt;code&gt;promote&lt;/code&gt; and &lt;code&gt;rollback&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The modem came up and &lt;code&gt;ath10k_snoc&lt;/code&gt; reported &lt;code&gt;WLAN.HL.3.2.2.c10-00767&lt;/code&gt;. Venus bound its decoder and encoder. The GPU came up as &lt;code&gt;FD618&lt;/code&gt; and gnome-shell left software rendering.&lt;/p&gt;

&lt;p&gt;The audio DSP failed safely: &lt;code&gt;segment outside memory range&lt;/code&gt;, &lt;code&gt;Boot failed: -22&lt;/code&gt;. Samsung's image loads at &lt;code&gt;0x90b00000&lt;/code&gt;, and the draft tree reserves the Aspire 1's region at &lt;code&gt;0x8e400000&lt;/code&gt;. Step 9 fixes the region.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Know What Comes From Windows
&lt;/h4&gt;

&lt;p&gt;Five signed binaries run this laptop's GPU, Wi-Fi, video and audio, and the only source for them is a Windows driver store. Debian's firmware packages ship none of them, and Samsung publishes no driver pack. They run on the modem, audio and video processors, outside what Linux can inspect. This repository keeps their hashes and source paths, and the files themselves stay out of it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;From Windows&lt;/th&gt;
&lt;th&gt;Used for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Five firmware files&lt;/td&gt;
&lt;td&gt;GPU, Wi-Fi, video decode, audio DSP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ACPI tables&lt;/td&gt;
&lt;td&gt;keyboard interrupt, camera power pin, battery and charger addresses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio calibration database&lt;/td&gt;
&lt;td&gt;speaker port and data line&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A Book Go whose Windows was wiped without a copy of its driver store has no source for the firmware. Copy &lt;code&gt;Windows\System32\DriverStore\FileRepository&lt;/code&gt; off before installing Linux. The Yoga C630 has the same dependency: its firmware comes from Lenovo's Windows driver releases, mirrored on GitHub.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Win the Wi-Fi Boot Race
&lt;/h4&gt;

&lt;p&gt;On some boots Wi-Fi took about 60 seconds, and on others 17. The modem asks &lt;code&gt;tqftpserv&lt;/code&gt; for &lt;code&gt;wlanmdsp.mbn&lt;/code&gt; a few milliseconds after the service starts. If it asks first, the WLAN start stalls, and the modem's watchdog crashes it after 40 seconds (&lt;code&gt;DOG detects stalled initialization&lt;/code&gt;). The kernel restarts it, and the second try works.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gbgo-wifi-kick&lt;/code&gt; runs after &lt;code&gt;rmtfs&lt;/code&gt; and &lt;code&gt;tqftpserv&lt;/code&gt;. If no Wi-Fi interface exists 10 seconds later, it stops and starts the modem's remote processor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;r &lt;span class="k"&gt;in&lt;/span&gt; /sys/class/remoteproc/remoteproc&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="s2"&gt;/name"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;R&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$R&lt;/span&gt;&lt;span class="s2"&gt;/state"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; running &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;stop &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$R&lt;/span&gt;&lt;span class="s2"&gt;/state"&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;2
&lt;span class="nb"&gt;echo &lt;/span&gt;start &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$R&lt;/span&gt;&lt;span class="s2"&gt;/state"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks for any interface under &lt;code&gt;/sys/class/ieee80211/phy*/device/net/&lt;/code&gt;, never for a name. Under sid the interface is &lt;code&gt;wld0&lt;/code&gt;. The restart was tested by hand. The service has not yet had to fire during a real boot.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Give Bluetooth an Address
&lt;/h4&gt;

&lt;p&gt;The WCN3991 loads &lt;code&gt;qca/crbtfw32.tlv&lt;/code&gt; from &lt;code&gt;firmware-atheros&lt;/code&gt; and stays unconfigured, because nothing supplies a public address. &lt;code&gt;gbgo-bt-addr&lt;/code&gt; sets a stable, locally administered one derived from &lt;code&gt;/etc/machine-id&lt;/code&gt;, before &lt;code&gt;bluetooth.service&lt;/code&gt; starts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;btmgmt&lt;/code&gt; from bluez 5.82 waits forever when its standard input is &lt;code&gt;/dev/null&lt;/code&gt;, which is what systemd gives a service. Over SSH the same command returns at once. The script feeds it an empty pipe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bm&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; | &lt;span class="nb"&gt;timeout &lt;/span&gt;3 btmgmt &lt;span class="nt"&gt;-i&lt;/span&gt; hci0 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bluetoothctl show | head -1
Controller 02:33:FD:7E:34:33 (public)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 6 — Read the Keyboard Interrupt From Samsung's ACPI Tables
&lt;/h4&gt;

&lt;p&gt;The keyboard worked, at a price. The draft tree wires its interrupt to TLMM GPIO 48, level-low, and GPIO 48 sits low permanently. The interrupt never clears, so the driver polls the keyboard about 1100 times a second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;i2c_hid_of 6-0005: i2c_hid_get_input: incomplete report (11/65535)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was 785,000 log lines in 14 minutes and about 7 % of a CPU. Watching all 73 free input GPIOs while typing showed nothing.&lt;/p&gt;

&lt;p&gt;Samsung's ACPI tables describe the board for the Windows drivers. Linux boots with a device tree here, so &lt;code&gt;/sys/firmware/acpi/tables&lt;/code&gt; is empty and &lt;code&gt;/dev/mem&lt;/code&gt; refuses the page. The UEFI shell can read it. A one-time GRUB entry chainloads Debian's &lt;code&gt;shellaa64.efi&lt;/code&gt;, and a &lt;code&gt;startup.nsh&lt;/code&gt; at the root of the ESP dumps the EFI-reserved block holding the tables and resets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dmem 0xFFF22000 0xDC000 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; mem.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;acpi-from-dmem.py&lt;/code&gt; rebuilds the bytes and follows RSDP → XSDT → DSDT. The keyboard device, &lt;code&gt;ECKB&lt;/code&gt;, declares:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GpioInt(Level, ActiveLow, PullUp) pin 0x140
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Qualcomm's ACPI, a pin of &lt;code&gt;0x40·k&lt;/code&gt; above the 120 real pins means direct-connect interrupt number &lt;em&gt;k&lt;/em&gt;. &lt;code&gt;0x140&lt;/code&gt; is interrupt 5, which is PDC pin 67, which is &lt;strong&gt;TLMM GPIO 104&lt;/strong&gt;. The mapping checks out against three known pins: the SD card detect, the touchpad and the lid.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gbgo-dtb-patch&lt;/code&gt; builds the tree from the draft with three changed keyboard lines: interrupt &lt;code&gt;0x30&lt;/code&gt; → &lt;code&gt;0x68&lt;/code&gt;, pin &lt;code&gt;gpio48&lt;/code&gt; → &lt;code&gt;gpio104&lt;/code&gt;, &lt;code&gt;bias-disable&lt;/code&gt; → &lt;code&gt;bias-pull-up&lt;/code&gt;. After the change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;hid-over-i2c /proc/interrupts
 147:        799          0          0          0          0          0          0          0  msmgpio   104 Level     hid-over-i2c
 148:       4645          0          0          0          0          0          0          0  msmgpio    31 Level     hid-over-i2c

&lt;span class="nv"&gt;$ &lt;/span&gt;journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"incomplete report"&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 799 interrupts on GPIO 104 are every key pressed since this boot, including the request that started this article.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Rehearse Firmware Scripts in QEMU
&lt;/h4&gt;

&lt;p&gt;The shell dump was run first in &lt;code&gt;qemu-system-aarch64 -M virt&lt;/code&gt; with AAVMF, the laptop's own shim, GRUB and UEFI shell, and twelve extra FAT disks. That rehearsal showed three things before any reboot of the laptop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The shell runs &lt;code&gt;startup.nsh&lt;/code&gt; only from the root of a filesystem.&lt;/li&gt;
&lt;li&gt;With many FAT partitions, the ESP sits past &lt;code&gt;fs9&lt;/code&gt;, so the script searches by a marker file.&lt;/li&gt;
&lt;li&gt;Debian's UEFI shell has no &lt;code&gt;acpiview&lt;/code&gt;. &lt;code&gt;dmem&lt;/code&gt; with &lt;code&gt;&amp;gt;&lt;/code&gt; redirection works.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 7 — Add the Power Button and the Lid, Quiet the Display Bridge
&lt;/h4&gt;

&lt;p&gt;The same patch script adds two more devices to the tree and removes one wrong interrupt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Power button.&lt;/strong&gt; The draft disables &lt;code&gt;pon@800&lt;/code&gt;, so &lt;code&gt;pm8941-pwrkey&lt;/code&gt; never binds. Enabling it gives &lt;code&gt;KEY_POWER&lt;/code&gt; and GNOME's power-off dialog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lid.&lt;/strong&gt; The draft has no node. ACPI &lt;code&gt;LID0&lt;/code&gt; returns the raw level of GPIO 93, &lt;code&gt;1&lt;/code&gt; for open, so a &lt;code&gt;gpio-keys&lt;/code&gt; node reports &lt;code&gt;SW_LID&lt;/code&gt; on TLMM 93, active low.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Display bridge interrupt.&lt;/strong&gt; The draft gives the SN65DSI86 eDP bridge the Aspire 1's level-high interrupt on TLMM 11. GPIO 11 reads high here, so the kernel disabled it at every boot after 100,001 interrupts (&lt;code&gt;irq 147: nobody cared&lt;/code&gt;), with I2C errors on the bridge's bus. Samsung's ACPI tables declare no interrupt for the bridge, so the patch removes it. Kernel warnings per boot fell from 62 to 26, and the panel works as before.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Suspend is &lt;code&gt;s2idle&lt;/code&gt; only. One cycle from the login screen resumed in 0.6 s, woken by Shift on the built-in keyboard, with Wi-Fi, Bluetooth and Ethernet back. Wake by lid is still missing, so closing the lid locks the screen.&lt;/p&gt;

&lt;p&gt;The PMIC clock was tested and left off. It reads 1970-01-03, because Samsung keeps the offset elsewhere, and it set the system clock to 1970 at boot. systemd-timesyncd and NTP keep the time instead.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — Upgrade to sid Without Touching the Boot
&lt;/h4&gt;

&lt;p&gt;A Lenovo Yoga C630 running the same plan lost its boot when sid's GRUB 2.14 blacked out every kernel. The Book Go upgrade was built around that:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Protection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;sid replaces GRUB 2.12 and shim&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apt-mark hold&lt;/code&gt; on all six &lt;code&gt;grub*&lt;/code&gt; and four &lt;code&gt;shim*&lt;/code&gt; packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sid's &lt;code&gt;initramfs-tools&lt;/code&gt; drops &lt;code&gt;qcom/&lt;/code&gt; firmware&lt;/td&gt;
&lt;td&gt;an initramfs hook adds the GPU firmware and zap shader&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a new kernel becomes the default&lt;/td&gt;
&lt;td&gt;the old kernel is held and stays default until a one-time boot passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a rebuilt initramfs breaks boot&lt;/td&gt;
&lt;td&gt;SAFE entries boot copies saved in &lt;code&gt;/boot/gbgo-known-good/&lt;/code&gt; with &lt;code&gt;SHA256SUMS&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;everything breaks&lt;/td&gt;
&lt;td&gt;an &lt;code&gt;rsync&lt;/code&gt; of &lt;code&gt;/&lt;/code&gt; and the ESP on another machine, checked by hash&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;gbgo-boot-verify&lt;/code&gt; runs before every reboot. It checks the holds, hashes all nine ESP files against the backup, runs &lt;code&gt;grub-script-check&lt;/code&gt;, confirms the default entry carries the tree and the options, reads the tree, initramfs and &lt;code&gt;grub.cfg&lt;/code&gt; off the raw disk, and lists the storage, keyboard and display modules and the GPU firmware inside every initramfs.&lt;/p&gt;

&lt;p&gt;The upgrade set up 1712 packages with no dpkg errors. GRUB stayed at 2.12:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;dpkg-query &lt;span class="nt"&gt;-W&lt;/span&gt; grub-efi-arm64-bin
grub-efi-arm64-bin  2.12-9+deb13u2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kernel 7.2.9 passed a one-time boot and became the default. On 7.2.9 the GPU loads after the root mount, so the zap shader failure seen on 6.12 is gone.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Three Names That Change Under You
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disk.&lt;/strong&gt; The root partition was &lt;code&gt;sda3&lt;/code&gt; at install and is &lt;code&gt;sdc3&lt;/code&gt; today. Every boot entry uses &lt;code&gt;UUID=&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wi-Fi.&lt;/strong&gt; sid's systemd renames &lt;code&gt;wlan0&lt;/code&gt; to &lt;code&gt;wld0&lt;/code&gt; a few seconds into boot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sudo path.&lt;/strong&gt; sid's &lt;code&gt;sudo&lt;/code&gt; no longer includes &lt;code&gt;/usr/local/sbin&lt;/code&gt;. Call the scripts by full path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;apt autoremove&lt;/code&gt; after the upgrade would also have removed the fallback kernel and &lt;code&gt;mesa-va-drivers&lt;/code&gt;. Both are now marked manual.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 9 — Bring Up the Speakers
&lt;/h4&gt;

&lt;p&gt;The draft tree has no sound card, no codec and the Aspire 1's audio DSP region. Samsung's ACPI tables name the headset codec, a Realtek RT5682 on &lt;code&gt;i2c@a8c000&lt;/code&gt;, and declare no speaker amplifier on any I2C bus. The audio device &lt;code&gt;AUDD&lt;/code&gt; drives two GPIOs, TLMM 21 and 20, so the amplifier is a plain I2S amplifier with enable pins.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gbgo-dtb-audio.py&lt;/code&gt; adds the audio parts to the tree:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DSP region &lt;code&gt;0x8e400000&lt;/code&gt;, grown to &lt;code&gt;0x5500000&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;covers Samsung's &lt;code&gt;0x90b00000&lt;/code&gt;–&lt;code&gt;0x93900000&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RT5682 on &lt;code&gt;i2c@a8c000&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;from the ACPI tables&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;amplifier enable on TLMM 21&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AUDD&lt;/code&gt;'s first GPIO&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;qcom,sc7180-qdsp6-sndcard&lt;/code&gt; with four front ends&lt;/td&gt;
&lt;td&gt;the driver for this SoC's DSP audio&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;speaker on tertiary MI2S, data line 1&lt;/td&gt;
&lt;td&gt;Samsung's Windows calibration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PM6150L &lt;code&gt;bob&lt;/code&gt; always on at 3.296 V&lt;/td&gt;
&lt;td&gt;Samsung's power settings for &lt;code&gt;AUDD&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The port comes from Samsung's Windows audio package. Its calibration database maps each audio device to a port record: &lt;code&gt;SPEAKER_OUT&lt;/code&gt; sits on &lt;code&gt;TERTIARY_MI2S_RX&lt;/code&gt;, with the same I2S fields as the headset on &lt;code&gt;PRIMARY_MI2S_RX&lt;/code&gt;. The supply comes from Samsung's power tables: &lt;code&gt;AUDD&lt;/code&gt; votes the PM6150L buck-boost regulator &lt;code&gt;bob&lt;/code&gt; on at &lt;code&gt;0x324B00&lt;/code&gt; µV, 3.296 V. Linux gives &lt;code&gt;bob&lt;/code&gt; no user, so nothing turns it on without the tree change.&lt;/p&gt;

&lt;p&gt;With all of that in place the card comes up, and the speakers still stay silent under PipeWire. The amplifier plays 16-bit stereo, and PipeWire opens the port as 8 channels of 24-bit audio:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stream to tertiary MI2S&lt;/th&gt;
&lt;th&gt;Speakers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PipeWire default, 8 channels &lt;code&gt;S24_LE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;silent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;speaker-test -D hw:0,1 -c 2 -F S16_LE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;tone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A WirePlumber rule opens the speakers as 16-bit stereo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;monitor.alsa.rules = [
  {
    matches = [ { device.name = "alsa_card.platform-sound" } ]
    actions = { update-props = { device.profile = "pro-audio" } }
  }
  {
    matches = [ { node.name = "alsa_output.platform-sound.pro-output-0" } ]
    actions = {
      update-props = {
        node.description = "Speakers"
        audio.format = "S16LE"
        audio.channels = 2
        audio.position = [ FL, FR ]
      }
    }
  }
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mixer route &lt;code&gt;TERT_MI2S_RX Audio Mixer MultiMedia1&lt;/code&gt; is saved with &lt;code&gt;alsactl store&lt;/code&gt;. After a reboot on the audio tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/class/remoteproc/remoteproc1/name /sys/class/remoteproc/remoteproc1/state
adsp
running

&lt;span class="nv"&gt;$ &lt;/span&gt;amixer &lt;span class="nt"&gt;-c0&lt;/span&gt; cget &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'TERT_MI2S_RX Audio Mixer MultiMedia1'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
  : &lt;span class="nv"&gt;values&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on

&lt;span class="nv"&gt;$ &lt;/span&gt;wpctl status | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A1&lt;/span&gt; Sinks
 ├─ Sinks:
 │  &lt;span class="k"&gt;*&lt;/span&gt;   53. Speakers                            &lt;span class="o"&gt;[&lt;/span&gt;vol: 0.80]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The headphone jack and the internal microphone are untested. The RT5682's headphone switches start muted, and Samsung's calibration puts the microphone on quaternary MI2S, which the tree does not wire yet.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 10 — Use the Video Decoder
&lt;/h4&gt;

&lt;p&gt;The Venus video block decodes H.264, VP8, VP9, HEVC and MPEG-2 through V4L2. Debian's ffmpeg reaches it as &lt;code&gt;h264_v4l2m2m&lt;/code&gt; and &lt;code&gt;vp9_v4l2m2m&lt;/code&gt;. Twenty seconds of 1080p30 test pattern, 600 frames:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Clip&lt;/th&gt;
&lt;th&gt;Decoder&lt;/th&gt;
&lt;th&gt;Wall&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;H.264&lt;/td&gt;
&lt;td&gt;software&lt;/td&gt;
&lt;td&gt;2.42 s&lt;/td&gt;
&lt;td&gt;11.62 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;H.264&lt;/td&gt;
&lt;td&gt;Venus&lt;/td&gt;
&lt;td&gt;7.42 s&lt;/td&gt;
&lt;td&gt;0.43 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VP9&lt;/td&gt;
&lt;td&gt;software&lt;/td&gt;
&lt;td&gt;2.92 s&lt;/td&gt;
&lt;td&gt;10.85 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VP9&lt;/td&gt;
&lt;td&gt;Venus&lt;/td&gt;
&lt;td&gt;7.56 s&lt;/td&gt;
&lt;td&gt;0.45 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Venus decodes about 80 frames a second at about a twenty-fifth of the CPU.&lt;/p&gt;

&lt;p&gt;The browsers decode in software. Chrome on Linux reaches hardware decoders through VA-API, and no VA-API driver exists for this chip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ vainfo
libva info: Trying to open /usr/lib/aarch64-linux-gnu/dri/msm_drv_video.so
libva info: va_openDriver() returns -1
vaInitialize failed with error code -1 (unknown libva error),exit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chrome 155 and Chromium 154 never opened &lt;code&gt;/dev/video0&lt;/code&gt; while playing a video, with or without the V4L2 and VA-API feature flags. Firefox ESR 153 with &lt;code&gt;media.hardware-video-decoding.force-enabled&lt;/code&gt; opens the V4L2 decoder, gets one frame back with no timestamp, and falls back to software. YouTube in Chrome runs its renderer at 47 to 81 % of a core, on a laptop with 3.4 GiB of memory.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Watch YouTube in mpv
&lt;/h4&gt;

&lt;p&gt;mpv with yt-dlp uses the decoder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mpv &lt;span class="nt"&gt;--hwdec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;v4l2m2m-copy &lt;span class="s1"&gt;'https://www.youtube.com/watch?v=aqz-KE-bpKQ'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints &lt;code&gt;Using hardware decoding (v4l2m2m-copy)&lt;/code&gt;. Twenty seconds each of 1080p60 H.264, 720p60 H.264 and 1080p60 VP9 decoded with 0 dropped frames, at about 7.5 s of CPU per run including yt-dlp. Those runs discarded the video output, so playback on the screen is not yet measured.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 11 — Charge the Battery
&lt;/h4&gt;

&lt;p&gt;Samsung's ACPI tables give the embedded controller I2C resources at &lt;code&gt;0x09&lt;/code&gt; and &lt;code&gt;0x0b&lt;/code&gt; on &lt;code&gt;i2c@890000&lt;/code&gt;. &lt;code&gt;0x0b&lt;/code&gt; answers the Smart Battery word commands, and Linux's &lt;code&gt;sbs-battery&lt;/code&gt; driver reads it once the tree has a node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;battery@b {
    compatible = "sbs,sbs-battery";
    reg = &lt;span class="err"&gt;&amp;lt;&lt;/span&gt;0x0b&amp;gt;;
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;POWER_SUPPLY_MANUFACTURER&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;SDI&lt;/span&gt;
&lt;span class="py"&gt;POWER_SUPPLY_MODEL_NAME&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4432D53&lt;/span&gt;
&lt;span class="py"&gt;POWER_SUPPLY_CHARGE_FULL_DESIGN&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;5320000&lt;/span&gt;
&lt;span class="py"&gt;POWER_SUPPLY_CYCLE_COUNT&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The battery then reported &lt;code&gt;Not charging&lt;/code&gt; with the adapter in, and it was right: zero current, constant charge. &lt;code&gt;0x09&lt;/code&gt; is a TI BQ25710 charger (manufacturer ID &lt;code&gt;0x0040&lt;/code&gt;, device ID &lt;code&gt;0x0089&lt;/code&gt;). Its readings after a one-shot conversion:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Register&lt;/th&gt;
&lt;th&gt;Reading&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ChargerStatus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0x8000&lt;/code&gt;, adapter present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;input voltage&lt;/td&gt;
&lt;td&gt;15.04 V&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ChargeCurrent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 mA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MaxChargeVoltage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8760 mV&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The BQ25710 datasheet explains the zero: the charger "terminate[s] charging if the charger does not receive a write MaxChargeVoltage() or write ChargeCurrent() command within 175 s". The charge voltage of 8760 mV is set before Linux starts, and nothing in Linux writes the charger, so charging stops within 175 s of Linux taking over.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gbgo-charge&lt;/code&gt; plays the host. Every 60 s it reads the battery's own requests and passes them to the charger, capped at 8760 mV and 2048 mA, and it writes 0 when the battery reports full, asks for 0, or reads outside 0–45 °C. If the service stops, the charger's timer ends charging within 175 s.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; gbgo-charge | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
gbgo-charge[827]: charge: 8760 mV, 2048 mA &lt;span class="o"&gt;(&lt;/span&gt;gauge asks 8860 mV 6916 mA&lt;span class="p"&gt;;&lt;/span&gt; battery 7993 mV, 26 C, charger status 0x8400&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;upower &lt;span class="nt"&gt;-i&lt;/span&gt; /org/freedesktop/UPower/devices/battery_sbs_4_000b | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"state|time to"&lt;/span&gt;
    state:               charging
    &lt;span class="nb"&gt;time &lt;/span&gt;to full:        3.0 hours
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The battery went from 30 % to 68 % in 53 minutes across a reboot, with the service restarting on its own.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 12 — Power the Camera
&lt;/h4&gt;

&lt;p&gt;The camera never enumerated, and nothing in the tree mentions one. Samsung's ACPI tables put it on USB: &lt;code&gt;ICAM&lt;/code&gt; sits on port 4 of the internal hub &lt;code&gt;EHUB&lt;/code&gt;, and the hub's power-on method switches it on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Method (_PS0, 0, NotSerialized)
{
    ADBG ("EHUB_PS0")
    \_SB.GIO0.CAME = One
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CAME&lt;/code&gt; is an output on TLMM GPIO 13, which the boot firmware leaves low. Driving it high brought the camera up at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;usb 1-1.4: Product: 720p HD Camera
usb 1-1.4: Manufacturer: Chicony
uvcvideo 1-1.4:1.0: Found UVC 1.00 device 720p HD Camera (04f2:b72f)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;gpio-hog&lt;/code&gt; in the tree holds GPIO 13 high from boot. The camera now registers before the video decoder, so it takes &lt;code&gt;/dev/video0&lt;/code&gt; and &lt;code&gt;video1&lt;/code&gt;, and Venus moves to &lt;code&gt;video2&lt;/code&gt; and &lt;code&gt;video3&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 13 — Find the Backlight Channel
&lt;/h4&gt;

&lt;p&gt;Samsung's panel configuration names a PMIC-driven PWM backlight at 6250 Hz, and the boot firmware leaves the PM6150L light pulse generator (LPG) running. With &lt;code&gt;pwm-backlight&lt;/code&gt; on LPG3, the GNOME slider moved LPG3's duty cycle and the screen stayed the same.&lt;/p&gt;

&lt;p&gt;LPG4 was running at 50 % with nothing in Linux holding it. Exported through &lt;code&gt;/sys/class/pwm&lt;/code&gt;, it was switched between 5 % and 100 % every 2 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;C&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/sys/class/pwm/pwmchip1/pwm3
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 20&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;8000 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$C&lt;/span&gt;/duty_cycle&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;2
  &lt;span class="nb"&gt;echo &lt;/span&gt;159688 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$C&lt;/span&gt;/duty_cycle&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;2
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The screen got darker and brighter in step. &lt;code&gt;pwm-backlight&lt;/code&gt; now uses channel index 3, and the slider drives LPG4:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; pwm-3   (backlight           ): requested
  requested configuration:  enabled, 158889/160000 ns, normal polarity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  What Works?
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hardware&lt;/th&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;🟢 Adreno 618, &lt;code&gt;FD618&lt;/code&gt;, &lt;code&gt;simple_ondemand&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video&lt;/td&gt;
&lt;td&gt;⚠️ venus decoder in mpv and ffmpeg. Browsers decode in software&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wi-Fi&lt;/td&gt;
&lt;td&gt;🟢 5 GHz, 80 MHz, with Samsung's modem firmware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bluetooth&lt;/td&gt;
&lt;td&gt;🟢 with &lt;code&gt;gbgo-bt-addr&lt;/code&gt;. ⚠️ Pairing and audio untested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyboard, touchpad&lt;/td&gt;
&lt;td&gt;🟢 keyboard on GPIO 104, touchpad on GPIO 31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power button, lid&lt;/td&gt;
&lt;td&gt;🟢 lid locks the screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suspend&lt;/td&gt;
&lt;td&gt;⚠️ &lt;code&gt;s2idle&lt;/code&gt;, one cycle tested, the lid does not wake it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USB-C&lt;/td&gt;
&lt;td&gt;⚠️ works if plugged at boot. No UCSI node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speakers&lt;/td&gt;
&lt;td&gt;🟢 16-bit stereo through PipeWire&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headphones, mic&lt;/td&gt;
&lt;td&gt;⚠️ untested. Mic not wired in the tree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Battery&lt;/td&gt;
&lt;td&gt;🟢 &lt;code&gt;sbs-battery&lt;/code&gt; on the EC's gauge. Charging through &lt;code&gt;gbgo-charge&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brightness&lt;/td&gt;
&lt;td&gt;🟢 PM6150L LPG4 dims and brightens the screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Camera&lt;/td&gt;
&lt;td&gt;🟢 Chicony 720p UVC, powered by GPIO 13&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  Compare and Contrast
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Yoga C630&lt;/th&gt;
&lt;th&gt;Galaxy Book Go&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SoC&lt;/td&gt;
&lt;td&gt;Snapdragon 850 (SDM850)&lt;/td&gt;
&lt;td&gt;Snapdragon 7c Gen 2 (SC7180)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Device tree&lt;/td&gt;
&lt;td&gt;🥇 upstream, in Debian's kernel&lt;/td&gt;
&lt;td&gt;🥈 community draft, patched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firmware source&lt;/td&gt;
&lt;td&gt;Lenovo's Windows driver releases&lt;/td&gt;
&lt;td&gt;this laptop's Windows driver store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;🥇 works&lt;/td&gt;
&lt;td&gt;🥈 speakers work, headphones and mic untested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Battery&lt;/td&gt;
&lt;td&gt;🥇 EC driver, the EC charges&lt;/td&gt;
&lt;td&gt;🥈 gauge driver, a service drives the charger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser video decode&lt;/td&gt;
&lt;td&gt;❌ software&lt;/td&gt;
&lt;td&gt;❌ software&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blocking driver&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ipa&lt;/code&gt; resets the SoC&lt;/td&gt;
&lt;td&gt;none found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boot loader&lt;/td&gt;
&lt;td&gt;GRUB 2.12 held&lt;/td&gt;
&lt;td&gt;GRUB 2.12 held&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h4&gt;
  
  
  So, Is It a Daily Driver?
&lt;/h4&gt;

&lt;p&gt;For writing, terminals, a browser and SSH, yes. This article is the test: the keyboard, the Wayland desktop, Wi-Fi and the assistant ran together for the whole session without a fault.&lt;/p&gt;

&lt;p&gt;The battery reading, charging, brightness, speakers and camera close the gaps that kept it on a desk. What remains: browsers decode video in software, and with 3.4 GiB of memory YouTube stutters in Chrome and plays through the hardware decoder in mpv. The headphone jack and microphone are untested, USB-C works only when plugged in at boot, and every Wi-Fi, GPU, video and audio feature rests on firmware copied out of Windows.&lt;/p&gt;




&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to run a current Debian with a full desktop on a laptop that has no upstream support, and then use it. The key to the solution was treating Samsung's Windows install as the reference: its driver store supplied the firmware and the audio calibration, and its ACPI tables supplied the keyboard interrupt, the camera power pin and the battery addresses that the community tree lacked. Each fix took one or more test boots. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 Debian sid, kernel 7.2.9 and GNOME Shell 50.5 on Wayland, with GPU acceleration, Wi-Fi, Bluetooth, keyboard, touchpad, power button and lid.&lt;/li&gt;
&lt;li&gt;🟢 The speakers play, configured from Samsung's Windows calibration: tertiary MI2S, &lt;code&gt;bob&lt;/code&gt; at 3.296 V, 16-bit stereo.&lt;/li&gt;
&lt;li&gt;⚠️ The video decoder works in mpv at about a twenty-fifth of the CPU, and browsers decode in software.&lt;/li&gt;
&lt;li&gt;🟢 The keyboard interrupt flood is gone: 0 &lt;code&gt;incomplete report&lt;/code&gt; lines per boot. The display bridge interrupt storm is gone too.&lt;/li&gt;
&lt;li&gt;🟢 The sid upgrade left the boot path byte-identical, checked by script before each reboot.&lt;/li&gt;
&lt;li&gt;🟢 This article was written, fact-checked and built on the machine.&lt;/li&gt;
&lt;li&gt;🟢 The battery reads and charges: a service relays the battery's requests to the BQ25710 charger every 60 s.&lt;/li&gt;
&lt;li&gt;🟢 The camera, powered by GPIO 13, and brightness, on PM6150L LPG4.&lt;/li&gt;
&lt;li&gt;⚠️ Wi-Fi, GPU, video and audio depend on five firmware files found only in a Windows driver store.&lt;/li&gt;
&lt;li&gt;⚠️ Suspend has one tested cycle, and the Wi-Fi restart service has not yet fired on a real boot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one Galaxy Book Go (NP340XLA-KA3CA, Wi-Fi model, BIOS &lt;code&gt;P00AJW.036&lt;/code&gt;), Debian sid as of 2026-10-11, Samsung firmware from the Windows driver store, brought up over two days and 26 logged boots, with the dog food session and the audio, video, battery, camera and backlight work on 2026-10-12.&lt;/p&gt;

&lt;p&gt;The strategy for bringing up Debian on the Galaxy Book Go was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/xbill9/linux-arm-adventures" rel="noopener noreferrer"&gt;https://github.com/xbill9/linux-arm-adventures&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Galaxy Book Go notes: &lt;a href="https://github.com/xbill9/linux-arm-adventures/tree/main/galaxy-book-go" rel="noopener noreferrer"&gt;galaxy-book-go/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Debian on the Yoga C630, the companion article: &lt;a href="https://github.com/xbill9/linux-arm-adventures/tree/main/articles/c630-debian" rel="noopener noreferrer"&gt;c630-debian&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bring-up history: &lt;a href="https://github.com/aarch64-laptops/debian-cdimage/issues/21" rel="noopener noreferrer"&gt;aarch64-laptops/debian-cdimage#21&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Current community status: &lt;a href="https://github.com/velvet-os/imagebuilder/issues/136" rel="noopener noreferrer"&gt;velvet-os/imagebuilder#136&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Draft device tree and kernel notes (&lt;code&gt;readme.qc7&lt;/code&gt;): &lt;a href="https://github.com/hexdump0815/linux-mainline-qcom-kernel" rel="noopener noreferrer"&gt;hexdump0815/linux-mainline-qcom-kernel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;BQ25710 charger datasheet: &lt;a href="https://www.ti.com/lit/ds/symlink/bq25710.pdf" rel="noopener noreferrer"&gt;ti.com/lit/ds/symlink/bq25710.pdf&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>debian</category>
      <category>arm64</category>
      <category>laptop</category>
    </item>
    <item>
      <title>Small Apache-2.0 Models Against Gemma 4 E2B on One TPU v5e: Which Ones Keep Up?</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Tue, 13 Oct 2026 00:57:26 +0000</pubDate>
      <link>https://dev.to/gde/small-apache-20-models-against-gemma-4-e2b-on-one-tpu-v5e-which-ones-keep-up-mm4</link>
      <guid>https://dev.to/gde/small-apache-20-models-against-gemma-4-e2b-on-one-tpu-v5e-which-ones-keep-up-mm4</guid>
      <description>&lt;p&gt;This article provides a step by step guide to surveying small open-weight models on one Google Cloud TPU v5e chip through vLLM, with Gemma 4 E2B as the baseline. Every model is Apache-2.0 or MIT licensed, so any of them can be repacked and redistributed, and every one is served on the same pinned image with the same settings, then timed and scored by code.&lt;/p&gt;

&lt;p&gt;Gemma 4 E2B is small and workable on a v5e-1, but its attention is unusual: two head sizes, 256 for sliding-window layers and 512 for full-attention layers, one KV head, and 20 of its 35 layers reading another layer's KV cache. The question here is which plainer model can stand in for it. Qwen3-4B matches Gemma on both accuracy tests at 0.57x the output. SmolLM3-3B and Granite 3.3 2B match it on tool calls at 0.76x and 0.93x. With long prompts, Gemma stays ahead of every model of comparable accuracy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/model-sweep" rel="noopener noreferrer"&gt;https://github.com/xbill9/model-sweep&lt;/a&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  Why One v5e Chip?
&lt;/h4&gt;

&lt;p&gt;A &lt;code&gt;v5litepod-1&lt;/code&gt; is the cheapest TPU Google Cloud rents and is usually available as flex-start capacity in &lt;code&gt;us-west4-a&lt;/code&gt;. It has 15.75 GiB of HBM, and vLLM gets 0.9 of that, so a bf16 model has to leave room for its KV cache in about 14 GiB.&lt;/p&gt;

&lt;p&gt;That rules the field down to roughly 4B parameters. Gemma 4 E2B loads in 8.97 GiB on this chip, which makes it one of the larger models in the lineup.&lt;/p&gt;




&lt;h4&gt;
  
  
  At This Point You Should Have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;A Google Cloud project with the Cloud TPU API enabled and flex-start TPU v5e quota in &lt;code&gt;us-west4-a&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A GCS bucket for results, and a Hugging Face token saved in Secret Manager as &lt;code&gt;hf-token&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gcloud&lt;/code&gt; with the alpha components, and Python 3 with no extra packages&lt;/li&gt;
&lt;li&gt;A clone: &lt;code&gt;git clone https://github.com/xbill9/model-sweep&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 1 — Build the Lineup From the Hub
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;survey.py&lt;/code&gt; reads each candidate's license, architecture and weight size from the Hugging Face API and decides whether it fits the chip. A model is in when its license is Apache-2.0 or MIT and at least 2 GiB is left for the KV cache. vLLM on TPU runs some architectures as native JAX models and the rest through a PyTorch fallback, so the survey splits the lineup into two tiers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 survey.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;| `tpu-vllm-v5e1-qwen3_4b` | `Qwen/Qwen3-4B` | apache-2.0 | Qwen3ForCausalLM | 4.02 | 7.49 | 6.68 | JAX | ✅ | 1 |
| `tpu-vllm-v5e1-gemma4_2b` | `google/gemma-4-E2B-it` | apache-2.0 | Gemma4ForConditionalGeneration | 5.12 | 9.54 | 4.63 | JAX | ✅ | 1 |
| `tpu-vllm-v5e1-smollm3_3b` | `HuggingFaceTB/SmolLM3-3B` | apache-2.0 | SmolLM3ForCausalLM | 3.08 | 5.73 | 8.45 | torchax |  | 2 |
| `tpu-vllm-v5e1-qwen25_3b` | `Qwen/Qwen2.5-3B-Instruct` | other | Qwen2ForCausalLM | 3.09 | 5.75 | 8.43 | JAX |  | excluded: license |
| `tpu-vllm-v5e1-qwen3_8b` | `Qwen/Qwen3-8B` | apache-2.0 | Qwen3ForCausalLM | 8.19 | 15.26 | -1.08 | JAX |  | excluded: memory |

Tier 1: 8 models. Tier 2: 7 models. Excluded: 2.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Qwen2.5-3B is out on its license and Qwen3-8B on memory. Everything else goes into two arms files, one per tier.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Describe Each Model in One Line
&lt;/h4&gt;

&lt;p&gt;An arms file lists one model per line: the checkpoint, its rig name, what to measure, and any serving flags it needs. The tool-call parser and the thinking switch differ by family:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;google/gemma-4-E2B-it=gemma4_2b=load+long+gen=parser=gemma4,mm=image+audio+video
Qwen/Qwen3-4B=qwen3_4b=load+long+gen=parser=hermes,nothink
HuggingFaceTB/SmolLM3-3B=smollm3_3b=load+long+gen=parser=hermes,nothink
ibm-granite/granite-3.3-2b-instruct=granite33_2b=load+long+gen=parser=granite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nothink&lt;/code&gt; sends &lt;code&gt;chat_template_kwargs {"enable_thinking": false}&lt;/code&gt; with every request, so Qwen3 and SmolLM3 answer directly like the others. &lt;code&gt;mm=&lt;/code&gt; turns off the image, audio and video inputs of multimodal checkpoints. A unit test checks every line against the lineup and against the tool-call parsers registered in the pinned vLLM build.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Launch One Chip per Tier
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;tpu/launch.sh&lt;/code&gt; bundles the committed tree, uploads it, and creates a flex-start queued resource whose startup script serves every arm in turn, uploads results after each one, and deletes the TPU at the end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tpu/launch.sh arms-v5e1-tier2.txt &lt;span class="nt"&gt;--go&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;arms:    tpu/arms-v5e1-tier2.txt (7 arms)
bundle:  gs://aisprint-491218-bucket/model-sweep/inputs/sweep-86d97ba02-0837b648.tgz
results: gs://aisprint-491218-bucket/model-sweep/2026-10-12-v5e1-tier2/
Created queued_resource [ms-2026-10-12-v5e1-tier2-1349].
created ms-2026-10-12-v5e1-tier2-1349; it deletes itself when the last arm finishes, or at 4h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both tiers ran on &lt;code&gt;vllm/vllm-tpu@sha256:19a1a0526476f902eb83e1057f3d8938f35b457dd7716a30e9ab4f7bee90d507&lt;/code&gt;, vLLM &lt;code&gt;0.29.1rc1.dev468+g0b7f11a1e&lt;/code&gt;, so the tier 2 models pair with tier 1's Gemma run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[sweep 2026-10-11T17:14:28Z] start 2026-10-11-v5e1-tier1 on us-west4-a, arms arms-v5e1-tier1.txt
[sweep 2026-10-11T18:24:30Z] DONE: all arms attempted
[sweep 2026-10-12T13:51:26Z] start 2026-10-12-v5e1-tier2 on us-west4-a, arms arms-v5e1-tier2.txt
[sweep 2026-10-12T16:02:12Z] DONE: all arms attempted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 4 — Measure Speed and Accuracy the Same Way
&lt;/h4&gt;

&lt;p&gt;Every model is served with &lt;code&gt;--max-model-len 4096 --max-num-seqs 16&lt;/code&gt; and prefix caching on. Each one gets three measurements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Output speed:&lt;/strong&gt; 256 output tokens per request at 1, 4 and 16 requests at once, median of three passes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long prompts:&lt;/strong&gt; the same with 1,024- and 3,072-token prompts that never repeat, so the prefix cache cannot help&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accuracy:&lt;/strong&gt; all 1,319 GSM8K problems, greedy, with a 2,048-token answer limit, and the 400 BFCL simple tool-call records for models with a tool-call parser&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Answers are scored in code. Differences against Gemma are paired on the same problems, with a 95% range from 10,000 resamples; a range that includes zero means no measured difference.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — File the Results and Build the Report
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 sweep_report.py file 2026-10-12-v5e1-tier2
python3 sweep_report.py report 2026-10-12-v5e1-tier2 &lt;span class="nt"&gt;--baseline-run&lt;/span&gt; 2026-10-11-v5e1-tier1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;filed smollm3_3b -&amp;gt; tpu-vllm-v5e1-smollm3_3b/benchmarks/runs/2026-10-12-v5e1-tier2
wrote results/2026-10-12-v5e1-tier2/REPORT.md (6 served, 1 failed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;file&lt;/code&gt; moves each model's logs and scored answers into its own directory. &lt;code&gt;report&lt;/code&gt; refuses to pair two runs that served different image digests.&lt;/p&gt;




&lt;h4&gt;
  
  
  How Accurate Is Each Model?
&lt;/h4&gt;

&lt;p&gt;Against Gemma 4 E2B, in percentage points, with the 95% range:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;GSM8K&lt;/th&gt;
&lt;th&gt;vs Gemma&lt;/th&gt;
&lt;th&gt;BFCL&lt;/th&gt;
&lt;th&gt;vs Gemma&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 E2B&lt;/td&gt;
&lt;td&gt;0.911&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.927&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-4B&lt;/td&gt;
&lt;td&gt;0.915&lt;/td&gt;
&lt;td&gt;+0.4 (-1.2 to +2.0)&lt;/td&gt;
&lt;td&gt;0.940&lt;/td&gt;
&lt;td&gt;+1.2 (-1.8 to +4.2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phi-4-mini&lt;/td&gt;
&lt;td&gt;0.895&lt;/td&gt;
&lt;td&gt;-1.6 (-3.2 to -0.1)&lt;/td&gt;
&lt;td&gt;not scored&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmolLM3-3B&lt;/td&gt;
&lt;td&gt;0.858&lt;/td&gt;
&lt;td&gt;-5.3 (-7.2 to -3.5)&lt;/td&gt;
&lt;td&gt;0.945&lt;/td&gt;
&lt;td&gt;+1.7 (-1.0 to +4.8)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-1.7B&lt;/td&gt;
&lt;td&gt;0.827&lt;/td&gt;
&lt;td&gt;-8.4 (-10.5 to -6.4)&lt;/td&gt;
&lt;td&gt;0.935&lt;/td&gt;
&lt;td&gt;+0.8 (-2.2 to +3.8)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Granite 3.3 2B&lt;/td&gt;
&lt;td&gt;0.817&lt;/td&gt;
&lt;td&gt;-9.5 (-11.6 to -7.4)&lt;/td&gt;
&lt;td&gt;0.920&lt;/td&gt;
&lt;td&gt;-0.7 (-3.8 to +2.2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen2.5-1.5B&lt;/td&gt;
&lt;td&gt;0.729&lt;/td&gt;
&lt;td&gt;-18.2 (-20.6 to -15.8)&lt;/td&gt;
&lt;td&gt;0.910&lt;/td&gt;
&lt;td&gt;-1.7 (-5.2 to +1.8)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-0.6B&lt;/td&gt;
&lt;td&gt;0.599&lt;/td&gt;
&lt;td&gt;-31.2 (-34.0 to -28.5)&lt;/td&gt;
&lt;td&gt;0.825&lt;/td&gt;
&lt;td&gt;-10.3 (-14.2 to -6.5)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen2.5-0.5B&lt;/td&gt;
&lt;td&gt;0.469&lt;/td&gt;
&lt;td&gt;-44.3 (-47.0 to -41.4)&lt;/td&gt;
&lt;td&gt;0.818&lt;/td&gt;
&lt;td&gt;-11.0 (-14.8 to -7.2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmolLM2-1.7B&lt;/td&gt;
&lt;td&gt;0.461&lt;/td&gt;
&lt;td&gt;-45.0 (-47.8 to -42.2)&lt;/td&gt;
&lt;td&gt;no parser&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five models are level with Gemma on BFCL tool calls, down to Qwen2.5-1.5B. Only Qwen3-4B is level on GSM8K, and from 1.7B down, Qwen3-1.7B aside, the score falls by 18 to 45 points.&lt;/p&gt;




&lt;h4&gt;
  
  
  How Fast Is Each Model?
&lt;/h4&gt;

&lt;p&gt;Output tokens per second with 16 requests at once, and against Gemma:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Weights GiB&lt;/th&gt;
&lt;th&gt;16 requests&lt;/th&gt;
&lt;th&gt;vs Gemma&lt;/th&gt;
&lt;th&gt;16 × 3,072-token prompts&lt;/th&gt;
&lt;th&gt;vs Gemma&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen2.5-0.5B&lt;/td&gt;
&lt;td&gt;0.92&lt;/td&gt;
&lt;td&gt;4176&lt;/td&gt;
&lt;td&gt;2.21x&lt;/td&gt;
&lt;td&gt;2596&lt;/td&gt;
&lt;td&gt;2.48x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-0.6B&lt;/td&gt;
&lt;td&gt;1.40&lt;/td&gt;
&lt;td&gt;3210&lt;/td&gt;
&lt;td&gt;1.70x&lt;/td&gt;
&lt;td&gt;1301&lt;/td&gt;
&lt;td&gt;1.24x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen2.5-1.5B&lt;/td&gt;
&lt;td&gt;2.88&lt;/td&gt;
&lt;td&gt;2780&lt;/td&gt;
&lt;td&gt;1.47x&lt;/td&gt;
&lt;td&gt;1570&lt;/td&gt;
&lt;td&gt;1.50x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-1.7B&lt;/td&gt;
&lt;td&gt;3.78&lt;/td&gt;
&lt;td&gt;2049&lt;/td&gt;
&lt;td&gt;1.09x&lt;/td&gt;
&lt;td&gt;926&lt;/td&gt;
&lt;td&gt;0.88x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 E2B&lt;/td&gt;
&lt;td&gt;8.97&lt;/td&gt;
&lt;td&gt;1887&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;td&gt;1049&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Granite 3.3 2B&lt;/td&gt;
&lt;td&gt;4.92&lt;/td&gt;
&lt;td&gt;1761&lt;/td&gt;
&lt;td&gt;0.93x&lt;/td&gt;
&lt;td&gt;732&lt;/td&gt;
&lt;td&gt;0.70x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmolLM3-3B&lt;/td&gt;
&lt;td&gt;6.22&lt;/td&gt;
&lt;td&gt;1440&lt;/td&gt;
&lt;td&gt;0.76x&lt;/td&gt;
&lt;td&gt;725&lt;/td&gt;
&lt;td&gt;0.69x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phi-4-mini&lt;/td&gt;
&lt;td&gt;8.31&lt;/td&gt;
&lt;td&gt;1136&lt;/td&gt;
&lt;td&gt;0.60x&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;0.39x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-4B&lt;/td&gt;
&lt;td&gt;7.49&lt;/td&gt;
&lt;td&gt;1080&lt;/td&gt;
&lt;td&gt;0.57x&lt;/td&gt;
&lt;td&gt;373&lt;/td&gt;
&lt;td&gt;0.36x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gemma carries the largest weights in the table and still out-serves every model with more than 4 GiB of weights. With 3,072-token prompts the gap widens: Granite and SmolLM3 drop to about 0.7x, and Qwen3-4B to 0.36x.&lt;/p&gt;




&lt;h4&gt;
  
  
  Why Does Gemma Hold Up on Long Prompts?
&lt;/h4&gt;

&lt;p&gt;The KV cache. Each model's boot log reports how many tokens of cache fit in the HBM left after its weights:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;KV heads&lt;/th&gt;
&lt;th&gt;Head size&lt;/th&gt;
&lt;th&gt;Layers&lt;/th&gt;
&lt;th&gt;KV cache tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 E2B&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;256 / 512&lt;/td&gt;
&lt;td&gt;35, 20 sharing&lt;/td&gt;
&lt;td&gt;321,280&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Granite 3.3 2B&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;125,184&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmolLM3-3B&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;120,320&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-1.7B&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;100,096&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-4B&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;50,688&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gemma stores one KV head per layer, 28 of its 35 layers keep only a 512-token sliding window, and 20 layers reuse an earlier layer's cache. That leaves it 2.6 to 6.3 times the cache of the four models below it, even though its weights are the largest.&lt;/p&gt;

&lt;p&gt;The same features that make Gemma awkward to port are the ones paying for its long-prompt speed on this chip.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Turn Thinking Off Per Request
&lt;/h4&gt;

&lt;p&gt;Qwen3 and SmolLM3 think by default, which spends the GSM8K answer budget on reasoning text. Passing the chat template switch with each request turns it off without a different checkpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"chat_template_kwargs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"enable_thinking"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With it, Qwen3-4B leaves no GSM8K answer at the 2,048-token limit, and SmolLM3-3B leaves 16 of 1,319.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: DeepSeek-R1-Distill-Qwen-1.5B Needs a One-Line Fix on TPU
&lt;/h4&gt;

&lt;p&gt;The MIT-licensed R1 distill stops at load on this image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FAILED deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B (exited) after 75s: AttributeError: 'Qwen2Config' object has no attribute 'text_config'. Did you mean: 'get_text_config'?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tpu_inference/models/jax/qwen2.py&lt;/code&gt; builds an untied &lt;code&gt;lm_head&lt;/code&gt; with &lt;code&gt;getattr(hf_config, 'hidden_size', hf_config.text_config.hidden_size)&lt;/code&gt;. Python evaluates the default argument first, so any Qwen2 checkpoint with &lt;code&gt;tie_word_embeddings: false&lt;/code&gt; fails. The Qwen2.5 Instruct checkpoints tie their embeddings and load.&lt;/p&gt;




&lt;h4&gt;
  
  
  Compare and Contrast
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;GSM8K&lt;/th&gt;
&lt;th&gt;BFCL&lt;/th&gt;
&lt;th&gt;16 requests&lt;/th&gt;
&lt;th&gt;Long prompts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 E2B&lt;/td&gt;
&lt;td&gt;5.12B&lt;/td&gt;
&lt;td&gt;0.911&lt;/td&gt;
&lt;td&gt;0.927&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;td&gt;1.00x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-4B&lt;/td&gt;
&lt;td&gt;4.02B&lt;/td&gt;
&lt;td&gt;0.915&lt;/td&gt;
&lt;td&gt;0.940&lt;/td&gt;
&lt;td&gt;0.57x&lt;/td&gt;
&lt;td&gt;0.36x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmolLM3-3B&lt;/td&gt;
&lt;td&gt;3.08B&lt;/td&gt;
&lt;td&gt;0.858&lt;/td&gt;
&lt;td&gt;0.945&lt;/td&gt;
&lt;td&gt;0.76x&lt;/td&gt;
&lt;td&gt;0.69x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-1.7B&lt;/td&gt;
&lt;td&gt;2.03B&lt;/td&gt;
&lt;td&gt;0.827&lt;/td&gt;
&lt;td&gt;0.935&lt;/td&gt;
&lt;td&gt;1.09x&lt;/td&gt;
&lt;td&gt;0.88x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Granite 3.3 2B&lt;/td&gt;
&lt;td&gt;2.53B&lt;/td&gt;
&lt;td&gt;0.817&lt;/td&gt;
&lt;td&gt;0.920&lt;/td&gt;
&lt;td&gt;0.93x&lt;/td&gt;
&lt;td&gt;0.70x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Long prompts are 16 requests of 3,072 tokens; every ratio is against Gemma on the same image.&lt;/p&gt;




&lt;h4&gt;
  
  
  So, Which One?
&lt;/h4&gt;

&lt;p&gt;For the best accuracy per chip, Gemma 4 E2B: no other model matches both its scores and its speed on a v5e-1. For accuracy with standard attention, Qwen3-4B, level with Gemma on both tests at a bit over half the output. For tool calling at Gemma's size with the plainest architecture, SmolLM3-3B: level on BFCL, 5.3 points lower on math, three quarters of the speed. For tool calling at close to Gemma's speed, Granite 3.3 2B or Qwen3-1.7B, if math matters less. The models from 1.7B down run faster than Gemma but, apart from Qwen3-1.7B, give up 18 to 45 points of GSM8K.&lt;/p&gt;




&lt;h4&gt;
  
  
  Teardown
&lt;/h4&gt;

&lt;p&gt;Each queued resource deletes itself after its last model. Confirm nothing is left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud alpha compute tpus queued-resources list &lt;span class="nt"&gt;--zone&lt;/span&gt; us-west4-a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Summary
&lt;/h4&gt;

&lt;p&gt;The goal of this article was to find which small Apache-2.0 or MIT model can stand in for Gemma 4 E2B on one TPU v5e chip. The key to the solution was serving every model on one pinned vLLM image with identical settings, scoring every answer in code, and pairing each model against Gemma on the same problems. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 Qwen3-4B is level with Gemma on GSM8K and BFCL, at 0.57x the output&lt;/li&gt;
&lt;li&gt;🟢 SmolLM3-3B, Granite 3.3 2B, Qwen3-1.7B and Qwen2.5-1.5B are level with Gemma on BFCL tool calls&lt;/li&gt;
&lt;li&gt;🟢 Gemma has the largest weights in the lineup and still out-serves every model with more than 4 GiB of weights&lt;/li&gt;
&lt;li&gt;⚠️ Gemma's shared, single-head, sliding-window KV cache holds 2.6 to 6.3 times the tokens of Granite, SmolLM3, Qwen3-1.7B and Qwen3-4B, and keeps its lead with 3,072-token prompts&lt;/li&gt;
&lt;li&gt;⚠️ Every model under 2B parameters except Qwen3-1.7B scores 18 to 45 points below Gemma on GSM8K&lt;/li&gt;
&lt;li&gt;❌ DeepSeek-R1-Distill-Qwen-1.5B and OLMo 2 1B do not load on this image&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one TPU v5e chip (&lt;code&gt;v5litepod-1&lt;/code&gt;) in &lt;code&gt;us-west4-a&lt;/code&gt;, flex-start, two runs on 2026-10-11 and 2026-10-12 on the same image digest, vLLM &lt;code&gt;0.29.1rc1.dev468+g0b7f11a1e&lt;/code&gt;. Speed is the median of three passes; accuracy is one greedy pass. Gemma's figures come from the first run and pair with the second through the shared image. OLMo 2 1B fails to compile (it asks for 133.56 MB of the chip's 128 MB vector memory). Ministral 3 3B and Qwen3.5 0.8B and 2B served but are left out of the tables: on this image Ministral returned server errors on part of each test and Qwen3.5 returned unusable text, so their scores describe the serving path. Phi-4-mini produced no parsed tool calls with the &lt;code&gt;phi4_mini_json&lt;/code&gt; parser and is reported on GSM8K and speed only. Parts of the analysis and writing were done with AI assistance (Claude); every figure comes from the committed report files.&lt;/p&gt;

&lt;p&gt;The strategy for surveying small open models against Gemma 4 on a TPU v5e was validated with an incremental step by step approach.&lt;/p&gt;




&lt;h4&gt;
  
  
  References
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Code, logs, reports and scored answers: &lt;a href="https://github.com/xbill9/model-sweep" rel="noopener noreferrer"&gt;https://github.com/xbill9/model-sweep&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;vLLM TPU: &lt;a href="https://github.com/vllm-project/tpu-inference" rel="noopener noreferrer"&gt;https://github.com/vllm-project/tpu-inference&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Gemma 4 E2B: &lt;a href="https://huggingface.co/google/gemma-4-E2B-it" rel="noopener noreferrer"&gt;https://huggingface.co/google/gemma-4-E2B-it&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Qwen3-4B: &lt;a href="https://huggingface.co/Qwen/Qwen3-4B" rel="noopener noreferrer"&gt;https://huggingface.co/Qwen/Qwen3-4B&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SmolLM3-3B: &lt;a href="https://huggingface.co/HuggingFaceTB/SmolLM3-3B" rel="noopener noreferrer"&gt;https://huggingface.co/HuggingFaceTB/SmolLM3-3B&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Granite 3.3 2B: &lt;a href="https://huggingface.co/ibm-granite/granite-3.3-2b-instruct" rel="noopener noreferrer"&gt;https://huggingface.co/ibm-granite/granite-3.3-2b-instruct&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GSM8K: &lt;a href="https://huggingface.co/datasets/openai/gsm8k" rel="noopener noreferrer"&gt;https://huggingface.co/datasets/openai/gsm8k&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Berkeley Function Calling Leaderboard: &lt;a href="https://gorilla.cs.berkeley.edu/leaderboard.html" rel="noopener noreferrer"&gt;https://gorilla.cs.berkeley.edu/leaderboard.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloud TPU v5e: &lt;a href="https://cloud.google.com/tpu/docs/v5e" rel="noopener noreferrer"&gt;https://cloud.google.com/tpu/docs/v5e&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>tpu</category>
      <category>vllm</category>
      <category>gemma</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
