ML Interview Notes
16 min read5 sections
Lab 13 · for chapter 10-04

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.

Hardware

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.

Not executed here

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".

§1

What you measure

Do not use the benchmark for this

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:

benchmarks/benchmark_batch_invariance.py:L148-L152 and L190-L192 vLLM
        # 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.

Where the correctness question is actually answered, at a556f3f / 7d89325
HarnessShapeWhat it asserts
tests/v1/determinism/test_batch_invariance.pyin-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.pyHTTP, 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.pyop-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.pydriver against a live server Four modes: single, prefix, radix_cache, p_vs_d. Prints a unique-completion count.

Three measurements, in order:

  1. Unique completions across batch sizes, flag off. Greater than one is the bug, reproduced.
  2. The same count with the flag on. It should be exactly one.
  3. 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.

§2

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:

tests/v1/determinism/conftest.py:L8-L12 vLLM
@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:

tests/v1/determinism/test_batch_invariance.py:L458-L470 vLLM
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:

tests/v1/determinism/utils.py:L39-L40 vLLM
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:

python/sglang/test/test_deterministic.py:L460-L471 SGLang
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:

python/sglang/test/test_deterministic_utils.py:L12-L18 SGLang
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.

shell — the two arms, SGLang shell
$ 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
The backend is chosen for you

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:

python/sglang/srt/arg_groups/overrides.py:L2097-L2108 SGLang
        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:

tests/v1/determinism/test_online_batch_invariance.py:L144-L161 vLLM
    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:

tests/v1/determinism/test_online_batch_invariance.py:L126-L133 vLLM
        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}"
                )
shell — labs/13-batch-invariance shell
$ 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.

§3

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:

Why a sweep can come back clean with determinism off — predictions
CauseHow 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 cost at 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.
§4

Exercises

  1. Run --mode sweep with --sizes 1,15,16,17 on vLLM with default CUDA graphs, then again with --enforce-eager. Predict which pairs differ in each case before running, then explain the inversion.
  2. Read the file. Open benchmarks/benchmark_batch_invariance.py and find every use of baseline_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?
  3. Predict, then verify. Run --mode sweep at --temperature 0.0 and 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?
  4. Run SGLang's --test-mode prefix with --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?
  5. 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
  1. 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-eager there 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.
  2. baseline_text is assigned once and printed once, truncated to 50 characters; it is never compared. Two lines fix it: capture needle_output.outputs[0].text and assert it equals baseline_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.
  3. 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.
  4. 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 at python/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.
  5. 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.
§5

Key takeaways

  • The file named benchmark_batch_invariance.py does 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.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px