Measured 2026-07-21 on vllm/vllm-tpu:nightly (vLLM 0.23.1rc1.dev1076), a GCE flex-start ct6e-standard-1t (one TPU v6e chip, 32 GB HBM) in europe-west4-a.
The plain google/gemma-4-E2B-it serves well on one v6e chip; none of its QAT siblings load at all. The 2-billion-parameter "efficient" Gemma 4 sustains 213 tok/s for a single user with a 16 ms first token, scales to ~2,200 output tok/s across concurrent streams, handles OpenAI-style function calling β including parallel calls and refusal to hallucinate calls β without a miss, and answers simple vision questions accurately in ~200 ms.
The QAT variants are a different story: the int4 compressed-tensors export hits an unimplemented quantization path, and the bf16 QAT export trips a bug β the Gemma 4 implementation demands per-layer norms that E2B's KV-sharing architecture legitimately doesn't have. Filed upstream as tpu-inference #3225.
One capability coupling to know about: with a reasoning parser configured, schema enforcement only engages when thinking is enabled β thinking-off requests sail through unconstrained with a 200 status. Config interaction, not TPU limitation; details below.
The host is a GCE flex-start VM β capacity granted on request, billed until deleted, hard-stopped at a 4-hour max run, $1.35/chip-hour. A startup script installs Docker, pulls vllm/vllm-tpu:nightly
, fetches the Hugging Face token from Secret Manager via the metadata server, and launches vLLM.
Boot timeline: VM RUNNING at t+0 (200 GB boot disk β the 10 GB default cannot hold the vLLM image) β Docker installed ~t+1:00 β image pulled ~t+6:00 β weights downloaded, XLA compiled, health green ~t+8:30.
Two environment quirks worth knowing:
gcloud compute ssh --tunnel-through-iap
) rides over HTTPS and works; so does tunneling the API port with gcloud compute start-iap-tunnel <vm> 8000
.Serving flags: --max-model-len 65536 --gpu-memory-utilization 0.9 --max_num_batched_tokens 4096 --enable-auto-tool-choice --tool-call-parser gemma4 --reasoning-parser gemma4
, bf16 weights, tensor-parallel 1.
| Checkpoint / path | Failure | Verdict |
|---|---|---|
-qat-w4a16-ct Β· JAX |
||
int4 compressed-tensors scheme unimplemented for E2B's per_layer_model_projection |
||
| β no load | ||
-qat-q4_0-unquantized Β· JAX |
||
k_norm.weight "missing" for layers 15β34 |
||
| β no load | ||
-qat-q4_0-unquantized Β· torchax |
||
identical missing-weights error via MODEL_IMPL_TYPE=vllm |
||
| β no load | ||
gemma-4-E2B-it (plain) Β· JAX |
||
| loads and serves | β serves |
The forensics point at the , not the checkpoint. Reading the safetensors headers of both repos: the plain export ships self_attn.k_norm
for all 35 layers; the QAT export ships it only for the 15 non-KV-shared layers. Both configs are identical β including num_kv_shared_layers: 20
. Layers 15β34 reuse K/V from lower layers and have no k-norm of their own, so the QAT export is the architecturally honest one; the plain checkpoint only loads because it carries those (unused) tensors anyway. Proposed fix in #3225: skip instantiating K/V-side parameters for KV-shared layers.
Until it lands: serve the plain checkpoint. At 2B parameters (~5 GB bf16 against 32 GB HBM), 4-bit weights buy little here anyway β memory pressure lives in the KV cache, which is already fp8.
Same workload at every level β 1,024-token prompts, 128-token completions, vllm bench serve
, random dataset.
| Concurrency | Req/s | Output tok/s | Total tok/s | TTFT med | TTFT p99 | TPOT med | Per-stream tok/s |
|---|---|---|---|---|---|---|---|
| 1 | 1.64 | 209 | 1,884 | 16 ms | 17 ms | 4.7 ms | 213 |
| 8 | 9.44 | 1,209 | 10,878 | 27 ms | 99 ms | 6.2 ms | 161 |
| 32 | 12.78 | 1,636 | 14,721 | 155 ms | 189 ms | 17.5 ms | 57 |
| 64 | 16.72 | 2,140 | 19,262 | 122 ms | 349 ms | 25.3 ms | 39 |
| 100 (burst) | 17.31 | 2,215 | 19,938 | 833 ms | 1,573 ms | 36.8 ms | 27 |
Reading the curve:
(Single run per configuration β more meaningful here than usual: a kernel study on this same stack, cited in Β§7, measured run-to-run cv β€ 0.3% under greedy decoding with static shapes.)
Served with --tool-call-parser gemma4 --enable-auto-tool-choice
, probed with two OpenAI-style tools at temperature 0. Five scenarios, five clean results:
| Probe | Behavior | Latency |
|---|---|---|
| Simple call | correct tool, inferred the optional unit arg from phrasing |
|
| 166 ms | ||
| Result synthesis | fed the tool result back β clean natural-language answer | 140 ms |
| No-tool restraint | answered directly, no spurious call | 97 ms |
| Parallel calls | both tool calls emitted in one turn, correct args each | 150 ms |
| Underspecified | asked "What city are you interested in?" instead of hallucinating a call | 44 ms |
A 2B model producing well-formed tool_calls
JSON, choosing correctly between calling and answering, batching parallel calls, and asking for missing arguments β at double-digit-millisecond latency. For high-volume, low-complexity agent steps, the quality floor is higher than the parameter count suggests.
| Probe | Observed behavior | Verdict |
|---|---|---|
json_schema , thinking off (default) |
||
free prose with a 200 status; strict: true , guided_json , structured_outputs spellings all equally unenforced |
||
| β silently skipped | ||
json_object , thinking off |
||
| fenced code block, array where an object was asked, invented enum value | Β± prompt-level | |
json_schema + enable_thinking |
||
exact schema conformance β bare JSON object, typed integer, "ASAP" correctly mapped into the high enum |
||
| β enforced | ||
| Reasoning, default | no reasoning traces on any prompt | off by default |
Reasoning, enable_thinking |
||
| parser cleanly splits thinking trace from a terse answer; ~2.4Γ completion tokens | β works |
The mechanism: with --reasoning-parser gemma4
configured, vLLM defers grammar enforcement until the reasoning section ends. Thinking off β no reasoning terminator β the grammar never engages, and unconstrained prose ships with a 200 status. Enable thinking ("chat_template_kwargs": {"enable_thinking": true}
) and the same request is enforced exactly.
Operational guidance: pair structured output with enable_thinking: true
under a reasoning parser β or drop --reasoning-parser
from servers that don't need it. And keep client-side validation regardless: the silent-skip failure mode means a trusting client can't tell an enforced response from a lucky one.
One restart with --limit-mm-per-prompt '{"image":4,"audio":1}'
makes it a vision server. COCO validation images, base64 data URIs, temperature 0:
| Probe | Answer (abridged) | Latency |
|---|---|---|
| Describe (two cats) | "Two tabby cats⦠on a bright pink surface⦠a remote control visible" | 197 ms |
| Count + attributes | 2 animals, both cats, remote + blanket identified | 421 ms |
| Scene (bear) | "A bear lying down in a grassy outdoor environment" | 329 ms |
| Room inventory | wall-mounted TV, shelving, furniture correctly enumerated | 874 ms |
An image costs ~280 prompt tokens and adds almost nothing over a text request. Two notes: server-side fetching of external image URLs proved flaky (intermittent 422s) β base64 data URIs are the reliable path β and the first multimodal request after boot can 422 while the processor warms; retry once.
fp8 vs bf16 KV: six greedy prompts (explanation, code, listing, translation, arithmetic, summarization β 889 completion tokens) run under the default fp8_e5m2 cache, then re-run after a restart with --kv-cache-dtype bfloat16
. Result: 6 of 6 outputs byte-identical. On this (small, greedy) sample the compression is genuinely free β take the fp8 cache.
Where the 32 GB goes (bf16-KV boot, 65,536 max context): usable HBM reports as 31.24 GiB; at 0.9 utilization vLLM works within 28.12 GiB β roughly 5.5β6 GiB weights, 16.3 GiB KV cache (8,713 blocks Γ 128 tokens Γ 15 layers Γ 128 KiB), ~6 GiB workspace.
The interesting physics: E2B's KV sharing is directly visible in the allocator β only 15 of 35 layers hold KV tensors, each with a single 256-dim KV head, so a token costs ~15 KiB of KV in bf16 (~7.5 KiB under fp8). That yields ~1.1 million tokens of resident KV β seventeen full 65K-context conversations on-chip β which is why the sweep saturated on compute, never memory. Also: of the 404-second engine init, 329 seconds is XLA compilation β the dominant term in the ~10-minute cold start.
Related work: a kernel-substitution study by Zimbres (DOI 10.5281/zenodo.21404069) shows the RPA v3 kernel's decode block-size heuristic costs 27.7β68.7% of large-batch throughput on 27B/31B models on v6e. E2B sits at the low-exposure end of that effect: at our c=64 operating point, attention is ~9% of memory traffic (vs ~41% in their regime) precisely because of the KV-sharing design above. Testing the override on E2B β smallest model, one chip, KV sharing β is a natural follow-up.
Rate verified against Google's published Dynamic Workload Scheduler pricing: $1.35 per chip-hour for v6e flex-start (europe-west4, us-east1, us-east5, asia-northeast1).
| Operating point | Output tok/s | $ / M output tokens |
|---|---|---|
| Saturation (burst) | 2,215 | $0.17 |
| Sweet spot (c=64) | 2,140 | $0.18 |
| Interactive (c=8) | 1,209 | $0.31 |
| Single stream | 209 | $1.79 |
docker run --name vllm-gemma4 --privileged --net=host -d \
-v /dev/shm:/dev/shm --shm-size 10gb -e HF_HOME=/dev/shm \
-e HF_TOKEN=$(gcloud secrets versions access latest --secret=hf-token) \
vllm/vllm-tpu:nightly vllm serve google/gemma-4-E2B-it \
--tensor-parallel-size 1 --max-model-len 65536 \
--gpu-memory-utilization 0.9 --max_num_batched_tokens 4096 \
--disable_chunked_mm_input --enable-auto-tool-choice \
--tool-call-parser gemma4 --reasoning-parser gemma4 \
--limit-mm-per-prompt '{"image":4,"audio":1}' # {"image":0,"audio":0} for text-only
vllm bench serve --backend vllm --model google/gemma-4-E2B-it \
--dataset-name random --num-prompts 100 \
--random-input-len 1024 --random-output-len 128 --max-concurrency C
gcloud compute start-iap-tunnel vllm-gemma4-e2b 8000 \
--local-host-port=localhost:8000 --zone=europe-west4-a
Environment: vLLM 0.23.1rc1.dev1076+g5c342876a (vllm-tpu:nightly, tpu-inference backend) Β· TPU v6e-1 (ct6e-standard-1t, 32 GB HBM, GCE flex-start) Β· bf16 weights, fp8_e5m2 KV cache. Single benchmark run per configuration; treat deltas under ~10% as noise.