Skip to content

feat(native-decode): make KV growth and commit geometry batch-aware, refuse seq-major relocation (2b-impl-2, #750) - #920

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

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

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

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.

Copilot AI added 2 commits August 13, 2026 21:01
…refuse seq-major relocation (2b-impl-2, #750)

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

Batch-general after this increment (no relocation):
- 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.
- Head-major (BNSH) growing-bucket KV: the flat 0..bucket range and the
  copy/zero block loop are already batch-general (batch is outermost, block
  count = batch*kv_heads); made explicit + unit-tested at batch=3.
- Seq-major *fixed full-context stride* commit: live_prefix_ranges,
  seq_major_kv_commit_requests and zero_seq_major_committed_tail now emit one
  dense run per sequence, each a fixed capacity*bytes_per_token apart. This is
  relocation-free (the per-sequence stride is the pinned full context), so it is
  batch-general with CPU unit tests at batch=3.

Refused (named, tested):
- Seq-major *growing-bucket / realloc* growth at batch>1: kv_growth_byte_layout
  now bails with a named error, because a growing bucket's per-sequence stride
  is the mutable bucket capacity, so growth would relocate every sequence b>0's
  KV. Head-major has no such problem (batch outermost). This refusal is the
  correct deliverable rather than a silently wrong capacity-dependent stride.

Scoping corrections (file/line evidence in the PR):
- live_prefix_ranges lives in crates/onnx-genai-engine (native_decode/kv_commit.rs),
  NOT "another crate" as SCOPING doc section 3 item 6 states.
- The doc's "KV growth stride math is already batch-general" is true only for
  head-major; seq-major growing-bucket at batch>1 relocates and is refused here.

Validation (all local, solo where GPU):
- cargo fmt -p onnx-genai-engine -- --check
- cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings
- 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)
- MANDATORY mobius_seqmajor_growth_parity_native_cuda solo: 2/2 passed, seq-major
  kept graph across 3 growth events, kv_bytes_moved=0, growth_keeps=3, 48-token
  stream byte-identical to head-major (VMM in-place capture ON/OFF, realloc).
- Hardware byte-identity solo (qwen14b-zp, 16 tokens, nvidia-smi verified empty):
  token IDs EXACT match reference; captures=2 fallbacks=0 invalidations=1;
  cuda_graph_measured captures=1; vmm_arena ref/byte underflows=0 unaccounted=0;
  oversubscribed=0. Throughput 7.32 tok/s within the 3.9-28 band (not a finding).

Does not close #750 (two increments remain).

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, and I caught one thing worth recording about my own process.

The mobius gate first came back vacuously green for me. My invocation filtered the test out (0 passed; 0 failed; 1 filtered out) and still exited 0 — the test is #[ignore]d and needs --ignored, and the binary name is not the test name. That is failure mode 8 from the measurement-discipline skill (a precondition observed rather than established), committed by the person who wrote the skill. Re-run properly: 2 passed, seq-major byte-identical to head-major across 3 forced growths, kv_bytes_moved=688576 identical on both arms, capture ON and OFF.

Hardware 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. 12.20 tok/s against 7.84 measured on main earlier today — I am not claiming that as a win; it sits inside this box's known 3.9–28 band and this change cannot make decode faster.

Gates: fmt clean, exact CI CUDA clippy gate clean, 96/96 native_decode tests, origin/main merges clean.

On the substance. The distinction this PR draws is the valuable part, and it was missing from my scoping: a fixed full-context stride commit is batch-general in both layouts, because the batch axis is outermost and each sequence sits a constant capacity × bytes_per_token apart — while a growing bucket is batch-general only in head-major, because seq-major's per-sequence stride is the mutable capacity, so growth relocates every sequence b > 0. Those are two different code paths that I had been treating as one, which is why my scoping said "KV growth stride math already batch-general" without qualification. That holds for head-major alone.

The refusal is the right call and is written the way a refusal should be: it names the constraint, explains why head-major escapes it, and points the reader at the path that is relocation-free rather than just failing. A silently capacity-dependent stride would have passed every batch-1 test we have and corrupted the first batch-N run.

Also accepted: live_prefix_ranges lives in this crate, not another one, as §3/§4 of the scoping doc claimed. That is the fourth accepted scoping correction on this line (#844, #891, #905, now this) — the pattern is worth more than the individual fixes.

Merging. 2b-impl-3 (device argmax) next.

@justinchuby
justinchuby merged commit 71a69f8 into main Aug 14, 2026
@justinchuby
justinchuby deleted the squad/batch-2b-impl-2-kvgrowth branch August 14, 2026 04:24
@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 77.79%. Comparing base (69cc561) to head (bbb3ddb).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #920      +/-   ##
==========================================
- Coverage   78.03%   77.79%   -0.25%     
==========================================
  Files         353      354       +1     
  Lines      143320   148042    +4722     
  Branches   143320   148042    +4722     
==========================================
+ Hits       111842   115162    +3320     
- Misses      27036    28387    +1351     
- Partials     4442     4493      +51     
Flag Coverage Δ
cli-ort-linux 83.42% <ø> (ø)
cli-ort-windows 83.01% <ø> (+0.09%) ⬆️
mlas 80.87% <ø> (-0.42%) ⬇️
offline 77.53% <ø> (-0.25%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 25 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
🔴 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 707.43 µs 1.19 ms +67.6%
🔴 block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 384.30 µs 639.13 µs +66.3%
🔴 matmul/large_generic_bf16_threads=8/32x1024x1024 1.51 ms 2.32 ms +53.8%
🔴 matmul/medium_generic_bf16_threads=8/32x512x512 442.78 µs 678.77 µs +53.3%
🔴 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 90.43 µs 130.62 µs +44.4%
🔴 matmul/small_generic_f32_threads=1/1x256x256 42.47 µs 60.69 µs +42.9%
🔴 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 67.25 µs 94.26 µs +40.2%
🔴 kv_cache/alloc_dealloc_pages 36.21 µs 49.49 µs +36.7%
⚠️ matmul/medium_generic_f32_threads=8/32x512x512 1.58 ms 2.02 ms +27.6%
⚠️ tokenization/decode_tokens_per_second 5.62 ms 7.03 ms +25.0%
⚠️ qwen3_sampling_processors/top_k_partial_selection 134.19 µs 166.62 µs +24.2%
⚠️ qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.23 ms 4.00 ms +24.0%
⚠️ gather/small_bf16_threads=1-internal/4096 536.5 ns 662.4 ns +23.5%
⚠️ gather/medium_f32_threads=1-internal/32768 3.80 µs 4.66 µs +22.6%
⚠️ matmul/large_generic_f32_threads=8/32x1024x1024 6.48 ms 7.94 ms +22.6%
⚠️ qwen3_sampling_processors/top_k_full_sort_baseline 1.98 ms 2.41 ms +22.1%
⚠️ gather/small_f16_threads=1-internal/4096 468.5 ns 570.9 ns +21.9%
⚠️ matmul/small_generic_bf16_threads=8/1x256x256 34.73 µs 41.13 µs +18.4%
⚠️ matmul/small_generic_f16_threads=8/1x256x256 32.98 µs 38.81 µs +17.7%
⚠️ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 5.49 ms 6.41 ms +16.6%
⚠️ gather/large_bf16_threads=1-internal/131072 16.09 µs 18.65 µs +15.9%
✅ qwen3_sampling_processors/top_k_top_p_fast 620.12 µs 708.92 µs +14.3%
✅ gather/small_f32_threads=1-internal/4096 650.1 ns 738.3 ns +13.6%
✅ qwen3_sampling_processors/top_p_fast_after_top_k 487.16 µs 542.13 µs +11.3%
✅ gather/large_f16_threads=1-internal/131072 17.98 µs 19.98 µs +11.2%
✅ sampling_latency/min_p_per_token 198.38 µs 218.44 µs +10.1%
✅ tokenization/encode_tokens_per_second 349.30 µs 382.85 µs +9.6%
✅ matmul/small_generic_bf16_threads=1/1x256x256 32.84 µs 35.98 µs +9.6%
✅ matmul/small_generic_f32_threads=8/1x256x256 64.08 µs 69.87 µs +9.0%
✅ add/small_f16_threads=1-internal/1024 13.48 µs 14.44 µs +7.1%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.22 ms 2.38 ms +6.9%
✅ add/small_bf16_threads=1-internal/1024 12.49 µs 13.27 µs +6.2%
✅ sampling_latency/top_p_per_token 368.47 µs 390.70 µs +6.0%
✅ add/medium_bf16_threads=1-internal/262144 2.56 ms 2.69 ms +5.0%
✅ logit_processing/seven_processor_chain_per_step 331.39 µs 347.10 µs +4.7%
✅ add/medium_f16_threads=1-internal/262144 2.54 ms 2.65 ms +4.3%
✅ add/large_f32_threads=1-internal/4194304 39.51 ms 40.92 ms +3.6%
✅ sampling_latency/greedy_per_token 3.27 µs 3.35 µs +2.6%
✅ add/medium_f32_threads=1-internal/262144 2.56 ms 2.61 ms +1.8%
✅ reduce_mean/large_f32_threads=1-internal/262144 1.03 ms 1.05 ms +1.2%
✅ matmul/large_generic_f32_threads=1/32x1024x1024 10.01 ms 9.99 ms -0.2%
✅ matmul/small_generic_f16_threads=1/1x256x256 37.88 µs 37.66 µs -0.6%
✅ reduce_mean/small_f32_threads=1-internal/4096 15.10 µs 14.98 µs -0.8%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 561.75 µs 555.71 µs -1.1%
✅ reduce_mean/medium_f32_threads=1-internal/65536 252.80 µs 246.51 µs -2.5%
✅ matmul/medium_generic_f16_threads=8/32x512x512 44.51 µs 42.88 µs -3.6%
✅ gather/medium_f16_threads=1-internal/32768 2.81 µs 2.66 µs -5.3%
✅ add/small_f32_threads=1-internal/1024 219.3 ns 206.1 ns -6.0%
✅ sampling_latency/top_k_per_token 57.50 µs 53.40 µs -7.1%
✅ matmul/large_generic_f16_threads=1/32x1024x1024 99.35 µs 91.70 µs -7.7%
✅ gather/large_f32_threads=1-internal/131072 53.77 µs 48.15 µs -10.4%
✅ grammar_masking/llguidance_compute_mask/32 92.81 µs 82.85 µs -10.7%
✅ add/large_f16_threads=1-internal/4194304 48.27 ms 42.86 ms -11.2%
✅ add/large_bf16_threads=1-internal/4194304 51.24 ms 44.32 ms -13.5%
🟢 block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 91.74 µs 77.98 µs -15.0%
🟢 matmul/large_generic_bf16_threads=1/32x1024x1024 2.39 ms 2.00 ms -16.3%
🟢 gather/medium_bf16_threads=1-internal/32768 3.50 µs 2.65 µs -24.5%
🟢 matmul/large_generic_f16_threads=8/32x1024x1024 141.24 µs 100.00 µs -29.2%
🟢 matmul/medium_generic_f16_threads=1/32x512x512 60.27 µs 41.89 µs -30.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: { 5.31 3.75 5.08 }
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.

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

2 participants