Skip to content

feat(native-decode): make the device argmax / greedy token selection batch-aware (2b-impl-3, #750) - #924

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

justinchuby merged 2 commits into
mainfrom
squad/batch-2b-impl-3-argmax

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

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.

…batch-aware (2b-impl-3, #750)

Select `batch` greedy tokens from a `[batch, 1, vocab]` logits tensor instead
of one token from `[1, 1, vocab]`, threading a `batch` count through the whole
device-argmax stack: the CUDA kernel launch, the `ExecutionProvider` trait, the
`onnx-runtime-ep-cuda` provider, `DeviceIoBinding::device_argmax`, the result
buffer allocation, and `read_greedy_result`.

`batch` stays constructor-fixed to 1; this delivers the capability, not batch-N
on (that is 2b-impl-4). Batch-1 is byte-identical by construction: the grid
stays 1-deep (gridDim.y == 1 / gridDim.x == 1), the result header stays 2 words,
and the scratch layout is unchanged, so the launch geometry and the 8-byte
read-back are bit-for-bit the prior single-sequence path. The reduction operator
is additionally order-invariant (max value / min index on ties), so tie-breaks
cannot drift with the grid shape.

New device test `device_argmax_batch_selects_per_row_max_with_deliberate_ties`
puts the max at a different position per row, forces multi-partial reductions,
and plants ties at an earlier index than the peak, asserting each batched row
equals a standalone single-sequence argmax — the control that a shared reduction
buffer or a wrong per-sequence stride would fail.

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
… capability negotiation (#926)

Two corrections, the first prompted by a simple observation: **Linux has
no WDDM.**

## The #912 closure was written as though it settled the hybrid
everywhere

It settles it on **one consumer GPU under WDDM**. On Linux neither half
of the argument survives:

- **The comparison arm does not exist.** There is no OS shared-memory
fallback, so an over-budget model that does not stream fails outright.
The hybrid competes with managed streaming (0.11–0.86 tok/s on this box)
or with *not running* — not with WDDM's 7.84 tok/s. A result that loses
badly on Windows can be the best available option on Linux.
- **The ceiling that closed it is plausibly VidMm's, not the
hardware's.** #863 already measured WDDM demoting our own
`cuMemCreate`/`cuMemMap` granules invisibly; a host registration being
silently remapped is the same family of behaviour. On Linux
`cuMemHostRegister` pins pages in the driver with nothing above it.

That second point is a **hypothesis**, so it is filed as #925 to be
measured rather than asserted in either direction. This is #783's lesson
turned on our own result.

Worth noting: **the code was already right.**
`shared_memory_weight_fallback` is `cfg!(windows)`, Linux still
auto-enables managed streaming, unit-locked by
`managed_default_no_flag_over_budget_model_auto_streams`. This is
documentation catching up to implementation, which is the safer
direction to be wrong in.

The decision table now splits the single "defer to the platform" row
**into two**, conditioned on whether a demand-paging path exists at all
— because those two rows are the same workload on two operating systems
with opposite correct answers.

## That split motivates the second change

The runtime chooses between those rows using `cfg!(windows)`: a
compile-time proxy for "is this WDDM" that is wrong for TCC-mode Windows
and silent about any third EP. Serviceable as an interim, indefensible
as a design.

New section: **Platform and EP capability negotiation.** Every row
traced to the measurement that established the need:

| Reported fact | Evidence | Status today |
|---|---|---|
| Oversubscription behaviour (demand-page / fail / unified) | #864/#874
vs #783 | `cfg!(windows)` |
| Ledger truthfulness | #863 | absent; assumed true |
| Aggregate host-mapped read capacity | #912; unmeasured on Linux (#925)
| absent; assumed unbounded, which corrupted output |
| VMM capability and granule | 2 MiB CUDA granule | known to the
allocator, not reported |
| Resident vs host bandwidth for the access pattern | #877 → #880, a 2×
error in the sizing input | absent |
| KV layout as a stride descriptor | #783 | a runtime-side enum |
| Reclaim capability | C5 | absent |

The point that organises it: **these are quantities, not flags.** A
boolean "supports host mapping" would not have prevented #912, whose
entire finding was about *how much*. Today's channel is
`ExecutionProviderCapabilities` — an opaque string set carrying exactly
one entry, `"nxrt"`.

## Do we need new EP API? Three different answers

- **Five reporting facts** want **one** versioned,
`struct_size`-prefixed record rather than seven entry points, negotiated
the way the plugin ABI already negotiates major/minor. A later
capability then costs a field, not an ABI break.
- **KV layout is a negotiation, not a report** — propose/accept/counter
over a stride descriptor (#783) — because the runtime must still bind
exactly one physical form per binding, so an EP that merely announces a
preference leaves disagreements unresolvable.
- **Reclaim is behavioural** — a `reclaim(target_bytes) ->
released_bytes` callback, so C5 pressure can reach an EP-side holder,
with the holder choosing victims (I6) and free to release zero.

Plus four rules that keep this from fragmenting, of which the
load-bearing one is: **an unreported capability degrades to its most
conservative reading, never its most convenient one.** Unknown
host-mapped capacity is zero, not unbounded. #912 is the cautionary case
— the optimistic default produced silently wrong tokens rather than an
error.

## A citation of my own, corrected

Three documents referenced `#924` as the Linux issue **before I had
created it**, and the number was taken by an unrelated PR in the
meantime. Filed for real as #925 and the references repointed. Citing a
number before verifying it exists is exactly what the
measurement-discipline skill tells everyone else not to do.

Docs only.

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

Copy link
Copy Markdown
Owner Author

Validated independently before merging.

The tie-breaking claim is the one that mattered here, and I verified it rather than accepting it. argmax_update is candidate > best || (candidate == best && candidate_index < best_index) — strict max with min-index tie-break, which is a total order on (value descending, index ascending). A reduction over a total order is order-invariant, so the result cannot depend on the tree shape, and reshaping the grid genuinely cannot move a tie. That plus the batch-1 launch being bit-identical (gridDim.y == 1, sequence offset 0) makes the argument airtight in both directions, which is more than I asked for.

The new device test is the right control: per-row maxima at different positions plus deliberate earlier-index ties, asserting each batched row equals a standalone single-sequence argmax. A wrong sequence stride or a shared scratch region shows up there and cannot show up at batch 1.

Gates, all re-run by me:

  • fmt clean across the four touched packages
  • exact CI CUDA clippy gate clean
  • native_decode 96/96; device_argmax 3/3
  • mobius parity solo: 2 passed (correct --ignored invocation — I checked the count, not just the exit code)
  • byte-identity solo, nvidia-smi verified empty first: token IDs exactly the reference, captures=2 fallbacks=0, all vmm underflows and unaccounted_committed_bytes 0, oversubscribed=0. 7.71 tok/s, inside this box's 3.9–28 band and claimed as nothing.

On the scoping observation. Reporting that write_decode_inputs is still single-token, and that writing N selected tokens is inseparable from the batch-N caller the doc itself defers to 2b-impl-4, is the right call — and leaving it alone was the right action. Selecting N tokens and having somewhere to put them are separable; selecting them and wiring the next step's input are not. That belongs with the increment that turns batch-N on.

Merging. 2b-impl-4 is the last one, and the only one that changes behaviour: pin the batch symbol, flip batch-N on, and report measured recapture-per-shape plus the #866 hit_rate offset.

@justinchuby
justinchuby merged commit 9d80234 into main Aug 14, 2026
@justinchuby
justinchuby deleted the squad/batch-2b-impl-3-argmax branch August 14, 2026 05:06
@codecov

codecov Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.43%. Comparing base (09d83f7) to head (6209fdc).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
crates/onnx-runtime-session/src/tensor.rs 0.00% 7 Missing ⚠️
crates/onnx-runtime-ep-api/src/provider.rs 0.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #924      +/-   ##
==========================================
+ Coverage   78.12%   78.43%   +0.30%     
==========================================
  Files         353      354       +1     
  Lines      143317   148049    +4732     
  Branches   143317   148049    +4732     
==========================================
+ Hits       111966   116116    +4150     
- Misses      26908    27436     +528     
- Partials     4443     4497      +54     
Flag Coverage Δ
cli-ort-linux 83.42% <ø> (ø)
cli-ort-windows 82.92% <ø> (?)
mlas 81.33% <ø> (ø)
offline 78.19% <0.00%> (+0.32%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
crates/onnx-runtime-ep-api/src/provider.rs 64.63% <0.00%> (-0.13%) ⬇️
crates/onnx-runtime-session/src/tensor.rs 67.58% <0.00%> (-0.45%) ⬇️

... and 23 files with indirect coverage changes

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

@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/medium_generic_bf16_threads=8/32x512x512 365.86 µs 893.40 µs +144.2%
🔴 matmul/small_generic_bf16_threads=8/1x256x256 29.51 µs 62.44 µs +111.6%
🔴 matmul/small_generic_f32_threads=8/1x256x256 31.88 µs 62.93 µs +97.4%
🔴 matmul/large_generic_f16_threads=8/32x1024x1024 78.64 µs 139.73 µs +77.7%
🔴 gather/large_bf16_threads=1-internal/131072 10.23 µs 17.16 µs +67.7%
🔴 matmul/large_generic_f16_threads=1/32x1024x1024 73.21 µs 120.61 µs +64.8%
🔴 matmul/small_generic_bf16_threads=1/1x256x256 28.80 µs 46.81 µs +62.5%
🔴 matmul/small_generic_f16_threads=8/1x256x256 27.92 µs 43.67 µs +56.4%
🔴 matmul/large_generic_f32_threads=8/32x1024x1024 3.56 ms 5.53 ms +55.0%
🔴 matmul/medium_generic_f32_threads=8/32x512x512 891.01 µs 1.30 ms +45.4%
🔴 matmul/large_generic_f32_threads=1/32x1024x1024 8.69 ms 12.62 ms +45.2%
🔴 matmul/small_generic_f32_threads=1/1x256x256 33.99 µs 49.20 µs +44.8%
🔴 matmul/medium_generic_f16_threads=8/32x512x512 27.78 µs 38.64 µs +39.1%
🔴 gather/large_f16_threads=1-internal/131072 10.86 µs 14.72 µs +35.6%
⚠️ matmul/medium_generic_f32_threads=1/32x512x512 2.21 ms 2.85 ms +28.8%
⚠️ matmul/small_generic_f16_threads=1/1x256x256 27.89 µs 34.61 µs +24.1%
⚠️ matmul/medium_generic_f16_threads=1/32x512x512 27.93 µs 34.56 µs +23.8%
⚠️ gather/large_f32_threads=1-internal/131072 21.95 µs 26.91 µs +22.6%
⚠️ matmul/large_generic_bf16_threads=1/32x1024x1024 1.82 ms 2.14 ms +17.9%
✅ add/small_bf16_threads=1-internal/1024 11.95 µs 13.29 µs +11.2%
✅ add/small_f16_threads=1-internal/1024 11.96 µs 13.10 µs +9.5%
✅ gather/medium_f32_threads=1-internal/32768 3.50 µs 3.83 µs +9.3%
✅ add/medium_f32_threads=1-internal/262144 2.37 ms 2.59 ms +9.2%
✅ add/large_bf16_threads=1-internal/4194304 38.98 ms 42.03 ms +7.8%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 485.72 µs 520.82 µs +7.2%
✅ gather/medium_f16_threads=1-internal/32768 2.25 µs 2.33 µs +3.6%
✅ add/medium_bf16_threads=1-internal/262144 2.46 ms 2.52 ms +2.5%
✅ qwen3_sampling_processors/top_k_partial_selection 149.31 µs 152.85 µs +2.4%
✅ gather/small_bf16_threads=1-internal/4096 440.5 ns 448.7 ns +1.9%
✅ gather/medium_bf16_threads=1-internal/32768 2.21 µs 2.24 µs +1.3%
✅ add/medium_f16_threads=1-internal/262144 2.41 ms 2.44 ms +1.3%
✅ reduce_mean/large_f32_threads=1-internal/262144 924.30 µs 930.18 µs +0.6%
✅ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 84.57 µs 84.75 µs +0.2%
✅ tokenization/decode_tokens_per_second 6.60 ms 6.58 ms -0.3%
✅ sampling_latency/greedy_per_token 3.28 µs 3.26 µs -0.5%
✅ add/large_f16_threads=1-internal/4194304 38.77 ms 38.18 ms -1.5%
✅ tokenization/encode_tokens_per_second 409.70 µs 403.10 µs -1.6%
✅ sampling_latency/top_k_per_token 55.94 µs 54.75 µs -2.1%
✅ qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.77 ms 3.65 ms -3.1%
✅ gather/small_f16_threads=1-internal/4096 457.7 ns 442.7 ns -3.3%
✅ block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 426.17 µs 409.47 µs -3.9%
✅ reduce_mean/medium_f32_threads=1-internal/65536 241.70 µs 230.60 µs -4.6%
✅ add/small_f32_threads=1-internal/1024 207.3 ns 197.7 ns -4.6%
✅ gather/small_f32_threads=1-internal/4096 673.2 ns 634.1 ns -5.8%
✅ kv_cache/alloc_dealloc_pages 39.25 µs 36.72 µs -6.5%
✅ grammar_masking/llguidance_compute_mask/32 76.33 µs 70.45 µs -7.7%
✅ reduce_mean/small_f32_threads=1-internal/4096 15.97 µs 13.97 µs -12.6%
✅ sampling_latency/top_p_per_token 409.81 µs 353.77 µs -13.7%
✅ add/large_f32_threads=1-internal/4194304 43.54 ms 37.56 ms -13.7%
🟢 qwen3_sampling_processors/top_p_fast_after_top_k 588.50 µs 487.04 µs -17.2%
🟢 logit_processing/seven_processor_chain_per_step 366.08 µs 298.73 µs -18.4%
🟢 qwen3_sampling_processors/top_k_top_p_full_sort_baseline 6.98 ms 5.54 ms -20.7%
🟢 sampling_latency/min_p_per_token 247.62 µs 191.64 µs -22.6%
🟢 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 703.98 µs 542.57 µs -22.9%
🟢 qwen3_sampling_processors/top_k_top_p_fast 810.56 µs 622.96 µs -23.1%
🟢 qwen3_sampling_processors/top_k_full_sort_baseline 2.78 ms 2.05 ms -26.2%
🟢 matmul/large_generic_bf16_threads=8/32x1024x1024 2.10 ms 1.48 ms -29.7%
🟢 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 69.55 µs 48.19 µs -30.7%
🟢 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 93.46 µs 64.05 µs -31.5%

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: { 3.53 4.24 5.03 }
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
…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.

2 participants