Tensor LabsTENSORLABS

Benchmark a Gemini 3.5 Flash to Gemini 3.6 Flash Migration Before You Ship

On July 21, 2026, Google shipped Gemini 3.6 Flash to general availability, alongside Gemini 3.5 Flash-Lite and a security-focused Gemini 3.5 Flash Cyber variant.

August 6, 20267 min read10 sectionsBy Ahmed Abdullah
Benchmark a Gemini 3.5 Flash to Gemini 3.6 Flash Migration Before You Ship

Introduction

On July 21, 2026, Google shipped Gemini 3.6 Flash to general availability, alongside Gemini 3.5 Flash-Lite and a security-focused Gemini 3.5 Flash Cyber variant. The headline from independent testing is unusual: Artificial Analysis measured the intelligence index flat against Gemini 3.5 Flash, while time per task roughly halved. A model upgrade that makes nothing smarter and everything faster is exactly the kind of release teams skip, and for agent workloads that instinct is expensive. The harness you build here runs your actual tasks against both models and answers, with numbers, whether Gemini 3.6 Flash should replace Gemini 3.5 Flash in your pipeline this week.

Why a speed-only upgrade changes agent math

A chat user feels latency once per message. An agent feels it once per step, and steps are serial: plan, call a tool, read the result, call another, synthesize. An eight-step chain at nine seconds per model call spends seventy-two seconds inside the model; halve the per-call time and the same chain returns half a minute sooner. Multiply by every run in a batch pipeline and per-task latency quietly becomes the number that decides whether your overnight job finishes overnight. Capability benchmarks do not capture this. Wall-clock on your own workload does.

What to measure, and what to refuse to measure

A migration harness needs exactly three measurements per task: did the output pass your checks, how long did the call take, and what did it cost in tokens. Resist the urge to add a subjective quality score in v1. Scores you cannot explain become scores you argue about, and the migration decision stalls in the argument. Pass/fail checks you wrote yourself, latency, and cost: all three are boring, and all three are sufficient for a swap-or-stay call.

Define the task set from production, not imagination

Pull ten to thirty real inputs from the workload you intend to migrate: real tickets, real documents, real diffs. Synthetic prompts measure the model's performance on a job you do not have.

typescript
# tasks.py
from pydantic import BaseModel
class Task(BaseModel):
task_id: str
prompt: str
# Substrings that MUST appear for a pass, e.g. a required field name.
must_contain: list[str] = []
# Substrings that must NOT appear, e.g. a hallucination tripwire.
must_not_contain: list[str] = []
require_valid_json: bool = False
TASKS = [
Task(
task_id="triage-001",
prompt=(
"Classify this support ticket into one of: billing, bug, "
"feature_request, spam. Reply as JSON with keys category and "
"reason.\n\nTicket: Payment page shows a blank screen after "
"clicking Pay Now on mobile Safari."
),
must_contain=['"category"', "bug"],
require_valid_json=True,
),
Task(
task_id="extract-001",
prompt=(
"Extract vendor_name, invoice_number, and total_due as JSON "
"from this text:\n\nInvoice INV-2291 from Meridian Hosting. "
"Amount due: $1,284.00 by August 15."
),
must_contain=["INV-2291", "Meridian"],
require_valid_json=True,
),
# Add 10-30 tasks lifted from your real pipeline here.
]

The checks are deliberately mechanical. A task passes when the required substrings appear, the forbidden ones do not, and the JSON parses when it must. Anything a regex cannot adjudicate belongs in a later, slower review, not in the swap decision.

Build the timed runner

One function calls one model on one task and returns everything the decision needs. The Gemini API exposes token counts in usage_metadata, so cost capture is arithmetic, not estimation.

python
# runner.py
import json
import time
from google import genai
from pydantic import BaseModel
from tasks import Task
client = genai.Client() # picks up GEMINI_API_KEY from your shell
class Result(BaseModel):
task_id: str
model: str
passed: bool
latency_s: float
input_tokens: int
output_tokens: int
failure_reason: str = ""
def check(task: Task, text: str) -> str:
if task.require_valid_json:
cleaned = text.strip().removeprefix("```json").removesuffix("```").strip()
try:
json.loads(cleaned)
except json.JSONDecodeError as exc:
return f"invalid JSON: {exc}"
for needle in task.must_contain:
if needle.lower() not in text.lower():
return f"missing required content: {needle}"
for needle in task.must_not_contain:
if needle.lower() in text.lower():
return f"contains forbidden content: {needle}"
return ""
def run_one(model: str, task: Task) -> Result:
start = time.perf_counter()
response = client.models.generate_content(model=model, contents=task.prompt)
elapsed = time.perf_counter() - start
reason = check(task, response.text or "")
usage = response.usage_metadata
return Result(
task_id=task.task_id,
model=model,
passed=reason == "",
latency_s=round(elapsed, 3),
input_tokens=usage.prompt_token_count,
output_tokens=usage.candidates_token_count,
failure_reason=reason,
)

Note what the timer wraps: the API call only. Network jitter is part of the number because it is part of production. Run from the same region your pipeline runs from, or you are benchmarking your laptop's wifi.

Run the A/B and repeat it

One pass per model is an anecdote. Three passes is a small sample with visible variance, which is the minimum for an infrastructure decision.

python
# ab_test.py
import statistics
from collections import defaultdict
from runner import run_one
from tasks import TASKS
MODELS = ["gemini-3.5-flash", "gemini-3.6-flash"]
ROUNDS = 3
def main() -> None:
results = []
for round_num in range(ROUNDS):
for model in MODELS:
for task in TASKS:
result = run_one(model, task)
results.append(result)
print(f"[round {round_num + 1}] {model} {task.task_id} "
f"{'PASS' if result.passed else 'FAIL'} "
f"{result.latency_s}s")
by_model = defaultdict(list)
for result in results:
by_model[result.model].append(result)
print("\nmodel pass% p50 lat p95 lat out-tokens")
for model, rows in by_model.items():
lats = sorted(row.latency_s for row in rows)
pass_rate = 100 * sum(row.passed for row in rows) / len(rows)
p50 = statistics.median(lats)
p95 = lats[max(0, int(len(lats) * 0.95) - 1)]
tokens = statistics.mean(row.output_tokens for row in rows)
print(f"{model:<20} {pass_rate:5.1f} {p50:7.2f} {p95:7.2f} {tokens:8.0f}")
if __name__ == "__main__":
main()

Read the output like an operator, not a fan. The p95 column matters more than the median for agent chains, because a chain is as slow as its slowest step, and a pipeline of chains is as slow as its unluckiest run.

Interpret the table

OutcomeDecision
Pass rate equal, latency halvedMigrate. This is the advertised case; take the win.
Pass rate equal, latency equal on your tasksStay. The speedup did not survive contact with your workload.
Pass rate drops on 3.6 FlashStay, and file the failing tasks as your regression set for the next release.
Pass rate improvesMigrate, then ask why your checks were failing on a model with a flat intelligence index. Usually the checks were sloppy, not the old model.

One more column worth adding when the first table is close: Gemini 3.5 Flash-Lite. If your tasks pass on the Lite tier, the migration conversation was never about 3.6 Flash at all; you were overpaying a tier the whole time, and the benchmark you almost did not run just found the refund

What this harness does not do

- Multi-turn agent behavior is out of scope. Wrap your real agent loop with the same timer for that; the principle survives, the code gets longer. - It does not detect subtle quality drift that substring checks cannot see. It is a swap gate, not an eval suite. - It does not control for provider-side variance over days. A Tuesday benchmark describes Tuesday (rerun the winner for a week in shadow mode before deleting the loser from your config). - It does not decide for you. It shrinks the decision to a table small enough to argue about honestly.

The final working example

python
"""Complete Gemini 3.5 -> 3.6 Flash migration benchmark, single file.
Requires: pip install google-genai pydantic
Env: GEMINI_API_KEY
"""
import json
import statistics
import time
from collections import defaultdict
from google import genai
from pydantic import BaseModel
client = genai.Client()
MODELS = ["gemini-3.5-flash", "gemini-3.6-flash"]
ROUNDS = 3
class Task(BaseModel):
task_id: str
prompt: str
must_contain: list[str] = []
must_not_contain: list[str] = []
require_valid_json: bool = False
class Result(BaseModel):
task_id: str
model: str
passed: bool
latency_s: float
output_tokens: int
failure_reason: str = ""
TASKS = [
Task(task_id="triage-001",
prompt=("Classify this support ticket into one of: billing, bug, "
"feature_request, spam. Reply as JSON with keys category "
"and reason.\n\nTicket: Payment page shows a blank screen "
"after clicking Pay Now on mobile Safari."),
must_contain=['"category"', "bug"], require_valid_json=True),
Task(task_id="extract-001",
prompt=("Extract vendor_name, invoice_number, and total_due as "
"JSON from this text:\n\nInvoice INV-2291 from Meridian "
"Hosting. Amount due: $1,284.00 by August 15."),
must_contain=["INV-2291", "Meridian"], require_valid_json=True),
]
def check(task: Task, text: str) -> str:
if task.require_valid_json:
cleaned = text.strip().removeprefix("```json").removesuffix("```").strip()
try:
json.loads(cleaned)
except json.JSONDecodeError as exc:
return f"invalid JSON: {exc}"
for needle in task.must_contain:
if needle.lower() not in text.lower():
return f"missing: {needle}"
for needle in task.must_not_contain:
if needle.lower() in text.lower():
return f"forbidden: {needle}"
return ""
def run_one(model: str, task: Task) -> Result:
start = time.perf_counter()
response = client.models.generate_content(model=model, contents=task.prompt)
elapsed = time.perf_counter() - start
reason = check(task, response.text or "")
return Result(task_id=task.task_id, model=model, passed=reason == "",
latency_s=round(elapsed, 3),
output_tokens=response.usage_metadata.candidates_token_count,
failure_reason=reason)
def main() -> None:
results = [run_one(model, task)
for _ in range(ROUNDS) for model in MODELS for task in TASKS]
by_model = defaultdict(list)
for result in results:
by_model[result.model].append(result)
print("model pass% p50 lat p95 lat")
for model, rows in by_model.items():
lats = sorted(row.latency_s for row in rows)
pass_rate = 100 * sum(row.passed for row in rows) / len(rows)
p95 = lats[max(0, int(len(lats) * 0.95) - 1)]
print(f"{model:<20} {pass_rate:5.1f} {statistics.median(lats):7.2f}"
f" {p95:7.2f}")
for result in results:
if not result.passed:
print(f"FAIL {result.model} {result.task_id}: {result.failure_reason}")
if __name__ == "__main__":
main()

When to run this

Run it every time a Flash-tier model ships, which lately is monthly. The harness is thirty minutes to build once and five minutes to rerun forever, and it converts every future "should we upgrade" thread into a table. Speed-only releases are the ones most worth testing precisely because they look skippable: nothing about your outputs changes, and everything about your throughput might.