Repository navigation
nxmem: bind the Phase 6 fixes to behaviour, not to their own counters - #1454
justinchuby wants to merge 1 commit into
Conversation
PR #1448's fixes were correct but five mutations against them survived the suite, because each was verified through a counter the fix increments rather than through the thing the fix does. Deleting a counter turned tests red; deleting the behaviour and keeping the counter did not. No production logic changes. The one production addition is observability: `PluginModule` gains a `Drop` that counts modules reaching `dlclose`, exposed as `PluginModule::modules_unmapped`. Dropping the `library` field *is* the `dlclose`, so this observes the unmap rather than restating the decision to leak — a leaked strong reference shows up as this count not moving. Everything else is test-side: - A1 `Drop for MemoryPlugin` — `modules_unmapped` deltas: 0 across the forced leak paths (measured across `drop(allocator)`, or the still-live Arc masks it), 1 across a clean unload. - A2 callback-table pinning — a `callback-after-drop` mechanism parks the host callback table and reports a completion from a real `std::thread` after the allocator is gone. Bound deterministically by `Arc::strong_count` on the module, since `mem::forget(context)` leaks the bridge's module reference. - B multi-pass drain — a `drip` mechanism retiring one release per call (kills the 16 -> 1 pass bound) and 20 queued releases against `lazy` (kills the unbounded-budget mutation, which survives at 3). - C per-allocator counter — two allocators on one module, a release queued on one only. - D count-before-call — a `reentrant-completion` mechanism plus an observer that reads the counter from inside `enqueue_release`. `fetch_sub` wraps, so the final value is unchanged; only a mid-call read distinguishes them. - E `live_views` — delta assertions over commit, refusal, and retirement. - F `find_cc` prefers `cl` on Windows targets, so an MSVC runner with LLVM on PATH no longer silently checks clang. Also closes four gaps found by mutating code this change did not write: the degraded unload report's `u64::MAX`, the `offset_of!(Self, allocate)` floor in `read_prefix_unvalidated`, the drop-time drain of leaked allocations, and the `enqueue_release` failure rollback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## justinchuby-phase-5-process-memory-manager #1454 +/- ##
==============================================================================
- Coverage 80.01% 79.48% -0.53%
==============================================================================
Files 366 375 +9
Lines 164950 168967 +4017
Branches 164950 168967 +4017
==============================================================================
+ Hits 131980 134299 +2319
- Misses 28150 29807 +1657
- Partials 4820 4861 +41
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
🔴 Benchmark Regression DetectedComparison 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
|
> **A100 safety update (head `13c37a7f`):** expected shared-prefix admission failures are now all-or-private: K/V commit is transactional, a failed V share rolls K back before enqueue, and only a successful rollback permits private-KV fallback. Fatal kernel faults remain process-fatal; the new cross-process VMM probe proves a worker fault does not affect an actively executing peer, while the current server is still in-process and does not yet consume physical shared-prefix metadata. See the [final verified conclusion](#1579 (comment)). Collapses phases 1–7 of the #1186 memory architecture rework onto current `main` as a single merge. The stack forked 270 commits ago (176 of them touching `crates/`), so rebasing it layer by layer would mean solving the same 17 conflicts fourteen times, with no reviewer ever looking at the intermediate states. Closes the stack: #1252 #1263 #1279 #1283 #1301 #1341 #1349 #1426 #1440 #1448 #1454 #1462 #1465 #1468 #1533. **A review guide is in the first comment.** It is the part worth reading — this diff is 103 files, but only three decisions in it are ones a compiler cannot check. ## What was already reviewed, and what wasn't Every phase was reviewed and approved by a session that was not its author, under the rejection-lockout rule (a rejected author never writes the next revision). | Phase | PR | Verdict | |---|---|---| | 1–5 | #1252 → #1426 | approved | | 6 | #1440, #1448, #1454 | **rejected** ×3 | | 6 | #1462 | approved | | 7 | #1465 | **rejected** | | 7 | #1468 | approved | | 7 test fixes | #1533 | approved, A100-verified | The four rejected rounds were not replaced — the later rounds are stacked **on top of** them. So the tree here is the approved state, but the history contains the rejected commits. **#1440 / #1448 / #1454 / #1465 should not be reviewed individually**; they close automatically. **Not reviewed anywhere:** the conflict resolutions themselves. That is what this PR is for. ## Verification Run here, on this merged tree: - `cargo check --workspace --all-targets` — clean - `cargo check -p onnx-genai-engine --features cuda,native-backend --all-targets` — clean. Worth calling out: the default feature set does **not** compile the `cfg(cuda)` code, which is where the riskiest edits are. Checking only the default set would have missed a real break (see the guide). - `cargo test` over 7 crates — **1105 passed, 2 failed, 88 ignored** - `cargo clippy --workspace --all-targets` - `cargo fmt` **The 2 failures and the 1 clippy error are pre-existing and proven so, not merge damage:** - `platform_capacity::{disk_capacity_is_measured_for_the_working_directory, an_explicit_byte_limit_is_honored_without_a_device_query}` — `platform_capacity.rs` is byte-identical to `main` (`git diff origin/main HEAD -- ` that file is empty). The cause is a macOS-only FFI layout bug: `fsblkcnt_t` is 4 bytes on macOS (confirmed: `sizeof(struct statvfs)=64`, `sizeof(fsblkcnt_t)=4`) while the Rust struct declares `f_blocks`/`f_bfree`/`f_bavail` as `u64`. CI is Linux, where it is 8 bytes. Left alone: it is a real bug but not this PR's. - `optimizer.rs` clippy `approx_constant` — present on both parents; the merged file is byte-identical to `main`. - Three files had `rustfmt` drift already present on `main` (`matmul_nbits.rs`, `normalization.rs`, `optimizer.rs`). Reverted rather than swept in, to keep the diff readable. **Final A100 revalidation (head `13c37a7f`):** full CUDA-memory GPU suite passed; CUDA EP default-parallel lib suite passed (**488 passed / 17 ignored**); real fp16 GQA covered three requests × two interleaved decode steps with two shared peers and one transactional private fallback, byte-identical to independent GPU and CPU references. A device-started/event-gated worker remained healthy through a peer process `CUDA_ERROR_ILLEGAL_ADDRESS`, owner exit, replacement worker, and process restart. Governor tests, targeted Miri, CI Clippy, fmt, and CUDA honesty also passed. ## Still open after this merges - `memory-plugin-provider-wiring` — the back half of Phase 6 criterion 8 (provider/context pinning), which fell in the gap between phases. **#1186 must not be closed until it lands.** This merge makes it more tractable, not less: see decision 3 in the guide. - `memory-deferred-invariant-asserts` — two surviving mutants found by #1462's reviewer, adjudicated NON-BLOCKING and not defects. - #1533's CUDA honesty guard warns on a legitimately portable anchor. Should be moved out of the CUDA test binary or allow-listed rather than left warning. --------- Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <copilot@github.com> Copilot-Session: 46c5d75b-8146-489c-b82f-08ee29c27ce4 Copilot-Session: 39ff6824-d35f-4d3b-8f5f-043a7119a100 Copilot-Session: c80f8522-983c-47f7-8241-2155a823aabe
|
Closing: superseded by #1579, which collapsed phases 1–7 into a single merge — landed as This PR was rejected under the lockout rule and superseded by the approved revision that was stacked on top of it (not by a replacement of it). Per #1579's own guidance, it was never meant to be reviewed individually. Containment verified, not assumed: Every one of the 15 stack heads (#1252 → #1533) is a literal ancestor of the merged head, and I separately confirmed the merged head's content reached Why this didn't close itself: #1579's body says #1186 stays open: #1579 explicitly gates it on |
Part of #1186 — Phase 6 only.
Supersedes #1448 and #1440, both of which stay open as records. Branches from
fb9a72c4(head of #1448), so all of that work is included.The reviewer's position on #1448 was that the production code is correct and that not a line of it needed to change — it was rejected because five mutations against the new code survived the suite. The specific failure was that each of the six fixes was verified through a counter the fix increments, rather than through the behaviour the fix performs. Deleting a counter turned tests red. Deleting the behaviour and keeping the counter did not.
This PR is therefore almost entirely test-side.
The one production change
crates/onnx-runtime-memory-host/src/loader.rs, +30 lines, no logic:impl Drop for PluginModulebumping aMODULES_UNMAPPEDstatic.pub fn PluginModule::modules_unmapped() -> u64.This is deliberately not another leak counter.
PluginModuledeclareslibrarylast andDrop::dropruns before fields, so reaching this drop meansdlcloseis unconditionally about to run — it observes the unmap rather than restating the decision to leak. A leaked strong reference shows up as this count not moving, whatever the platform then does with the mapping. (Confirmed addingDrophere does not re-trigger the E0509 that forced thetry_unloadrestructure.)Findings
A1 (High) —
Drop for MemoryPluginself-retentionMutated: deleted
core::mem::forget(Arc::clone(&self.module)), keptFORCED_MODULE_LEAKS.fetch_add.Was: 101 passed / 0 failed. Now: 108 passed / 3 failed.
dropping_a_plugin_with_live_objects_keeps_the_module_mappedunload_is_refused_when_only_the_plugin_still_owns_somethinga_plugin_that_cannot_report_readiness_is_neither_unloaded_nor_unmappedThe
modules_unmapped()delta must be measured across a region that includesdrop(allocator)— otherwise the allocator's own liveArcmasks the mutation and the test passes for the wrong reason.an_idle_plugin_unloadsasserts the delta is 1, so the mutation is caught in both directions: it cannot be satisfied by never unmapping anything.A2 (High) —
AllocatorCore::dropcallback-table pinningMutated:
core::mem::forget(context)→drop(context), keptLEAKED_CALLBACK_TABLES.Now: 74 passed / 1 failed — the low pass count is itself the evidence: the new end-to-end test takes SIGBUS and destroys the whole 36-test
nxmem_abibinary.This was the finding the original rejection named and neither revision closed.
report_completionhad exactly one caller, reachable only synchronously from a host call, and there was nothread::spawnanywhere in the test plugin — so no test ever had the plugin touch the host callback table afterAllocatorCore::dropreturned. The guarantee atabi/src/lib.rs:49was verified as a property of this plugin, not of the ABI.New
callback-after-dropmechanism + exportedNxmemTestpluginReportParkedCompletionhook: parks theNxmemHostCallbackspointer at open, and on demand reports a completion through it from a realstd::thread(joined before returning, so the test is deterministic).The binding assertion is
Arc::strong_count, not the crash. Opening an allocator takes exactly twoArc<PluginModule>refs — one inAllocatorCore.module, one inHostBridge.module.mem::forget(context)leaks the box and its contents, so the bridge's ref is never released;drop(context)releases it:in
nxmem_abi_callback_pinning.rs. That is safe, deterministic, and green/red without relying on UB. The SIGBUS ina_plugin_thread_reports_a_completion_after_its_allocator_is_goneis reported as corroboration only.Miri does not cover this. Miri cannot execute a
dlopen'd cdylib, so the entire host integration suite is outside it. I am not claiming sanitiser coverage of this path. Miri coverage is-p onnx-runtime-memory-abi --libonly.B (Medium) — multi-pass drain loop
Two separate tests, because one does not kill both mutations.
MAX_DRAIN_PASSES_ON_DROP: 16 → 1dropping_an_allocator_drains_in_as_many_passes_as_the_plugin_needs: "the host must come back once per release the plugin was willing to retire"u64::MAX → 1dropping_an_allocator_offers_an_unbounded_budget_per_pass: "an unbounded budget must let a willing mechanism retire the whole queue in one pass"|| retired == 0progress guarddropping_an_allocator_with_a_queued_release_leaks_its_callback_table: "a pass that retires nothing must end the loop, not restart it"New
dripmechanism retires exactly one release per call however large a budget it is offered, so a queue of n genuinely takes n passes. The budget mutation needs 20 queued releases againstlazy— with 3 it survives, because 3 ≤ 16 passes.C (Medium) — per-allocator
outstanding_releasesMutated: the check in
AllocatorCore::drop→self.module.host_live_counts().queued_releases. Red:a_queued_release_on_one_allocator_does_not_pin_anothers_table— "a release queued against a different allocator must not leak this allocator's callback table".Its own binary (
nxmem_abi_allocator_scoped_pinning.rs), becausestickypoisons process-global module counters.D (Low) — count-before-call ordering
Mutated: moved
outstanding_releases.fetch_addafterenqueue_release(D1), and separately the module-widerelease_queued()(D2). Both red ona_completion_arriving_inside_enqueue_never_sees_an_underflowed_count.The naive version of this test does not work:
AtomicU64::fetch_subwraps rather than panicking even in debug, so a single reentrant completion goes0 → u64::MAX → 0and the final value is identical. The kill requires reading the counter during the window — a newreentrant-completionmechanism reports the completion from insideenqueue_release, and aMidCallObserversnapshots host accounting from itsrequest_reclaim:The observer uses
OnceLock, notMutex— this path re-enters the host from inside a plugin call, and a held host lock would deadlock rather than fail.E (Medium) —
live_viewsMutated: deleted
counters().live_views.fetch_add(1, …). Was: 38 green. Now: 15 red.New
a_committed_shared_prefix_is_reported_as_a_live_viewmirrors thelive_capabilitiescoverage: delta assertions over commit, the refusal path, a view outliving its prefix handle, and retirement at block removal. The plugin-side model and retiring-at-block-removal are unchanged, as directed.F (Low) — MSVC probe order
find_ccnow triesclfirst oncfg!(target_os = "windows")(after$CC), extracted asprobe_msvc(). Without this, a Windows runner with LLVM onPATHsilently checked clang and never MSVC — which defeats the purpose, MSVC being the compiler most likely to disagree with Rust on#[repr(C)]packing.I cannot verify this. This host is macOS/arm64; the changed branch is
cfg-gated to Windows and was never executed here. It is a code-reading change only.Mutations I invented against code I did not write
19 mutations against the #1448 and pre-existing code, especially the F2 pinning and F4
Drop. Four survived and are now closed; one is proven equivalent; one is disclosed as probably equivalent.Drop for MemoryPlugingate ignores the plugin's own reportErr(_) => u64::MAX→=> 0retire_queued_releases_on_drop()entirely== 0→<= 1outstanding_releases.fetch_sub(1)inhost_release_completedfetch_subto the top ofhost_release_completedtry_unload'sArc::try_unwrapalwaysOkoffset_of!(Self, allocate)floor inread_prefix_unvalidatedAllocatorCore::dropskips drainingself.live.min(negotiated_minor)clampAbandonOnDropguardstructured_release_slotdrops its minor guardself.factories.clear()beforedrop(self)intry_unloadinsert_allocationrollback after a failedenqueue_releasevtable.release_allocation = Noneinread_prefix_unvalidatedrelease_allocationregardless of negotiated minorThe four that survived, and what closed them
O2 — the degraded unload report.
try_unload's degraded path usesErr(_) => u64::MAXso a plugin that cannot answer "what do you still own?" is treated as owning everything. Changing that to0— i.e. unloading a module whose state is unknown — was invisible. Closed bynxmem_abi_degraded_report.rs(its own binary; the mechanism poisons a process-global static): a plugin made to failunload_readinessis asserted to be neither unloaded nor unmapped, the second half viamodules_unmapped.O9 — the
allocateoffset floor.read_prefix_unvalidatedrefuses anything shorter thanoffset_of!(Self, allocate)(48 bytes) before consulting the sender'sabi_minor. Every existing short-struct test usedMIN_STRUCT_SIZE_MINOR_0 - 1, which is far above the floor, so the floor itself was never exercised. Newa_vtable_too_short_to_declare_its_own_minor_is_refused_without_reading_it:abi_minor = 1must be refused against the baseline requirement, and the message must not contain"minor 1"— a reader that believed the claimed level would have readabi_minorfrom a struct too small to contain it;struct_size = 4, where readingabi_minoris genuinely out of bounds. Plaincargo testcannot see that; this one is under Miri, which does cover theabicrate's--libtests.O10 — the drop-time drain of leaked allocations. Making
AllocatorCore::dropskip drainingself.livewas invisible, and the obvious assertion doesn't help: the module-wide tally self-heals, because the plugin's ownrelease_statedrains its maps at refcount zero. Assertinglive_allocations == 0is therefore a property of this plugin, not of the ABI — precisely the weakness class that caused the rejection. Closed with aTERMINAL_RELEASEScounter incremented on entry intodeallocate/release_allocation, which separates "the host handed these back" from "the plugin cleaned up after it":O16 — the
enqueue_releasefailure rollback. No mechanism refused an enqueue, so the rollback path was dead code under test. Newrefusing-deferredmechanism refuses before touching any state. The binding assertion is deliberately not a counter: a host that unwound only its tallies would have forgotten the address, so the caller's eventualreleasewould be refused as a stray pointer and the bytes stranded with no way to name them.O13 — proven equivalent, not a coverage gap
structured_release_slot's guardif self.negotiated_minor < 1 || self.vtable.abi_minor < 1 { return None; }can be deleted with the suite fully green, and no test can close it, because it is unreachable:NxmemAllocatorVtablereaching anAllocatorCorecomes fromread_prefixorread_prefix_unvalidated(allocator.rs:1520,:1665;read_prefixdelegates to the unvalidated form).effective_minor = declared_minor.min(negotiated_minor)and thenif effective_minor < 1 { vtable.release_allocation = None; }.vtable.abi_minor <= negotiated_minor, and either disjunct of the host guard implieseffective_minor < 1, which implies the slot is alreadyNone.It is defence in depth, and correct as such. I verified this rather than assuming it, by mutating each layer:
a_vtable_above_the_negotiated_ceiling_is_clamped_not_refused.So the new assertion is genuinely behavioural — it fires exactly when the behaviour breaks — and I have not claimed a kill I do not have.
an_older_host_range_still_drives_the_current_pluginpreviously asserted its central claim only in a comment ("using the minor-0deallocateslot rather than structured release"); it now asserts it, via aSTRUCTURED_RELEASEScounter, that the structured delta is 0 and the terminal delta is 1.O14 — disclosed as probably equivalent
Removing
self.factories.clear()beforedrop(self)intry_unloadsurvives. I believe this is an equivalent mutant rather than a gap:drop(self)runsDrop for MemoryPlugin::drop(gate check, early return, gate open) and then drops fields includingfactories, which is the very next thing anyway. The test plugin does not count factory objects in its unload report, so clearing early is observationally identical for this plugin.I am flagging rather than closing it, because it is not unreachable the way O13 is: it would be a real ordering defect for a plugin that reports factory objects in
live_capabilities, and a test for that would be testing a plugin behaviour this plugin does not have. I would rather disclose it than manufacture a mechanism to paper over it.Test plugin additions
Three new mechanisms —
callback-after-drop,reentrant-completion,drip,refusing-deferred(four;MECHANISM_NAMESgoes 12 → 16, andevery_factory_is_released_exactly_oncenow derives its count fromMECHANISM_NAMES.len()instead of a hardcoded12) — plus exported countersNxmemTestpluginDrainCalls,NxmemTestpluginTerminalReleases,NxmemTestpluginStructuredReleases, and the hooksNxmemTestpluginReportParkedCompletion/NxmemTestpluginRefuseUnloadReport.Two new test binaries (
nxmem_abi_degraded_report.rs,nxmem_abi_allocator_scoped_pinning.rs). The multi-binary split is unchanged in principle and was extended for the same reason it exists: module counters are statics inside the loaded.soand are process-global, so a mechanism that poisons them needs its own process.Validation — macOS/arm64, no CUDA
cargo test -p onnx-runtime-memory-abi -p onnx-runtime-memory-host -p onnx-runtime-memory-testpluginfb9a72c4onnx_runtime_memory_abi(lib)header_contract.rsonnx_runtime_memory_host(lib)nxmem_abi.rsnxmem_abi_allocator_scoped_pinning.rsnxmem_abi_callback_pinning.rsnxmem_abi_degraded_report.rsnxmem_abi_plugin_gate.rsnxmem_abi_unload_gate.rsonnx_runtime_memory_testplugin(lib)cargo clippy -p onnx-runtime-memory-abi -p onnx-runtime-memory-host -p onnx-runtime-memory-testplugin --all-targets -- -D warnings— clean.cargo fmt --check— clean.cargo doc --no-deps— 0 warnings.cargo +nightly miri test -p onnx-runtime-memory-abi --lib— 51 passed / 0 failed / 0 ignored (baseline 50/0/0).Miri scope, stated plainly: Miri cannot execute the
dlopen'd cdylib, so every host integration test — including the A2 use-after-free path — is outside it. The only Miri coverage is theabicrate's own--libtests, which is where the O9 out-of-bounds case lives.Pre-existing failures elsewhere in the repo (
completions.rsrustfmt drift,matmul_nbits_marlin_numerics, Windows CUDAunnecessary_unwrap, macOS MLAS,onnx-runtime-memory-governorrustdoc links) are untouched and unrelated; nothing in these three crates touches CUDA.Scope
Unchanged, as directed: the
read_prefix_unvalidated/read_prefixsplit, theAbandonOnDropguard, thetry_unloadrestructure forced by E0509 and its degraded path, bounded-drain-then-leak as a policy,live_viewsretiring at block removal, theread_prefixclamp design, and the three-way test-binary split.These crates do not depend on
onnx-runtime-memory-governor, so the Phase 1–5 lock graph is untouched by construction. No host lock is held across a call into the plugin — a live concern for the newcallback-after-droppath, which re-enters the host; a held lock there would deadlock rather than fail, which is whyMidCallObserverusesOnceLock. The three accounting axes remain distinct and no quarantine path refunds.Criterion 8's execution-provider/context wiring remains unimplemented. It is deliberately out of scope for Phase 6 and tracked separately;
onnx-runtime-ep-cuda/src/deferred_release.rsis untouched. This half is not done.Not for merge — this stack stays open for human review. Please do not enable auto-merge or enqueue.