Repository navigation
Return overload response for KV admission refusal - #743
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
✅ Benchmarks — No RegressionComparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).
Visual flags: Host infoWhat this cannot catch
|
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>
6b2a5a8 to
4cd720c
Compare
|
Final review passed after four rounds. Contract now enforcedStreaming/non-streaming admission separates three events:
HTTP/SSE headers follow (1)+(2), never wait for (3), and never acknowledge mere insertion into an unbounded queue.
Measured E2EGoverned native CUDA server, qwen2.5-14b:
Review bugs closed
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. |
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>
…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>
…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>
## 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>
…#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>
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>
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>
…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>
Summary First falsifiable consumer increment for #738, using the truthful native KV admission geometry merged in #644. - Preserves the typed
SchedulerAdmissionError::ByteBudgetclassification across the engine-driver boundary. - Maps only that memory-admission refusal to HTTP 429 withRetry-After: 1and stableresource_limit_errorJSON naming the KV memory resource limit. - Leaves unrelated generation failures as HTTP 500;BatchFullis not treated as memory overload. - Reuses the existing request middleware accounting, so each 429 incrementsonnx_genai_rejections_totalexactly 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 (500vs429), 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.onnxabsent 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, responseOK, 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 withRetry-After: 1andresource_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 madestreaming_completion_refusal_is_http_overload_before_ssefail, 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 withRetry-After: 1, exact stable JSON body,rejections_total0 → 1,requests_waiting=0, and/metricsresponding 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 resolveErr(DriverFailure)without starting the backend; accepted requests resolveOk(())before tokens/results. Dropped senders resolve as internal errors rather than hanging. Continuous batching now keeps the handshake pending aftermanager.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_total0 → 1,requests_waiting=0,/metricsresponsive in 131 ms; - server and native-engine-D warningsclippy gates pass.Continuous-row admission correction
After rebasing onto current
main(0f0b6c0e), continuous batching now distinguishes queue insertion, physical row assignment, and token output:ContinuousBatchManageremits handle-specificAssigned/Rejectedadmission notifications.assign_row, before backend prefill/token generation.Final validation:
cargo fmt --all -- --check✅-D warnings✅native-backendand-D warnings✅vision.onnxfixture; its 100 ms FIM timing assertion passed when run without full-suite contention)--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 withRetry-After: 1and the stable body;rejections_total0→16,requests_waitingpeak 0,/metricsmax 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.