Repository navigation
An unmap walks no futex bucket while no thread of its space is in a futex wait - #1223
Conversation
Review of 6b3f796What I read: both commits and the full diff. In full: Architecture
The race argument: it holds
FindingsBLOCKER
SUGGESTION
Evidence
Guest rowsNone. The declared test runs only in the metal What metal must show for the reworked head
SEND BACK |
…cord says where map/unmap's time went `range_heap` still charged each range for the by-start free map this PR deletes, and its comment named two indexes and two removal lists. It now charges a range's entry, its gap's place by length, and its share of the one list a removal gathers, at up to three times its length while that list doubles. The mixed-load test compares the free index with the page map's gaps exactly. #1223's paragraph putting the T14's `mem.map_unmap_4k` in the inventory moves here, with this PR's host split. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-review of bc11e5eScope: whether round 1's findings are resolved, and whether the changed code is correct. What I read:
ArchitectureCounting per space gives the gate the right scope. The Round 1
Soundness, traced
FindingsBLOCKER
For the orchestrator (description and title):
SUGGESTION
Evidence
Rule
Landing conditions
SEND BACK |
…cide it, not before the first read The syscall translates the futex word, under the space's lock, before the wait counts itself, so "counted from before the word's translation is first read" was false. What the argument rests on is that only `read`'s later re-checks decide whether the wait registers, and each comes after the count. The comments say that now. The record names what is owed on metal now that no bench boots: the T14's `testcases` boot for `test_rs_futex_wake_counts`, and the before/after A/B with `osbench_sched`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c523b8d to
a5c75b8
Compare
Review r3 of a5c75b8Scope: the moved count, with the code at the head, which is rebased onto 06fcb81 and includes #1253. What I read:
I ran no builds or tests. My role is limited to reading code and evidence, and that limit overrides the brief's request to run them. The host evidence is the author's logs at this exact head. ArchitectureThe design is unchanged from round 2:
This round moves one line, the count, from after the syscall's translation to before it. Production code in the kernel is +74/−14, about half of it comments. The records are +59/−5. No test was added. The premise round 2 lost, confirmedRound 2 argued that every check deciding registration takes the space's lock after the count. #1253 broke that:
The C11 model and AArch64 allow both loads to read the old value. On x86, the locked RMWs would in practice have hidden it. So the author's counterexample is real. The new order, traced at the headTake a wait W and an unmap U, with L the space's lock and B the word's bucket lock.
Every path
The code is correct. A modelNone exists.
If one is written, it should cover #1253's
Round 2's placement in comments and recordsNone remains. FindingsBLOCKER
SUGGESTION
For the orchestrator (description and commit message):
Evidence
SEND BACK: the comment fix only. |
a5c75b8 to
f110f44
Compare
Review r4 of f110f44Scope: the What I read:
I ran no builds. ArchitectureA guard that owns the counted space and hands out the translation is the right shape. It settles one of round 3's premises by construction: the counted space and the translated space are now one The type closes only one end of the window, though, and only for a caller that chooses to use it. Kernel production code is +96/−22, against +74/−14 at r3. No test was added. The questions asked
FindingsBLOCKER
SUGGESTION
For the orchestrator (PR body):
Evidence
SEND BACK |
f110f44 to
697e3bb
Compare
Review r5 of 697e3bbScope: whether round 4's blocker is resolved, read against the five questions in the brief. I did not reopen settled points. What I read:
I ran no builds. ArchitectureThis is the shape round 4 asked for. The guard owns the counted space, and the translation borrows the guard. The same type now enforces both ends of the window. Each wait clones one The questions
Round 4's findings:
FindingsBLOCKER: none. SUGGESTION
For the orchestrator (PR body): Evidence
LAND, once guest run 43119229089 is green. |
…utex wait Every unmap that freed a touched private page took each of the 64 futex bucket locks to end waits on its frames, whether or not anything waited. The 2026-11-11 stage probes put that walk at 1,159 ns on the T14 and 1,146 ns on the AMD bench per unmap of one touched page. Each space now counts its own threads' futex waits (`sched::futex::Waits`, held by the `Space` and its `AddressSpace`). A clear or a discard ends waits on its own private frames only while that count is not zero, which it reads under the space's lock. **The order the gate needs.** A wait's count must come before the hold of the space's lock in which its syscall translates the word. That hold's release carries the count to any unmap that takes the lock after it. And the count must last until the wait has unregistered. So the futex-wait syscall begins a `mm::space::FutexWait`, which raises the count when made and lowers it when dropped. The word's only translation is `FutexWait::translate`'s, and that `Translation<'_>` borrows the guard (`Space::translate` is folded into it), so: - the guard cannot be dropped while the wait still holds the translation (E0505); - it cannot be a temporary that the translation outlives (E0515); - `scheduler::futex_wait` takes only such a translation, so no wait can skip the count (E0308). The wake translates its word to a bare frame, which no wait accepts. Why that is exact, with #1253's single translation, whose later checks read the space's count of leaf changes without the lock: - A private frame is mapped in one space, so only that space's waits can be keyed on it, and only on a frame that translation found. - If the translation held the lock before the clear, the wait was counted by then. The lock orders the count before the gate's load, so the buckets are walked. The walk's bucket locks then order the shootdown's count of the change before any later check of the wait, which finds the frame gone, as #1253 argues. - If the translation held the lock after the clear, it found the frame gone, and the wait is keyed on none of the clear's frames. The lock orders both cases, so the count can be `Relaxed`. A count taken after the translation would race #1253's lockless checks: nothing would order it before the gate's load, or the change before the checks. A shared object's frames can be keyed by waits in any space that mapped it, so its drop keeps walking every bucket, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and what the futex gate owes on metal The 2026-11-11 stage probes on both benches give: - the remote unmap's split between shootdown, inventory and futex walk; - the short-copy candidates; - a correction: page size explains at most about 1% of the T14's large-copy gap, and the cause stays open. The frees bullet names the space whose waits gate the walk. With no bench booting, the record says what the gate owes on metal: the T14's `testcases` boot, and a before/after A/B with `osbench_sched`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
697e3bb to
14291a2
Compare
|
Orchestrator: landing under the benches-down rule. Review r5 LAND. Head 14291a2 is the same code commit (range-diff |
… mapper ever linked #1223 took the walk of all 64 futex buckets off unmaps. Step 2's revocation walk put it back on every removal of present pages from any paged object: once per 64 frames on each withdraw, fail, shrink and release. That covered `/tmp` deletes and the cache's withdraws, nearly all of whose objects were never mapped. Only a mapping's leaf can key a futex wait on a frame of a paged object. A pager may not map its objects, and a supply copies into fresh frames. An `Object` now records whether a mapper ever linked. `Object::link` sets the bit under the pager's lock and answers the epoch the new node is seen at, in place of the `epoch` getter, so no node links without setting it. The bit never resets, because an unmap clears its leaves without ending a wait keyed through them (B8's case). `Pages::walk` asks for `Next::Sweep` only where the bit is set, by the same rule as before: again whenever a pass began since the last sweep. The last sweep's epoch is now a `u64`, 0 before the first, in place of an `Option<u64>` reset at each walk's start. Every walk's epoch is at least 1, so the reset was redundant. This keeps `Object` at 88 bytes with the new bit, so the gate costs no slot space (`Entry` 120 bytes, slot 136, on both kernel targets). The model's `a_walk_begun_before_any_mapping_gives_back_no_frame_a_waiter_is_keyed_on` explores five steps from a walk begun on an object no mapper has linked. A mapper links during that walk, maps, waits, and queues another pass. The futex oracle stays green. Each control is a checked patch with the source restored byte-identical (https://github.com/ToyOSOrg/evidence/tree/a5fe696e711c2715f4a1bdc0dfa952c1759993fe/r9/controls): - Gating on a mapper linked now rather than ever is red on the oracle in four tests, among them `a_waiter_an_unmap_left_is_never_on_a_frame_given_back` and `every_interleaving_…`. - A bit never set is red in eleven tests. - A link during a walk left uncounted is red in the new exploration and in `long_runs_…`. - The faithful pre-r3 rule under the `u64` epoch is red in the same two tests as r4's control. B16: `end_futex_waits` names `revoke_sorted`, which walks every bucket, rather than the gated per-space `revoke_frames`. `holders` says it counts the spaces mapping the object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… mapper ever linked #1223 took the walk of all 64 futex buckets off unmaps. Step 2's revocation walk put it back on every removal of present pages from any paged object: once per 64 frames on each withdraw, fail, shrink and release. That covered `/tmp` deletes and the cache's withdraws, nearly all of whose objects were never mapped. Only a mapping's leaf can key a futex wait on a frame of a paged object. A pager may not map its objects, and a supply copies into fresh frames. An `Object` now records whether a mapper ever linked. `Object::link` sets the bit under the pager's lock and answers the epoch the new node is seen at, in place of the `epoch` getter, so no node links without setting it. The bit never resets, because an unmap clears its leaves without ending a wait keyed through them (B8's case). `Pages::walk` asks for `Next::Sweep` only where the bit is set, by the same rule as before: again whenever a pass began since the last sweep. The last sweep's epoch is now a `u64`, 0 before the first, in place of an `Option<u64>` reset at each walk's start. Every walk's epoch is at least 1, so the reset was redundant. This keeps `Object` at 88 bytes with the new bit, so the gate costs no slot space (`Entry` 120 bytes, slot 136, on both kernel targets). The model's `a_walk_begun_before_any_mapping_gives_back_no_frame_a_waiter_is_keyed_on` explores five steps from a walk begun on an object no mapper has linked. A mapper links during that walk, maps, waits, and queues another pass. The futex oracle stays green. Each control is a checked patch with the source restored byte-identical (https://github.com/ToyOSOrg/evidence/tree/a5fe696e711c2715f4a1bdc0dfa952c1759993fe/r9/controls): - Gating on a mapper linked now rather than ever is red on the oracle in four tests, among them `a_waiter_an_unmap_left_is_never_on_a_frame_given_back` and `every_interleaving_…`. - A bit never set is red in eleven tests. - A link during a walk left uncounted is red in the new exploration and in `long_runs_…`. - The faithful pre-r3 rule under the `u64` epoch is red in the same two tests as r4's control. B16: `end_futex_waits` names `revoke_sorted`, which walks every bucket, rather than the gated per-space `revoke_frames`. `holders` says it counts the spaces mapping the object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… mapper ever linked Main's #1223 took the walk of all 64 futex buckets off unmaps. Step 2's revocation walk put it back on every removal of present pages from any paged object: once per 64 frames on each withdraw, fail, shrink and release. That covered `/tmp` deletes and the cache's withdraws, nearly all of whose objects were never mapped. Only a mapping's leaf can key a futex wait on a frame of a paged object. A pager may not map its objects, and a supply copies into fresh frames. An `Object` now records whether a mapper ever linked. `Object::link` sets the bit under the pager's lock and answers the epoch the new node is seen at, in place of the `epoch` getter, so no node links without setting it. The bit never resets, because an unmap clears its leaves without ending a wait keyed through them (B8's case). `Pages::walk` asks for `Next::Sweep` only where the bit is set, by the same rule as before: again whenever a pass began since the last sweep. The last sweep's epoch is now a `u64`, 0 before the first, in place of an `Option<u64>` reset at each walk's start. Every walk's epoch is at least 1, so the reset was redundant. This keeps `Object` at 88 bytes with the new bit, so the gate costs no slot space (`Entry` 120 bytes, slot 136, on both kernel targets). The model's `a_walk_begun_before_any_mapping_gives_back_no_frame_a_waiter_is_keyed_on` explores five steps from a walk begun on an object no mapper has linked. A mapper links during that walk, maps, waits, and queues another pass. The futex oracle stays green. Each control is a checked patch with the source restored byte-identical (https://github.com/ToyOSOrg/evidence/tree/a5fe696e711c2715f4a1bdc0dfa952c1759993fe/r9/controls): - Gating on a mapper linked now rather than ever is red on the oracle in four tests, among them `a_waiter_an_unmap_left_is_never_on_a_frame_given_back` and `every_interleaving_…`. - A bit never set is red in eleven tests. - A link during a walk left uncounted is red in the new exploration and in `long_runs_…`. - The faithful pre-r3 rule under the `u64` epoch is red in the same two tests as r4's control. B16: `end_futex_waits` names `revoke_sorted`, which walks every bucket, rather than the gated per-space `revoke_frames`. `holders` says it counts the spaces mapping the object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… mapper ever linked Main's #1223 took the walk of all 64 futex buckets off unmaps. Step 2's revocation walk put it back on every removal of present pages from any paged object: once per 64 frames on each withdraw, fail, shrink and release. That covered `/tmp` deletes and the cache's withdraws, nearly all of whose objects were never mapped. Only a mapping's leaf can key a futex wait on a frame of a paged object. A pager may not map its objects, and a supply copies into fresh frames. An `Object` now records whether a mapper ever linked. `Object::link` sets the bit under the pager's lock and answers the epoch the new node is seen at, in place of the `epoch` getter, so no node links without setting it. The bit never resets, because an unmap clears its leaves without ending a wait keyed through them (B8's case). `Pages::walk` asks for `Next::Sweep` only where the bit is set, by the same rule as before: again whenever a pass began since the last sweep. The last sweep's epoch is now a `u64`, 0 before the first, in place of an `Option<u64>` reset at each walk's start. Every walk's epoch is at least 1, so the reset was redundant. This keeps `Object` at 88 bytes with the new bit, so the gate costs no slot space (`Entry` 120 bytes, slot 136, on both kernel targets). The model's `a_walk_begun_before_any_mapping_gives_back_no_frame_a_waiter_is_keyed_on` explores five steps from a walk begun on an object no mapper has linked. A mapper links during that walk, maps, waits, and queues another pass. The futex oracle stays green. Each control is a checked patch with the source restored byte-identical (https://github.com/ToyOSOrg/evidence/tree/a5fe696e711c2715f4a1bdc0dfa952c1759993fe/r9/controls): - Gating on a mapper linked now rather than ever is red on the oracle in four tests, among them `a_waiter_an_unmap_left_is_never_on_a_frame_given_back` and `every_interleaving_…`. - A bit never set is red in eleven tests. - A link during a walk left uncounted is red in the new exploration and in `long_runs_…`. - The faithful pre-r3 rule under the `u64` epoch is red in the same two tests as r4's control. B16: `end_futex_waits` names `revoke_sorted`, which walks every bucket, rather than the gated per-space `revoke_frames`. `holders` says it counts the spaces mapping the object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
Every unmap that freed a touched private page took each of the 64 futex bucket locks, to end waits on its frames, whether or not anything waited on them. Stage probes measured this on both benches, one boot each (evidence): 1,159 ns on the T14 and 1,146 ns on the AMD bench per unmap of one touched page. That is about a third of
mem.munmap_touched_remote.4k(T14 3,341 ns against Linux's 2,279 in the 2026-11-11 scoreboard).Change
Each address space counts its own threads' futex waits (
sched::futex::Waits, held by theSpaceand itsAddressSpace).FutexWait's doc. The futex-wait syscall begins amm::space::FutexWait, which raises the space's count when made and lowers it when dropped. The word's only translation isFutexWait::translate's, and thatTranslation<'_>borrows the guard. So the count comes before the translation's hold of the space's lock, and it falls only once the wait, which checks the translation until it has unregistered, is done with it. That hold's release carries the count to any unmap that takes the lock after it, and the gate reads the count under that lock.revoke_frames(&self.futex, ..)).Translation::holds):Relaxedis enough.SharedMemObject::drop) holds no space lock and keeps walking every bucket, as on main. Unmapping a shared page in one space ends no wait, because its frame is not freed then, as on main.Rebased onto main after #1253, which moved the count
#1253 made a wait translate its word once, in the syscall. Its checks became a load through the direct map plus
Translation::holds, which takes the space's lock only when the space's count of leaf changes has moved.Round 2's argument rested on every check taking the space's lock after the count, and that no longer holds. With the count where round 2 put it, inside
scheduler::futex_waitafter the syscall's translation, the gate would be unsound under #1253:holds.So the count now precedes the translation, which decides the frame. That restores the lock ordering, and #1253's bucket-lock argument covers the checks after it.
Rounds 4 and 5 make that order the type's.
FutexWaitowns the space it counts, andTranslation<'w>borrows it (&'w Space).FutexWait::translateis the only maker of aTranslation, andSpace::translateis folded into it. The wake translates to a bare frame (translate_user -> Option<u64>), whichscheduler::futex_waitdoes not accept. The three broken forms are compile errors, each shown on a scratch patch (evidence): the guard dropped before the wait (E0505), the guard made as a temporary in the translating closure (E0515), and a wait handed the wake's translation (E0308). Round 4's code commit (440d4bf, head f110f44) claimed this while two of those forms still compiled; its message is replaced. The head is rebased onto main 36ed75b, after #1267, as 14291a2.git range-diffshows the code commit unchanged (=, now a891051). The records commit keeps both records' text ina-tlb-shootdown…md, with the diagnosis put in the past tense where #1267 changed what it describes.Round 1 made the count machine-wide, so it never reached zero on a booted system. The supervisor's files worker and logkeeper's carrier-stall thread wait on futexes for the whole boot.
The records commit holds the 2026-11-11 diagnosis:
mem.bw.copy, 16 MiB and 64 MiB (3.7 to 4.9% under both);mem.map_unmap_4kparagraph has moved to The inventory keeps its gaps once, by length, and inserts and removes in place #1256.Checks
cargo test -p kernel --lib --features sched-check: 463 passed. That buildskernel/pure; the clippy runs are the host compile of the changed files.cargo run -- --ci host models: 83 steps green, every kernel model with its negative controls.build/clippy.rs, with the adopted lints and-D warnings: the two host shapes (sched-check;sched-check,protocol-portwith the kernel controls), six onx86_64-unknown-noneand four onaarch64-unknown-none-softfloat.cargo run -- --ci hostwas not run, under the owner's testing policy.kernel/src, so a model would only restate those edges.virt_smp,std_threads_count_the_cpus_and_give_back_ended_threadsanda_user_copy_faults_in_mid_copy_or_ends_as_bad_address. The head 14291a2 carries the same code commit (range-diff=). What main gained since, A pass skips the timer write and the ask swaps it does not owe #1263 and A user change asks each CPU running the space for its range, and owes itself nothing #1267, touches no file of the futex path butspace.rs'smap, which validates an object's range.test_rs_futex_wake_counts'sorphaned_by_unmap, runs only in the metaltestcasesboot.issues/user-memory-hot-paths-are-slower-than-2-mib-pages.md. The benches are down, so neither is a landing condition:testcasesboot. Main must passtest_rs_futex_wake_counts: itsorphaned_by_unmapwaiter shares the unmapping space, so the walk runs. The queued session's mutation boot (the walk skipped while a thread waits) would show the test reds without it.osbench_system,osbench_memandosbench_sched, main from before and after. Main after must readmem.munmap_touched_remote.4kand the other unmaps of touched pages faster by up to the walk's cost, andpingpong.futex.*and the wake storm no slower.memperf-futex-ab-base942c3c3,memperf-futex-ab-head03c896c) were superseded, predating A futex wait translates its word once and checks it with one load #1253 and the moved count, and are deleted; they never booted. The owed A/B is of main from before and after.🤖 Generated with Claude Code