Skip to content

fix(native-cuda): re-zero recurrent/conv state on session reuse (LinearAttention corruption) - #554

Merged
justinchuby merged 1 commit into
mainfrom
squad/native-session-reuse-reset
Jul 31, 2026
Merged

justinchuby merged 1 commit into
mainfrom
squad/native-session-reuse-reset

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

Summary

Native CUDA decode corrupted generation #2+ within a reused NativeDecodeSession on hybrid LinearAttention models (dense conv_state/recurrent_state). The first generate() was correct; subsequent generations on the same session returned non-deterministic degenerate garbage. Discovered during the 27B weight-offload A/B (see #384).

Repro (before): profile_native --runs 2 on the 27B →
native greedy decode was not deterministic: first=[11751,...] rerun=[279,6511,...].

Scope (Step-0 blast-radius test — decided early)

LinearAttention-only, NOT a general session-reuse bug.

  • Plain GQA transformer (KV-only, no recurrent state), qwen2.5-0.5b-instruct-cuda, --runs 2: clean / deterministic.
  • 27B hybrid LinearAttention: corrupts gen#2+.

So the general KV / position / decode-step reset path is fine for all multi-turn/server/REPL usage. Only models carrying fixed-size recurrent/conv state were affected.

Root cause

DecodeCudaState zero-initializes the fixed recurrent/conv-state device bindings once, in new(). reset() → rewind(0) (start of every generate()) only re-zeroed the attention-mask binding and reset logical_len. Growable KV is masked + length-tracked (stale slots inert); fixed recurrent state is an unmasked rolling cache, so gen#2 inherited gen#1's terminal recurrent state → garbage. Reproduces with CUDA-graph capture ON and with ONNX_GENAI_CUDA_GRAPH=0 (eager) → a state-reset bug, not a capture bug.

Fix (general — no model-specific special-casing)

  • Track the fixed-state bindings as fixed_state_binding_range on DecodeCudaState (empty for pure-KV models).
  • In rewind(), when target_len == 0, re-zero those bindings (same zero-init the constructor applies; state_pairs declare init: zeros). Only at the reset boundary — speculative recurrent rewind to a non-zero length is intentionally unsupported, mirroring the CPU path.

Pure-KV decoders have an empty range and are entirely unaffected (GQA path verified unchanged). CPU native was checked and is clean (its rewind(0) clears recurrent state via the past map), so the regression test is CUDA-gated.

Regression test (non-vacuous)

native_cuda_reused_session_rezeros_recurrent_state (#[cfg(feature="cuda")], gated by ONNX_GENAI_RUN_CUDA_SMOKE=1): a synthetic recurrent decoder whose logits are a direct function of the incoming conv_state, decoded twice across a reset(), asserting gen#1 == gen#2 (and that the per-step logits grow, proving state feeds logits).

Non-vacuity proof (re-zero disabled): gen#1 [0,60,144] != gen#2 [252,312,396] → FAIL. With the fix → PASS.

The existing profile_native --runs 2 determinism check is the end-to-end CI guard on real models.

End-to-end verification (greedy, "The capital of France is", --runs 2)

model capture gen#1==gen#2
27B int4 LinearAttention ON (captures=2) ✅ " Paris.\n\n<think>\n\n</think>\n\nThat is correct. Paris is the capital and"
27B int4 LinearAttention OFF (ONNX_GENAI_CUDA_GRAPH=0) ✅ identical
qwen2.5-0.5b GQA (regression) ON ✅ unchanged

Guardrails

Refs #384. Possibly related to the repeated/degenerate-sentence report on multi-turn native decode.

⚠️ This will get independent review before merge.

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

Native CUDA decode corrupted generation #2+ within a reused
NativeDecodeSession on hybrid LinearAttention models (dense conv_state /
recurrent_state). First generate() was correct; subsequent generations on
the same session returned non-deterministic degenerate output.

Scope (Step-0 blast-radius test): LinearAttention-only. A plain GQA
transformer (KV-only) is clean under `profile_native --runs 2`; only models
carrying fixed-size recurrent/conv state corrupt. So the general
KV/position/decode-step reset path is fine.

Root cause: DecodeCudaState zero-initializes the fixed recurrent/conv-state
device bindings once in new(), but reset() -> rewind(0) only re-zeroed the
attention-mask binding. Growable KV is masked and length-tracked (stale slots
inert); fixed recurrent state is an unmasked rolling cache, so gen#2 inherited
gen#1's terminal state -> garbage. Reproduces with capture ON and with
ONNX_GENAI_CUDA_GRAPH=0, so it is a state-reset bug, not a capture bug.

Fix (general, no model-specific casing): track the fixed-state bindings as
`fixed_state_binding_range` and re-zero them in rewind() at target_len == 0
(the reset boundary), matching the constructor's zero-init and the state_pairs'
declared `init: zeros`. Pure-KV decoders have an empty range and are unaffected.

Regression test (non-vacuous, cuda-gated behind ONNX_GENAI_RUN_CUDA_SMOKE):
a synthetic recurrent decoder whose logits are a direct function of the
incoming conv_state, decoded twice across a reset; asserts gen#1 == gen#2.
Without the fix it fails gen#1 [0,60,144] != gen#2 [252,312,396]; with the fix
it passes. Verified end-to-end: 27B int4 LinearAttention now deterministic
(capture ON and OFF), GQA unchanged.

Refs #384.

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

codecov Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #554      +/-   ##
==========================================
+ Coverage   80.59%   81.16%   +0.57%     
==========================================
  Files         315      315              
  Lines      123446   123446              
  Branches   123446   123446              
==========================================
+ Hits        99489   100199     +710     
+ Misses      19907    19191     -716     
- Partials     4050     4056       +6     
Flag Coverage Δ
cli-ort-linux 83.27% <ø> (ø)
cli-ort-windows 82.67% <ø> (-0.11%) ⬇️
mlas 77.91% <ø> (ø)
offline 81.12% <ø> (+0.60%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 5 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 Change 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
⚠️ sampling_latency/top_p_per_token 905.80 µs 1.08 ms +19.1%
⚠️ grammar_masking/llguidance_compute_mask/32 68.61 µs 79.54 µs +15.9%
✅ logit_processing/seven_processor_chain_per_step 1.10 ms 1.25 ms +13.8%
✅ sampling_latency/top_k_per_token 438.38 µs 494.72 µs +12.9%
✅ gather/large_f32_threads=1-internal/131072 23.20 µs 25.99 µs +12.0%
✅ sampling_latency/min_p_per_token 320.19 µs 356.79 µs +11.4%
✅ kv_cache/alloc_dealloc_pages 35.07 µs 38.94 µs +11.0%
✅ matmul/medium_generic_f16_threads=1/32x512x512 27.83 µs 29.09 µs +4.5%
✅ matmul/large_generic_bf16_threads=8/32x1024x1024 1.24 ms 1.28 ms +3.2%
✅ add/large_f32_threads=1-internal/4194304 37.60 ms 38.44 ms +2.2%
✅ matmul/large_generic_f32_threads=1/32x1024x1024 8.56 ms 8.65 ms +1.1%
✅ gather/medium_f32_threads=1-internal/32768 3.40 µs 3.43 µs +1.0%
✅ matmul/medium_generic_f16_threads=8/32x512x512 27.71 µs 27.89 µs +0.6%
✅ matmul/small_generic_f32_threads=1/1x256x256 33.72 µs 33.93 µs +0.6%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.14 ms 2.16 ms +0.6%
✅ matmul/large_generic_bf16_threads=1/32x1024x1024 1.81 ms 1.82 ms +0.6%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 485.07 µs 487.54 µs +0.5%
✅ matmul/small_generic_f32_threads=8/1x256x256 32.06 µs 32.17 µs +0.3%
✅ add/medium_f16_threads=1-internal/262144 2.41 ms 2.42 ms +0.2%
✅ matmul/medium_generic_f32_threads=8/32x512x512 885.93 µs 887.57 µs +0.2%
✅ gather/small_f32_threads=1-internal/4096 591.3 ns 591.9 ns +0.1%
✅ matmul/large_generic_f16_threads=1/32x1024x1024 72.86 µs 72.90 µs +0.1%
✅ matmul/medium_generic_bf16_threads=8/32x512x512 358.09 µs 358.20 µs +0.0%
✅ matmul/small_generic_bf16_threads=8/1x256x256 29.72 µs 29.71 µs -0.0%
✅ add/medium_f32_threads=1-internal/262144 2.35 ms 2.34 ms -0.1%
✅ matmul/large_generic_f16_threads=8/32x1024x1024 77.93 µs 77.87 µs -0.1%
✅ matmul/small_generic_f16_threads=1/1x256x256 27.71 µs 27.69 µs -0.1%
✅ tokenization/decode_tokens_per_second 5.73 ms 5.73 ms -0.1%
✅ add/small_f16_threads=1-internal/1024 12.08 µs 12.05 µs -0.2%
✅ add/medium_bf16_threads=1-internal/262144 2.43 ms 2.42 ms -0.3%
✅ add/small_bf16_threads=1-internal/1024 11.89 µs 11.86 µs -0.3%
✅ gather/small_f16_threads=1-internal/4096 436.8 ns 435.2 ns -0.4%
✅ reduce_mean/small_f32_threads=1-internal/4096 14.11 µs 14.06 µs -0.4%
✅ sampling_latency/greedy_per_token 3.03 µs 3.01 µs -0.5%
✅ add/large_bf16_threads=1-internal/4194304 39.01 ms 38.80 ms -0.5%
✅ matmul/small_generic_bf16_threads=1/1x256x256 29.14 µs 28.91 µs -0.8%
✅ gather/medium_f16_threads=1-internal/32768 2.16 µs 2.14 µs -0.9%
✅ matmul/large_generic_f32_threads=8/32x1024x1024 3.60 ms 3.56 ms -1.2%
✅ gather/small_bf16_threads=1-internal/4096 439.3 ns 433.7 ns -1.3%
✅ add/small_f32_threads=1-internal/1024 183.8 ns 180.7 ns -1.7%
✅ reduce_mean/large_f32_threads=1-internal/262144 924.98 µs 908.36 µs -1.8%
✅ gather/large_bf16_threads=1-internal/131072 9.55 µs 9.37 µs -1.8%
✅ matmul/small_generic_f16_threads=8/1x256x256 28.94 µs 28.38 µs -1.9%
✅ tokenization/encode_tokens_per_second 355.72 µs 348.63 µs -2.0%
✅ add/large_f16_threads=1-internal/4194304 39.14 ms 38.34 ms -2.0%
✅ reduce_mean/medium_f32_threads=1-internal/65536 236.17 µs 227.80 µs -3.5%
✅ gather/medium_bf16_threads=1-internal/32768 2.26 µs 2.13 µs -5.4%
✅ gather/large_f16_threads=1-internal/131072 10.48 µs 9.34 µs -10.9%

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.4.0 arm64
Rust: rustc 1.97.1 (8bab26f4f 2026-07-14)
Load avg: { 5.56 4.10 5.05 }
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

Copy link
Copy Markdown
Owner Author

VERDICT: APPROVE

Independent review by Harry (I did NOT author this; Mary did). Every claim in the PR description was re-verified from scratch in a scratch worktree (wt-harry-554) on an H200, device 0. All of Mary's claims hold.

Code review (fix commit aab80e4 — only 3 files: cuda.rs, tests.rs, decision note)

General, no special-casing (VERIFIED). The fix adds fixed_state_binding_range: Range to DecodeCudaState. In new(), bindings are laid out [0]=mask, then KV (kv_start..kv_end), then fixed recurrent/conv state (kv_end..fixed_state_end) — the sort_by_key(contains(fixed_state_inputs)) puts fixed-state pairs last, so the range covers exactly the conv_state/recurrent_state bindings and nothing else (not the mask, not KV). No model names, no architecture switches (cuda.rs:1116, 1412). For pure-KV decoders fixed_state_inputs is empty so kv_end == fixed_state_end -> empty range -> the re-zero loop is a no-op. Common path provably unchanged.

Right time (VERIFIED). rewind() re-zeros the range only at target_len == 0 (cuda.rs:1496-1514). reset() -> rewind(0) is called at the start of every generate_with_callback() (mod.rs:356). Mid-generation never calls rewind(0); non-zero rewind (incremental/speculative) intentionally leaves recurrent state intact, mirroring the CPU path (backend.rs:86-105: past.clear() at 0, recurrent left intact otherwise). The re-zero uses the same native_cuda_memset_zero + checked_shape_bytes(physical_shape, dtype) as the constructor's zero-init (cuda.rs:1106-1113) — symmetric. First-generation short-circuit (backend.rs:58, current_len==0) is safe because the constructor already zero-inits.

Run evidence (all commands via profile_native, which reuses ONE session across runs and bail!s on any cross-run mismatch — profile_native.rs:744-757)

27B int4 hybrid LinearAttention (overlay /home/justinchu/mary-model-overlays/qwen36-ortref, conv_state+recurrent_state state_pairs, 64 layers), prompt "The capital of France is", --tokens 16 --warmups 0 --runs 2:

  • capture ON (captures=2 replays=26): DETERMINISTIC -> " Paris.\n\n\n\n\n\nThat is correct. Paris is the capital and"
  • capture OFF (ONNX_GENAI_CUDA_GRAPH=0, captures=0): DETERMINISTIC -> identical output.
  • BEFORE (re-zero disabled, rebuilt): FAILS -> "native greedy decode was not deterministic: first=[11751, ...], rerun=[279, 6511, 314, 9338, 369, 279, 6511, 314, 9338, ...]" — a degenerate repeating loop (the reported symptom). Matches Mary's claimed rerun=[279,6511,...] exactly.

So it reproduces both capture ON and OFF -> state-reset bug, not a capture bug. Confirmed.

Regression test native_cuda_reused_session_rezeros_recurrent_state (ONNX_GENAI_RUN_CUDA_SMOKE=1, feature cuda):

  • WITH fix: PASS.
  • NON-VACUITY — re-zero disabled: FAIL -> "gen#1 [0.0, 60.0, 144.0] != gen#2 [252.0, 312.0, 396.0]". Exactly the values Mary cited. Restored -> PASS again.
  • Test is genuinely non-vacuous: logits = ReduceSum(Cast(conv_state)) so a stale state changes emitted logits; it asserts first[0]==0.0 and strictly-growing per-step logits (proving state feeds logits) before asserting gen#1==gen#2.

GQA qwen2.5-0.5b int4 (KV-only common path), --runs 2: DETERMINISTIC -> " Paris. It is the largest city in France and the second largest in the European". No regression on the common path.

Gates

  • cargo fmt --all --check: clean (exit 0).
  • Touched crate onnx-genai-engine builds under cuda,native-backend.
  • weight_paging.rs / provider.rs: NOT touched by the fix commit (verified via git show --name-only). Guardrail honored.

Conclusion

Root cause, scope (LinearAttention-only / empty range for pure-KV), fix generality, reset-boundary timing, CPU parity, the non-vacuous regression test, and end-to-end 27B determinism (capture ON and OFF) are all independently confirmed. Approving for merge.

— Harry (independent reviewer)

@justinchuby
justinchuby merged commit 67b09c3 into main Jul 31, 2026
14 checks passed
@justinchuby
justinchuby deleted the squad/native-session-reuse-reset branch July 31, 2026 08:43
justinchuby added a commit that referenced this pull request Jul 31, 2026
…dation (#555)

Consolidates 6 inbox decision notes into decisions.md (20458->20332 B,
under the 20480 gate) and archives two historical wave records. Updates
agent histories.

Wave summary (all merged):
- #544 — async fence-ordered CUDA weight page-in (#87 increment-1) +
deterministic anti-regression test
- #552 — profile_native capture-counter observability for genai_config
decoders
- #554 — native-CUDA session-reuse recurrent-state reset fix (closes
#553); 27B LinearAttention gen#2+ corruption
- 27B native offload A/B proof: 2.9x VRAM reduction, byte-exact output

State-only change (decisions/histories/archive). Logs are gitignored.

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Jul 31, 2026
…ture scoping (#556)

State consolidation only (no code). Merges 3 decision notes into
decisions.md (20332→20465 B, under gate).

**Key durable record:** the ⚑ PENDING-JUSTIN 27B Scan→CUDA-capture
decision — the ~15-30× decode lever is NOT a contained increment
(prefill/decode share one plan/session; static single-trip inline
corrupts prefill). Correct fix = runtime shape-conditional dual-path
capture infra, which reshapes the delicate #443/#543 control-flow
capture core. Awaiting @justinchuby go-ahead before touching that core.

Also records: 27B decode profile (Scan=56.5%, ~35× off HBM roofline,
structural not kernel), and the #554 session-reuse fix. Round-8 wave
record archived; histories compacted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Jul 31, 2026
…capture slice 1a) (#564)

## 27B decode: flag-gated single-trip Scan inline dual-path (slice 1a of
Scan→CUDA-capture)

First slice of the Justin-approved 27B Scan→CUDA-capture workstream
(root-cause: eager `Scan`/LinearAttention recurrence = 56.5% of 27B
decode, structurally un-capturable; ~15-30× lever). **Slice 1a is
correctness-only — NO capture changes yet.**

### What
A runtime-conditional dual-path in `exec_scan`: when
`ONNX_GENAI_SCAN_INLINE_SINGLE_TRIP` is ON **and** the runtime
trip_count==1, the Scan body runs once straight-line via a shared
`run_scan_body_step` helper; otherwise the unchanged loop. Selection is
**runtime-keyed** (not a graph rewrite) because prefill (trip_count>1)
and decode (trip_count==1) share one executor/plan — a static seq=1
inline would corrupt prefill.

- **Flag default OFF** ⇒ zero behavior change (loop for all trip
counts).
- Loop and inline share the same body-step + finishing code →
**byte-exact with a one-iteration loop by construction**. DRY, no
model/op special-casing.
- **No capture-core changes** (provider.rs/capture.rs untouched); Scan
still declines capture in both paths. That's slice 1b.

### Evidence
- **27B (qwen3.6-27b int4, greedy 48 tok, CUDA):** token ids
**identical** flag OFF vs ON across prefill + 48 decode steps.
- **Non-vacuous tests** (CPU always-on + CUDA-gated): assert
byte-equality vs loop AND runtime-keyed engagement (counter==1 only at
trip_count==1, ==0 on prefill). Mutation-checked (independent reviewer
ran 2 CPU + 1 CUDA mutations, all FAIL as required).
- Regressions green: #554 session-reuse, #544 prefetch-WAR,
cuda_control_flow_safety, CPU executor/control_flow/session suites.
fmt+clippy clean.

### Review
Independent review by Melina (author Mary locked out on rejection) —
**APPROVE** with full non-vacuity evidence.

### 1b handoff
Let the single-trip inlined body enter CUDA-graph capture (blast radius
`provider.rs:458` + `executor/capture.rs`); assert captures/replays
counters rise and 27B tokens stay byte-identical to this 1a reference.

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Jul 31, 2026
…#565)

## Wire pure-native multi-component pipeline decode (GAP-3 Inc-A)

First slice of the Justin-approved native multi-component pipeline
workstream (GAP-3). Previously `pipeline/mod.rs build()` rejected
pure-native pipeline selection with *"native pipeline decode is not yet
implemented"* — blocking native decode for any multi-component (split
embed→decoder) model.

### What
Removed the construction-time bail. The pure-native path
(`PipelineBackend::Native`) now falls through to the **already-working,
backend-agnostic** flat-autoregressive decode loop (`run_autoregressive`
→ `PipelineDecodeLoopBackend`), building every component as a native
`ComponentSession` + the decoder as `NativePipelineDecoder` — the same
thing the hybrid env-flag path builds, but driven by backend selection
instead of env injection.

- **DRY:** new `native_component_selection()` converges both native
sources (Native-backend and Ort+env-flag hybrid) on ONE decision point +
the SAME builders. No forked construction; hybrid (#543/#541)
byte-unchanged.
- **Scope:** NON-PAGED only — `use_native_decoder ⇒
paged_enabled=false`, so S2 `mirror_last_present_kv`
(`decoder_component.rs:256`) is never reached (that's Inc-C). No
decode-loop / `native_decode/*` / capture-core changes — zero overlap
with the concurrent Scan work.
- Non-flat-AR native plans get a precise
`native_pipeline_plan_unsupported` error (no silent mis-route).
Auto→Native is normalized so Auto-resolved-native also drives native
selection.

### Evidence
- **Token-exact 3-way** (CPU, `tiny-gemma4-vlm`): ORT oracle ==
hybrid-env == pure-native, all `[0,5,6,7]`.
- **CUDA differential** (`tiny-gqa-embeds-cuda`): pure-native == hybrid,
both `[0,5,6,7]`.
- **Non-vacuous:** reviewer re-inserted the bail → test FAILS at the
pure-native case.
- Regressions green: #384 native parity, #543/#541 hybrid, #554
session-reuse, 343 lib tests. fmt+clippy clean.

### Review
Independent review by Harry (author Cohaagen locked out on rejection) —
**APPROVE** with mutation + regression evidence.

### Handoff
- Inc-B: native `prompt_only` prologue (e.g. vision_encoder).
- Inc-C: native present-KV mirroring / paging at
`decoder_component.rs:256` (unblocks Qwen3.6-35B-A3B MoE).
- Follow-up: native-only loader (pure-native still loads ORT
`PipelineModels`; ORT-unloadable block-quant models need it).

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Jul 31, 2026
…GAP-3 Inc-C) (#566)

## GAP-3 Inc-C — native present-KV mirroring → paged native pipeline
decode

Closes the S2 bail in `NativePipelineDecoder::mirror_last_present_kv` so
the pure-native pipeline decoder (Inc-A #565) can run **paged**
(cross-request KV prefix reuse), not only the non-paged flat-AR path.
Host-KV path; device-resident present-KV (35B-A3B GPU) is explicitly
deferred to Inc-D.

### What's wired
- `native_decode/mod.rs`: `supports_host_kv_mirror` gate,
`host_present_kv` (read), `seed_growable_kv` (seed).
- `pipeline/decoder_component.rs`: real `mirror_last_present_kv` —
byte-identical geometry to ORT's `mirror_present_kv_to_pages` (shared
`extract_present_token`/`append_token_kv`); `supports_paged_kv` +
`load_paged_prefix` seams.
- `pipeline/flat_autoregressive.rs`: DRY-factored `claim_paged_prefix`
shared by ORT + native admit paths (only the KV sink differs).
- `pipeline/mod.rs`: read-only `#[cfg(feature="native-backend")]` test
accessor `materialize_published_prefix_kv` (non-mutating prefix-cache
read-back).
- `tests/native_pipeline_backend_selection_parity.rs`:
`native_paged_prefix_reuse_matches_fresh_and_ort` — warm==cold==ORT
tokens **plus** direct native-vs-ORT paged-KV **byte-equality**
assertion.

### Correctness
- Token-exact 3-way: pure-native warm == native cold == ORT oracle;
`reused=4`.
- **Geometry non-vacuity (independently re-run by reviewer):** no-op
mirror, key/value swap, zeroed mirror, and forced-reused=0 mutations ALL
fail — the byte-equality assert catches geometry corruption that argmax
alone missed.
- No production regressions: Inc-A #565, #541/#543 hybrid, #554
session-reuse green.
- Scope: zero edits to the parked capture core (`plan_capture_region`,
`executor/capture.rs`, `CudaGraphLifecycle`).

### Deferred to Inc-D (35B-A3B GPU end-to-end)
Device-resident present-KV read-out, in-place-GQA CPU KV, f16/non-rank-4
round-trip, MoE routed-expert specifics if needed. These decoders
correctly gate `supports_paged_kv=false` → Inc-A non-paged fallback (no
silent-wrong paged run).

### Reviews
Independent opus review with strict author-lockout: Mary REJECTED the
original (vacuous geometry test) → revised the test as authorized
non-author → Harry independent re-review **APPROVE** (all mutations
re-run, byte-equality confirmed, production byte-identical, deferral
gate verified).

Note: run the parity test binary with `--test-threads=1` (two tests
share a process-global decoder-device env var; parallel races flake —
pre-existing on HEAD).

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Jul 31, 2026
…DA decode (GAP-3 Inc-D) (#567)

## GAP-3 Inc-D — device-resident present-KV read-out → paged native CUDA
decode

Lifts the Inc-C (`#566`) `supports_paged_kv=false` gate for
**device-resident f32 rank-4 CUDA GQA present-KV**, so a native CUDA
pipeline decoder now runs **paged** (cross-request KV reuse) instead of
the Inc-A non-paged fallback. Closes the present-KV threading gap for
Qwen3.6-35B-A3B GPU decode.

### How (pure post-step plumbing — no kernel/capture-core edits)
- `native_decode/cuda.rs`: `read_present_kv` reads the KV binding
**after** the decode step's existing stream sync
(`read_bytes`→`copy_to_host`→`dtoh`, which synchronizes) using the
**physical/capacity** shape `[1,H,max_len,Dh]` so strides address the
padded buffer; `seed_prefix` is the device counterpart of Inc-C's host
seed; `device_present_kv_view` isolates the physical-shape handling.
- `native_decode/mod.rs`:
`present_kv`/`seed_kv`/`supports_device_kv_mirror` unify host-growable
(Inc-C) and device-CUDA (Inc-D) onto the **same**
`extract_present_token`/`append_token_kv` geometry + host f32 paged
store (DRY, byte-comparable with ORT).
- `pipeline/decoder_component.rs`: `supports_paged_kv = host OR device`;
`mirror_last_present_kv &self→&mut self` (rippled to trait + ORT impl);
`load_paged_prefix → seed_kv`.

### Correctness
- `native_paged_prefix_reuse_matches_ort_on_cuda_device`:
paged-native-CUDA == non-paged-native-CUDA == ORT oracle == closed-form
tokens; mirrored pages **byte-equal** CUDA-vs-ORT; `reused=4`.
- **H=2 unit test** for the physical-vs-logical stride bug (all existing
fixtures are H=1, where the head-stride error is invisible). Mutating to
the logical stride fails it.
- Non-vacuity (independently re-run by reviewer): gate-revert,
logical-stride, and forced-reused=0 mutations ALL fail.
- Honest gating: f16 / non-rank-4 / CPU-in-place-GQA /
sink-discontinuous stay `supports_paged_kv=false` → Inc-A non-paged
fallback (no silent-wrong paged run).
- No regressions: Inc-C #566, Inc-A #565, #541/#543 hybrid, #554 reuse
(14/14), native_decode lib (54); scope clean (no standard_attention/GQA
kernel, capture core, or `plan_capture_region` edits).

### Remaining (follow-up Inc-D.1)
Real 35B-A3B export in **f16** device KV → f16 read-out + lossless paged
round-trip; and CPU-in-place-GQA f32 (needs its own H≥2 ORT-oracle
fixture — not free). Both correctly gated to non-paged today.

### Reviews
Independent opus review, author-lockout enforced: Mary (native-decode
specialist) **APPROVE** — 8/8 items verified with reproduced evidence on
GPU 0, full mutation battery.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Jul 31, 2026
…code (GAP-3 Inc-D.1) (#568)

## GAP-3 Inc-D.1 — f16 device-resident present-KV read-out → paged
native CUDA decode

Relaxes the Inc-D (`#567`) device paged gate `cuda && f32 && rank==4` →
`cuda && (f32||f16) && rank==4`, so **f16 device-resident rank-4 CUDA
GQA present-KV** runs **paged** instead of falling to the Inc-A
non-paged path. This is the unlock for real fp16 models (e.g.
gemma4-e2b, whose decoder KV is confirmed FLOAT16) to decode natively
paged.

### How (dtype handling in native read/seed only)
- `native_decode/tensor.rs`: `kv_dtype_to_f32` widens f16→f32 via `half`
`to_f32_vec` (identical to ORT `to_vec_f32_lossy`);
`f32_slice_to_dtype_bytes` narrows f32→f16 via `half::f16::from_f32`
(identical to ORT `from_f32_slice_as`) — NOT the logits bit-twiddle.
Narrower shared with the embedding-input path (DRY).
- `native_decode/cuda.rs`: gate `kv_bindings_paged_rank4`; dtype-branch
the read-out + seed. Host paged store stays **f32 for f16 models**
(identical to ORT) → the Inc-C/D byte-equality oracle is preserved
unchanged.
- bf16, CPU-in-place-GQA, non-rank-4, sink-discontinuous stay gated →
non-paged fallback (bf16 additionally bails defensively inside the
convert).

### Correctness
- New `native_paged_prefix_reuse_matches_ort_on_cuda_device_f16`:
paged-native-f16 == non-paged-native-cold == ORT-cold tokens;
device-mirrored pages **byte-equal** CUDA-vs-ORT (f32 store both sides);
`reused=4`.
- Convert unit tests: native widen == `half` reference; **f16→f32→f16
bit-exact across all 65536 non-NaN f16 patterns**; f32 identity.
- New fixture `tiny-gemma4-vlm-cuda-f16` (Concat-KV so an ORT oracle
exists; `value=key*2` bit-exact — the `+0.5` variant was rejected for
hitting an f16 round-to-even midpoint).
- Non-vacuity (independently re-run by reviewer): gate→f32-only,
raw-u16-as-f32 wrong-convert, and mirror-disabled mutations ALL fail.
- No regressions: Inc-D #567 f32 path still green, Inc-C #566, Inc-A
#565, #541/#543 hybrid, #554 reuse (14/14), 354 lib tests. Scope clean
(no attention/GQA kernel, capture core, provider, `CudaGraphLifecycle`,
or ORT-bridge edits).

### Remaining (follow-up Inc-D.2)
qwen3-30b-a3b is `torch_dtype=bfloat16`; if its ONNX export keeps bf16
KV it stays gated → Inc-D.2 flips the bf16 arm (helpers already have it)
after confirming ORT widens bf16→f32 in its paged store. MoE FFN
produces no KV (orthogonal); present-KV dtype is the only decode-path
gate.

### Reviews
Independent opus review with strict author-lockout: Harry **APPROVE** —
convert matches ORT exactly, round-trip bit-exact (all 65536 patterns),
bf16 excluded, full mutation battery fires, scope clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

1 participant