Skip to content

feat(native-decode): thread batch through CUDA decode shape/IO layer, fixed to 1 (2b-impl-1, #750) - #913

Merged
justinchuby merged 2 commits into
mainfrom
squad/batch-2b-impl-1-shapes
Aug 14, 2026
Merged

justinchuby merged 2 commits into
mainfrom
squad/batch-2b-impl-1-shapes

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Stage 2b-impl-1 (#750): thread batch through the CUDA decode shape / IO-binding layer, fixed to 1

Increment 1 of 4 from the split proposed in #905 (docs/NATIVE_BATCH_DECODE_2B_IMPL_SCOPING.md). Behaviour-preserving by construction — a batch value is introduced and threaded so the batch extent is no longer a hard-coded 1 literal at the shape/IO sites a batch-N step will later need, but every current entry point still constructs at batch 1, so decode is byte-identical. Batch-N is not turned on.

Does not close #750 (four-way split, three increments remain).

What changed

  • DecodeCudaState gains a batch: usize field, set to 1 in new().
  • persistent_state_shapes: axis-0 1 → batch in both the growing-KV and fixed-recurrent arms. BNSH axis order preserved — the GQA node reads present_capacity from axis 2 regardless of kv_layout, so axis 0 stays batch / axis 2 stays seq.
  • persistent_output_shape: batch axis (0) symbolic collapse → batch; every other symbolic axis stays 1. The logits binding passes batch; auxiliary outputs keep the historical collapse-to-1 (pass 1 explicitly) — their batch-symbol pinning is collect_unit_symbols work owned by 2b-impl-4.
  • Mask / input_ids / inputs_embeds / position_ids allocations, extend_mask, rewind's mask reshape, and both mask-growth paths (VMM in-place grow_vmm_mask_in_place + legacy realloc build_grown_mask) use batch.
  • Two new CPU unit tests assert the threading is real (batch=3 yields axis-0=3; batch=1 is byte-identical to the old collapse) and existing shape tests updated for the new signatures.

Validation (all green)

  • cargo fmt -p onnx-genai-engine -- --check
  • cargo clippy --locked --all-targets -p onnx-genai-engine --features native-backend -- -D warnings
  • cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings (untouched crate; run anyway as the CI gate)
  • cargo test -p onnx-genai-engine --features native-backend (incl. 2 new tests)
  • cargo test -p onnx-runtime-session
  • the six fused_batch_prefill guards
  • MANDATORY mobius_seqmajor_growth_parity_native_cuda -- --ignored --test-threads=1, run solo with nvidia-smi verified empty: 2/2 passed, seq-major byte-identical to head-major across forced growth, capture ON/OFF, VMM/realloc (growth_keeps=3, kv_bytes_moved=0 for seq-major; identical 48-token stream). (I hit self-inflicted GPU contention from a sibling agent running the same test in another worktree, stopped my run, coordinated, and re-ran solo per Latent gap: seq-major capture-retention proof covers KV binding pointers only, not weight buffers (original flaky-gate claim RETRACTED — contended measurement) #851.)

Hardware batch-1 byte-identity (solo, nvidia-smi verified empty before the run)

profile_native --model qwen14b-zp --ep cuda --backend native --tokens 16 --steady:

generated_token_ids  [96347, 3375, 724, 11, 358, 2776, 14589, 311, 6723, 429, 498, 3003, 2581, 6617, 315, 752]  # exact #750 reference
htod_bytes_per_token 2,349,010,944          # == #905 baseline
page_ins_per_token   188.0                  # ==
hit_rate/byte        78.32% / 70.16%        # ==
cuda_graph_measured  captures=1 replays=14 fallbacks=0 invalidations=1   # captures>0, fallbacks==0
cuda_graph (full)    captures=2 replays=28 fallbacks=0 invalidations=1
peak_committed       7,725,907,968 < managed_limit 7,726,753,178
oversubscribed / ref_underflows / byte_underflows / unaccounted  0 / 0 / 0 / 0

Every deterministic counter matches the #905 baseline exactly. Wall-clock is not headlined (WDDM-paging noise, #863).

Was #905's file/line inventory accurate?

Yes, substantially — it held up under implementation. Every §3 site I touched exists and pins batch at axis 0 as described, and the shape layer threaded cleanly without dragging in a later increment. Four refinements worth recording:

  1. write_decode_inputs and read_logits (named in the 2b-impl-1 list) needed no edit at fixed batch=1. write_decode_inputs writes a single i64 at offset 0 — the per-row loop is a behaviour change that belongs to a batch-N writer/entry point (2b-impl-4), not a shape literal. read_logits already reads through self.logits_shape and returns Vec<Vec<f32>>, so it is already batch-general once the logits shape carries batch. The 2b-impl-1 intent for these two is satisfied by threading the shapes they consume, not by editing the functions. Minor scoping refinement, not a defect.
  2. position_ids batch axis is index 1 for rank-N mrope ([N, B, S]), not index 0. The doc's [1,1] / [rank,1,1] is correct but doesn't call this out; the fix places batch at [rank, batch, 1] (and [batch, 1] for rank 1).
  3. Two extra mask-shape sites beyond the doc's explicit 2b-impl-1 enumeration are part of the same shape layer and were threaded for consistency: rewind's mask logical reshape and build_grown_mask (legacy realloc). For grow_vmm_mask_in_place I threaded only the shape (batch axis) and left the commit byte-range (0..bytes) to 2b-impl-2 — the shape/commit boundary the doc drew is real and is respected.
  4. Two out-of-scope batch-1 literals deliberately left untouched, correctly per the split: the paged-prefix seed validation [1, heads, seq_len, head_dim] (head-major host-mirror path, not the batch-N decode target — it already bail!s under seq-major), and the QMoE workspace-planning zeros [1, input_len, hidden] (prepare-only planner). Both are batch-1 by nature of the non-batch-N paths they serve; 2b-impl-4 can revisit if a batch-N caller ever reaches them.

The mobius gate was runnable this time — both qwen2.5-0.5b-q4_0-mobius (head) and ...-mobius-seqmajor (seq-major-patched) model dirs are present on this box, so unlike #905 this increment cleared the mandatory KV-governance gate solo.

… fixed to 1 (#750)

Stage 2b-impl-1 of the native batch-N decode split. Introduce a �atch
field on DecodeCudaState (constructor-fixed to 1) and thread it through the
shape and IO-binding layer so the batch extent is no longer a hard-coded 1
literal at the sites a batch-N step will need:

- persistent_state_shapes: KV/recurrent axis-0 -> batch (both arms), BNSH
  axis order preserved (GQA reads present_capacity from axis 2).
- persistent_output_shape: batch-axis (0) symbolic collapse -> batch; other
  symbolic axes stay 1. Logits binding passes batch; auxiliary outputs keep
  the historical collapse-to-1 (their batch-symbol pinning is 2b-impl-4).
- mask/input_ids/embeds/position_ids allocations, extend_mask, rewind and
  both mask-growth paths (VMM in-place + realloc) use batch.

Behaviour-preserving by construction: every current entry point constructs
at batch 1, so shapes and decode are byte-identical. KV growth/commit
geometry, the token writer, device argmax and batch-symbol capture pinning
stay batch-1 and are owned by 2b-impl-2..4.

Validated: fmt, clippy (engine native-backend all-targets; ep-cuda cuda),
engine native-backend tests (+2 new shape-threading unit tests),
onnx-runtime-session, the six fused_batch_prefill guards, the mandatory
mobius_seqmajor_growth_parity_native_cuda GPU gate solo (2/2, seq-major
byte-identical to head-major across growth), and a solo on-hardware batch-1
byte-identity profile_native run on qwen14b-zp: tokens exact-match the #750
reference and htod_bytes_per_token=2,349,010,944 / page_ins_per_token=188 /
captures=1 replays=14 fallbacks=0 invalidations=1 / peak_committed
7,725,907,968 < managed_limit / oversubscribed=0 all match the #905 baseline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.43%. Comparing base (1c7b093) to head (ed59f14).
⚠️ Report is 12 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #913      +/-   ##
==========================================
+ Coverage   77.78%   78.43%   +0.64%     
==========================================
  Files         354      354              
  Lines      148042   148042              
  Branches   148042   148042              
==========================================
+ Hits       115161   116114     +953     
+ Misses      28390    27433     -957     
- Partials     4491     4495       +4     
Flag Coverage Δ
cli-ort-linux 83.42% <ø> (ø)
cli-ort-windows 82.92% <ø> (-0.10%) ⬇️
mlas 80.73% <ø> (-0.24%) ⬇️
offline 78.21% <ø> (+0.68%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@justinchuby

Copy link
Copy Markdown
Owner Author

Validated independently before merging.

Byte-identity on hardware, solo (
vidia-smi verified empty first): token IDs exactly the reference [96347, 3375, 724, 11, 358, 2776, 14589, 311, 6723, 429, 498, 3003, 2581, 6617, 315, 752], 7.60 tok/s against 7.84 measured on \main\ in the same session, \captures=2 fallbacks=0, all underflows 0. Within this box's noise band and with no counter moving that should not have.

Gates: fmt clean, 93/93
ative_decode\ tests pass, \origin/main\ merges without conflict.

I read the diff rather than trusting the batch-1 result, because a change that is byte-identical by construction can hide a wrong axis that only appears at batch N. \persistent_state_shapes\ keeps batch on axis 0 and the grown seq extent on axis 2 for both KV layouts, which is the binding contract the CUDA GQA node depends on; the new \cuda_persistent_state_shapes_thread_batch_axis_0\ test pins that at \�atch=3\ where a transposed axis would actually show up, rather than at 1 where it could not. That is the right test for this increment.

The scope discipline is what makes this mergeable: \�atch\ is constructor-fixed to 1 and the doc comment names exactly what is not generalized (token writer, KV growth/commit geometry, device argmax, capture symbol pinning). #905's inventory held up under implementation — worth recording, since the last three increments on this line each returned a correction instead.

Merging. 2b-impl-2 (KV growth/commit) is next.

@justinchuby
justinchuby merged commit 13e42c9 into main Aug 14, 2026
@justinchuby
justinchuby deleted the squad/batch-2b-impl-1-shapes branch August 14, 2026 03:32
justinchuby added a commit that referenced this pull request Aug 14, 2026
…l it the route (#919)

`PROGRESS.md` and `WEIGHT_OFFLOAD.md` both still described the #864
hybrid as the open route to beating the OS, and the north-star row went
as far as *"its two blocking risks are now measured away"*. True when
written; now stale **in the worst direction**, because it points the
next person at a lever that has already been built and measured to fail
(#912).

Corrected with what was actually found. The two blocking risks really
were measured away first (#880: ~5.6 GB/s real strided GEMV,
bit-identical capture with host-mapped pointers) — so what killed it was
**neither of them**:

- aggregate *distinct* host-mapped reads above ~0.44–0.65 GB/step
**silently return stale data** (48 cold weights collapsed generation 16
→ 3 tokens; a single read is bit-identical at 1/8/16/32, and a
copy-instead A/B pins the fault on the read, so it is an aperture
ceiling rather than a per-read bug);
- capped at a provably safe 256 MiB, the arm ran **0.73 tok/s against
WDDM's 7.84**. The safe aperture (0.26 GB/step) is smaller than the
traffic it must displace (~0.6 GB/step) — short **by construction**, not
by tuning.

Row 3's honest status is now stated as such: **on WDDM consumer hardware
we do not beat the OS, we correctly decline to compete with it, and no
route currently on the table changes that.** It then names the two
levers that do remain — batching (which creates the intra-step reuse
that makes residency pay at all) and the admission side of the residency
gap.

Also in this pass:

- Removes a justification the residency-policy item was **borrowing**
from the hybrid, and adds what #892/#901 since established: eviction
order is not the cause and is worth at most ~10% of the gap, so ~90% is
admission. Anyone picking that up should not spend it on eviction
policy.
- Refreshes the #750 batching item, which still listed stage 2b as
remaining after #891 and #913 landed.
- Adds seven link-reference definitions (#783, #863, #864, #874, #877,
#880, #912) that were used in bracket-link form but never defined, so
they rendered as literal text.

Docs only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
@github-actions

Copy link
Copy Markdown

🔴 Benchmark Regression Detected

Comparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).

ℹ️ Absolute times are informational only — they vary with runner load. The % change column is the reliable signal because both sides ran under identical conditions.

Status Scenario Base PR Change
🔴 matmul/large_generic_f32_threads=8/32x1024x1024 3.98 ms 5.94 ms +49.2%
🔴 kv_cache/alloc_dealloc_pages 37.54 µs 50.58 µs +34.7%
🔴 qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.62 ms 4.71 ms +30.3%
⚠️ matmul/medium_generic_bf16_threads=8/32x512x512 493.72 µs 600.19 µs +21.6%
⚠️ qwen3_sampling_processors/top_k_top_p_fast 721.99 µs 876.37 µs +21.4%
⚠️ matmul/medium_generic_f16_threads=8/32x512x512 34.07 µs 40.98 µs +20.3%
⚠️ qwen3_sampling_processors/top_k_full_sort_baseline 2.13 ms 2.52 ms +18.0%
✅ matmul/medium_generic_f16_threads=1/32x512x512 32.12 µs 36.25 µs +12.9%
✅ matmul/medium_generic_f32_threads=8/32x512x512 1.26 ms 1.42 ms +12.6%
✅ add/small_f32_threads=1-internal/1024 257.4 ns 286.3 ns +11.2%
✅ add/small_bf16_threads=1-internal/1024 12.81 µs 14.10 µs +10.0%
✅ add/medium_f32_threads=1-internal/262144 2.57 ms 2.80 ms +9.0%
✅ add/large_bf16_threads=1-internal/4194304 40.58 ms 42.61 ms +5.0%
✅ reduce_mean/small_f32_threads=1-internal/4096 14.79 µs 15.43 µs +4.4%
✅ add/medium_bf16_threads=1-internal/262144 2.64 ms 2.73 ms +3.6%
✅ qwen3_sampling_processors/top_k_partial_selection 157.60 µs 163.33 µs +3.6%
✅ sampling_latency/top_p_per_token 386.47 µs 398.26 µs +3.1%
✅ add/small_f16_threads=1-internal/1024 13.65 µs 14.06 µs +3.0%
✅ add/large_f16_threads=1-internal/4194304 41.77 ms 42.39 ms +1.5%
✅ sampling_latency/top_k_per_token 53.33 µs 54.00 µs +1.2%
✅ add/medium_f16_threads=1-internal/262144 2.61 ms 2.64 ms +1.0%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 566.46 µs 570.67 µs +0.7%
✅ tokenization/decode_tokens_per_second 6.55 ms 6.49 ms -0.9%
✅ matmul/large_generic_f32_threads=1/32x1024x1024 10.07 ms 9.95 ms -1.2%
✅ add/large_f32_threads=1-internal/4194304 41.82 ms 41.26 ms -1.4%
✅ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 78.32 µs 77.06 µs -1.6%
✅ matmul/small_generic_bf16_threads=8/1x256x256 34.85 µs 34.23 µs -1.8%
✅ tokenization/encode_tokens_per_second 397.57 µs 381.24 µs -4.1%
✅ sampling_latency/min_p_per_token 216.65 µs 206.45 µs -4.7%
✅ gather/large_bf16_threads=1-internal/131072 17.36 µs 16.53 µs -4.8%
✅ matmul/small_generic_bf16_threads=1/1x256x256 35.14 µs 33.45 µs -4.8%
✅ qwen3_sampling_processors/top_p_fast_after_top_k 612.71 µs 579.53 µs -5.4%
✅ logit_processing/seven_processor_chain_per_step 366.18 µs 344.25 µs -6.0%
✅ matmul/small_generic_f16_threads=8/1x256x256 35.60 µs 33.42 µs -6.1%
✅ sampling_latency/greedy_per_token 3.51 µs 3.29 µs -6.5%
✅ matmul/small_generic_f16_threads=1/1x256x256 36.52 µs 33.44 µs -8.4%
✅ gather/large_f16_threads=1-internal/131072 16.40 µs 14.51 µs -11.5%
✅ reduce_mean/medium_f32_threads=1-internal/65536 285.89 µs 250.03 µs -12.5%
✅ matmul/large_generic_f16_threads=8/32x1024x1024 113.64 µs 99.29 µs -12.6%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.82 ms 2.43 ms -13.8%
🟢 matmul/small_generic_f32_threads=1/1x256x256 48.00 µs 40.68 µs -15.3%
🟢 qwen3_sampling_processors/top_k_top_p_full_sort_baseline 7.22 ms 6.09 ms -15.6%
🟢 gather/medium_f16_threads=1-internal/32768 2.84 µs 2.39 µs -15.7%
🟢 gather/medium_bf16_threads=1-internal/32768 2.87 µs 2.36 µs -17.9%
🟢 grammar_masking/llguidance_compute_mask/32 98.94 µs 80.85 µs -18.3%
🟢 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 737.03 µs 595.97 µs -19.1%
🟢 gather/small_bf16_threads=1-internal/4096 608.7 ns 491.1 ns -19.3%
🟢 block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 504.88 µs 405.65 µs -19.7%
🟢 matmul/large_generic_f16_threads=1/32x1024x1024 107.87 µs 84.79 µs -21.4%
🟢 gather/small_f32_threads=1-internal/4096 862.3 ns 677.7 ns -21.4%
🟢 reduce_mean/large_f32_threads=1-internal/262144 1.30 ms 1.02 ms -21.5%
🟢 matmul/small_generic_f32_threads=8/1x256x256 50.40 µs 39.16 µs -22.3%
🟢 gather/small_f16_threads=1-internal/4096 649.4 ns 497.5 ns -23.4%
🟢 gather/large_f32_threads=1-internal/131072 51.12 µs 35.42 µs -30.7%
🟢 matmul/large_generic_bf16_threads=1/32x1024x1024 2.83 ms 1.93 ms -31.7%
🟢 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 71.41 µs 46.12 µs -35.4%
🟢 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 75.01 µs 47.70 µs -36.4%
🟢 gather/medium_f32_threads=1-internal/32768 6.17 µs 3.78 µs -38.6%
🟢 matmul/large_generic_bf16_threads=8/32x1024x1024 2.41 ms 1.34 ms -44.4%

Visual flags: ⚠️ ≥ 15% slower, 🔴 ≥ 30% slower — calibrated against measured runner noise (~27% worst-case on multi-threaded matmul)

Host info
CPU: Apple M1 (Virtual)
Cores: 3
OS: Darwin 25.5.0 arm64
Rust: rustc 1.97.1 (8bab26f4f 2026-07-14)
Load avg: { 4.60 3.63 5.92 }
What this cannot catch
  • Regressions in code paths not covered by these benchmarks (e.g., end-to-end decode with a real model)
  • Sub-threshold regressions that compound over multiple PRs
  • Performance changes that only manifest under GPU execution
  • Latency changes in the ORT integration path (these benchmarks exercise the native Rust kernels)

justinchuby added a commit that referenced this pull request Aug 14, 2026
…refuse seq-major relocation (2b-impl-2, #750) (#920)

## Stage 2b-impl-2 (#750): make KV growth and commit geometry
batch-aware

Increment 2 of 4 from the split in
`docs/NATIVE_BATCH_DECODE_2B_IMPL_SCOPING.md`. Extends #913 (which
threaded a constructor-fixed `batch=1` through the shape/IO layer) by
making the **KV growth and commit geometry** compute over `batch`
sequences instead of assuming a single sequence at batch index 0.
`batch` stays **constructor-fixed to 1**, so every change is
**byte-identical at batch 1** (the primary gate). Batch-N is **not**
turned on (2b-impl-4). Does **not** close #750.

### What is batch-general after this increment (per layout)

**Relocation-free, made batch-general + tested at batch=3:**
- **Mask commit byte extents** — construction (`mask_bytes` /
`full_mask_bytes`), `grow_vmm_mask_in_place` memset,
`vmm_growth_requests` mask island, and the realloc `build_grown_mask`
memset now all span `batch` rows (they zeroed/committed one row before).
- **Head-major (BNSH) growing bucket** — the flat `0..bucket` KV range
(`checked_shape_bytes` already includes the batch axis) and the
`copy_kv_prefix*` / `zero_kv_suffix` block loop are already
batch-general because batch is the **outermost** axis (block count =
`batch × kv_heads`, each `(batch, head)` stripe re-strides
independently). Made explicit + unit-tested
(`kv_growth_byte_layout_refuses_seq_major_batch_n_and_allows_head_major_batch_n`).
- **Seq-major fixed full-context stride commit** — `live_prefix_ranges`
(now takes `batch`), `seq_major_kv_commit_requests`, and
`zero_seq_major_committed_tail` emit **one dense run per sequence**,
each a fixed `capacity × bytes_per_token` apart. The per-sequence stride
is the *pinned* full context, so this is **relocation-free** and
batch-general. New CPU tests:
`seq_major_batch_n_is_one_dense_run_per_sequence`,
`head_major_batch_n_is_one_fragment_per_sequence_head`.

**Refused (named, tested) — NOT batch-general:**
- **Seq-major growing-bucket / realloc growth at `batch > 1`.**
`kv_growth_byte_layout` now `bail!`s with a named error. A growing
bucket's per-sequence stride **is** the mutable bucket capacity, so
growing the bucket would **relocate every sequence `b > 0`'s**
already-written KV — defeating the seq-major fixed-stride "no KV moves /
keep the captured graph" contract and the `moved_bytes_per_token == 0`
accounting. Head-major has no such problem (batch outermost). A clear
refusal is the correct deliverable here rather than a silently wrong
capacity-dependent stride. The relocation-free batch-N seq-major path is
the fixed-stride commit above, which never calls this helper.

### Byte-identity + counters (hardware, solo, `nvidia-smi` verified
empty)
`profile_native --model qwen14b-zp --ep cuda --tokens 16 --steady`:
```
generated_token_ids  [96347, 3375, 724, 11, 358, 2776, 14589, 311, 6723, 429, 498, 3003, 2581, 6617, 315, 752]  # EXACT #750 reference
cuda_graph           captures=2 replays=28 fallbacks=0 invalidations=1
cuda_graph_measured  captures=1 replays=14 fallbacks=0 invalidations=1
vmm_arena            ref_underflows=0 byte_underflows=0 unaccounted_committed_bytes=0
resource_vram        oversubscribed_bytes=0
throughput           7.32 tok/s   # within the 3.9-28 band vs 7.84 baseline -> NOT a finding (#863)
```
Token identity is exact and the deterministic counters (`captures=2
fallbacks=0`, underflows 0) match the baseline conditions of the run
command.

### Mobius parity gate (MANDATORY, run SOLO)
`mobius_seqmajor_growth_parity_native_cuda -- --ignored
--test-threads=1`, `nvidia-smi` verified empty: **2/2 passed**.
Seq-major **kept** the captured graph across **3 growth events** with
`kv_bytes_moved=0`, `growth_keeps=3`, and a **byte-identical 48-token
stream** to head-major across VMM in-place (capture ON/OFF), realloc,
and the no-growth control. This is the most relevant gate since this
increment touches growth/commit, and it confirms the seq-major
fixed-commit + tail-zero changes are byte-identical at batch 1.

### Other validation (all green, local)
- `cargo fmt -p onnx-genai-engine -- --check`
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
(exact CI gate)
- `cargo clippy -p onnx-genai-engine --features native-backend,cuda --
-D warnings`
- `cargo test -p onnx-genai-engine --features native-backend,cuda --lib
native_decode` — **96 passed**, incl. the 3 new batch>1 geometry tests.

### What did not survive contact with the code (scoping corrections)
1. **`live_prefix_ranges` is NOT "another crate."** SCOPING §3 item 6
and §4 say the seq-major `live_prefix_ranges` path "must be proven
batch-correct (another crate)." It actually lives **in this crate**:
`crates/onnx-genai-engine/src/native_decode/kv_commit.rs:106`. Its only
non-test caller is `seq_major_kv_commit_requests` (`cuda.rs`). No
cross-crate signature change was needed; the driver-level residency
measurement in `onnx-runtime-cuda-memory` is a **mirror**, not a caller,
so it was unaffected (its clippy gate still passes).
2. **"KV growth stride math is already batch-general" is only true for
head-major.** SCOPING §3 ("Already batch-general") claims the
block-count stride math re-strides N batch blocks correctly for both
layouts and "this is the part people assume is the blocker; it is not."
That holds for **head-major** (batch outermost). For **seq-major growing
bucket at `batch > 1`** the block loop would *silently relocate*
sequences `b > 0` (capacity-dependent block stride) and break the
`moved_bytes_per_token == 0` / "no KV moves" invariant. This PR turns
that silent misbehavior into an explicit refusal.
3. **The finer split is fixed-stride vs growing-bucket, not head- vs
seq-major.** The relocation trap is specific to a *growing bucket*; the
seq-major *fixed full-context stride* commit is relocation-free and was
made fully batch-general here.

### Judge-it-yourself
Merge `origin/main`, then: `cargo test -p onnx-genai-engine --features
native-backend,cuda --lib native_decode` reproduces the 3 batch>1
geometry tests; the solo mobius run reproduces the batch-1
byte-identity;
`kv_growth_byte_layout_refuses_seq_major_batch_n_and_allows_head_major_batch_n`
reproduces the refusal proof.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
justinchuby added a commit that referenced this pull request Aug 14, 2026
…batch-aware (2b-impl-3, #750) (#924)

## What this is

**Increment 3 of 4** of native batch-N decode (#750), stage 2b-impl-3:
make the **device argmax / greedy token selection** path batch-aware.
`batch` stays **constructor-fixed to 1** — this delivers the
*capability*, it does not turn batch-N on (that is 2b-impl-4).
Byte-identity at batch 1 is the primary gate.

Threads a `batch` count through the whole device-argmax stack:
- the CUDA kernel (`onnx-runtime-ep-cuda` `kernels::device_argmax`):
partials grid gains a sequence row (`blockIdx.y`), finalize gains one
block per sequence (`blockIdx.x`), result header is `2 × batch` words (a
`[token_id, capture_error]` pair per sequence), scratch is `batch × 2 ×
partial_count`;
- `device_argmax_scratch_words(elements, batch)` and
`scratch_words(elements, batch)`;
- the `ExecutionProvider::device_argmax` trait + the CUDA impl;
- `DeviceIoBinding::device_argmax(elements, batch, result)`;
- the `__native_greedy_argmax` buffer allocation (now `2·batch +
scratch`);
- `read_greedy_result` now delegates to a new `read_greedy_result_batch`
returning `Vec<(TokenId, u32)>` of length `batch`.

## What is now batch-general, and what is not

**Batch-general:** the argmax kernel selects `batch` tokens from
`[batch, 1, vocab]`, one grid row per sequence, writing `batch`
`[token_id, capture_error]` pairs. `read_greedy_result_batch` reads all
N rows.

**Not batch-general (correctly out of scope):** the **token writer**
`write_decode_inputs` (`cuda.rs`) still writes one i64 at offset 0. Per
the scoping doc that writer is item 3 = **2b-impl-1**, and the batch-N
entry point that would present N tokens and consume N rows is
**2b-impl-4** (doc §2.1, §3). So nothing yet writes N selected tokens
into N input slots — there is no batch-N caller until impl-4. I did
**not** touch `write_decode_inputs`.

## Is batch-1 tie-breaking provably unchanged? Yes — two independent
reasons

1. **Launch geometry is bit-identical at batch 1.** `partial_count` is
per-sequence and unchanged; at `batch == 1` the partials grid is
`(partial_count, 1, 1)` and finalize is `(1, 1, 1)` — exactly the prior
launch. The sequence offset (`blockIdx.y`, `blockIdx.x`) is 0, the
result header is 2 words, and the scratch offset/layout are unchanged,
so the kernel writes the same words and the 8-byte read-back is
unchanged.
2. **The reduction operator is order-invariant by construction.**
`argmax_update` keeps *max value, min index on ties* — commutative and
associative — so the selected `(value, index)` is independent of how the
vocab is partitioned into blocks/partials. Even if the grid shape
changed, ties could not drift. This is why greedy selection here is not
a numerics risk the way a floating-point sum would be.

## Byte-identity result (gate 5, solo)

`profile_native --model qwen14b-zp --ep cuda --tokens 16 --steady`,
`nvidia-smi` verified empty before the run (`0 MiB`), greedy (`sampling:
OFF`):

```
generated_token_ids: [96347, 3375, 724, 11, 358, 2776, 14589, 311, 6723, 429, 498, 3003, 2581, 6617, 315, 752]   ← exact match to the #750 reference
cuda_graph: enabled=true captures=2 replays=28 fallbacks=0 invalidations=1
cuda_graph_measured: captures=1 replays=14 fallbacks=0 invalidations=1
vmm_arena: ref_underflows=0 byte_underflows=0 unaccounted_committed_bytes=0
resource_vram: oversubscribed_bytes=0
```

Deterministic counters all green (`captures=2`, `fallbacks=0`,
underflows `0`, `oversubscribed=0`), matching the doc's batch-1
baseline. No throughput delta reported (this box's wall-clock ranges
widely; #863/#884). Note: this run had
`MANAGED_WEIGHT_STREAMING`/`CUDA_GRAPH` env unset, so the
`weight_offload_*` byte counters read 0 — that is an env difference, not
a code effect; token identity and the graph/underflow counters are the
gates and they hold.

## Mobius parity (gate 4, solo): **2 passed**

```
cargo test -p onnx-genai-engine --features cuda,native-backend --test mobius_seqmajor_growth_parity_native_cuda -- --ignored --nocapture --test-threads=1
=> test result: ok. 2 passed; 0 failed
```
(Requires the anaconda site-packages CUDA DLLs on PATH, else CublasLt is
not found and the child emits no measurement — a failing gate, not a
pass.)

## Other gates
- `cargo fmt -p onnx-genai-engine -- --check` ✓ (also
ep-cuda/ep-api/session)
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
✓ (exact CI gate, no `--all-targets`)
- `cargo test -p onnx-genai-engine --features native-backend,cuda --lib
native_decode` ✓ 96 passed
- device test
`device_argmax_batch_selects_per_row_max_with_deliberate_ties` ✓ (GPU,
solo) — plus the two pre-existing 151,936-element argmax tests still
pass.

## The falsifying control
`device_argmax_batch_selects_per_row_max_with_deliberate_ties` puts the
max at a **different position in every row**, makes each row larger than
one block's grid-stride reach (so multiple partials are reduced), and
plants a tie at an **earlier index than the peak** in two rows. It
asserts (a) the expected per-row token, (b) `capture_error == 0` per
row, and (c) each batched row equals a standalone single-sequence argmax
of the same logits. A shared reduction buffer, a wrong per-sequence
stride, or global (rather than per-sequence) tie-breaking would each
fail this.

## Scoping doc: what survived contact with the code
The doc's item 5 (device argmax → 2b-impl-3) matched the code exactly:
`read_greedy_result` calls `binding.device_argmax(vocab, …)` returning
one token, and the kernel + `device_argmax_scratch_words` live in
`onnx-runtime-ep-cuda`. **No correction to the split.** One observation
worth recording (not a re-split): the doc lists `write_decode_inputs`
under 2b-impl-1 (item 3, "Need N tokens × N rows"), but merged #913 left
it single-token and it is still single-token here — because the N-token
write is inseparable from a batch-N *caller*, which the doc itself
places in 2b-impl-4 (§2.1). So "where the N selected tokens go"
genuinely lands with the entry points in impl-4, consistent with
§2.1/§3; impl-1's item 3 could only ever write the one token a batch-1
constructor presents.

Closes nothing — leaves #750 open. Do not merge on my behalf.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
justinchuby added a commit that referenced this pull request Aug 14, 2026
…n batch-N on (2b-impl-4, #750) (#930)

## What this is

Increment **4 of 4** of native batch-N decode (issue #750), stage
2b-impl-4:
pin the batch symbol, wire the token writer, turn batch-N on. Increments
1-3 (#913, #920, #924) were byte-identical by construction with `batch`
constructor-fixed to `1`. **This is the increment that changes
behaviour**,
so it is measured rather than asserted. **#750 stays open** for the 2c
caller.

> **Correction (2nd commit).** The first version of this PR reported
that
> "capture and weight-streaming are mutually exclusive on this build."
**That
> was a harness artifact, not a runtime property**, and it has been
corrected
> in place. The sweep loaded the native session via
`load_with_resolved_io`,
> which passes `cuda_offload_policy: None`;
`load_with_cuda_options_and_io`
> then leaves `weight_offload_stable_va` at its conservative `false`
default
> (`native_decode/load.rs:793`), so the capture predicate
> `weight_offload_enabled && !weight_offload_stable_va` declined capture
*by
> construction* whenever offload was on. The knob I set (the entry
point) set
> the flag that produced the decline that I read as a finding. The
harness now
> drives the **fully governed** `Engine` session, whose effective
offload
> policy carries the managed no-spill stable-VA authority — and
**capture and
> streaming coexist**, exactly as #796/#836 built and verified. All the
> capture/streaming numbers below are re-measured on that governed path.

## What is on and under what flag

- **`ONNX_GENAI_NATIVE_DECODE_BATCH`** (default `1`). At the default the
decode path is **byte-identical to `main`**. Setting `N>1` binds a
stable
  batch extent at session construction and drives N rows through the
  persistent captured decode path.
- Batch symbol pinned for aux outputs via `persistent_output_shape`
  (axis-0 → `batch`); KV seq-axis pinning unchanged.
- Token writer wired: `write_decode_inputs_batch` (N token IDs → N
slots),
`write_position_binding_batch` (rank-R mrope per sequence),
`extend_mask`
  (N mask rows). Single-token entry points delegate to the batch writers
  (byte-identical at batch 1).
- `decode_cuda_greedy_batch` /
`NativeDecodeSession::decode_greedy_batch` is
the batch-N greedy entry point; `native_decode_batch()` exposes the
extent.
- `Engine::native_decode_session_mut()` (new, native-backend) lets the
  measurement harness drive batch-N decode against the governed session.
- `profile_native --native-decode-batch-sweep <list>
[--native-decode-batch-context L]`
builds the governed `Engine` per batch shape and exercises the captured
  decode path. **The batch-N generation driver (real per-sequence prompt
  seeding) is stage 2c and out of scope here** — the harness fans one
identical token to all N rows, which is a strict correctness probe and,
because the weight/page/capture counters are content-invariant (#884),
the
  trustworthy measurement vehicle.

## Mandatory gates — all re-run locally after the correction

| Gate | Result |
|------|--------|
| `cargo fmt -p onnx-genai-engine -- --check` (+ `-p onnx-genai-bench`)
| **pass** |
| `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
| **exit 0** |
| `cargo test -p onnx-genai-engine --features native-backend,cuda --lib
native_decode` | **96 passed, 0 failed** |
| **mobius parity** `--test mobius_seqmajor_growth_parity_native_cuda --
--ignored --nocapture --test-threads=1` | **2 passed** (SOLO) |
| **batch-1 byte-identity on hardware** | **exact match** (below) |

### Batch-1 byte-identity with counters

Reference token IDs matched **exactly** with the feature compiled in and
batch configured to 1 (`qwen14b-zp`, `--steady --tokens 16`):

```
[96347, 3375, 724, 11, 358, 2776, 14589, 311, 6723, 429, 498, 3003, 2581, 6617, 315, 752]
```

Counters matched the documented baseline: `captures=2`, `replays=28`,
`fallbacks=0`, `htod_bytes_per_token=2349010944`,
`page_ins_per_token=188`,
`hit_rate=78.32%`, `byte_hit_rate=70.16%`.

## Correctness of batch-N (row identity)

`qwen14b-zp`, all N, every step, on the governed captured+streaming arm:
**all N rows == row 0** (the #892 cross-row-corruption detector) and
**row 0
== the batch-1 stream** at N=2,4,8 (the scoping-doc §3.8 guard). Token
*values* at N>1 are content-arbitrary (real per-sequence seeding is 2c);
the
counters are not.

## Recapture per shape (#891) — **measured, capture survives batch-N
WHILE streaming**

`qwen14b-zp`, governed engine, managed streaming forced
(`ONNX_GENAI_MANAGED_WEIGHT_STREAMING=1`, `ONNX_GENAI_CUDA_GRAPH=1`),
empty
past, measured window, per batch shape. **Non-zero htod confirms weights
are
actually streaming during these captured steps:**

| N | graph enabled | captures | replays | fallbacks | invalidations |
htod_bytes_per_token |

|---|---------------|----------|---------|-----------|---------------|----------------------|
| 1 | true | 1 | 6 | 0 | 0 | 2,994,222,848 |
| 2 | true | 1 | 6 | 0 | 0 | 1,502,098,688 |
| 4 | true | 1 | 6 | 0 | 0 | 736,831,040 |
| 8 | true | 1 | 6 | 0 | 0 | 424,642,624 |

**Capture survives batch-N as implemented, on the real streaming decode
path** — one capture per shape, zero invalidations, zero fallbacks at
every N,
with ~3 GB/token of weights streaming underneath. This is the positive
resolution of #891's open risk the line has been waiting for, and it is
the
part the original PR got wrong by measuring the wrong arm.

## 1/N weight-stream amortization on the captured decode path —
**measured**

Same governed captured+streaming arm as above (managed path, #874 note:
the
WDDM default is the OS shared-memory path with htod=0, so managed
streaming
is forced). Model `qwen14b-zp` (8.3 GB weights), empty past, `--tokens
8`.
Emitted tokens = steps × N.

| N | htod_bytes_per_token | page_ins_per_token | abs page_ins/forward |
evictions | byte_hit_rate |

|---|----------------------|--------------------|----------------------|-----------|---------------|
| 1 | 2,994,222,848 | 276.8 | 2214 | 1273 | 61.96% |
| 2 | 1,502,098,688 | 139.9 | 2239 | 1293 | 61.83% |
| 4 | 736,831,040 | 66.6 | 2132 | 1185 | 62.55% |
| 8 | 424,642,624 | 41.6 | 2662 | 1678 | 56.84% |

`htod_bytes_per_token` falls **~1/N** (2.99 GB → 1.50 → 0.74 → 0.42; a
7.05×
reduction N=1→8 against the ideal 8×). Amortization vs the N=1 ideal
extrapolation: N=2 **+0.3%**, N=4 **−1.6%**, N=8 **+13.5%**. The N=8
shortfall
is not noise — it is the #866 offset (next section). All four shapes ran
SOLO
with `nvidia-smi` verified empty; no contention flags.

**The amortization is approximate here, not byte-exact.** On this
governed
managed arm the evictor's working set and page order vary per session
load,
so the per-forward `abs page_ins` drifts (2214 / 2239 / 2132 / 2662)
rather
than staying identical. That is the honest behaviour of the *captured
streaming* path. For contrast, the earlier *eager* offload arm (offload
on,
capture declined, fixed `DEVICE_BYTES=6e9`) gave **byte-exact** 1/N with
an
identical `abs page_ins=1791` at every N — because a fixed budget with
no
capture is fully deterministic. Both arms agree that the weight stream
is
paged once per forward and the per-produced-token cost falls ~1/N; the
captured arm is the one that matters for #750 and it now measures
cleanly.

Per the task instruction, **no throughput delta is reported as a
finding**
(this box ranges 3.9–28 tok/s across identical configs). The
deterministic
counters above are the evidence.

## The #866 offset — **measured within-sweep on the governed arm**

#866 makes the weight budget elastic against occupancy, so batch-N (N×
the
working set) should shrink the weight residency and push `byte_hit_rate`
down.
The governed captured arm shows exactly this **within a single sweep**:

- N = 1, 2, 4: `byte_hit_rate` ≈ **62%** (61.96 / 61.83 / 62.55, flat
within
  run-to-run noise), evictions ≈ 1200–1300.
- N = 8: `byte_hit_rate` drops to **56.84% — a −5.12-point offset**,
evictions
rise to 1678, and `abs page_ins/forward` rise 2214 → 2662. That offset
is
precisely why the N=8 per-token htod runs **+13.5% above** the ideal 1/N
line: the N× working set ate into the elastic weight budget, so each
forward
  had to re-stream more.

This is the #866 offset the line predicted, measured on the real decode
path
rather than inferred. (The earlier PR could only show it as an
*arm-to-arm*
budget difference because the un-governed sweep never let the elastic
budget
respond to batch; the governed engine does.)

## Honest summary of what is and isn't proven

- **Proven:** batch symbol pinned; token/mask/position writer wired;
batch-N
  runs end-to-end N=1,2,4,8; batch-1 byte-identity; row identity and
cross-batch identity; **capture survives batch-N *while weights
stream***
(captures=1, invalidations=0, htod≈3 GB/token) on the governed engine
path;
~1/N weight-stream amortization on the captured decode path; the within-
  sweep #866 byte-hit-rate offset (−5.12 pts at N=8).
- **Corrected:** the original "capture and streaming are mutually
exclusive"
  claim was a harness artifact of `load_with_resolved_io`'s conservative
`weight_offload_stable_va=false` default; the governed path proves they
  coexist.
- **Named limitation (in the spirit of #920's seq-major `bail!`):** on
the
governed managed-streaming arm the 1/N amortization is *approximate*,
not
byte-exact, because the evictor's per-load working set varies and the
#866
offset grows the stream at N=8. This is a property of managed streaming,
not
  a batch-N defect.
- **Out of scope (2c):** real per-sequence prompt seeding / the batch-N
generation driver. The harness fans one identical token to all rows, so
  token *values* at N>1 are content-arbitrary; the counters are content-
  invariant (#884) and are the measurement.

**Do not merge** — leaving #750 open for the 2c caller.

---------

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
justinchuby added a commit that referenced this pull request Aug 14, 2026
…mpl-4 (#939)

docs(progress): record the #750 batch-N line as complete through
2b-impl-4

The four-way split (#905) is done: #913, #920, #924, #930 all landed.
The
entry still described increments 2-4 as remaining and carried none of
what
they found.

The headline is the result the line was started for, reproduced
independently rather than taken from the PR: capture survives batch-N
while
weights stream. Solo, nvidia-smi verified empty, managed streaming
forced,
qwen14b-zp at context 64 -- N=2 and N=4 both show enabled=true
captures=1
invalidations=0 fallbacks=0 with htod_bytes_per_token 1,234,271,232 and
547,528,704, byte_hit_rate 68.64% -> 72.17%. captures=0 replays=8 inside
the measured window is capture-once/replay-many working as intended.

Also records what each increment actually contributed, because three of
them returned a correction to my scoping rather than the scoping:

- #920 drew the distinction the scoping had missed -- a fixed
full-context
stride commit is batch-general in both layouts, a growing bucket only in
head-major -- and refuses seq-major growing-bucket growth at batch>1
with
  a named bail! rather than computing a capacity-dependent stride that
would have passed every batch-1 test and corrupted the first batch-N
run.
- #924 proved batch-1 tie-breaking unchanged two ways, including that
  argmax_update is a total order so the reduction cannot depend on tree
  shape.

And it records the near-miss, because that is the part worth
remembering.
#930's first version reported capture and weight streaming as mutually
exclusive, from a real decline message naming a real predicate. The
sweep
harness called load_with_resolved_io, which passes cuda_offload_policy:
None, so stable_va fell to its conservative unwrap_or(false) and capture
declined by construction. The harness asserted pointer-instability; the
runtime was never asked. That would have closed the most valuable open
question on this line with a convincing wrong answer.

Finally, the #866 offset is now measured within-sweep rather than
inferred:
byte_hit_rate flat ~62% at N=1,2,4 then -5.12 points at N=8 as evictions
go
1273 -> 1678, which is why N=8 sits +13.5% above the ideal 1/N line.
Batch-N
multiplies KV occupancy by N and eats the elastic weight budget -- the
mechanism #866 predicted, now observed rather than argued.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
justinchuby added a commit that referenced this pull request Aug 14, 2026
…increment corrected (#941)

docs(#750): mark the 2b-impl scoping complete and annotate what each
increment corrected

All four increments landed (#913, #920, #924, #930), but the document
still
read as an open plan, and 2c is being scoped against it right now.

The corrections are the more useful part of it now, because three of the
four returned one, so they are recorded both as a table up front and
inline
at the exact line that was wrong -- a reader trips over the claim, not
over
the summary.

- §3's "the pleasant surprise: KV growth stride math is already
  batch-general" holds only for head-major. A fixed full-context stride
  commit is batch-general in both layouts because the batch axis is
  outermost; a growing bucket is not, because seq-major's per-sequence
stride IS the mutable capacity, so growth relocates every sequence b>0.
  #920 refuses that case with a named bail! rather than computing a
capacity-dependent stride, which would have passed every batch-1 test
and
  corrupted the first batch-N run. The section conflated the two paths.
- §4 item 6 places live_prefix_ranges in onnx-genai-kv; it is in this
crate
  at native_decode/kv_commit.rs:122, so no cross-crate coordination was
needed. Line number checked against current main rather than copied from
  the PR, since it moved when the batch parameter was added.

Records the outcome the line was started for, reproduced by me rather
than
quoted from the PRs: capture survives batch-N while weights stream --
enabled=true captures=1 invalidations=0 fallbacks=0 with
htod_bytes_per_token 1,234,271,232 at N=2 and 547,528,704 at N=4,
byte_hit_rate 68.64% -> 72.17%, solo with managed streaming forced. That
closes #891's flagged risk positively.

And records the near-miss, because it is the part most worth carrying
into
2c: #930's first version reported capture and streaming as mutually
exclusive, from a real decline naming a real predicate, because its
harness
called an entry point that passed cuda_offload_policy: None and
stable_va
fell to a conservative default. The harness asserted
pointer-instability;
the runtime was never asked.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native backend cannot batch: continuous batching is ORT-only, so --max-batch is silently ignored and every request decodes alone

2 participants