<?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>Six Serving Engines on One EC2 G6: Gemma 4 E2B on an NVIDIA L4</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Fri, 06 Nov 2026 18:41:59 +0000</pubDate>
      <link>https://dev.to/aws-builders/six-serving-engines-on-one-ec2-g6-gemma-4-e2b-on-an-nvidia-l4-3ff3</link>
      <guid>https://dev.to/aws-builders/six-serving-engines-on-one-ec2-g6-gemma-4-e2b-on-an-nvidia-l4-3ff3</guid>
      <description>&lt;p&gt;This article provides a step by step comparison of six inference serving engines on one Amazon EC2 G6 instance: llama.cpp, vLLM, SGLang, mistral.rs, a plain PyTorch engine and Google's LiteRT-LM, each serving Gemma 4 E2B on Google's QAT 4-bit weights from one NVIDIA L4.&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;The question is which engine to put on a G6 when one small model has the whole GPU. Each engine gets the same weights, the same prompts, the same 1-to-64-request load sweep and the same 1,319 GSM8K and 400 BFCL questions, and each is measured with and without Google's E2B drafter wherever the engine supports one. Every engine ran on one instance, one at a time, with the card to itself.&lt;/p&gt;




&lt;h4&gt;
  
  
  The Results in Brief
&lt;/h4&gt;

&lt;p&gt;Answer quality barely moves between engines. GSM8K lands between 0.8954 and 0.9014 on the five CUDA engines with the drafter on or off, and every paired difference against llama.cpp is within the run-to-run range. LiteRT-LM scores level on GSM8K and lower on BFCL.&lt;/p&gt;

&lt;p&gt;For one request, five engines are within 8% of each other without a drafter (134.7 to 144.6 tokens per second end to end) and LiteRT-LM runs at 0.62 to 0.73x llama.cpp. The drafter decides the order: it gives vLLM 1.96x, llama.cpp 1.53x, SGLang 1.39x and LiteRT-LM 1.15x, and it slows mistral.rs to 0.95x.&lt;/p&gt;

&lt;p&gt;Under load the batching engines separate. At 64 requests of 128 tokens in and 512 out, vLLM reaches 3,716 tokens per second without a drafter and 4,516 with it, SGLang 2,967, mistral.rs 1,825 and llama.cpp 1,056. PyTorch and LiteRT-LM serve one request at a time.&lt;/p&gt;

&lt;p&gt;With each engine at its best setting, vLLM is the fastest at every level from 1 to 64 requests, on both shapes, and its drafter never loses. At the g6.4xlarge on-demand price that is $0.08 per million output tokens at full load.&lt;/p&gt;




&lt;h4&gt;
  
  
  What Changes and What Stays
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Weights&lt;/th&gt;
&lt;th&gt;Installed with&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp&lt;/td&gt;
&lt;td&gt;&lt;code&gt;7182ef71e&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;exact Q4_0 GGUF&lt;/td&gt;
&lt;td&gt;CMake, CUDA 13.1, sm_89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;0.33.0&lt;/td&gt;
&lt;td&gt;W4A16, int4 tables&lt;/td&gt;
&lt;td&gt;pip, its own &lt;code&gt;torch 2.14.0+cu130&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SGLang&lt;/td&gt;
&lt;td&gt;0.5.23&lt;/td&gt;
&lt;td&gt;W4A16, int4 tables&lt;/td&gt;
&lt;td&gt;pip, cp313 wheel on Python 3.14, two patches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mistral.rs&lt;/td&gt;
&lt;td&gt;v0.9.5&lt;/td&gt;
&lt;td&gt;exact Q4_0 GGUF&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cargo install&lt;/code&gt;, CUDA 13.1, sm_89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;this repo's int4 engine&lt;/td&gt;
&lt;td&gt;W4A16&lt;/td&gt;
&lt;td&gt;the DLAMI's torch 2.13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LiteRT-LM&lt;/td&gt;
&lt;td&gt;0.19.0&lt;/td&gt;
&lt;td&gt;exact Q4_0, and Google's int2/4/8 file&lt;/td&gt;
&lt;td&gt;pip, GPU through WebGPU on Vulkan&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same for all six: one g6.4xlarge on-demand in us-east-1d (16 vCPUs, 64 GiB, one NVIDIA L4 with 23,034 MiB, driver 595.112.07), the PyTorch 2.13 / Ubuntu 26.04 Deep Learning AMI, Gemma 4 E2B on Google's QAT Q4_0 grid in every container, greedy decoding, and the system Python 3.14 with no virtual environment. The exact Q4_0 GGUF and the W4A16 checkpoint carry the same 4-bit values: &lt;code&gt;xbill9/gemma-4-E2B-it-qat-q4_0-exact-gguf&lt;/code&gt; for llama.cpp and mistral.rs, &lt;code&gt;xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4&lt;/code&gt; for vLLM and SGLang.&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 G-family vCPU quota in us-east-1 and an instance profile with SSM access&lt;/li&gt;
&lt;li&gt;An S3 bucket for moving files to and from the instance&lt;/li&gt;
&lt;li&gt;The system &lt;code&gt;python3&lt;/code&gt; with &lt;code&gt;boto3&lt;/code&gt;, and the MCP server in &lt;code&gt;gpu-pytorch-g6-2b-q4w4a16/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A clone: &lt;code&gt;git clone https://github.com/xbill9/gemma4-dev&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 1 — Find a G6 With Capacity
&lt;/h4&gt;

&lt;p&gt;The rigs name a g6.2xlarge (8 vCPUs, one L4). On 2026-11-05 it had no spot or on-demand capacity in any us-east-1 zone, and neither did g6.4xlarge spot. The MCP tool &lt;code&gt;create_g6_instance&lt;/code&gt; walks every subnet, spot first and then on-demand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subnet-0c2872fe4182b9ec1 spot=True: ❌ AWS InsufficientInstanceCapacity: There is no Spot capacity available that matches your request.
subnet-09e0f13c0a5b43092 spot=True: ❌ AWS InsufficientInstanceCapacity: There is no Spot capacity available that matches your request.
subnet-0c2872fe4182b9ec1 spot=False: ❌ AWS InsufficientInstanceCapacity: We currently do not have sufficient g6.4xlarge capacity in the Availability Zone you requested (us-east-
subnet-0d2b2038965e8d0c0 spot=False: ✅ Launching `i-040ed957b67c2d514` (g6.4xlarge, on-demand, 1x L4) in `us-east-1`.
AMI: `ami-0f5139ed9ed52b805`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both sizes carry the same L4. The g6.4xlarge doubles the vCPUs and memory, which only llama.cpp's prompt threads and the build steps use.&lt;/p&gt;

&lt;p&gt;🔎 &lt;strong&gt;Tip: all access is SSM and S3.&lt;/strong&gt; The instance has no SSH key and its role has no S3 access. Commands go through SSM Run Command, which returns at most 24,000 characters, so files larger than that move through presigned S3 URLs generated on the workstation.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — One Rig Directory per Engine
&lt;/h4&gt;

&lt;p&gt;Each engine has its own directory with a &lt;code&gt;tpu.env&lt;/code&gt; that decides every serving flag, a Makefile run on the instance, and its measurements under &lt;code&gt;benchmarks/runs/&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;gpu-llamacpp-g6-2b-q4_0/
gpu-vllm-g6-2b-q4w4a16emb4/
gpu-sglang-g6-2b-q4w4a16emb4/
gpu-mistralrs-g6-2b-q4_0/
gpu-pytorch-g6-2b-q4w4a16/
gpu-litertlm-g6-2b-int248/
gpu-litertlm-g6-2b-q4w4a16emb4/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gpu-vllm-g6-2b-q4w4a16emb4/flight/setup.sh&lt;/code&gt; installs every engine in order and logs each step, so a failed step reruns alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GEMMA4_DEV=/opt/gemma4-dev MODEL_S3_URI=s3://... bash setup.sh llamacpp vllm sglang mistralrs litert
18:48:44 START llamacpp
18:56:56 END llamacpp
18:56:56 START vllm
18:59:18 END vllm
18:59:18 START sglang
18:59:57 END sglang
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 3 — What the Deep Learning AMI Needs
&lt;/h4&gt;

&lt;p&gt;The AMI ships the NVIDIA driver and a PyTorch 2.13 environment, and no CUDA toolkit. Four things differ from a desktop install:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;vLLM brings its own torch.&lt;/strong&gt; The system Python has no torch, so &lt;code&gt;pip install vllm==0.33.0&lt;/code&gt; pulls its pinned wheel from PyPI, built for CUDA 13.0, and it runs on driver 595.112.07:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;torch 2.14.0+cu130 cuda 13.0 NVIDIA L4 (8, 9)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;llama.cpp and mistral.rs build against CUDA 13.1&lt;/strong&gt; from NVIDIA's apt repository, for sm_89. mistral.rs also links cuRAND, which is a separate package: &lt;code&gt;libcurand-dev-13-1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LiteRT-LM finds the L4 through Vulkan.&lt;/strong&gt; The AMI lists the L4 as a Vulkan device, and LiteRT-LM's WebGPU backend serves on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pip puts scripts in &lt;code&gt;~/.local/bin&lt;/code&gt;.&lt;/strong&gt; SSM runs commands as root with &lt;code&gt;HOME=/root&lt;/code&gt;, and that directory is off its &lt;code&gt;PATH&lt;/code&gt;, so &lt;code&gt;vllm&lt;/code&gt;, &lt;code&gt;litert-lm&lt;/code&gt; and SGLang's &lt;code&gt;ninja&lt;/code&gt; are found only once it is added.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;🔎 &lt;strong&gt;Tip: SGLang 0.5.23 needs two patches for this checkpoint.&lt;/strong&gt; Its Gemma 4 attention shim runs only for multimodal configs, and it leaves the int4 &lt;code&gt;embed_tokens&lt;/code&gt; and per-layer table as uninitialized bf16. The rig's &lt;code&gt;patches/&lt;/code&gt; keep both tables packed on the card.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Size Each Engine for the Whole Card
&lt;/h4&gt;

&lt;p&gt;The card serves one engine at a time and nothing else, so each engine gets the most the L4 will hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;vLLM&lt;/strong&gt; reserves 85% of the card (&lt;code&gt;--gpu-memory-utilization 0.85&lt;/code&gt;), 64 running requests, 16,384 tokens each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SGLang&lt;/strong&gt; reserves 80% (&lt;code&gt;--mem-fraction-static 0.80&lt;/code&gt;), 64 running requests, decode CUDA graphs up to 64.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;llama.cpp&lt;/strong&gt; runs 64 slots over a 65,536-token cache for the load sweep, and one slot of 131,072 for accuracy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mistral.rs&lt;/strong&gt; runs 32 requests over a fixed 16,384-token cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;🔎 &lt;strong&gt;Tip: mistral.rs plans activation memory per running request.&lt;/strong&gt; It sets aside about 395 MiB for each, so 64 requests do not load on the L4's 23 GB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Primary device cuda[0] needs 28292 MiB (2976 MiB of fixed model components, 25316 MiB of activations, 0 MiB of post-load reservations), but only 21192 MiB is usable under the memory goal.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 32 requests a 65,536-token cache leaves too little room; &lt;code&gt;--max-seqs 32 --kv-cache 16384tokens --max-num-batched-tokens 4096&lt;/code&gt; serves in 5,286 MiB.&lt;/p&gt;




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

&lt;p&gt;&lt;code&gt;flight/engines.py&lt;/code&gt; sends six chat prompts, 256 tokens each, three times per engine, and takes the geometric mean of each prompt's median, end to end. Then 4 requests 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;arm              e2e tok/s vs llama  decode TTFT ms 4 at once  x one    memory ready s repeat
llama                135.7    1.000   136.9      29     437.6   3.23  2930 MiB     6.1   6/6
llama-d2             207.7    1.531   211.4      30     373.2   1.80  3004 MiB     4.1   6/6
mistralrs            144.6    1.066   144.4      11     500.0   3.46  4664 MiB    24.1   6/6
pytorch              134.7    0.993       -       -     134.5   1.00  3248 MiB   236.2   6/6
litert-int248         98.4    0.725   125.2     512     100.5   1.02  1489 MiB     9.4   6/6
vllm                 140.4    1.035   140.7      16     537.2   3.83 18228 MiB   167.3   6/6
vllm-d4              275.3    2.030   281.2      19     711.3   2.58 18918 MiB   132.5   6/6
sglang               136.5    1.006   139.7      50     475.4   3.48 18345 MiB   307.2   6/6
sglang-d4            189.6    1.398   198.1      55     508.3   2.68 15191 MiB    61.1   6/6
&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;Engine&lt;/th&gt;
&lt;th&gt;No drafter&lt;/th&gt;
&lt;th&gt;Best drafter&lt;/th&gt;
&lt;th&gt;Drafter gain&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;140.4&lt;/td&gt;
&lt;td&gt;🥇 275.3 (4 tokens)&lt;/td&gt;
&lt;td&gt;1.96x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp&lt;/td&gt;
&lt;td&gt;135.7&lt;/td&gt;
&lt;td&gt;207.7 (2 tokens)&lt;/td&gt;
&lt;td&gt;1.53x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SGLang&lt;/td&gt;
&lt;td&gt;136.5&lt;/td&gt;
&lt;td&gt;189.6 (4 tokens)&lt;/td&gt;
&lt;td&gt;1.39x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mistral.rs&lt;/td&gt;
&lt;td&gt;144.6&lt;/td&gt;
&lt;td&gt;137.6 (1 token)&lt;/td&gt;
&lt;td&gt;0.95x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;134.7&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LiteRT-LM&lt;/td&gt;
&lt;td&gt;98.4&lt;/td&gt;
&lt;td&gt;112.8 (its own)&lt;/td&gt;
&lt;td&gt;1.15x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;LiteRT-LM's exact Q4_0 build runs at 84.4 tokens per second, and LiteRT-LM takes about half a second to its first token against 11 to 55 ms for the others. Every engine repeats its own greedy text on all six prompts.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — 1 to 64 Requests
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;sweep.py&lt;/code&gt; runs two shapes, 512 tokens in / 128 out and 128 in / 512 out, at 1 to 64 requests at once, five repeats, a unique prompt per request so no prefix cache can hit, output timed from the stream. &lt;code&gt;flight/suite.py&lt;/code&gt; runs every sweep and every accuracy job one server at a time, and marks each finished job so a rerun skips it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python3 suite.py /opt/flight-out/2026-11-06-suite-g6 sweep:vllm,sweep:vllm-d1,sweep:vllm-d4,sweep:sglang,...
2026-11-06 04:06:20 START sweep:vllm ready_s=39.1 gpu=52, 2040 MHz, 19296 MiB
2026-11-06 04:07:33   sweep:vllm in512-out128 rc=0
2026-11-06 04:10:07   sweep:vllm in128-out512 rc=0
2026-11-06 04:10:08 END sweep:vllm ok=True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aggregate output tokens per second without a drafter, 128 in / 512 out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;vLLM&lt;/th&gt;
&lt;th&gt;SGLang&lt;/th&gt;
&lt;th&gt;mistral.rs&lt;/th&gt;
&lt;th&gt;llama.cpp&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;138.8&lt;/td&gt;
&lt;td&gt;134.6&lt;/td&gt;
&lt;td&gt;133.9&lt;/td&gt;
&lt;td&gt;133.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;991.0&lt;/td&gt;
&lt;td&gt;967.4&lt;/td&gt;
&lt;td&gt;626.5&lt;/td&gt;
&lt;td&gt;597.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;🥇 3,715.7&lt;/td&gt;
&lt;td&gt;2,967.1&lt;/td&gt;
&lt;td&gt;1,825.2&lt;/td&gt;
&lt;td&gt;1,056.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;512 in / 128 out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;vLLM&lt;/th&gt;
&lt;th&gt;SGLang&lt;/th&gt;
&lt;th&gt;mistral.rs&lt;/th&gt;
&lt;th&gt;llama.cpp&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;132.8&lt;/td&gt;
&lt;td&gt;129.6&lt;/td&gt;
&lt;td&gt;133.1&lt;/td&gt;
&lt;td&gt;125.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;738.3&lt;/td&gt;
&lt;td&gt;708.3&lt;/td&gt;
&lt;td&gt;591.2&lt;/td&gt;
&lt;td&gt;428.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;🥇 1,593.7&lt;/td&gt;
&lt;td&gt;1,373.0&lt;/td&gt;
&lt;td&gt;1,132.0&lt;/td&gt;
&lt;td&gt;630.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 126 levels completed. vLLM's output at 64 requests is 26.8x its one-request rate on long replies. SGLang reaches 0.80 to 0.86x vLLM at 64, mistral.rs 0.49 to 0.71x and llama.cpp 0.28 to 0.40x.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — Each Engine at Its Best
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;best_per_level.py&lt;/code&gt; picks each engine's fastest measured setting at each level, 128 in / 512 out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;vLLM&lt;/th&gt;
&lt;th&gt;SGLang&lt;/th&gt;
&lt;th&gt;llama.cpp&lt;/th&gt;
&lt;th&gt;mistral.rs&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;🥇 453 (4 drafted)&lt;/td&gt;
&lt;td&gt;283 (4 drafted)&lt;/td&gt;
&lt;td&gt;271 (2 drafted)&lt;/td&gt;
&lt;td&gt;134 (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;🥇 856 (4 drafted)&lt;/td&gt;
&lt;td&gt;552 (4 drafted)&lt;/td&gt;
&lt;td&gt;430 (2 drafted)&lt;/td&gt;
&lt;td&gt;254 (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;🥇 1,483 (4 drafted)&lt;/td&gt;
&lt;td&gt;875 (4 drafted)&lt;/td&gt;
&lt;td&gt;596 (2 drafted)&lt;/td&gt;
&lt;td&gt;468 (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;🥇 2,399 (4 drafted)&lt;/td&gt;
&lt;td&gt;1,223 (4 drafted)&lt;/td&gt;
&lt;td&gt;791 (2 drafted)&lt;/td&gt;
&lt;td&gt;627 (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;🥇 2,823 (4 drafted)&lt;/td&gt;
&lt;td&gt;1,673 (none)&lt;/td&gt;
&lt;td&gt;1,021 (2 drafted)&lt;/td&gt;
&lt;td&gt;1,339 (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;🥇 3,665 (1 drafted)&lt;/td&gt;
&lt;td&gt;2,257 (none)&lt;/td&gt;
&lt;td&gt;1,132 (2 drafted)&lt;/td&gt;
&lt;td&gt;1,843 (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;🥇 4,516 (4 drafted)&lt;/td&gt;
&lt;td&gt;2,967 (none)&lt;/td&gt;
&lt;td&gt;1,170 (2 drafted)&lt;/td&gt;
&lt;td&gt;1,825 (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;512 in / 128 out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;vLLM&lt;/th&gt;
&lt;th&gt;SGLang&lt;/th&gt;
&lt;th&gt;llama.cpp&lt;/th&gt;
&lt;th&gt;mistral.rs&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;🥇 345 (4 drafted)&lt;/td&gt;
&lt;td&gt;214 (4 drafted)&lt;/td&gt;
&lt;td&gt;224 (2 drafted)&lt;/td&gt;
&lt;td&gt;133 (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;🥇 1,217 (4 drafted)&lt;/td&gt;
&lt;td&gt;711 (4 drafted)&lt;/td&gt;
&lt;td&gt;494 (2 drafted)&lt;/td&gt;
&lt;td&gt;591 (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;🥇 1,389 (1 drafted)&lt;/td&gt;
&lt;td&gt;1,026 (none)&lt;/td&gt;
&lt;td&gt;572 (2 drafted)&lt;/td&gt;
&lt;td&gt;1,121 (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;🥇 1,710 (1 drafted)&lt;/td&gt;
&lt;td&gt;1,373 (none)&lt;/td&gt;
&lt;td&gt;631 (none)&lt;/td&gt;
&lt;td&gt;1,132 (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;vLLM's drafter pays at every level: with 4 drafted tokens 2.60x / 3.26x no drafter for one request, still 1.02x / 1.22x at 64. SGLang's drafter pays up to 4 to 8 requests and loses from 16 (0.73x / 0.69x at 64). llama.cpp's 2-token drafter pays at every level on long replies (1.11x at 64) and up to 16 requests on long prompts.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — Answer Quality
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;gen_eval.py&lt;/code&gt; asks each engine all 1,319 GSM8K test questions (2,048-token limit) and the 400 BFCL simple function-calling questions (512), one request at a time, greedy, thinking off:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;GSM8K&lt;/th&gt;
&lt;th&gt;BFCL&lt;/th&gt;
&lt;th&gt;GSM8K with drafter&lt;/th&gt;
&lt;th&gt;BFCL with drafter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp&lt;/td&gt;
&lt;td&gt;0.8984&lt;/td&gt;
&lt;td&gt;0.9250&lt;/td&gt;
&lt;td&gt;0.8976&lt;/td&gt;
&lt;td&gt;0.9200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;0.8969&lt;/td&gt;
&lt;td&gt;0.9225&lt;/td&gt;
&lt;td&gt;0.8992&lt;/td&gt;
&lt;td&gt;0.9225&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SGLang&lt;/td&gt;
&lt;td&gt;0.8954&lt;/td&gt;
&lt;td&gt;0.9150&lt;/td&gt;
&lt;td&gt;0.8992&lt;/td&gt;
&lt;td&gt;0.9150&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mistral.rs&lt;/td&gt;
&lt;td&gt;0.9014&lt;/td&gt;
&lt;td&gt;0.9200&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;0.8961&lt;/td&gt;
&lt;td&gt;0.9225&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LiteRT-LM, exact Q4_0&lt;/td&gt;
&lt;td&gt;0.8999&lt;/td&gt;
&lt;td&gt;0.8600&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LiteRT-LM, Google's file&lt;/td&gt;
&lt;td&gt;0.8908&lt;/td&gt;
&lt;td&gt;0.8250&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Paired question by question, every engine is level with llama.cpp on GSM8K, and the drafter changes no score. On BFCL, SGLang is 4 questions behind llama.cpp, a gap bigger than the run-to-run range, and LiteRT-LM is 26 and 40 behind.&lt;/p&gt;

&lt;p&gt;The drafter cuts the time to finish GSM8K:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;No drafter&lt;/th&gt;
&lt;th&gt;With drafter&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;3,918 s&lt;/td&gt;
&lt;td&gt;🥇 1,270 s&lt;/td&gt;
&lt;td&gt;3.08x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SGLang&lt;/td&gt;
&lt;td&gt;4,035 s&lt;/td&gt;
&lt;td&gt;1,956 s&lt;/td&gt;
&lt;td&gt;2.06x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp&lt;/td&gt;
&lt;td&gt;3,971 s&lt;/td&gt;
&lt;td&gt;2,007 s&lt;/td&gt;
&lt;td&gt;1.98x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mistral.rs&lt;/td&gt;
&lt;td&gt;3,918 s&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PyTorch&lt;/td&gt;
&lt;td&gt;3,991 s&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LiteRT-LM&lt;/td&gt;
&lt;td&gt;5,018 s&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;🔎 &lt;strong&gt;Tip: send thinking off explicitly to SGLang and mistral.rs.&lt;/strong&gt; &lt;code&gt;"chat_template_kwargs": {"enable_thinking": false}&lt;/code&gt; keeps replies short on every engine; mistral.rs thinks by default on Gemma 4.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 9 — Cost
&lt;/h4&gt;

&lt;p&gt;At the g6.4xlarge on-demand price, $1.3232 an hour in us-east-1 from the AWS Price List API, the measured throughput works out to (arithmetic, from &lt;code&gt;cost.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;Setting&lt;/th&gt;
&lt;th&gt;128 in / 512 out&lt;/th&gt;
&lt;th&gt;512 in / 128 out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vLLM + drafter, 1 request&lt;/td&gt;
&lt;td&gt;$0.81 per M tokens&lt;/td&gt;
&lt;td&gt;$1.06 per M tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vLLM + drafter, 64 requests&lt;/td&gt;
&lt;td&gt;🥇 $0.08&lt;/td&gt;
&lt;td&gt;🥇 $0.21 (1 drafted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SGLang, 64 requests&lt;/td&gt;
&lt;td&gt;$0.12&lt;/td&gt;
&lt;td&gt;$0.27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mistral.rs, 64 requests&lt;/td&gt;
&lt;td&gt;$0.20&lt;/td&gt;
&lt;td&gt;$0.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp + drafter, 64 requests&lt;/td&gt;
&lt;td&gt;$0.31&lt;/td&gt;
&lt;td&gt;$0.59&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The whole sweep and accuracy suite ran 12.2 hours, $16.14 on demand. A g6.2xlarge carries the same L4 at $0.9776 an hour; it was not measured here.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 10 — Tear Down
&lt;/h4&gt;

&lt;p&gt;Results come off the instance as one tarball through a presigned S3 PUT, and then the instance is terminated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python3 -c "import boto3; print(boto3.client('ec2', region_name='us-east-1').terminate_instances(InstanceIds=['i-040ed957b67c2d514'])['TerminatingInstances'][0]['CurrentState']['Name'])"
shutting-down
&lt;/code&gt;&lt;/pre&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;vLLM&lt;/th&gt;
&lt;th&gt;SGLang&lt;/th&gt;
&lt;th&gt;llama.cpp&lt;/th&gt;
&lt;th&gt;mistral.rs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 request, best&lt;/td&gt;
&lt;td&gt;🥇 275.3&lt;/td&gt;
&lt;td&gt;189.6&lt;/td&gt;
&lt;td&gt;207.7&lt;/td&gt;
&lt;td&gt;144.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64 requests, 128/512, best&lt;/td&gt;
&lt;td&gt;🥇 4,516&lt;/td&gt;
&lt;td&gt;2,967&lt;/td&gt;
&lt;td&gt;1,170&lt;/td&gt;
&lt;td&gt;1,825&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drafter under load&lt;/td&gt;
&lt;td&gt;🟢 pays at every level&lt;/td&gt;
&lt;td&gt;❌ 0.69x at 64&lt;/td&gt;
&lt;td&gt;🟢 to 16–64&lt;/td&gt;
&lt;td&gt;❌ slower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K level&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFCL level&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;td&gt;⚠️ 4 behind&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs as installed&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;td&gt;❌ two patches&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;td&gt;⚠️ needs cuRAND, fixed cache&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PyTorch and LiteRT-LM serve one request 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;PyTorch&lt;/th&gt;
&lt;th&gt;LiteRT-LM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 request, best&lt;/td&gt;
&lt;td&gt;134.7&lt;/td&gt;
&lt;td&gt;112.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSM8K level&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BFCL level&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;td&gt;❌ 26–40 behind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs as installed&lt;/td&gt;
&lt;td&gt;🟢 yes&lt;/td&gt;
&lt;td&gt;⚠️ Vulkan&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;On a G6 serving one small model, run vLLM with Google's W4A16 drafter at 4 tokens: it is the fastest engine for one request (275.3 tokens per second) and at every load to 64 requests, it finishes GSM8K 3.08x sooner than without the drafter at the same score, and at full load it costs $0.08 per million output tokens on demand. SGLang without a drafter reaches 0.66x of that at 64 requests and needs two patches. llama.cpp with its 2-token drafter is the simplest to build and serves one user at 207.7 tokens per second, but tops out near 1,170 tokens per second. mistral.rs, PyTorch and LiteRT-LM suit one user at a time.&lt;/p&gt;




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

&lt;p&gt;The goal of this article was to find the inference serving engine to run for Gemma 4 E2B on an Amazon EC2 G6. The key was holding everything else fixed: one instance, the same 4-bit values in every container, the same prompts, load sweep and question sets, and each engine sized for the whole L4. The six-engine results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 All six engines give the same GSM8K answers (0.8908 to 0.9014) with the drafter on or off.&lt;/li&gt;
&lt;li&gt;🟢 vLLM with its drafter is the fastest engine at every level from 1 to 64 requests: 275.3 tokens per second for one request and 4,516 at 64 (128 in / 512 out).&lt;/li&gt;
&lt;li&gt;🟢 vLLM's drafter pays at every load on the L4, and finishes GSM8K 3.08x sooner.&lt;/li&gt;
&lt;li&gt;🟢 At full load vLLM serves a million output tokens for $0.08 on demand.&lt;/li&gt;
&lt;li&gt;⚠️ The drafter's gain depends on the engine: 1.96x vLLM, 1.53x llama.cpp, 1.39x SGLang, 1.15x LiteRT-LM, 0.95x mistral.rs.&lt;/li&gt;
&lt;li&gt;⚠️ g6.2xlarge had no capacity in us-east-1 on the day; g6.4xlarge on-demand carries the same L4.&lt;/li&gt;
&lt;li&gt;❌ mistral.rs loads at most 32 requests on the L4's 23 GB.&lt;/li&gt;
&lt;li&gt;❌ SGLang's drafter cuts output to 0.69 to 0.73x of running without it at 64 requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one g6.4xlarge on-demand in us-east-1d, one NVIDIA L4, the Deep Learning AMI with driver 595.112.07, Gemma 4 E2B on Google's QAT Q4_0 grid, one engine at a time, greedy decoding. One-request speed is six prompts of 256 tokens, three repeats; load is two synthetic shapes at 1 to 64 requests, five repeats each; accuracy is one pass of GSM8K (1,319) and BFCL simple (400). The one-request runs were on 2026-11-05 and the sweeps and accuracy on 2026-11-06, on the same instance; costs are arithmetic from the on-demand price.&lt;/p&gt;

&lt;p&gt;The strategy for comparing inference serving engines on Amazon EC2 G6 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" rel="noopener noreferrer"&gt;gemma4-dev on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/gpu-vllm-g6-2b-q4w4a16emb4/benchmarks/runs/2026-11-05-engines-g6/REPORT.md" rel="noopener noreferrer"&gt;One-request run: 2026-11-05-engines-g6&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/gemma4-dev/blob/main/gpu-vllm-g6-2b-q4w4a16emb4/benchmarks/runs/2026-11-06-suite-g6/REPORT.md" rel="noopener noreferrer"&gt;Sweeps and accuracy: 2026-11-06-suite-g6&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/ec2/instance-types/g6/" rel="noopener noreferrer"&gt;Amazon EC2 G6 instances&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/ai/machine-learning/amis/" rel="noopener noreferrer"&gt;AWS Deep Learning AMIs&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&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://github.com/sgl-project/sglang" rel="noopener noreferrer"&gt;SGLang&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/EricLBuehler/mistral.rs" rel="noopener noreferrer"&gt;mistral.rs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/google-ai-edge/LiteRT-LM" rel="noopener noreferrer"&gt;LiteRT-LM&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;xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4&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&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/openai/grade-school-math" rel="noopener noreferrer"&gt;GSM8K&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://gorilla.cs.berkeley.edu/leaderboard.html" rel="noopener noreferrer"&gt;Berkeley Function Calling Leaderboard&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>ec2</category>
      <category>vllm</category>
      <category>nvidia</category>
    </item>
    <item>
      <title>Retro Techno Generator: Your Pulse Plays 90s Acid Techno, Your Dancing Plays the Filters</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Fri, 06 Nov 2026 18:40:02 +0000</pubDate>
      <link>https://dev.to/xbill/retro-techno-generator-your-pulse-plays-90s-acid-techno-your-dancing-plays-the-filters-1a6g</link>
      <guid>https://dev.to/xbill/retro-techno-generator-your-pulse-plays-90s-acid-techno-your-dancing-plays-the-filters-1a6g</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/presage-2026-11-04"&gt;Presage SmartSpectra SDK Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This article provides a step by step guide to Retro Techno Generator, a music toy built on the Presage SmartSpectra SDK for Node.js. A webcam reads your pulse, breathing and facial expressions, and a browser page turns them into 90s acid techno and hardcore with Web Audio. Every sound is synthesised; there are no samples.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Your breathing and pulse steer the music, your face plays the lead, and your dancing opens the filters.&lt;/strong&gt; Pulse and breathing need you to hold still, and the music asks you to move, so the app makes the two take turns: dance and the movement drives the sound, freeze for a few seconds and it re-reads your pulse. A hard dance raises your pulse, and the next still moment pushes the tempo up.&lt;/p&gt;




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

&lt;p&gt;A Node server runs a SmartSpectra session on the local camera and streams the signals over a WebSocket to a page on &lt;code&gt;localhost&lt;/code&gt;. The page is the instrument: a 16-step sequencer, a 303-style acid line, drum synthesis, pads, piano stabs and a hoover, all built from Web Audio oscillators and filters.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;What it plays&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pulse&lt;/td&gt;
&lt;td&gt;Tempo is pulse × 2, held between 100 and 190 BPM. Below about 70: ambient pads. 70 to 90: acid techno. Above about 90: early-90s hardcore breaks and piano stabs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Movement&lt;/td&gt;
&lt;td&gt;Opens the master filter, brightens the acid line and doubles the hi-hats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Breathing waveform&lt;/td&gt;
&lt;td&gt;Sways the master filter while you are still&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smile&lt;/td&gt;
&lt;td&gt;Opens the acid filter and switches to a major key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Surprise&lt;/td&gt;
&lt;td&gt;Fires a hoover synth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blink&lt;/td&gt;
&lt;td&gt;Stutters the drums and tears the visuals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Talking&lt;/td&gt;
&lt;td&gt;Pitched-up formant "vocal" chops&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftkxpihegldr5wbamenjy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftkxpihegldr5wbamenjy.jpg" alt="Retro Techno Generator in acid mode: HUD with mode, BPM, pulse, breaths per minute, smile, movement and vitals state, an oscilloscope trace and the 16-step grid" width="800" height="397"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The style switch has 3 BPM of hysteresis at each edge, so a pulse sitting at 70 does not flip between ambient and acid every bar. A dropdown overrides the pulse-picked style for anyone who wants to stay in hardcore.&lt;/p&gt;




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

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/_FX8n_wk7t4" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The clip is 34 seconds of a live camera session, recorded with the page's own Record button, with the camera preview hidden. The status line reads "in position", the breathing rate follows the SDK from 14 down to 6 and back to 12 breaths per minute, VITALS switches to HELD while I move and back to LIVE when I freeze, and the mode dropdown steps through acid, hardcore and ambient. The pulse readout holds its starting value of 72 for the whole clip: no pulse reading arrived during that session, so the tempo stays on its default.&lt;/p&gt;




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

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

&lt;p&gt;MIT licence. Setup is in the README and below.&lt;/p&gt;

&lt;h4&gt;
  
  
  At this point you should have…
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Node 20 or later on Linux x64/arm64, macOS Apple Silicon or Windows x64&lt;/li&gt;
&lt;li&gt;A webcam and a desk lamp you can point at your face&lt;/li&gt;
&lt;li&gt;A free SmartSpectra API key from &lt;a href="https://physiology.presagetech.com/auth/register" rel="noopener noreferrer"&gt;https://physiology.presagetech.com/auth/register&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Step 1 — Install and Run
&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/retro-techno-generator
&lt;span class="nb"&gt;cd &lt;/span&gt;retro-techno-generator
npm &lt;span class="nb"&gt;install
echo&lt;/span&gt; &lt;span class="s2"&gt;"SMARTSPECTRA_API_KEY=your-key"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env
npm start
&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;Retro Techno Generator on http://localhost:8080 (live camera)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt; and press Start. &lt;code&gt;npm run fake&lt;/code&gt; runs the whole synth with sliders in place of the camera and needs no key, which is the quickest way to hear it.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 2 — Pick a Camera
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node server.js &lt;span class="nt"&gt;--list-cameras&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;CAMERA=&amp;lt;id&amp;gt;&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; to use a camera other than the default.&lt;/p&gt;




&lt;h2&gt;
  
  
  How It Responds to Human Signals
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Step 3 — Open the SmartSpectra Session
&lt;/h4&gt;

&lt;p&gt;The server creates the session when the page sends &lt;code&gt;start&lt;/code&gt;, and requests breathing, cardio and face metrics in one session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;sdk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SmartSpectraSDK&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestedMetrics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;breathingMetrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cardioMetrics&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;faceMetrics&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;enableTelemetry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;metrics&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decodeMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="nx"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;validationStatus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hint&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;videoOutput&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onVideo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useCamera&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CameraSelection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;onMetrics&lt;/code&gt; takes the latest pulse rate, breathing rate and upper-chest breathing trace, the blink and talking flags, and the expression scores, and sends them to the page as one small JSON message. On Stop the server calls &lt;code&gt;stopAsync()&lt;/code&gt; and then &lt;code&gt;destroy()&lt;/code&gt;, which turns the camera off.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 4 — Show the Person Where to Stand
&lt;/h4&gt;

&lt;p&gt;The SDK measures only when it can see your face and upper chest, lit from the front, and it reports what is wrong through &lt;code&gt;validationStatus&lt;/code&gt; codes. With a face in view and the chest below the frame it repeats this every frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VALID 16 Move up, or tilt the camera down.
VALID 7 Place more of the chest in view.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A status line is easy to miss when you cannot see yourself, so the page has a mirror view. The SDK's &lt;code&gt;videoOutput&lt;/code&gt; event delivers each processed frame, 1280×720 RGB at 30 frames per second on this laptop's camera:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VO 2764800 1280 720 3840 0
video frames 149
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server shrinks every third frame or so to 320×180 and sends about ten a second as a binary WebSocket message. The page draws it mirrored, with a dashed head-and-shoulders outline, a border that is red until you are in position, yellow while the first readings arrive and green once they flow, and one large instruction mapped from the SDK code:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;SDK name&lt;/th&gt;
&lt;th&gt;On screen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kTooDark&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Too dark. Put a light in front of you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kChestNotVisible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;⬇ Show your shoulders: lean back or tilt the screen forward&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kExcessiveMotion&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Moving. Freeze for a few seconds to re-read pulse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kFaceTooLow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;⬆ Move up, or tilt the screen forward&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After the first good reading the preview shrinks to a corner thumbnail and stays there, so dancing does not bring the full panel back. A &lt;strong&gt;Pixelate&lt;/strong&gt; button draws the preview as coarse blocks, enough to line up against the outline without showing a recognisable face, for streaming or recording. &lt;strong&gt;Camera view&lt;/strong&gt; hides the preview entirely, which is how the demo was recorded.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 5 — Turn Movement Into an Input
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;kExcessiveMotion&lt;/code&gt; is the SDK saying what every camera vitals method needs: stillness. A dance app cannot ask for that all the time, so the same 320×180 frames give a movement level, the mean brightness change per pixel between consecutive previews:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&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="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;77&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;q&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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;29&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prevLuma&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;Math&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="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prevLuma&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;motion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Camera noise sets a baseline that depends on the room light. Sitting still in a dim room, 100 samples measured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"n":100,"min":3.03,"p50":3.56,"p90":8.58,"max":17.58}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The page tracks that baseline from below and subtracts it, so sitting still reads 0.00 and moving reads up to about 0.37 on a 0 to 1 scale. Movement then drives the master filter, the acid filter and the hi-hat density.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 6 — Read Vitals Only When Still
&lt;/h4&gt;

&lt;p&gt;Pulse and breathing updates are accepted only while the movement level stays under 0.15 and has stayed there for 1.5 seconds. Otherwise the last good values hold. The HUD shows the state as &lt;code&gt;VITALS LIVE&lt;/code&gt; or &lt;code&gt;VITALS HELD&lt;/code&gt;. Sampled every 500 ms in the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;["0.00","LIVE",72],["0.06","LIVE",72],["0.26","HELD",72],["0.14","HELD",72],
["0.37","HELD",72],["0.17","HELD",72],["0.04","HELD",72],["0.02","LIVE",72]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tempo closes 5% of the gap to the held pulse on every sixteenth-note step, so a fresh reading after a dance ramps the track up over a few bars.&lt;/p&gt;

&lt;p&gt;🔎 Tip: light from in front matters more than anything else. With a window behind you the camera exposes for the window, and the SDK reports &lt;code&gt;Too dark&lt;/code&gt; or finds no face at all. A desk lamp aimed at your face fixes both.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Most Creative or Delightful Use.&lt;/strong&gt; The idea came from the rave flyers of the early 90s promising music that moves with you. Here the music listens to the body directly: your pulse picks between ambient, acid and hardcore, a smile flips the key to major, a surprised face fires a hoover, and a blink stutters the drums. The part that makes it play like an instrument is the turn-taking between dancing and stillness. Dance hard, freeze, and you hear your own heart rate push the track into hardcore.&lt;/p&gt;




&lt;h2&gt;
  
  
  Consent and Data Handling
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Consent first.&lt;/strong&gt; The page opens on a consent screen that says what the camera is for. The camera turns on only when you press Start and turns off when you press Stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video stays on the machine.&lt;/strong&gt; Frames go from the camera to the SDK in the local Node process. A 320×180 copy goes to the page for the mirror view. No frame is recorded or written to disk. Pixelate reduces that view to coarse blocks for anyone streaming or recording their screen. The app writes no camera frame to disk. The one exception is the &lt;strong&gt;● Record&lt;/strong&gt; button, used for the demo: only while it is on, it saves a .webm of the page as shown, with the synth audio, to the browser's Downloads folder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local only.&lt;/strong&gt; The server listens on &lt;code&gt;127.0.0.1&lt;/code&gt;, so nothing outside the machine can open the page or the WebSocket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing is stored.&lt;/strong&gt; No measurements, frames or logs of signals are kept. Closing the page or pressing Stop ends everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SDK telemetry is off&lt;/strong&gt; (&lt;code&gt;enableTelemetry: false&lt;/code&gt;). Per Presage's Telemetry &amp;amp; Privacy page, the SDK then starts no telemetry session and transmits no session summary, and even when on, that summary never contains video frames or measured values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network.&lt;/strong&gt; The SDK needs a connection: measurements run inside an authenticated session with Presage's servers, opened with your API key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No AI services&lt;/strong&gt; are used by the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A music toy.&lt;/strong&gt; It makes no health or medical claims and takes no decisions from the numbers. It turns them into music.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;The goal of this article was to build a music toy that responds to the body in real time with the Presage SmartSpectra SDK. The key to the solution was letting dancing and stillness take turns: movement drives the sound, and pulse and breathing are read only in the still moments between. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 Breathing, pulse, smile, surprise, blink and talking each drive a part of the music&lt;/li&gt;
&lt;li&gt;🟢 In the demo the breathing rate updates live from the SDK, from 14 to 6 and back to 12 breaths per minute&lt;/li&gt;
&lt;li&gt;❌ No pulse reading arrived during the recorded session, so the demo's tempo stays on its default&lt;/li&gt;
&lt;li&gt;🟢 The SDK's &lt;code&gt;videoOutput&lt;/code&gt; frames feed a mirror view with plain instructions mapped from the SDK's validation codes&lt;/li&gt;
&lt;li&gt;🟢 The same frames give a movement level that reads 0.00 sitting still and drives the filters while you dance&lt;/li&gt;
&lt;li&gt;🟢 Pulse and breathing update only while still, and the HUD shows LIVE or HELD&lt;/li&gt;
&lt;li&gt;⚠️ The SDK needs your face and upper chest in frame and light in front of you before it sends any metrics&lt;/li&gt;
&lt;li&gt;⚠️ The movement baseline was tuned in one dim room; a much noisier camera may need a different scale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one Linux laptop with its integrated 1280×720 camera, SmartSpectra Node SDK 3.4.0, Chrome, measured on 2026-11-06. The movement figures come from one 100-sample still run and one 17-second mixed run in a dim room, and the demo is one 34-second session. macOS and Windows were not tested.&lt;/p&gt;

&lt;p&gt;The strategy for turning real-time human signals into music with the SmartSpectra SDK was validated with an incremental step by step approach.&lt;/p&gt;

&lt;p&gt;This app and article were created with the help of AI: the code, the write-up and the demo recording tool were built with Claude Code, and every figure was checked against the runs and files linked in the repo.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;The code: &lt;a href="https://github.com/xbill9/retro-techno-generator" rel="noopener noreferrer"&gt;https://github.com/xbill9/retro-techno-generator&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Presage SmartSpectra SDK documentation: &lt;a href="https://smartspectra.presagetech.com/docs/" rel="noopener noreferrer"&gt;https://smartspectra.presagetech.com/docs/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SDK Telemetry &amp;amp; Privacy: &lt;a href="https://smartspectra.presagetech.com/docs/telemetry-and-privacy" rel="noopener noreferrer"&gt;https://smartspectra.presagetech.com/docs/telemetry-and-privacy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SmartSpectra Node SDK on npm: &lt;a href="https://www.npmjs.com/package/@smartspectra/node-sdk" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@smartspectra/node-sdk&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The challenge: &lt;a href="https://dev.to/challenges/presage-2026-11-04"&gt;https://dev.to/challenges/presage-2026-11-04&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>presagechallenge</category>
      <category>javascript</category>
      <category>music</category>
    </item>
    <item>
      <title>The A2A CLI on Azure: One Client for Container Apps, Agent Framework and Localhost</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Fri, 06 Nov 2026 18:21:08 +0000</pubDate>
      <link>https://dev.to/xbill/the-a2a-cli-on-azure-one-client-for-container-apps-agent-framework-and-localhost-4b6k</link>
      <guid>https://dev.to/xbill/the-a2a-cli-on-azure-one-client-for-container-apps-agent-framework-and-localhost-4b6k</guid>
      <description>&lt;p&gt;This article provides a step by step guide to running A2A agents on Azure and driving them with the A2A CLI. A Rust agent on the official A2A Rust SDK runs in Azure Container Apps. A Microsoft Agent Framework agent on an Azure OpenAI model runs in Container Apps behind Entra ID and hands its work to the Rust agent. Both also run on a laptop, and one set of &lt;code&gt;a2a&lt;/code&gt; commands checks all four.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/rust-dev/tree/main/a2a-agentframework-azure" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/tree/main/a2a-agentframework-azure&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The A2A CLI drove all four endpoints with the same commands, and 19 of 19 checks passed.&lt;/strong&gt; Every hop speaks A2A 1.0: the Rust SDK serves it, Agent Framework serves it, and the Agent Framework agent calls the Rust agent with the A2A 1.0 Python client. On Azure the CLI needs one extra flag, an Entra ID bearer token, which &lt;code&gt;az login&lt;/code&gt; already provides. Model access, registry pulls and the token all run on Entra ID, with no API key anywhere.&lt;/p&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;th&gt;A2A&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mersenne-agent&lt;/code&gt; (Rust)&lt;/td&gt;
&lt;td&gt;laptop, port 8304&lt;/td&gt;
&lt;td&gt;1.0, JSON-RPC and REST&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mersenne-agent&lt;/code&gt; (same image)&lt;/td&gt;
&lt;td&gt;Container Apps, 0.25 vCPU, 0.5 GiB&lt;/td&gt;
&lt;td&gt;1.0, JSON-RPC and REST&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent Framework coordinator&lt;/td&gt;
&lt;td&gt;laptop, port 8309&lt;/td&gt;
&lt;td&gt;1.0, JSON-RPC and REST&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-5.4-mini&lt;/code&gt; on Azure OpenAI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent Framework coordinator (same code)&lt;/td&gt;
&lt;td&gt;Container Apps, Entra ID auth&lt;/td&gt;
&lt;td&gt;1.0, JSON-RPC and REST&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-5.4-mini&lt;/code&gt; on Azure OpenAI&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;a2a CLI ──► Container Apps auth (Entra ID token) ──► Agent Framework Agent ──Entra ID──► Azure OpenAI gpt-5.4-mini
                                                           └── find_mersenne_primes tool
                                                                 └── a2a-sdk 1.x client ──► Container Apps (A2A 1.0) ──► mersenne-agent
a2a CLI ────────────────────────────────────────────────────────────────────────────────► Container Apps (A2A 1.0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workload is a Lucas-Lehmer test over the first N known Mersenne prime exponents, timed by the agent itself. Every reply carries the line &lt;code&gt;Found first N Mersenne primes in X ms.&lt;/code&gt; and a data part with &lt;code&gt;requested&lt;/code&gt;, &lt;code&gt;found&lt;/code&gt; and &lt;code&gt;elapsed_ms&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Versions, read from the machine on 2026-11-06:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&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;A2A CLI (&lt;code&gt;a2a&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;v0.3.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;a2a-lf&lt;/code&gt; / &lt;code&gt;a2a-server-lf&lt;/code&gt; (Rust SDK, A2A 1.0)&lt;/td&gt;
&lt;td&gt;0.4.2 / 0.5.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;agent-framework-core&lt;/code&gt; / &lt;code&gt;agent-framework-openai&lt;/code&gt; / &lt;code&gt;agent-framework-a2a&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.24.0 / 1.18.0 / 1.0.0b261030&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;a2a-sdk&lt;/code&gt; (Python, A2A 1.0) / &lt;code&gt;azure-identity&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.3.0 / 1.27.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python / Rust&lt;/td&gt;
&lt;td&gt;3.14.7 / 1.99.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure CLI&lt;/td&gt;
&lt;td&gt;2.90.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gpt-5.4-mini&lt;/code&gt; 2026-03-17, GlobalStandard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Region&lt;/td&gt;
&lt;td&gt;eastus2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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

&lt;ul&gt;
&lt;li&gt;The A2A CLI on your &lt;code&gt;PATH&lt;/code&gt; as &lt;code&gt;a2a&lt;/code&gt;: &lt;code&gt;go install github.com/a2aproject/a2a-cli@latest&lt;/code&gt;, then rename &lt;code&gt;a2a-cli&lt;/code&gt; to &lt;code&gt;a2a&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Rust, Docker and Python 3&lt;/li&gt;
&lt;li&gt;The Azure CLI with the &lt;code&gt;containerapp&lt;/code&gt; extension, signed in with &lt;code&gt;az login&lt;/code&gt; to a subscription where you can create resource groups and Entra ID app registrations
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a version
&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;a2a v0.3.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 1 — The Model and the Shared Resources
&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/rust-dev &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;rust-dev/a2a-agentframework-azure
scripts/deploy-model.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script creates one resource group in eastus2 holding a container registry, a user-assigned managed identity, a Container Apps environment and an AIServices account with a &lt;code&gt;gpt-5.4-mini&lt;/code&gt; deployment. It grants "Cognitive Services OpenAI User" on the account twice: to the signed-in user, for the laptop, and to the managed identity, for the containers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ az group create -n a2a-agentframework-azure-rg -l eastus2
$ az containerapp env create -n a2a-af-env -g a2a-agentframework-azure-rg -l eastus2 --logs-destination none
$ az cognitiveservices account create -n a2a-af-openai-defaba -g a2a-agentframework-azure-rg -l eastus2 --kind AIServices --sku S0 --custom-domain a2a-af-openai-defaba --yes
$ az cognitiveservices account deployment create ... --model-name gpt-5.4-mini --model-version 2026-03-17 --model-format OpenAI --sku-name GlobalStandard --sku-capacity 50
✅ https://a2a-af-openai-defaba.cognitiveservices.azure.com/ deployment gpt-5.4-mini
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The account gets a custom subdomain, and every call to the model authenticates with Entra ID. The coordinator holds no key.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Run Both Agents on the Laptop
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/start-local.sh
&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;up: 8304 rust agent, 8309 agent framework coordinator -&amp;gt; http://localhost:8304
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The coordinator is an Agent Framework &lt;code&gt;Agent&lt;/code&gt; with one tool, served over A2A 1.0 by &lt;code&gt;agent_framework_a2a&lt;/code&gt; on the &lt;code&gt;a2a-sdk&lt;/code&gt; server routes. The same file runs on the laptop and in Container Apps:&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAIChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&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="n"&gt;azure_endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AZURE_OPENAI_ENDPOINT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DESCRIPTION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;INSTRUCTIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;find_mersenne_primes&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DefaultRequestHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;agent_executor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;WholeTextA2AExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&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;task_store&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;InMemoryTaskStore&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;agent_card&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Starlette&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;routes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;create_agent_card_routes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;create_rest_routes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path_prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/rest&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="nf"&gt;create_jsonrpc_routes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&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="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DefaultAzureCredential&lt;/code&gt; picks up &lt;code&gt;az login&lt;/code&gt; on the laptop and the managed identity in Azure. The CLI reads both cards and shows each one's protocol version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a card get &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8304
a2a card get &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8309
&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;Name:         Mersenne Prime Agent Rust
Version:      0.1.0
Interfaces:
  JSONRPC (A2A v1.0)     http://localhost:8304/jsonrpc
  HTTP+JSON (A2A v1.0)   http://localhost:8304/rest
Name:         Agent Framework Mersenne Coordinator
Version:      0.1.0
Interfaces:
  JSONRPC (A2A v1.0)     http://localhost:8309/
  HTTP+JSON (A2A v1.0)   http://localhost:8309/rest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 3 — The Tool Calls the Rust Agent With the A2A 1.0 Python Client
&lt;/h4&gt;

&lt;p&gt;Agent Framework's A2A package is built on &lt;code&gt;a2a-sdk&lt;/code&gt; 1.x, the same protocol version as the Rust agent. The tool sends a data part and reads the Rust agent's structured report straight from the reply:&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;struct_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SendMessageRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ROLE_USER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;create_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;RUST_AGENT_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ClientConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;streaming&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;httpx_client&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HasField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;artifact&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;artifacts&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="k"&gt;else&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;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;artifact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&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;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HasField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MessageToDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HasField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool refuses a reply whose &lt;code&gt;requested&lt;/code&gt; or &lt;code&gt;found&lt;/code&gt; differs from the count the model asked for, and the instructions tell the model to quote the agent's own reply line. The model picks N; the Rust agent does the counting and the timing. Data parts cross a protobuf &lt;code&gt;Struct&lt;/code&gt;, so integers arrive as floats and the tool reads them with &lt;code&gt;int()&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Two A2A SDK Versions on One Python
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;agent-framework-a2a&lt;/code&gt; requires &lt;code&gt;a2a-sdk&lt;/code&gt; 1.x, and the same machine runs a Strands rig whose &lt;code&gt;a2a-sdk&lt;/code&gt; is pinned to 0.3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# agent-framework-a2a wheel metadata (Requires-Dist)
Requires-Dist: agent-framework-core&amp;gt;=1.22.0,&amp;lt;2
Requires-Dist: a2a-sdk&amp;gt;=1.0.0,&amp;lt;2

# shared pyenv Python (used by the Strands rig)
a2a-sdk 0.3.26
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scripts/start-local.sh&lt;/code&gt; installs the coordinator's pinned requirements into &lt;code&gt;build/local-site&lt;/code&gt; with &lt;code&gt;pip install --target&lt;/code&gt;, and starts the coordinator with &lt;code&gt;PYTHONPATH=build/local-site&lt;/code&gt;. The two SDK versions never meet, and the container image installs the same pins.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — The Rust Agent on Container Apps
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/deploy-rust.py&lt;/code&gt; builds &lt;code&gt;docker/agent.Dockerfile&lt;/code&gt;, pushes it to the registry and creates a container app with external ingress. The app pulls with the managed identity, which holds AcrPull, so there is no registry password. The script computes the app's address from the environment's default domain before creating it and sets &lt;code&gt;ADVERTISE_URL&lt;/code&gt; to it, so the card points at Container Apps from the first revision.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/deploy-rust.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;$ docker build --platform linux/amd64 -f docker/agent.Dockerfile -t a2aafazuredefaba.azurecr.io/mersenne-agent:20261106T153734Z /home/xbill/rust-dev/a2a-agentframework-azure
$ docker push a2aafazuredefaba.azurecr.io/mersenne-agent:20261106T153734Z
$ az containerapp create -n a2a-rust-mersenne ... --target-port 8080 --ingress external --registry-identity &amp;lt;identity&amp;gt; --cpu 0.25 --memory 0.5Gi --min-replicas 1 --max-replicas 1 --env-vars PORT=8080 HOST=0.0.0.0 ADVERTISE_URL=https://a2a-rust-mersenne.braveground-531dbad2.eastus2.azurecontainerapps.io
✅ https://a2a-rust-mersenne.braveground-531dbad2.eastus2.azurecontainerapps.io/.well-known/agent-card.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commands are the laptop's, with the Container Apps address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/test-cli.sh aca-rust
&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;== aca-rust rust agent (https://a2a-rust-mersenne.braveground-531dbad2.eastus2.azurecontainerapps.io)
Interfaces:
  JSONRPC (A2A v1.0)     https://a2a-rust-mersenne.braveground-531dbad2.eastus2.azurecontainerapps.io/jsonrpc
  HTTP+JSON (A2A v1.0)   https://a2a-rust-mersenne.braveground-531dbad2.eastus2.azurecontainerapps.io/rest
✅ aca-rust card (A2A v1.0)
✅ aca-rust send
✅ aca-rust stream
✅ aca-rust rest
✅ aca-rust data-part
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 5 — The Coordinator on Container Apps Behind Entra ID
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/deploy-coordinator.py&lt;/code&gt; does four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;creates an Entra ID app registration with the identifier URI &lt;code&gt;api://&amp;lt;app id&amp;gt;&lt;/code&gt;, one delegated scope &lt;code&gt;access_as_user&lt;/code&gt;, version 2 access tokens, and the Azure CLI's client id pre-authorized on that scope&lt;/li&gt;
&lt;li&gt;builds &lt;code&gt;docker/coordinator.Dockerfile&lt;/code&gt; and pushes it&lt;/li&gt;
&lt;li&gt;creates the container app with &lt;code&gt;RUST_AGENT_URL&lt;/code&gt; set to the Container Apps Rust agent and &lt;code&gt;AZURE_CLIENT_ID&lt;/code&gt; set to the managed identity&lt;/li&gt;
&lt;li&gt;turns on Container Apps built-in authentication with the Microsoft provider, answering 401 to any request without a valid token
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/deploy-coordinator.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;$ az containerapp auth microsoft update -n a2a-af-coordinator -g a2a-agentframework-azure-rg --client-id 67ce79d7-7432-4261-b394-d32c2fc36890 --issuer https://login.microsoftonline.com/&amp;lt;tenant&amp;gt;/v2.0 --allowed-audiences api://67ce79d7-7432-4261-b394-d32c2fc36890,67ce79d7-7432-4261-b394-d32c2fc36890 --yes
$ az containerapp auth update -n a2a-af-coordinator -g a2a-agentframework-azure-rg --enabled true --unauthenticated-client-action Return401
✅ https://a2a-af-coordinator.braveground-531dbad2.eastus2.azurecontainerapps.io/.well-known/agent-card.json (401 without a token)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the CLI, the coordinator takes one flag beyond the laptop's: a bearer token for the app registration.&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;URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;scripts/azure_state.py get coordinator_url&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;scripts/entra-token.py&lt;span class="si"&gt;)&lt;/span&gt;    &lt;span class="c"&gt;# az account get-access-token --resource api://&amp;lt;app id&amp;gt;&lt;/span&gt;
a2a &lt;span class="nt"&gt;--auth&lt;/span&gt; &lt;span class="s2"&gt;"Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; card get &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
a2a &lt;span class="nt"&gt;--auth&lt;/span&gt; &lt;span class="s2"&gt;"Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; send &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--transport&lt;/span&gt; rest &lt;span class="s2"&gt;"Find the first 3 Mersenne primes"&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;Name:         Agent Framework Mersenne Coordinator
Interfaces:
  JSONRPC (A2A v1.0)     https://a2a-af-coordinator.braveground-531dbad2.eastus2.azurecontainerapps.io/
  HTTP+JSON (A2A v1.0)   https://a2a-af-coordinator.braveground-531dbad2.eastus2.azurecontainerapps.io/rest
Status:   completed (2026-11-06T16:20:22Z)
Artifacts:
  [msg_0a2ef6a5893a0e6d006aedfe460ca48195931f337d1ca8db4e] Found first 3 Mersenne primes in 0.0030ms.
2, 3, 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI takes the base URL, the card sits at the standard &lt;code&gt;/.well-known/agent-card.json&lt;/code&gt; path, and each call gets its own A2A context with no session header. Without a token, and with a malformed one, the card itself answers 401:&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 -o /dev/null -w '%{http_code}' $URL/.well-known/agent-card.json
401
$ curl -s -o /dev/null -w '%{http_code}' -H 'Authorization: Bearer not-a-jwt' $URL/.well-known/agent-card.json
401
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  🔎 Tip: Pre-Authorize the Azure CLI on the App Registration
&lt;/h4&gt;

&lt;p&gt;With the Azure CLI's client id listed under &lt;code&gt;preAuthorizedApplications&lt;/code&gt; for the &lt;code&gt;access_as_user&lt;/code&gt; scope, &lt;code&gt;az account get-access-token --resource api://&amp;lt;app id&amp;gt;&lt;/code&gt; returns a token for the coordinator with no consent prompt and no client secret. The token's claims, payload only:&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;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"67ce79d7-7432-4261-b394-d32c2fc36890"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://login.microsoftonline.com/40482c55-d00d-4c6d-8903-643d76a74b9c/v2.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;"ver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.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;"scp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"access_as_user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"azp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"04b07795-8ddb-461a-bbee-02f9e1bf7b46"&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;&lt;code&gt;azp&lt;/code&gt; is the Azure CLI. With &lt;code&gt;requestedAccessTokenVersion: 2&lt;/code&gt; the audience is the bare app id and the issuer ends in &lt;code&gt;/v2.0&lt;/code&gt;, so the built-in authentication lists both &lt;code&gt;api://&amp;lt;app id&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;app id&amp;gt;&lt;/code&gt; as allowed audiences and uses the v2 issuer.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Pin aiohttp for the Async Azure Credential
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;azure.identity.aio&lt;/code&gt; needs &lt;code&gt;aiohttp&lt;/code&gt; for managed identity, and nothing in the Agent Framework or &lt;code&gt;a2a-sdk&lt;/code&gt; dependency tree installs it. The same requirements without the pin, in a clean &lt;code&gt;python:3.14-slim-trixie&lt;/code&gt; container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aiohttp dist-info dirs installed: 0
ImportError: aiohttp package is not installed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;coordinator/requirements.txt&lt;/code&gt; pins &lt;code&gt;aiohttp==3.14.5&lt;/code&gt;. A shared Python on a laptop that already has &lt;code&gt;aiohttp&lt;/code&gt; from another package hides the gap, so the clean container is the place to check it.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — One Reply, One Artifact
&lt;/h4&gt;

&lt;p&gt;Agent Framework's OpenAI client streams a reply as text deltas, then sends one more update holding the whole text under the same message id. One streamed reply, every text update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 msg_02d7537ef107 'Found'
2 msg_02d7537ef107 ' first'
...
21 msg_02d7537ef107 ' 7'
22 msg_02d7537ef107 'Found first 4 Mersenne primes in 0.0026ms.\n2, 3, 5, 7'
# last update repeats everything streamed before it under the same message_id: True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;A2AExecutor&lt;/code&gt; appends every update to one artifact, which leaves the reply in the task twice. &lt;code&gt;WholeTextA2AExecutor&lt;/code&gt; lets the closing update replace the artifact, so streaming clients see the deltas and the finished task holds the reply once, as a single text part:&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;WholeTextA2AExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A2AExecutor&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updater&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;streamed_artifact_ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_artifact_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AgentResponseUpdate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;default_artifact_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
            &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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="n"&gt;text&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contents&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;so_far&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_streamed&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="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;so_far&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;so_far&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_streamed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&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;streamed_artifact_ids&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;streamed_artifact_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;discard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# next add_artifact replaces
&lt;/span&gt;            &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_streamed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;so_far&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;handle_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updater&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;streamed_artifact_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_artifact_id&lt;/span&gt;&lt;span class="p"&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 shell"&gt;&lt;code&gt;a2a &lt;span class="nt"&gt;--auth&lt;/span&gt; &lt;span class="s2"&gt;"Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; send &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="s2"&gt;"Find the first 10 Mersenne primes"&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;"artifacts": [
  {
    "artifactId": "msg_0df9fa92840724f8006aedfe47b9208195b7c9c1698118b7c1",
    "parts": [
      {
        "text": "Found first 10 Mersenne primes in 0.0549ms.\n2, 3, 5, 7, 13, 17, 19, 31, 61, 89"
      }
    ]
  }
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The coordinator checks in the next step require the reply line exactly once, and the timing script counts a call only when it appears once.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — The Same Checks on All Four Endpoints
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/test-cli.sh all&lt;/code&gt; starts the laptop agents and runs one set of checks against each endpoint. Each Rust agent gets card (A2A v1.0), send, stream, REST and a data part. Each coordinator gets card (A2A v1.0), send, stream and REST, and the Container Apps coordinator also gets a request with no token, which must answer 401.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/test-cli.sh all
&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;== local rust agent (http://localhost:8304)
✅ local card (A2A v1.0)
✅ local send
✅ local stream
✅ local rest
✅ local data-part
== local agent framework coordinator (http://localhost:8309)
✅ local card (A2A v1.0)
✅ local send
✅ local stream
✅ local rest
== aca-rust rust agent (https://a2a-rust-mersenne.braveground-531dbad2.eastus2.azurecontainerapps.io)
✅ aca-rust card (A2A v1.0)
✅ aca-rust send
✅ aca-rust stream
✅ aca-rust rest
✅ aca-rust data-part
-- without a token (expect 401)
✅ aca-coordinator rejects no token (401)
== aca-coordinator agent framework coordinator (https://a2a-af-coordinator.braveground-531dbad2.eastus2.azurecontainerapps.io)
✅ aca-coordinator card (A2A v1.0)
✅ aca-coordinator send
✅ aca-coordinator stream
✅ aca-coordinator rest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Container Apps coordinator checks ask for 7, 11 and 3 primes. All three reached the Container Apps Rust agent, and the coordinator's log shows each one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO:coordinator:rust agent https://a2a-rust-mersenne.braveground-531dbad2.eastus2.azurecontainerapps.io: Found first 7 Mersenne primes in 0.0059ms.
INFO:coordinator:rust agent https://a2a-rust-mersenne.braveground-531dbad2.eastus2.azurecontainerapps.io: Found first 11 Mersenne primes in 0.0339ms.
INFO:coordinator:rust agent https://a2a-rust-mersenne.braveground-531dbad2.eastus2.azurecontainerapps.io: Found first 3 Mersenne primes in 0.0039ms.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 8 — Timing the Same Command on Each Platform
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/cli-bench.py&lt;/code&gt; starts one &lt;code&gt;a2a&lt;/code&gt; process per call and times it, so wall time includes starting the CLI, fetching the card, TLS on Azure and the reply. Targets take turns, one call each per round, so drift during the run lands on every target alike. Every reply is checked, and the statistics come from the script (&lt;code&gt;results/summary-20261106.txt&lt;/code&gt;). N=10 and 20 calls per target throughout.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/cli-bench.py &lt;span class="nt"&gt;--targets&lt;/span&gt; local-rust,aca-rust &lt;span class="nt"&gt;--reps&lt;/span&gt; 20 &lt;span class="nt"&gt;--n&lt;/span&gt; 10
&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;local-rust               ok 20/20  wall median 10.5 ms (min 7.9, max 12.3)  compute median 0.02268 ms
aca-rust                 ok 20/20  wall median 164.2 ms (min 143.4, max 199.9)  compute median 0.02985 ms
&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;scripts/cli-bench.py &lt;span class="nt"&gt;--targets&lt;/span&gt; local-coordinator,aca-coordinator &lt;span class="nt"&gt;--reps&lt;/span&gt; 20 &lt;span class="nt"&gt;--n&lt;/span&gt; 10
&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;local-coordinator        ok 20/20  wall median 1836.0 ms (min 1374.5, max 4833.5)
aca-coordinator          ok 20/20  wall median 1891.2 ms (min 1581.2, max 4372.4)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Rust agent's own compute time is a fraction of a millisecond on both machines, so the 153.7 ms between the laptop and Container Apps medians is the network and TLS. A coordinator call is an Azure OpenAI call plus a nested A2A call to a Rust agent. The Container Apps coordinator's median sits 55.2 ms above the laptop's, and both ranges span more than a second: the model call sets the time, and where the coordinator runs moves it by less than the spread between calls.&lt;/p&gt;




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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make teardown
&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;$ az ad app delete --id 67ce79d7-7432-4261-b394-d32c2fc36890
$ az group delete -n a2a-agentframework-azure-rg --yes
$ az cognitiveservices account purge -n a2a-af-openai-defaba -g a2a-agentframework-azure-rg -l eastus2
deleted a2a-agentframework-azure-rg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything except the app registration lives in one resource group, so one delete removes it. The app registration lives in Entra ID and is deleted first. The script then purges the AIServices account, so no soft-deleted copy is left behind. Afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ az group exists -n a2a-agentframework-azure-rg
false
$ az cognitiveservices account list-deleted --query "[].name"
[]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Container Apps and model addresses shown in this article belong to that deployment and no longer resolve. &lt;code&gt;make deploy&lt;/code&gt; recreates all three steps under new addresses, and &lt;code&gt;make test-cli T=all&lt;/code&gt; checks them again.&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;Endpoint&lt;/th&gt;
&lt;th&gt;CLI flags beyond the laptop's&lt;/th&gt;
&lt;th&gt;A2A&lt;/th&gt;
&lt;th&gt;Median wall time, N=10&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rust, laptop 🥇&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;10.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust, Container Apps 🥈&lt;/td&gt;
&lt;td&gt;none, HTTPS address&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;164.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent Framework, laptop&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;1836.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent Framework, Container Apps&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--auth&lt;/code&gt; with an Entra ID token&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;1891.2 ms&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 an agent with no model in it, Container Apps is the short path: one container app with external ingress, a public HTTPS address and no client changes. Every call costs the network trip, 153.7 ms here, on top of a computation that takes a fraction of a millisecond.&lt;/p&gt;

&lt;p&gt;For an agent built around a model, the same Container Apps environment hosts it behind Entra ID with built-in authentication and no code in the agent. The CLI adds one flag, and &lt;code&gt;az login&lt;/code&gt; supplies the token. The model call sets the response time, wherever the agent runs.&lt;/p&gt;

&lt;p&gt;For talking to any of them, the A2A CLI is the one client. With Agent Framework on A2A 1.0, the agents talk to each other with the Python SDK and the laptop talks to all of them with the CLI, over one protocol version end to end.&lt;/p&gt;




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

&lt;p&gt;The goal of this article was to show that the A2A CLI drives the same agents on a laptop and on Azure Container Apps with the same commands. The key to the solution was A2A 1.0 on every hop, Entra ID for every credential, and one set of checks run against every endpoint. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 19 of 19 CLI checks passed across four endpoints: card, send, stream, REST and data parts on the Rust agent; card, send, stream, REST and a 401 without a token on the coordinator&lt;/li&gt;
&lt;li&gt;🟢 Agent Framework serves A2A 1.0 with JSON-RPC and REST, and its tool calls the Rust 1.0 agent with the &lt;code&gt;a2a-sdk&lt;/code&gt; 1.x client&lt;/li&gt;
&lt;li&gt;🟢 The Container Apps coordinator's calls reached the Container Apps Rust agent, confirmed in its log&lt;/li&gt;
&lt;li&gt;🟢 No API keys and no registry password: Entra ID for the model, the image pull and the CLI's token&lt;/li&gt;
&lt;li&gt;⚠️ The coordinator needs &lt;code&gt;WholeTextA2AExecutor&lt;/code&gt;, or each reply lands in the task twice&lt;/li&gt;
&lt;li&gt;⚠️ The async Azure credential needs &lt;code&gt;aiohttp&lt;/code&gt; pinned; nothing else installs it&lt;/li&gt;
&lt;li&gt;⚠️ Container Apps added 153.7 ms per Rust agent call over the laptop; the coordinators' medians differ by 55.2 ms, inside a spread of more than a second&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one laptop (13th Gen Intel Core i7-1360P, Linux), one Azure region (eastus2), one replica of each app at 0.25 vCPU and 0.5 GiB, one model (&lt;code&gt;gpt-5.4-mini&lt;/code&gt;), measured on 2026-11-06. Timing used N=10 with 20 calls per target, one run per comparison. Every Azure figure includes the trip from the laptop. Both apps kept one replica running, so cold starts were not measured; cost, gRPC, task commands, push notifications and load were not tested.&lt;/p&gt;

&lt;p&gt;The strategy for running A2A agents on Azure and driving them with the A2A CLI 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;The rig: &lt;a href="https://github.com/xbill9/rust-dev/tree/main/a2a-agentframework-azure" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/tree/main/a2a-agentframework-azure&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CLI check transcript: &lt;a href="https://github.com/xbill9/rust-dev/blob/main/a2a-agentframework-azure/results/test-cli-all-20261106T162111Z.txt" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/blob/main/a2a-agentframework-azure/results/test-cli-all-20261106T162111Z.txt&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Timing summary: &lt;a href="https://github.com/xbill9/rust-dev/blob/main/a2a-agentframework-azure/results/summary-20261106.txt" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/blob/main/a2a-agentframework-azure/results/summary-20261106.txt&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A2A CLI: &lt;a href="https://github.com/a2aproject/a2a-cli" rel="noopener noreferrer"&gt;https://github.com/a2aproject/a2a-cli&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A2A Rust SDK: &lt;a href="https://github.com/a2aproject/a2a-rs" rel="noopener noreferrer"&gt;https://github.com/a2aproject/a2a-rs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A2A Python SDK: &lt;a href="https://github.com/a2aproject/a2a-python" rel="noopener noreferrer"&gt;https://github.com/a2aproject/a2a-python&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Agent Framework: &lt;a href="https://github.com/microsoft/agent-framework" rel="noopener noreferrer"&gt;https://github.com/microsoft/agent-framework&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Authentication and authorization in Azure Container Apps: &lt;a href="https://learn.microsoft.com/azure/container-apps/authentication" rel="noopener noreferrer"&gt;https://learn.microsoft.com/azure/container-apps/authentication&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Managed identities in Azure Container Apps: &lt;a href="https://learn.microsoft.com/azure/container-apps/managed-identity" rel="noopener noreferrer"&gt;https://learn.microsoft.com/azure/container-apps/managed-identity&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Configure Azure OpenAI with Microsoft Entra ID authentication: &lt;a href="https://learn.microsoft.com/azure/ai-foundry/openai/how-to/managed-identity" rel="noopener noreferrer"&gt;https://learn.microsoft.com/azure/ai-foundry/openai/how-to/managed-identity&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>a2a</category>
      <category>azure</category>
      <category>rust</category>
      <category>agents</category>
    </item>
    <item>
      <title>One Word, Two GPUs: Why Gemma 4 Answers the Same Question Differently on a Laptop and a Tesla T4</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Fri, 06 Nov 2026 16:31:06 +0000</pubDate>
      <link>https://dev.to/gde/one-word-two-gpus-why-gemma-4-answers-the-same-question-differently-on-a-laptop-and-a-tesla-t4-1dj2</link>
      <guid>https://dev.to/gde/one-word-two-gpus-why-gemma-4-answers-the-same-question-differently-on-a-laptop-and-a-tesla-t4-1dj2</guid>
      <description>&lt;p&gt;This article provides a step by step investigation of why Gemma 4 E2B, run from the same GGUF file with the same llama.cpp build and greedy decoding, gives different answers on a GTX 1650 Ti laptop GPU and a Tesla T4, followed through one GSM8K question from the first differing word down to the order in which each card adds its numbers.&lt;/p&gt;

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




&lt;h4&gt;
  
  
  Same File, Same Build, Two Different Answers
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Give a laptop GPU and a cloud GPU the same Gemma 4 file, the same llama.cpp build and temperature zero, and ask them the same grade-school math question. The laptop answers $20, and the Tesla T4 answers 2000.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each card repeats itself exactly: the same answer, token for token, every time it runs. What differs is the number of processors on each chip. llama.cpp splits every big matrix product into one share per processor, 16 shares on the laptop and 40 on the T4, and float32 addition rounds the last digits differently when the same numbers are grouped differently. Those last-digit differences build up through the model's layers. After 124 identical tokens the model reaches a near-tie between " spent" and " saved", the two cards fall on opposite sides of it, and every word after that follows from the one that was picked. One stock llama.cpp setting, &lt;code&gt;-ub 1&lt;/code&gt;, makes the two cards agree on every token.&lt;/p&gt;




&lt;h4&gt;
  
  
  A Week Down One Comment
&lt;/h4&gt;

&lt;p&gt;On 2026-10-29 the L4 article in this series picked up one line from a reader:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Same files, same build, and still 2 BF16 questions graded differently across the two cards is the part I keep coming back to.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same question had been nagging here too. Byte-identical model files, one llama.cpp tag, temperature zero: two cards ought to print the same words. At BF16 they disagreed on the grade of 2 questions. At Q8_0 they disagreed on the wording of most answers.&lt;/p&gt;

&lt;p&gt;A week of runs on three NVIDIA cards later, every one of those differences is accounted for. This article follows one of them, question &lt;strong&gt;test-358&lt;/strong&gt;, from the answer down to the arithmetic.&lt;/p&gt;




&lt;h4&gt;
  
  
  The Results in Brief
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;The GTX 1650 Ti answers test-358 with &lt;strong&gt;$20&lt;/strong&gt;, which is right. The Tesla T4 answers &lt;strong&gt;2000&lt;/strong&gt;, which is graded wrong.&lt;/li&gt;
&lt;li&gt;The two cards print the same 124 tokens, then part at one word: the 1650 picks " spent" at 51.4% against " saved" at 47.8%; the T4 picks " saved" at 59.1% against " spent" at 40.4%.&lt;/li&gt;
&lt;li&gt;Each card repeats itself exactly. Run twice on different days, the 1650 gives the same score at all 97,116 tokens compared.&lt;/li&gt;
&lt;li&gt;Two things make the cards differ. llama.cpp splits each big matrix product into one share per streaming multiprocessor (SM), and the 1650 has 16 where the T4 has 40. One matrix stored in BF16 goes to NVIDIA's cuBLAS library, which also picks its method by the card.&lt;/li&gt;
&lt;li&gt;Adding the same 8,960 float32 numbers in 16 shares gives 47.0389595; in 40 shares it gives 47.0389099.&lt;/li&gt;
&lt;li&gt;With one stock setting, &lt;code&gt;-ub 1&lt;/code&gt;, the two cards match on all 250 answers and all 97,450 token scores at Q8_0, and on all 107,195 at Q4_0. It costs 14 to 15% of total run time.&lt;/li&gt;
&lt;li&gt;The model's own scores cannot say which answers will change grade. Catching all 18 that change across seven setups means flagging 164 of 250 questions.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;ul&gt;
&lt;li&gt;The repository, &lt;code&gt;xbill9/gemma-graveyard&lt;/code&gt;, with its 250 held-out GSM8K questions in &lt;code&gt;data/heldout.jsonl&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;llama.cpp &lt;code&gt;b11699&lt;/code&gt; (&lt;code&gt;7182ef71e&lt;/code&gt;) built with CUDA for compute capability 7.5 on every card.&lt;/li&gt;
&lt;li&gt;The Gemma 4 E2B Q8_0 GGUF, sha256 &lt;code&gt;6b0301104c89a43c3bec5bae4b14d744f6000bc21e00e46e887fbbfe024de38a&lt;/code&gt;, on each machine.&lt;/li&gt;
&lt;li&gt;Two cards of the same generation: a GTX 1650 Ti with Max-Q Design (laptop, 4 GB) and a Tesla T4 (GCE &lt;code&gt;n1-standard-2&lt;/code&gt;). Both are Turing, compute capability 7.5.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 1 — Ask Both Cards the Same Question
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;graveyard.py run&lt;/code&gt; starts its own &lt;code&gt;llama-server&lt;/code&gt; with &lt;code&gt;-ngl 99 --seed 0&lt;/code&gt;, sends each question with greedy decoding and thinking off, grades the last number in the answer, and with &lt;code&gt;--top-logprobs 2&lt;/code&gt; records the two highest-scoring candidates at every generated token.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./graveyard.py run Q8_0 ~/graveyard-gguf/e2b-Q8_0.gguf data/heldout.jsonl out/1650-default &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--llama&lt;/span&gt; ~/graveyard-llama &lt;span class="nt"&gt;--top-logprobs&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;question: Marisa gets $5 as pocket money every day from her parents. She buys 4 lollipops worth 25 cents each every day and saves the change in her piggy bank. How much money does she put in her piggy bank if she saves for 5 days?
gold answer: 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two cards' last lines, from &lt;code&gt;docs/flip_derived.py&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;GTX 1650 Ti tokens generated: 412; final line: 'Answer: 20.00'; extracted 20.0; right True
T4 tokens generated: 345; final line: 'Answer: 2000'; extracted 2000.0; right False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both cards do the sum correctly. The T4 works in cents and reports 2000 cents. The grader wants dollars.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Find the Word Where the Answers Part
&lt;/h4&gt;

&lt;p&gt;The model writes one token at a time, and each token is the highest-scoring candidate given everything written so far. So the two answers are identical up to the first token where the cards rank the candidates differently, and everything after that follows from it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 docs/flip_derived.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;tokens identical on both cards before the split: 124
text before the split (last 120 chars): 'tep 3: Calculate the amount of money Marisa spends each day.**\nMarisa receives \\$5 (which is 500 cents) every day.\nMoney'
GTX 1650 Ti at the split: ' spent' 51.4%, ' saved' 47.8%
T4 at the split: ' saved' 59.1%, ' spent' 40.4%
GTX 1650 Ti next 12 tokens: ' spent per day = Total pocket money - Cost of lollip'
T4 next 12 tokens: ' saved per day = Money received - Money spent\nMoney saved'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At "Money ___" the model is close to a coin flip between " spent" and " saved" on both cards. The 1650 lands on one side and the T4 on the other. From there the 1650 writes "Money spent per day", the T4 writes "Money saved per day", and 221 tokens later they reach different final lines.&lt;/p&gt;

&lt;p&gt;🔎 &lt;strong&gt;Tip: the split is almost always a near-tie.&lt;/strong&gt; Across all 165 Q8_0 answers where the two cards' text differs, the median lead of the top candidate at the split is 0.12 logits on the 1650 and 0.13 on the T4, and in 157 of the 165 the two cards hold the same two candidates in opposite order.&lt;/p&gt;

&lt;p&gt;Edward Lorenz met the same shape when he repeated a weather simulation with one value rounded from .506127 to .506, and the whole pattern his program produced was transformed. A fully deterministic calculation, a difference in the sixth decimal place, and a result that changes completely.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Check That Each Card Repeats Itself
&lt;/h4&gt;

&lt;p&gt;A difference between cards means something only if each card agrees with itself. The 1650 ran Q8_0 at default settings on 2026-11-02, and again on 2026-11-03 with a patched build whose new switches were all off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;same card twice: 1650 default 2026-11-02 vs 1650 patched build, variables unset, 2026-11-03: text differs 0, grade differs 0, tokens compared 97116, same score 97116, median lead at the split None / None, top two swapped 0 of 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every answer matches word for word, and every one of 97,116 token scores is the same number. The T4 gives the same result against its own earlier run (98,637 of 98,637). Each card is deterministic. The answers depend on which card runs them.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Count Each Card's Processors
&lt;/h4&gt;

&lt;p&gt;Both cards report compute capability 7.5. They differ in how many streaming multiprocessors (SMs) they have, read from the driver with &lt;code&gt;tools/sm_count.py&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;NVIDIA GeForce GTX 1650 Ti with Max-Q Design sm=16 cc=7.5 smem_per_block=49152
Tesla T4 sm=40 cc=7.5 smem_per_block=49152
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;llama.cpp reads the SM count once at start-up (&lt;code&gt;ggml/src/ggml-cuda/ggml-cuda.cu:353&lt;/code&gt;). Its matrix kernel for quantized weights splits a prompt's work into one share per SM so that every SM stays busy (&lt;code&gt;mmq.cuh&lt;/code&gt;, the "stream-k" split), then adds the shares' partial sums together. Sixteen SMs means sixteen partial sums. Forty SMs means forty.&lt;/p&gt;

&lt;p&gt;Float32 addition rounds after every step, so the grouping changes the last digits. &lt;code&gt;docs/flip_derived.py&lt;/code&gt; adds the same 8,960 random float32 numbers both ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8,960 float32 numbers, numpy default_rng(0).standard_normal, each share added in order, then the shares
16 shares of 560: 47.0389595
40 shares of 224: 47.0389099
exact sum (math.fsum in float64): 47.0389371
16-share and 40-share sums equal: False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are valid float32 results, either side of the exact sum. The model makes sums like this in every layer for every token, and at a near-tie like "Money ___" a change in the sixth digit is enough to swap the two candidates.&lt;/p&gt;

&lt;p&gt;The test: a small patch (&lt;code&gt;tools/graveyard-probe.patch&lt;/code&gt;) adds &lt;code&gt;GGML_CUDA_NSM_OVERRIDE&lt;/code&gt;, which replaces the SM count llama.cpp reads. Told it has 40 SMs, the 1650 splits its work the way the T4 does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1650 default vs T4 default: text differs 165, grade differs 4, tokens compared 50572, same score 29969, ...
1650 told 40 SMs vs T4 default: text differs 32, grade differs 2, tokens compared 87580, same score 85282, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SM count accounts for 133 of the 165 differing answers.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Find the Matrix That Goes to cuBLAS
&lt;/h4&gt;

&lt;p&gt;The other 32 come from one matrix. The Q8_0 file stores &lt;code&gt;per_layer_model_proj&lt;/code&gt; (1536 × 8960) in BF16, because llama-quantize leaves it unquantized (&lt;code&gt;src/llama-quant.cpp:324&lt;/code&gt;). llama.cpp has no batch kernel for BF16 on compute capability 7.5, so for any prompt longer than 8 tokens it hands that product to cuBLAS. With the patch's &lt;code&gt;GGML_CUDA_LOG_CUBLAS=1&lt;/code&gt;, 200 of 200 logged cuBLAS calls are this one matrix.&lt;/p&gt;

&lt;p&gt;cuBLAS chooses its own method by the card. NVIDIA's documentation, section 2.1.4, states the limit:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;By design, all cuBLAS API routines from a given toolkit version, generate the same bit-wise results at every run when executed on GPUs with the same architecture and the same number of SMs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The 1650 and the T4 share the architecture and differ in SM count, so cuBLAS is free to give them different bits. The SM override cannot reach it, because cuBLAS reads the real card.&lt;/p&gt;

&lt;p&gt;The patch's &lt;code&gt;GGML_CUDA_SPLIT_TO_VEC=1&lt;/code&gt; sends that one product through llama.cpp's single-token kernel instead. With both changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1650 both fixes vs T4 matrix fix: text differs 0, grade differs 0, tokens compared 97380, same score 97380, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 250 answers match, and all 97,380 token scores are the same number. The same two changes make the cards match at Q4_0 and at every K-quant rung from Q6_K to Q2_K.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — Make Them Agree With One Stock Setting
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;-ub 1&lt;/code&gt; sets llama.cpp's micro-batch to one token. The prompt then goes through the same single-token kernels a generated token uses, which split no work by SM count and never call cuBLAS. It needs no patch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tools/ub1-ladder.sh 1650 ~/graveyard-gguf ~/graveyard-llama
&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;1650 -ub 1 vs T4 -ub 1: text differs 0, grade differs 0, tokens compared 97450, same score 97450, ...
Q4_0: 1650 -ub 1 vs T4 -ub 1: text differs 0, grade differs 0, tokens compared 107195, same score 107195, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;-ub 1&lt;/code&gt; on both cards, both cards answer test-358 with $20 and get 226 of 250 right at Q8_0.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — What Agreement Costs
&lt;/h4&gt;

&lt;p&gt;Generation speed stays within 1 token per second of default on both cards (42.5 to 42.9 on the 1650, 57.0 to 58.0 on the T4). Prompt processing slows down, more on the T4 than on the 1650. From the server logs and the summed per-answer times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;card | route | prompt tokens/s | prompt slowdown | median generation tokens/s | whole run, summed seconds | whole run vs default
GTX 1650 Ti | default | 289 | 1.0x | 42.8 | 2358 | +0%
GTX 1650 Ti | both fixes | 239 | 1.2x | 42.5 | 2403 | +2%
GTX 1650 Ti | -ub 1 | 58 | 5.0x | 42.9 | 2696 | +14%
T4 | default | 1270 | 1.0x | 57.9 | 1722 | +0%
T4 | matrix fix | 1060 | 1.2x | 57.0 | 1734 | +1%
T4 | -ub 1 | 81 | 15.7x | 58.0 | 1986 | +15%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GSM8K prompts are short, so a 15.7x slower prompt adds 15% to the whole run. Long prompts were not measured; they spend a larger share of each run on the prompt.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — Ask Whether the Model Can Warn You
&lt;/h4&gt;

&lt;p&gt;If the model knew when it was at a coin flip, it could flag the answers at risk. Seven Q8_0 setups give seven sets of answers: the T4, the 1650 and the L4 at default settings, the L4 with &lt;code&gt;-ub 1&lt;/code&gt;, the 1650 and T4 with &lt;code&gt;-ub 1&lt;/code&gt; (identical), the 1650 told 40 SMs, and the 1650 and T4 with both fixes (identical). &lt;code&gt;tools/route_spread.py&lt;/code&gt; counts them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"runs": 7, "questions": 250, "text_varies": 219, "grade_varies": 18
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;219 of 250 answers are worded differently in at least one setup, and 18 change grade. test-358 is right in 3 of the 7.&lt;/p&gt;

&lt;p&gt;Flag a question when any token in the T4's default answer had a lead below a threshold:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;flag when some token's lead is below&lt;/th&gt;
&lt;th&gt;questions flagged&lt;/th&gt;
&lt;th&gt;of the 18 that change grade&lt;/th&gt;
&lt;th&gt;of the 232 that do not&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.05&lt;/td&gt;
&lt;td&gt;112&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.1&lt;/td&gt;
&lt;td&gt;164&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;146&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.25&lt;/td&gt;
&lt;td&gt;218&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Catching all 18 means flagging 164 questions. Nearly every answer has a near-tie somewhere, and most of them change only the wording. Looking only at the last 12 tokens, where the final number is written, catches 1 of the 18.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 9 — Ask Which Setup Is Right
&lt;/h4&gt;

&lt;p&gt;On the 1650 alone, each rung under &lt;code&gt;-ub 1&lt;/code&gt;, under default settings and under both fixes, from &lt;code&gt;docs/evidence-flips/ladder-routes.txt&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;rung&lt;/th&gt;
&lt;th&gt;right, &lt;code&gt;-ub 1&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;right, default&lt;/th&gt;
&lt;th&gt;right, both fixes&lt;/th&gt;
&lt;th&gt;grade changes, &lt;code&gt;-ub 1&lt;/code&gt; vs default&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;226&lt;/td&gt;
&lt;td&gt;229&lt;/td&gt;
&lt;td&gt;229&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q6_K&lt;/td&gt;
&lt;td&gt;231 🥇&lt;/td&gt;
&lt;td&gt;226&lt;/td&gt;
&lt;td&gt;228&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q5_K_M&lt;/td&gt;
&lt;td&gt;228&lt;/td&gt;
&lt;td&gt;226&lt;/td&gt;
&lt;td&gt;230 🥇&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q4_K_M&lt;/td&gt;
&lt;td&gt;223&lt;/td&gt;
&lt;td&gt;222&lt;/td&gt;
&lt;td&gt;226 🥇&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q4_0&lt;/td&gt;
&lt;td&gt;226 🥇&lt;/td&gt;
&lt;td&gt;216&lt;/td&gt;
&lt;td&gt;224&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q3_K_M&lt;/td&gt;
&lt;td&gt;190&lt;/td&gt;
&lt;td&gt;183&lt;/td&gt;
&lt;td&gt;195 🥇&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q2_K&lt;/td&gt;
&lt;td&gt;3 🥇&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No setup wins every rung. The totals move by 3 to 12 per rung while 6 to 37 individual questions change grade. Each setup is its own sample of the model's near-ties, and the score is an average over which way they happened to fall.&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;default settings&lt;/th&gt;
&lt;th&gt;SM count + matrix fix&lt;/th&gt;
&lt;th&gt;&lt;code&gt;-ub 1&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Needs a patch&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1650 and T4 answers match, Q8_0&lt;/td&gt;
&lt;td&gt;85 of 250&lt;/td&gt;
&lt;td&gt;250 of 250 🥇&lt;/td&gt;
&lt;td&gt;250 of 250 🥇&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whole run, T4&lt;/td&gt;
&lt;td&gt;baseline 🥇&lt;/td&gt;
&lt;td&gt;+1%&lt;/td&gt;
&lt;td&gt;+15%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt speed, T4&lt;/td&gt;
&lt;td&gt;1,270 tokens/s 🥇&lt;/td&gt;
&lt;td&gt;1,060 tokens/s&lt;/td&gt;
&lt;td&gt;81 tokens/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right of 250, Q8_0, T4&lt;/td&gt;
&lt;td&gt;227&lt;/td&gt;
&lt;td&gt;229&lt;/td&gt;
&lt;td&gt;226&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 benchmark that two people on two cards need to reproduce, run with &lt;code&gt;-ub 1&lt;/code&gt; and say so: it makes these two cards agree to the last digit with stock llama.cpp, for 15% more run time on short prompts. For serving, the default settings are faster and no less accurate; the cost is that an answer from one card is a sample, and another card can word it differently or grade it differently.&lt;/p&gt;

&lt;p&gt;Report a score with the card, the build and the settings that produced it. Two cards that agree on a total can still disagree on which questions they got right.&lt;/p&gt;




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

&lt;p&gt;The goal of this article was to explain why the same Gemma 4 E2B file gives different answers on two GPUs of the same generation. The key to the solution was following one question, test-358, to the single word where the two cards part, and then removing each cause of the difference until the two cards matched at every token. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 Each card is deterministic: the same card run twice matches on all 97,116 token scores.&lt;/li&gt;
&lt;li&gt;🟢 Two causes explain every difference: llama.cpp splits matrix work by SM count (16 against 40), and cuBLAS handles one BF16 matrix by the card. Removing both gives 250 of 250 matching answers.&lt;/li&gt;
&lt;li&gt;🟢 Stock &lt;code&gt;-ub 1&lt;/code&gt; makes the GTX 1650 Ti and the T4 match on every token score at Q8_0 and Q4_0, for 14 to 15% more run time.&lt;/li&gt;
&lt;li&gt;⚠️ At default settings the two cards word 165 of 250 Q8_0 answers differently and grade 4 differently. test-358 is right on the 1650 and wrong on the T4.&lt;/li&gt;
&lt;li&gt;⚠️ Seven setups on Q8_0 change the grade of 18 questions; scores stay between 225 and 230.&lt;/li&gt;
&lt;li&gt;❌ The model's own scores cannot pick out the answers at risk: catching all 18 means flagging 164 of 250.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: Gemma 4 E2B, 250 held-out GSM8K questions, greedy, seed 0, thinking off, 1,024 max tokens, llama.cpp &lt;code&gt;b11699&lt;/code&gt; built for compute capability 7.5 with CUDA 13.4, one run per setup. Cards: a GTX 1650 Ti with Max-Q Design (laptop), a Tesla T4 on GCE, and an L4 on AWS for the seven-setup count. The T4 rungs under &lt;code&gt;-ub 1&lt;/code&gt; stop at Q4_0; the K-quant rungs under &lt;code&gt;-ub 1&lt;/code&gt; are on the 1650 only. Speed is compared within each card only. Every figure here is recomputed from the committed runs by &lt;code&gt;docs/flip_derived.py&lt;/code&gt; and &lt;code&gt;make verify&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The strategy for tracing a GPU-to-GPU answer flip in Gemma 4 down to the order of its additions 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/robertpaschal/comment/3jd4d"&gt;The comment that started it&lt;/a&gt;, on the L4 article&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/gde/performance-vs-quality-gemma-4-e2b-quantization-on-an-nvidia-l4-and-a-tesla-t4-4b90"&gt;Performance vs Quality: Gemma 4 E2B Quantization on an NVIDIA L4 and a Tesla T4&lt;/a&gt;, the L4 article&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/gde/four-gpus-one-quantization-ladder-gemma-4-e2b-from-a-4-gb-laptop-to-an-nvidia-l4-4olp"&gt;Four GPUs, One Quantization Ladder: Gemma 4 E2B From a 4 GB Laptop to an NVIDIA L4&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/gemma-graveyard" rel="noopener noreferrer"&gt;gemma-graveyard on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/gemma-graveyard/blob/main/runs/2026-11-03-nsm/REPORT.md" rel="noopener noreferrer"&gt;SM count and cuBLAS report&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/gemma-graveyard/blob/main/runs/2026-11-04-ub1/REPORT.md" rel="noopener noreferrer"&gt;&lt;code&gt;-ub 1&lt;/code&gt; report&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.nvidia.com/cuda/cublas/index.html#results-reproducibility" rel="noopener noreferrer"&gt;cuBLAS documentation, Results Reproducibility&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.technologyreview.com/2011/02/22/196987/when-the-butterfly-effect-took-flight/" rel="noopener noreferrer"&gt;When the Butterfly Effect Took Flight&lt;/a&gt;, MIT Technology Review&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ggml-org/llama.cpp" rel="noopener noreferrer"&gt;llama.cpp&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&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/openai/grade-school-math" rel="noopener noreferrer"&gt;GSM8K&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>gemma</category>
      <category>llamacpp</category>
      <category>cuda</category>
      <category>nvidia</category>
    </item>
    <item>
      <title>The A2A CLI on AWS: One Client for Lightsail, Bedrock AgentCore and Localhost</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Thu, 05 Nov 2026 18:13:26 +0000</pubDate>
      <link>https://dev.to/aws-builders/the-a2a-cli-on-aws-one-client-for-lightsail-bedrock-agentcore-and-localhost-o69</link>
      <guid>https://dev.to/aws-builders/the-a2a-cli-on-aws-one-client-for-lightsail-bedrock-agentcore-and-localhost-o69</guid>
      <description>&lt;p&gt;This article provides a step by step guide to running A2A agents on AWS and driving them with the A2A CLI. A Rust agent on the official A2A Rust SDK runs in an Amazon Lightsail container. A Strands agent on an Amazon Bedrock model runs in Amazon Bedrock AgentCore Runtime and hands its work to the Rust agent. Both also run on a laptop, and one set of &lt;code&gt;a2a&lt;/code&gt; commands checks all four.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/rust-dev/tree/main/a2a-strands-aws" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/tree/main/a2a-strands-aws&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The A2A CLI drove all four endpoints with the same commands, 16 of 16 checks passed, and the CLI also carried the call from the Strands agent to the Rust agent.&lt;/strong&gt; The two agents speak different protocol versions: the Rust SDK serves A2A 1.0 and Strands serves A2A 0.3. The CLI reads both. Strands' own Python client reads only 0.3, so the Strands agent calls the Rust agent by running the same &lt;code&gt;a2a&lt;/code&gt; binary the laptop uses. On AWS the CLI needs a login token, a session header and the full agent card URL, and nothing else changes.&lt;/p&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;th&gt;A2A&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mersenne-agent&lt;/code&gt; (Rust)&lt;/td&gt;
&lt;td&gt;laptop, port 8204&lt;/td&gt;
&lt;td&gt;1.0, JSON-RPC and REST&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mersenne-agent&lt;/code&gt; (same image)&lt;/td&gt;
&lt;td&gt;Lightsail container service, nano&lt;/td&gt;
&lt;td&gt;1.0, JSON-RPC and REST&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strands coordinator&lt;/td&gt;
&lt;td&gt;laptop, port 8209&lt;/td&gt;
&lt;td&gt;0.3, JSON-RPC&lt;/td&gt;
&lt;td&gt;Claude Haiku 5.5 on Bedrock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strands coordinator (same code)&lt;/td&gt;
&lt;td&gt;Bedrock AgentCore Runtime&lt;/td&gt;
&lt;td&gt;0.3, JSON-RPC&lt;/td&gt;
&lt;td&gt;Claude Haiku 5.5 on Bedrock&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;a2a CLI ──► AgentCore Runtime (A2A 0.3, Cognito token) ──► Strands Agent ──Bedrock──► Claude Haiku 5.5
                                                                └── find_mersenne_primes tool
                                                                      └── a2a CLI ──► Lightsail (A2A 1.0) ──► mersenne-agent
a2a CLI ──────────────────────────────────────────────────────────────────────────► Lightsail (A2A 1.0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workload is the one from the sibling rig: a Lucas-Lehmer test over the first N known Mersenne prime exponents, timed by the agent itself. Every reply carries the line &lt;code&gt;Found first N Mersenne primes in X ms.&lt;/code&gt; and a data part with &lt;code&gt;requested&lt;/code&gt;, &lt;code&gt;found&lt;/code&gt; and &lt;code&gt;elapsed_ms&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Versions, read from the machine on 2026-11-05:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&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;A2A CLI (&lt;code&gt;a2a&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;v0.3.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;a2a-lf&lt;/code&gt; / &lt;code&gt;a2a-server-lf&lt;/code&gt; (Rust SDK, A2A 1.0)&lt;/td&gt;
&lt;td&gt;0.4.2 / 0.5.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;strands-agents&lt;/code&gt; / &lt;code&gt;a2a-sdk&lt;/code&gt; / &lt;code&gt;bedrock-agentcore&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.64.0 / 0.3.26 / 1.25.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;1.99.1, edition 2024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;us.anthropic.claude-haiku-5-5&lt;/code&gt; on Bedrock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Region&lt;/td&gt;
&lt;td&gt;us-east-1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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

&lt;ul&gt;
&lt;li&gt;The A2A CLI on your &lt;code&gt;PATH&lt;/code&gt; as &lt;code&gt;a2a&lt;/code&gt;: &lt;code&gt;go install github.com/a2aproject/a2a-cli@latest&lt;/code&gt;, then rename &lt;code&gt;a2a-cli&lt;/code&gt; to &lt;code&gt;a2a&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Rust, Go, Docker and Python 3 with &lt;code&gt;pip install "strands-agents[a2a]" "bedrock-agentcore[a2a]"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;AWS credentials with Bedrock model access in us-east-1, and the &lt;code&gt;lightsailctl&lt;/code&gt; plugin for the AWS CLI
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a version
&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;a2a v0.3.1
commit:  none
built:   unknown
go:      go1.26.3
os/arch: linux/amd64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 1 — Run Both Agents on the Laptop
&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/rust-dev &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;rust-dev/a2a-strands-aws
scripts/start-local.sh
&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;up: 8204 rust agent, 8209 strands coordinator -&amp;gt; http://localhost:8204
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The coordinator is a Strands &lt;code&gt;Agent&lt;/code&gt; with one tool, served over A2A by the AgentCore SDK's &lt;code&gt;serve_a2a&lt;/code&gt;. The same file runs on the laptop and in AgentCore:&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;create_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_context_id&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;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Strands Mersenne Coordinator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;BedrockModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL_ID&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;find_mersenne_primes&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;callback_handler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;serve_a2a&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StrandsA2AExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;create_agent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;agent_card&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI reads both cards and shows each one's protocol version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a card get &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8204
a2a card get &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8209
&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;Name:         Mersenne Prime Agent Rust
Version:      0.1.0
Interfaces:
  JSONRPC (A2A v1.0)     http://localhost:8204/jsonrpc
  HTTP+JSON (A2A v1.0)   http://localhost:8204/rest
Name:         Strands Mersenne Coordinator
Version:      0.1.0
Interfaces:
  JSONRPC (A2A v0.3.0)   http://localhost:8209/
Streaming:    true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 2 — Why the Strands Tool Calls the A2A CLI
&lt;/h4&gt;

&lt;p&gt;Strands pins &lt;code&gt;a2a-sdk&lt;/code&gt; below 0.4, so its A2A client and server speak protocol 0.3. An A2A 1.0 card lists its endpoints under &lt;code&gt;supportedInterfaces&lt;/code&gt; and drops the top-level &lt;code&gt;url&lt;/code&gt; field that a 0.3 card requires. &lt;code&gt;scripts/check-sdk03-card.py&lt;/code&gt; fetches each card with the 0.3 client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/check-sdk03-card.py http://localhost:8204
scripts/check-sdk03-card.py http://localhost:8209
&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;a2a-sdk 0.3.26, card from http://localhost:8204
A2AClientJSONError: JSON Error: Failed to validate agent card structure from http://localhost:8204/.well-known/agent-card.json: [{"type":"missing","loc":["url"],"msg":"Field required", ...
a2a-sdk 0.3.26, card from http://localhost:8209
ok: Strands Mersenne Coordinator, protocolVersion 0.3.0, url http://localhost:8209/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The A2A CLI reads both, so the tool runs it with JSON output and a data part, then reads the Rust agent's structured report:&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="n"&gt;A2A_BIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send&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;-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RUST_AGENT_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-o&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;json&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;--timeout&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;60s&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;--data-part&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capture_output&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;text&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;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;90&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 refuses a reply whose &lt;code&gt;requested&lt;/code&gt; or &lt;code&gt;found&lt;/code&gt; differs from the count the model asked for, and the system prompt tells the model to quote the agent's own reply line. The model picks N; the Rust agent does the counting and the timing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a send &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8209 &lt;span class="s2"&gt;"Find the first 9 Mersenne primes"&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;Artifacts:
  [agent_response] Found first 9 Mersenne primes in 0.0298ms.

Exponents: 2, 3, 5, 7, 13, 17, 19, 31, 61
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 3 — The Rust Agent on Lightsail
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/deploy-lightsail.py&lt;/code&gt; creates a nano container service, builds &lt;code&gt;docker/agent.Dockerfile&lt;/code&gt;, pushes it with &lt;code&gt;aws lightsail push-container-image&lt;/code&gt; and deploys it with &lt;code&gt;ADVERTISE_URL&lt;/code&gt; set to the service's own HTTPS address, so the card's interfaces point at Lightsail. The health check is the agent card path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/deploy-lightsail.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;creating container service a2a-rust-mersenne (nano, scale 1)
$ docker build --platform linux/amd64 -f docker/agent.Dockerfile -t a2a-rust-mersenne:local /home/xbill/rust-dev/a2a-strands-aws
$ aws lightsail push-container-image --region us-east-1 --service-name a2a-rust-mersenne --label agent --image a2a-rust-mersenne:local
pushed :a2a-rust-mersenne.agent.222
✅ https://a2a-rust-mersenne.6wpv8vensby5c.us-east-1.cs.amazonlightsail.com/.well-known/agent-card.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commands are the laptop's, with the Lightsail address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/test-cli.sh lightsail
&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;== lightsail rust agent (https://a2a-rust-mersenne.6wpv8vensby5c.us-east-1.cs.amazonlightsail.com)
Interfaces:
  JSONRPC (A2A v1.0)     https://a2a-rust-mersenne.6wpv8vensby5c.us-east-1.cs.amazonlightsail.com/jsonrpc
  HTTP+JSON (A2A v1.0)   https://a2a-rust-mersenne.6wpv8vensby5c.us-east-1.cs.amazonlightsail.com/rest
✅ lightsail card (A2A v1.0)
✅ lightsail send
✅ lightsail stream
✅ lightsail rest
✅ lightsail data-part
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 4 — An arm64 Image Built on an x86 Laptop
&lt;/h4&gt;

&lt;p&gt;AgentCore Runtime runs arm64 containers, serves A2A on port 9000, and expects the card at &lt;code&gt;/.well-known/agent-card.json&lt;/code&gt; and a health answer at &lt;code&gt;/ping&lt;/code&gt;. &lt;code&gt;serve_a2a&lt;/code&gt; provides all three. The image has to be arm64, and this laptop has no arm64 emulator, so &lt;code&gt;scripts/build-coordinator.sh&lt;/code&gt; assembles it from parts that are already built for arm64:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pip downloads aarch64 wheels: &lt;code&gt;--platform manylinux_2_28_aarch64 --python-version 3.13 --only-binary=:all:&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Go cross-compiles the A2A CLI: &lt;code&gt;GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the Dockerfile only copies, so Docker never runs an arm64 binary
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.13-slim-trixie&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; build/coordinator/site /opt/site&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; build/coordinator/a2a /usr/local/bin/a2a&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; coordinator/main.py /app/main.py&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; PYTHONPATH=/opt/site PYTHONUNBUFFERED=1 DOCKER_CONTAINER=1 A2A_PORT=9000&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 9000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["python", "main.py"]&lt;/span&gt;
&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;file build/coordinator/a2a
docker image inspect a2a-strands-coordinator:local &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Os}}/{{.Architecture}}'&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;build/coordinator/a2a: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, Go BuildID
linux/arm64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ARCH=amd64 scripts/build-coordinator.sh&lt;/code&gt; builds the same image for x86, which runs on the laptop and checks the image layout before AgentCore sees it.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — The Strands Agent on Bedrock AgentCore Runtime
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/deploy-agentcore.py&lt;/code&gt; pushes the image to ECR and creates an IAM role with ECR pull, CloudWatch Logs and &lt;code&gt;bedrock:InvokeModel*&lt;/code&gt;. It creates a Cognito user pool with one app client and one user, and an AgentCore runtime with &lt;code&gt;serverProtocol: A2A&lt;/code&gt;, a &lt;code&gt;customJWTAuthorizer&lt;/code&gt; on that pool, public networking, and &lt;code&gt;RUST_AGENT_URL&lt;/code&gt; set to the Lightsail agent. A second update sets &lt;code&gt;AGENTCORE_RUNTIME_URL&lt;/code&gt; once the runtime's ARN exists, so the card advertises the AgentCore address.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/deploy-agentcore.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;  runtime CREATING (version 1)
  runtime READY (version 1)
  runtime UPDATING (version 2)
  runtime READY (version 2)
✅ https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A106059658660%3Aruntime%2Fa2a_strands_coordinator-Rtn6xkF0PS/invocations/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the CLI, AgentCore takes a bearer token from Cognito and a session id header of at least 33 characters:&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;CARD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;scripts/aws_state.py get agentcore_card&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;scripts/cognito-token.py&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;SID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"a2a-cli-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/kernel/random/uuid&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
a2a &lt;span class="nt"&gt;--auth&lt;/span&gt; &lt;span class="s2"&gt;"Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--svc-param&lt;/span&gt; &lt;span class="s2"&gt;"X-Amzn-Bedrock-AgentCore-Runtime-Session-Id=&lt;/span&gt;&lt;span class="nv"&gt;$SID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; card get &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CARD&lt;/span&gt;&lt;span class="s2"&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;Name:         Strands Mersenne Coordinator
Description:  Strands agent on Amazon Bedrock (A2A 0.3) that delegates Mersenne prime runs to a Rust A2A 1.0 agent through the a2a CLI.
Version:      0.1.0
Interfaces:
  JSONRPC (A2A v0.3.0)   https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A106059658660%3Aruntime%2Fa2a_strands_coordinator-Rtn6xkF0PS/invocations
Streaming:    true
Skills:
  mersenne-via-rust    Mersenne primes via the Rust agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  🔎 Tip: Give the CLI the Full Card URL for AgentCore
&lt;/h4&gt;

&lt;p&gt;The CLI reads a URL with no card path as a host, and asks that host's origin for &lt;code&gt;/.well-known/agent-card.json&lt;/code&gt;. For AgentCore the origin is &lt;code&gt;https://bedrock-agentcore.us-east-1.amazonaws.com&lt;/code&gt;, which answers 404:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ a2a --auth "Bearer $TOKEN" --svc-param "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id=$SID" card get -a "https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3A...%2Fa2a_strands_coordinator-Rtn6xkF0PS/invocations/"
Error: failed to resolve agent card: card request failed, status: 404 Not Found
hint: check the --agent-card reference (host, full card URL, or local file path)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Append &lt;code&gt;.well-known/agent-card.json&lt;/code&gt; to the invocations URL and the same command returns the card. The deploy script stores that URL as &lt;code&gt;agentcore_card&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — The Same Checks on All Four Endpoints
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/test-cli.sh all&lt;/code&gt; starts the laptop agents and runs one set of checks against each endpoint. Each Rust agent gets card (A2A v1.0), send, stream, REST and a data part. Each coordinator gets card (A2A v0.3), send and stream. Every check looks for the agent's reply line with the right N.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/test-cli.sh all
&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;== local rust agent (http://localhost:8204)
✅ local card (A2A v1.0)
✅ local send
✅ local stream
✅ local rest
✅ local data-part
== local strands coordinator (http://localhost:8209)
✅ local card (A2A v0.3)
✅ local send
✅ local stream
== lightsail rust agent (https://a2a-rust-mersenne.6wpv8vensby5c.us-east-1.cs.amazonlightsail.com)
✅ lightsail card (A2A v1.0)
✅ lightsail send
✅ lightsail stream
✅ lightsail rest
✅ lightsail data-part
== agentcore strands coordinator (https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/arn%3A...%2Fa2a_strands_coordinator-Rtn6xkF0PS/invocations/.well-known/agent-card.json)
✅ agentcore card (A2A v0.3)
✅ agentcore send
✅ agentcore stream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AgentCore checks ask for 7 and 11 primes. Both requests reached Lightsail, through the CLI inside the AgentCore container. The Lightsail container log shows them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO mersenne_agent: computed requested=7 found=7 elapsed_ms=0.005935
INFO mersenne_agent: computed requested=11 found=11 elapsed_ms=0.056663000000000005
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 7 — Timing the Same Command on Each Platform
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/cli-bench.py&lt;/code&gt; starts one &lt;code&gt;a2a&lt;/code&gt; process per call and times it, so wall time includes starting the CLI, fetching the card, TLS on AWS and the reply. Targets take turns, one call each per round, so drift during the run lands on every target alike. Every reply is checked, and the statistics come from the script (&lt;code&gt;results/summary-20261105.txt&lt;/code&gt;). N=10 throughout.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/cli-bench.py &lt;span class="nt"&gt;--targets&lt;/span&gt; local-rust,lightsail-rust &lt;span class="nt"&gt;--reps&lt;/span&gt; 20 &lt;span class="nt"&gt;--n&lt;/span&gt; 10
&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;local-rust               ok 20/20  wall median 27.7 ms (min 24.1, max 35.1)  compute median 0.05682 ms
lightsail-rust           ok 20/20  wall median 154.4 ms (min 140.4, max 174.3)  compute median 0.03928 ms
&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;scripts/cli-bench.py &lt;span class="nt"&gt;--targets&lt;/span&gt; local-coordinator,agentcore-coordinator &lt;span class="nt"&gt;--reps&lt;/span&gt; 5 &lt;span class="nt"&gt;--n&lt;/span&gt; 10
scripts/cli-bench.py &lt;span class="nt"&gt;--targets&lt;/span&gt; local-coordinator,agentcore-coordinator &lt;span class="nt"&gt;--reps&lt;/span&gt; 5 &lt;span class="nt"&gt;--n&lt;/span&gt; 10 &lt;span class="nt"&gt;--reuse-session&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;local-coordinator        ok 5/5  wall median 2596.2 ms (min 2288.0, max 2662.2)
agentcore-coordinator    ok 5/5  wall median 3416.5 ms (min 3364.7, max 3482.8)
local-coordinator        ok 5/5  wall median 2459.9 ms (min 2184.5, max 2683.8)
agentcore-coordinator    ok 5/5  wall median 2997.1 ms (min 2849.7, max 12177.7)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Rust agent's own compute time is a fraction of a millisecond on both machines, so the 126.7 ms between the laptop and Lightsail medians is the network and TLS. A coordinator call is a Bedrock model call plus a nested CLI call to the Rust agent. AgentCore added 820.3 ms over the laptop with a new session id per call and 537.2 ms with one session for the run. In the one-session run, the first AgentCore call took 12177.7 ms and the other four stayed near the median.&lt;/p&gt;




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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make teardown
&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;deleted runtime a2a_strands_coordinator
deleted user pool a2a-strands-aws
deleted role a2a-strands-coordinator-runtime
deleted ECR repository a2a-strands-coordinator
deleted a2a-rust-mersenne
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;make deploy&lt;/code&gt; recreates both, and &lt;code&gt;make test-cli T=all&lt;/code&gt; checks them again.&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;Endpoint&lt;/th&gt;
&lt;th&gt;CLI flags beyond the laptop's&lt;/th&gt;
&lt;th&gt;A2A&lt;/th&gt;
&lt;th&gt;Median wall time, N=10&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rust, laptop 🥇&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;27.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust, Lightsail 🥈&lt;/td&gt;
&lt;td&gt;none, HTTPS address&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;154.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strands, laptop&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;2596.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strands, AgentCore&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--auth&lt;/code&gt;, session header, full card URL&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;3416.5 ms new session per call, 2997.1 ms one session&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 an agent with no model in it, Lightsail is the short path: one container, a public HTTPS address, no client changes. Every call costs the network trip, 126.7 ms here, on top of a sub-millisecond computation.&lt;/p&gt;

&lt;p&gt;For an agent built around a model, AgentCore Runtime gives it a managed home with inbound authentication, and costs the CLI two flags and the full card URL. Reuse a session id across calls when a client sends many.&lt;/p&gt;

&lt;p&gt;For talking to any of them, the A2A CLI is the one client. It reads A2A 1.0 and 0.3 cards, and it fits inside an agent as well as on a laptop: the Strands agent reaches a Rust 1.0 agent with the same binary, where its own 0.3 client cannot read the card.&lt;/p&gt;




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

&lt;p&gt;The goal of this article was to show that the A2A CLI drives the same agents on a laptop, on Amazon Lightsail and on Amazon Bedrock AgentCore Runtime with the same commands. The key to the solution was one set of checks run against every endpoint, and a Strands tool that calls the Rust agent through the CLI. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 16 of 16 CLI checks passed across four endpoints: card, send, stream, REST and data parts on the Rust agent, card, send and stream on the Strands agent&lt;/li&gt;
&lt;li&gt;🟢 The CLI read A2A 1.0 cards (Rust SDK) and A2A 0.3 cards (Strands) with the same commands&lt;/li&gt;
&lt;li&gt;🟢 Inside AgentCore the CLI carried the agent-to-agent call to Lightsail, confirmed in the Lightsail log&lt;/li&gt;
&lt;li&gt;🟢 An arm64 AgentCore image built on an x86 laptop with no emulator&lt;/li&gt;
&lt;li&gt;⚠️ AgentCore needs &lt;code&gt;--auth&lt;/code&gt;, a session id header and the full card URL; the invocations URL alone returns 404&lt;/li&gt;
&lt;li&gt;⚠️ Lightsail added 126.7 ms per call over the laptop, AgentCore 820.3 ms with a new session per call and 537.2 ms with one session&lt;/li&gt;
&lt;li&gt;❌ The &lt;code&gt;a2a-sdk&lt;/code&gt; 0.3 Python client that Strands uses cannot parse an A2A 1.0 card&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one laptop (13th Gen Intel Core i7-1360P, Linux), one AWS region (us-east-1), one Lightsail power (nano), one model (&lt;code&gt;us.anthropic.claude-haiku-5-5&lt;/code&gt;), measured on 2026-11-05. Timing used N=10 with 20 calls per Rust target and 5 per coordinator target, and each coordinator comparison is a single run. Every AWS figure includes the trip from the laptop. gRPC, task commands, push notifications and load were not tested.&lt;/p&gt;

&lt;p&gt;The strategy for running A2A agents on AWS and driving them with the A2A CLI 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;The rig: &lt;a href="https://github.com/xbill9/rust-dev/tree/main/a2a-strands-aws" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/tree/main/a2a-strands-aws&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The sibling rig, the same Rust agent on a laptop: &lt;a href="https://github.com/xbill9/rust-dev/tree/main/a2a-benchmark-rust" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/tree/main/a2a-benchmark-rust&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CLI check transcript: &lt;a href="https://github.com/xbill9/rust-dev/blob/main/a2a-strands-aws/results/test-cli-all-20261105T163653Z.txt" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/blob/main/a2a-strands-aws/results/test-cli-all-20261105T163653Z.txt&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Timing summary: &lt;a href="https://github.com/xbill9/rust-dev/blob/main/a2a-strands-aws/results/summary-20261105.txt" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/blob/main/a2a-strands-aws/results/summary-20261105.txt&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A2A CLI: &lt;a href="https://github.com/a2aproject/a2a-cli" rel="noopener noreferrer"&gt;https://github.com/a2aproject/a2a-cli&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A2A Rust SDK: &lt;a href="https://github.com/a2aproject/a2a-rs" rel="noopener noreferrer"&gt;https://github.com/a2aproject/a2a-rs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Strands Agents: &lt;a href="https://strandsagents.com/" rel="noopener noreferrer"&gt;https://strandsagents.com/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Deploy A2A servers in AgentCore Runtime: &lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-a2a.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-a2a.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A2A protocol contract for AgentCore Runtime: &lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-a2a-protocol-contract.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-a2a-protocol-contract.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Amazon Lightsail container services: &lt;a href="https://docs.aws.amazon.com/lightsail/latest/userguide/amazon-lightsail-container-services.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/lightsail/latest/userguide/amazon-lightsail-container-services.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>a2a</category>
      <category>aws</category>
      <category>rust</category>
      <category>agents</category>
    </item>
    <item>
      <title>Android 17 on a 4.9 Kernel: LineageOS 24.0 on the Pixel 3a</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Thu, 05 Nov 2026 16:37:16 +0000</pubDate>
      <link>https://dev.to/gde/android-17-on-a-49-kernel-lineageos-240-on-the-pixel-3a-1mok</link>
      <guid>https://dev.to/gde/android-17-on-a-49-kernel-lineageos-240-on-the-pixel-3a-1mok</guid>
      <description>&lt;p&gt;This article provides a step by step guide to running LineageOS 24.0, which is Android 17, on a Google Pixel 3a and its original 4.9 Linux kernel. It continues the LineageOS 23.2 port on the same phone. Every patch, script and build is published.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/android_kernel_google_msm-4.9/releases/tag/lineage-24.0-sargo-20261027" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_kernel_google_msm-4.9/releases/tag/lineage-24.0-sargo-20261027&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Pixel 3a boots LineageOS 24.0 on kernel 4.9.337 with SELinux enforcing in 83 s, with Wi-Fi, Bluetooth, the sensors, Chrome and Google Play Services working, and updates that keep user data. Android 17 assumes a kernel of at least 5.10 in more places than Android 16 did. The port needed one kernel fix, for a cgroup BPF reference count the 4.9 backport had missed, five forks of platform code (fscrypt keys, the BPF loader, netd and Connectivity, and the native app zygote), and device-tree changes for &lt;code&gt;ueventd&lt;/code&gt;, gralloc and the ION memory allocator. The &lt;code&gt;/init&lt;/code&gt; logger from the 23.2 port found each one.&lt;/strong&gt;&lt;/p&gt;




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

&lt;p&gt;The Pixel 3a (codename sargo) has a Snapdragon 670 and a 4.9 kernel. Its last Google build is Android 12. LineageOS carries it officially to 22.2, and the previous article in this series brought it to 23.2, Android 16, by backporting three kernel features.&lt;/p&gt;

&lt;p&gt;The goal here: keep the phone current and out of a landfill by running the newest LineageOS release, 24.0, on the kernel it already has.&lt;/p&gt;

&lt;p&gt;The kernel carries a BPF backport that reports itself to Android as 5.4. The NetBpfLoad lines from the first 24.0 boot show the version Android reads, &lt;code&gt;0x504012e&lt;/code&gt;, against the 5.10 it asks for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NetBpfLoad: skipping map sk_storage which requires kernel version 0x504012e &amp;gt;= 0x50a0000
NetBpfLoad: skipping map packet_trace_ringbuf which requires kernel version 0x504012e &amp;gt;= 0x50a0000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;ul&gt;
&lt;li&gt;A Google Pixel 3a (sargo), bootloader unlocked&lt;/li&gt;
&lt;li&gt;The LineageOS 24.0 source with the local manifest from the release&lt;/li&gt;
&lt;li&gt;A build machine with room for the tree; the 24.0 tree here is 266 GB, and the home machine is an i7-3770 with 8 threads and 31 GB of RAM&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;adb&lt;/code&gt; and &lt;code&gt;fastboot&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A clone: &lt;code&gt;git clone https://github.com/xbill9/android_kernel_google_msm-4.9&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first full build ran on a 192-core EC2 instance and the tree then moved to the home machine. That half of the work has its own article: &lt;a href="https://dev.to/aws-builders/rent-the-cores-keep-the-phone-building-lineageos-24-on-ec2-and-iterating-at-home-4pmc"&gt;https://dev.to/aws-builders/rent-the-cores-keep-the-phone-building-lineageos-24-on-ec2-and-iterating-at-home-4pmc&lt;/a&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Carry the 23.2 Tree to 24.0
&lt;/h4&gt;

&lt;p&gt;The device tree starts from the 23.2 port, on a new &lt;code&gt;lineage-24.0&lt;/code&gt; branch of the bonito fork. Android 17 dropped several HIDL HALs and moved a few Pixel components, so the tree changed in the same places the Pixel 4a (sunfish) did:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;vibrator HAL (drv2624) carried in the tree; thermal from &lt;code&gt;hardware/google/pixel/thermal&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;AIDL lights on &lt;code&gt;/sys/class/backlight/panel0-backlight&lt;/code&gt;, since Android 17 has no HIDL lights&lt;/li&gt;
&lt;li&gt;power stats AIDL only, HIDL dumpstate dropped&lt;/li&gt;
&lt;li&gt;the fingerprint blob patched to &lt;code&gt;lineage.frameworks.stats@1.0&lt;/code&gt;, as sunfish does&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KERNEL_CROSS_COMPILE&lt;/code&gt; with the GCC 4.9 binutils: LineageOS 24 builds kernels with LLVM only, and 4.9 predates &lt;code&gt;LLVM=1&lt;/code&gt; and links LTO with GNU gold&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full build then stopped on six more device-tree errors, among them &lt;code&gt;unknown type logger_app&lt;/code&gt;, two &lt;code&gt;genfscon&lt;/code&gt; rules for &lt;code&gt;/sys/class/typec&lt;/code&gt;, and a framework compatibility matrix that starts at level 7. A small shim keeps the SecureUI blob linking against Android 17's &lt;code&gt;SurfaceComposerClient::createSurface&lt;/code&gt;. The list, with commits, is in the 24.0 README.&lt;/p&gt;

&lt;p&gt;The lunch form changed too: &lt;code&gt;lunch lineage_sargo cp2a userdebug&lt;/code&gt;, with spaces. The first complete package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lineage-24.0-20261023-UNOFFICIAL-sargo.zip  1,208,903,032 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 2 — Read the Boot With the /init Logger
&lt;/h4&gt;

&lt;p&gt;The 23.2 port built a 4 KB static &lt;code&gt;/init&lt;/code&gt;, &lt;code&gt;kmsglog.c&lt;/code&gt;, that execs the real init as process 1 and copies the kernel log to the &lt;code&gt;metadata&lt;/code&gt; partition. For 24.0 it gained three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it also appends logcat to &lt;code&gt;/metadata/logcat.txt&lt;/code&gt;, by entering init's root through &lt;code&gt;/proc/1/root&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kmsglog.reboot=&amp;lt;s&amp;gt;&lt;/code&gt; on the kernel command line dumps every blocked task and reboots to the bootloader after that many seconds, so a hung boot ends by itself&lt;/li&gt;
&lt;li&gt;the log file is truncated each boot and wraps at 2 MB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;add-logger.sh&lt;/code&gt; repacks any boot image with 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;EXTRA_CMDLINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"androidboot.selinux=permissive kmsglog.reboot=150"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ./add-logger.sh boot.img boot-log.img
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;test-boot-b.sh&lt;/code&gt; RAM-boots the image on slot b, waits, and pulls &lt;code&gt;/metadata/boot-kmsg.txt&lt;/code&gt; and &lt;code&gt;/metadata/logcat.txt&lt;/code&gt; from the fallback system. The flashed slot keeps a working build, so a failed test falls back to it with no hands on the phone.&lt;/p&gt;

&lt;p&gt;🔎 &lt;strong&gt;Tip: &lt;code&gt;/metadata&lt;/code&gt; is only 10 MB.&lt;/strong&gt; It also holds vold's keys and the feature flags in &lt;code&gt;/metadata/aconfig&lt;/code&gt;, so the logger caps the kernel log at 2 MB, rotates logcat at 1 MB times two, and the test script deletes the logs after pulling them.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — ueventd
&lt;/h4&gt;

&lt;p&gt;The first permissive boot shows keymaster, gatekeeper, citadeld and surfaceflinger aborting. Their device nodes, &lt;code&gt;/dev/qseecom&lt;/code&gt;, &lt;code&gt;/dev/citadel0&lt;/code&gt; and &lt;code&gt;/dev/kgsl-3d0&lt;/code&gt;, still have root-only permissions.&lt;/p&gt;

&lt;p&gt;Android 17's ueventd reads only &lt;code&gt;/system/etc/ueventd.rc&lt;/code&gt;, which imports &lt;code&gt;/vendor/etc/ueventd.rc&lt;/code&gt;. The device installed its rules to the old &lt;code&gt;/vendor/ueventd.rc&lt;/code&gt;, which ueventd no longer parses. Bonito &lt;code&gt;31b5a72&lt;/code&gt; installs them to &lt;code&gt;vendor/etc&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Gralloc
&lt;/h4&gt;

&lt;p&gt;Next, the composer HAL aborts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;F GraphicBufferMapper: gralloc-mapper is missing
F DEBUG   : Abort message: 'gralloc-mapper is missing'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Android 17's &lt;code&gt;libui&lt;/code&gt; loads only IMapper 4.0 and newer on API 36 and later. The sdm845 display stack provides IMapper 2.1. &lt;code&gt;libui&lt;/code&gt; still has the older path behind &lt;code&gt;LEGACY_GRALLOC&lt;/code&gt;, and bonito &lt;code&gt;433912b&lt;/code&gt; builds it with that flag through Soong config.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — The fscrypt Session Keyring
&lt;/h4&gt;

&lt;p&gt;With a full build flashed, the phone goes to recovery 4.9 s into the boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;E vold    : Failed to install fscrypt key to /data: Inappropriate ioctl for device
E init: Exec service failed, status 25: Rebooting into recovery, reason: enablefilecrypto_failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 4.9 kernel has no &lt;code&gt;FS_IOC_ADD_ENCRYPTION_KEY&lt;/code&gt;, so file encryption keys go into a keyring that every process shares through a session keyring. Android 17 removed that path from both sides: init no longer joins a session keyring, and vold no longer installs keys into it.&lt;/p&gt;

&lt;p&gt;Two forks bring it back from 23.2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;system/core&lt;/code&gt; &lt;code&gt;5742820&lt;/code&gt;: init joins a session keyring at second stage, and &lt;code&gt;mount_all&lt;/code&gt; creates the &lt;code&gt;fscrypt&lt;/code&gt; keyring on encrypted devices&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;system/vold&lt;/code&gt; &lt;code&gt;fce7dad&lt;/code&gt;: &lt;code&gt;isFsKeyringSupported()&lt;/code&gt;, &lt;code&gt;installKeyLegacy()&lt;/code&gt;, &lt;code&gt;evictKeyLegacy()&lt;/code&gt; and the cache drop after eviction, with inline &lt;code&gt;keyctl&lt;/code&gt; wrappers since Android 17 removed &lt;code&gt;libkeyutils&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;🔎 &lt;strong&gt;Tip: do not accept the factory reset.&lt;/strong&gt; Recovery offers one after &lt;code&gt;enablefilecrypto_failed&lt;/code&gt;. The data on &lt;code&gt;/data&lt;/code&gt; is intact, and the other slot still boots it.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — BPF on a Kernel That Reports 5.4
&lt;/h4&gt;

&lt;p&gt;Android 17's network stack loads its BPF programs per kernel version, and several now need 5.8 or 5.10. Each missing one stopped the boot in a different place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;init: Reboot start, reason: reboot,bpfloader-failed, reboot_target: bpfloader-failed
E NetdUpdatable: libnetd_updatable_init: Failed: Failed to get program from /sys/fs/bpf/netd_shared/prog_netd_egress_stats: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two forks handle them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;system/bpf&lt;/code&gt; &lt;code&gt;72b37ef&lt;/code&gt;: &lt;code&gt;bpfRingbufProg&lt;/code&gt; is a test program that needs BPF ring buffers, from 5.8. It is marked critical, so its load failure rebooted userdebug builds. It is now non-critical.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;packages/modules/Connectivity&lt;/code&gt; &lt;code&gt;5c298de&lt;/code&gt;: NetBpfLoad warns below 5.10, as it did in 23.2; it hands off to &lt;code&gt;uprobestatsbpfload&lt;/code&gt; only on 5.8 and later; the 5.4 ingress and egress stats programs in &lt;code&gt;netd.c&lt;/code&gt; extend to the newest API level, since Android 17 has none below 5.10; and ConnectivityService skips the two ring-buffer listeners when their maps are absent, which aborted system_server.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 7 — netd Stuck in rcu_barrier
&lt;/h4&gt;

&lt;p&gt;With the forks in, about one permissive boot in five reaches the boot animation and stops. The logger's task dump shows netd blocked in the kernel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[&amp;lt;00000000edff5dad&amp;gt;] _rcu_barrier+0x210/0x2b0
[&amp;lt;000000007d48fdfd&amp;gt;] netdev_run_todo+0xc4/0x9b4
[&amp;lt;00000000610d42d3&amp;gt;] rtnetlink_rcv+0x58/0x78
[&amp;lt;000000004b18ba16&amp;gt;] netlink_unicast+0x2b0/0x468
[&amp;lt;000000004bbdc794&amp;gt;] netlink_sendmsg+0x4bc/0x604
netd            S    0  1595      1 0x00000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;system_server waits for netd until its watchdog kills it, and the cycle repeats. The kernel log of these boots carries one warning that names the cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;percpu ref (cgroup_bpf_release_fn) &amp;lt;= 0 (0) after switching to atomic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cgroup_sk_clone()&lt;/code&gt; reached this tree from 4.9-stable before the tree had a &lt;code&gt;cgroup_bpf&lt;/code&gt; reference count. The BPF backport added &lt;code&gt;cgroup_bpf_get()&lt;/code&gt; to &lt;code&gt;cgroup_sk_alloc()&lt;/code&gt; and &lt;code&gt;cgroup_bpf_put()&lt;/code&gt; to &lt;code&gt;cgroup_sk_free()&lt;/code&gt;, and left the clone path out. Every socket made by &lt;code&gt;accept()&lt;/code&gt; dropped a reference it never took. When a cgroup was removed, its BPF programs were freed while sockets in it could still run them.&lt;/p&gt;

&lt;p&gt;Kernel &lt;code&gt;e7a12e10fc29&lt;/code&gt; is one line: take the reference, as upstream does since &lt;code&gt;ad0f75e5f57c&lt;/code&gt;. Before the fix 2 of 9 boots hung; after it 8 of 8 booted.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — ION Under SELinux Enforcing
&lt;/h4&gt;

&lt;p&gt;Permissive boots reached the home screen. Enforcing boots stopped at a black screen with no USB. The permissive logs show the reason, from 21 domains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;avc:  denied  { read } for  comm="qseecomd" name="ion" dev="tmpfs" ino=12110 scontext=u:r:tee:s0 tcontext=u:object_r:device:s0 tclass=chr_file permissive=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Android 17 removed &lt;code&gt;/dev/ion&lt;/code&gt; from the platform &lt;code&gt;file_contexts&lt;/code&gt; and every &lt;code&gt;ion_device&lt;/code&gt; rule from the platform policy. This kernel has ION and no DMA-BUF heaps. Three bonito commits put it back in vendor policy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;2cbfb4b&lt;/code&gt;: label &lt;code&gt;/dev/ion&lt;/code&gt; as &lt;code&gt;ion_device&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;e2ca4b1&lt;/code&gt;: the Android 16 &lt;code&gt;ion_device&lt;/code&gt; rules for surfaceflinger, keymaster, gatekeeper, gralloc and qseecomd&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;a6e99a3&lt;/code&gt;: four more accesses an enforcing boot denies, for the sensors, Wi-Fi, USB gadget and power HALs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three boots from flash with all three took 81, 48 and 80 s, with Wi-Fi up each time.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 9 — The Native App Zygote
&lt;/h4&gt;

&lt;p&gt;The phone booted, but Chrome exited with &lt;code&gt;Timed out waiting for GPU channel&lt;/code&gt; and then every app launch stalled for minutes. Chrome 156 runs its renderers in a native app zygote, and the zygote died on start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to check capability bound: GetCapBoundFailure(EINVAL)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Android 17's &lt;code&gt;zygote_next&lt;/code&gt; drops the capability bounding set before it forks apps, and it reads every capability up to 40. Linux 5.9 added &lt;code&gt;CAP_PERFMON&lt;/code&gt;, &lt;code&gt;CAP_BPF&lt;/code&gt; and &lt;code&gt;CAP_CHECKPOINT_RESTORE&lt;/code&gt; (38 to 40); 4.9 returns &lt;code&gt;EINVAL&lt;/code&gt; for them. A capability the kernel does not know cannot be in the bounding set, so the &lt;code&gt;system/zygote&lt;/code&gt; fork, &lt;code&gt;d6dadb5&lt;/code&gt;, skips it, as the C++ zygote already does. Chrome opens pages and its sandboxed renderers start.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 10 — CHRE and Active Edge
&lt;/h4&gt;

&lt;p&gt;One service still restarted about every 5 s, 146 times in the first 14 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dynamic loading failed for chre_slpi on domain 0 (dlerror undefined symbol #25 __sensors_island_start in ./libchre_slpi_skel.so)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sensor DSP firmware on the phone rejects the CHRE program from the vendor blobs. Its only user is Active Edge, the squeeze gesture. Bonito &lt;code&gt;7f1369f&lt;/code&gt; drops ElmyraService, and &lt;code&gt;3f29377&lt;/code&gt; stops &lt;code&gt;vendor.chre&lt;/code&gt; once boot completes. The accelerometer and light sensor keep reporting without it.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 11 — Google Apps
&lt;/h4&gt;

&lt;p&gt;MindTheGapps 17.0.0 ships Play Services as an APEX whose payload is EROFS, a filesystem the 4.9 kernel cannot mount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apexd: Mounting failed ... No such device
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;repack-gapps.sh&lt;/code&gt; rebuilds the zip with the same APK installed as &lt;code&gt;/product/priv-app/PrebuiltGmsCoreVic&lt;/code&gt;, with the APEX's permission files. On sargo it needs 682 MiB of the 900 MiB &lt;code&gt;/product&lt;/code&gt; reserve.&lt;/p&gt;

&lt;p&gt;Flashed onto a phone that is already set up, Google apps never get their default runtime permissions, and Play Services refuses location requests. &lt;code&gt;post-install-24.sh&lt;/code&gt; grants them, then runs the background compile job. After an update every app sits at the &lt;code&gt;verify&lt;/code&gt; filter until the phone is idle and charging. Gmail, one run each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gmail&lt;/th&gt;
&lt;th&gt;Start&lt;/th&gt;
&lt;th&gt;Janky frames scrolling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Uncompiled (&lt;code&gt;verify&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;1,372 ms&lt;/td&gt;
&lt;td&gt;3.37%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compiled (&lt;code&gt;speed-profile&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;🥇 1,010 ms&lt;/td&gt;
&lt;td&gt;🥇 0.09%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The compile job took 2 min 53 s.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 12 — Install and Update Without Wiping
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;update_engine&lt;/code&gt; installs a full build into the inactive slot from the running system and keeps &lt;code&gt;/data&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;10:59:56 update_engine install to the inactive slot
[INFO:update_engine_client_android.cc(102)] onPayloadApplicationComplete(ErrorCode::kSuccess (0))
[INFO:update_engine_client_android.cc(94)] onStatusUpdate(UPDATE_STATUS_UPDATED_NEED_REBOOT (6), 0)
[INFO:update_engine_client_android.cc(367)] Command took 300501 ms
11:04:57 installed; not rebooting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a build machine, &lt;code&gt;phone-update-24.sh&lt;/code&gt; syncs, builds, installs, sideloads the repacked Google apps and compiles the apps. On a laptop with only &lt;code&gt;adb&lt;/code&gt;, &lt;code&gt;phone-install-24.sh&lt;/code&gt; does the same from the release zip; &lt;code&gt;INSTALL.md&lt;/code&gt; has the steps. The one manual step is in recovery: accept the test-key signature warning, then reboot.&lt;/p&gt;

&lt;p&gt;The phone after the update:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;932AY05KVV             device usb:5-2 product:sargo model:Pixel_3a device:sargo transport_id:34
24.0-20261027-UNOFFICIAL-sargo
4.9.337-gf002f96f2646
Enforcing
1
_b
17
2026-10-01
Wifi is enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔎 &lt;strong&gt;Tip: list every fork in the local manifest.&lt;/strong&gt; &lt;code&gt;repo sync&lt;/code&gt; resets any project the local manifest does not replace to LineageOS upstream, and a build from that tree loses the fork commits. On this phone that build went straight back to &lt;code&gt;enablefilecrypto_failed&lt;/code&gt;. &lt;code&gt;update-forks.sh&lt;/code&gt; reports how far each fork is behind upstream and rebases it.&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;LineageOS 23.2 on this kernel&lt;/th&gt;
&lt;th&gt;LineageOS 24.0 on this kernel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;🥇 17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kernel changes&lt;/td&gt;
&lt;td&gt;three backports from 4.14 and 4.17&lt;/td&gt;
&lt;td&gt;23.2's plus one cgroup BPF fix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform forks&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;five: core, vold, bpf, Connectivity, zygote&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SELinux&lt;/td&gt;
&lt;td&gt;🟢 enforcing&lt;/td&gt;
&lt;td&gt;🟢 enforcing, with ION rules in vendor policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google apps&lt;/td&gt;
&lt;td&gt;🟢 MindTheGapps 16&lt;/td&gt;
&lt;td&gt;⚠️ MindTheGapps 17, repacked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active Edge&lt;/td&gt;
&lt;td&gt;⚠️ not tested&lt;/td&gt;
&lt;td&gt;❌ off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signing&lt;/td&gt;
&lt;td&gt;⚠️ test keys&lt;/td&gt;
&lt;td&gt;⚠️ test keys&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 spare Pixel 3a, 24.0 is the current one: Android 17, the October 2026 security patch level, and the same update path that keeps your data. Back up before moving from 23.2: the update tools here install over 24.0 builds only, and a first install wipes the phone.&lt;/p&gt;




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

&lt;p&gt;The goal of this project was to run Android 17 on the Pixel 3a's original 4.9 kernel. The key to the solution was the same &lt;code&gt;/init&lt;/code&gt; logger as the 23.2 port, now with logcat and a task dump on a timed reboot, which turned each stopped boot into a log line. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 LineageOS 24.0 boots on kernel 4.9.337 with SELinux enforcing in 83 s and installs over the previous build, keeping user data.&lt;/li&gt;
&lt;li&gt;🟢 One kernel fix: &lt;code&gt;cgroup_sk_clone()&lt;/code&gt; takes its &lt;code&gt;cgroup_bpf&lt;/code&gt; reference. 2 of 9 boots hung before it; 8 of 8 booted after.&lt;/li&gt;
&lt;li&gt;🟢 Five platform forks cover a kernel below 5.10: the fscrypt session keyring, the BPF loader, netd and Connectivity, and the native app zygote.&lt;/li&gt;
&lt;li&gt;🟢 Wi-Fi connects, Bluetooth turns on, the sensors report, Chrome renders pages.&lt;/li&gt;
&lt;li&gt;🟢 Play Services runs as a system app from repacked MindTheGapps 17, and network location gives a fix.&lt;/li&gt;
&lt;li&gt;🟢 Compiling apps after an update brings Gmail from 1,372 ms to 1,010 ms to start.&lt;/li&gt;
&lt;li&gt;⚠️ Signed with test keys; recovery shows a signature warning on every install.&lt;/li&gt;
&lt;li&gt;🟢 Mobile data works from a Google Fi data-only SIM in the 20261104-1751 build, over IPv4 and IPv6; the four fixes are in the mobile data porting notes.&lt;/li&gt;
&lt;li&gt;⚠️ Calls, SMS, audio by ear, taking photos, fingerprint enrolment, NFC payments and satellite GPS are untested.&lt;/li&gt;
&lt;li&gt;❌ Active Edge is off: the sensor DSP firmware rejects the CHRE program.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one Pixel 3a (sargo, 932AY05KVV) with no SIM fitted, LineageOS 24.0 built 2026-10-27 from bonito &lt;code&gt;lineage-24.0&lt;/code&gt; at &lt;code&gt;3f29377&lt;/code&gt; and the five &lt;code&gt;lineage-24.0&lt;/code&gt; forks, kernel 4.9.337 from this repository. The hang counts come from 9 and 8 boots of one phone; the Gmail figures are one run each. Parts of the analysis and writing were done with AI assistance (Claude); every log line comes from the phone or the build machine.&lt;/p&gt;

&lt;p&gt;The strategy for running Android 17 on a 4.9 kernel 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;Release, build and kernel source: &lt;a href="https://github.com/xbill9/android_kernel_google_msm-4.9/releases/tag/lineage-24.0-sargo-20261027" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_kernel_google_msm-4.9/releases/tag/lineage-24.0-sargo-20261027&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Build, install and boot-test notes: &lt;a href="https://github.com/xbill9/android_kernel_google_msm-4.9/tree/main/backport-tools/lineage-24.0" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_kernel_google_msm-4.9/tree/main/backport-tools/lineage-24.0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Mobile data with Google Fi, porting notes: &lt;a href="https://github.com/xbill9/android_kernel_google_msm-4.9/blob/main/backport-tools/lineage-24.0/PORTING-NOTES-mobile-data.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_kernel_google_msm-4.9/blob/main/backport-tools/lineage-24.0/PORTING-NOTES-mobile-data.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Mobile data release: &lt;a href="https://github.com/xbill9/android_kernel_google_msm-4.9/releases/tag/lineage-24.0-sargo-20261104-1751" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_kernel_google_msm-4.9/releases/tag/lineage-24.0-sargo-20261104-1751&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Install from a laptop: &lt;a href="https://github.com/xbill9/android_kernel_google_msm-4.9/blob/main/INSTALL.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_kernel_google_msm-4.9/blob/main/INSTALL.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Device tree: &lt;a href="https://github.com/xbill9/android_device_google_bonito/tree/lineage-24.0" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_device_google_bonito/tree/lineage-24.0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fork, system/core: &lt;a href="https://github.com/xbill9/android_system_core" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_system_core&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fork, system/vold: &lt;a href="https://github.com/xbill9/android_system_vold" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_system_vold&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fork, system/bpf: &lt;a href="https://github.com/xbill9/android_system_bpf" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_system_bpf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fork, Connectivity: &lt;a href="https://github.com/xbill9/android_packages_modules_Connectivity" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_packages_modules_Connectivity&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Fork, system/zygote: &lt;a href="https://github.com/xbill9/android_system_zygote" rel="noopener noreferrer"&gt;https://github.com/xbill9/android_system_zygote&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The 23.2 port: &lt;a href="https://dev.to/gde/android-16-on-a-49-kernel-lineageos-232-on-the-pixel-3a-5e79"&gt;https://dev.to/gde/android-16-on-a-49-kernel-lineageos-232-on-the-pixel-3a-5e79&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The EC2 build: &lt;a href="https://dev.to/aws-builders/rent-the-cores-keep-the-phone-building-lineageos-24-on-ec2-and-iterating-at-home-4pmc"&gt;https://dev.to/aws-builders/rent-the-cores-keep-the-phone-building-lineageos-24-on-ec2-and-iterating-at-home-4pmc&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MindTheGapps 17.0.0 arm64: &lt;a href="https://github.com/MindTheGapps/17.0.0-arm64" rel="noopener noreferrer"&gt;https://github.com/MindTheGapps/17.0.0-arm64&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>android</category>
      <category>linux</category>
      <category>kernel</category>
      <category>lineageos</category>
    </item>
    <item>
      <title>Gemma 4 E2B in JAX on One TPU v5e: bf16 or 4-Bit, Python or Rust, and How It Stands Against vLLM</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Wed, 04 Nov 2026 22:49:08 +0000</pubDate>
      <link>https://dev.to/gde/gemma-4-e2b-in-jax-on-one-tpu-v5e-bf16-or-4-bit-python-or-rust-and-how-it-stands-against-vllm-j72</link>
      <guid>https://dev.to/gde/gemma-4-e2b-in-jax-on-one-tpu-v5e-bf16-or-4-bit-python-or-rust-and-how-it-stands-against-vllm-j72</guid>
      <description>&lt;p&gt;This article provides a step by step guide to serving Gemma 4 E2B on a single Cloud TPU v5e chip with JAX. A pure-JAX engine serves the model from Python, a second engine builds the same graph in Rust and hands it to the libtpu compiler that JAX itself uses, and vLLM serves the same checkpoint as the reference. Every number below was measured on one flex-start v5e-1 VM on 2026-11-03, and every run is committed with its logs.&lt;/p&gt;

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




&lt;h4&gt;
  
  
  Why One v5e Chip?
&lt;/h4&gt;

&lt;p&gt;A v5e-1 is the smallest Cloud TPU you can rent: one chip, 16.909 GB of HBM, in a &lt;code&gt;ct5lp-hightpu-1t&lt;/code&gt; VM. Gemma 4 E2B in bf16 puts 9.257 GB of weights on it, which leaves room for the KV cache with no quantization at all. That makes it the place to ask three questions with nothing else in the way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does 4-bit pay off for a model that already fits in bf16?&lt;/li&gt;
&lt;li&gt;How much of the JAX engine's time is Python?&lt;/li&gt;
&lt;li&gt;Where does a hand-written JAX engine stand against vLLM on the same chip?&lt;/li&gt;
&lt;/ul&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 TPU API enabled and flex-start quota for v5e (&lt;code&gt;TPUV5sLitepodPerProjectPerZoneForTPUAPI&lt;/code&gt;) in &lt;code&gt;us-west4-a&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;An authenticated &lt;code&gt;gcloud&lt;/code&gt; with the alpha components&lt;/li&gt;
&lt;li&gt;The repository cloned, and its &lt;code&gt;tpu-jax-v5e1-2b&lt;/code&gt; directory as the working directory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No Hugging Face token is needed: &lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt; and both 4-bit builds are public.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Provision a Flex-Start v5e-1
&lt;/h4&gt;

&lt;p&gt;Each rig ships an MCP server, &lt;code&gt;server.py&lt;/code&gt;, whose tools wrap the &lt;code&gt;gcloud&lt;/code&gt; calls. The rig's &lt;code&gt;tpu.env&lt;/code&gt; sets &lt;code&gt;ACCELERATOR_TYPE=v5litepod-1&lt;/code&gt;, and the server derives the machine type from it. &lt;code&gt;workload="jax"&lt;/code&gt; installs Python 3.13 and &lt;code&gt;jax[tpu]&lt;/code&gt; on a bare VM with no Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 -c 'import asyncio, server as s; print(asyncio.run(s.create_tpu_vm_instance(instance_name="jax-v5e1-2b", zone="us-west4-a", accelerator="v5litepod-1", workload="jax", max_run_duration="4h", request_valid_for="30m")))'
Executing gcloud command: gcloud compute instances create jax-v5e1-2b --project=&amp;lt;project-id&amp;gt; --zone=us-west4-a --machine-type=ct5lp-hightpu-1t --provisioning-model=FLEX_START --request-valid-for-duration=30m --max-run-duration=4h --instance-termination-action=DELETE ... --boot-disk-size=200GB --scopes=cloud-platform
🚀 Flex-start TPU VM `jax-v5e1-2b` (ct5lp-hightpu-1t, 1 chip(s)) created in us-west4-a; installing Python 3.13 + `jax[tpu]`.
NAME         ZONE        MACHINE_TYPE      PREEMPTIBLE  STATUS
jax-v5e1-2b  us-west4-a  ct5lp-hightpu-1t               RUNNING
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wait_for_jax_ready&lt;/code&gt; watches the serial console until the startup script has seen a TPU device from JAX:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🟢 JAX environment ready on `jax-v5e1-2b`. Verify with `verify_jax_tpu`; run workloads over SSH with `python3.13`.

$ python3.13 -c "import jax; print(jax.__version__, jax.devices())"
0.12.0.1 [TpuDevice(id=0, process_index=0, coords=(0,0,0), core_on_chip=0)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flex-start VM deletes itself after &lt;code&gt;--max-run-duration&lt;/code&gt;, so a forgotten VM stops billing on its own.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Load E2B Into the Pure-JAX Engine
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;jax_engine.py&lt;/code&gt; is a Gemma 4 E2B decoder written directly in JAX: jitted prefill, a static KV cache, and one jitted decode step per token. It reads the Hugging Face safetensors itself, so no PyTorch is installed. &lt;code&gt;--quant-mode fp16&lt;/code&gt; is its dense path, which keeps the bf16 weights as they are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3.13 jax_openai_server.py --model google/gemma-4-E2B-it --quant-mode fp16 --max-model-len 2048
JAX devices: [TpuDevice(id=0, process_index=0, coords=(0,0,0), core_on_chip=0)]
Loading tokenizer: google/gemma-4-E2B-it
Loaded 9.26 GB of parameters on TPU_0(process=0,(0,0,0,0)) in 5.4s (KV cache: bf16)
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server speaks the OpenAI chat API, streamed or not, so any OpenAI client can talk to it.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — bf16 or 4-Bit?
&lt;/h4&gt;

&lt;p&gt;Two 4-bit builds of E2B load into the same engine with &lt;code&gt;--quant-mode w4a16&lt;/code&gt;: Google's &lt;code&gt;google/gemma-4-E2B-it-qat-w4a16-ct&lt;/code&gt;, and &lt;code&gt;xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text&lt;/code&gt;, a repack that stores the QAT grid exactly. &lt;code&gt;tools/ab_bench.py&lt;/code&gt; runs each in its own process on 8 prompts, 128 greedy tokens each, one untimed run and three timed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;loaded google/gemma-4-E2B-it in 64.7s, weights 9.257 GB, HBM 9.260 / 16.909 GB
loaded xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text in 39.2s, weights 6.559 GB, HBM 6.561 / 16.909 GB
loaded google/gemma-4-E2B-it-qat-w4a16-ct in 51.9s, weights 6.559 GB, HBM 6.562 / 16.909 GB

arm                                                tok/s  x ref weights GB  HBM GB
google/gemma-4-E2B-it (fp16)                       132.6  1.000      9.257   9.260
xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text (w4a16)   103.0  0.777      6.559   6.561
google/gemma-4-E2B-it-qat-w4a16-ct (w4a16)         103.1  0.777      6.559   6.562
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;bf16 decodes 1.29x faster than either 4-bit build and prefills in 7.4 ms against 23.2 ms. The 4-bit builds save 2.70 GB of HBM. This engine unpacks the int4 weights inside every matmul, and on a chip where bf16 already fits, that unpacking costs more time than the smaller weights save.&lt;/p&gt;

&lt;p&gt;The repack and Google's export tie on speed and size. Their outputs differ: none of the 8 completions match token for token, and they agree for 26.6 of 128 tokens on average. Which of the two is closer to the trained QAT weights is measured in the earlier article listed under References.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — The Same Graph, Built From Rust
&lt;/h4&gt;

&lt;p&gt;JAX traces Python into StableHLO and hands it to the TPU's PJRT plugin, &lt;code&gt;libtpu.so&lt;/code&gt;, to compile and run. The &lt;code&gt;tpu-jaxrust-v5e1-2b&lt;/code&gt; rig writes that StableHLO directly. &lt;code&gt;rust/src/gemma.rs&lt;/code&gt; builds the forward pass op by op, following &lt;code&gt;jax_e_model.py&lt;/code&gt; down to where JAX promotes bf16 to f32:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;rms_norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="py"&gt;.dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="nf"&gt;.rank&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;i64&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;let&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="nf"&gt;.dim&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="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;xf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="nf"&gt;.convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;DType&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;F32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;sq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="nf"&gt;.mul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;xf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;xf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="nf"&gt;.reduce_sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="nf"&gt;.scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="nf"&gt;.add_scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eps&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="nf"&gt;.rsqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&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;p&gt;&lt;code&gt;rust/src/hlo.rs&lt;/code&gt; prints the module as MLIR text, and &lt;code&gt;rust/src/pjrt.c&lt;/code&gt;, a small C shim over the vendored PJRT C API header, opens the same &lt;code&gt;libtpu.so&lt;/code&gt; that JAX loaded and compiles it. A PyO3 module, &lt;code&gt;jaxrust_rs&lt;/code&gt;, hands the result to Python for the tokenizer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;platform {'plugin': '/usr/local/lib/python3.13/dist-packages/libtpu/libtpu.so', 'header_api': (0, 117), 'plugin_api': (0, 116), 'devices': 1}
build {'rustc': 'rustc 1.101.0-nightly (1c3a65069 2026-11-02)', 'pjrt_header': '0.117', 'crate': '0.1.0'}
engine Engine(context=256, weight_bytes=9257336832, cache_bytes=4718592) in 8.4s, {'load_s': 2.318838774, 'compile_s': 5.73853671}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The weights upload in 2.3 s and the decode program compiles in 5.7 s. There is no JAX in this path, and the Rust crate has no ML dependencies: &lt;code&gt;pyo3&lt;/code&gt;, &lt;code&gt;safetensors&lt;/code&gt;, &lt;code&gt;memmap2&lt;/code&gt; and &lt;code&gt;serde_json&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Does Rust Produce the Same Tokens as JAX?
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;tools/parity.py&lt;/code&gt; runs each engine in its own process on the same 8 prompts, with the same chat template and the same leading &lt;code&gt;&amp;lt;bos&amp;gt;&lt;/code&gt;, and compares the last-position logits after the first prompt and every greedy token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;logits after prompt 0: max |diff| 0.2306, mean |diff| 0.07346, argmax 7216 vs 7216, top-10 overlap 10/10, KL(ref||got) 1.31e-06
prompts tokenized the same: 8 of 8; completions identical: 7 of 8; matching prefix per prompt: [128, 128, 128, 128, 128, 128, 42, 128] (mean 117.2 of 128)
decode tok/s (median over prompts): jax 131.6, rust 150.7, ratio 1.145
weights on device: jax 9.257 GB, rust 9.257 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both engines pick the same next token with the same top 10, 1.31e-6 apart in KL divergence. Seven of eight 128-token completions match token for token; the eighth parts at token 42, where "Time until 4:00 pm" and "Next full hour: 4:00 pm" were nearly tied. Timed inside each engine, Rust decodes 1.145x faster on identical weights.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — Serve All Three Through One Client
&lt;/h4&gt;

&lt;p&gt;The Rust engine gets an OpenAI-style server of its own, &lt;code&gt;rust_openai_server.py&lt;/code&gt;, and vLLM 0.33 runs from the &lt;code&gt;vllm/vllm-tpu&lt;/code&gt; image on the same VM. Each server takes port 8000 alone, and &lt;code&gt;tools/serve_bench.py&lt;/code&gt; times all three the same way: streamed chat, one request at a time, temperature 0, exactly 128 tokens (&lt;code&gt;ignore_eos&lt;/code&gt;), context 2048, 8 prompts with one untimed and three timed requests each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 tools/serve_bench.py compare vllm.json jax.json rust.json
engine      decode tok/s           range  first token ms  tokens  same text as vllm
vllm               148.7   148.7-148.7              13.0     128              8 of 8
jax                126.7   126.0-128.4              12.5     128              6 of 8
rust               148.4   147.8-148.6               8.9     128              6 of 8
prompt tokens per engine: {'vllm': [20, 23, 23, 21, 20, 24, 35, 17], 'jax': [20, 23, 23, 21, 20, 24, 35, 17], 'rust': [20, 23, 23, 21, 20, 24, 35, 17]}
repeats identical: {'vllm': 8, 'jax': 8, 'rust': 8}
ratio jax / vllm: 0.852
ratio rust / vllm: 0.998
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Rust engine decodes at vLLM's rate, 148.4 against 148.7 tokens per second, and reaches the first token in 8.9 ms against 13.0. The Python JAX engine decodes at 0.852x vLLM with a first token level with it. All three tokenize every prompt to the same length, and each repeats its own text exactly. The JAX and Rust engines part from vLLM at the same two near-ties, in prompts 2 and 6.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Time Every Engine by the Same Clock
&lt;/h4&gt;

&lt;p&gt;Engines report their own speed in their own way: inside the decode loop, per HTTP response, with or without the first token. One client gives every engine the same definition. In &lt;code&gt;serve_bench.py&lt;/code&gt;, decode speed is completion tokens minus one, over the time from the first text chunk to the finish chunk, with the token count read from the server's usage chunk. The clock stops at the finish chunk because, past the end of the answer, &lt;code&gt;ignore_eos&lt;/code&gt; tokens can decode to empty text. The same Rust engine reads 150.7 tokens per second timed inside the engine at context 256, and 148.4 through this client at context 2048.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: libtpu Wants One Replica and One Partition
&lt;/h4&gt;

&lt;p&gt;PJRT's compile call takes a serialized &lt;code&gt;xla.CompileOptionsProto&lt;/code&gt;. With empty options, libtpu asks for zero cores and refuses to compile ("Requested total cores: 0"). Six bytes set one replica and one partition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="cd"&gt;/// `xla.CompileOptionsProto { executable_build_options { num_replicas: 1, num_partitions: 1 } }`&lt;/span&gt;
&lt;span class="cd"&gt;/// serialized by hand: field 3 (length 4) holding fields 4 and 5, varint 1 each.&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;COMPILE_OPTIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;6&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="mi"&gt;0x1a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x04&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0x01&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules of the MLIR text format surface at the same compile call: a float literal needs a decimal point (&lt;code&gt;2.0e0&lt;/code&gt;), and an empty integer array is written &lt;code&gt;array&amp;lt;i64&amp;gt;&lt;/code&gt;.&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;One v5e chip, E2B&lt;/th&gt;
&lt;th&gt;JAX engine, bf16&lt;/th&gt;
&lt;th&gt;JAX engine, 4-bit&lt;/th&gt;
&lt;th&gt;Rust engine, bf16&lt;/th&gt;
&lt;th&gt;vLLM 0.33, bf16&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Decode tok/s, timed in the engine&lt;/td&gt;
&lt;td&gt;🥈 131.6&lt;/td&gt;
&lt;td&gt;🥉 103.0&lt;/td&gt;
&lt;td&gt;🥇 150.7&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decode tok/s, one client&lt;/td&gt;
&lt;td&gt;🥉 126.7&lt;/td&gt;
&lt;td&gt;not run&lt;/td&gt;
&lt;td&gt;🥈 148.4&lt;/td&gt;
&lt;td&gt;🥇 148.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First token, one client&lt;/td&gt;
&lt;td&gt;12.5 ms&lt;/td&gt;
&lt;td&gt;not run&lt;/td&gt;
&lt;td&gt;🥇 8.9 ms&lt;/td&gt;
&lt;td&gt;13.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weights on the chip&lt;/td&gt;
&lt;td&gt;9.257 GB&lt;/td&gt;
&lt;td&gt;🥇 6.559 GB&lt;/td&gt;
&lt;td&gt;9.257 GB&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same text as vLLM&lt;/td&gt;
&lt;td&gt;6 of 8&lt;/td&gt;
&lt;td&gt;not run&lt;/td&gt;
&lt;td&gt;6 of 8&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrent requests&lt;/td&gt;
&lt;td&gt;no&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;For one user at a time, the Rust engine: it decodes at vLLM's rate, answers first, and puts the same 9.257 GB on the chip. For JAX work in Python, the bf16 JAX engine: 0.852x vLLM, with the whole model written in JAX you can read and change. For more than one user, vLLM, which batches requests and is the only one of the three that does. On this chip keep E2B in bf16: the 4-bit builds save 2.70 GB and decode at 0.777x its speed in this engine.&lt;/p&gt;




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

&lt;p&gt;The goal of this article was to measure Gemma 4 E2B in JAX on one TPU v5e chip: bf16 against 4-bit, Python against Rust, and both against vLLM. The key to the solution was holding everything else fixed: one VM, one checkpoint revision, the same 8 prompts through each server's own chat template, and one client timing every engine. The results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 The Rust engine, the same graph built from Rust and compiled by JAX's own libtpu, decodes at vLLM's rate (0.998x) with the fastest first token, 8.9 ms&lt;/li&gt;
&lt;li&gt;🟢 It matches the JAX engine: same argmax and top 10, KL 1.31e-6, 7 of 8 completions token for token&lt;/li&gt;
&lt;li&gt;🟢 bf16 E2B fits a v5e chip (HBM 9.260 / 16.909 GB in use) and decodes 1.29x faster than 4-bit in the JAX engine&lt;/li&gt;
&lt;li&gt;⚠️ The Python JAX engine decodes at 0.852x vLLM over HTTP; where its time goes was not measured&lt;/li&gt;
&lt;li&gt;⚠️ The repack and Google's 4-bit export run at the same speed and size, with different outputs&lt;/li&gt;
&lt;li&gt;❌ Neither JAX engine takes concurrent requests, so vLLM's batching is not compared here&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one flex-start &lt;code&gt;ct5lp-hightpu-1t&lt;/code&gt; VM in &lt;code&gt;us-west4-a&lt;/code&gt; (one TPU v5 lite chip, 16.909 GB HBM) on 2026-11-03; JAX 0.12.0.1 and libtpu 0.0.51 for both JAX-side engines, vLLM 0.33.0rc1.dev430 from &lt;code&gt;vllm/vllm-tpu@sha256:f1f925f5…&lt;/code&gt;; &lt;code&gt;google/gemma-4-E2B-it&lt;/code&gt; at revision &lt;code&gt;4adf1574&lt;/code&gt;. Every figure is the median over 8 prompts of the median of 3 timed runs, greedy, 128 tokens, one request at a time. The in-engine runs used context 4096 (bf16 against 4-bit: 132.6 tok/s for bf16) and 256 (JAX against Rust: 131.6 for bf16); the one-client run used 2048 for all three. Parts of the analysis and writing were done with AI assistance (Claude); every figure comes from the committed run files.&lt;/p&gt;

&lt;p&gt;The strategy for serving Gemma 4 E2B with JAX on a single TPU v5e chip 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;The monorepo: &lt;a href="https://github.com/xbill9/jax-dev" rel="noopener noreferrer"&gt;https://github.com/xbill9/jax-dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The pure-JAX rig: &lt;a href="https://github.com/xbill9/jax-dev/tree/main/tpu-jax-v5e1-2b" rel="noopener noreferrer"&gt;https://github.com/xbill9/jax-dev/tree/main/tpu-jax-v5e1-2b&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The Rust rig: &lt;a href="https://github.com/xbill9/jax-dev/tree/main/tpu-jaxrust-v5e1-2b" rel="noopener noreferrer"&gt;https://github.com/xbill9/jax-dev/tree/main/tpu-jaxrust-v5e1-2b&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;bf16 against 4-bit run: &lt;a href="https://github.com/xbill9/jax-dev/tree/main/tpu-jax-v5e1-2b/benchmarks/runs/2026-11-03-ab-v5e1" rel="noopener noreferrer"&gt;https://github.com/xbill9/jax-dev/tree/main/tpu-jax-v5e1-2b/benchmarks/runs/2026-11-03-ab-v5e1&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;JAX against Rust run: &lt;a href="https://github.com/xbill9/jax-dev/tree/main/tpu-jaxrust-v5e1-2b/benchmarks/runs/2026-11-03-parity-v5e1" rel="noopener noreferrer"&gt;https://github.com/xbill9/jax-dev/tree/main/tpu-jaxrust-v5e1-2b/benchmarks/runs/2026-11-03-parity-v5e1&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;One-client run: &lt;a href="https://github.com/xbill9/jax-dev/tree/main/tpu-jaxrust-v5e1-2b/benchmarks/runs/2026-11-03-serve-v5e1" rel="noopener noreferrer"&gt;https://github.com/xbill9/jax-dev/tree/main/tpu-jaxrust-v5e1-2b/benchmarks/runs/2026-11-03-serve-v5e1&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The repack against Google's 4-bit export, on a Colab v5e: &lt;a href="https://dev.to/gde/gemma-4-e2b-in-pure-jax-on-a-colab-tpu-googles-4-bit-export-against-an-exact-repack-4dle"&gt;https://dev.to/gde/gemma-4-e2b-in-pure-jax-on-a-colab-tpu-googles-4-bit-export-against-an-exact-repack-4dle&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The model: &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;The repack: &lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text" rel="noopener noreferrer"&gt;https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google's 4-bit export: &lt;a href="https://huggingface.co/google/gemma-4-E2B-it-qat-w4a16-ct" rel="noopener noreferrer"&gt;https://huggingface.co/google/gemma-4-E2B-it-qat-w4a16-ct&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;StableHLO specification: &lt;a href="https://openxla.org/stablehlo/spec" rel="noopener noreferrer"&gt;https://openxla.org/stablehlo/spec&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PJRT C API header: &lt;a href="https://github.com/openxla/xla/blob/261d4d4979f65a89b614a6a5baae1790ce51f048/xla/pjrt/c/pjrt_c_api.h" rel="noopener noreferrer"&gt;https://github.com/openxla/xla/blob/261d4d4979f65a89b614a6a5baae1790ce51f048/xla/pjrt/c/pjrt_c_api.h&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>jax</category>
      <category>tpu</category>
      <category>gemma</category>
      <category>rust</category>
    </item>
    <item>
      <title>The A2A CLI and Rust: The Same Code, Three Ways to Serve It</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Wed, 04 Nov 2026 01:04:48 +0000</pubDate>
      <link>https://dev.to/gde/the-a2a-cli-and-rust-the-same-code-three-ways-to-serve-it-4el0</link>
      <guid>https://dev.to/gde/the-a2a-cli-and-rust-the-same-code-three-ways-to-serve-it-4el0</guid>
      <description>&lt;p&gt;This article provides a step by step guide to the A2A CLI, driven against Rust agents that speak A2A Protocol v1.0. The same Rust code, a Lucas-Lehmer test over the first N Mersenne prime exponents, is served three ways: on the official A2A Rust SDK, as a plain stdin/stdout program wrapped by &lt;code&gt;a2a server --exec&lt;/code&gt;, and behind an ADK coordinator on Gemini started with the ADK CLI. Each one is timed from the A2A CLI and from a Rust client.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/rust-dev/tree/main/a2a-benchmark-rust" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/tree/main/a2a-benchmark-rust&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The A2A CLI talks to all three with the same commands, and &lt;code&gt;a2a server --exec&lt;/code&gt; turns a 30-line program with no A2A code into an agent that ADK can call.&lt;/strong&gt; One &lt;code&gt;a2a send&lt;/code&gt; from a shell takes a median 20.74 ms against the SDK agent, of which 7.62 ms is starting the CLI. From a Rust client the SDK agent adds 0.55 to 1.40 ms per call, the wrapped program 3.33 to 5.05 ms, and the ADK coordinator a median 1328 ms on &lt;code&gt;gemini-3.5-flash&lt;/code&gt; and 2283 ms on &lt;code&gt;gemini-3.8-flash&lt;/code&gt;. Every call through N=26 returned the right count of primes.&lt;/p&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;How it speaks A2A&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mersenne-agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8104&lt;/td&gt;
&lt;td&gt;Official Rust SDK, JSON-RPC at &lt;code&gt;/jsonrpc&lt;/code&gt; and HTTP+JSON at &lt;code&gt;/rest&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mersenne-exec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8105&lt;/td&gt;
&lt;td&gt;A stdin/stdout Rust program served by &lt;code&gt;a2a server --exec&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rust_bench&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8106&lt;/td&gt;
&lt;td&gt;ADK &lt;code&gt;LlmAgent&lt;/code&gt; on Gemini, served by &lt;code&gt;adk api_server --a2a&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;echo&lt;/td&gt;
&lt;td&gt;8108&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;a2a server --echo&lt;/code&gt;, for the first command&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;a2a CLI ──► adk api_server --a2a (:8106) ──Gemini──► RemoteA2aAgent ──► mersenne-agent (:8104)
                    rust_bench                                     └──► a2a server --exec (:8105) ──► mersenne-exec
                        └── run_benchmark tool ──► mersenne-bench ──► both Rust agents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Versions, read from the machine on 2026-11-03:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&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;A2A protocol&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A2A CLI (&lt;code&gt;a2a&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;v0.3.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;a2a-lf&lt;/code&gt; / &lt;code&gt;a2a-server-lf&lt;/code&gt; / &lt;code&gt;a2a-client-lf&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.4.2 / 0.5.3 / 0.2.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;1.99.1, edition 2024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;google-adk&lt;/code&gt; / &lt;code&gt;a2a-sdk&lt;/code&gt; / &lt;code&gt;google-genai&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2.13.0 / 1.3.0 / 2.34.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gemini-3.8-flash&lt;/code&gt; on Vertex AI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;a2a-*-lf&lt;/code&gt; crates are the official SDK from &lt;code&gt;a2aproject/a2a-rs&lt;/code&gt;. Agent cards carry the protocol as &lt;code&gt;Major.Minor&lt;/code&gt;, so they read &lt;code&gt;1.0&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Install the A2A CLI
&lt;/h4&gt;

&lt;p&gt;The CLI is one Go binary. Homebrew, WinGet and release binaries work; from source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/a2aproject/a2a-cli@latest
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; a2a-cli&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&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;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; a2a-cli&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/a2a"&lt;/span&gt;
a2a version
&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;a2a v0.3.1
commit:  none
built:   unknown
go:      go1.26.3
os/arch: linux/amd64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;go install&lt;/code&gt; names the binary &lt;code&gt;a2a-cli&lt;/code&gt;; the docs and the CLI's own skill call it &lt;code&gt;a2a&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Talk to an Echo Server
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;a2a server --echo&lt;/code&gt; starts an agent that sends back what it receives, so the client can be checked before any agent code exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a server &lt;span class="nt"&gt;--echo&lt;/span&gt; &lt;span class="nt"&gt;--port&lt;/span&gt; 8108 &amp;amp;
a2a send &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8108 &lt;span class="s2"&gt;"hello world from A2A"&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;Task:     01a1a35f-2b2b-7f3a-92a2-e3dc6c69e359
Context:  01a1a35f-2b2c-7120-ac45-a74adab9d1d2
Status:   completed (2026-11-03T16:44:45-05:00)
Artifacts:
  [01a1a35f-2b2c-71ba-8e6c-22c4b7c210df] hello world from A2A
History:
  [user] hello world from A2A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;send&lt;/code&gt; creates a task with an id, a context, a final status and its artifacts. The Go servers started by the CLI stamp local time; the Rust SDK agent stamps UTC.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Build and Start the Rust Agents
&lt;/h4&gt;

&lt;p&gt;The rig is one Cargo project with four binaries: the SDK agent, the stdin/stdout program, the benchmark client, and a shared library holding the Lucas-Lehmer code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/xbill9/rust-dev
&lt;span class="nb"&gt;cd &lt;/span&gt;rust-dev/a2a-benchmark-rust
cargo build &lt;span class="nt"&gt;--release&lt;/span&gt;
scripts/start-all.sh
&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;agents up: 8104 native, 8105 exec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;start-all.sh&lt;/code&gt; rebuilds, stops any running copy, starts the SDK agent on 8104 and &lt;code&gt;a2a server --exec&lt;/code&gt; on 8105 with logs in &lt;code&gt;logs/&lt;/code&gt;, and waits until both cards answer. &lt;code&gt;scripts/test-cli.sh&lt;/code&gt; runs the CLI commands below against them and checks each reply.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Read an Agent Card
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;card get&lt;/code&gt; fetches &lt;code&gt;/.well-known/agent-card.json&lt;/code&gt; and prints what the agent offers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a card get &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8104
&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;Name:         Mersenne Prime Agent Rust
Description:  A Rust A2A v1.0 agent (official a2a-rs SDK) that confirms the first n Mersenne primes with the Lucas-Lehmer test and reports the elapsed time.
Version:      0.1.0
Interfaces:
  JSONRPC (A2A v1.0)     http://localhost:8104/jsonrpc
  HTTP+JSON (A2A v1.0)   http://localhost:8104/rest
Streaming:    true
Skills:
  find-mersenne-rust   Find Mersenne Primes in Rust
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The card lists two interfaces. The CLI picks one, and &lt;code&gt;--transport&lt;/code&gt; chooses.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Send, Stream, Switch Transport, Send Data
&lt;/h4&gt;

&lt;p&gt;A plain message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a send &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8104 &lt;span class="s2"&gt;"Calculate the first 12 Mersenne primes"&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;Status:   completed (2026-11-03T21:44:45Z)
  Found first 12 Mersenne primes in 0.0902ms.
Artifacts:
  [mersenne-report] Found first 12 Mersenne primes in 0.0902ms. {"elapsed_ms":0.090239,"exponents":[2,3,5,7,13,17,19,31,61,89,107,127],"found":12,"requested":12}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;mersenne-report&lt;/code&gt; artifact carries two parts: the summary as text, and the same result as a data part, so a client reads the timing from a field.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--stream&lt;/code&gt; prints each event as it arrives, here the &lt;code&gt;working&lt;/code&gt; status before the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a send &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8104 &lt;span class="nt"&gt;--stream&lt;/span&gt; &lt;span class="s2"&gt;"first 8"&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;[status] working
Task:     01a1a35f-2b64-77b5-9417-18b1f425b17d
Context:  01a1a35f-2b64-77b5-9417-18b24253b719
Status:   completed (2026-11-03T21:44:45Z)
  Found first 8 Mersenne primes in 0.0164ms.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--transport rest&lt;/code&gt; sends the same request over HTTP+JSON, and &lt;code&gt;--data-part&lt;/code&gt; sends structured input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a send &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8104 &lt;span class="nt"&gt;--transport&lt;/span&gt; rest &lt;span class="s2"&gt;"first 4"&lt;/span&gt;
a2a send &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8104 &lt;span class="nt"&gt;--data-part&lt;/span&gt; &lt;span class="s1"&gt;'{"count": 6}'&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;  Found first 4 Mersenne primes in 0.0080ms.
  Found first 6 Mersenne primes in 0.0108ms.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  🔎 Tip: Data Parts Arrive as Floats
&lt;/h4&gt;

&lt;p&gt;A2A v1.0 carries data parts as a protobuf &lt;code&gt;Struct&lt;/code&gt;, whose only number type is a double. &lt;code&gt;{"count": 6}&lt;/code&gt; sent by the CLI reaches the agent as &lt;code&gt;6.0&lt;/code&gt;, so a strict integer field fails to deserialise. Read numbers with &lt;code&gt;as_f64&lt;/code&gt; and check for a whole value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nn"&gt;PartContent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="py"&gt;.content&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="nf"&gt;.as_f64&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="nf"&gt;.fract&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 6 — Script It With JSON Output
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;-o json&lt;/code&gt; prints the whole task, which pipes into anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a send &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8104 &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="s1"&gt;'first 3'&lt;/span&gt; | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import sys,json; t=json.load(sys.stdin)["task"]; print(t["status"]["state"], [p for a in t["artifacts"] for p in a["parts"] if "data" in p][0]["data"])'&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;TASK_STATE_COMPLETED {'elapsed_ms': 0.007166, 'exponents': [2, 3, 5], 'found': 3, 'requested': 3}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 7 — Tasks: List, Start in the Background, Wait
&lt;/h4&gt;

&lt;p&gt;The SDK agent keeps its tasks in memory, and the CLI reads them back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a task list &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8104
&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;ID                                    STATUS     CONTEXT
01a1a35f-2b4f-7190-b6df-81bb7b76c2cc  completed  01a1a35f-2b4f-7190-b6df-81bca35e8b6d
01a1a35f-2b64-77b5-9417-18b1f425b17d  completed  01a1a35f-2b64-77b5-9417-18b24253b719
01a1a35f-2bab-7012-b094-1022dce75f36  completed  01a1a35f-2bab-7012-b094-1023824b35eb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--async&lt;/code&gt; returns as soon as the task exists. N=25 takes over a second, long enough to catch it running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a send &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8104 &lt;span class="nt"&gt;--async&lt;/span&gt; &lt;span class="s2"&gt;"first 25"&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;Task:     01a1a3f5-9da1-746b-abae-84307e4e1735
Context:  01a1a3f5-9da1-746b-abae-8431b422e72e
Status:   working (2026-11-04T00:29:05Z)
History:
  [user] first 25
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;task get --wait&lt;/code&gt; polls until the task finishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a task get &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8104 01a1a3f5-9da1-746b-abae-84307e4e1735 &lt;span class="nt"&gt;--wait&lt;/span&gt; &lt;span class="nt"&gt;--poll-interval&lt;/span&gt; 500ms
&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;Task:     01a1a3f5-9da1-746b-abae-84307e4e1735
Context:  01a1a3f5-9da1-746b-abae-8431b422e72e
Status:   completed (2026-11-04T00:29:06Z)
  Found first 25 Mersenne primes in 1509.7683ms.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 8 — The Native Rust Agent
&lt;/h4&gt;

&lt;p&gt;The SDK agent is one &lt;code&gt;AgentExecutor&lt;/code&gt;. It emits a &lt;code&gt;working&lt;/code&gt; status, runs the big-integer work on a blocking thread, then returns the finished task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Big-integer work is CPU-bound; keep it off the async workers.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;task&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn_blocking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;find_mersenne_primes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&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="nn"&gt;A2AError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"worker panicked: {e}"&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="nf"&gt;.summary&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&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="nn"&gt;A2AError&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"serialise report: {e}"&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="c1"&gt;// ... Task { status: Completed, artifacts: [Part::text(summary), Part::data(data)] }&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nn"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;working&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="nf"&gt;.chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server wires the SDK's routers into axum, one per transport plus the card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;DefaultRequestHandler&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MersenneExecutor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;InMemoryTaskStore&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Arc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;StaticAgentCard&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;agent_card&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;axum&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.nest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;a2a_server&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;jsonrpc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;jsonrpc_router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="nf"&gt;.nest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/rest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;a2a_server&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;rest_router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;.merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;a2a_server&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;agent_card&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;agent_card_router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Lucas-Lehmer test runs on &lt;code&gt;num-bigint&lt;/code&gt; with the reduction mod 2^p − 1 done by folding the high bits onto the low ones. The timed region holds only the tests, on a monotonic clock, and no prime is ever turned into a decimal string. Two slow tests check the arithmetic: one runs the test on every prime exponent up to 9941 and asserts it accepts exactly the first 22 known ones, the other confirms all 26:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--release&lt;/span&gt; &lt;span class="nt"&gt;--lib&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--ignored&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;test tests::all_26_exponents_prime ... ok
test tests::exhaustive_through_n22 ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 5 filtered out; finished in 25.70s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 9 — Serve a Plain Program With &lt;code&gt;a2a server --exec&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;mersenne-exec&lt;/code&gt; knows nothing about A2A. It reads the message on stdin and prints two lines:&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;echo&lt;/span&gt; &lt;span class="s1"&gt;'first 5'&lt;/span&gt; | target/release/mersenne-exec
&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;{"requested":5,"found":5,"elapsed_ms":0.007023000000000001,"exponents":[2,3,5,7,13]}
Found first 5 Mersenne primes in 0.0070ms.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The A2A CLI makes it an agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a server &lt;span class="nt"&gt;--exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;&lt;span class="s2"&gt;/target/release/mersenne-exec"&lt;/span&gt; &lt;span class="nt"&gt;--chunk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Mersenne Prime Exec Rust"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--advertise-address&lt;/span&gt; &lt;span class="s2"&gt;"http://localhost:8105"&lt;/span&gt; &lt;span class="nt"&gt;--port&lt;/span&gt; 8105
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same client commands work against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a send &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8105 &lt;span class="nt"&gt;--stream&lt;/span&gt; &lt;span class="s2"&gt;"first 8"&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;Status:   submitted
History:
  [user] first 8
[status] working
[artifact] {"requested":8,"found":8,"elapsed_ms":0.01543,"exponents":[2,3,5,7,13,17,19,31]}Found first 8 Mersenne primes in 0.0154ms.
[status] completed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exec server starts the program once per message, hands it the text, and returns stdout as the artifact.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Two &lt;code&gt;--exec&lt;/code&gt; Flags ADK Needs
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--advertise-address http://localhost:8105&lt;/code&gt;. The exec server's card names &lt;code&gt;127.0.0.1&lt;/code&gt; by default, and ADK's &lt;code&gt;RemoteA2aAgent&lt;/code&gt; refuses a card whose RPC URL has a different origin from the one the card came from.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--chunk=$'\n'&lt;/code&gt;. With it, each output line streams as its own chunk. Without it, stream requests get &lt;code&gt;UNSUPPORTED_OPERATION&lt;/code&gt;: the Go CLI falls back to polling and ADK's Python client fails. ADK keeps only the last chunk, so &lt;code&gt;mersenne-exec&lt;/code&gt; prints the JSON line first and the summary last.&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 10 — Put ADK in Front
&lt;/h4&gt;

&lt;p&gt;ADK needs its Python package and Gemini on Vertex AI, signed in with application-default credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="s2"&gt;"google-adk[a2a]"&lt;/span&gt;
gcloud auth application-default login
&lt;span class="nb"&gt;source &lt;/span&gt;scripts/adk-env.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;adk-env.sh&lt;/code&gt; sets &lt;code&gt;GOOGLE_GENAI_USE_VERTEXAI=TRUE&lt;/code&gt;, &lt;code&gt;GOOGLE_CLOUD_LOCATION=global&lt;/code&gt;, the project, and &lt;code&gt;MODEL_NAME&lt;/code&gt;, which defaults to &lt;code&gt;gemini-3.8-flash&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;adk/rust_bench/agent.py&lt;/code&gt; is an ADK &lt;code&gt;LlmAgent&lt;/code&gt; with both Rust agents as &lt;code&gt;RemoteA2aAgent&lt;/code&gt; sub-agents and one tool:&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;native_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RemoteA2aAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;native_rust_agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Rust A2A v1.0 agent on the official a2a-rs SDK. ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;agent_card&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;NATIVE_URL&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;AGENT_CARD_WELL_KNOWN_PATH&lt;/span&gt;&lt;span class="si"&gt;}&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="n"&gt;root_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LlmAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rust_bench&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MODEL_NAME&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;gemini-3.8-flash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;run_benchmark&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;sub_agents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;native_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exec_agent&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ADK CLI runs it three ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adk run rust_bench                                &lt;span class="c"&gt;# terminal&lt;/span&gt;
adk web &lt;span class="nt"&gt;--port&lt;/span&gt; 8107                               &lt;span class="c"&gt;# dev UI&lt;/span&gt;
adk api_server &lt;span class="nt"&gt;--a2a&lt;/span&gt; &lt;span class="nt"&gt;--port&lt;/span&gt; 8106 adk              &lt;span class="c"&gt;# A2A server, from the rig root&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;adk api_server --a2a&lt;/code&gt; serves each agent folder that holds an &lt;code&gt;agent.json&lt;/code&gt; card, at &lt;code&gt;/a2a/&amp;lt;name&amp;gt;&lt;/code&gt;; &lt;code&gt;scripts/start-adk.sh&lt;/code&gt; starts it in the background. The A2A CLI then treats the coordinator like any other agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a send &lt;span class="nt"&gt;-a&lt;/span&gt; http://localhost:8106/a2a/rust_bench/.well-known/agent-card.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt; 120s &lt;span class="s2"&gt;"Find the first 9 Mersenne primes with the exec agent"&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;Status:   completed (2026-11-03T21:44:51Z)
Artifacts:
  [f2d2f27d-24ce-4120-8368-d966c99617a9] Found first 9 Mersenne primes in 0.0236ms.
History:
  [user] Find the first 9 Mersenne primes with the exec agent
  [agent] {"args":{"agent_name":"exec_rust_agent"},"id":"call_1332004","name":"transfer_to_agent"}
  [agent] {"id":"call_1332004","name":"transfer_to_agent","response":{"result":null}}
  [agent] {"requested":9,"found":9,"elapsed_ms":0.023635,"exponents":[2,3,5,7,13,17,19,31,61]}
  [agent] Found first 9 Mersenne primes in 0.0236ms.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The history shows the hand-off: Gemini calls &lt;code&gt;transfer_to_agent&lt;/code&gt;, the Rust agent answers, and its JSON report comes back through two A2A hops.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 11 — Keep the Model Away From the Numbers
&lt;/h4&gt;

&lt;p&gt;A benchmark through an LLM can time the model writing a number. Two rules keep the timing in code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The benchmark client checks every reply.&lt;/strong&gt; &lt;code&gt;mersenne-bench&lt;/code&gt; reads the timing from the agent's data part or JSON line, rejects a report where &lt;code&gt;requested&lt;/code&gt; differs from N or &lt;code&gt;found&lt;/code&gt; differs from min(N, 26), and rejects an LLM-path reply with no &lt;code&gt;transfer_to_agent&lt;/code&gt; to a Rust agent. Each row records where its report came from and who it was routed to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ADK benchmarks go through a tool.&lt;/strong&gt; &lt;code&gt;run_benchmark&lt;/code&gt; runs &lt;code&gt;mersenne-bench&lt;/code&gt; and returns medians already computed and rounded. The instruction tells Gemini to quote them, and &lt;code&gt;scripts/check-adk-reply.py&lt;/code&gt; verifies a reply: one &lt;code&gt;run_benchmark&lt;/code&gt; call, with the arguments asked for, and every number in the answer a value the tool returned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A benchmark request sent from the CLI, saved with &lt;code&gt;-o json&lt;/code&gt;. The tool call and the head of the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;a2a send &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nv"&gt;$CARD&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt; 300s &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="s2"&gt;"Benchmark both agents for N=3 to 6 with 2 reps"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; reply.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;[tool call] {"max_n": 6, "min_n": 3, "reps": 2}
### Arguments Used
- `min_n`: 3
- `max_n`: 6
- `reps`: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer goes on with a summary per agent and a row per N. The check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/check-adk-reply.py reply.json 3 6 2
&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;state: TASK_STATE_COMPLETED
run_benchmark calls: 1
args sent: {'max_n': 6, 'min_n': 3, 'reps': 2}  requested: {'min_n': 3, 'max_n': 6, 'reps': 2}
numbers in reply: 57  not from the tool: 0
PASS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scripts/adk-reliability.sh&lt;/code&gt; sends ten benchmark requests with varied ranges and ten requests that name no agent. On &lt;code&gt;gemini-3.8-flash&lt;/code&gt;, 10 of 10 benchmark replies passed the checker and 10 of 10 unnamed requests went to &lt;code&gt;native_rust_agent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The sweep covers the whole exponent table, N=1 to 26. Exponent 23209 is a 6,987-digit number, and the arithmetic stays in &lt;code&gt;BigUint&lt;/code&gt; throughout.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 12 — Benchmark From a Rust Client
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;REPS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20 scripts/bench.sh          &lt;span class="c"&gt;# native + exec, N=1..26&lt;/span&gt;
&lt;span class="nv"&gt;ADK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;REPS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 scripts/bench.sh     &lt;span class="c"&gt;# adds the ADK series&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mersenne-bench&lt;/code&gt; is built on the SDK's client crate, &lt;code&gt;a2a-client-lf&lt;/code&gt;. Each call records the agent's own compute time and the client's round trip; overhead is the round trip minus compute, per call. Direct calls, medians of 20 reps:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;N&lt;/th&gt;
&lt;th&gt;native compute ms&lt;/th&gt;
&lt;th&gt;native overhead ms&lt;/th&gt;
&lt;th&gt;exec compute ms&lt;/th&gt;
&lt;th&gt;exec overhead ms&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;0.001039&lt;/td&gt;
&lt;td&gt;0.731&lt;/td&gt;
&lt;td&gt;0.0004555&lt;/td&gt;
&lt;td&gt;4.046&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0.04249&lt;/td&gt;
&lt;td&gt;0.793&lt;/td&gt;
&lt;td&gt;0.04200&lt;/td&gt;
&lt;td&gt;4.391&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;26.33&lt;/td&gt;
&lt;td&gt;0.970&lt;/td&gt;
&lt;td&gt;60.21&lt;/td&gt;
&lt;td&gt;4.374&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;181.1&lt;/td&gt;
&lt;td&gt;1.255&lt;/td&gt;
&lt;td&gt;264.6&lt;/td&gt;
&lt;td&gt;3.558&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;781.1&lt;/td&gt;
&lt;td&gt;1.073&lt;/td&gt;
&lt;td&gt;868.9&lt;/td&gt;
&lt;td&gt;3.335&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;2484&lt;/td&gt;
&lt;td&gt;1.396&lt;/td&gt;
&lt;td&gt;2619&lt;/td&gt;
&lt;td&gt;3.588&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Through ADK, medians of 3 reps, one run per model. All 26 rows in each run went to &lt;code&gt;native_rust_agent&lt;/code&gt; and read its data part:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;N&lt;/th&gt;
&lt;th&gt;3.5-flash compute ms&lt;/th&gt;
&lt;th&gt;3.5-flash round trip ms&lt;/th&gt;
&lt;th&gt;3.8-flash compute ms&lt;/th&gt;
&lt;th&gt;3.8-flash round trip ms&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;0.002051&lt;/td&gt;
&lt;td&gt;1665.053&lt;/td&gt;
&lt;td&gt;0.002615&lt;/td&gt;
&lt;td&gt;2073.666&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0.04513&lt;/td&gt;
&lt;td&gt;1394.245&lt;/td&gt;
&lt;td&gt;0.04390&lt;/td&gt;
&lt;td&gt;2458.155&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;72.95&lt;/td&gt;
&lt;td&gt;1332.994&lt;/td&gt;
&lt;td&gt;68.52&lt;/td&gt;
&lt;td&gt;2336.569&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;279.8&lt;/td&gt;
&lt;td&gt;1868.178&lt;/td&gt;
&lt;td&gt;287.3&lt;/td&gt;
&lt;td&gt;2679.324&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;2226&lt;/td&gt;
&lt;td&gt;3455.492&lt;/td&gt;
&lt;td&gt;2241&lt;/td&gt;
&lt;td&gt;4123.353&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The slowest single call on &lt;code&gt;gemini-3.8-flash&lt;/code&gt; took 29.0 s, at N=9.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 13 — Time the A2A CLI Itself
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;scripts/cli-bench.py&lt;/code&gt; runs one &lt;code&gt;a2a send -o json&lt;/code&gt; process per call, the way a shell script would, and applies the same checks to each reply. Wall time covers starting the CLI, fetching the card, sending, and exiting. Two reference commands, 20 runs each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Median ms&lt;/th&gt;
&lt;th&gt;Min ms&lt;/th&gt;
&lt;th&gt;Max ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;a2a version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7.17&lt;/td&gt;
&lt;td&gt;5.57&lt;/td&gt;
&lt;td&gt;9.62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;a2a card get&lt;/code&gt; (native)&lt;/td&gt;
&lt;td&gt;9.18&lt;/td&gt;
&lt;td&gt;8.30&lt;/td&gt;
&lt;td&gt;11.17&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The full sweep, N=1 to 26, 5 reps direct and 3 through ADK:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Calls&lt;/th&gt;
&lt;th&gt;Overhead per call, per-N medians&lt;/th&gt;
&lt;th&gt;Median of the per-N medians&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;native&lt;/td&gt;
&lt;td&gt;130&lt;/td&gt;
&lt;td&gt;10.92 to 21.82 ms&lt;/td&gt;
&lt;td&gt;20.28 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exec&lt;/td&gt;
&lt;td&gt;130&lt;/td&gt;
&lt;td&gt;9.43 to 20.81 ms&lt;/td&gt;
&lt;td&gt;17.51 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ADK, 3.8-flash&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;1825.22 to 3017.92 ms&lt;/td&gt;
&lt;td&gt;2217.55 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every ADK call went to &lt;code&gt;native_rust_agent&lt;/code&gt;; the slowest took 22693.6 ms. The sweep times each agent in its own block, so native and exec ran minutes apart. Run in alternating order, N=1, 30 runs each (&lt;code&gt;scripts/cli-transport.py&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;native, default (JSON-RPC): median 20.74 ms, min 18.60, max 26.51
native, --transport rest: median 21.49 ms, min 19.63, max 26.15
exec (HTTP+JSON only): median 20.81 ms, min 18.67, max 28.07
a2a version: median 7.62 ms, min 6.07, max 10.71
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From a shell, the transport and the serving path make no difference to the CLI's cost: about 21 ms per call, about a third of it starting the process. Against the Rust client's 0.55 to 1.40 ms, the CLI fits scripts and checks, and the Rust client fits a benchmark.&lt;/p&gt;

&lt;p&gt;The CLI's overhead in the sweep drops to about 11 ms for both agents once compute passes 100 ms (N=21 and up). Why is untested.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Compare Compute Only Between Runs Paced Alike
&lt;/h4&gt;

&lt;p&gt;The same native process at N=20 takes 26.07 ms when calls arrive back to back and 68.09 ms when each call follows a 1.3 s idle gap (&lt;code&gt;mersenne-bench --gap-ms 1300&lt;/code&gt;). ADK's model round trip spaces calls out too, and its mid-range compute times sit close to the gap series: 68.52 ms at N=20 on &lt;code&gt;gemini-3.8-flash&lt;/code&gt;. The CPU-level cause, clock scaling or core placement on a hybrid laptop CPU, is untested.&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;Serving path&lt;/th&gt;
&lt;th&gt;Code the agent needs&lt;/th&gt;
&lt;th&gt;Overhead from a Rust client&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;a2a send&lt;/code&gt; from a shell, N=1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Official Rust SDK 🥇&lt;/td&gt;
&lt;td&gt;an &lt;code&gt;AgentExecutor&lt;/code&gt; and three routers&lt;/td&gt;
&lt;td&gt;0.55 to 1.40 ms&lt;/td&gt;
&lt;td&gt;20.74 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;a2a server --exec&lt;/code&gt; 🥈&lt;/td&gt;
&lt;td&gt;none: stdin in, stdout out&lt;/td&gt;
&lt;td&gt;3.33 to 5.05 ms&lt;/td&gt;
&lt;td&gt;20.81 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ADK coordinator 🥉&lt;/td&gt;
&lt;td&gt;an &lt;code&gt;agent.py&lt;/code&gt;, an &lt;code&gt;agent.json&lt;/code&gt; card&lt;/td&gt;
&lt;td&gt;1328 ms on 3.5-flash, 2283 ms on 3.8-flash&lt;/td&gt;
&lt;td&gt;2664.00 ms on 3.8-flash&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rust-client ranges are the smallest and largest per-N medians; the ADK figures are the medians of its per-N medians. The exec figure includes starting a process for every message; how it splits between process start and the HTTP hop was not measured. At N=26 the work itself dominates every path: 2484 ms native and 2619 ms exec, direct.&lt;/p&gt;




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

&lt;p&gt;For an agent built to be one, use the SDK: about a millisecond of protocol cost, both transports, tasks the CLI can list and wait on.&lt;/p&gt;

&lt;p&gt;For a program that already exists, &lt;code&gt;a2a server --exec&lt;/code&gt; makes it an agent with no code change. A few milliseconds per call buys that, and ADK can call it once the two flags are set.&lt;/p&gt;

&lt;p&gt;For routing by natural language, put ADK in front and keep the timing out of the model: a tool for anything computed, and a check that the model sent the arguments asked for.&lt;/p&gt;

&lt;p&gt;For talking to any of them, the A2A CLI is the one client: &lt;code&gt;card get&lt;/code&gt;, &lt;code&gt;send&lt;/code&gt;, &lt;code&gt;--stream&lt;/code&gt;, &lt;code&gt;task get --wait&lt;/code&gt;, and &lt;code&gt;-o json&lt;/code&gt; for scripts. Each call costs about 21 ms, so time agents from a long-lived client.&lt;/p&gt;




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

&lt;p&gt;The goal of this article was to drive the A2A CLI against Rust agents on A2A v1.0, and to measure what each way of serving the same Rust code costs per call. The key to the solution was one benchmark client that reads timing from a structured report and checks that every reply answers the question asked. The results on one laptop were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 The A2A CLI drove all three agents with the same commands: card, send, stream, REST, data parts, JSON output, task list, &lt;code&gt;--async&lt;/code&gt; and &lt;code&gt;task get --wait&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;🟢 Every call through N=26, from the Rust client and from the CLI, reported &lt;code&gt;found&lt;/code&gt; = min(N, 26)&lt;/li&gt;
&lt;li&gt;🟢 The SDK agent added 0.55 to 1.40 ms per call from a Rust client&lt;/li&gt;
&lt;li&gt;🟢 &lt;code&gt;a2a server --exec&lt;/code&gt; served an A2A-unaware program to the CLI and to ADK&lt;/li&gt;
&lt;li&gt;🟢 On &lt;code&gt;gemini-3.8-flash&lt;/code&gt;, 10 of 10 benchmark replies quoted only tool values, and 10 of 10 unnamed requests went to the native agent&lt;/li&gt;
&lt;li&gt;⚠️ One &lt;code&gt;a2a send&lt;/code&gt; from a shell costs about 21 ms whatever the agent, 7.62 ms of it starting the CLI&lt;/li&gt;
&lt;li&gt;⚠️ The wrapped program added 3.33 to 5.05 ms per call from a Rust client&lt;/li&gt;
&lt;li&gt;⚠️ ADK added a median 1328 ms per call on &lt;code&gt;gemini-3.5-flash&lt;/code&gt; and 2283 ms on &lt;code&gt;gemini-3.8-flash&lt;/code&gt;, with one call at 29.0 s&lt;/li&gt;
&lt;li&gt;❌ Compute times from calls spaced by idle gaps run slower and do not compare with back-to-back runs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one 13th Gen Intel Core i7-1360P laptop on Linux, release build, every agent and client on the same machine, 2026-11-03 and 2026-11-04. Rust-client series have 20 reps per N direct and 3 through ADK, with one run per model on Vertex AI's shared &lt;code&gt;global&lt;/code&gt; endpoint, so the difference between the two Gemini models is a single sample. The CLI series has 5 reps per N direct and 3 through ADK, on &lt;code&gt;gemini-3.8-flash&lt;/code&gt; only, and reliability was checked on that model only, ten requests of each kind. The gRPC transport, &lt;code&gt;task cancel&lt;/code&gt;, &lt;code&gt;task subscribe&lt;/code&gt;, push notifications and authentication were not exercised.&lt;/p&gt;

&lt;p&gt;The strategy for serving and benchmarking Rust A2A agents with the A2A CLI 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;The rig: &lt;a href="https://github.com/xbill9/rust-dev/tree/main/a2a-benchmark-rust" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/tree/main/a2a-benchmark-rust&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CLI tour transcripts: &lt;a href="https://github.com/xbill9/rust-dev/blob/main/a2a-benchmark-rust/results/cli-tour-20261103T214445Z.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/blob/main/a2a-benchmark-rust/results/cli-tour-20261103T214445Z.md&lt;/a&gt; and &lt;a href="https://github.com/xbill9/rust-dev/blob/main/a2a-benchmark-rust/results/cli-tour-20261104T002904Z.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/blob/main/a2a-benchmark-rust/results/cli-tour-20261104T002904Z.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CLI timing: &lt;a href="https://github.com/xbill9/rust-dev/blob/main/a2a-benchmark-rust/results/cli-bench-20261104T002328Z.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/rust-dev/blob/main/a2a-benchmark-rust/results/cli-bench-20261104T002328Z.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Introducing the A2A CLI: &lt;a href="https://a2a-protocol.org/latest/blog/2026/10/01/introducing-a2a-cli/" rel="noopener noreferrer"&gt;https://a2a-protocol.org/latest/blog/2026/10/01/introducing-a2a-cli/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A2A CLI: &lt;a href="https://github.com/a2aproject/a2a-cli" rel="noopener noreferrer"&gt;https://github.com/a2aproject/a2a-cli&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A2A Rust SDK: &lt;a href="https://github.com/a2aproject/a2a-rs" rel="noopener noreferrer"&gt;https://github.com/a2aproject/a2a-rs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Agent Development Kit: &lt;a href="https://google.github.io/adk-docs/" rel="noopener noreferrer"&gt;https://google.github.io/adk-docs/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Why Did My Benchmark Stop at N=22?: &lt;a href="https://dev.to/gde/why-did-my-benchmark-stop-at-n22-a-debugging-story-in-nine-bugs-3m2l"&gt;https://dev.to/gde/why-did-my-benchmark-stop-at-n22-a-debugging-story-in-nine-bugs-3m2l&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rust</category>
      <category>a2a</category>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>Gemma 4 in Rust on TorchTPU: A Native TPU Rig With PyO3 and MCP</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Mon, 02 Nov 2026 21:32:30 +0000</pubDate>
      <link>https://dev.to/gde/gemma-4-in-rust-on-torchtpu-a-native-tpu-rig-with-pyo3-and-mcp-47j1</link>
      <guid>https://dev.to/gde/gemma-4-in-rust-on-torchtpu-a-native-tpu-rig-with-pyo3-and-mcp-47j1</guid>
      <description>&lt;p&gt;This article provides a step by step guide to serving Gemma 4 E2B from a Cloud TPU v5e-1 VM with the model written in Rust and every operation run by TorchTPU. A Claude Code MCP server provisions the TPU, the VM builds the Rust crate on boot and serves an OpenAI-compatible endpoint, and a local shim gives it a chat page and a second MCP server for demos. Python hosts the process and the HTTP server; the 35 layers, the ring cache and the decode loop are Rust.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/torchtpu-dev/tree/main/tpu-pytorchrust-v5e1-2b-q4w4a16emb4" rel="noopener noreferrer"&gt;https://github.com/xbill9/torchtpu-dev/tree/main/tpu-pytorchrust-v5e1-2b-q4w4a16emb4&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Rust model reaches TorchTPU through a 154-line C++ shim over ATen, so it builds against whichever PyTorch is installed: here Python 3.14, PyTorch 2.14.1 and a nightly Rust.&lt;/strong&gt; It matches Hugging Face Transformers on 64 of 64 greedy tokens on CPU, boots on a fresh TPU VM in 4 minutes 29 seconds from installing Python to starting the service, and decodes at a median 12.20 tokens per second on the TPU.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvfaw9preatlfd9c2m0cx.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvfaw9preatlfd9c2m0cx.jpg" alt="The local chat page streaming from the Rust model on the TPU, with the build and versions in the side panel" width="800" height="407"&gt;&lt;/a&gt;&lt;/p&gt;




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

&lt;p&gt;&lt;a href="https://dev.to/gde/gemma-4-in-rust-on-torchtpu-hands-on-with-colab-44hi"&gt;Gemma 4 in Rust on TorchTPU: Hands On With Colab&lt;/a&gt; ran the same Rust engine in a Colab notebook. This rig takes it to a TPU VM of its own and keeps it there:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Where&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;Capacity&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;An MCP server that creates a v5e-1 queued resource and reads its boot stages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;TPU VM&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;torchtpu_rs&lt;/code&gt;, a Rust crate built on the VM as a PyO3 extension&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kernels&lt;/td&gt;
&lt;td&gt;TPU VM&lt;/td&gt;
&lt;td&gt;ATen calls through PyTorch's dispatcher to TorchTPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;TPU VM, port 8000&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rust_openai_server.py&lt;/code&gt;, OpenAI-compatible, streaming&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Demo&lt;/td&gt;
&lt;td&gt;This laptop&lt;/td&gt;
&lt;td&gt;A shim on &lt;code&gt;127.0.0.1:8015&lt;/code&gt; with a chat page, and a demo MCP server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The checkpoint is the W4A16 repack &lt;code&gt;xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text&lt;/code&gt; with the int4 vocabulary tables from its &lt;code&gt;-text-emb4&lt;/code&gt; twin. The directory name follows the rig naming scheme: &lt;code&gt;tpu&lt;/code&gt; (Cloud TPU API), &lt;code&gt;pytorchrust&lt;/code&gt; (the forward pass is Rust over PyTorch's ATen), &lt;code&gt;v5e1&lt;/code&gt;, &lt;code&gt;2b&lt;/code&gt;, and &lt;code&gt;q4w4a16emb4&lt;/code&gt; for the encoding.&lt;/p&gt;

&lt;p&gt;Versions on the VM, read from its &lt;code&gt;/v1/engine&lt;/code&gt;: Python 3.14.10, PyTorch 2.14.1, &lt;code&gt;torch_tpu&lt;/code&gt; 0.2.0, &lt;code&gt;libtpu&lt;/code&gt; 0.0.51, Transformers 5.20.2, &lt;code&gt;rustc&lt;/code&gt; 1.101.0-nightly.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Install the Rig
&lt;/h4&gt;

&lt;p&gt;The rig is a Claude Code plugin with its own MCP server and skill:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/xbill9/torchtpu-dev
&lt;span class="nb"&gt;cd &lt;/span&gt;torchtpu-dev/tpu-pytorchrust-v5e1-2b-q4w4a16emb4
./project-setup.sh &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--project&lt;/span&gt; &amp;lt;gcp-project-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every setting lives in &lt;code&gt;tpu.env&lt;/code&gt;: the zone, the two checkpoints, the Python and PyTorch versions the VM installs, the cache length and the firewall tags. PyTorch and TorchTPU come from public PyPI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PYTHON_BIN=python3.14
TORCH_PIP_SPEC=torch==2.14.1 torch_tpu==0.2.0 transformers==5.20.2 compressed-tensors==0.19.0 'jinja2&amp;gt;=3.1' huggingface_hub fastapi uvicorn
SERVE_CONTEXT=8192
SERVE_DEFER_AND_FUSE=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python 3.14 is the newest version &lt;code&gt;torch_tpu&lt;/code&gt; 0.2.0 ships wheels for. The Rust toolchain is named in the crate's &lt;code&gt;rust-toolchain.toml&lt;/code&gt;, &lt;code&gt;channel = "nightly"&lt;/code&gt;, and the crate is on edition 2024.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Reach TorchTPU Without &lt;code&gt;tch&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;TorchTPU registers its kernels on libtorch's dispatcher and ships no C++ API of its own, so Rust reaches it through ATen. The &lt;code&gt;tch&lt;/code&gt; crate wraps every ATen operation in generated C++ that compiles against PyTorch 2.13.0 only. The model uses a few dozen operations, so the crate wraps those itself, in &lt;code&gt;rust/src/aten.cpp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define RET(expr) TRY(return box(expr);) return nullptr;
&lt;/span&gt;
&lt;span class="k"&gt;extern&lt;/span&gt; &lt;span class="s"&gt;"C"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;aten_matmul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;RET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;matmul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;aten_linear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;RET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;aten_softmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int64_t&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;RET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;softmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;aten_index_copy_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Tensor&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;span class="kt"&gt;int64_t&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;RET&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;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;index_copy_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// torch.Tensor &amp;lt;-&amp;gt; at::Tensor, through libtorch_python. Both directions share storage.&lt;/span&gt;
&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;aten_from_python&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PyObject&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;RET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;THPVariable_Unpack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;PyObject&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;aten_to_python&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Tensor&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;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;TRY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;THPVariable_Wrap&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;nullptr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rust/src/aten.rs&lt;/code&gt; gives them &lt;code&gt;tch&lt;/code&gt;'s method names, so the model code reads as before. &lt;code&gt;build.rs&lt;/code&gt; asks the installed &lt;code&gt;torch&lt;/code&gt; for its headers, libraries, C++ ABI and version, and compiles the shim against them with C++20, which PyTorch 2.14's headers require. The module records what built it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt;&amp;gt;&amp;gt; torchtpu_rs.BUILD
{'torch': '2.14.1+cpu', 'rustc': 'rustc 1.101.0-nightly (3e92e1a2d 2026-11-01)', 'python': '3.14.7', 'crate': '0.1.0'}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A module built for one PyTorch refuses to load under another, because the C++ ABI differs: &lt;code&gt;import torchtpu_rs&lt;/code&gt; compares &lt;code&gt;torch.__version__&lt;/code&gt; with the version it was built against and raises ImportError on a mismatch.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — The PyO3 Bindings
&lt;/h4&gt;

&lt;p&gt;Every PyO3 item is in &lt;code&gt;rust/src/bindings.rs&lt;/code&gt;; the model and weight code never see a Python object. &lt;code&gt;rust/PYO3.md&lt;/code&gt; lists each crossing, and &lt;code&gt;rust/python/torchtpu_rs.pyi&lt;/code&gt; is the type stub. Four rules shape the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token ids are lists, tensors share storage.&lt;/strong&gt; &lt;code&gt;decode(ids: list[int], ...)&lt;/code&gt; returns &lt;code&gt;list[int]&lt;/code&gt;; generated tokens never cross as tensors. The one tensor that crosses on every load is &lt;code&gt;like&lt;/code&gt;, a &lt;code&gt;torch.zeros(1, device="tpu")&lt;/code&gt; that names the device: Rust builds each weight on the host and moves it with &lt;code&gt;type_as(like)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long calls release the GIL; the callback takes it back.&lt;/strong&gt; The server streams through a callback that runs once per token batch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="nf"&gt;.detach&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nn"&gt;aten&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;no_grad&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;gemma&lt;/span&gt;&lt;span class="nf"&gt;.decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_every&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;on_tokens&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
            &lt;span class="nn"&gt;Python&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;PyList&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="nf"&gt;.call1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,)));&lt;/span&gt;
                &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="nf"&gt;.and_then&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;r&lt;/span&gt;&lt;span class="nf"&gt;.is_truthy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;go_on&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;go_on&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="nf"&gt;Err&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="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;Some&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="k"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A false return ends the decode with &lt;code&gt;finish="cancelled"&lt;/code&gt;, and an exception in the callback is raised from &lt;code&gt;decode&lt;/code&gt;. Generation stops at the checkpoint's &lt;code&gt;eos_token_id&lt;/code&gt; list, &lt;code&gt;[1, 106, 50]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The engine is &lt;code&gt;#[pyclass(unsendable)]&lt;/code&gt;.&lt;/strong&gt; It owns TPU tensors and one ring cache, so PyO3 checks the calling thread on every access. A call from another thread raises &lt;code&gt;pyo3_runtime.PanicException&lt;/code&gt;, which derives from BaseException. The server builds the engine on one dedicated thread and queues every request to 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="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EngineWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Owns the one `torchtpu_rs.Engine`. Nothing outside this thread may touch it.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Errors a caller can cause are ValueError.&lt;/strong&gt; An empty prompt, &lt;code&gt;max_new&lt;/code&gt; below 1, or a prompt longer than the cache are checked in &lt;code&gt;bindings.rs&lt;/code&gt; before the model's own asserts.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tests/test_rust_bindings.py&lt;/code&gt; runs all of these on CPU against the real checkpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;test_bad_input_is_value_error ... ok
test_callback_exception_propagates ... ok
test_cancel_and_length ... ok
test_decode_stops_and_streams ... ok
test_gil_released_during_decode ... ok
test_module_surface ... ok
test_tensor_functions ... ok
test_unsendable ... ok
Ran 8 tests in 247.568s
OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 4 — Check It Against Transformers
&lt;/h4&gt;

&lt;p&gt;Before any TPU time, the Rust module and Hugging Face Transformers 5.20.2 run the same prompt on CPU, both on PyTorch 2.14.1, one process per side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;top5 rust [818, 8409, 102500, 4420, 16520] hf [818, 102500, 8409, 4420, 16520]
KL(hf||rs) 1.078e-05; same 64 of 64; first diff None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The five top candidates are the same set, the KL divergence of the first token's distribution is 1.1e-5, and all 64 greedy tokens match.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Provision the TPU
&lt;/h4&gt;

&lt;p&gt;From Claude Code, the rig's MCP server creates a flex-start v5e-1 queued resource in &lt;code&gt;us-west4-a&lt;/code&gt;. The startup script travels in the instance metadata with the crate and the server packed inside it, 47 KiB rendered, so the VM needs no SSH to get its code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; create_tpu_queued_resource
🚀 Queued Resource gemma4-rust-qr creation initiated in us-west4-a, serving `xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4`.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A TPU VM has no serial-console command, so the startup script writes each stage to a guest attribute and &lt;code&gt;get_rig_progress&lt;/code&gt; reads it back without SSH. The boot on 2026-11-02:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STAGE: python
g++ (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
STAGE: torch
torch 2.14.1+cpu
STAGE: rust
STAGE: crate
rustc 1.101.0-nightly (3e92e1a2d 2026-11-01)
    Finished `release` profile [optimized] target(s) in 21.03s
STAGE: weights
STAGE: smoke
torchtpu_rs 0.1.0 matmul_sum 512.0 on tpu:0
TPU smoke OK
STAGE: unit
STAGE: serving
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From installing Python to starting the service took 4 minutes 29 seconds. The smoke test is a Rust call on a tensor on the TPU, through the shim, checked for the right value and a result on &lt;code&gt;tpu:0&lt;/code&gt;. The service then loads the weights in 6.8 seconds and runs a warmup decode in 18.8 seconds before &lt;code&gt;/health&lt;/code&gt; answers.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Pin &lt;code&gt;jinja2&amp;gt;=3.1&lt;/code&gt; on a TPU VM
&lt;/h4&gt;

&lt;p&gt;The TPU image's own &lt;code&gt;jinja2&lt;/code&gt; 3.0.3 sits in &lt;code&gt;/usr/lib/python3/dist-packages&lt;/code&gt;, which is on the path of the deadsnakes Python. Transformers' chat templates need 3.1 or later and refuse to render with the older one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ImportError: apply_chat_template requires jinja2&amp;gt;=3.1.0 to be installed. Your version is 3.0.3.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;'jinja2&amp;gt;=3.1'&lt;/code&gt; in the pip spec puts a newer copy in &lt;code&gt;/usr/local/lib/python3.14/dist-packages&lt;/code&gt;, which comes first on the path.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — The OpenAI Endpoint
&lt;/h4&gt;

&lt;p&gt;The VM serves &lt;code&gt;/v1/chat/completions&lt;/code&gt; and &lt;code&gt;/v1/completions&lt;/code&gt;, streamed or not, plus &lt;code&gt;/v1/models&lt;/code&gt;, &lt;code&gt;/health&lt;/code&gt; and &lt;code&gt;/metrics&lt;/code&gt;. &lt;code&gt;/v1/engine&lt;/code&gt; reports what is loaded and how it is bound:&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;"torch_tpu_defer_and_fuse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"load_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;6.83&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"warmup_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;18.8&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"layout"&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;"full_rings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sliding_rings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"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;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8192&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;"engine_class"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Engine — #[pyclass(unsendable)], owned by thread torchtpu-rs-engine"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decoding is greedy. Each non-streamed response carries the engine's own seconds for reading the prompt and for decoding, and &lt;code&gt;system_fingerprint&lt;/code&gt; reads &lt;code&gt;torchtpu_rs-greedy&lt;/code&gt;.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — Measure It
&lt;/h4&gt;

&lt;p&gt;The same prompt and length as a vLLM run on the same chip type, &lt;code&gt;Write a short paragraph about the ocean.&lt;/code&gt; at 100 tokens, five requests one at a time, rate from the engine's own timer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request 1: 83 tokens, finish stop, read 0.088 s, decode 6.651 s, 12.33 tok/s, client 6.83 s
request 2: 83 tokens, finish stop, read 0.086 s, decode 6.720 s, 12.20 tok/s, client 6.89 s
request 3: 83 tokens, finish stop, read 0.086 s, decode 6.734 s, 12.18 tok/s, client 6.91 s
request 4: 83 tokens, finish stop, read 0.087 s, decode 6.762 s, 12.13 tok/s, client 6.94 s
request 5: 83 tokens, finish stop, read 0.083 s, decode 6.561 s, 12.50 tok/s, client 6.74 s
median decode tok/s 12.20, min 12.13, max 12.50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Streaming eight prompts through the shim from a laptop, &lt;code&gt;run_rust_benchmark&lt;/code&gt; measured a first token in 0.175 to 0.256 seconds and 11.5 to 12.6 tokens per second as the client saw them.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — Demo It From a Laptop
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;demo/install.sh&lt;/code&gt; installs two things on the laptop: a shim on &lt;code&gt;127.0.0.1:8015&lt;/code&gt; as a systemd user service, and a demo MCP server registered with Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;demo/install.sh &lt;span class="nt"&gt;--project&lt;/span&gt; &amp;lt;gcp-project-id&amp;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;&amp;gt; endpoint_status
🟢 local http://127.0.0.1:8015/v1 → http://136.85.219.123:8000
- TPU /health: 200
- queued resource `gemma4-rust-qr` in us-west4-a, project aisprint-491218
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shim finds the VM's address with &lt;code&gt;gcloud&lt;/code&gt; and looks it up again when the VM stops answering, so a new flex-start VM needs no reconfiguration. It passes every request through, streams included, so any OpenAI client works against &lt;code&gt;http://127.0.0.1:8015/v1&lt;/code&gt;. Its root is a chat page: replies stream, the conversation carries over, and Stop cancels the decode on the TPU. A client that disconnected after 10 streamed chunks moved the server's cancel counter from 1 to 2, and the engine went idle.&lt;/p&gt;

&lt;p&gt;The demo MCP server, &lt;code&gt;torchtpu-rs-demo&lt;/code&gt;, has five tools, all through the shim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; ask_gemma_rust "What is a TPU? Two sentences."
A TPU stands for **Tensor Processing Unit**, which is a specialized type of integrated circuit designed to accelerate machine learning workloads. ...
---
xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4 · finish `stop` · 17 prompt + 55 completion tokens · prompt read 0.088 s · decode 12.2 tok/s on the TPU · 4.59 s round trip from here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stream_gemma_rust&lt;/code&gt;, &lt;code&gt;engine_info&lt;/code&gt;, &lt;code&gt;endpoint_status&lt;/code&gt; and &lt;code&gt;serving_metrics&lt;/code&gt; cover streaming, the build, the shim's view of the VM and the server's counters.&lt;/p&gt;




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

&lt;p&gt;One TPU v5e chip, Gemma 4 E2B, decode tokens per second for one request at a time. Cost per million output tokens is computed from each rate and the v5e-1 flex-start list price, USD 0.60 an hour (arithmetic, &lt;code&gt;evidence/arithmetic.txt&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;One v5e chip&lt;/th&gt;
&lt;th&gt;Decode tok/s&lt;/th&gt;
&lt;th&gt;Model code&lt;/th&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;USD per M tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;vLLM 0.30.0 🥇&lt;/td&gt;
&lt;td&gt;144.1&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;vLLM TPU, bf16 checkpoint&lt;/td&gt;
&lt;td&gt;1.16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python &lt;code&gt;torch.compile&lt;/code&gt;, Colab 🥈&lt;/td&gt;
&lt;td&gt;33.5 streamed, 45.1 batched&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;TorchTPU, PyTorch 2.14.1&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust, &lt;code&gt;tch&lt;/code&gt;, Colab 🥉&lt;/td&gt;
&lt;td&gt;15.1 streamed&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;TorchTPU defer-and-fuse, PyTorch 2.13.0&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust, ATen shim, this rig&lt;/td&gt;
&lt;td&gt;12.20&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;TorchTPU defer-and-fuse, PyTorch 2.14.1&lt;/td&gt;
&lt;td&gt;13.66&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The vLLM figure is from &lt;a href="https://github.com/xbill9/gcp-inference" rel="noopener noreferrer"&gt;the gcp-inference demo&lt;/a&gt; on 2026-10-12, read from vLLM's own metrics. vLLM decodes 11.8x faster than this rig on the same chip type. The compiled Python step decodes 2.75x faster when streaming.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;torch.compile&lt;/code&gt; traces Python and cannot see a Rust model, so the Rust ways run TorchTPU's defer-and-fuse mode, which fuses operations below ATen. This rig streams at 0.81x the Colab &lt;code&gt;tch&lt;/code&gt; run. The two differ in PyTorch version, ATen layer, cache length (8,192 tokens against 33,024), a Python callback per streamed token, and where the timer sits; which of those accounts for the gap is not measured.&lt;/p&gt;




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

&lt;p&gt;For throughput on a v5e, serve with vLLM: 144.1 tokens per second on one request and USD 1.16 per million tokens.&lt;/p&gt;

&lt;p&gt;For a model written in Rust on TorchTPU, this rig is the working shape: a crate that builds against the installed PyTorch, PyO3 bindings that keep the event loop free while the TPU works, and an OpenAI endpoint any client can use. It decodes at about 12 tokens per second.&lt;/p&gt;

&lt;p&gt;For a demo, the shim gives one local address for a browser, an OpenAI client and Claude Code, whichever VM is up.&lt;/p&gt;




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

&lt;p&gt;The goal of this article was to serve Gemma 4 E2B from a TPU VM with the model in Rust, through TorchTPU and without JAX, and to operate it from Claude Code. The key to the solution was a C++ shim over the few dozen ATen operations the model uses, compiled against the installed PyTorch, with PyO3 bindings that release the GIL during decoding and confine the engine to one thread. The results on one TPU v5e chip were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 Rust matches Transformers on CPU on 64 of 64 tokens, with a KL divergence of 1.1e-5, on PyTorch 2.14.1&lt;/li&gt;
&lt;li&gt;🟢 No pinned PyTorch: Python 3.14, PyTorch 2.14.1 and nightly Rust on the VM&lt;/li&gt;
&lt;li&gt;🟢 4 minutes 29 seconds from installing Python to starting the service, with the crate built on the VM in 21 seconds&lt;/li&gt;
&lt;li&gt;🟢 OpenAI endpoint, streaming, cancel on disconnect, an MCP server for the TPU and one for demos, and a local chat page&lt;/li&gt;
&lt;li&gt;⚠️ 12.20 tokens per second median decode, 11.8x slower than vLLM on the same chip type&lt;/li&gt;
&lt;li&gt;⚠️ One request at a time and greedy decoding only; no tool-call parsing&lt;/li&gt;
&lt;li&gt;❌ No compile path from Rust: &lt;code&gt;torch.compile&lt;/code&gt; traces Python, and TorchTPU 0.2.0 has no C++ API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: Gemma 4 E2B-it QAT W4A16 with int4 vocabulary tables, text only, one flex-start v5e-1 queued resource in &lt;code&gt;us-west4-a&lt;/code&gt; on 2026-11-02, an 8,192-token cache, greedy decoding, one request at a time. The vLLM and &lt;code&gt;torch.compile&lt;/code&gt; figures come from other runs on the same chip type (gcp-inference on 2026-10-12, Colab notebook 06 on 2026-11-01) with other checkpoints and cache lengths. Batched host reads, a longer cache and concurrent requests were not run on this rig. The parity check and the binding tests ran on a laptop CPU.&lt;/p&gt;

&lt;p&gt;The strategy for serving a Rust model on TorchTPU 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;The rig: &lt;a href="https://github.com/xbill9/torchtpu-dev/tree/main/tpu-pytorchrust-v5e1-2b-q4w4a16emb4" rel="noopener noreferrer"&gt;https://github.com/xbill9/torchtpu-dev/tree/main/tpu-pytorchrust-v5e1-2b-q4w4a16emb4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyO3 bindings: &lt;a href="https://github.com/xbill9/torchtpu-dev/blob/main/tpu-pytorchrust-v5e1-2b-q4w4a16emb4/rust/PYO3.md" rel="noopener noreferrer"&gt;https://github.com/xbill9/torchtpu-dev/blob/main/tpu-pytorchrust-v5e1-2b-q4w4a16emb4/rust/PYO3.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Demo shim, chat page and MCP server: &lt;a href="https://github.com/xbill9/torchtpu-dev/tree/main/tpu-pytorchrust-v5e1-2b-q4w4a16emb4/demo" rel="noopener noreferrer"&gt;https://github.com/xbill9/torchtpu-dev/tree/main/tpu-pytorchrust-v5e1-2b-q4w4a16emb4/demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Gemma 4 in Rust on TorchTPU, Hands On With Colab: &lt;a href="https://dev.to/gde/gemma-4-in-rust-on-torchtpu-hands-on-with-colab-44hi"&gt;https://dev.to/gde/gemma-4-in-rust-on-torchtpu-hands-on-with-colab-44hi&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;gcp-inference: &lt;a href="https://github.com/xbill9/gcp-inference" rel="noopener noreferrer"&gt;https://github.com/xbill9/gcp-inference&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyO3: &lt;a href="https://github.com/PyO3/pyo3" rel="noopener noreferrer"&gt;https://github.com/PyO3/pyo3&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;W4A16 repack: &lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text" rel="noopener noreferrer"&gt;https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;4-bit-tables repack: &lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4" rel="noopener noreferrer"&gt;https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rust</category>
      <category>pytorch</category>
      <category>tpu</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Gemma 4 in Rust on TorchTPU: Hands On With Colab</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:52:55 +0000</pubDate>
      <link>https://dev.to/gde/gemma-4-in-rust-on-torchtpu-hands-on-with-colab-44hi</link>
      <guid>https://dev.to/gde/gemma-4-in-rust-on-torchtpu-hands-on-with-colab-44hi</guid>
      <description>&lt;p&gt;This article provides a step by step guide to running Gemma 4 E2B with the model written in Rust and every operation executed by TorchTPU, on a Colab v5e-1 runtime: one TPU v5e chip. A Rust crate reads the checkpoint, unpacks the 4-bit weights, runs the 35 layers and the ring cache, and drives the decode loop. Python hosts the backend and starts the engine. No JAX is involved.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/xbill9/torchtpu-dev/tree/main/colab/rust" rel="noopener noreferrer"&gt;https://github.com/xbill9/torchtpu-dev/tree/main/colab/rust&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rust writes the same tokens as compiled Python at about half the streaming speed and less than half the peak memory.&lt;/strong&gt; Same chip, same checkpoint, same 19-token prompt, 64 greedy tokens, 33,024-token context:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;One Colab v5e-1 chip&lt;/th&gt;
&lt;th&gt;Streamed tok/s&lt;/th&gt;
&lt;th&gt;Batched tok/s&lt;/th&gt;
&lt;th&gt;First call&lt;/th&gt;
&lt;th&gt;Peak memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rust, defer-and-fuse&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14.7 to 15.7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15.2 to 16.0&lt;/td&gt;
&lt;td&gt;82 to 84 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.75 GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust, strict eager&lt;/td&gt;
&lt;td&gt;4.8 to 5.0&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;td&gt;23 to 25 s&lt;/td&gt;
&lt;td&gt;8.62 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python &lt;code&gt;torch.compile&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;33.4 to 33.5&lt;/td&gt;
&lt;td&gt;45.1 to 45.2&lt;/td&gt;
&lt;td&gt;98 to 101 s&lt;/td&gt;
&lt;td&gt;16.91 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both Rust ways match the compiled Python model on all 64 tokens and on all three long-prompt answers.&lt;/p&gt;




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

&lt;p&gt;TorchTPU (&lt;code&gt;torch_tpu&lt;/code&gt;) is a PyTorch backend for TPUs. The question here is how far a model can move out of Python and still run on it. The Rust ways run on PyTorch 2.13.0 and the Python baseline on 2.14.1. The notebook swaps versions between them, for a reason Step 3 covers.&lt;/p&gt;

&lt;p&gt;The model is Gemma 4 E2B in the W4A16 repack &lt;code&gt;xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text&lt;/code&gt;, with the per-layer table from its &lt;code&gt;-text-emb4&lt;/code&gt; twin. The Python baseline comes from notebook 05, the ring cache in &lt;a href="https://dev.to/gde/pytorch-on-tpu-hands-on-with-colab-5b5g"&gt;PyTorch on TPU: Hands On With Colab&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Versions on the Colab image: Python 3.13.18, &lt;code&gt;torch_tpu&lt;/code&gt; 0.2.0, &lt;code&gt;libtpu&lt;/code&gt; 0.0.50, Transformers 5.20.2, &lt;code&gt;compressed-tensors&lt;/code&gt; 0.19.0, &lt;code&gt;rustc&lt;/code&gt; 1.99.1. Each way runs in a fresh Python process, so none inherits another's compiled programs.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 1 — Run the Notebook
&lt;/h4&gt;

&lt;p&gt;Open &lt;a href="https://colab.research.google.com/github/xbill9/torchtpu-dev/blob/main/colab/notebooks/06_e2b_rust_on_torchtpu.ipynb" rel="noopener noreferrer"&gt;notebook 06&lt;/a&gt; in Colab, pick &lt;strong&gt;Runtime → Change runtime type → v5e-1 TPU&lt;/strong&gt;, then &lt;strong&gt;Runtime → Run all&lt;/strong&gt;. No Hugging Face token is needed. The notebook installs Rust and builds the crate on the VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Finished `release` profile [optimized] target(s) in 1m 44s
rustc 1.99.1 (58cf8f9c3 2026-10-13)
Rust installed and crate built in 122 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From a terminal, the Colab CLI runs it on a real v5e-1 and keeps the executed copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/xbill9/torchtpu-dev
&lt;span class="nb"&gt;cd &lt;/span&gt;torchtpu-dev/colab
make setup
python3 tools/verify_on_tpu.py 06_e2b_rust_on_torchtpu
&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;OK: 06_e2b_rust_on_torchtpu ran clean on v5e1 -&amp;gt; runs/2026-11-01/06_e2b_rust_on_torchtpu_output.ipynb

1/1 notebooks verified
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 2 — How Rust Reaches the Chip
&lt;/h4&gt;

&lt;p&gt;TorchTPU 0.2.0 ships no supported C++ API, and it links &lt;code&gt;libtorch_python&lt;/code&gt;, so Python has to host the process. Importing &lt;code&gt;torch_tpu&lt;/code&gt; registers TorchTPU's kernels on libtorch's dispatcher. The &lt;a href="https://github.com/LaurentMazare/tch-rs" rel="noopener noreferrer"&gt;&lt;code&gt;tch&lt;/code&gt;&lt;/a&gt; crate calls that same libtorch, loaded once in the same process, so a Rust call on a &lt;code&gt;tpu&lt;/code&gt; tensor lands on the TorchTPU kernel.&lt;/p&gt;

&lt;p&gt;The bridge is a &lt;a href="https://github.com/PyO3/pyo3" rel="noopener noreferrer"&gt;PyO3&lt;/a&gt; extension. PyO3 0.30 builds the crate as a native Python module, &lt;code&gt;torchtpu_rs&lt;/code&gt;, and turns Rust functions and structs into Python functions and classes. &lt;code&gt;tch&lt;/code&gt;'s &lt;code&gt;python-extension&lt;/code&gt; feature converts a Python &lt;code&gt;torch.Tensor&lt;/code&gt; to a &lt;code&gt;tch::Tensor&lt;/code&gt; and back, in two small helpers, &lt;code&gt;take&lt;/code&gt; and &lt;code&gt;give&lt;/code&gt;. A function unpacks a Python tensor, runs ATen calls and hands the result back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[pyfunction]&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;matmul_sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Python&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'_&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Bound&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PyAny&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PyResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Py&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PyAny&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&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;give&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="nf"&gt;.matmul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run on the chip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rust matmul then sum: 64.0 on tpu:0
built in Rust, moved: [0.0, 1.0, 2.0, 3.0, 4.0] on tpu:0
bf16 kept: torch.bfloat16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result stays on &lt;code&gt;tpu:0&lt;/code&gt;, and bf16 survives the trip.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: &lt;code&gt;tch&lt;/code&gt; Has No TPU Device
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;tch::Device&lt;/code&gt; has no &lt;code&gt;tpu&lt;/code&gt; variant. &lt;code&gt;type_as&lt;/code&gt; follows any tensor already on the chip, so the crate builds on the host and moves by example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="cd"&gt;/// Host to device without naming the device: tch has no `tpu`, but `type_as` follows the tensor.&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;like&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Tensor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="nf"&gt;.type_as&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;like&lt;/span&gt;&lt;span class="nf"&gt;.to_kind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="nf"&gt;.kind&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python passes in &lt;code&gt;torch.zeros(1, device="tpu")&lt;/code&gt; and every tensor follows it. Avoid &lt;code&gt;Tensor::device()&lt;/code&gt; on these tensors for the same reason.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Pin PyTorch to What &lt;code&gt;tch&lt;/code&gt; Generates
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;tch&lt;/code&gt; 0.26 is generated for PyTorch 2.13.0, and its bindings fail to compile on 2.14.1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'qr' is not a member of 'torch'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the crate builds against 2.13.0, which TorchTPU 0.2.0 supports, with &lt;code&gt;LIBTORCH_USE_PYTORCH=1&lt;/code&gt; pointing the build at the installed PyTorch:&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="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dependencies&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;tch&lt;/span&gt; &lt;span class="o"&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="n"&gt;version&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="s"&gt;"0.26.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;features&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="s"&gt;"python-extension"&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="n"&gt;torch-sys&lt;/span&gt; &lt;span class="o"&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="n"&gt;version&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="s"&gt;"0.26.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;features&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="s"&gt;"python-extension"&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="n"&gt;pyo3&lt;/span&gt; &lt;span class="o"&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="n"&gt;version&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="s"&gt;"0.30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;features&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="s"&gt;"extension-module"&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="n"&gt;safetensors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"0.8.0"&lt;/span&gt;
&lt;span class="n"&gt;memmap2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"0.9.12"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  Step 4 — The Model in Rust
&lt;/h4&gt;

&lt;p&gt;The crate is about 580 lines. &lt;code&gt;weights.rs&lt;/code&gt; memory-maps the safetensors files and unpacks the compressed-tensors 4-bit format to bf16. &lt;code&gt;gemma.rs&lt;/code&gt; holds the model: 35 layers, of which 15 own keys and values and 20 reuse them, the same layout as the Python ring.&lt;/p&gt;

&lt;p&gt;The ring's attention masks every slot that is empty or out of the window, so a step is the same program at every position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;attend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Tensor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="nf"&gt;.matmul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.k&lt;/span&gt;&lt;span class="nf"&gt;.tr&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.to_kind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kpos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qpos&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="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.kpos&lt;/span&gt;&lt;span class="nf"&gt;.unsqueeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="nf"&gt;.unsqueeze&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="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kpos&lt;/span&gt;&lt;span class="nf"&gt;.ge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.logical_and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;kpos&lt;/span&gt;&lt;span class="nf"&gt;.less_equal_tensor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;qpos&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.window&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="nf"&gt;.logical_and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;kpos&lt;/span&gt;&lt;span class="nf"&gt;.greater_tensor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;qpos&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="nf"&gt;.masked_fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="nf"&gt;.logical_not&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unsqueeze&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="nn"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NEG_INFINITY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// softmax, then the values&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python starts it with a directory and a tensor on the chip:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch_tpu&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torchtpu_rs&lt;/span&gt;

&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torchtpu_rs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MODEL_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EMB4_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zeros&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="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tpu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;33024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decode_s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last argument sets how often tokens come back to the host: 1 streams, 8 reads in batches of eight.&lt;/p&gt;

&lt;p&gt;PyO3 is what makes &lt;code&gt;Engine&lt;/code&gt; a Python class. The Rust model sits behind a &lt;code&gt;#[pyclass]&lt;/code&gt;, and its methods take and return plain Python lists and numbers, so token ids cross the boundary with no tensor at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[pyclass(unsendable)]&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;gemma&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Gemma&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nd"&gt;#[pymethods]&lt;/span&gt;
&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;Engine&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// new, layout and logits omitted&lt;/span&gt;
    &lt;span class="nd"&gt;#[pyo3(signature&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;(ids,&lt;/span&gt; &lt;span class="nd"&gt;max_new,&lt;/span&gt; &lt;span class="nd"&gt;read_every=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nd"&gt;))]&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_new&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_every&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;i64&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nn"&gt;tch&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;no_grad&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="na"&gt;.0&lt;/span&gt;&lt;span class="nf"&gt;.generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_every&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[pymodule]&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;torchtpu_rs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Bound&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PyModule&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PyResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// the two test functions omitted&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="py"&gt;.add_class&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;Engine&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;unsendable&lt;/code&gt; makes PyO3 refuse calls on the engine from any thread but the one that created it.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Check It Against Transformers
&lt;/h4&gt;

&lt;p&gt;Before any TPU time, the Rust engine runs on CPU beside Hugging Face Transformers, on PyTorch 2.13.0 with the real checkpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;load 6.8 (3, 12, 20, 1024)
top5 rust [818, 102500, 4420, 16520, 18318] [25.75, 18.875, 18.75, 18.5, 18.125]
top5 hf   [818, 102500, 4420, 16520, 18318] [25.625, 18.75, 18.625, 18.25, 18.0]
max abs diff 0.375 KL(hf||rs) 1.8714923498919234e-05
same 64 of 64
first diff None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The top five candidates are the same, the KL divergence is 1.9e-5, and all 64 tokens match. The layout line reads 3 full-attention rings, 12 sliding rings and 20 shared layers.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — The Three Ways on the Chip
&lt;/h4&gt;

&lt;p&gt;Final run of notebook 06:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;way                       load s  first s  graphs  breaks  streamed  in batches  peak GB
Rust fused, torch 2.13      23.1     83.5       -       -      15.1        15.2     7.75
Rust eager, torch 2.13      23.8     22.8       -       -       4.8         4.8     8.62
Python compile, 2.14.1      25.9    101.1       2       0      33.5        45.1    16.91
Rust fused, torch 2.13: first 64 of 64 tokens identical to Python compile on 2.14.1
Rust eager, torch 2.13: first 64 of 64 tokens identical to Python compile on 2.14.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Defer-and-fuse is the fast mode for Rust.&lt;/strong&gt; &lt;code&gt;torch.compile&lt;/code&gt; traces Python, so it cannot see Rust. &lt;code&gt;TORCH_TPU_DEFER_AND_FUSE=1&lt;/code&gt; works below ATen and needs no change to the crate. It takes Rust from 4.8 to 15.1 tokens per second streamed, about 3.2x, at the same tokens. In this run Rust fused streams at 0.45x the compiled Python step and batched Python reaches 2.97x Rust (arithmetic, &lt;code&gt;evidence/arithmetic.txt&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Compiling costs Python more memory: its 16.91 GB peak against 7.75 GB for Rust fused is 2.18x, close to the chip's 16 GiB.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Don't Run &lt;code&gt;torch.compile&lt;/code&gt; on 2.13 For This Model
&lt;/h4&gt;

&lt;p&gt;The compiled Python ring needs 2.14.1. On 2.13.0 the same source behaves differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Python compile, 2.13        25.6     34.6      65      64      15.9        16.2    11.26
Python compile, 2.13: first 21 of 64 tokens identical to Python compile on 2.14.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It splits into 65 programs with 64 graph breaks and writes a different reply from token 21. Changing Transformers and &lt;code&gt;compressed-tensors&lt;/code&gt; versions made no difference, and changing the PyTorch version did. Its cause is untested. The Rust ways on 2.13.0 are unaffected and match 2.14.1.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — Long Prompts
&lt;/h4&gt;

&lt;p&gt;Notebook 05's prompts fill the context with numbered records, each with a random code, and ask about one record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;way                       prompt  read s read tok/s  decode   answer  found
Rust fused, torch 2.13        61     0.1        776    14.6   915965    yes
Rust fused, torch 2.13      4081     1.0       4172    14.8   610395    yes
Rust fused, torch 2.13     32761     8.0       4116    15.6   319550     no
Rust eager, torch 2.13     32761    28.5       1148     4.8   319550     no
Python compile, 2.14.1     32761     5.0       6555    45.1   319550     no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rust fused reads the 32,761-token prompt in 7.6 to 8.0 s, the compiled ring in 5.0 s and Rust eager in about 29 s. All three ways give the same three answers. The third is wrong in every way, the same code the Python ring gives, so the limit belongs to the model at this depth. Decode speed holds steady at every depth, because each step attends over every slot of a cache of the same size.&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;One v5e chip, Gemma 4 E2B&lt;/th&gt;
&lt;th&gt;Streamed tok/s&lt;/th&gt;
&lt;th&gt;Model code&lt;/th&gt;
&lt;th&gt;PyTorch&lt;/th&gt;
&lt;th&gt;Peak memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python &lt;code&gt;torch.compile&lt;/code&gt; 🥇&lt;/td&gt;
&lt;td&gt;33.4 to 33.5&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;2.14.1&lt;/td&gt;
&lt;td&gt;16.91 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust, defer-and-fuse 🥈&lt;/td&gt;
&lt;td&gt;14.7 to 15.7&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;2.13.0&lt;/td&gt;
&lt;td&gt;7.75 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust, strict eager 🥉&lt;/td&gt;
&lt;td&gt;4.8 to 5.0&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;2.13.0&lt;/td&gt;
&lt;td&gt;8.62 GB&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 speed on this chip, stay with the compiled Python ring on PyTorch 2.14.1: 45 tokens per second in batches and 33 streamed.&lt;/p&gt;

&lt;p&gt;For a model in Rust, set &lt;code&gt;TORCH_TPU_DEFER_AND_FUSE=1&lt;/code&gt;: about 15 tokens per second streamed, the same tokens and answers, and less than half the peak memory. TorchTPU marks that variable experimental, so it may change.&lt;/p&gt;

&lt;p&gt;Keep strict eager for debugging, at about 5 tokens per second.&lt;/p&gt;




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

&lt;p&gt;The goal of this article was to run Gemma 4 E2B on a TPU with the model written in Rust, through TorchTPU and without JAX. The key to the solution was &lt;code&gt;tch&lt;/code&gt; sharing the one libtorch that TorchTPU registers its kernels with, so a Rust ATen call on a &lt;code&gt;tpu&lt;/code&gt; tensor reaches a TorchTPU kernel, with PyO3 exposing the Rust engine to Python as a module. The Colab v5e-1 results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 Rust matches compiled Python on 64 of 64 tokens and 3 of 3 long-prompt answers&lt;/li&gt;
&lt;li&gt;🟢 Rust matches Transformers on CPU on 64 of 64 tokens, with a KL divergence of 1.9e-5&lt;/li&gt;
&lt;li&gt;🟢 One environment variable takes Rust from about 5 to about 15 tokens per second streamed&lt;/li&gt;
&lt;li&gt;🟢 Rust peaks at 7.75 GB against 16.91 GB for compile&lt;/li&gt;
&lt;li&gt;⚠️ Rust fused streams at 0.45x compiled Python, because &lt;code&gt;torch.compile&lt;/code&gt; cannot trace Rust&lt;/li&gt;
&lt;li&gt;⚠️ &lt;code&gt;tch&lt;/code&gt; 0.26 builds against PyTorch 2.13.0 only&lt;/li&gt;
&lt;li&gt;⚠️ Python still hosts the process, builds the prompts and tokenizes&lt;/li&gt;
&lt;li&gt;❌ &lt;code&gt;torch.compile&lt;/code&gt; on PyTorch 2.13.0 splits this model into 65 programs and changes the reply&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: Gemma 4 E2B-it QAT only, text only, one Colab v5e-1 runtime per run on 2026-11-01, greedy decoding, one sequence at a time, four prompts, a 33,024-token context. Rust ranges cover three runs and compiled Python ranges two. The earliest Rust run used Transformers 5.21.0 and &lt;code&gt;compressed-tensors&lt;/code&gt; 0.20.1, and the rest 5.20.2 and 0.19.0. The Rust ways ran on PyTorch 2.13.0 and the Python baseline on 2.14.1, one version apart. The 131,328-token context, a serving endpoint, a Rust Colab kernel, and where the time goes between Rust fused and compile were not run. The final crate formatting commit was built and checked on CPU, and the TPU runs used the source before it.&lt;/p&gt;

&lt;p&gt;The strategy for running a Rust model on TorchTPU 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;Rust crate: &lt;a href="https://github.com/xbill9/torchtpu-dev/tree/main/colab/rust" rel="noopener noreferrer"&gt;https://github.com/xbill9/torchtpu-dev/tree/main/colab/rust&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Notebook 06: &lt;a href="https://github.com/xbill9/torchtpu-dev/blob/main/colab/notebooks/06_e2b_rust_on_torchtpu.ipynb" rel="noopener noreferrer"&gt;https://github.com/xbill9/torchtpu-dev/blob/main/colab/notebooks/06_e2b_rust_on_torchtpu.ipynb&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Executed run: &lt;a href="https://github.com/xbill9/torchtpu-dev/tree/main/colab/runs/2026-11-01" rel="noopener noreferrer"&gt;https://github.com/xbill9/torchtpu-dev/tree/main/colab/runs/2026-11-01&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyTorch on TPU: Hands On With Colab: &lt;a href="https://dev.to/gde/pytorch-on-tpu-hands-on-with-colab-5b5g"&gt;https://dev.to/gde/pytorch-on-tpu-hands-on-with-colab-5b5g&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;tch: &lt;a href="https://github.com/LaurentMazare/tch-rs" rel="noopener noreferrer"&gt;https://github.com/LaurentMazare/tch-rs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyO3: &lt;a href="https://github.com/PyO3/pyo3" rel="noopener noreferrer"&gt;https://github.com/PyO3/pyo3&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;W4A16 repack: &lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text" rel="noopener noreferrer"&gt;https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;4-bit-tables repack: &lt;a href="https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4" rel="noopener noreferrer"&gt;https://huggingface.co/xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Colab CLI: &lt;a href="https://github.com/googlecolab/google-colab-cli" rel="noopener noreferrer"&gt;https://github.com/googlecolab/google-colab-cli&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rust</category>
      <category>pytorch</category>
      <category>tpu</category>
      <category>gemma</category>
    </item>
    <item>
      <title>Profile Sprawl: Solving a Problem You Didn't Know You Had</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Sun, 01 Nov 2026 14:35:31 +0000</pubDate>
      <link>https://dev.to/aws-builders/profile-sprawl-solving-a-problem-you-didnt-know-you-had-3gi9</link>
      <guid>https://dev.to/aws-builders/profile-sprawl-solving-a-problem-you-didnt-know-you-had-3gi9</guid>
      <description>&lt;p&gt;This article provides a step by step guide to keeping a public web presence consistent across many sites. An agent skill for Claude Code is built that treats a bio as source code: one file holds the facts, one table records where it has landed, and scripts read the live sites to confirm it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Every profile you own is a copy of your bio, and copies drift. The fix is the one software already uses: a single source, a status table and a check that reads production.&lt;/strong&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  What Is Profile Sprawl?
&lt;/h4&gt;

&lt;p&gt;A working developer collects profiles. LinkedIn, GitHub, Medium, dev.to, Substack, a certification wallet, a community portal, a program dashboard. Each one was accurate on the day you filled it in.&lt;/p&gt;

&lt;p&gt;Profile sprawl is what happens next. A new role or credential goes on the site you happened to have open, and the rest keep the old text. No site reports an error, because each one is internally fine. The inconsistency exists only across sites, and nobody reads them side by side except a recruiter, a conference organizer or a reviewer.&lt;/p&gt;




&lt;h4&gt;
  
  
  How Bad Could It Be?
&lt;/h4&gt;

&lt;p&gt;One audit of ten profiles belonging to one person, read on 2026-10-31, the day after a new certification was issued:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Site&lt;/th&gt;
&lt;th&gt;Certifications&lt;/th&gt;
&lt;th&gt;Years of experience&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn headline&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn About&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;over 30+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;20+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Developers profile&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;20+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credly&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;20+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Advocu&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same LinkedIn profile disagreed with itself. The headline said 10 and the About said 9, and each line looked fine alone.&lt;/p&gt;

&lt;p&gt;The other four sites had a different problem. AWS Builder Center listed 0 credentials, dev.to held a tagline, GitHub held an unexplained string, and Substack was empty apart from the platform's default text.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;A list of your public profiles and a browser where you are logged in to them&lt;/li&gt;
&lt;li&gt;Python 3, with no extra packages&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://claude.ai/code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; with the Chrome extension, or the patience to run the same steps by hand&lt;/li&gt;
&lt;li&gt;A git repository, which can be public as long as it holds public text only&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 1 — Write One Source File
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;bio.md&lt;/code&gt; holds everything a site could need, in plain Markdown with no site-specific syntax. Facts are lists. Text comes in lengths, written from those facts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-10-31&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Bio&lt;/span&gt;

&lt;span class="gu"&gt;## Headline&lt;/span&gt;
&lt;span class="gu"&gt;## Tagline&lt;/span&gt;
&lt;span class="gu"&gt;## Short&lt;/span&gt;
&lt;span class="gu"&gt;## Summary&lt;/span&gt;
&lt;span class="gu"&gt;## Long&lt;/span&gt;
&lt;span class="gu"&gt;## Roles&lt;/span&gt;
&lt;span class="gu"&gt;## Community&lt;/span&gt;
&lt;span class="gu"&gt;## Certifications&lt;/span&gt;
&lt;span class="gu"&gt;## Education&lt;/span&gt;
&lt;span class="gu"&gt;## Links&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lengths cover every form measured here: Short at 133 characters, Summary at 561 and Long at 735. A site gets its own text only when a limit forces it, and even then the shorter text becomes a section of the file. Per-site copies are how sprawl starts.&lt;/p&gt;

&lt;p&gt;The rule that keeps this working: a change starts in &lt;code&gt;bio.md&lt;/code&gt;, never on a site. If a site was edited directly, that change is copied back first.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Track Where It Has Landed
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;tracker.md&lt;/code&gt; is one table with one row per site. Status takes one of four values.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&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;Current&lt;/td&gt;
&lt;td&gt;Synced version matches &lt;code&gt;bio.md&lt;/code&gt; and the live profile was checked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Behind&lt;/td&gt;
&lt;td&gt;The site has an older version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unverified&lt;/td&gt;
&lt;td&gt;Nobody has compared this site to &lt;code&gt;bio.md&lt;/code&gt; yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blocked&lt;/td&gt;
&lt;td&gt;Something outside your control stops the update, explained in Notes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;strong&gt;Text used&lt;/strong&gt; column records which sections a site takes. A change to Long reaches only the sites that use Long, and every other row moves to the new version with no edit.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Make the Computer Do the Counting
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;preflight.py&lt;/code&gt; compares the two files and exits 1 on any disagreement. It counts the &lt;code&gt;Certifications&lt;/code&gt; list and compares that count with every claim in the text, so a number in a headline can never drift from the list it summarizes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 .claude/skills/profile-sync/scripts/preflight.py
bio.md 2026-10-31, 10 sites: {'Behind': 0, 'Blocked': 1, 'Current': 9, 'Unverified': 0}
preflight OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also fails on a text longer than a site's form allows, a &lt;code&gt;Current&lt;/code&gt; row with no check date, and a status outside the four values. The repository is public, so it scans both files for email addresses and phone numbers too.&lt;/p&gt;

&lt;p&gt;Arithmetic belongs in code. A model or a person can miscount a list. A script compares 10 with 10.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Read the Live Sites
&lt;/h4&gt;

&lt;p&gt;A tracker row is a claim. &lt;code&gt;check-live.py&lt;/code&gt; fetches what a visitor gets from each site with a public source and compares it with &lt;code&gt;bio.md&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;$ python3 .claude/skills/profile-sync/scripts/check-live.py
  SKIP  LinkedIn: browser check
  OK    GitHub: live text matches Short
  SKIP  Medium: browser check
  OK    dev.to: live text matches Short
  OK    Substack: live text matches Short
  SKIP  AWS Builder Center: browser check
  SKIP  Google Developers profile: browser check
  SKIP  Google Cloud Skills Boost: browser check
  SKIP  Credly: browser check
  SKIP  Advocu: browser check
check-live OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A site marked &lt;code&gt;Current&lt;/code&gt; that differs fails the run. A check that can only pass proves nothing, so the same script was run against a stale value to confirm it exits 1.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Measure Every Form
&lt;/h4&gt;

&lt;p&gt;Each site has its own field, limit and save button. These are read from the forms themselves, and they live in one reference file so the next run starts with them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Site&lt;/th&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Short bio&lt;/td&gt;
&lt;td&gt;160&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dev.to&lt;/td&gt;
&lt;td&gt;Bio&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Builder Center&lt;/td&gt;
&lt;td&gt;About&lt;/td&gt;
&lt;td&gt;255&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credly&lt;/td&gt;
&lt;td&gt;Personal Summary&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Developers profile&lt;/td&gt;
&lt;td&gt;Bio&lt;/td&gt;
&lt;td&gt;750&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn&lt;/td&gt;
&lt;td&gt;About&lt;/td&gt;
&lt;td&gt;2,600&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A property of one site is evidence about that site only. The Long text at 735 characters fits 2 of these fields and fails the other 4, so the limits go in &lt;code&gt;biolib.py&lt;/code&gt; and &lt;code&gt;preflight.py&lt;/code&gt; enforces them before any browser opens.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Reading and Writing Are Different Routes
&lt;/h4&gt;

&lt;p&gt;dev.to has a good API, and a key reads the profile with no trouble. Profile fields are read-only there: &lt;code&gt;PUT&lt;/code&gt; and &lt;code&gt;PATCH&lt;/code&gt; on &lt;code&gt;/api/users/me&lt;/code&gt; return 404, so the bio is saved through the settings page. Test both directions before planning a rollout around an API.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — Compare Before Saving
&lt;/h4&gt;

&lt;p&gt;Browser automation can drop a character while reporting success, and a page can show a success message for the wrong text. So each save follows the same order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scroll to the field and confirm it has focus.&lt;/li&gt;
&lt;li&gt;Select the old text and type the new text.&lt;/li&gt;
&lt;li&gt;Compare the field with the exact payload from &lt;code&gt;bio.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Read the position of the save button after scrolling.&lt;/li&gt;
&lt;li&gt;Save, reload and compare the live text.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;payload.py&lt;/code&gt; supplies step 3. It prints the text for a site, or a small JavaScript function that compares a field with it. The text travels base64-encoded, which survives quotes, emoji and line breaks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 .claude/skills/profile-sync/scripts/payload.py Credly &amp;gt; /dev/null
Summary: 561 characters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  🔎 Tip: Decline the Announcement
&lt;/h4&gt;

&lt;p&gt;Adding a certification on LinkedIn opens a prompt to post about it to your network. Updating a profile and announcing to it are separate decisions. The skill clicks Skip unless an announcement was requested.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — Package It as a Skill
&lt;/h4&gt;

&lt;p&gt;The steps above become an agent skill, in the shape of &lt;a href="https://github.com/xbill9/publishing-kit" rel="noopener noreferrer"&gt;publishing-kit&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.claude/skills/profile-sync/
  SKILL.md               the workflow
  references/sites.md    route, field, limit and save button per site
  scripts/biolib.py      reads bio.md and tracker.md
  scripts/preflight.py   the two files agree
  scripts/check-live.py  the live sites agree
  scripts/payload.py     the exact text for a field
tests/test_preflight.py  one case per failure, with a passing case beside it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests are the guard on the guards. Each failure gets a case that must fail and a case that must pass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 -m unittest discover -s tests
Ran 18 tests in 0.004s

OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The passing cases carry weight too. One holds a date, so a phone number pattern that matches &lt;code&gt;2026-10-31&lt;/code&gt; fails the suite before it can fail every row in the tracker.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — Roll Out One Change
&lt;/h4&gt;

&lt;p&gt;The certification that started this run shows the whole loop.&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="c"&gt;# 1. Edit bio.md, set version to today&lt;/span&gt;
&lt;span class="c"&gt;# 2. Confirm the files agree&lt;/span&gt;
python3 .claude/skills/profile-sync/scripts/preflight.py
&lt;span class="c"&gt;# 3. In Claude Code, run /profile-sync to roll it out&lt;/span&gt;
&lt;span class="c"&gt;# 4. Confirm the live sites agree&lt;/span&gt;
python3 .claude/skills/profile-sync/scripts/check-live.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The skill works down the tracker, saves each field in Chrome in the logged-in session, reloads each page, records the check date, then commits and pushes. Credentials stay in the browser, so the repository holds public text only.&lt;/p&gt;

&lt;p&gt;Before starting, confirm the fact at its source. Here that source was the Credly badge, issued 2026-10-30, which settled whether 10 or 9 was correct.&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;By hand&lt;/th&gt;
&lt;th&gt;A notes file&lt;/th&gt;
&lt;th&gt;profile-sync&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Source of truth&lt;/td&gt;
&lt;td&gt;Whichever site is newest&lt;/td&gt;
&lt;td&gt;A notes file&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bio.md&lt;/code&gt; in git&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knows which sites are behind&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;If you update it&lt;/td&gt;
&lt;td&gt;🥇 Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catches a wrong count&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;Reads the live site&lt;/td&gt;
&lt;td&gt;When you look&lt;/td&gt;
&lt;td&gt;When you look&lt;/td&gt;
&lt;td&gt;🥇 3 sites by script, the rest by browser&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;Start with Step 1 and Step 2, even with no automation. The first side-by-side read will show you what your profiles have been saying.&lt;/p&gt;

&lt;p&gt;Add the skill once you hold more profiles than you can check in one sitting. That number is small, and a new credential is a good day to start.&lt;/p&gt;




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

&lt;p&gt;The goal of this article was to keep one person's public profiles consistent across many sites. The key to the solution was treating a bio as source code, with one source file, a status table and a check that reads the live sites. The profile-sync results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 9 of the 10 sites are &lt;code&gt;Current&lt;/code&gt;, each confirmed on its live page&lt;/li&gt;
&lt;li&gt;🟢 The certification count is computed from a list, so the headline and the About can no longer disagree&lt;/li&gt;
&lt;li&gt;🟢 18 tests keep each failure beside a passing case&lt;/li&gt;
&lt;li&gt;❌ 5 sites said 9 certifications the day after a tenth was issued, and 3 said 20+ years&lt;/li&gt;
&lt;li&gt;⚠️ Google Skills stays &lt;code&gt;Blocked&lt;/code&gt;: it has no bio field and its public profile page returns an error&lt;/li&gt;
&lt;li&gt;⚠️ &lt;code&gt;check-live.py&lt;/code&gt; reads 3 sites and only the Short text, so the other sites still need a browser check&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one person's ten profiles on 2026-10-31, in one Chrome session. Form limits were read from each site's own form on that date and can change. Two edits on the same day share one &lt;code&gt;version&lt;/code&gt;, so version equality alone does not show that a site has the latest text, and the live comparison covers that gap.&lt;/p&gt;

&lt;p&gt;The strategy for keeping a public profile consistent 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/profile-sync" rel="noopener noreferrer"&gt;profile-sync&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/profile-sync/blob/main/.claude/skills/profile-sync/SKILL.md" rel="noopener noreferrer"&gt;The skill's own instructions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/profile-sync/tree/main/articles/profile-sprawl" rel="noopener noreferrer"&gt;Evidence for this article&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/publishing-kit" rel="noopener noreferrer"&gt;publishing-kit&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>devtools</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Profile Sprawl: Solving a Problem You Didn't Know You Had</title>
      <dc:creator>xbill</dc:creator>
      <pubDate>Sun, 01 Nov 2026 14:35:26 +0000</pubDate>
      <link>https://dev.to/gde/profile-sprawl-solving-a-problem-you-didnt-know-you-had-19k5</link>
      <guid>https://dev.to/gde/profile-sprawl-solving-a-problem-you-didnt-know-you-had-19k5</guid>
      <description>&lt;p&gt;This article provides a step by step guide to keeping a public web presence consistent across many sites. An agent skill for Claude Code is built that treats a bio as source code: one file holds the facts, one table records where it has landed, and scripts read the live sites to confirm it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Every profile you own is a copy of your bio, and copies drift. The fix is the one software already uses: a single source, a status table and a check that reads production.&lt;/strong&gt;&lt;/p&gt;




&lt;h4&gt;
  
  
  What Is Profile Sprawl?
&lt;/h4&gt;

&lt;p&gt;A working developer collects profiles. LinkedIn, GitHub, Medium, dev.to, Substack, a certification wallet, a community portal, a program dashboard. Each one was accurate on the day you filled it in.&lt;/p&gt;

&lt;p&gt;Profile sprawl is what happens next. A new role or credential goes on the site you happened to have open, and the rest keep the old text. No site reports an error, because each one is internally fine. The inconsistency exists only across sites, and nobody reads them side by side except a recruiter, a conference organizer or a reviewer.&lt;/p&gt;




&lt;h4&gt;
  
  
  How Bad Could It Be?
&lt;/h4&gt;

&lt;p&gt;One audit of ten profiles belonging to one person, read on 2026-10-31, the day after a new certification was issued:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Site&lt;/th&gt;
&lt;th&gt;Certifications&lt;/th&gt;
&lt;th&gt;Years of experience&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn headline&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn About&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;over 30+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;20+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Developers profile&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;20+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credly&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;20+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Advocu&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same LinkedIn profile disagreed with itself. The headline said 10 and the About said 9, and each line looked fine alone.&lt;/p&gt;

&lt;p&gt;The other four sites had a different problem. AWS Builder Center listed 0 credentials, dev.to held a tagline, GitHub held an unexplained string, and Substack was empty apart from the platform's default text.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;A list of your public profiles and a browser where you are logged in to them&lt;/li&gt;
&lt;li&gt;Python 3, with no extra packages&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://claude.ai/code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; with the Chrome extension, or the patience to run the same steps by hand&lt;/li&gt;
&lt;li&gt;A git repository, which can be public as long as it holds public text only&lt;/li&gt;
&lt;/ul&gt;




&lt;h4&gt;
  
  
  Step 1 — Write One Source File
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;bio.md&lt;/code&gt; holds everything a site could need, in plain Markdown with no site-specific syntax. Facts are lists. Text comes in lengths, written from those facts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-10-31&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Bio&lt;/span&gt;

&lt;span class="gu"&gt;## Headline&lt;/span&gt;
&lt;span class="gu"&gt;## Tagline&lt;/span&gt;
&lt;span class="gu"&gt;## Short&lt;/span&gt;
&lt;span class="gu"&gt;## Summary&lt;/span&gt;
&lt;span class="gu"&gt;## Long&lt;/span&gt;
&lt;span class="gu"&gt;## Roles&lt;/span&gt;
&lt;span class="gu"&gt;## Community&lt;/span&gt;
&lt;span class="gu"&gt;## Certifications&lt;/span&gt;
&lt;span class="gu"&gt;## Education&lt;/span&gt;
&lt;span class="gu"&gt;## Links&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lengths cover every form measured here: Short at 133 characters, Summary at 561 and Long at 735. A site gets its own text only when a limit forces it, and even then the shorter text becomes a section of the file. Per-site copies are how sprawl starts.&lt;/p&gt;

&lt;p&gt;The rule that keeps this working: a change starts in &lt;code&gt;bio.md&lt;/code&gt;, never on a site. If a site was edited directly, that change is copied back first.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 2 — Track Where It Has Landed
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;tracker.md&lt;/code&gt; is one table with one row per site. Status takes one of four values.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&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;Current&lt;/td&gt;
&lt;td&gt;Synced version matches &lt;code&gt;bio.md&lt;/code&gt; and the live profile was checked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Behind&lt;/td&gt;
&lt;td&gt;The site has an older version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unverified&lt;/td&gt;
&lt;td&gt;Nobody has compared this site to &lt;code&gt;bio.md&lt;/code&gt; yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blocked&lt;/td&gt;
&lt;td&gt;Something outside your control stops the update, explained in Notes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;strong&gt;Text used&lt;/strong&gt; column records which sections a site takes. A change to Long reaches only the sites that use Long, and every other row moves to the new version with no edit.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 3 — Make the Computer Do the Counting
&lt;/h4&gt;

&lt;p&gt;&lt;code&gt;preflight.py&lt;/code&gt; compares the two files and exits 1 on any disagreement. It counts the &lt;code&gt;Certifications&lt;/code&gt; list and compares that count with every claim in the text, so a number in a headline can never drift from the list it summarizes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 .claude/skills/profile-sync/scripts/preflight.py
bio.md 2026-10-31, 10 sites: {'Behind': 0, 'Blocked': 1, 'Current': 9, 'Unverified': 0}
preflight OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also fails on a text longer than a site's form allows, a &lt;code&gt;Current&lt;/code&gt; row with no check date, and a status outside the four values. The repository is public, so it scans both files for email addresses and phone numbers too.&lt;/p&gt;

&lt;p&gt;Arithmetic belongs in code. A model or a person can miscount a list. A script compares 10 with 10.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 4 — Read the Live Sites
&lt;/h4&gt;

&lt;p&gt;A tracker row is a claim. &lt;code&gt;check-live.py&lt;/code&gt; fetches what a visitor gets from each site with a public source and compares it with &lt;code&gt;bio.md&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;$ python3 .claude/skills/profile-sync/scripts/check-live.py
  SKIP  LinkedIn: browser check
  OK    GitHub: live text matches Short
  SKIP  Medium: browser check
  OK    dev.to: live text matches Short
  OK    Substack: live text matches Short
  SKIP  AWS Builder Center: browser check
  SKIP  Google Developers profile: browser check
  SKIP  Google Cloud Skills Boost: browser check
  SKIP  Credly: browser check
  SKIP  Advocu: browser check
check-live OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A site marked &lt;code&gt;Current&lt;/code&gt; that differs fails the run. A check that can only pass proves nothing, so the same script was run against a stale value to confirm it exits 1.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 5 — Measure Every Form
&lt;/h4&gt;

&lt;p&gt;Each site has its own field, limit and save button. These are read from the forms themselves, and they live in one reference file so the next run starts with them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Site&lt;/th&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Short bio&lt;/td&gt;
&lt;td&gt;160&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dev.to&lt;/td&gt;
&lt;td&gt;Bio&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Builder Center&lt;/td&gt;
&lt;td&gt;About&lt;/td&gt;
&lt;td&gt;255&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credly&lt;/td&gt;
&lt;td&gt;Personal Summary&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Developers profile&lt;/td&gt;
&lt;td&gt;Bio&lt;/td&gt;
&lt;td&gt;750&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn&lt;/td&gt;
&lt;td&gt;About&lt;/td&gt;
&lt;td&gt;2,600&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A property of one site is evidence about that site only. The Long text at 735 characters fits 2 of these fields and fails the other 4, so the limits go in &lt;code&gt;biolib.py&lt;/code&gt; and &lt;code&gt;preflight.py&lt;/code&gt; enforces them before any browser opens.&lt;/p&gt;




&lt;h4&gt;
  
  
  🔎 Tip: Reading and Writing Are Different Routes
&lt;/h4&gt;

&lt;p&gt;dev.to has a good API, and a key reads the profile with no trouble. Profile fields are read-only there: &lt;code&gt;PUT&lt;/code&gt; and &lt;code&gt;PATCH&lt;/code&gt; on &lt;code&gt;/api/users/me&lt;/code&gt; return 404, so the bio is saved through the settings page. Test both directions before planning a rollout around an API.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 6 — Compare Before Saving
&lt;/h4&gt;

&lt;p&gt;Browser automation can drop a character while reporting success, and a page can show a success message for the wrong text. So each save follows the same order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scroll to the field and confirm it has focus.&lt;/li&gt;
&lt;li&gt;Select the old text and type the new text.&lt;/li&gt;
&lt;li&gt;Compare the field with the exact payload from &lt;code&gt;bio.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Read the position of the save button after scrolling.&lt;/li&gt;
&lt;li&gt;Save, reload and compare the live text.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;payload.py&lt;/code&gt; supplies step 3. It prints the text for a site, or a small JavaScript function that compares a field with it. The text travels base64-encoded, which survives quotes, emoji and line breaks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 .claude/skills/profile-sync/scripts/payload.py Credly &amp;gt; /dev/null
Summary: 561 characters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h4&gt;
  
  
  🔎 Tip: Decline the Announcement
&lt;/h4&gt;

&lt;p&gt;Adding a certification on LinkedIn opens a prompt to post about it to your network. Updating a profile and announcing to it are separate decisions. The skill clicks Skip unless an announcement was requested.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 7 — Package It as a Skill
&lt;/h4&gt;

&lt;p&gt;The steps above become an agent skill, in the shape of &lt;a href="https://github.com/xbill9/publishing-kit" rel="noopener noreferrer"&gt;publishing-kit&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.claude/skills/profile-sync/
  SKILL.md               the workflow
  references/sites.md    route, field, limit and save button per site
  scripts/biolib.py      reads bio.md and tracker.md
  scripts/preflight.py   the two files agree
  scripts/check-live.py  the live sites agree
  scripts/payload.py     the exact text for a field
tests/test_preflight.py  one case per failure, with a passing case beside it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests are the guard on the guards. Each failure gets a case that must fail and a case that must pass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python3 -m unittest discover -s tests
Ran 18 tests in 0.004s

OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The passing cases carry weight too. One holds a date, so a phone number pattern that matches &lt;code&gt;2026-10-31&lt;/code&gt; fails the suite before it can fail every row in the tracker.&lt;/p&gt;




&lt;h4&gt;
  
  
  Step 8 — Roll Out One Change
&lt;/h4&gt;

&lt;p&gt;The certification that started this run shows the whole loop.&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="c"&gt;# 1. Edit bio.md, set version to today&lt;/span&gt;
&lt;span class="c"&gt;# 2. Confirm the files agree&lt;/span&gt;
python3 .claude/skills/profile-sync/scripts/preflight.py
&lt;span class="c"&gt;# 3. In Claude Code, run /profile-sync to roll it out&lt;/span&gt;
&lt;span class="c"&gt;# 4. Confirm the live sites agree&lt;/span&gt;
python3 .claude/skills/profile-sync/scripts/check-live.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The skill works down the tracker, saves each field in Chrome in the logged-in session, reloads each page, records the check date, then commits and pushes. Credentials stay in the browser, so the repository holds public text only.&lt;/p&gt;

&lt;p&gt;Before starting, confirm the fact at its source. Here that source was the Credly badge, issued 2026-10-30, which settled whether 10 or 9 was correct.&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;By hand&lt;/th&gt;
&lt;th&gt;A notes file&lt;/th&gt;
&lt;th&gt;profile-sync&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Source of truth&lt;/td&gt;
&lt;td&gt;Whichever site is newest&lt;/td&gt;
&lt;td&gt;A notes file&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bio.md&lt;/code&gt; in git&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knows which sites are behind&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;If you update it&lt;/td&gt;
&lt;td&gt;🥇 Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catches a wrong count&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;Reads the live site&lt;/td&gt;
&lt;td&gt;When you look&lt;/td&gt;
&lt;td&gt;When you look&lt;/td&gt;
&lt;td&gt;🥇 3 sites by script, the rest by browser&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;Start with Step 1 and Step 2, even with no automation. The first side-by-side read will show you what your profiles have been saying.&lt;/p&gt;

&lt;p&gt;Add the skill once you hold more profiles than you can check in one sitting. That number is small, and a new credential is a good day to start.&lt;/p&gt;




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

&lt;p&gt;The goal of this article was to keep one person's public profiles consistent across many sites. The key to the solution was treating a bio as source code, with one source file, a status table and a check that reads the live sites. The profile-sync results were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 9 of the 10 sites are &lt;code&gt;Current&lt;/code&gt;, each confirmed on its live page&lt;/li&gt;
&lt;li&gt;🟢 The certification count is computed from a list, so the headline and the About can no longer disagree&lt;/li&gt;
&lt;li&gt;🟢 18 tests keep each failure beside a passing case&lt;/li&gt;
&lt;li&gt;❌ 5 sites said 9 certifications the day after a tenth was issued, and 3 said 20+ years&lt;/li&gt;
&lt;li&gt;⚠️ Google Skills stays &lt;code&gt;Blocked&lt;/code&gt;: it has no bio field and its public profile page returns an error&lt;/li&gt;
&lt;li&gt;⚠️ &lt;code&gt;check-live.py&lt;/code&gt; reads 3 sites and only the Short text, so the other sites still need a browser check&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scope: one person's ten profiles on 2026-10-31, in one Chrome session. Form limits were read from each site's own form on that date and can change. Two edits on the same day share one &lt;code&gt;version&lt;/code&gt;, so version equality alone does not show that a site has the latest text, and the live comparison covers that gap.&lt;/p&gt;

&lt;p&gt;The strategy for keeping a public profile consistent 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/profile-sync" rel="noopener noreferrer"&gt;profile-sync&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/profile-sync/blob/main/.claude/skills/profile-sync/SKILL.md" rel="noopener noreferrer"&gt;The skill's own instructions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/profile-sync/tree/main/articles/profile-sprawl" rel="noopener noreferrer"&gt;Evidence for this article&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/xbill9/publishing-kit" rel="noopener noreferrer"&gt;publishing-kit&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>devtools</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
