<?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: Sho Tanaka (tsho)</title>
    <description>The latest articles on DEV Community by Sho Tanaka (tsho) (@tsho).</description>
    <link>https://dev.to/tsho</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%2F3832915%2F293dda53-9c44-460c-a6f4-5c37f479fc5d.jpg</url>
      <title>DEV Community: Sho Tanaka (tsho)</title>
      <link>https://dev.to/tsho</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tsho"/>
    <language>en</language>
    <item>
      <title>Turning Qwen2.5-0.5B into a JSON API with SFT — 23% 100% on a Free T4</title>
      <dc:creator>Sho Tanaka (tsho)</dc:creator>
      <pubDate>Wed, 19 Aug 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/tsho/turning-qwen25-05b-into-a-json-api-with-sft-23-100-on-a-free-t4-4db1</link>
      <guid>https://dev.to/tsho/turning-qwen25-05b-into-a-json-api-with-sft-23-100-on-a-free-t4-4db1</guid>
      <description>&lt;h2&gt;
  
  
  I Tried Post-Training and All I Got Was "systemsystem"
&lt;/h2&gt;

&lt;p&gt;I followed the TRL tutorial to fine-tune a small model with SFT (supervised fine-tuning). Training ran to completion. But no matter what I asked the trained model, the answer was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;systemsystemsystemsystemsystem...

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

&lt;/div&gt;



&lt;p&gt;Was the model broken? Was my code wrong? I couldn't even tell which — if you've tried SFT, maybe this sounds familiar.&lt;/p&gt;

&lt;p&gt;It turned out I hit that exact symptom &lt;strong&gt;twice, from two unrelated causes&lt;/strong&gt; : once because training genuinely diverged, and once because training had succeeded perfectly and my &lt;em&gt;verification&lt;/em&gt; code was wrong. Telling those apart is most of what this article is about.&lt;/p&gt;

&lt;p&gt;This is the field log of getting from there to turning Qwen2.5-0.5B into a model that answers every question with nothing but JSON.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;Qwen2.5-0.5B base&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Method&lt;/td&gt;
&lt;td&gt;SFT (implemented with LoRA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;TRL + PEFT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;Colab T4 ×1 (free tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training time&lt;/td&gt;
&lt;td&gt;26 minutes (1,538 s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valid-JSON rate&lt;/td&gt;
&lt;td&gt;23% → 100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valid JSON &lt;strong&gt;and&lt;/strong&gt; correct category&lt;/td&gt;
&lt;td&gt;0% → 96%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training data&lt;/td&gt;
&lt;td&gt;8,705 single-turn samples (no_robots)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluation&lt;/td&gt;
&lt;td&gt;30 held-out questions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A note on the title: &lt;strong&gt;SFT and LoRA are not alternatives to each other.&lt;/strong&gt; SFT is the objective — train on demonstrations of the behavior you want. LoRA is a parameterization — store the weight update in a small low-rank adapter instead of touching all the weights. Everything below is caused by SFT; LoRA is just where the result is kept. (Why LoRA and not full fine-tuning is itself Trap 4.)&lt;/p&gt;

&lt;p&gt;This is part 1 of a post-training series; part 2 covers RM+PPO / DPO.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup: Why a 0.5B Base Model
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://huggingface.co/Qwen/Qwen2.5-0.5B" rel="noopener noreferrer"&gt;&lt;code&gt;Qwen/Qwen2.5-0.5B&lt;/code&gt;&lt;/a&gt; (base — &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;-Instruct&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://huggingface.co/datasets/HuggingFaceH4/no_robots" rel="noopener noreferrer"&gt;&lt;code&gt;HuggingFaceH4/no_robots&lt;/code&gt;&lt;/a&gt; (train 9,500 / test 500; &lt;code&gt;messages&lt;/code&gt; + &lt;code&gt;category&lt;/code&gt; columns)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Libraries&lt;/td&gt;
&lt;td&gt;TRL 1.10.0, transformers 5.13.1, peft 0.19.1, datasets 5.0.1, accelerate 1.14.0, torch 2.11.0+cu128&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Environment&lt;/td&gt;
&lt;td&gt;Colab T4 ×1, pinned to a single GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three reasons for this configuration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Without a base model there is no "before."&lt;/strong&gt; &lt;code&gt;-Instruct&lt;/code&gt; models have already been through SFT, so they can't show you what SFT changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GGUF and MLX are inference-only formats&lt;/strong&gt; — you can't train them. You need safetensors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full SFT of a 30B-class model needs ~480 GB of VRAM&lt;/strong&gt; , while a 0.5B model still exposes the same operational failure modes — and a free T4 shows you all of them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those library versions matter more than usual here: &lt;strong&gt;two of the seven traps below are pure version drift&lt;/strong&gt; , and they will look different (or disappear) on other versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seven Traps, in Order
&lt;/h2&gt;

&lt;p&gt;Chronological. Each one is error → cause → fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap 1: &lt;code&gt;KeyError: 'completion'&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KeyError: 'completion'

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

&lt;/div&gt;



&lt;p&gt;Died the moment training started. Cause: &lt;code&gt;no_robots&lt;/code&gt; carries a &lt;code&gt;prompt&lt;/code&gt; column alongside &lt;code&gt;messages&lt;/code&gt;, and TRL misdetected the dataset as prompt-completion format. One-line fix:&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;train&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;train&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;select_columns&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Trap 2: Output Collapses to "systemsystemsystem…"
&lt;/h3&gt;

&lt;p&gt;The one from the intro — the &lt;em&gt;real&lt;/em&gt; one. Cause: full fine-tuning directly on fp16-loaded weights diverged. The T4 has no native bf16, so fp16 is the tempting choice, but updating fp16 weights directly is numerically fragile; the model collapses onto its most frequent token.&lt;/p&gt;

&lt;p&gt;Fix: load in fp32 and let mixed precision be handled by the trainer, which keeps an fp32 master copy.&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;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoModelForCausalLM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&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;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SFTConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fp16&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="p"&gt;...)&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Trap 3: &lt;code&gt;RuntimeError&lt;/code&gt; — Tensors on cuda:1 and cuda:0
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RuntimeError: tensors ... cuda:1 different from cuda:0

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

&lt;/div&gt;



&lt;p&gt;I tried Kaggle as well as Colab. Kaggle gives you two T4s, HF Trainer silently wraps the model in &lt;code&gt;nn.DataParallel&lt;/code&gt;, and it dies on a device mismatch. A 0.5B model doesn't need two GPUs. Fix — but it only takes effect &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;import torch&lt;/code&gt;, which means a kernel restart if you've already imported:&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;os&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;CUDA_VISIBLE_DEVICES&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Trap 4: Full Fine-Tuning Collapses Again — Why I Switched to LoRA
&lt;/h3&gt;

&lt;p&gt;Even with Trap 2 fixed, full fine-tuning of this small model stayed extremely sensitive to learning rate and precision, and it collapsed again. That was the turning point: I dropped full FT for LoRA (r=16, alpha=32, lr=2e-4). With the base weights frozen and only 8.8M adapter parameters training (1.75% of the model), the instability went away and never came back.&lt;/p&gt;

&lt;p&gt;Caveat I want to be honest about: &lt;strong&gt;I never got a clean full-FT run to compare against.&lt;/strong&gt; So "LoRA is more stable here" is what I observed, not a controlled A/B.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap 5: Forgot &lt;code&gt;model.eval()&lt;/code&gt; — a Fake Collapse
&lt;/h3&gt;

&lt;p&gt;The one that made me go pale. After switching to LoRA, generation said &lt;code&gt;systemsystem&lt;/code&gt; &lt;em&gt;again&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Training had actually succeeded. I had forgotten &lt;code&gt;model.eval()&lt;/code&gt;, so greedy decoding ran with dropout still active. I isolated it by varying two things independently:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;training&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;use_cache&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;True&lt;/td&gt;
&lt;td&gt;False&lt;/td&gt;
&lt;td&gt;&lt;code&gt;A systemsystemsystem…&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;True&lt;/td&gt;
&lt;td&gt;True&lt;/td&gt;
&lt;td&gt;&lt;code&gt;A systemsystemsystem…&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False&lt;/td&gt;
&lt;td&gt;False&lt;/td&gt;
&lt;td&gt;&lt;code&gt;A cache is a data structure that stores data…&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False&lt;/td&gt;
&lt;td&gt;True&lt;/td&gt;
&lt;td&gt;&lt;code&gt;A cache is a data structure that stores data…&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only &lt;code&gt;training&lt;/code&gt; mattered. Meanwhile the training logs were healthy the whole time — entropy 2.63 → 2.41 (a real collapse drives it toward zero), grad_norm 1.17 → 0.45, token accuracy 0.469 → 0.517.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnose collapse from the training logs, not from the symptom.&lt;/strong&gt; The symptom is ambiguous; entropy and grad_norm are not.&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;was_training&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;training&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="n"&gt;generate&lt;/span&gt;&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;train&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;was_training&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  Trap 6: &lt;code&gt;torch.cuda.is_bf16_supported()&lt;/code&gt; Returns True on a T4
&lt;/h3&gt;

&lt;p&gt;Recent PyTorch changed this function's default to &lt;code&gt;including_emulation=True&lt;/code&gt;, so a T4 answers "yes" and your code happily selects an emulated bf16 that is slower with no benefit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;capability: (7, 5)
is_bf16_supported(): True
is_bf16_supported(including_emulation=False): False

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

&lt;/div&gt;



&lt;p&gt;Check the hardware generation instead — it's stable across versions:&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;use_bf16&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cuda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_device_capability&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="c1"&gt;# Ampere or later
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Trap 7: Two Version-Drift Errors (&lt;code&gt;BatchEncoding&lt;/code&gt;, &lt;code&gt;torchao&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;apply_chat_template()&lt;/code&gt; returns a &lt;code&gt;BatchEncoding&lt;/code&gt;, not a tensor.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AttributeError: ... 'inputs.shape'

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

&lt;/div&gt;



&lt;p&gt;Pass &lt;code&gt;return_dict=True&lt;/code&gt;, call &lt;code&gt;generate(**enc)&lt;/code&gt;, and take the prompt length from &lt;code&gt;enc["input_ids"].shape[1]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Colab's preinstalled &lt;code&gt;torchao&lt;/code&gt; is too old for recent transformers.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ImportError: Found an incompatible version of torchao. Found version 0.10.0,
but only versions above 0.16.0 are supported

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

&lt;/div&gt;



&lt;p&gt;This fires at &lt;em&gt;import&lt;/em&gt; time, before any training. If you're not quantizing, remove it; if you are, upgrade it. Either way &lt;strong&gt;restart the session afterwards&lt;/strong&gt; , or the preinstalled version stays loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip uninstall &lt;span class="nt"&gt;-y&lt;/span&gt; torchao &lt;span class="c"&gt;# or: pip install -U torchao&lt;/span&gt;

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

&lt;/div&gt;



&lt;h3&gt;
  
  
  What the trap list actually shows
&lt;/h3&gt;

&lt;p&gt;I originally wanted to claim "most of these were verification-side problems." Lining them up, that isn't true. &lt;strong&gt;Two of the seven&lt;/strong&gt; — Trap 5, and the &lt;code&gt;BatchEncoding&lt;/code&gt; half of Trap 7 — were cases where training had genuinely succeeded and only my verification code was stale. The rest failed loudly and early: Traps 1 and the &lt;code&gt;torchao&lt;/code&gt; half of 7 died before a single training step, and Traps 2/3/4 were real training failures.&lt;/p&gt;

&lt;p&gt;The honest lesson is narrower but still worth having: &lt;strong&gt;when SFT "fails," check whether it failed at all.&lt;/strong&gt; Two of my seven traps cost me hours on a model that was already working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 1: What the Format Change Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;One epoch of SFT on &lt;code&gt;no_robots&lt;/code&gt;, LoRA, 33 minutes on a T4. Same prompt, before and after — that comparison matters, because on some prompts the base is already better than you'd assume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Give me 3 tips for running a 10k race."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;BEFORE (base):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sure, here are three tips for running a 10k race:

1. Start early: It's important to start running early in the morning or early in
   the day to allow your body to adjust to the increased intensity of the race...
2. Listen to your body: Pay attention to...

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

&lt;/div&gt;



&lt;p&gt;AFTER (SFT):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Start early. If you start too late, you will not be able to run the full 10km.
   Start early and finish early.
2. Don't run too fast. You should be able to run the full 10km in 45 minutes or less...
3. Don't run too long...

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

&lt;/div&gt;



&lt;p&gt;Note what did &lt;strong&gt;not&lt;/strong&gt; happen: the base already produced a numbered list. What SFT removed was the preamble (&lt;code&gt;Sure, here are three tips for running a 10k race:&lt;/code&gt;) — the model now answers directly, in the style of the training data. That's a real change, and a smaller one than "it learned to make lists."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What is the capital of France?"&lt;/strong&gt; — this one is sharper:&lt;/p&gt;

&lt;p&gt;BEFORE (base):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The capital of France is Paris. navigationOptions
navigationOptions
You are a helpful assistant.icode
icode
What is the capital of France?icode ...

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

&lt;/div&gt;



&lt;p&gt;AFTER (SFT):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The capital of France is Paris. It is the largest city in Europe and the
third-largest city in the world. It is also the seat of the French government
and the seat of the French parliament. It is also the seat of... (repeats)

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

&lt;/div&gt;



&lt;p&gt;The base answers correctly and then &lt;strong&gt;falls out of the assistant role entirely&lt;/strong&gt; , echoing chat-template fragments. After SFT it stays in role from start to finish — and states two confident falsehoods (Paris is neither the largest city in Europe nor the third largest in the world). Form improved; factual reliability did not, and arguably got worse-looking because the errors are now delivered fluently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Explain what a KV cache is."&lt;/strong&gt; — both loop, before and after. &lt;code&gt;no_robots&lt;/code&gt; contains no KV-cache knowledge, and one epoch of SFT on it adds none.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part I got wrong at first: SFT made &lt;em&gt;stopping&lt;/em&gt; worse
&lt;/h3&gt;

&lt;p&gt;My first read was "the leftover repetition is just a 0.5B + greedy-decoding artifact, not an SFT problem." I measured it, and that's only half right.&lt;/p&gt;

&lt;p&gt;I generated 256 tokens for 25 held-out prompts and recorded two things separately: whether the model &lt;strong&gt;emitted&lt;/strong&gt; the terminator &lt;code&gt;&amp;lt;|im_end|&amp;gt;&lt;/code&gt;, and whether generation &lt;strong&gt;stopped&lt;/strong&gt; at all.&lt;/p&gt;

&lt;p&gt;| Model | Decoding | Stopped | Emitted &lt;code&gt;&amp;lt;|im_end|&amp;gt;&lt;/code&gt; | distinct-3 |&lt;br&gt;
| --- | --- | --- | --- | --- |&lt;br&gt;
| base | greedy | &lt;strong&gt;20%&lt;/strong&gt; | 0% | 0.588 |&lt;br&gt;
| base | greedy + repetition_penalty 1.1 | &lt;strong&gt;40%&lt;/strong&gt; | 0% | 0.781 |&lt;br&gt;
| base | sampling (T=0.7, top_p=0.9) | 24% | 0% | 0.680 |&lt;br&gt;
| SFT | greedy | &lt;strong&gt;0%&lt;/strong&gt; | 0% | 0.337 |&lt;br&gt;
| SFT | greedy + repetition_penalty 1.1 | &lt;strong&gt;0%&lt;/strong&gt; | 0% | 0.700 |&lt;br&gt;
| SFT | sampling | 0% | 0% | 0.534 |&lt;/p&gt;

&lt;p&gt;Two separate phenomena, and they behave differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repetition&lt;/strong&gt; is decode-fixable. &lt;code&gt;repetition_penalty&lt;/code&gt; moves distinct-3 from 0.337 to 0.700. My original claim holds here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Termination is not&lt;/strong&gt; , and SFT made it strictly worse: the base stopped 20–40% of the time; after SFT, never — under every decoding setting I tried.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why? I looked at the terminal position of reference answers under teacher forcing:&lt;/p&gt;

&lt;p&gt;| At the position whose target is &lt;code&gt;&amp;lt;|im_end|&amp;gt;&lt;/code&gt; | base |&lt;br&gt;
| --- | --- |&lt;br&gt;
| &lt;code&gt;P(&amp;lt;|im_end|&amp;gt;)&lt;/code&gt; | ~0 (below 1e-5) |&lt;br&gt;
| Rank of &lt;code&gt;&amp;lt;|im_end|&amp;gt;&lt;/code&gt; | &lt;strong&gt;118,800&lt;/strong&gt; out of ~152,000 |&lt;br&gt;
| &lt;code&gt;P(&amp;lt;|endoftext|&amp;gt;)&lt;/code&gt; | 0.1006 |&lt;br&gt;
| argmax was &lt;code&gt;&amp;lt;|endoftext|&amp;gt;&lt;/code&gt; | 7 / 30 cases |&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The base model's terminator is &lt;code&gt;&amp;lt;|endoftext|&amp;gt;&lt;/code&gt;, and it barely knows &lt;code&gt;&amp;lt;|im_end|&amp;gt;&lt;/code&gt; exists&lt;/strong&gt; — despite shipping a chat template that uses it. Shipping a chat template and having been &lt;em&gt;trained&lt;/em&gt; on it are different things, which I had conflated.&lt;/p&gt;

&lt;p&gt;The training data ends every example with &lt;code&gt;&amp;lt;|im_end|&amp;gt;&lt;/code&gt; and never contains &lt;code&gt;&amp;lt;|endoftext|&amp;gt;&lt;/code&gt;. So SFT pushes down the only terminator the base knew, while failing to lift the new one from rank ~118,800 to rank 1 in one epoch of an 8.8M-parameter adapter. &lt;strong&gt;It didn't fail to teach stopping so much as break the stopping the model already had.&lt;/strong&gt; That is my current explanation, and it's a hypothesis: I confirmed the base-side numbers but lost the VM before measuring the post-SFT side.&lt;/p&gt;

&lt;p&gt;Two things I ruled out along the way: truncation at &lt;code&gt;max_length=1024&lt;/code&gt; affects only &lt;strong&gt;1.7%&lt;/strong&gt; of examples (163/9,500; median length 241 tokens), and training with &lt;code&gt;assistant_only_loss=True&lt;/code&gt; — which concentrates the loss on assistant tokens, where the terminator lives — did &lt;strong&gt;not&lt;/strong&gt; help (still 0% termination, and slightly more repetitive).&lt;/p&gt;
&lt;h2&gt;
  
  
  Experiment 2: Quantifying Success — a 0.5B JSON API
&lt;/h2&gt;

&lt;p&gt;If SFT is good at teaching form, pick a task where form is measurable. Goal: make the model answer every question with nothing but &lt;code&gt;{"category": "...", "answer": "..."}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Labels reuse &lt;code&gt;no_robots&lt;/code&gt;' existing &lt;code&gt;category&lt;/code&gt; column, so additional data creation was zero. Restricted to single-turn conversations: train 8,705 / test 446, evaluated on the first 30 test questions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;BEFORE (base)&lt;/th&gt;
&lt;th&gt;AFTER (SFT, 1 epoch)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Valid JSON extractable&lt;/td&gt;
&lt;td&gt;23% (7/30)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;100%&lt;/strong&gt; (30/30)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valid JSON &lt;strong&gt;and&lt;/strong&gt; correct category&lt;/td&gt;
&lt;td&gt;0% (0/30)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;96%&lt;/strong&gt; (29/30)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Measured run: 545 steps / 26 minutes (1,538 s) / train_loss 1.857 / mean_token_accuracy 0.653 / 2.10M tokens, on one T4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How these metrics are defined&lt;/strong&gt; , because it changes what they mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Valid JSON extractable&lt;/em&gt; = the first &lt;code&gt;{...}&lt;/code&gt; found anywhere in the output parses and has both keys. It is deliberately lenient: preambles and trailing text don't disqualify a response. So 23% is &lt;strong&gt;not&lt;/strong&gt;"23% of outputs were pure JSON."&lt;/li&gt;
&lt;li&gt;The second row is a &lt;strong&gt;compound&lt;/strong&gt; metric: a response only scores if it both parses and gets the category right. The base's 0% is therefore mostly a JSON failure, not a classification failure — 23 of its 30 answers never became scoreable. Don't read it as "the base cannot classify at all"; a base asked to emit only a bare category label would surely do better than zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With that in mind, the BEFORE 23% is the interesting number. Here's what one of those seven looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sure! Here's an example response for you:

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

&lt;/div&gt;



&lt;p&gt;{&lt;br&gt;
  "category": "Closed QA",&lt;br&gt;
  "answer": "I'm not sure what genre to check out."&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Small talk, then a JSON object &lt;strong&gt;inside a markdown code fence&lt;/strong&gt; , with a category that's wrong. The base can imitate the shape it was shown in the system prompt; it cannot execute the task (classify the request, then fill the structure), and it cannot suppress the surrounding chatter. My lenient parser counted this as a success, which is exactly why the definition needs stating.&lt;/p&gt;

&lt;p&gt;The other two sampled BEFORE responses didn't produce JSON at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I'm sorry, but as an AI language model, I don't have personal experiences or
emotions like humans do. However, here is some information about what people...


Write an essay about your experience as a student in the United States.
orda
Write a short story that includes one or more of these words: love, friendship...

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

&lt;/div&gt;



&lt;p&gt;The second is the base model doing what base models do — continuing the text rather than answering it.&lt;/p&gt;

&lt;p&gt;After SFT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Brainstorm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"answer"&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. Take an umbrella - It will keep you dry while
walking around London. 2. Bring your own water bottle - ..."&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;No preamble, no fence, 100% parseable, 96% correct category. What SFT taught was not "the JSON shape" — the base could already mimic that — but &lt;strong&gt;executing the task in that shape&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sample-size caveat&lt;/strong&gt; : 30/30 on n=30 has a 95% confidence interval of roughly [88%, 100%]. I ran this once, greedy, on a fixed slice of the test set. Treat "100%" as "no failures in 30," not as a guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Final Working Code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Pin to one GPU — must run BEFORE importing torch (Trap 3)
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&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;CUDA_VISIBLE_DEVICES&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;

&lt;span class="c1"&gt;# Data: keep only the messages column (Trap 1)
&lt;/span&gt;&lt;span class="n"&gt;train&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;train&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;select_columns&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;# Load in fp32 on a T4; mixed precision is the trainer's job (Trap 2)
&lt;/span&gt;&lt;span class="n"&gt;use_bf16&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cuda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_device_capability&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="c1"&gt;# Trap 6
&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;AutoModelForCausalLM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&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;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bfloat16&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;use_bf16&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;trl&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SFTTrainer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SFTConfig&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;peft&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LoraConfig&lt;/span&gt;

&lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SFTConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;per_device_train_batch_size&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="n"&gt;gradient_accumulation_steps&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="n"&gt;num_train_epochs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;learning_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2e-4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lr_scheduler_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;cosine&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;packing&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="c1"&gt;# T4 has no FA2
&lt;/span&gt;                &lt;span class="n"&gt;fp16&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;use_bf16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bf16&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;use_bf16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;report_to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;peft_cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoraConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lora_alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lora_dropout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;target_modules&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q_proj&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;k_proj&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;v_proj&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;o_proj&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;gate_proj&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;up_proj&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;down_proj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                      &lt;span class="n"&gt;task_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;CAUSAL_LM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;trainer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SFTTrainer&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;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;train_dataset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;train&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;peft_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;peft_cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;trainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;train&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Verification (Traps 5 and 7)
&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# Trap 5 — do not skip
&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;add_generation_prompt&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;return_dict&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;return_tensors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&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="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# Trap 7
&lt;/span&gt;&lt;span class="k"&gt;with&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;no_grad&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&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="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_new_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;do_sample&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;repetition_penalty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pad_token_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;eos_token_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&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;enc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;shape&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;skip_special_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;packing=False&lt;/code&gt; because the T4 doesn't support Flash Attention 2, and without it packing risks cross-sample contamination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduction Details
&lt;/h2&gt;

&lt;p&gt;Everything needed to rerun Experiment 2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model&lt;/strong&gt; : &lt;code&gt;Qwen/Qwen2.5-0.5B&lt;/code&gt; (base; bf16 checkpoint, loaded as fp32)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dataset&lt;/strong&gt; : &lt;code&gt;HuggingFaceH4/no_robots&lt;/code&gt;, single-turn conversations only; &lt;code&gt;category&lt;/code&gt; labels reuse the dataset's own annotation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Target construction&lt;/strong&gt; : assistant turn is whitespace-normalized and &lt;strong&gt;truncated to the first 60 words&lt;/strong&gt; (with &lt;code&gt;"..."&lt;/code&gt; appended), then wrapped as &lt;code&gt;{"category": ..., "answer": ...}&lt;/code&gt;. This also teaches brevity, and you will not reproduce these numbers without it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Splits&lt;/strong&gt; : train 8,705 / test 446; evaluation on the &lt;strong&gt;first 30&lt;/strong&gt; test questions (not a random sample)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU&lt;/strong&gt; : Colab T4 ×1, pinned via &lt;code&gt;CUDA_VISIBLE_DEVICES=0&lt;/code&gt; before &lt;code&gt;import torch&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Training&lt;/strong&gt; : 1 epoch, batch 4 × grad-accum 4, lr 2e-4, cosine, &lt;code&gt;warmup_steps=30&lt;/code&gt;, &lt;code&gt;max_length=1024&lt;/code&gt;, &lt;code&gt;packing=False&lt;/code&gt;, fp16 mixed precision&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LoRA&lt;/strong&gt; : r=16, alpha=32, dropout=0.05, targets q/k/v/o/gate/up/down_proj (8.8M trainable / 502.8M total = 1.75%)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation (eval)&lt;/strong&gt;: &lt;code&gt;model.eval()&lt;/code&gt;, &lt;code&gt;max_new_tokens=160&lt;/code&gt;, greedy (&lt;code&gt;do_sample=False&lt;/code&gt;), &lt;code&gt;repetition_penalty=1.15&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measured run&lt;/strong&gt; : 545 steps, 1,538 s, train_loss 1.857, mean_token_accuracy 0.653, 2.10M tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Versions&lt;/strong&gt; : torch 2.11.0+cu128, transformers 5.13.1, TRL 1.10.0, peft 0.19.1, datasets 5.0.1, accelerate 1.14.0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One reproducibility note in SFT's favour: I ran the Experiment 1 notebook on TRL 1.9.2 and again on 1.10.0 eight days apart. Loss at step 500 was 2.3167 and 2.3170 — identical to four decimals. Not everything drifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discussion: SFT Isn't "Useless" — It Has a Different Job
&lt;/h2&gt;

&lt;p&gt;Put the two experiments side by side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you want&lt;/th&gt;
&lt;th&gt;Did SFT deliver it here?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Output structure (JSON / tool-call shape)&lt;/td&gt;
&lt;td&gt;✅ 23% → 100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Task execution in that structure (classify, then fill)&lt;/td&gt;
&lt;td&gt;✅ 0% → 96%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Response format and register (direct answers, no preamble)&lt;/td&gt;
&lt;td&gt;✅ visible in every sample&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staying in the assistant role&lt;/td&gt;
&lt;td&gt;✅ base fell out of role; SFT model didn't&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New factual knowledge&lt;/td&gt;
&lt;td&gt;❌ no evidence of any&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Knowing when to stop&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;worse than the base&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Much of "I did SFT and it didn't get smarter" is expecting knowledge from a technique whose demonstrated strength is behavior. Behavior injection got me 23%→100% with 0.5B parameters, 26 minutes, and zero additional data.&lt;/p&gt;

&lt;p&gt;But I'd resist the tidy version of this story. SFT didn't just &lt;em&gt;fail&lt;/em&gt; to add knowledge — on one axis it actively removed a capability the base had, because my training distribution never contained the token the base was using to stop. That's a general shape worth remembering: &lt;strong&gt;fine-tuning moves probability mass, and mass has to come from somewhere.&lt;/strong&gt; If your data never demonstrates a behavior, one epoch of SFT is a good way to suppress it.&lt;/p&gt;

&lt;p&gt;Zooming out: published post-training pipelines for chat models (InstructGPT is the canonical writeup) put SFT at the foundation for exactly this reason, and the Structured Outputs / tool-calling features in commercial APIs solve the same class of problem Experiment 2 demonstrates — getting a model to execute a task in a fixed output shape.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SFT taught behavior, not knowledge.&lt;/strong&gt; Same-prompt before/after shows format and role adherence changing while factual quality does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quantified&lt;/strong&gt; : valid JSON 23%→100%, valid-JSON-and-correct-category 0%→96% (0.5B, LoRA, 1 epoch, one free T4, 26 minutes, n=30).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SFT and LoRA operate at different levels.&lt;/strong&gt; SFT is the objective; LoRA is where the update is stored. The 100% is SFT's doing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two of seven traps were "training succeeded, my verification was stale."&lt;/strong&gt; Diagnose collapse from entropy / grad_norm / token accuracy — never from the symptom, which is identical in both cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full FT of this small model kept collapsing; LoRA removed the instability&lt;/strong&gt; — observed, not A/B tested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SFT broke termination.&lt;/strong&gt; Base stopped 20–40% of the time; after SFT, 0% under every decoding setting. Repetition is decode-fixable; termination isn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: SFT's next limitation — it can't teach "A is preferable to B" — with RM+PPO and DPO.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;InstructGPT — &lt;a href="https://arxiv.org/abs/2203.02155" rel="noopener noreferrer"&gt;arXiv:2203.02155&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;FLAN — &lt;a href="https://arxiv.org/abs/2109.01652" rel="noopener noreferrer"&gt;arXiv:2109.01652&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://rlhfbook.com" rel="noopener noreferrer"&gt;The RLHF Book&lt;/a&gt; — Nathan Lambert&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/docs/trl" rel="noopener noreferrer"&gt;TRL documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://implicit-none.com/en/vllm-tpu-v6e1-vs-a100-benchmark/" rel="noopener noreferrer"&gt;TPU v6e-1 vs A100 80GB Single-Chip Showdown (Qwen3-8B on vLLM)&lt;/a&gt; — the inference-side measurements for the same Qwen family&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>posttraining</category>
      <category>sft</category>
    </item>
    <item>
      <title>pgvector vs LanceDB: A Reproducible Vector Search Benchmark (100k 1536-dim)</title>
      <dc:creator>Sho Tanaka (tsho)</dc:creator>
      <pubDate>Sat, 08 Aug 2026 04:56:27 +0000</pubDate>
      <link>https://dev.to/tsho/pgvector-vs-lancedb-a-reproducible-vector-search-benchmark-100k-x-1536-dim-odm</link>
      <guid>https://dev.to/tsho/pgvector-vs-lancedb-a-reproducible-vector-search-benchmark-100k-x-1536-dim-odm</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://implicit-none.com/en/pgvector-vs-lancedb-benchmark/" rel="noopener noreferrer"&gt;implicit-none.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Whenever you build a vector database — for a recommendation engine, RAG, or anything else — the same question comes up: &lt;strong&gt;should vector search ride along in the Postgres you already run (pgvector), or live in a dedicated embedding store (like LanceDB)?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As far as I could find, reproducible same-data, same-query measurements are rare. So this post benchmarks two open-source options — &lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector&lt;/a&gt; 0.8.6 and &lt;a href="https://github.com/lancedb/lancedb" rel="noopener noreferrer"&gt;LanceDB&lt;/a&gt; 0.36.0 — on &lt;strong&gt;100k real OpenAI embeddings of DBpedia (1536-dim)&lt;/strong&gt; across four axes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ingest + index build&lt;/strong&gt; (speed and disk)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The recall-latency curve&lt;/strong&gt; (comparisons only make sense at equal recall, so both systems' search parameters are swept into curves)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent load&lt;/strong&gt; (1 thread vs 8 threads)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filtered search&lt;/strong&gt; (&lt;code&gt;WHERE&lt;/code&gt; + vector — unavoidable in real RAG)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The headline results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LanceDB ingests 20× faster and uses 1/3 the disk&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Single-threaded queries: LanceDB is ~2× faster at equal recall&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;But at 8 concurrent clients, pgvector flips it and wins by 1.8×&lt;/strong&gt; (server-side process parallelism vs embedded + GIL)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filtered search is the big discovery&lt;/strong&gt;: pgvector's recall can collapse to 0.07 or jump to exact depending on what the Postgres planner silently decides; LanceDB's prefilter is boringly consistent&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architectural difference, first
&lt;/h2&gt;

&lt;p&gt;These two aren't rivals on the same field — they're differently shaped tools, and that shapes how to read the numbers:&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;pgvector&lt;/th&gt;
&lt;th&gt;LanceDB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Form&lt;/td&gt;
&lt;td&gt;PostgreSQL extension (client/server)&lt;/td&gt;
&lt;td&gt;Embedded library (in-process)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invocation&lt;/td&gt;
&lt;td&gt;SQL over TCP&lt;/td&gt;
&lt;td&gt;Python function calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data format&lt;/td&gt;
&lt;td&gt;Postgres heap tables&lt;/td&gt;
&lt;td&gt;Lance (columnar, Arrow-native)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transactions&lt;/td&gt;
&lt;td&gt;Full Postgres ACID&lt;/td&gt;
&lt;td&gt;Optimistic concurrency&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;pgvector's latencies include connection and protocol overhead; LanceDB's don't. That's not unfair — it's &lt;strong&gt;how you'd actually use each one&lt;/strong&gt;, and this benchmark takes that stance.&lt;/p&gt;

&lt;p&gt;One terminology note: LanceDB is the database layer built on top of the &lt;a href="https://github.com/lance-format/lance" rel="noopener noreferrer"&gt;Lance format&lt;/a&gt; (a columnar format + engine developed under its own org). The storage and vector-index implementations live in the lance core; what this article measures is the &lt;code&gt;lancedb&lt;/code&gt; package usage on top of it (using the format directly via &lt;code&gt;pylance&lt;/code&gt; is a topic for the upcoming Lance vs Parquet article).&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment &amp;amp; setup
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Machine&lt;/td&gt;
&lt;td&gt;Apple M5 Pro / 48 GB RAM / macOS 26.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pgvector&lt;/td&gt;
&lt;td&gt;PostgreSQL 18.4 + pgvector &lt;strong&gt;0.8.6&lt;/strong&gt; (official Docker image, capped at 8 CPUs / 6 GB, shared_buffers 3GB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LanceDB&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.36.0&lt;/strong&gt; (Python 3.14, in-process)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://huggingface.co/datasets/KShivendu/dbpedia-entities-openai-1M" rel="noopener noreferrer"&gt;DBpedia OpenAI embeddings&lt;/a&gt;, 100,000 × 1536-dim, L2-normalized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queries&lt;/td&gt;
&lt;td&gt;1,000 held-out vectors (never inserted), k=10, cosine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ground truth&lt;/td&gt;
&lt;td&gt;Exact top-10 by full scan (recomputed per filter condition)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexes&lt;/td&gt;
&lt;td&gt;pgvector: HNSW (m=16, ef_construction=64) / LanceDB: IVF_HNSW_SQ (m=16, ef_construction=64)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter columns&lt;/td&gt;
&lt;td&gt;Boolean columns at 1% / 10% selectivity (deterministic by row index)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All measurements after warmup, fixed seeds, every setting auto-saved into the result JSONs (&lt;a href="https://github.com/tsho/tsho-lab/tree/main/pgvector-vs-lancedb" rel="noopener noreferrer"&gt;code on GitHub&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 1: Ingest &amp;amp; index build — a LanceDB landslide
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpl5z4ntk8qcik27wpme5.webp" 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%2Fpl5z4ntk8qcik27wpme5.webp" alt="Ingest and index build" width="799" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;pgvector&lt;/th&gt;
&lt;th&gt;LanceDB&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ingest (100k)&lt;/td&gt;
&lt;td&gt;31.3 s (3,190 vec/s)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.4 s (71,474 vec/s)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;22.4×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index build&lt;/td&gt;
&lt;td&gt;60.3 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.2 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11.6×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total disk&lt;/td&gt;
&lt;td&gt;2.48 GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.78 GB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.2×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;pgvector's ingest uses the standard &lt;code&gt;COPY&lt;/code&gt; (text format), which pays the structural cost of textifying 1536-dim vectors through the SQL layer. LanceDB writes Arrow tables essentially as-is. The disk gap comes from Lance's columnar format plus the SQ (scalar quantization) index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your pipeline regenerates or appends embeddings daily, this 20× gap is wall-clock time you feel.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 2: The recall-latency curve — LanceDB 2× faster single-threaded
&lt;/h2&gt;

&lt;p&gt;The core comparison. Since equal-recall comparison is the only fair method for ANN benchmarks, both systems' parameters are swept into curves:&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%2Fr71l4bofb6nvabdnn17p.webp" 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%2Fr71l4bofb6nvabdnn17p.webp" alt="Recall vs latency" width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;recall@10 band&lt;/th&gt;
&lt;th&gt;pgvector p50&lt;/th&gt;
&lt;th&gt;LanceDB p50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;~0.95&lt;/td&gt;
&lt;td&gt;2.50 ms (ef=40)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.30 ms&lt;/strong&gt; (nprobes=8)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~0.97–0.98&lt;/td&gt;
&lt;td&gt;2.86 ms (ef=80)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.58 ms&lt;/strong&gt; (np=8, refine=2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~0.99&lt;/td&gt;
&lt;td&gt;3.42 ms (ef=160)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1.86 ms&lt;/strong&gt; (np=16, refine=4)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;LanceDB is roughly 2× faster across the whole range — which, per the architecture note, includes pgvector's loopback TCP + SQL overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha&lt;/strong&gt;: LanceDB's IVF_HNSW_SQ &lt;strong&gt;plateaus around recall 0.95&lt;/strong&gt; on its own (scalar-quantization error). If you need high recall, &lt;code&gt;refine_factor&lt;/code&gt; (re-ranking with full-precision vectors) is mandatory — every ≥0.97 row above uses it. pgvector's HNSW is unquantized, so cranking ef_search takes it toward recall 1.0 with no extra knobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 3: Concurrency — pgvector flips it at 8 threads
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0xv35f6oroe4mzvuu0vg.webp" 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%2F0xv35f6oroe4mzvuu0vg.webp" alt="Concurrency scaling" width="800" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;1-thread QPS&lt;/th&gt;
&lt;th&gt;8-thread QPS&lt;/th&gt;
&lt;th&gt;Scaling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pgvector (ef=80, recall 0.98)&lt;/td&gt;
&lt;td&gt;350&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,376&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.8×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LanceDB (np=8, rf=2, recall 0.97)&lt;/td&gt;
&lt;td&gt;611&lt;/td&gt;
&lt;td&gt;1,338&lt;/td&gt;
&lt;td&gt;2.2×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The engine that was 2× faster alone loses by 1.8× under concurrency. The cause is structural:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;pgvector&lt;/strong&gt;: each connection gets its own Postgres backend process — 8 cores fully used (6.8× scaling)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LanceDB&lt;/strong&gt;: the search kernel is Rust, but it runs inside a Python process, so &lt;strong&gt;thread concurrency is constrained by the GIL&lt;/strong&gt; (2.2×)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're serving high QPS behind a web server, this reversal matters. LanceDB could do better with multiprocessing or a free-threaded (3.13+) Python build — but these numbers are the reality of using it from stock Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 4: Filtered search — pgvector is "planner-dependent", LanceDB is steady
&lt;/h2&gt;

&lt;p&gt;Combining &lt;code&gt;WHERE category = ...&lt;/code&gt; with vector search is mandatory in real RAG, and this is where the two personalities diverge most:&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%2Felk9otg7amfcqjj2t743.webp" 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%2Felk9otg7amfcqjj2t743.webp" alt="Filtered search" width="800" height="319"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LanceDB (prefilter)&lt;/strong&gt;: recall 0.97–1.0 at p50 2.3–7.5 ms at both selectivities — &lt;strong&gt;consistently predictable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pgvector&lt;/strong&gt;: results are governed by &lt;strong&gt;which plan the Postgres planner picks&lt;/strong&gt;. Verified by recording EXPLAIN for every case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1% selectivity&lt;/strong&gt;: the planner skips HNSW entirely and runs an &lt;strong&gt;exact scan + sort&lt;/strong&gt; → recall 1.0 at ~5 ms. With only 1,000 candidate rows, that's the right call&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10% selectivity&lt;/strong&gt;: &lt;strong&gt;the plan flips non-monotonically with ef_search&lt;/strong&gt; — HNSW at ef=10 and ef=80–320 (fast, recall 0.09–0.99), exact at ef=20–40 (recall 1.0 but 28 ms)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hnsw.iterative_scan = relaxed_order&lt;/code&gt;&lt;/strong&gt; (new in 0.8) lifts the HNSW-path recall from 0.76 to 0.97 at ef=80. &lt;strong&gt;Non-negotiable if you do filtered search on pgvector&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The most important gotcha&lt;/strong&gt;: right after a bulk load (before &lt;code&gt;ANALYZE&lt;/code&gt;), the planner has no statistics and picks HNSW + post-filter even at 1% selectivity — &lt;strong&gt;recall collapsed to 0.071&lt;/strong&gt;. The identical query returns recall 1.0 after ANALYZE. &lt;strong&gt;Always ANALYZE after bulk-loading into pgvector&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short: pgvector's filtered search lets the planner silently decide between fast and exact, and demands tuning literacy (ANALYZE, iterative_scan, reading plans). LanceDB has none of that complexity — but also none of the planner's cleverness (like falling back to exact at 1%).&lt;/p&gt;

&lt;h2&gt;
  
  
  Which to pick
&lt;/h2&gt;

&lt;p&gt;Two axes — performance and operations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Postgres already deployed, high QPS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;pgvector&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.8× at 8 threads; ACID, backups, and permissions come free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ML pipeline with frequent embedding regeneration&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;LanceDB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20× ingest, Arrow/pandas-native, zero servers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filter + vector compound queries dominate&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;LanceDB&lt;/strong&gt; (or pgvector + tuning literacy)&lt;/td&gt;
&lt;td&gt;predictable prefilter; pgvector needs ANALYZE/iterative_scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single-request latency first (agent memory etc.)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;LanceDB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~2× faster at equal recall, in-process, no network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many millions of rows, ops team = DBAs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;pgvector&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;reuse the whole Postgres operational stack&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;Measured pgvector 0.8.6 vs LanceDB 0.36.0 on identical data (100k × 1536-dim), identical queries, recall-matched, across four axes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LanceDB: 22.4× ingest, 11.6× index build, 1/3.2 disk&lt;/strong&gt; — decisive for write-heavy workloads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LanceDB ~2× faster single-threaded at equal recall; pgvector wins 1.8× at 8 concurrent clients.&lt;/strong&gt; "One client or many" is the fork in the road&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filtered search is pgvector's minefield&lt;/strong&gt;: recall 0.07 without ANALYZE, non-monotonic plan flips with ef_search — both measured, both fixed by &lt;code&gt;iterative_scan = relaxed_order&lt;/code&gt; + ANALYZE&lt;/li&gt;
&lt;li&gt;LanceDB plateaus at recall ~0.95 without &lt;code&gt;refine_factor&lt;/code&gt; (SQ quantization) — always add it for high-precision requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Future work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The 1M-scale rerun&lt;/strong&gt; (memory and index-build gaps should widen further)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lance vs Parquet&lt;/strong&gt;: format-level read/write comparison (part of this blog's data engineering series)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unquantized HNSW head-to-head&lt;/strong&gt;: LanceDB's flat-family indexes vs pgvector&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Benchmark code
&lt;/h2&gt;

&lt;p&gt;All scripts (Docker Compose, data prep, ground-truth generation, both benchmarks, chart generation) and the measured JSONs are at &lt;a href="https://github.com/tsho/tsho-lab/tree/main/pgvector-vs-lancedb" rel="noopener noreferrer"&gt;tsho/tsho-lab&lt;/a&gt;. Reproduce with &lt;code&gt;docker compose up&lt;/code&gt; plus three scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/pgvector/pgvector" rel="noopener noreferrer"&gt;pgvector (GitHub)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lancedb/lancedb" rel="noopener noreferrer"&gt;LanceDB (GitHub — Rust core with Python/TS bindings)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lance-format/lance" rel="noopener noreferrer"&gt;Lance format (GitHub — the format underneath LanceDB, now governed under the lance-format org)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lancedb.github.io/lancedb/" rel="noopener noreferrer"&gt;LanceDB Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/pgvector/pgvector/releases" rel="noopener noreferrer"&gt;pgvector 0.8.0 release — iterative index scans&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/datasets/KShivendu/dbpedia-entities-openai-1M" rel="noopener noreferrer"&gt;DBpedia OpenAI 1M embeddings (Hugging Face)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>ai</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why the First Turn in a Coding Agent Can Use So Many Input Tokens — and Why That Gets Better Over Time</title>
      <dc:creator>Sho Tanaka (tsho)</dc:creator>
      <pubDate>Wed, 29 Apr 2026 17:22:26 +0000</pubDate>
      <link>https://dev.to/snowflake/why-the-first-turn-in-a-coding-agent-can-use-so-many-input-tokens-and-why-that-gets-better-over-f8b</link>
      <guid>https://dev.to/snowflake/why-the-first-turn-in-a-coding-agent-can-use-so-many-input-tokens-and-why-that-gets-better-over-f8b</guid>
      <description>&lt;p&gt;Coding agents such as Cortex Code, Claude Code, Codex, and Cursor rely on large language models (LLMs) behind the scenes. A common question from users is: “Why does my first turn consume so many input tokens when I only typed a short prompt?” This post explains how prompt caching works in these systems, why the first turn often looks expensive, and why cache hit rates usually improve as a session continues.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key point&lt;/strong&gt;: Coding agents like Cortex Code benefit from the same general prompt-caching principles described by Anthropic and OpenAI. Understanding those mechanics helps you interpret token usage more accurately.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Why the First Turn Can Look Expensive
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1-1. Why users notice high input token usage on the first turn
&lt;/h3&gt;

&lt;p&gt;When you start a new session in a coding agent and type something simple like “fix the typo in line 3,” the API usage may show thousands of input tokens — far more than your short message. This is because the total prompt sent to the LLM usually includes much more than your message:&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;Typical size (example)&lt;/th&gt;
&lt;th&gt;Changes between turns?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;System prompt&lt;/td&gt;
&lt;td&gt;2,000–10,000+ tokens&lt;/td&gt;
&lt;td&gt;Usually no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool definitions (file read, write, search, bash, etc.)&lt;/td&gt;
&lt;td&gt;3,000–8,000+ tokens&lt;/td&gt;
&lt;td&gt;Usually no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Additional instructions and rules&lt;/td&gt;
&lt;td&gt;1,000–5,000+ tokens&lt;/td&gt;
&lt;td&gt;Usually no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your message&lt;/td&gt;
&lt;td&gt;10–200 tokens&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Even a 5-word user prompt can result in a 10,000+ token API request because the system prompt, tool definitions, and other static instructions are prepended to every turn.&lt;/p&gt;

&lt;h3&gt;
  
  
  1-2. Why a short user prompt does not mean a small total prompt
&lt;/h3&gt;

&lt;p&gt;The model needs the full relevant context on every turn. In a standard stateless API pattern, that means the system prompt, tools, instructions, and conversation history are sent again with each request. On the first turn, there is typically no reusable cache entry yet, so the request must be processed from scratch. Anthropic’s Messages API follows this explicit multi-turn pattern, where developers construct each turn and manage conversation state themselves.&lt;/p&gt;

&lt;p&gt;This is not a bug or wasted spend. It is how the model preserves context across separate API calls. The good news is that much of this content is repeated across turns, which is exactly what prompt caching is designed to exploit. Anthropic documents prompt caching for repeated prefixes, and OpenAI likewise describes automatic reuse of previously computed prompt prefixes on supported models.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What Gets Reused Across Turns
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2-1. What makes up the reusable prefix: system prompt, tools, instructions, and history
&lt;/h3&gt;

&lt;p&gt;Prompt caching works by reusing a previously computed prompt prefix.&lt;/p&gt;

&lt;p&gt;A coding-agent request often looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Tools] → [System prompt] → [Other static instructions] → [Message history] → [New user message]
 ←—————————————— reusable prefix candidate ——————————————→              ← new content →
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Anthropic, prompt caching applies to the prompt prefix across cacheable blocks such as &lt;code&gt;tools&lt;/code&gt;, &lt;code&gt;system&lt;/code&gt;, and &lt;code&gt;messages&lt;/code&gt;, depending on where the cache breakpoint is placed. Anthropic currently supports both automatic caching and explicit cache breakpoints. With automatic caching, the system manages the breakpoint for you and moves it forward as the conversation grows.&lt;/p&gt;

&lt;p&gt;In OpenAI, prompt caching works automatically on supported models for prompts longer than 1,024 tokens. The API reuses the longest previously computed prompt prefix, starting at 1,024 tokens and increasing in 128-token increments.&lt;/p&gt;

&lt;h3&gt;
  
  
  2-2. The difference between cacheable context, cache writes, and cache hits
&lt;/h3&gt;

&lt;p&gt;There are three practical categories to think about:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Anthropic&lt;/th&gt;
&lt;th&gt;OpenAI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cache write / cache creation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The prefix is being cached for future reuse&lt;/td&gt;
&lt;td&gt;Billed separately from normal input; pricing depends on the cache setting&lt;/td&gt;
&lt;td&gt;Automatic behavior; no separate cache-write fee&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cache read / cache hit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A previously cached prefix is reused&lt;/td&gt;
&lt;td&gt;Discounted relative to uncached input&lt;/td&gt;
&lt;td&gt;Discounted relative to uncached input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Uncached input&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tokens after the reusable prefix, or tokens not served from cache&lt;/td&gt;
&lt;td&gt;Standard input pricing&lt;/td&gt;
&lt;td&gt;Standard input pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the first turn, there is usually no prior cache entry, so most or all of the request is effectively new. On later turns, repeated prefix content may be served from cache, depending on factors such as model support, prompt length, retention window, routing, and whether the prefix remains unchanged. Anthropic exposes &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; and &lt;code&gt;cache_read_input_tokens&lt;/code&gt; in usage reporting, while OpenAI exposes cached prompt usage through &lt;code&gt;prompt_tokens_details.cached_tokens&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For Anthropic, the usage formula is documented as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;total_input_tokens = cache_read_input_tokens + cache_creation_input_tokens + input_tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anthropic also notes that minimum cacheable prompt lengths differ by model family, rather than being a single universal threshold.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Why Cache Hit Rates Improve Over Time
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3-1. Why cache hit rates usually improve after the first few turns
&lt;/h3&gt;

&lt;p&gt;Here is what typically happens in a multi-turn coding-agent session:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Turn&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Likely cache behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Turn 1&lt;/td&gt;
&lt;td&gt;System + Tools + User(1)&lt;/td&gt;
&lt;td&gt;Everything is new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn 2&lt;/td&gt;
&lt;td&gt;System + Tools + User(1) + Asst(1) + User(2)&lt;/td&gt;
&lt;td&gt;Earlier shared prefix may be reused; newer content is new&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn 3&lt;/td&gt;
&lt;td&gt;System + Tools + User(1) + Asst(1) + User(2) + Asst(2) + User(3)&lt;/td&gt;
&lt;td&gt;A longer shared prefix may be reused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn N&lt;/td&gt;
&lt;td&gt;Full history&lt;/td&gt;
&lt;td&gt;A large fraction of the repeated prefix may be cached&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In many healthy multi-turn sessions, the cache hit rate improves after the first turn because the repeated prefix gets larger and more stable. That is why judging costs based only on the first turn can be misleading. Anthropic explicitly describes multi-turn caching that moves forward with the conversation, and OpenAI describes reuse of the longest previously computed prefix.&lt;/p&gt;

&lt;p&gt;That said, the exact hit rate will vary. It depends on the model, the prompt length, whether the repeated prefix is identical, how long the session has been idle, and provider-specific routing behavior. So percentages like “60–80% on turn 2” or “80–95% on turn 4+” should be treated as common patterns, not guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. How to Improve Cache Reuse in Practice
&lt;/h2&gt;

&lt;h3&gt;
  
  
  4-1. How to improve cache hit rates without changing developer behavior too much
&lt;/h3&gt;

&lt;p&gt;Based on how caching works, here are some practical tips:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep sessions alive when possible&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Starting a brand-new session often means rebuilding the reusable prefix from scratch. Longer, continuous sessions generally create more opportunities for cache reuse.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep the repeated prefix stable&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Prompt caching depends on prefix reuse. If your system prompt, static instructions, or tool definitions change frequently, cache reuse will usually drop.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Avoid unnecessary prompt reordering&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Put the most stable content first and the most dynamic content later. OpenAI’s guidance explicitly recommends placing static content at the beginning and variable content at the end to improve cache effectiveness.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use provider features that preserve cacheability&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Anthropic documents that, in some tool-search workflows, additional tools can be introduced without breaking the cached prefix because the prefix itself remains unchanged.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Let the platform handle cache placement when supported&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
In many cases, prompt caching is automatic. Anthropic supports automatic caching via a top-level &lt;code&gt;cache_control&lt;/code&gt; setting, and OpenAI applies prompt caching automatically on supported models without additional integration changes.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  Switching models mid-session usually reduces cache reuse
&lt;/h4&gt;

&lt;p&gt;Prompt caches are provider- and model-specific. If you switch to a different model in the middle of a session, previously reusable prefixes may no longer apply to the new model, and the next request may need to be processed largely from scratch for that model. As a rule of thumb, if you want to maximize cache reuse, it is usually better to stick with one model throughout a session. This follows from the provider documentation being model-specific and from extended retention options being available only for certain OpenAI models.&lt;/p&gt;

&lt;h3&gt;
  
  
  4-2. Common misunderstandings about “high token usage on the first prompt”
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Misunderstanding&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;“The first turn used 15K tokens — that’s wasteful”&lt;/td&gt;
&lt;td&gt;Those tokens often include the system prompt, tools, and static instructions that can later be reused through caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“A bigger system prompt is always a bad idea”&lt;/td&gt;
&lt;td&gt;A larger stable prefix may have an upfront cost, but repeated reuse can make it much cheaper over a session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“Cache writes are always an extra penalty”&lt;/td&gt;
&lt;td&gt;Anthropic and OpenAI expose caching differently; you should interpret usage using each provider’s own pricing and usage fields&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;“My cache hit rate is low”&lt;/td&gt;
&lt;td&gt;The first turn often has little or no reuse. It is more useful to evaluate cache behavior across the full session&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  5. How to Calculate and Monitor Cache Hit Rates
&lt;/h2&gt;

&lt;h3&gt;
  
  
  5-1. How cache hit rate is calculated
&lt;/h3&gt;

&lt;p&gt;A simple way to think about cache hit rate is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache_hit_rate = cache_read_input_tokens / (cache_read_input_tokens + cache_creation_input_tokens + input_tokens)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This formula maps cleanly onto Anthropic’s usage fields. For OpenAI, you typically estimate cache reuse using &lt;code&gt;prompt_tokens_details.cached_tokens&lt;/code&gt; relative to total prompt tokens. Anthropic and OpenAI expose different fields, so the exact calculation is provider-specific.&lt;/p&gt;

&lt;p&gt;In a healthy multi-turn coding session, you will often see this pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turn 1&lt;/strong&gt;: little or no cache reuse&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn 2–3&lt;/strong&gt;: noticeable reuse if the prefix is stable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Later turns&lt;/strong&gt;: higher reuse if the conversation remains active and the repeated prefix continues to match&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5-2. How to monitor cache hit rates in practice
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Anthropic API&lt;/strong&gt; returns usage fields like:&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;"cache_creation_input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cache_read_input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;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;"input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&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;On an early turn, &lt;code&gt;cache_read_input_tokens&lt;/code&gt; may be 0 while &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; is high. On later turns, &lt;code&gt;cache_read_input_tokens&lt;/code&gt; may grow as more of the repeated prefix is reused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAI API&lt;/strong&gt; returns fields like:&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;"prompt_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2006&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prompt_tokens_details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cached_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cached_tokens&lt;/code&gt; field indicates how many prompt tokens were served from cache. OpenAI documents automatic prompt caching on supported models for prompts longer than 1,024 tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache retention&lt;/strong&gt; also matters.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic&lt;/strong&gt;: default 5-minute cache lifetime, with a 1-hour option available. Anthropic also notes that cache behavior is managed either automatically or via explicit cache breakpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt;: in-memory cached prefixes are typically retained for a short inactive window, and extended prompt cache retention of up to 24 hours is available for supported models.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your session sits idle long enough for the retention window to expire, the next request may behave more like a fresh cache write or a cache miss. In interactive coding sessions, though, users often stay well within these windows.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Final Takeaway
&lt;/h2&gt;

&lt;p&gt;If your coding agent shows high input token usage on the first turn, that does not necessarily mean the system is inefficient. In many cases, you are seeing the cost of sending and processing the full reusable context: tools, system instructions, static guidance, and conversation history. Prompt caching exists precisely to make those repeated prefixes cheaper and faster on later turns. Anthropic and OpenAI both document prefix-based caching that rewards stable repeated context over multi-turn sessions.&lt;/p&gt;

&lt;p&gt;So when evaluating token usage in a coding agent, do not judge the session by the first turn alone. Look at the full conversation. The first turn often establishes the reusable prefix; later turns are where prompt caching usually starts to pay off.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching" rel="noopener noreferrer"&gt;Anthropic Claude API docs — Prompt caching&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-use-with-prompt-caching" rel="noopener noreferrer"&gt;Anthropic Claude API docs — Tool use with prompt caching&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://claude.com/blog/prompt-caching" rel="noopener noreferrer"&gt;Anthropic blog — Prompt caching with Claude&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/docs/guides/prompt-caching" rel="noopener noreferrer"&gt;OpenAI docs — Prompt caching&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://openai.com/index/api-prompt-caching/" rel="noopener noreferrer"&gt;OpenAI blog — Prompt Caching in the API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anthropics/claude-cookbooks/blob/main/misc/prompt_caching.ipynb" rel="noopener noreferrer"&gt;Anthropic cookbook — prompt_caching.ipynb&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/openai/openai-cookbook/blob/main/examples/Prompt_Caching101.ipynb" rel="noopener noreferrer"&gt;OpenAI cookbook — Prompt_Caching101.ipynb&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>snowflake</category>
      <category>claude</category>
      <category>openai</category>
    </item>
    <item>
      <title>TPU v6e vs A100 80GB 2 for Gemma 4 31B on vLLM: 21 Benchmarks Show When Each Wins</title>
      <dc:creator>Sho Tanaka (tsho)</dc:creator>
      <pubDate>Sun, 19 Apr 2026 03:00:00 +0000</pubDate>
      <link>https://dev.to/tsho/tpu-v6e-vs-a100-80gbx2-for-gemma-4-31b-on-vllm-21-benchmarks-show-when-each-wins-2a0k</link>
      <guid>https://dev.to/tsho/tpu-v6e-vs-a100-80gbx2-for-gemma-4-31b-on-vllm-21-benchmarks-show-when-each-wins-2a0k</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why "speed" and "cost" of LLM inference matter more than ever
&lt;/h3&gt;

&lt;p&gt;LLMs have moved from research demos to product surfaces, and the operational concern has shifted from accuracy to &lt;strong&gt;how fast and how cheap&lt;/strong&gt; we can serve them. For chat-style interactions, &lt;strong&gt;TTFT (Time To First Token)&lt;/strong&gt; is the metric users actually feel; for RAG, summarization, and code generation, &lt;strong&gt;TPOT (Time Per Output Token, the average inter-token time after the first one)&lt;/strong&gt; and the server-wide &lt;strong&gt;throughput&lt;/strong&gt; drive the bill.&lt;/p&gt;

&lt;p&gt;Whether on-prem or on GCP / AWS / Azure, hardware choice can swing the cost of running the same model by &lt;strong&gt;multiples&lt;/strong&gt;. That's exactly why a benchmark that says, for &lt;em&gt;your&lt;/em&gt; workload, &lt;em&gt;which placement to pick&lt;/em&gt; is increasingly valuable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why vLLM
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.vllm.ai/" rel="noopener noreferrer"&gt;vLLM&lt;/a&gt; has effectively become the de-facto serving engine for open-weight LLMs. We picked it for this benchmark because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PagedAttention&lt;/strong&gt; removes KV-cache fragmentation. A virtual-memory-style paging scheme makes variable-length KV cache cheap to manage, which boosts concurrent request capacity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuous batching&lt;/strong&gt; keeps the accelerator busy. Tokens — not requests — are the scheduling unit, so a freed slot is immediately filled by the next prefill. Throughput consistently dominates static batching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI-compatible API&lt;/strong&gt; (&lt;code&gt;/v1/chat/completions&lt;/code&gt;, &lt;code&gt;/v1/completions&lt;/code&gt;) — drop-in for existing clients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-accelerator support&lt;/strong&gt; — the same code path runs on NVIDIA (Ampere/Hopper), AMD ROCm, and Cloud TPU (v5e, v6e). That's exactly what makes a fair TPU vs GPU comparison possible in a single article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Official Docker images&lt;/strong&gt; (&lt;code&gt;vllm/vllm-openai&lt;/code&gt;, &lt;code&gt;vllm/vllm-tpu&lt;/code&gt;) eliminate dependency drift. We used them throughout this benchmark.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What this article covers
&lt;/h3&gt;

&lt;p&gt;We use &lt;strong&gt;&lt;a href="https://huggingface.co/google/gemma-4-31B-it" rel="noopener noreferrer"&gt;Gemma 4 31B Instruct&lt;/a&gt;&lt;/strong&gt;, the latest dense model from Google, and serve it on GCP with vLLM, running &lt;code&gt;vllm bench serve&lt;/code&gt; against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TPU v6e-4 (Trillium)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NVIDIA A100 80GB × 2&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3 input/output profiles&lt;/strong&gt; (short / medium / long) × &lt;strong&gt;7 request rates&lt;/strong&gt; (1, 2, 4, 8, 16, 32, ∞) → &lt;strong&gt;21 cases per platform&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Three families of metrics: &lt;strong&gt;Throughput&lt;/strong&gt; (req/s, tokens/s), &lt;strong&gt;Latency&lt;/strong&gt; (TTFT, TPOT, ITL), &lt;strong&gt;Cost&lt;/strong&gt; ($ / 1M output tokens).&lt;/li&gt;
&lt;li&gt;One important caveat: vLLM's Gemma 4 attention path has an &lt;code&gt;assert&lt;/code&gt; that rejects &lt;code&gt;fp8_e5m2&lt;/code&gt; KV cache, and &lt;code&gt;fp8_e4m3&lt;/code&gt; is Hopper-only. So &lt;strong&gt;A100 ran with bf16 KV cache&lt;/strong&gt; while TPU ran with fp8 — we'll be explicit about how that affects the numbers below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Beyond the numbers, we also share the &lt;strong&gt;operational gotchas&lt;/strong&gt; we hit on both sides — quota, capacity, image setup, error reading — so readers can budget for them next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment &amp;amp; Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Hardware
&lt;/h3&gt;

&lt;p&gt;We followed vLLM's &lt;a href="https://docs.vllm.ai/projects/recipes/en/latest/Google/Gemma4.html" rel="noopener noreferrer"&gt;official Gemma 4 recipe&lt;/a&gt;, which lists the minimum requirement for the 31B dense variant as &lt;strong&gt;80GB GPU with TP=2&lt;/strong&gt; or &lt;strong&gt;TPU v6e (Trillium) × 4 chips&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;GCP instance&lt;/th&gt;
&lt;th&gt;Accelerator&lt;/th&gt;
&lt;th&gt;Parallelism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPU (A100)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;a2-ultragpu-2g&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A100 80GB × 2&lt;/td&gt;
&lt;td&gt;tensor-parallel-size=2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TPU (Trillium)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v6e-4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;TPU v6e × 4&lt;/td&gt;
&lt;td&gt;tensor-parallel-size=4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;H100 80GB×2 is on the wishlist for a follow-up (see &lt;em&gt;Future work&lt;/em&gt;).&lt;/p&gt;

&lt;p&gt;L4 (24GB) and TPU v5e (16GB) lack the memory for Gemma 4 31B and are not part of this comparison.&lt;/p&gt;

&lt;h3&gt;
  
  
  Software
&lt;/h3&gt;

&lt;p&gt;We run vLLM through the &lt;strong&gt;official Docker images on both sides&lt;/strong&gt;. Pip-based install is technically possible on GPUs, but on TPU the standard PyPI vLLM (CUDA build) gets pulled in and the server fails with &lt;code&gt;Failed to infer device type&lt;/code&gt;. To keep operations symmetric, we put both platforms on Docker.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;GPU side&lt;/th&gt;
&lt;th&gt;TPU side&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Host OS&lt;/td&gt;
&lt;td&gt;Ubuntu 24.04 LTS (DLVM &lt;code&gt;common-cu129-ubuntu-2404-nvidia-580&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Ubuntu 22.04 LTS (TPU runtime &lt;code&gt;v2-alpha-tpuv6e&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host Python&lt;/td&gt;
&lt;td&gt;3.12&lt;/td&gt;
&lt;td&gt;3.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CUDA / TPU stack&lt;/td&gt;
&lt;td&gt;CUDA 12.9 + NVIDIA Driver 580&lt;/td&gt;
&lt;td&gt;libtpu (v6e), torch_xla (bundled in image)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vLLM Docker image&lt;/td&gt;
&lt;td&gt;&lt;a href="https://hub.docker.com/r/vllm/vllm-openai" rel="noopener noreferrer"&gt;&lt;code&gt;vllm/vllm-openai:latest&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://hub.docker.com/r/vllm/vllm-tpu" rel="noopener noreferrer"&gt;&lt;code&gt;vllm/vllm-tpu:gemma4&lt;/code&gt;&lt;/a&gt; (per Gemma 4 recipe)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GPU-side launch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;sudo docker run -itd --name gemma4-gpu \
  --gpus all --ipc=host --shm-size=16g \
  -v ~/.cache/huggingface:/root/.cache/huggingface \
  -e HF_TOKEN="$HF_TOKEN" -p 8000:8000 \
  vllm/vllm-openai:latest \
    --model google/gemma-4-31B-it \
    --tensor-parallel-size 2 \
    --max-model-len 16384 \
    --gpu-memory-utilization 0.90 \
    --kv-cache-dtype auto \
    --host 0.0.0.0 --port 8000

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

&lt;/div&gt;



&lt;p&gt;TPU-side launch (per the official recipe; we put &lt;code&gt;vllm serve&lt;/code&gt; first because the entrypoint is a pass-through):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;sudo docker run -itd --name gemma4-tpu \
  --privileged --network host --shm-size 16G \
  -v /dev/shm:/dev/shm \
  -e HF_TOKEN="$HF_TOKEN" \
  vllm/vllm-tpu:gemma4 \
    vllm serve google/gemma-4-31B-it \
      --tensor-parallel-size 4 \
      --max-model-len 16384 \
      --disable_chunked_mm_input \
      --host 0.0.0.0 --port 8000

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

&lt;/div&gt;



&lt;h4&gt;
  
  
  KV cache dtype: not symmetric between GPU and TPU
&lt;/h4&gt;

&lt;p&gt;We wanted to align the KV cache dtype across platforms, but the combination we wanted wasn't possible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;fp8_e4m3&lt;/code&gt; (= &lt;code&gt;fp8e4nv&lt;/code&gt;) is &lt;strong&gt;Hopper-only&lt;/strong&gt; ; on A100 it fails Triton compilation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fp8_e5m2&lt;/code&gt; is &lt;strong&gt;rejected by vLLM's Gemma 4 attention implementation&lt;/strong&gt; with &lt;code&gt;assert kv_cache_dtype in {"fp8", "fp8_e4m3"}&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;→ A100 was forced to &lt;code&gt;--kv-cache-dtype auto&lt;/code&gt; (= bf16). TPU v6e auto-enables &lt;code&gt;fp8_e5m2&lt;/code&gt; (we saw the log line &lt;code&gt;Automatically using fp8_e5m2 for FP8 KV cache on TPU v6e&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The practical impact: &lt;strong&gt;A100's per-request KV cache is roughly 2× larger than TPU's&lt;/strong&gt; , which roughly halves its concurrent capacity. Read the GPU numbers below as the &lt;em&gt;bf16-KV-cache&lt;/em&gt; number, not as an upper bound for A100. The clean FP8-on-FP8 comparison is on H100 — we're moving that to &lt;em&gt;Future work&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;For reproducibility, we capture &lt;code&gt;docker image inspect&lt;/code&gt; output (&lt;code&gt;RepoDigests&lt;/code&gt;) alongside each run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/google/gemma-4-31B-it" rel="noopener noreferrer"&gt;&lt;code&gt;google/gemma-4-31B-it&lt;/code&gt;&lt;/a&gt; (31B dense, instruction-tuned, Apache 2.0, 256k context)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gemma 4 is officially supported by vLLM, so the same code path serves it on both TPU and GPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dataset
&lt;/h3&gt;

&lt;p&gt;We use vLLM's built-in &lt;code&gt;--dataset-name random&lt;/code&gt; so we can pin input/output token lengths exactly. This guarantees identical workload shapes across platforms.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;Input tokens&lt;/th&gt;
&lt;th&gt;Output tokens&lt;/th&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;short&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;Chat replies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;4096&lt;/td&gt;
&lt;td&gt;512&lt;/td&gt;
&lt;td&gt;RAG / mid-length generation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;long&lt;/td&gt;
&lt;td&gt;8000&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;Long-form summarization, code generation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How we drove the load
&lt;/h3&gt;

&lt;p&gt;We use vLLM's official &lt;code&gt;vllm bench serve&lt;/code&gt; CLI as the load generator. The server runs in one process (&lt;code&gt;vllm serve&lt;/code&gt;); the bench client runs separately.&lt;/p&gt;

&lt;p&gt;Server start (example, H100×2 with TP=2):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve google/gemma-4-31B-it &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tensor-parallel-size&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 16384 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.90 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kv-cache-dtype&lt;/span&gt; fp8

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--kv-cache-dtype fp8&lt;/code&gt; is the recipe-recommended option that cuts KV cache memory by ~50% (when supported).&lt;/p&gt;

&lt;p&gt;Bench run (example):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm bench serve &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; google/gemma-4-31B-it &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dataset-name&lt;/span&gt; random &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--random-input-len&lt;/span&gt; 1024 &lt;span class="nt"&gt;--random-output-len&lt;/span&gt; 256 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--request-rate&lt;/span&gt; 8 &lt;span class="nt"&gt;--num-prompts&lt;/span&gt; 1000

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

&lt;/div&gt;



&lt;p&gt;The TPU side runs the same model, dataset, and lengths — only &lt;code&gt;--tensor-parallel-size&lt;/code&gt; changes to 4.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sweep
&lt;/h3&gt;

&lt;p&gt;For each platform we sweep 3 input/output profiles × &lt;code&gt;request-rate ∈ {1, 2, 4, 8, 16, 32, inf}&lt;/code&gt;, so 21 cases. &lt;code&gt;inf&lt;/code&gt; corresponds to offline maximum-throughput measurement. Each run is preceded by a single warmup round; results are saved as JSON and uploaded to GCS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metrics
&lt;/h3&gt;

&lt;p&gt;Three families:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Throughput&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Output throughput (tokens/s): server-wide output tokens generated per second&lt;/li&gt;
&lt;li&gt;Request throughput (req/s): completed requests per second&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Latency&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TTFT: time from request to first token&lt;/li&gt;
&lt;li&gt;TPOT: average inter-token time after the first token&lt;/li&gt;
&lt;li&gt;E2E p50/p95/p99: full-request latency percentiles&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Cost&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We compute "cost per 1M output tokens" in $ from the GCP on-demand price:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$/1M tokens = (instance_$_per_hour / 3600) × total_seconds / total_output_tokens × 1_000_000

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

&lt;/div&gt;



&lt;p&gt;We use on-demand prices (no Spot) at the time of measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;For each profile we fix &lt;code&gt;--num-prompts 1000&lt;/code&gt; and sweep request rate over 1 / 2 / 4 / 8 / 16 / 32 / ∞. Numbers below are taken from the raw JSON: &lt;code&gt;request_throughput&lt;/code&gt;, &lt;code&gt;output_throughput&lt;/code&gt;, &lt;code&gt;median_ttft_ms&lt;/code&gt;, &lt;code&gt;median_tpot_ms&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Summary: saturation throughput
&lt;/h3&gt;

&lt;p&gt;For each profile, we take the maximum Output throughput across the 7 rates as the "saturation" point. &lt;strong&gt;TPU wins on short, A100 wins on medium and long.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Output throughput vs. request rate
&lt;/h3&gt;

&lt;p&gt;X axis is request rate (req/s); &lt;code&gt;∞&lt;/code&gt; is &lt;code&gt;--request-rate inf&lt;/code&gt; (1000 prompts dispatched at once). On short, TPU scales linearly from rate=4 and lands above 1,000 tokens/s. On medium and long, both platforms saturate at rate=1 and the curves are flat above it.&lt;/p&gt;

&lt;h3&gt;
  
  
  TPOT (per-output-token time) vs. request rate
&lt;/h3&gt;

&lt;p&gt;TPOT is &lt;strong&gt;lower (= faster) on TPU across every profile and every rate&lt;/strong&gt;. This is the metric that drives the perceived smoothness of streaming responses.&lt;/p&gt;

&lt;h3&gt;
  
  
  TTFT (time to first token) vs. request rate (log scale)
&lt;/h3&gt;

&lt;p&gt;The Y axis is logarithmic. At low rates both platforms stay in the millisecond range; once saturated, queues stretch into seconds and minutes. The high-rate end of short and the entire long profile show this clearly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Short profile (input 1024 / output 256)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;th&gt;TPU req/s&lt;/th&gt;
&lt;th&gt;A100 req/s&lt;/th&gt;
&lt;th&gt;TPU out_tok/s&lt;/th&gt;
&lt;th&gt;A100 out_tok/s&lt;/th&gt;
&lt;th&gt;TPU TTFT p50 (ms)&lt;/th&gt;
&lt;th&gt;A100 TTFT p50 (ms)&lt;/th&gt;
&lt;th&gt;TPU TPOT p50 (ms)&lt;/th&gt;
&lt;th&gt;A100 TPOT p50 (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;1.00&lt;/td&gt;
&lt;td&gt;0.99&lt;/td&gt;
&lt;td&gt;255&lt;/td&gt;
&lt;td&gt;254&lt;/td&gt;
&lt;td&gt;123&lt;/td&gt;
&lt;td&gt;249&lt;/td&gt;
&lt;td&gt;17.6&lt;/td&gt;
&lt;td&gt;33.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1.98&lt;/td&gt;
&lt;td&gt;1.97&lt;/td&gt;
&lt;td&gt;508&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;td&gt;135&lt;/td&gt;
&lt;td&gt;291&lt;/td&gt;
&lt;td&gt;23.6&lt;/td&gt;
&lt;td&gt;55.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3.76&lt;/td&gt;
&lt;td&gt;2.56&lt;/td&gt;
&lt;td&gt;963&lt;/td&gt;
&lt;td&gt;656&lt;/td&gt;
&lt;td&gt;6,634&lt;/td&gt;
&lt;td&gt;86,709&lt;/td&gt;
&lt;td&gt;46.2&lt;/td&gt;
&lt;td&gt;116.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;3.82&lt;/td&gt;
&lt;td&gt;2.88&lt;/td&gt;
&lt;td&gt;978&lt;/td&gt;
&lt;td&gt;738&lt;/td&gt;
&lt;td&gt;64,592&lt;/td&gt;
&lt;td&gt;99,110&lt;/td&gt;
&lt;td&gt;45.1&lt;/td&gt;
&lt;td&gt;115.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;3.89&lt;/td&gt;
&lt;td&gt;2.89&lt;/td&gt;
&lt;td&gt;995&lt;/td&gt;
&lt;td&gt;740&lt;/td&gt;
&lt;td&gt;90,980&lt;/td&gt;
&lt;td&gt;130,163&lt;/td&gt;
&lt;td&gt;45.0&lt;/td&gt;
&lt;td&gt;114.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;3.92&lt;/td&gt;
&lt;td&gt;2.89&lt;/td&gt;
&lt;td&gt;1,004&lt;/td&gt;
&lt;td&gt;740&lt;/td&gt;
&lt;td&gt;104,790&lt;/td&gt;
&lt;td&gt;145,809&lt;/td&gt;
&lt;td&gt;44.7&lt;/td&gt;
&lt;td&gt;114.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;∞&lt;/td&gt;
&lt;td&gt;3.91&lt;/td&gt;
&lt;td&gt;2.88&lt;/td&gt;
&lt;td&gt;1,002&lt;/td&gt;
&lt;td&gt;737&lt;/td&gt;
&lt;td&gt;120,783&lt;/td&gt;
&lt;td&gt;162,543&lt;/td&gt;
&lt;td&gt;44.7&lt;/td&gt;
&lt;td&gt;114.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;Saturation: TPU lands at ~3.9 req/s / ~1,000 tokens/s, A100 at ~2.9 req/s / ~740 tokens/s. &lt;strong&gt;TPU is 1.35–1.36× higher&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A100 spikes TTFT to 86 seconds at rate=4, indicating it has hit the concurrency ceiling (bf16 KV cache).&lt;/li&gt;
&lt;li&gt;TPOT is roughly half on TPU at saturation (44.7 ms vs 114.8 ms). &lt;strong&gt;TPU is 2.55× faster.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Medium profile (input 4096 / output 512)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;th&gt;TPU req/s&lt;/th&gt;
&lt;th&gt;A100 req/s&lt;/th&gt;
&lt;th&gt;TPU out_tok/s&lt;/th&gt;
&lt;th&gt;A100 out_tok/s&lt;/th&gt;
&lt;th&gt;TPU TTFT p50 (ms)&lt;/th&gt;
&lt;th&gt;A100 TTFT p50 (ms)&lt;/th&gt;
&lt;th&gt;TPU TPOT p50 (ms)&lt;/th&gt;
&lt;th&gt;A100 TPOT p50 (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.78&lt;/td&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;425&lt;/td&gt;
&lt;td&gt;132,484&lt;/td&gt;
&lt;td&gt;94,063&lt;/td&gt;
&lt;td&gt;29.5&lt;/td&gt;
&lt;td&gt;153.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0.79&lt;/td&gt;
&lt;td&gt;0.84&lt;/td&gt;
&lt;td&gt;402&lt;/td&gt;
&lt;td&gt;430&lt;/td&gt;
&lt;td&gt;375,386&lt;/td&gt;
&lt;td&gt;332,822&lt;/td&gt;
&lt;td&gt;29.7&lt;/td&gt;
&lt;td&gt;153.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0.79&lt;/td&gt;
&lt;td&gt;0.84&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;430&lt;/td&gt;
&lt;td&gt;498,994&lt;/td&gt;
&lt;td&gt;458,066&lt;/td&gt;
&lt;td&gt;29.8&lt;/td&gt;
&lt;td&gt;153.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0.79&lt;/td&gt;
&lt;td&gt;0.84&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;430&lt;/td&gt;
&lt;td&gt;561,402&lt;/td&gt;
&lt;td&gt;521,088&lt;/td&gt;
&lt;td&gt;29.8&lt;/td&gt;
&lt;td&gt;153.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;0.79&lt;/td&gt;
&lt;td&gt;0.84&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;430&lt;/td&gt;
&lt;td&gt;592,751&lt;/td&gt;
&lt;td&gt;552,527&lt;/td&gt;
&lt;td&gt;29.8&lt;/td&gt;
&lt;td&gt;153.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;0.79&lt;/td&gt;
&lt;td&gt;0.84&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;430&lt;/td&gt;
&lt;td&gt;608,386&lt;/td&gt;
&lt;td&gt;568,216&lt;/td&gt;
&lt;td&gt;29.7&lt;/td&gt;
&lt;td&gt;153.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;∞&lt;/td&gt;
&lt;td&gt;0.79&lt;/td&gt;
&lt;td&gt;0.84&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;624,698&lt;/td&gt;
&lt;td&gt;585,161&lt;/td&gt;
&lt;td&gt;29.7&lt;/td&gt;
&lt;td&gt;153.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;A100 wins by 6–7% on saturation throughput (430 vs 403 tokens/s). Both saturate at rate=1; raising the rate only stretches TTFT.&lt;/li&gt;
&lt;li&gt;TPOT: &lt;strong&gt;TPU is 5.1× faster&lt;/strong&gt; (29.7 ms vs 153.1 ms).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Long profile (input 8000 / output 1000)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;th&gt;TPU req/s&lt;/th&gt;
&lt;th&gt;A100 req/s&lt;/th&gt;
&lt;th&gt;TPU out_tok/s&lt;/th&gt;
&lt;th&gt;A100 out_tok/s&lt;/th&gt;
&lt;th&gt;TPU TTFT p50 (ms)&lt;/th&gt;
&lt;th&gt;A100 TTFT p50 (ms)&lt;/th&gt;
&lt;th&gt;TPU TPOT p50 (ms)&lt;/th&gt;
&lt;th&gt;A100 TPOT p50 (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.264&lt;/td&gt;
&lt;td&gt;0.381&lt;/td&gt;
&lt;td&gt;264&lt;/td&gt;
&lt;td&gt;381&lt;/td&gt;
&lt;td&gt;1,384,448&lt;/td&gt;
&lt;td&gt;773,158&lt;/td&gt;
&lt;td&gt;22.4&lt;/td&gt;
&lt;td&gt;129.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0.263&lt;/td&gt;
&lt;td&gt;0.381&lt;/td&gt;
&lt;td&gt;263&lt;/td&gt;
&lt;td&gt;381&lt;/td&gt;
&lt;td&gt;1,639,398&lt;/td&gt;
&lt;td&gt;1,024,209&lt;/td&gt;
&lt;td&gt;22.4&lt;/td&gt;
&lt;td&gt;129.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0.263&lt;/td&gt;
&lt;td&gt;0.380&lt;/td&gt;
&lt;td&gt;263&lt;/td&gt;
&lt;td&gt;380&lt;/td&gt;
&lt;td&gt;1,764,755&lt;/td&gt;
&lt;td&gt;1,150,795&lt;/td&gt;
&lt;td&gt;22.3&lt;/td&gt;
&lt;td&gt;129.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0.263&lt;/td&gt;
&lt;td&gt;0.381&lt;/td&gt;
&lt;td&gt;263&lt;/td&gt;
&lt;td&gt;381&lt;/td&gt;
&lt;td&gt;1,827,451&lt;/td&gt;
&lt;td&gt;1,213,094&lt;/td&gt;
&lt;td&gt;22.3&lt;/td&gt;
&lt;td&gt;129.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;0.263&lt;/td&gt;
&lt;td&gt;0.380&lt;/td&gt;
&lt;td&gt;263&lt;/td&gt;
&lt;td&gt;380&lt;/td&gt;
&lt;td&gt;1,859,960&lt;/td&gt;
&lt;td&gt;1,244,920&lt;/td&gt;
&lt;td&gt;22.3&lt;/td&gt;
&lt;td&gt;129.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;0.263&lt;/td&gt;
&lt;td&gt;0.381&lt;/td&gt;
&lt;td&gt;263&lt;/td&gt;
&lt;td&gt;381&lt;/td&gt;
&lt;td&gt;1,875,266&lt;/td&gt;
&lt;td&gt;1,259,711&lt;/td&gt;
&lt;td&gt;22.3&lt;/td&gt;
&lt;td&gt;129.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;∞&lt;/td&gt;
&lt;td&gt;0.263&lt;/td&gt;
&lt;td&gt;0.380&lt;/td&gt;
&lt;td&gt;263&lt;/td&gt;
&lt;td&gt;380&lt;/td&gt;
&lt;td&gt;1,891,177&lt;/td&gt;
&lt;td&gt;1,276,151&lt;/td&gt;
&lt;td&gt;22.3&lt;/td&gt;
&lt;td&gt;129.4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;A100 leads saturation throughput by &lt;strong&gt;1.44×&lt;/strong&gt; (380 vs 263 tokens/s). Long-input prefill performance dominates here.&lt;/li&gt;
&lt;li&gt;A100's TTFT is 35–40% lower — long prefills finish faster, so the queue drains faster.&lt;/li&gt;
&lt;li&gt;TPOT: &lt;strong&gt;TPU is 5.8× faster&lt;/strong&gt; (22.3 ms vs 129.4 ms).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Combined summary
&lt;/h3&gt;

&lt;p&gt;Saturation values from each profile in one place:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;Saturation req/s (TPU / A100)&lt;/th&gt;
&lt;th&gt;Saturation out_tok/s (TPU / A100)&lt;/th&gt;
&lt;th&gt;TPOT p50 (TPU / A100)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;short (1024/256)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3.92&lt;/strong&gt; / 2.89&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1,004&lt;/strong&gt; / 740&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;44.7&lt;/strong&gt; / 114.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;medium (4096/512)&lt;/td&gt;
&lt;td&gt;0.79 / &lt;strong&gt;0.84&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;403 / &lt;strong&gt;430&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;29.7&lt;/strong&gt; / 153.1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;long (8000/1000)&lt;/td&gt;
&lt;td&gt;0.263 / &lt;strong&gt;0.381&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;263 / &lt;strong&gt;380&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;22.3&lt;/strong&gt; / 129.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Throughput's winner switches with input/output length, while TPOT is 2.55–5.8× faster on TPU across every profile.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Cost estimate ($ / 1M output tokens)
&lt;/h3&gt;

&lt;p&gt;The relationship is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$/1M tokens = (instance_$_per_hour / 3600) × 1,000,000 / output_tok_per_s
            = instance_$_per_hour / 3.6 / output_tok_per_s

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

&lt;/div&gt;



&lt;p&gt;Plug in the saturation throughput and your hourly rate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;TPU v6e-4 (out_tok/s)&lt;/th&gt;
&lt;th&gt;A100 80GB×2 (out_tok/s)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;short&lt;/td&gt;
&lt;td&gt;1,004&lt;/td&gt;
&lt;td&gt;740&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;430&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;long&lt;/td&gt;
&lt;td&gt;263&lt;/td&gt;
&lt;td&gt;380&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If TPU v6e-4 and A100 80GB×2 are priced similarly per hour (which is roughly the case in our regions), TPU is cheaper for short and A100 is cheaper for medium/long. Replace the rates with your own figures for an exact answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The throughput winner switches with input/output length
&lt;/h3&gt;

&lt;p&gt;The most striking finding is that &lt;strong&gt;TPU wins for short, A100 wins for long&lt;/strong&gt;. The cause is that LLM inference has two phases — prefill (process the entire prompt at once) and decode (one token at a time) — and the balance shifts with input/output length:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;short (1024 in / 256 out)&lt;/strong&gt;: 256-token decode dominates wall time. TPU's per-token decode efficiency wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;medium (4096 in / 512 out)&lt;/strong&gt;: 4K prefill and 512 decode are roughly balanced. A100 edges ahead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;long (8000 in / 1000 out)&lt;/strong&gt;: 8K prefill dominates. A100's prefill compute pays off.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  TPOT is dramatically faster on TPU across every profile
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;TPOT ratio (A100 / TPU)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;short&lt;/td&gt;
&lt;td&gt;2.55×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;td&gt;5.15×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;long&lt;/td&gt;
&lt;td&gt;5.80×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This shows TPU v6e's per-token decode efficiency is consistently strong. For &lt;strong&gt;streaming UX (where each token shows up immediately on screen)&lt;/strong&gt;, this is the metric your users actually feel. The "fluidity" of generated text is significantly smoother on TPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caveat: A100 ran with bf16 KV cache
&lt;/h3&gt;

&lt;p&gt;When reading the throughput numbers, remember that &lt;strong&gt;A100 was on bf16 KV cache&lt;/strong&gt; (see the Software section): &lt;code&gt;fp8_e4m3&lt;/code&gt; is Hopper-only and &lt;code&gt;fp8_e5m2&lt;/code&gt; is rejected by vLLM's Gemma 4 attention &lt;code&gt;assert&lt;/code&gt;. So A100 was running on roughly half the concurrent capacity of TPU's fp8 setup — and &lt;strong&gt;still beat TPU on medium and long throughput&lt;/strong&gt;. On Hopper (H100) with &lt;code&gt;fp8_e4m3&lt;/code&gt;, the gap could grow further. Conversely, &lt;strong&gt;TPU v6e — running aggressive fp8 KV compression — still beats A100 on short and on TPOT across the board&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workload-based recommendation
&lt;/h3&gt;

&lt;p&gt;Mapping the numbers to product use cases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;th&gt;Rationale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chat (interactive UX)&lt;/td&gt;
&lt;td&gt;short (~1K in / ~256 out)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;TPU v6e-4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Higher saturation throughput and lower TPOT → fast, smooth replies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG / mid-length generation&lt;/td&gt;
&lt;td&gt;medium (~4K in / ~512 out)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Either&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Saturation throughput is close. TPU if TPOT matters; A100 if cost is closer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-form summarization / code&lt;/td&gt;
&lt;td&gt;long (~8K in / ~1K out)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;A100 80GB × 2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.44× higher throughput on long prefill — though TPU still has smoother token streaming&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming UX above all&lt;/td&gt;
&lt;td&gt;any profile&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;TPU v6e-4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TPOT 2.5–5.8× faster than A100&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Operational notes: where TPU and GPU each tripped us up
&lt;/h3&gt;

&lt;p&gt;We hit multiple snags on the way to a clean run. Interestingly, &lt;strong&gt;the layers that broke on TPU and on GPU were quite different&lt;/strong&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  TPU side
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't install vLLM via pip on TPU.&lt;/strong&gt; PyPI's plain &lt;code&gt;vllm&lt;/code&gt; is the CUDA build; the server fails at startup with &lt;code&gt;Failed to infer device type&lt;/code&gt;. The official Docker image (&lt;code&gt;vllm/vllm-tpu:gemma4&lt;/code&gt;) is far simpler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image entrypoint quirks.&lt;/strong&gt; &lt;code&gt;vllm/vllm-tpu:gemma4&lt;/code&gt; uses a pass-through entrypoint, so the first arg has to be a runnable command. We put &lt;code&gt;vllm serve &amp;lt;model&amp;gt;&lt;/code&gt; first; passing &lt;code&gt;--model X&lt;/code&gt; directly produces &lt;code&gt;bash: exec --: invalid option&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the right TPU runtime.&lt;/strong&gt; &lt;code&gt;v2-alpha-tpuv6e&lt;/code&gt;. Confirm with &lt;code&gt;gcloud compute tpus tpu-vm versions list --zone=$ZONE&lt;/code&gt;. TPU 7x (Ironwood) wasn't accessible from our project — application is in flight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TPU v6e capacity is tight.&lt;/strong&gt; Even with unlimited quota, &lt;code&gt;Insufficient capacity&lt;/code&gt; was the most common failure; &lt;code&gt;us-central1-{a,c}&lt;/code&gt; and &lt;code&gt;us-east5-a&lt;/code&gt; were all capacity-constrained, and we eventually landed on &lt;code&gt;us-east5-b&lt;/code&gt;. &lt;strong&gt;Queued Resource (QR)&lt;/strong&gt; is a practical fallback if every zone fails on the first try.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  GPU side
&lt;/h4&gt;

&lt;p&gt;The GPU side hit fewer accelerator-specific issues; most of the friction was &lt;strong&gt;Linux distro and GCP resource management&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fresh GCP projects start with zero GPU quota.&lt;/strong&gt; The project we used had &lt;code&gt;NVIDIA_A100_80GB_GPUS&lt;/code&gt; and &lt;code&gt;NVIDIA_H100_80GB_GPUS&lt;/code&gt; at 0 in every region. We submitted three quota requests — the first two went out for &lt;code&gt;Committed NVIDIA H100 GPUs&lt;/code&gt; (CUD), which were approved but useless for on-demand. We then requested both &lt;strong&gt;plain &lt;code&gt;NVIDIA X 80GB GPUs&lt;/code&gt;&lt;/strong&gt; (without the &lt;code&gt;Committed&lt;/code&gt; or &lt;code&gt;Preemptible&lt;/code&gt; prefix) and the new umbrella &lt;code&gt;GPUS_PER_GPU_FAMILY&lt;/code&gt; (with &lt;code&gt;gpu_family=NVIDIA_H100&lt;/code&gt;); we're still not sure exactly which combination is the bare minimum and would love expert input on this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A100 had capacity issues too.&lt;/strong&gt; Both &lt;code&gt;us-east5-{a,b}&lt;/code&gt; returned &lt;code&gt;ZONE_RESOURCE_POOL_EXHAUSTED&lt;/code&gt;. We followed GCP's hint and ended up in &lt;code&gt;us-central1-c&lt;/code&gt;. (The TPU and GPU ended up in different regions; since the bench loops through &lt;code&gt;docker exec&lt;/code&gt; on the same VM, we believe this doesn't affect comparison fairness.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DLVM &lt;code&gt;common-cu129-ubuntu-2404-nvidia-580&lt;/code&gt; does not include Docker.&lt;/strong&gt; It ships NVIDIA Driver 580 and NVIDIA Container Toolkit 1.17.8, but Docker engine itself you install yourself: &lt;code&gt;sudo apt install -y docker.io&lt;/code&gt; followed by &lt;code&gt;sudo nvidia-ctk runtime configure --runtime=docker&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fresh VM has its apt held by &lt;code&gt;unattended-upgrades&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;sudo apt install&lt;/code&gt; gives &lt;code&gt;Could not get lock&lt;/code&gt; for the first few minutes. Our setup script now waits on the lock before proceeding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ubuntu 24.04 enforces PEP 668.&lt;/strong&gt; &lt;code&gt;pip install --user&lt;/code&gt; is blocked by &lt;code&gt;externally-managed-environment&lt;/code&gt;. Tools like the HF CLI now go in a dedicated venv.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;huggingface-cli&lt;/code&gt; is deprecated → &lt;code&gt;hf&lt;/code&gt; CLI.&lt;/strong&gt; As of &lt;code&gt;huggingface_hub&lt;/code&gt; 1.12, &lt;code&gt;huggingface-cli&lt;/code&gt; prints a "use &lt;code&gt;hf&lt;/code&gt; instead" warning and stops working. The replacements are &lt;code&gt;hf auth login --token&lt;/code&gt; and &lt;code&gt;hf download REPO_ID&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HF cache permission collision.&lt;/strong&gt; Once Docker (running as root) writes into &lt;code&gt;~/.cache/huggingface&lt;/code&gt;, the host user can't always write back: &lt;code&gt;[Errno 13] Permission denied: ...lock&lt;/code&gt;. Fixed with &lt;code&gt;sudo chown -R $USER:$USER ~/.cache/huggingface&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A100 + Gemma 4 can't use FP8 KV cache.&lt;/strong&gt; Detailed above; the practical resolution is &lt;code&gt;--kv-cache-dtype auto&lt;/code&gt; (= bf16).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read vLLM v1 errors at the worker level.&lt;/strong&gt; &lt;code&gt;Engine core initialization failed. See root cause above. Failed core proc(s): {}&lt;/code&gt; is a messenger, not the actual exception. The real exception is in lines prefixed with &lt;code&gt;(Worker_TP*)&lt;/code&gt;. We extracted them with &lt;code&gt;docker logs gemma4-gpu | grep '(Worker_TP'&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Cross-platform takeaways
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't paste long shell commands&lt;/strong&gt; through chat / IDE buffers. Backslash continuations and heredocs corrupt easily. &lt;strong&gt;Author them as files locally, scp them, then bash them.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run long jobs under &lt;code&gt;nohup&lt;/code&gt; or &lt;code&gt;tmux&lt;/code&gt;/&lt;code&gt;screen&lt;/code&gt; from the start.&lt;/strong&gt; A single ssh disconnect cost us more than an hour of progress and accelerator time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TPU breaks at the accelerator-specific layers (image, runtime, arg parsing, capacity). GPU breaks at the Linux distro and GCP-resource layers (quota, Docker, pip, hf). &lt;strong&gt;Plan for a half-day to one-day of buffer per platform on a fresh project.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Summary of findings
&lt;/h3&gt;

&lt;p&gt;We benchmarked Gemma 4 31B on TPU v6e-4 and A100 80GB × 2 with vLLM, running 21 cases (3 profiles × 7 rates) on each side. Highlights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Throughput winner depends on input/output length.&lt;/strong&gt; TPU wins short. A100 wins medium and long.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TPOT is uniformly faster on TPU&lt;/strong&gt; by 2.5–5.8×. For streaming UX, TPU has a real edge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A100 ran with bf16 KV cache, not FP8.&lt;/strong&gt; It still won on medium and long. On Hopper with &lt;code&gt;fp8_e4m3&lt;/code&gt;, the gap could grow further.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cleanest split:&lt;/strong&gt; chat → TPU; long-form generation → A100.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operationally, TPU and GPU break in different layers.&lt;/strong&gt; Budget time accordingly on fresh projects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For middle-of-the-road dense LLMs in the ~30B class on GCP, this hopefully gives a concrete reference for choosing TPU or GPU based on your actual workload shape.&lt;/p&gt;

&lt;h3&gt;
  
  
  Future work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;H100 + FP8 KV cache for a fair fight.&lt;/strong&gt; A100 was forced to bf16; H100 with &lt;code&gt;fp8_e4m3&lt;/code&gt; would let us put GPU on the same FP8 footing as TPU and rerun the sweep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TPU 7x (Ironwood).&lt;/strong&gt; 192 GiB HBM/chip — six times TPU v6e. Currently allowlist-only, but a follow-up benchmark is planned once it's broadly available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quantized weights.&lt;/strong&gt; AWQ, GPTQ, and FP8 weight quantization for accuracy/throughput tradeoffs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Larger and MoE models.&lt;/strong&gt; Gemma 4 26B-A4B (MoE) and bigger dense models with quantization.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Appendix: Smoke test on the short profile
&lt;/h2&gt;

&lt;p&gt;Before the main sweep, we ran a one-off smoke test on the short profile (input 1024 / output 256), submitting 1000 prompts with &lt;code&gt;--request-rate inf&lt;/code&gt;-equivalent dispatch (&lt;code&gt;Maximum request concurrency: None&lt;/code&gt;, &lt;code&gt;Peak concurrent requests: 1000&lt;/code&gt;). The numbers aren't part of the official sweep but give a quick pre-flight comparison.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The authoritative numbers are in the Results section above.&lt;/strong&gt; The smoke test is a single one-shot run right after Docker container startup, while the main sweep includes a warmup round before each of the 21 cases. The post-warmup A100 numbers come out slightly better (rate=∞: req/s 2.78 → 2.88, out_tok/s 712 → 737, median TTFT 174,897 → 162,543 ms; TPU is virtually unchanged). Treat the appendix as a "yes, both platforms are running the same configuration" sanity check rather than a benchmark snapshot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;TPU v6e-4 (FP8 KV)&lt;/th&gt;
&lt;th&gt;A100 80GB × 2 (bf16 KV)&lt;/th&gt;
&lt;th&gt;Ratio (TPU / A100)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Successful requests&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failed requests&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Benchmark duration (s)&lt;/td&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;359&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request throughput (req/s)&lt;/td&gt;
&lt;td&gt;3.91&lt;/td&gt;
&lt;td&gt;2.78&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.40×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output token throughput (tok/s)&lt;/td&gt;
&lt;td&gt;1001&lt;/td&gt;
&lt;td&gt;712&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.41×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total token throughput (tok/s)&lt;/td&gt;
&lt;td&gt;5004&lt;/td&gt;
&lt;td&gt;3561&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.41×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mean TTFT (ms)&lt;/td&gt;
&lt;td&gt;121,590&lt;/td&gt;
&lt;td&gt;176,526&lt;/td&gt;
&lt;td&gt;TPU 1.45× faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median TTFT (ms)&lt;/td&gt;
&lt;td&gt;120,927&lt;/td&gt;
&lt;td&gt;174,897&lt;/td&gt;
&lt;td&gt;TPU 1.45× faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P99 TTFT (ms)&lt;/td&gt;
&lt;td&gt;240,983&lt;/td&gt;
&lt;td&gt;348,870&lt;/td&gt;
&lt;td&gt;TPU 1.45× faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mean TPOT (ms)&lt;/td&gt;
&lt;td&gt;46.8&lt;/td&gt;
&lt;td&gt;112.3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;TPU 2.40× faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median TPOT (ms)&lt;/td&gt;
&lt;td&gt;44.8&lt;/td&gt;
&lt;td&gt;114.7&lt;/td&gt;
&lt;td&gt;TPU 2.56× faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P99 TPOT (ms)&lt;/td&gt;
&lt;td&gt;79.3&lt;/td&gt;
&lt;td&gt;150.3&lt;/td&gt;
&lt;td&gt;TPU 1.90× faster&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Observations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Throughput is ~1.4× higher on TPU.&lt;/strong&gt; A100 is on bf16 KV cache (~half the concurrency of TPU's fp8), so this isn't a pure hardware gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TPOT is ~2× faster on TPU.&lt;/strong&gt; KV cache size doesn't directly affect TPOT, so this is closer to a clean compute comparison and v6e looks strong. The main sweep confirms the pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTFT is in the 100-second range for both platforms.&lt;/strong&gt; This is &lt;code&gt;--request-rate inf&lt;/code&gt; with 1000 prompts thrown in at once — queue-dominated. In real Poisson traffic at lower rates, TTFT shrinks dramatically; the main sweep shows the realistic numbers at rate=1–32.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.vllm.ai/" rel="noopener noreferrer"&gt;vLLM Documentation&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.vllm.ai/projects/recipes/en/latest/Google/Gemma4.html" rel="noopener noreferrer"&gt;vLLM Gemma 4 recipe&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://huggingface.co/google/gemma-4-31B-it" rel="noopener noreferrer"&gt;Gemma 4 31B Instruct on Hugging Face&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://cloud.google.com/tpu/docs/v6e" rel="noopener noreferrer"&gt;Cloud TPU v6e (Trillium) docs&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://implicit-none.com/en/llm-inference-benchmark/" rel="noopener noreferrer"&gt;LLM Inference Hardware Benchmark (living hub)&lt;/a&gt; — this campaign indexed alongside all our other inference measurements&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>benchmark</category>
      <category>gpu</category>
      <category>tpu</category>
    </item>
  </channel>
</rss>
