Reproducibility under batch invariance
Send one prompt at several batch sizes with temperature 0 and diff the outputs; then re-run with the determinism flag and measure what it costs. Uses the engines' own correctness harnesses, not the performance benchmark.
Send the same prompt at batch 1 and at batch 32, greedily, and read the two completions side by side. When they differ, nothing was random: the reduction order moved because the batch moved. This lab reproduces that, fixes it with the determinism flag, and prices the fix — using the engines' own correctness harnesses, not the performance benchmark that shares the name.
One GPU that both engines will start on: SM80 (A100) or newer for vLLM's determinism suite
(tests/v1/determinism/utils.py:L26-L36 gates on
has_device_capability(80)), and a card big enough for a small model — the
in-tree defaults are Qwen3-1.7B for vLLM and Qwen3-8B for SGLang, so a 24 GB card runs both.
Nothing here needs 80 GB. Where no GPU is available, the substituted figure is the overhead
of the flag, which this lab declines to guess at: chapter
§10.4 also refuses to, and
says so under an "Unmeasured" callout. There is no published number to cite that would apply to
your model, backend and TP degree, so What to expect asks you for four numbers rather
than handing you one.
The floor is worth stating precisely, because the tree disagrees with itself about it. The
comment above the environment variable reads "Requires NVIDIA GPU with compute capability
>= 9.0" (vllm/envs.py:L627-L629), but
enable_batch_invariant_mode() has an explicit SM80 branch installing Triton
mm/addmm/matmul/linear overrides
(vllm/model_executor/layers/batch_invariant.py:L916-L929), and the test suite gates
on capability 80. Trust the code and the tests: an A100 runs this lab.
run.py was written against the request and response shapes cited below, and its
comparison arithmetic — ULP distance, first-divergence index, the bucket-straddling batch
ladder — was exercised offline, but it has not been run against a live engine; no GPU
was available while writing. Every expectation below is a prediction. In particular, the claim
that a given batch size diverges is a claim about your hardware, backend and graph
configuration, and the honest outcome of this lab is sometimes "it did not diverge, and here is
why".
What you measure
benchmarks/benchmark_batch_invariance.py looks like the right tool and is not. It
generates a baseline for a "needle" prompt, then inserts that prompt into random batches and
checks one thing:
# Generate baseline
print("Generating baseline (warmup)...")
baseline_out = llm.generate([needle_prompt], sampling)
assert len(baseline_out) == 1
baseline_text = baseline_out[0].outputs[0].text
# Verify needle output still matches
needle_output = outputs[needle_pos]
assert needle_output.prompt == needle_prompt
baseline_text is computed and then never used. The assertion compares the
prompt it sent against the prompt it got back — a check that the engine did not
shuffle the batch, which is true by construction. It is a performance harness: it times the same
workload with VLLM_BATCH_INVARIANT at 0 and 1 and prints an overhead percentage. Read
its green output as a determinism result and you will conclude the property holds when you have not
tested it. The correctness harnesses are elsewhere, and this lab drives those.
| Harness | Shape | What it asserts |
|---|---|---|
tests/v1/determinism/test_batch_invariance.py | in-process
LLM() |
Two mirror-image tests: one that the property fails with the flag off, one that it holds with it on. 960 lines. |
tests/v1/determinism/test_online_batch_invariance.py | HTTP, against a real server | BS=1 versus BS=N over the OpenAI completions API, comparing token ids and
per-step logprobs bitwise. This is the one run.py --mode sweep imitates. |
tests/v1/determinism/test_matmul_batch_invariant.py,
test_rms_norm_batch_invariant.py, test_cutlass_batch_invariance.py,
test_nvfp4_batch_invariant.py | op-level | Where to go when a model-level test fails and you need to localise it. 1,138 lines between them. |
python/sglang/test/test_deterministic.py | driver against a live server | Four modes: single, prefix, radix_cache,
p_vs_d. Prints a unique-completion count. |
Three measurements, in order:
- Unique completions across batch sizes, flag off. Greater than one is the bug, reproduced.
- The same count with the flag on. It should be exactly one.
- The overhead the flag cost, for your model, backend and TP degree — measured, because there is no meaningful number to cite.
There is also a fourth thing worth watching, and run.py reports it separately: the
maximum ULP distance between the baseline's per-step logprobs and each batch's. Token equality is
the weaker claim. Two runs can emit identical tokens while every logprob differs in the last few
bits — the argmax simply survived the perturbation this time. The logprob column is what tells
you the arithmetic moved.
Running it
Part A — the upstream harnesses, verbatim
Start here. run.py --mode harness prints these with no server and no GPU, so you can
read them before installing anything.
vLLM's suite has a detail that will confuse you if you skip it. A directory-scoped fixture turns invariance on for every test in it:
@pytest.fixture(autouse=True)
def enable_batch_invariant_mode(monkeypatch: pytest.MonkeyPatch):
"""Automatically enable batch invariant kernel overrides for all tests."""
monkeypatch.setattr(envs, "VLLM_BATCH_INVARIANT", True)
monkeypatch.setenv("VLLM_BATCH_INVARIANT", "1")
So the test that proves the effect exists has to switch it back off itself, and its docstring is the clearest statement of the whole idea in either repository:
def test_logprobs_without_batch_invariance_should_fail(
backend, monkeypatch: pytest.MonkeyPatch
):
"""
This test is the inverse of test_logprobs_bitwise_batch_invariance_bs1_vs_bsN.
It DISABLES batch invariance mode and expects to see non-deterministic behavior
between BS=1 and BS=N runs. This demonstrates that batch invariance is actually
doing something useful.
The test will PASS if we detect differences (proving batch invariance matters).
The test will FAIL if everything matches (suggesting batch invariance isn't needed).
"""
# CRITICAL: Disable batch invariance for this test
The model is an environment variable, not a flag, so you can point the whole suite at your own weights:
DEFAULT_MODEL = "Qwen/Qwen3-1.7B"
TEST_MODEL = os.getenv("VLLM_TEST_MODEL", DEFAULT_MODEL)
SGLang's is a driver rather than a test: you start a server and run it against the live endpoint.
Its single mode is the experiment in eleven lines — the same prompt at batch 1
through n, and a count of distinct completions:
def test_deterministic(args):
if args.test_mode == "single":
# In single mode, we test the deterministic behavior by sending the same prompt in batch sizes ranging from 1 to n_trials.
texts = []
for i in range(1, args.n_trials + 1):
batch_size = i
text = send_single(args, args.profile, prompt=[PROMPT_1] * batch_size)
text = text.replace("\n", " ")
print(f"Trial {i} with batch size {batch_size}: {text}")
texts.append(text)
print(f"Total samples: {len(texts)}, Unique samples: {len(set(texts))}")
return [len(set(texts))]
Its in-tree test class fixes the server arguments, and one of them is not obvious:
DEFAULT_MODEL = "Qwen/Qwen3-8B"
COMMON_SERVER_ARGS = [
"--trust-remote-code",
"--cuda-graph-max-bs-decode",
"32",
"--enable-deterministic-inference",
]
--cuda-graph-max-bs-decode 32 caps the largest decode batch SGLang captures a
graph for (python/sglang/srt/server_args.py:L1906-L1910, resolved at
:L4561-L4562). The source does not say why the test class sets it, and I am not
going to guess; what matters here is the consequence. It fixes the bucket ladder the sweep runs
against, and above the cap there is no graph to pad into. That changes what a clean result means
— see What to expect.
$ python3 run.py --mode harness # prints all of the below, offline
# arm 1: flag off. Expect "Unique samples" > 1.
$ python3 -m sglang.launch_server --model-path Qwen/Qwen3-8B \
--attention-backend triton --cuda-graph-max-bs-decode 32
$ python3 -m sglang.test.test_deterministic --n-trials 50 --test-mode single
# arm 2: flag on. Expect exactly 1.
$ python3 -m sglang.launch_server --model-path Qwen/Qwen3-8B \
--attention-backend triton --cuda-graph-max-bs-decode 32 \
--enable-deterministic-inference
$ python3 -m sglang.test.test_deterministic --n-trials 50 --test-mode single
Omit --attention-backend with determinism on and SGLang picks one and tells you;
pass an unsupported one and it refuses to start rather than degrading:
logger.warning(
f"Attention backend not specified. Falling back to '{backend}' for deterministic inference. "
f"You can explicitly set --attention-backend to one of {DETERMINISTIC_ATTENTION_BACKEND_CHOICES}."
)
return {"attention_backend": backend}
elif view.attention_backend not in DETERMINISTIC_ATTENTION_BACKEND_CHOICES:
# User explicitly specified an incompatible attention backend
raise ValueError(
f"Currently only {DETERMINISTIC_ATTENTION_BACKEND_CHOICES} attention backends are supported for deterministic inference, "
f"but you explicitly specified '{view.attention_backend}'."
)
return {}
That is a comparison hazard, not a convenience: if the two arms of your experiment ran different
attention backends, the difference you measured is not the difference you meant to measure. Pin
--attention-backend explicitly in both arms.
Part B — the sweep, over plain HTTP
The upstream harnesses want pytest and a checkout. run.py --mode sweep does the same
comparison against either engine over the wire, with nothing but the standard library. It sends
n copies of one prompt in a single request — which is what the online test does, and
what makes the engine schedule them together:
sp_kwargs: dict[str, Any] = {
"temperature": 0.6,
"top_p": 1.0,
"max_tokens": 8,
"seed": 42,
"logprobs": 5,
}
tp_size = os.getenv("VLLM_TP_SIZE", "1")
server_args: list[str] = [
"--max-model-len=8192",
"--max-num-seqs=32",
f"--attention-backend={backend}",
]
if tp_size:
server_args += ["-tp", tp_size]
with RemoteOpenAIServer(TEST_MODEL, server_args) as server:
Note that the upstream test does not use temperature=0. It uses 0.6 with a
fixed seed, which exercises more of the distribution while remaining reproducible by seed. Greedy is
the sharper demonstration and is run.py's default, because a greedy divergence is
unarguable; seeded sampling is the more thorough test. Run both. And the comparison it makes is not
on text:
for t, (a, b) in enumerate(zip(logprobs_bs1, logprobs_bsN)):
if a != b:
diff = abs(a - b)
raise AssertionError(
f"Prompt {i} Step {t}: Bitwise mismatch "
f"(abs diff={diff:.6e}). "
f"BS=1 tokens: {tokens_bs1} BS=N tokens: {tokens_bsN}"
)
$ python3 run.py --mode sweep --engine vllm --url http://localhost:8000 \
--model Qwen/Qwen3-1.7B --max-batch 32 --save off.json
$ python3 run.py --mode sweep --engine sglang --url http://localhost:30000 \
--sizes 1,15,16,17,23,24,25 --temperature 0.0
$ python3 run.py --mode cost --url http://localhost:8000 \
--invariant http://localhost:8001 --batch 32 --trials 20
$ python3 run.py --help # every knob, all four modes
The default batch ladder deliberately straddles vLLM's CUDA-graph bucket boundaries — 15, 16, 17 and 23, 24, 25 — rather than sampling round numbers. The reason is in What to expect.
What to expect
Flag off, a long sweep: several unique completions. Not all of them different from each other; divergence is sporadic, because it needs a position where the top two logits are within a few ULP. Chapter §10.4 shows four fp32 partials rearranged differing by 1,746 ULP, which is a relative error of $1.5 \times 10^{-4}$ — enormous next to a near-tie and invisible next to a confident one. One flipped argmax at position 40 rewrites everything after it, which is why the effect looks bimodal: completions are either identical or wildly different, rarely close.
Flag on: exactly one. If it is not, you have found either a coverage gap or a bug, and the gaps are documented. §10.4's coverage map names three: vLLM's fused-MoE kernels default to unsupported and must opt in; SGLang's routing top-k stays unsorted unless the model has fused shared experts; Mamba backends raise rather than degrade. Check your model against that list before filing anything.
The result that surprises people: no divergence at all, flag off. This is common and it is not a failed experiment. Three causes, in order of likelihood:
| Cause | How to confirm it |
|---|---|
| Every batch you tried landed in one CUDA-graph bucket. Replay pads to the nearest captured size, so batches 9 through 16 run byte-identical launch geometry and agree trivially. | Re-run with --sizes 1,16,17. If 16 and 17 differ but 15 and 16 do not, you
found a bucket riser, and the effect is a step function rather than a trend. Then re-run
with --enforce-eager: with no padding, every pair should be free to differ. |
| The prompt is too easy. A confident model has no near-ties to flip. | Raise --max-tokens — more positions, more chances — and watch
the ULP column rather than the token column. Moving logprobs with stable tokens is the
effect, just below the threshold where it becomes visible. |
| The adaptive split policy already decayed to one or two splits at your sequence length, so pinning it changes nothing. | §10.4's exercise 2 works this out: for Llama-3-70B at TP=8 and $s = 8192$, both
batch 1 and batch 32 clamp to max_kv_splits = 8, so that configuration is
already invariant across those two sizes by accident. |
The cost. This is the number nobody can hand you, and the reason is instructive. §10.4
argues the intuitive ranking is wrong: forcing one attention split and one fixed GEMM tile is nearly
free at small batch, because those layers are bandwidth-bound and the padded arithmetic rides along
in time the machine was already spending on memory. What you actually pay for is single-channel NCCL
— NCCL_MIN_NCHANNELS and NCCL_MAX_NCHANNELS pinned to 1, with
NCCL_ALGO=allreduce:tree and custom all-reduce off — and disabled prefix caching.
Neither of those appears at TP=1 on a cold workload, which is exactly the configuration a quick
benchmark uses. So:
- Run
--mode costat TP=1 and at your real TP degree. The split between attention, GEMM and collective cost inverts between them. - Run it on a shared-prefix workload and a cold one. Turning cache hits into full prefills can cost more than every kernel change combined.
- Report all four cells, or report none. One number with no configuration attached is what §10.3 spends a chapter arguing against.
Exercises
- Run
--mode sweepwith--sizes 1,15,16,17on vLLM with default CUDA graphs, then again with--enforce-eager. Predict which pairs differ in each case before running, then explain the inversion. - Read the file. Open
benchmarks/benchmark_batch_invariance.pyand find every use ofbaseline_text. Then write the two-line change that would turn it into a correctness harness. Why do you think it was written this way — what question was its author actually asking? - Predict, then verify. Run
--mode sweepat--temperature 0.0and again at--temperature 0.6 --seed 42, same server, same sizes. Which finds more divergence, and why is the answer not obviously the greedy one? - Run SGLang's
--test-mode prefixwith--return-logprob. It varies the shared-prefix length across 1, 511, 2048 and 4097 tokens. Which of those four is the odd one out, and what does that tell you about where SGLang's determinism reaches beyond the kernels? - Design. Your eval suite must be bitwise reproducible, but production runs TP=8 and cannot afford the NCCL pinning. Propose a configuration that gets reproducibility for evals without touching production, and name the property of the harness it depends on.
Answers
- vLLM's default ladder is
[1, 2, 4] + range(8, 256, 8), so with graphs on 15 and 16 both pad up to the batch-16 graph and replay byte-identical launch geometry — they should agree. 17 pads to 24 and is free to differ; 1 has its own graph and is also free to differ. So you expect one agreeing pair (15, 16) and divergence across the 16→17 riser. With--enforce-eagerthere is no padding at all, so every size runs its true shape and any pair may differ, including 15 versus 16. The inversion — graphs make more pairs agree, not fewer — is the tell that you are looking at a bucket effect and not a scheduling one. baseline_textis assigned once and printed once, truncated to 50 characters; it is never compared. Two lines fix it: captureneedle_output.outputs[0].textand assert it equalsbaseline_text. The author was asking "how much does the flag cost", which is a legitimate question, and the needle is there to keep the batch composition realistic — a fixed prompt among random ones. The name is the trap, not the code.- Greedy is the sharper demonstration, because any difference is unarguable. But seeded sampling at 0.6 finds more, because it draws from the whole distribution: greedy only diverges at a near-tie for first place, while a sampled draw is sensitive to perturbations anywhere in the top of the distribution that move the cumulative mass across the sampled threshold. That is why the upstream online test uses 0.6 with a seed rather than 0.
- 4097. SGLang snaps the chunked-prefill boundary to a multiple of the attention split tile
under determinism, and that value is 4096 for both the FlashInfer and Triton prefill paths, set
in
Scheduler.init_deterministic_inference_config. A 4097-token prefix is the only one of the four that must be split across chunks, so it is the only one that exercises the alignment constraint atpython/sglang/srt/managers/schedule_policy.py:L1393-L1402. The lesson: batch invariance is not only a kernel property in SGLang, it is a scheduling constraint. - Run a second engine process for evals at TP=1 with the flag on and leave production untouched; the NCCL pinning is inert at world size 1, and SGLang gates it on TP>1 explicitly. The dependency is that the eval harness must fit the model on one GPU. If it cannot, fix the batch instead of the kernels: send eval requests strictly serially at concurrency 1, so every batch is a batch of one. This removes co-batch variability at a throughput cost, but does not prove reproducibility: nondeterministic kernels, RNG state, cache history, hardware and version changes remain. Verify the exact token/logprob contract on an isolated server.
Key takeaways
- The file named
benchmark_batch_invariance.pydoes not test batch invariance. It computes a baseline and discards it. Check what a harness asserts before you trust that it passed — especially one whose name answers your question. - Compare logprobs, not text. Identical tokens with moving logprobs means the arithmetic changed and the argmax happened to survive; it will not survive the next near-tie.
- A clean sweep is a result, not a failure, and usually means CUDA-graph bucket padding made every batch you tried run identical launch geometry. Straddle a riser, or turn graphs off, before concluding the property holds.
- Pin the attention backend in both arms. SGLang silently substitutes a determinism-capable backend when the flag is on, which quietly makes the two halves of your experiment incomparable.
- There is no citable overhead number for the flag, and this lab does not invent one. The cost lives in collectives and disabled prefix caching, both of which are invisible at TP=1 on a cold workload — so measure at your real TP degree and your real prefix-sharing rate, or say nothing.