Three Gemma 4 Deployments on One T4G for Under $3: What the Runtime Changes, and What It Doesn't A developer benchmarked three Gemma 4 deployments on a single AWS T4G GPU for under $3, comparing vLLM, JAX, and PyTorch runtimes. The project built a common harness to measure decode throughput, revealing that previous comparisons were invalid due to inconsistent metrics. The exercise cost under three dollars and caught five incorrect assumptions through measurement. This article provides a step by step comparison of three Gemma 4 deployments on a single AWS hosted GPU enabled system. A suite of Python MCP tools is built to simplify management of each deployment, and one benchmark harness is shared across all three so that the runtime is the only variable. https://github.com/xbill9/gemma4-dev https://github.com/xbill9/gemma4-dev The whole exercise cost under three dollars, and that is the part worth keeping. Nineteen instances and about four and a half instance-hours bought three serving sweeps, nine timed boots and a handful of A/B restarts. It also bought five wrong claims, each caught by measuring instead of reasoning. On hardware where a run is expensive, the cheapest of those five would have shipped with a caveat attached. Three rigs in this monorepo serve google/gemma-4-E2B-it on an AWS G5g instance. One runs vLLM, one runs a pure JAX port, one runs PyTorch with transformers. The hardware is identical and only the runtime slot moves, so this should be the cleanest A/B available. For months it was not, because each rig measured itself with its own harness and quoted its own number. Three harnesses computing three statistics is not a comparison. us-east-1 . Each g5g.2xlarge is 8 vCPU, so 16 vCPU of spot quota runs two at once. AmazonSSMManagedInstanceCore plus read on the Hugging Face token secret. EnvironmentFile and never appears in user data. boto3 and the standard credential chain. No AWS CLI shell-outs, no inbound SSH rule, and no private key anywhere in the flow.| Instance | g5g.2xlarge — 8 vCPU, 16 GiB host | | Host CPU | AWS Graviton2, aarch64 | | GPU | 1x NVIDIA T4G, Turing, SM 7.5 | | GPU memory | 15,360 MiB per nvidia-smi ; AWS lists 16,384 nominal | G5g is the only family AWS ships that puts an NVIDIA GPU behind a Graviton host, which makes it the only place to get aarch64 and compute capability 7.5 together. google/gemma-4-E2B-it is the reference instruction-tuned release. It is 2B effective from about 5B total, and the split matters here: most of what is resident is a per-layer-embedding table that decode reads as a gather and never streams through a matmul. The dense checkpoint fits. 9.5 GiB of float16 weights go into 15,360 MiB of device memory with room for the KV cache, which at roughly 18 KiB per token is tens of megabytes at this context and never the binding constraint. | runtime | engine | how it serves | |---|---|---| | vLLM | v0.27.2rc0, built from source for sm 75 | continuous batching, paged KV, prefix caching | | JAX | this project's own port | hand-written KV ring with a bucket ladder | | PyTorch | AutoModelForCausalLM + transformers | past key values , one request at a time | Turing has no bfloat16 datapath, so all three run float16. It has no fp8 either, which rules out the KV-cache tricks that work on newer parts. check g5g quotas | Quota | vCPUs | | Running On-Demand G and VT instances vCPU | 16 | | All G and VT Spot Instance Requests vCPU | 16 | g5g.2xlarge needs 8 vCPUs. That is the constraint behind every launch below: two rigs in parallel, and no more. The sweep script read its throughput figure straight out of the response body: "decode tps": usage.get "decode tokens per second", 0.0 , usage.decode tokens per second is a field our own servers invent. vLLM does not emit it, and neither does anything else, so the harness could not be pointed at the vLLM rig at all. The three-way comparison had never actually been run. Re-running a rig does not fix that. Only a common statistic does. Every OpenAI-compatible server streams, so the portable measurement is the gap between tokens on the wire. python3 sweep.py --help | grep -A2 decode-source --decode-source {auto,usage,stream,both} where the decode figure comes from; see the module docstring The stream path uses vllm bench serve 's exact TPOT definition, latency - ttft / output len - 1 , so a number from this harness is directly comparable to that tool's published figures. auto probes the endpoint once and picks both where the server emits its own gauge, stream where it does not. No, and that is worth a measurement rather than an assumption. Running both measures each rig's offset between the two statistics. | rig | server gauge | client stream | stream/gauge | |---|---|---|---| | JAX | 12.962 | 12.687 | 0.9799 | | PyTorch | 10.814 | 10.243 | 0.9543 | Two percent against 4.6 percent, on the same day and the same instance shape. Borrowing one rig's ratio to convert the other's number would inject a 2.6 percent error into a comparison whose smallest interesting gap is 24 percent. The cross-rig table below is therefore built from stream throughout. The JAX server emitted its decode gauge with one decimal place: f'tpu jax decode tokens per second{{model="{MODEL ID}"}} {METRICS "last tokens per second" :.1f}', At about 13 tok/s, one decimal is 0.78 percent resolution. Every sweep that rig had produced showed all three repeats of a cell as byte-identical: 12.8, 12.8, 12.8. That is not reproducibility, it is the measurement floor. The rig had been used to argue about two percent effects it could not resolve. Two characters fixed it. The first run afterwards reads 12.962, where before it would have said 13.0. Capacity for the whole G5g family was exhausted across all four availability zones several times, so the launcher cycles them with a sixty second backoff. 12:51:45 round 5 us-east-1c: ❌ AWS InsufficientInstanceCapacity 12:52:47 us-east-1a: ✅ Launching i-02e79988a6cbeecbf g5g.2xlarge, spot, 1x T4G in us-east-1 . The walkthrough from here follows the PyTorch rig on i-02e79988a6cbeecbf ; the other two run the same steps against their own instances. All three landed in us-east-1a within hours of each other. Note that AWS names the other zones as available in every one of those errors — that text describes on-demand capacity and says nothing about spot. Cloud-init installs the runtime and then backgrounds itself, so the progress tool reports cloud-init's own state as well as the install log. A dead bootstrap and a slow one must not render identically. get install progress i-02e79988a6cbeecbf INSTALL COMPLETE --- cloud-init --- status: done errors: This is a wheel install, not a build. Across the three timed boots the install finishes a median 113.55 s after launch, against the hours the vLLM rig needs for a from-source build. A config flag being accepted proves nothing, so the probe runs a real matmul on the device. verify gpu arch i-02e79988a6cbeecbf NVIDIA T4G, 7.5, 15360 MiB torch: 2.12.0+cu132 arch list: 'sm 75', 'sm 80', 'sm 90', 'sm 100', 'sm 110', 'sm 120' capability: 7, 5 compute dtype: float16 fp16 matmul ok: True ✅ torch reached the GPU and a real fp16 matmul executed. The DLAMI's torch carries sm 75 . Upstream PyPI aarch64 wheels do not, so a pip install torch on this box would serve on CPU without saying so. The payload is the rig's own source, shipped over SSM as a gzipped tarball because user data caps at 16 KiB. deploy torch server i-02e79988a6cbeecbf ✅ Deployed 3 files 16 KiB base64 to i-02e79988a6cbeecbf . Payload root: /home/xbill/gemma4-dev/gpu-pytorch-g5g-2b Build id: 060a572aeb55 — verify model health checks the running server reports this. A non-empty reply is not evidence of health. One sibling was once measured answering ': ok: ok: ok…' , so the check reads the server's own degenerate-response counter either side of its probe, and compares the served build id against the local payload. verify model health i-02e79988a6cbeecbf ✅ health=200 tokens=5 reply='ok' - Degenerate server's own verdict on the full text : no - Build id served: 060a572aeb55 - Build id matches the local payload 060a572aeb55 . The same command runs against all three rigs. Only the endpoint changes. python3 sweep.py --base http://