Skip to content

Return overload response for KV admission refusal - #743

Merged
justinchuby merged 4 commits into
mainfrom
squad/738-governor-refusal
Aug 8, 2026
Merged

justinchuby merged 4 commits into
mainfrom
squad/738-governor-refusal

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

Summary First falsifiable consumer increment for #738, using the truthful native KV admission geometry merged in #644. - Preserves the typed SchedulerAdmissionError::ByteBudget classification across the engine-driver boundary. - Maps only that memory-admission refusal to HTTP 429 with Retry-After: 1 and stable resource_limit_error JSON naming the KV memory resource limit. - Leaves unrelated generation failures as HTTP 500; BatchFull is not treated as memory overload. - Reuses the existing request middleware accounting, so each 429 increments onnx_genai_rejections_total exactly once. - Adds no budget, concurrency threshold, queue cap, preemption, or swap state. This advances #738 and #706 but intentionally does not close the full #738 preemption/state-machine issue. ## Tests - HTTP status/header/body mapping test. - Typed scheduler-error classification test (KV byte budget vs unrelated batch-full). - Rejection metric increments exactly once for 429 and not for unrelated 500. - Sabotage: changed memory mapping back to 500; the HTTP test failed (500 vs 429), then restored. ## Validation - cargo fmt --all -- --check ✅ - cargo clippy --locked --all-targets -p onnx-genai-server -- -D warnings ✅ - cargo clippy --locked --all-targets -p onnx-genai-engine --features native-backend -- -D warnings ✅ - Targeted server refusal tests ✅ - Full server lib target: 152 passed, 2 ignored; one unrelated fixture-path failure (vision.onnx absent in the compatibility-package fixture). - CUDA/native server build ✅ ## Governed qwen14b-zp E2E Command: native CUDA server with --vram-limit 6000000000. - First accepted request: HTTP 200, response OK, 197.21 s. - Admission estimate remained 196,608 B/token (192 KiB/token). - 16 concurrent identical over-budget requests: 16× HTTP 429, each in 1.68–3.82 s, all with Retry-After: 1 and resource_limit_error. - onnx_genai_requests_waiting: max 0, final 0. - onnx_genai_rejections_total: 0 → 16 (then 17 after a separate body-capture probe), exactly one per 429. - /metrics: 29 overload samples, maximum response latency 0.292 s. ## Review fixes - Streaming completion/chat now await the first driver event before constructing SSE. A first-event KV refusal returns normal HTTP 429; an accepted token/result is buffered and forwarded without loss. Chat emits no role chunk before acceptance. - Audio transcription now uses the same typed generation-failure mapping (KV refusal 429, unrelated failure 500). - Public overload bodies are fixed and omit scheduler IDs, usage, limits, and configuration internals; full details are logged server-side. - Sabotaging the handshake to accept a first-event error made streaming_completion_refusal_is_http_overload_before_sse fail, then was restored. Validation after review fixes: - server format/clippy gate ✅ - streaming completion/chat refusal tests ✅ - transcription mapping tests ✅ - accepted completion/chat stream order and first-token preservation ✅ - governed native CUDA E2E: accepted chat stream returned 200 with role → OK → finish → [DONE]; strict governed overload probe returned HTTP 429 in 0.036 s with Retry-After: 1, exact stable JSON body, rejections_total 0 → 1, requests_waiting=0, and /metrics responding in 83 ms. The original 6 GB governed acceptance/overload evidence remains above; the stricter 1 GB run isolates and falsifies the streaming refusal path. ## Final handshake correction The first-event preflight described above has been replaced by an explicit, exactly-once driver admission oneshot. Engine generation APIs notify it immediately after scheduler admission and before backend execution. Refusals resolve Err(DriverFailure) without starting the backend; accepted requests resolve Ok(()) before tokens/results. Dropped senders resolve as internal errors rather than hanging. Continuous batching now keeps the handshake pending after manager.submit: it resolves only when that handle is assigned a physical decode row. Row-assignment failures resolve before HTTP headers as internal errors (not KV-memory 429s), and canceled pending handles are removed. FIM uses the engine admission callback and no longer waits for its final result before returning SSE headers. Additional validation: - delayed-first-token admission test returns promptly without consuming/buffering any event; - dropped admission sender resolves promptly; - FIM stream returns headers through the admission handshake; - accepted zero-visible-output stream returns headers and terminates with [DONE]; - native rejection test confirms the admission callback is not fired before a KV refusal; - native CUDA accepted chat stream: HTTP 200 headers in 0.026 s, first/final output completed in 24.760 s, preserving role → content → finish → [DONE] order; - native CUDA streaming overload: HTTP 429 in 0.089 s, Retry-After: 1, stable body, rejections_total 0 → 1, requests_waiting=0, /metrics responsive in 131 ms; - server and native-engine -D warnings clippy gates pass.

Continuous-row admission correction

After rebasing onto current main (0f0b6c0e), continuous batching now distinguishes queue insertion, physical row assignment, and token output:

  • ContinuousBatchManager emits handle-specific Assigned/Rejected admission notifications.
  • The driver acknowledges SSE only after successful assign_row, before backend prefill/token generation.
  • Per-handle row failures are returned before headers and remain HTTP 500, never KV-memory 429.
  • Pending canceled handles are removed without leaking a route or permit.

Final validation:

  • cargo fmt --all -- --check ✅
  • server all-target clippy with -D warnings ✅
  • native-engine all-target clippy with native-backend and -D warnings ✅
  • 4 manager admission tests, 17 server admission tests, 5 streaming admission tests, and 4 scheduled continuous-batch integration tests ✅
  • focused FIM pre-header handshake test ✅ (the full server lib run also has the already documented missing vision.onnx fixture; its 100 ms FIM timing assertion passed when run without full-suite contention)
  • rebuilt native CUDA CLI on current main ✅
  • strict governed streaming E2E (--vram-limit 1000000000): accepted stream HTTP 200 headers in 0.017 s, completion in 9.373 s; 16 concurrent over-budget streams returned 16×429 in 0.066–0.279 s, each with Retry-After: 1 and the stable body; rejections_total 0→16, requests_waiting peak 0, /metrics max latency 0.033 s.

The 6 GB Qwen evidence above remains the requested governed-path check; the stricter limit makes the refusal predicate independently falsifiable without relying on concurrent reservation timing. Full #738 remains open.

@codecov

codecov Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.23%. Comparing base (0f0b6c0) to head (4cd720c).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #743      +/-   ##
==========================================
- Coverage   80.93%   80.23%   -0.71%     
==========================================
  Files         331      331              
  Lines      134104   134104              
  Branches   134104   134104              
==========================================
- Hits       108543   107599     -944     
- Misses      21298    22246     +948     
+ Partials     4263     4259       -4     
Flag Coverage Δ
cli-ort-linux 83.37% <ø> (ø)
cli-ort-windows 82.96% <ø> (ø)
mlas 80.96% <ø> (ø)
offline 80.09% <ø> (-0.75%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 6 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

github-actions Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

✅ Benchmarks — No Regression

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
✅ add/large_f32_threads=1-internal/4194304 39.39 ms 43.77 ms +11.1%
✅ sampling_latency/top_p_per_token 357.85 µs 366.53 µs +2.4%
✅ qwen3_sampling_processors/top_k_partial_selection 133.10 µs 135.62 µs +1.9%
✅ block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 72.63 µs 73.87 µs +1.7%
✅ sampling_latency/min_p_per_token 193.78 µs 196.19 µs +1.2%
✅ qwen3_sampling_processors/top_k_full_sort_baseline 1.99 ms 2.01 ms +1.0%
✅ gather/small_bf16_threads=1-internal/4096 438.7 ns 441.5 ns +0.6%
✅ reduce_mean/medium_f32_threads=1-internal/65536 232.99 µs 234.23 µs +0.5%
✅ sampling_latency/greedy_per_token 2.99 µs 2.99 µs -0.2%
✅ sampling_latency/top_k_per_token 49.85 µs 49.53 µs -0.6%
✅ gather/small_f16_threads=1-internal/4096 434.0 ns 430.8 ns -0.7%
✅ qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.30 ms 3.26 ms -1.0%
✅ qwen3_sampling_processors/top_p_fast_after_top_k 490.73 µs 481.74 µs -1.8%
✅ reduce_mean/large_f32_threads=1-internal/262144 939.40 µs 919.54 µs -2.1%
✅ add/medium_f32_threads=1-internal/262144 2.78 ms 2.71 ms -2.4%
✅ gather/medium_f32_threads=1-internal/32768 3.53 µs 3.43 µs -2.7%
✅ add/small_bf16_threads=1-internal/1024 13.90 µs 13.50 µs -2.8%
✅ gather/small_f32_threads=1-internal/4096 620.8 ns 597.7 ns -3.7%
✅ add/medium_bf16_threads=1-internal/262144 2.58 ms 2.48 ms -3.8%
✅ add/medium_f16_threads=1-internal/262144 2.79 ms 2.65 ms -5.3%
✅ add/large_f16_threads=1-internal/4194304 43.20 ms 40.37 ms -6.5%
✅ qwen3_sampling_processors/top_k_top_p_fast 663.03 µs 616.76 µs -7.0%
✅ matmul/large_generic_bf16_threads=1/32x1024x1024 2.00 ms 1.86 ms -7.0%
✅ add/small_f16_threads=1-internal/1024 14.66 µs 13.37 µs -8.8%
✅ reduce_mean/small_f32_threads=1-internal/4096 15.28 µs 13.84 µs -9.4%
✅ matmul/medium_generic_f16_threads=1/32x512x512 30.36 µs 27.47 µs -9.5%
✅ add/large_bf16_threads=1-internal/4194304 43.20 ms 38.87 ms -10.0%
✅ tokenization/decode_tokens_per_second 6.37 ms 5.69 ms -10.6%
✅ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 5.94 ms 5.30 ms -10.8%
✅ logit_processing/seven_processor_chain_per_step 337.90 µs 300.45 µs -11.1%
✅ grammar_masking/llguidance_compute_mask/32 80.96 µs 71.37 µs -11.9%
✅ kv_cache/alloc_dealloc_pages 39.15 µs 34.28 µs -12.4%
✅ matmul/small_generic_f32_threads=1/1x256x256 42.54 µs 37.04 µs -12.9%
✅ matmul/large_generic_f16_threads=8/32x1024x1024 94.62 µs 81.57 µs -13.8%
🟢 matmul/large_generic_f32_threads=1/32x1024x1024 10.51 ms 8.91 ms -15.2%
🟢 add/small_f32_threads=1-internal/1024 261.2 ns 221.5 ns -15.2%
🟢 gather/medium_bf16_threads=1-internal/32768 2.59 µs 2.18 µs -15.8%
🟢 matmul/large_generic_f16_threads=1/32x1024x1024 87.73 µs 73.23 µs -16.5%
🟢 matmul/small_generic_bf16_threads=1/1x256x256 35.66 µs 29.55 µs -17.1%
🟢 matmul/medium_generic_f32_threads=1/32x512x512 2.63 ms 2.17 ms -17.5%
🟢 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 709.70 µs 579.24 µs -18.4%
🟢 tokenization/encode_tokens_per_second 434.73 µs 346.93 µs -20.2%
🟢 matmul/small_generic_f16_threads=8/1x256x256 36.70 µs 29.04 µs -20.9%
🟢 matmul/small_generic_bf16_threads=8/1x256x256 39.62 µs 30.75 µs -22.4%
🟢 matmul/medium_generic_bf16_threads=1/32x512x512 651.86 µs 500.25 µs -23.3%
🟢 matmul/small_generic_f16_threads=1/1x256x256 37.02 µs 28.34 µs -23.4%
🟢 block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 491.85 µs 365.61 µs -25.7%
🟢 gather/medium_f16_threads=1-internal/32768 3.12 µs 2.18 µs -30.0%
🟢 block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 60.61 µs 41.09 µs -32.2%
🟢 matmul/large_generic_f32_threads=8/32x1024x1024 5.37 ms 3.64 ms -32.2%
🟢 gather/large_f16_threads=1-internal/131072 14.75 µs 9.67 µs -34.5%
🟢 gather/large_f32_threads=1-internal/131072 36.42 µs 23.76 µs -34.8%
🟢 matmul/large_generic_bf16_threads=8/32x1024x1024 2.05 ms 1.32 ms -35.6%
🟢 matmul/small_generic_f32_threads=8/1x256x256 52.38 µs 32.39 µs -38.2%
🟢 matmul/medium_generic_f32_threads=8/32x512x512 1.48 ms 893.86 µs -39.5%
🟢 matmul/medium_generic_f16_threads=8/32x512x512 47.12 µs 28.05 µs -40.5%
🟢 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 73.51 µs 43.63 µs -40.6%
🟢 matmul/medium_generic_bf16_threads=8/32x512x512 705.69 µs 378.32 µs -46.4%
🟢 gather/large_bf16_threads=1-internal/131072 18.24 µs 9.61 µs -47.3%

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: { 2.48 3.05 4.29 }
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)

Copilot AI added 4 commits August 8, 2026 02:08
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby force-pushed the squad/738-governor-refusal branch from 6b2a5a8 to 4cd720c Compare August 8, 2026 09:33
@justinchuby

Copy link
Copy Markdown
Owner Author

Final review passed after four rounds.

Contract now enforced

Streaming/non-streaming admission separates three events:

  1. scheduler/governor KV byte grant;
  2. continuous-batch decode-row assignment;
  3. token/output production.

HTTP/SSE headers follow (1)+(2), never wait for (3), and never acknowledge mere insertion into an unbounded queue.

  • memory admission refusal → pre-header HTTP 429, Retry-After: 1, stable resource_limit_error body;
  • unrelated generation/row errors remain 500;
  • detailed scheduler internals are server logs only, not public response;
  • audio transcription uses the same typed mapping;
  • cancellation while pending removes the request and resolves the handshake without leaking a row;
  • errors after accepted streaming remain stream errors, not false rejections.

Measured E2E

Governed native CUDA server, qwen2.5-14b:

  • accepted streaming headers: 17 ms, before first token/output;
  • 16 concurrent over-budget requests: 16/16 HTTP 429;
  • rejections_total delta: 16, exactly once per refusal;
  • requests_waiting peak/final: 0/0;
  • metrics remained responsive.

Review bugs closed

  1. Streaming originally returned HTTP 200 before admission and emitted refusal as SSE error.
  2. Audio kept admission as 500.
  3. Public body exposed IDs/limits/internal scheduler text.
  4. First fix waited for first token/result rather than admission, blocking FIM/zero-output streams.
  5. Continuous batching first acknowledged queue insertion rather than actual decode-row assignment.

All are covered by targeted tests and final review found no significant issue.

This is the first increment of #738, not full preemption/swap. It closes the unbounded-queue behaviour measured in #706 without inventing a private budget or magic concurrency cap.

Merging.

@justinchuby
justinchuby merged commit 9d3f800 into main Aug 8, 2026
14 of 15 checks passed
@justinchuby
justinchuby deleted the squad/738-governor-refusal branch August 8, 2026 09:48
justinchuby added a commit that referenced this pull request Aug 9, 2026
Implements workspace-planning Stage 0 for block-quantized MoE prefill.

- adds a default-zero typed Kernel workspace requirement API
- uses one checked QMoE layout helper for planning and execution
- prepares/reserves governed workspace before cold and ordinary
persistent-session admission callbacks
- cleans scheduler state on preparation failure and maps device-limit
refusals through the existing pre-header 429 path
- removes QMoE persistent scratch calls to CudaRuntime::alloc_raw
- preserves routed pipelines when QMoE input shapes do not depend on
runtime-produced values
- conservatively binds inputs_embeds as [1, request_len, static_hidden];
genuinely unresolved routed QMoE dependencies fail precisely before
admission
- recursively plans static QMoE in If/Loop/Scan children, takes the peak
across reachable children, and passes the parent prepared workspace
through nested executors for sequential reuse
- grows scratch workspace without transiently double-charging old and
replacement allocations

Refs #736
Refs #743

Validation:
- targeted session/engine checks and library clippy with -D warnings
passed
- nested control-flow QMoE prepare/reuse regression passed
- block_quantized_moe_gpu: 5 passed on CUDA
- glm_tiny_qmoe_native_cuda_e2e: 3 passed serially on CUDA, including
ordinary non-cold session admission ordering
- native_cuda_routed_pipeline_decoder_parity: passed on CUDA with
CUDA_VISIBLE_DEVICES=0 and ONNX_GENAI_CUDA_KV_MAX_LEN=16
- server workspace overload classification test passed

The PR remains draft pending production-server ordinary streaming
coverage and the Qwen14B 6 GiB long-prompt reproducer.

---------

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

## Summary

Next increment on #736: audit the CUDA activation/scratch/workspace
allocations that still bypass the device memory authority (beyond the
QMoE path already governed by #747/#748), then govern the largest live
bypass.

References #736. Does **not** close the issue.

## Step 1 — Audit

A full inventory of production scratch/workspace allocations across
`onnx-runtime-ep-cuda`, `onnx-runtime-session` and the CUDA kernels is
added to `docs/MEMORY_ARCHITECTURE.md` §2.1, with file/line, byte
formula, size class (static / config-derived / prompt-dependent) and
lifetime class (model-fixed / session-persistent / request-prefill /
step-scoped / already-governed) for every remaining raw path.

Ranking by live bytes, the largest ungoverned bypass is the
`com.microsoft::Attention` **Phase-2a** scratch:
- **scores buffer** `batch · num_heads · sq · sk · elem` — quadratic in
sequence length (≈268–536 MB at B=1, H=32, S=2048), the single largest
prompt-dependent scratch; plus
- a static **32 MiB cuBLASLt workspace**.

The fused (flash) prefill path allocates **no** device scratch (shared
memory only), so `run_attention_phase2a` is the *only* scratch source in
`AttentionKernel` — a clean StepScoped slice.

## Step 2 — Govern one vertical slice

- **`attention.rs`**: `phase2a_scratch_layout` + `Phase2aScratch` carve
the scores buffer and cuBLASLt workspace from one governed blob.
`run_attention_phase2a` uses an executor-prepared `WorkspaceView` when
present; the compatibility/opt-out path (direct `execute`, GQA reuse,
unit tests) keeps its self-owned `alloc_raw` scratch. New
`Kernel::workspace_requirement` reports the exact bytes (StepScoped,
`NONE` for the fused path) and `execute_with_workspace` consumes the
reserved slot.
- **`executor/bindings.rs`**: prepare-only planning (#747) now tracks
one workspace peak **per lifetime class** (SessionPersistent +
StepScoped) and prepares both slots, so a graph mixing QMoE
(session-persistent) and Attention (step-scoped) governs both. Reserve
logic factored into `reserve_prepared_workspace`; node matching
centralised in `is_planned_workspace_node`.
- **Same helper for plan and exec**: planning and execution both size
through `use_fused` + `phase2a_scratch_layout`, so the reserved slot
always covers the dispatch. A shortfall would error deterministically
rather than silently under-allocate.
- **429, not 500/OOM**: reservation happens inside
`prepare_with_device_bindings` (pre-header, prefill admission).
`reserve_workspace`/`prepare_mapped_growth` emit
`TierExhausted`/`CapacityUnavailable`, which
`DriverFailure::from_engine_error` already maps to HTTP 429 (#743) — no
server change needed.
- **Lifetime correctness**: step-scoped scratch is released by the
executor's existing `release_step_workspace` on
success/error/cancellation; the governed kernel path no longer owns or
frees it. No asserts in any `Drop`.
- **Regression guard**: new unit test pins `phase2a_scratch_layout`'s
byte formula; a GPU-gated test asserts `workspace_requirement` equals
the layout for a Phase-2a shape and returns `0` (NONE) for a fused shape
— preventing a raw-allocation regression on the governed slice.

## Step 3 — Document

`docs/MEMORY_ARCHITECTURE.md` §2.1 records the governed-today table
(QMoE, Attention Phase-2a) and every remaining raw path. It identifies
**`GroupQueryAttention`'s `WS_SCORES` slot** (`batch·heads·sq·sk·4`,
session-persistent growable) as the recommended next increment, with the
three shared 32 MiB cuBLASLt GEMM workspaces as a cheap follow-up.

## Validation

- `cargo fmt --all -- --check` ✅
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
✅ (exact CI gate, no `--all-targets`)
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend -- -D warnings` ✅
- Session executor lib tests (76 incl.
`prepare_reserves_static_nested_qmoe_workspace_and_child_reuses_it`) ✅
- CUDA EP attention lib tests incl. new guard tests ✅ (GPU present)
- GPU tests: `attention_gpu` (5), `qmoe_gpu` (27),
`allocation_counts_gpu` (2), `group_query_attention_gpu` (25,
single-threaded), `standard_attention_gpu` ✅

Note: `gqa_gpu_fixed_decode_capture_replays_bit_identically` fails only
when many capture tests share one binary in parallel (pre-existing CUDA
graph-capture stream interaction,
`CUDA_ERROR_STREAM_CAPTURE_INVALIDATED`); it passes in isolation and
single-threaded. Unrelated to this change (the GQA edit only adds a
`None` arg to the prefill helper).

Per instructions: local validation only, not waiting for CI; PR opened
but **not** merged and issue **not** closed.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 11, 2026
…le and honest (#750) (#758)

## Summary

First increment for #750: make the **native backend's inability to batch
observable and honest**, without implementing batched native decode.

The native decode path pins both the batch and query-seq dimensions to
`1` as a structural invariant (`native_decode/cuda.rs`). Despite that,
the server inferred "batching enabled" solely from whether an ORT
decoder session existed: `Engine::continuous_batch_manager` returns
`Err` on native (`"ORT decoder session is unavailable"`), and
`driver.rs` swallowed that into a debug/info "using per-request engine
path" log line. So `--max-batch` was accepted and reported while having
**no effect** on the native path.

## What changed

### Engine (`onnx-genai-engine`)
- New `Engine::batching_capability() -> BatchingCapability`, **sourced
from the decode path** (resolved `EngineDecodeBackend` +
`ModelDecodePath`), *not* from `session.is_some()`. This is the core
honesty fix.
- Native → `Some(1)` with a reason naming the backend and citing
`native_decode/cuda.rs`.
- ORT static-cache and shared-buffer past/present → unlimited (`None`,
bounded only by memory / `--max-batch`).
- Legacy and non-shared-buffer past/present → `Some(1)` with the reason.
- `BatchingCapability` re-exported from the crate root.

### Server (`onnx-genai-server`)
- A decode-path-sourced `BatchingReport` is resolved at engine startup
and logged at **info** level (`resolved batching capability` with
`batch_supported` / `requested_max_batch` / `effective_max_batch` /
`reason`).
- Surfaced so an operator can *see* the truth instead of inferring it:
- `GET /v1/resources` → `batching { supported, requested_max_batch,
effective_max_batch, reason }`.
  - `/v1/debug/kv` → `batch_supported` / `effective_max_batch`.
  - admin vram-override response includes the same report for parity.
- New `--max-batch` CLI flag (env `ONNX_GENAI_MAX_BATCH`).

### Metrics honesty note
Documented at the metric site that `onnx_genai_batch_size_current`
counts **admitted generations**, not sequences co-decoded in one batched
pass (`crates/onnx-genai-server/src/metrics.rs`). It climbs with
concurrent requests even on a backend that decodes them one at a time —
which is exactly why metrics alone never revealed the missing batching.

## Decision: explicit refusal vs. silent clamp

The task asked to decide and justify. I chose a **hybrid**, biased
toward explicit refusal:

- **Operator explicitly asks for `--max-batch > 1` on a backend that
cannot honor it → hard startup refusal** naming the flag and the reason
(e.g. `--max-batch 4 cannot be honored: the native decode backend
advances exactly one sequence per step…`). No silent acceptance.
- **Flag omitted → the server default width is clamped to the decode
path's limit** (silently), and the clamp is made **visible** in
`/v1/resources` (`effective_max_batch`, `supported`).

Rationale for not refusing in the omitted case: the server default
(`DEFAULT_MAX_BATCH = 4`) applies to *every* deployment. Refusing on the
default would break every existing native / legacy server on startup,
which is out of scope for a "report the truth" increment. Refusal is
reserved for the case where an operator made an explicit, unhonorable
request — there the silent no-op is genuinely misleading. Either way the
effective width is never left invisible.

## Tests (CPU-only, no GPU required)

- **engine integration** (`tests/batching_capability.rs`): static-cache
capability matches a *successful* `continuous_batch_manager`; plain
past/present capability matches single-sequence decode (manager errors).
Ties the reported capability to what the decode path can actually do,
for both paths.
- **engine native unit test**: a native engine reports `Some(1)` even
with a StaticCache I/O shape (proving capability is a property of the
decode path, not the model), and cannot build a `>1` continuous batch
manager.
- **server**: explicit `--max-batch 4` is **refused** on a non-batching
backend (not silently accepted); the default width is clamped and
`/v1/resources` reports the honest capability; static-cache reports
`supported=true` (positive control). Deleting the capability plumbing
breaks these — no vacuous assertions.

## Validation (local; CI not awaited)

- `cargo fmt --all -- --check` — clean
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend -- -D warnings` — clean
- `cargo clippy --locked --all-targets -p onnx-genai-server -- -D
warnings` — clean
- Targeted engine + server tests pass. (One pre-existing failure,
`registry::tests::failed_runtime_shrink_preserves_policy_and_ledger_limit`,
also fails on the base commit `675b697b` in this CPU-only environment
and is unrelated to this change; the other registry tests pass
single-threaded — the multi-threaded flakiness is pre-existing
global-VRAM-ledger state, not touched here.)

## Scope guardrails

No existing admission/refusal behavior is weakened or changed (#743
pre-header 429, #745 committed-granule admission, #748 growth grants).
This increment is purely about reporting the truth. Batched native
decode is **not** implemented here.

Refs #750. Does not close the issue.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 11, 2026
## Govern the `GroupQueryAttention` `WS_SCORES` workspace (#736)

Next increment of the CUDA allocation-governance program (#736), chosen
from the audit table in `docs/MEMORY_ARCHITECTURE.md` §2.1, which named
this slice explicitly. Routes the `GroupQueryAttention` f32
reference-attention score buffer (`WS_SCORES`) — quadratic in `sq *
present_capacity` and the largest remaining live GQA scratch — through
the merged workspace-governance contracts instead of the raw
`self.runtime.alloc_raw` pooled bypass.

Closes #736 — no, **does not** close: this is one increment; the issue
stays open.

### What changed

- **`crates/onnx-runtime-ep-cuda/src/kernels/group_query_attention.rs`**
- `Kernel::workspace_requirement` reports the exact governed bytes;
`execute_with_workspace` consumes the executor-prepared slot; the
compatibility `execute` path keeps its self-owned pooled scratch.
- Planning and execution size the reservation through the **same**
`gqa_reference_scores_bytes` helper, so the reserved and consumed byte
counts cannot drift. A shortfall in the prepared buffer **errors
deterministically** rather than silently under-allocating.
- New free helpers: `gqa_reference_scores_path(dtype, q_seq, head_dim)`
(the static reachability predicate) and
`gqa_reference_scores_bytes(batch, heads, q_seq, kv_capacity)` (the
shared, overflow-checked byte formula `rows * kv * sizeof(f32)`,
`.max(1)`).
- CPU-only source-scan **regression guard** (`raw_allocation_guard`)
that fails if the governed slot reintroduces an unconditional raw pooled
allocation — matches the #751 bar (a unit test that runs on CI without a
GPU, not a `Select-String` external scan). Needle assembled at runtime
so the literal does not self-match.
- **`crates/onnx-runtime-session/src/executor/bindings.rs`**
- Extended the centralised `is_planned_workspace_node` predicate to
cover `com.microsoft::GroupQueryAttention` alongside
`BlockQuantizedMoE`, `IndexShare`, and `Attention`. No parallel
predicate added.
- **`docs/MEMORY_ARCHITECTURE.md` §2.1**
- Moved the GQA `WS_SCORES` slot into the governed-today rows, recorded
derived bytes, documented the lifetime justification and the
over-reservation finding, and reselected the next slice from evidence.

### Measured / derived bytes

`gqa_reference_scores_bytes = (batch · num_heads · q_seq ·
present_capacity).max(1) · sizeof(f32)` — identical to the old
`score_count.max(1) * 4`.

- Reference path, `B=1, H=32, Sq=1024, present_capacity=1024, f32` ≈
**128 MiB**.
- fp16/bf16 decode & prefill, and **all** f32 single-token decode: **0
bytes** (reported `WorkspaceRequirement::NONE`).

### Lifetime justification — `SessionPersistent` (not `StepScoped`)

GQA's slot allocator grows each slot to the largest geometry seen and
**retains** it across decode/prefill steps — a standing claim held for
the whole session. So `WS_SCORES` is charged as
`WorkspaceLifetime::SessionPersistent` + `MemoryRole::Workspace {
step_scoped: false }`. This deliberately differs from the Attention
Phase-2a scratch landed in #753, which was per-call `StepScoped`.
Because `sq * present_capacity` is prompt-dependent, the executor grows
the slot transactionally via `MappedGrowthGrant` (#748) against the
device authority, and capacity refusal surfaces as the pre-header
**429** typed path (`TierExhausted` / `CapacityUnavailable`, #743)
rather than a late 500/OOM.

A graph mixing QMoE, IndexShare, Attention Phase-2a (step-scoped) and
GQA (session-persistent) now exercises **both** per-lifetime-class
peaks; the classes reserve into separate executor slots so neither peak
stands in for the other.

### Over-reservation finding (the #751 defect class)

**There is over-reservation to avoid, and this PR avoids it.**
`WS_SCORES` is materialized on **exactly one** GQA route: the f32
*reference* path, reachable only when `query dtype == f32 &&
!gqa_decode::supported(q_seq, head_dim)` (i.e. f32 prefill `Sq > 1`, or
f32 `head_dim > 128`). The fused-flash path, the capture-safe f32/fp16
split-K decode kernels, and the phase-2a path never allocate a device
score matrix — they stream softmax through registers/shared memory
(phase-2a owns its own scratch). Reserving `WS_SCORES` unconditionally
would charge the device authority for bytes the common fp16 decode path
never touches — exactly the defect #751 found in IndexShare's
present-K/V staging. `workspace_requirement` threads that static signal
(query dtype + `gqa_decode::supported`) through the shared helper and
reports **NONE** on every fp16/bf16 path and every f32 *decode* path, so
they charge zero.

### cuBLASLt GEMM workspaces

The audit named three shared 32 MiB cuBLASLt GEMM workspaces
(`fused_gemm.rs`, `gemm.rs`, `matmul.rs`) as a cheap follow-up. They do
**not** fall out of this change — they are separate step-scoped cuBLASLt
workspaces, not GQA slots — and are left for a dedicated slice, as the
task permitted.

### Validation (all local, green)

- `cargo fmt -p onnx-runtime-ep-cuda -p onnx-runtime-session -- --check`
— clean.
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
— 0 warnings.
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend -- -D warnings` — 0 warnings.
- `cargo test -p onnx-runtime-session` — **green** (mandatory 151+
suite).
- `cargo test -p onnx-runtime-ep-cuda --features cuda --lib` — 318
passed (incl. the 4 new GQA governance tests).
- GPU suites single-threaded (`--features cuda,gpu-tests --
--test-threads=1`): `group_query_attention_gpu` **25 passed** (incl.
`gqa_gpu_fixed_decode_capture_replays_bit_identically`, which passed
here — the #626 flake only appears under parallel capture contention),
`qmoe_gpu` 29, `attention_gpu` 5, `allocation_counts_gpu` 2,
`gqa_seqmajor_parity_gpu` 1.

### Next slice (recorded in the doc, chosen from evidence)

`standard_attention.rs:602` (default-domain `Attention` pooled scores
slot) — same quadratic shape and session-persistent retention — through
the same contract with the same over-reservation discipline, then fold
in the legacy `attention.rs:711` per-call path.

### Notes

- Edits confined to workspace/allocation concerns; KV indexing
untouched, to merge cleanly with the concurrent flash/prefill
stride-descriptor work in this crate.
- No `assert!` in any `Drop` (STATUS_STACK_BUFFER_OVERRUN on this
platform).
- Do **not** merge and do **not** close #736.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 12, 2026
…#755) (#798)

Closes #755.

Makes the authority-scoped **managed no-spill VMM** the **default** on
the native CUDA path — no flag required — with **automatic weight
streaming** when a model exceeds the resolved device budget. Being
larger than the budget becomes a supported configuration rather than an
error.

Builds directly on the prerequisite #716 (PR #796), which made weight
offload capture-compatible by paging weights under a stable virtual
address, so flipping the default no longer disables CUDA graph capture
for offloaded models.

### What "default" now means
1. **Managed VMM is on without a flag.** Native CUDA loads run
memory-strategy inference (#752) unconditionally and it *drives* policy:
allocation goes through the physical-handle pool (#740) with
committed-granule admission (#745), governed workspace planning
(#747/#753/#795), transactional growth grants (#748), against one
server-owned authority per device (#744).
2. **Over-budget is not an error.** When the resolved budget cannot hold
the weights, the runtime auto-enables weight streaming/offload instead
of failing.
3. **`--vram-limit` is an override**, not the trigger.
4. **The legacy allocator stays reachable** for one release via
`ONNX_GENAI_LEGACY_ALLOCATOR=1` (back-compat:
`ONNX_GENAI_DYNAMIC_KV_WEIGHT_LENDING=0`).

### Observability
The resolved budget, chosen strategy and offload state are printed at
startup and exposed in `/v1/resources` under a new `memory_strategy`
block, so the new default is observable rather than mysterious.

### Invariants preserved
- **No silent WDDM spill** — committed physical bytes stay within the
managed cap; `cuMemCreate` structurally cannot spill (#704).
- **Capacity refusal stays a pre-header HTTP 429** (#743);
committed-granule admission (#745) and growth grants (#748) unchanged.
- **A model that fits does not start paging** under the new default —
tested explicitly (the most likely regression).
- **Capture stays ON when offload is active on the stable-VA path**
(#796 property), pinned by the existing unit tests.
- No prefetch re-introduced (#715); no assert inside any `Drop`.

### Measurement (native CUDA, deterministic counters lead)
Ignored live e2e `qwen2_5_0_5b_managed_vmm_default_e2e` (Qwen2.5-0.5B
int4 mobius, **same process**, interleaved managed-default vs. legacy
opt-out):

| scenario | strategy | offload | managed_no_spill | resolved budget |
committed device bytes | page-ins / evictions |
|---|---|---|---|---|---|---|
| **A** fits, managed default (no flag) | `FullResident` | false | true
| 7,730,940,928 | 381,681,664 | **0 / 0** (does not page) |
| **B** synthetic over-budget (`--vram-limit 268,435,456`) |
`DynamicWeightResidency` | true | true | 268,435,456 | 90,177,536 («
cap; no spill) | **434 / 432** |
| **C** legacy opt-out (`ONNX_GENAI_LEGACY_ALLOCATOR=1`) |
`FullResident` | — | **false** | — | — | — |
| **D** explicit `--vram-limit 12 GiB` | override applied | — | — |
12,884,901,888 | — | — |

In **B**, at that extreme synthetic cap the residency arithmetic
**refused cleanly** ("no page is evictable") rather than spilling — the
no-spill contract holds.

**Caveats (measurement discipline):**
- Wall-clock / tok/s is **not** reported: this box ranged 3.9–28 tok/s
across identical runs and other agents build concurrently. Deterministic
counters only.
- A genuine native large-model run that exceeds VRAM was **not
measured**: `qwen14b-zp` lacks `inference_metadata.yaml` (#384) so it is
not native-loadable. The over-budget condition was **synthesized** with
a small explicit budget, as stated.
- **CUDA-graph segment count** is not a public counter;
capture-ON-under-offload on the stable-VA path is pinned by the #796
unit tests, not re-measured here.

### Validation
- `cargo fmt -p onnx-genai-engine -p onnx-genai-server -- --check` clean
(`--all-targets --all` fmt hits Windows os error 206 on this box's long
worktree paths — a command-line-length artifact, not a formatting
issue).
- clippy `-D warnings`: engine (`native-backend`; also
`cuda,native-backend`), server, `onnx-runtime-ep-cuda --features cuda` —
all clean.
- Full `cargo test -p onnx-runtime-session` passes.
- Engine `memory_strategy` unit tests (12, incl. 4 new) and server
resources tests pass; cuda-gated engine tests incl. #796 capture arms
pass.
- **Pre-existing unrelated failure:**
`failed_runtime_shrink_preserves_policy_and_ledger_limit` (server) fails
identically on `origin/main` (confirmed via stash); its error is on the
`host` authority, untouched by this native-CUDA-only change. Not fixed
here (out of scope).

Do **not** merge and do **not** close the issue via this PR beyond the
`Closes #755` reference; leaving final disposition to review.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 12, 2026
References #736. Do not merge; do not close the issue.

## Summary

Governs the default-domain `Attention` (`StandardAttentionKernel`) f32
score matrix in the CUDA EP, routing its device scratch through the
merged `Kernel::workspace_requirement` + `execute_with_workspace`
contracts with prepare-only planning (#747), so it is reserved against
the memory authority and a capacity shortfall surfaces as the pre-header
**429** typed path (`TierExhausted` / `CapacityUnavailable`, #743) via
`MappedGrowthGrant` (#748) rather than a late 500/OOM.

## Measured bytes

`batch * q_heads * q_seq * total_seq * sizeof(f32)` — quadratic in
sequence length. At `B=1, H=32, q_seq=total_seq=2048` = **512 MiB**
(536,870,912 bytes), larger than the already-governed Phase-2a range
(#753). The buffer is always fp32 (fp32 accumulate) regardless of Q/K/V
operand dtype.

## Which routes materialize the matrix (negative result)

**Every route.** Unlike GQA's f32-reference-only `WS_SCORES` (#795) or
the fused Attention prefill path that streams softmax through shared
memory (#753), this kernel's `attention_row` has **no**
flash/shared-memory route: it always stages the score matrix in global
device memory (writes `scores[srow+j]`, re-reads for
softcap/mask/softmax/`probs·V`). So every valid dispatch reserves it and
**none reports `WorkspaceRequirement::NONE`** — only unresolvable or
non-float input metadata reports `NONE`. This is a genuine-use result,
recorded in the audit so the next reader does not re-investigate
expecting a zero-charge route.

## Lifetime split

Modelled truthfully as **both** classes:
- **StepScoped** — per-call prefill/batched route (freed on each exit);
this is the 512-MiB-class quadratic worst case.
- **SessionPersistent** — the capture-eligible single-token decode route
(`batch == 1 && q_seq == 1`), pooled/retained in the kernel workspace
(small: linear in `total_seq`, ~256 KiB at the example shape).

The classifier keys the split on static input metadata (`batch`,
`q_seq`) as a conservative proxy for capture eligibility. Prepare-only
planning reserves each class into its own executor slot (#753). The
frozen fixed-capacity decode path over-estimates `total_seq` by padding
rows, which is safe (reserve >= consume) — execution re-derives the
exact size through the **same** `std_attention_scores_bytes` helper and
refuses deterministically on any shortfall.

## Over-reservation found

None. Planning and execution size through the same helper. The only
sibling bypass — the staged-K/V + capture-metadata slots
(`standard_attention.rs:702,1396`) — is a genuinely different
lifetime/geometry, did **not** fall out of this change, and is left raw
(tracked as the next slice).

## Scope / safety

- Extends the central `is_planned_workspace_node` predicate
(default-domain `"Attention"`); **no** parallel predicate.
- Preserves the compatibility/opt-out direct-`execute()` path with
self-owned scratch (pooled on the capture-eligible route, per-call
otherwise) — unit/GPU allocation-count tests unaffected.
- Released on success, error, and cancellation with truthful role/holder
attribution. No assert inside any `Drop`.
- Adds CPU-only governance unit tests + a source-scan regression guard
(runtime-assembled needle) that fails on CI without a GPU if a raw score
allocation is reintroduced on the governed site.

## Updated audit table

`docs/MEMORY_ARCHITECTURE.md` §2.1: the default-domain `Attention`
scores move into the governed rows (both the summary "Governed today"
table and the site-by-site audit), with the route/lifetime split and the
no-flash-route/no-`NONE` note verified against `attention_row`. The next
slice is named from evidence: **`GroupQueryAttention` pooled
QKV-projection staging** (`group_query_attention.rs:1033`, `batch * seq
* hidden * elem`, session-persistent), now the largest remaining
evidenced non-QMoE workspace bypass after the three quadratic score
matrices are governed; present K/V there is KV-cache residency (govern
via the KV residency contract, #791), not this table.

## Validation (local; did not wait for CI)

- `cargo fmt -p onnx-runtime-ep-cuda -p onnx-runtime-session -- --check`
— clean
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
— clean (exact CI gate, no `--all-targets`)
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend -- -D warnings` — clean
- `cargo test -p onnx-runtime-session` — green
- GPU suites single-threaded (`--features cuda,gpu-tests`):
`standard_attention_gpu` (25), `attention_gpu`,
`group_query_attention_gpu` (25), `qmoe_gpu`, `allocation_counts_gpu` —
all green

Working as the workspace/allocation slice; edits kept to
workspace/allocation concerns to merge cleanly with concurrent KV
shape/stride work in `native_decode/`.

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 12, 2026
Governs the `GroupQueryAttention` packed QKV-projection staging buffers
— the largest remaining evidenced non-QMoE workspace bypass after the
three quadratic score matrices (Phase-2a #753, GQA `WS_SCORES` #795,
default-domain `Attention` #736). References #736. **Do not merge; do
not close the issue.**

## What was ungoverned

`crates/onnx-runtime-ep-cuda/src/kernels/group_query_attention.rs` — the
pooled slot allocator (`bytes.max(1)` per slot) raw-allocated the packed
Q/K/V projection-staging buffers (`WS_PACKED_Q/K/V`). The unfused prep
path splits an interleaved `[B, S, (H + 2·Hkv)·D]` query tensor into
three device scratch buffers before the BSH→BNSH transpose and cache
append. Those bytes were invisible to the device authority.

## Measured / derived bytes

Formula: `align256(B·sq·num_heads·head_dim·elem) +
2·align256(B·sq·kv_num_heads·head_dim·elem)`, `elem = dtype.byte_size()`
(f32=4, f16/bf16=2).

At `B=1, num_heads=32, head_dim=128` (`q_hidden=4096`), `kv_num_heads=8`
(`k_hidden=1024`), `sq=1024`:
- **f32 packed prefill:** 16 MiB (Q) + 2·4 MiB (K,V) = **24 MiB**
staging, plus the ~128 MiB f32 reference score region (#795) when that
route is taken.
- **fp16/bf16 packed prefill:** **12 MiB** staging, **0** scores.
- **unpacked-input and fused-decode routes:** **0** staging.

## Which routes populate staging vs report `NONE`

Staging is materialized on **exactly one** family of routes: a *packed*
QKV tensor split on the unfused prep path (`packed_qkv && !fuse_prep`).

- **Unpacked inputs** (Q/K/V arrive as three separate tensors) never
split → staging regions are zero-length → charge **0**. This is the
primary "size from use" finding (#751 class): the pre-#736 pool grew
`WS_PACKED_*` on any path reaching `reserve`, but the separate-input
route writes Q/K/V straight through.
- **fp16/bf16 fused decode** (`q_seq==1` with an aliased device KV
cache) fuses prep into the decode kernel and never splits.
- The requirement returns `WorkspaceRequirement::NONE` when neither
staging nor f32 reference scores are needed.

## Over-reservation found

Yes (this is **not** a #736-default-Attention-style
genuine-use-everywhere result). Two routes charge zero. One residual
conservative reservation: `fuse_prep` aliases the present cache via a
**runtime pointer comparison** not visible at planning, so planning
reserves staging for every packed dispatch; on fused decode the reserved
region is tiny (`q_seq==1`) and left untouched rather than corrupting.
Every packed *prefill* genuinely splits, so this is a
fallback-of-a-fallback idle.

## Lifetime model + justification

**Session-persistent, single class** — verified against the
`GqaWorkspace` slot allocator (`group_query_attention.rs:1191`), which
grows each slot to the largest geometry seen and **retains** it across
decode/prefill steps. Unlike the default-domain `Attention` scores
(route-dependent lifetime needing both classes modelled), this lifetime
is not route-dependent, so one class models the truth. The staging
shares the same session-persistent composite reservation as the #795
`WS_SCORES` region (the executor reserves one slot per lifetime class
and hands the kernel one view; the f32 reference-prefill route needs
both live at once → disjoint regions of one buffer). Staging regions
come first (shape-derived offsets, identical in planning and execution);
the score region is last and unpadded so its capacity-dependent size
extends into the reserved peak without perturbing staging offsets, and
the composite total equals exactly the score bytes when no staging
exists (preserves #795 accounting).

## Contract wiring

- `Kernel::workspace_requirement` + prepare-only planning (#747);
`execute_with_workspace` consumes the reserved slot.
- Planning and execution size through the **same**
`gqa_workspace_layout` / `gqa_packed_staging_bytes` /
`gqa_reference_scores_bytes` helpers; `gqa_carve` refuses a short
prepared view **deterministically** rather than under-allocating.
- Growth via `MappedGrowthGrant` (#748); capacity refusal surfaces as
the pre-header **429** typed path (`TierExhausted` /
`CapacityUnavailable`, #743) — never a late 500/OOM.
- Central `is_planned_workspace_node` predicate already covers GQA —
**no parallel predicate added** (doc comment updated only).
- Compatibility/opt-out `execute()` path keeps its self-owned pooled
scratch.
- No asserts in any `Drop`.
- CPU-only source-scan regression test
(`gqa_packed_qkv_staging_is_governed_not_raw_allocated`) +
layout/requirement unit tests guard the governed site on CI without a
GPU.

## Updated audit table

`docs/MEMORY_ARCHITECTURE.md` §2.1 and the site-by-site audit: QKV
staging moved to governed-today rows with the byte formula +
route/lifetime split; the `WS_SCORES`/`:1033` line references corrected
to current code (`:1191`/`:1203`, scores carved at `:2662`); the next
slice named from evidence — **GQA BNSH transpose scratch** (`WS_Q_BNSH`
`:2127` always-reserved, `WS_OUT_BNSH` `:2131` for `q_seq>1`), the
always-used sibling (expect a genuine-use prefill result with a possible
decode `NONE`). Present K/V remains a KV-residency concern, not
workspace.

## Validation (local; CI not waited on)

- `cargo fmt -p onnx-runtime-ep-cuda -p onnx-runtime-session -- --check`
✅
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
✅ (no `--all-targets`, exact CI gate)
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend -- -D warnings` ✅
- Full `cargo test -p onnx-runtime-session` ✅
- GPU (single-threaded): `group_query_attention_gpu` (25),
`gqa_seqmajor_parity_gpu`, `gqa_fp16_gpu`, `standard_attention_gpu`
(24), `attention_gpu` (5), `qmoe_gpu` (29), `allocation_counts_gpu` (2),
plus new lib unit tests ✅

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

Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com>
justinchuby added a commit that referenced this pull request Aug 12, 2026
…r); floor is granule-bound on qwen0.5b (#797 #801 #812 #811 #787 #777) (#827)

## Summary

Realises the `layers × 2` KV floor **through the engine** by wiring the
seq‑major fixed‑stride KV commit path through the shared
`kv_commit::live_prefix_ranges` geometry, the last increment in the
chain named by #797, #801 and #812.
`DecodeCudaState::seq_major_kv_commit_requests` now *consumes* the same
`kv_commit.rs` geometry the driver‑level residency test
(`vmm_kv_layout_residency_gpu`) and the `kv_commit.rs` unit tests
exercise, instead of duplicating the byte arithmetic inline — so the
live commit geometry and the measured floor are single‑sourced and
cannot drift (previously `kv_commit.rs` carried a
`#![cfg_attr(not(test), allow(dead_code))]` because nothing in a
non‑test build called it).

Head‑major stays **byte‑identical**: on a growing packed bucket its
per‑head stride *is* the bucket, so the `kv_heads` live‑prefix fragments
tile the same contiguous `[0, bucket_bytes)` run — head‑major's dense
ranges equal its bucket ranges. `vmm_growth_requests` is left as the
flat bucket commit, now with a doc comment saying exactly that.

Refs #797 #801 #812 #811 #787 #777.

## Headline: committed‑bytes comparison

`physical_committed_bytes`, process‑isolated,
`mobius_seqmajor_growth_parity_native_cuda`, qwen2.5‑0.5b:

| Config (vmm‑inplace) | head‑major | seq‑major |
|---|---:|---:|
| capture ON, forced growth | 100,663,296 | 100,663,296 |
| capture OFF, forced growth | 100,663,296 | 100,663,296 |
| no‑growth control (`min_bucket=64`) | 100,663,296 | 100,663,296 |
| realloc, forced growth | 786,432 | 786,432 |

**The floor does not move on this model, and that is the correct result,
not an un‑wired commit.** At `head_dim=64`/fp16 with a 512‑token hard
max, a single binding's *entire reservation* is `512 × 2 × 64 × 2 =
131,072 B = 128 KiB`, far below the 2 MiB CUDA granule. So **both**
layouts pin at exactly one granule per binding: `48 bindings × 2 MiB =
100,663,296 B`, which *is* the `layers × 2 = 48`‑granule floor.
Seq‑major already sits on that floor; head‑major coincides because its
64‑token bucket is also sub‑granule.

The `kv_heads×` separation (`vmm_kv_layout_residency_gpu`, qwen14b,
32,768‑token stride) only materialises once a **single head's live
stripe crosses a granule** — `capacity × head_dim × elem ≥ 2 MiB`, i.e.
≈8,192 tokens at head_dim 128/fp16 (the measured head‑major crossover,
#776). A 512‑max, 48‑token qwen0.5b run never approaches it, so #797's
driver‑level `kv_heads×` win is **not reproducible end‑to‑end on this
harness** — its geometry never leaves a single granule. This is the same
class of precise negative result as #794 (identical bytes) and #812,
stated with the same precision.

## Full per‑config counters (measured)

| Config | growth_events | kv_bytes_moved | captures | replays |
invalidations | growth_keeps |
|---|---:|---:|---:|---:|---:|---:|
| head vmm ON, growth | 3 | 688,576 | 4 | 39 | 4 | 0 |
| seq  vmm ON, growth | 3 | **0** | 1 | 45 | 1 | **3** |
| head vmm ON, no‑growth control | 0 | 0 | 1 | 45 | 1 | 0 |
| seq  vmm ON, no‑growth control | 0 | 0 | 1 | 45 | 1 | 0 |
| head vmm OFF, growth | 3 | 688,576 | 0 | 0 | 4 | 0 |
| seq  vmm OFF, growth | 3 | 0 | 0 | 0 | 1 | 3 |
| head realloc OFF, growth | 3 | 688,576 | 0 | 0 | 4 | 0 |
| seq  realloc OFF, growth | 3 | 688,576 | 0 | 0 | 4 | 0 |

- **Token IDs byte‑identical** across every configuration (`" Paris. It
is the largest city…"`, 48 tokens).
- Seq‑major keeps its captured graph across all 3 growths
(`growth_keeps=3`, forced‑growth graph accounting `1/45/1` collapses
onto the no‑growth control) — **the #811 keep decision is preserved**;
the dense‑range commit changes neither device pointers nor physical
shapes, so the four keep dependencies still hold.
- Head‑major still moves 688,576 B and re‑captures per growth (`4/39/4`,
`growth_keeps=0`) — unchanged.

Wall‑clock intentionally omitted (deterministic counters answer the
question; this box's identical runs vary 3.9–28 tok/s).

## Invariants held

- Idempotent re‑commit; governor bills only newly mapped granules (via
`seq_major_kv_commit_requests` →
`commit_binding_ranges_with_mapped_growth`).
- #748 `MappedGrowthGrant` growth path, #745 committed‑granule
admission, #814 governor claim ordering, pre‑header 429 refusal (#743) —
all unchanged.
- No map/grow inside a captured region; no unmap under in‑flight replay;
**no assert inside any `Drop`**.

## What this unblocks / does not

- The dense‑prefix commit is now floor‑faithful and single‑sourced, so
the `layers × 2` geometry is realised end‑to‑end **wherever a per‑head
stripe exceeds a granule** (large `head_dim`/context). Demonstrating the
`kv_heads×` win end‑to‑end needs such a model, **not** a change to this
commit path.
- It does **not** address the seq‑major seed/host‑mirror stride
arithmetic (`read_present_kv` / `seed_prefix`), which still refuse under
seq‑major (#812). Prefix sharing under seq‑major therefore still needs
the BSNH seed as a separate follow‑on; the doc bullet is corrected to
say so.

## Validation (all green, on an RTX 4060 Laptop, ORT 1.27, CUDA 13
wheels)

- `cargo fmt -p onnx-genai-engine -- --check` ✔ (`--all-targets`
full‑workspace fmt hits Windows os error 206, a command‑length artifact,
not a formatting problem)
- `cargo clippy --locked --all-targets -p onnx-genai-engine --features
native-backend -- -D warnings` ✔
- `cargo clippy -p onnx-runtime-ep-cuda --features cuda -- -D warnings`
✔
- `cargo test -p onnx-runtime-session` ✔ (277 passed)
- `cargo test -p onnx-genai-engine --features native-backend --lib` ✔
(454 passed; `kv_commit` 9/9)
- **Mandatory** `mobius_seqmajor_growth_parity_native_cuda --ignored
--test-threads=1` ✔ (2 passed, incl. the head‑major negative oracle)
- `gqa_seqmajor_parity_gpu` ✔ (2), `gqa_shared_prefix_parity_gpu` ✔ (1),
`group_query_attention_gpu` ✔ (26), `vmm_kv_contiguous_tail_gpu` ✔ (3),
`vmm_kv_layout_residency_gpu` ✔ (2), `vmm_kv_token_major_floor_gpu` ✔
(2) — all `--ignored --test-threads=1`.

Do not merge / do not close any issue.

Co-authored-by: justinchuby <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.

2 participants