Repository navigation
A futex wait translates its word once and checks it with one load - #1253
Conversation
`SYS_FUTEX_WAIT` translated the word under its space's lock, and then `wait_until`'s predicate translated it again under the same lock on every check: before arming, after arming, after each wake and at the deadline, each with an `Arc` clone of the current space. wakeperf's segments put the translation at 60 to 63 ns, two to four times a round trip. The wait now translates once (`Space::translate`), and the translation carries the space's count of leaf changes read in that same hold. Every change to a present leaf is shot down, and `AddressSpace::shoot_down` now counts it first, under the lock, before the shootdown, the futex revoke and the free; `build`'s unwinding and `discard` go through it too, so their shootdowns are also counted in the test kernel's `SPACE_REACHED`. The predicate reads the word through the direct map and then asks `Translation::holds`: an acquire fence and a load of the count. While the count is unchanged the address still maps the frame and the frame is not freed. When it has moved, one hold of the lock asks whether the address still maps the frame, as every check did before, and takes the new count if it does, so a change elsewhere in the space neither ends the wait nor turns a timeout into a wake. What keeps a waiter from sleeping on a frame an unmap freed is unchanged: the unmap ends the waits on its frames under the bucket locks after it counts the change, and a waiter arms under its bucket's lock before it checks, so either the revoke finds it armed or its check sees the count moved and the address no longer mapping the frame. `futex::watch_of` and `scheduler::futex_wake` take the physical address rather than a direct-map pointer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r1 of #1253 at 2583d7dArchitecture. A wait now translates its word once and validates that translation against a per-space count of leaf changes, kept under the space's own lock. The count lives in Correctness is sound.
Blockers
Suggestions
Evidence
What metal must show
SEND BACK. This is for the evidence only; no code change is requested. It can land once the |
…ct map holds Since #1211 the direct map leaves out the bring-up guard pages, so it no longer maps every frame; it maps every frame a user page can hold, which is what the read relies on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Response to review r1, head bb5ae53.
🤖 Generated with Claude Code |
|
Orchestrator: verified bb5ae53 against review r1. |
Both benches are down. #1253 lands on its review, host checks and guest rows, and `issues/a-two-thread-ping-pong-costs-toyos-up-to-twice-linuxs-on-the-t14.md`, the record of the futex round trip it targets, records what only metal can establish: the T14's whole `testcases`, `testcases-watchdog` and `shared-debug` boots of main after it, and the A/B of main before and after it on both benches with the Linux comparison. Its speed-up is not yet measured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Orchestrator verification, landing under the benches-down rule (metal unavailable; review + host + guest, metal owed):
|
…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`). The futex-wait syscall counts itself before it translates its word, under the space's lock, and stops counting after the wait has unregistered. A clear or a discard ends waits on its own private frames only while the count is not zero. Why that is exact, with #1253's single translation, where a wait's 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 the syscall's translation found. - A wait whose translation came before the clear is counted by then. The space's lock orders that 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. - A wait whose translation came after the clear finds the frame gone and waits on no frame of these. The lock orders both cases, so the count can be `Relaxed`. A count only around the later checks would not do: those checks take no lock, so a skipped walk would leave nothing ordering the change before them. 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. A machine-wide count, this PR's first form, never reached zero on a booted ToyOS: the supervisor's files worker and logkeeper's carrier-stall thread wait on futexes for the whole boot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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. So the futex-wait syscall makes a `mm::space::FutexWait` first. It raises the count when made and lowers it when dropped, after the wait unregisters, and the word's translation goes through it (`FutexWait::translate`). The order is the type's, not the statements'. 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>
…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>
…e model's futex wait translates and arms apart B13. `clear_paged` shot its span down through the tables directly, which leaves the space's count of changes where it was; #1253's `Translation::holds` trusts an unmoved count, so a futex wait that translated before a visit cleared its leaf could arm after the walk's sweep, keyed on a frame the walk then gave back. The visit now shoots down through the space's own `shoot_down`, which counts the change under the space's lock before the sweep can run. `clear_paged`'s and `futex_word`'s docs say so. The model's futex wait is two steps, as the kernel's is: `Translate(s, page)` notes the frame and the space's count; `Arm` keys the waiter on the frame where the count has not moved and otherwise looks at the leaf again. A visit's clear and an unmap move the count. The reviewer's sequence is `a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with the visit's clear uncounted (a checked patch, the source restored byte-identical) it is red on the model's oracle, "frame 101 given back with a futex waiter keyed on it". B14: `Unmapped`'s doc says only that no space maps anything, and the double blank line above it is gone. Teardown takes the space out of the process data, as the handles line above it does, before ending it, so the process data's lock is not held across the space's shootdowns and pager calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e model's futex wait translates and arms apart B13. `clear_paged` shot its span down through the tables directly, which leaves the space's count of changes where it was; #1253's `Translation::holds` trusts an unmoved count, so a futex wait that translated before a visit cleared its leaf could arm after the walk's sweep, keyed on a frame the walk then gave back. The visit now shoots down through the space's own `shoot_down`, which counts the change under the space's lock before the sweep can run. `clear_paged`'s and `futex_word`'s docs say so. The model's futex wait is two steps, as the kernel's is: `Translate(s, page)` notes the frame and the space's count; `Arm` keys the waiter on the frame where the count has not moved and otherwise looks at the leaf again. A visit's clear and an unmap move the count. The reviewer's sequence is `a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with the visit's clear uncounted (a checked patch, the source restored byte-identical) it is red on the model's oracle, "frame 101 given back with a futex waiter keyed on it". B14: `Unmapped`'s doc says only that no space maps anything, and the double blank line above it is gone. Teardown takes the space out of the process data, as the handles line above it does, before ending it, so the process data's lock is not held across the space's shootdowns and pager calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…e model's futex wait translates and arms apart B13. `clear_paged` shot its span down through the tables directly, which leaves the space's count of changes where it was; #1253's `Translation::holds` trusts an unmoved count, so a futex wait that translated before a visit cleared its leaf could arm after the walk's sweep, keyed on a frame the walk then gave back. The visit now shoots down through the space's own `shoot_down`, which counts the change under the space's lock before the sweep can run. `clear_paged`'s and `futex_word`'s docs say so. The model's futex wait is two steps, as the kernel's is: `Translate(s, page)` notes the frame and the space's count; `Arm` keys the waiter on the frame where the count has not moved and otherwise looks at the leaf again. A visit's clear and an unmap move the count. The reviewer's sequence is `a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with the visit's clear uncounted (a checked patch, the source restored byte-identical) it is red on the model's oracle, "frame 101 given back with a futex waiter keyed on it". B14: `Unmapped`'s doc says only that no space maps anything, and the double blank line above it is gone. Teardown takes the space out of the process data, as the handles line above it does, before ending it, so the process data's lock is not held across the space's shootdowns and pager calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e model's futex wait translates and arms apart B13. `clear_paged` shot its span down through the tables directly, which leaves the space's count of changes where it was; #1253's `Translation::holds` trusts an unmoved count, so a futex wait that translated before a visit cleared its leaf could arm after the walk's sweep, keyed on a frame the walk then gave back. The visit now shoots down through the space's own `shoot_down`, which counts the change under the space's lock before the sweep can run. `clear_paged`'s and `futex_word`'s docs say so. The model's futex wait is two steps, as the kernel's is: `Translate(s, page)` notes the frame and the space's count; `Arm` keys the waiter on the frame where the count has not moved and otherwise looks at the leaf again. A visit's clear and an unmap move the count. The reviewer's sequence is `a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with the visit's clear uncounted (a checked patch, the source restored byte-identical) it is red on the model's oracle, "frame 101 given back with a futex waiter keyed on it". B14: `Unmapped`'s doc says only that no space maps anything, and the double blank line above it is gone. Teardown takes the space out of the process data, as the handles line above it does, before ending it, so the process data's lock is not held across the space's shootdowns and pager calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e model's futex wait translates and arms apart B13. `clear_paged` shot its span down through the tables directly, which leaves the space's count of changes where it was; #1253's `Translation::holds` trusts an unmoved count, so a futex wait that translated before a visit cleared its leaf could arm after the walk's sweep, keyed on a frame the walk then gave back. The visit now shoots down through the space's own `shoot_down`, which counts the change under the space's lock before the sweep can run. `clear_paged`'s and `futex_word`'s docs say so. The model's futex wait is two steps, as the kernel's is: `Translate(s, page)` notes the frame and the space's count; `Arm` keys the waiter on the frame where the count has not moved and otherwise looks at the leaf again. A visit's clear and an unmap move the count. The reviewer's sequence is `a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with the visit's clear uncounted (a checked patch, the source restored byte-identical) it is red on the model's oracle, "frame 101 given back with a futex waiter keyed on it". B14: `Unmapped`'s doc says only that no space maps anything, and the double blank line above it is gone. Teardown takes the space out of the process data, as the handles line above it does, before ending it, so the process data's lock is not held across the space's shootdowns and pager calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e model's futex wait translates and arms apart B13. `clear_paged` shot its span down through the tables directly, which leaves the space's count of changes where it was; #1253's `Translation::holds` trusts an unmoved count, so a futex wait that translated before a visit cleared its leaf could arm after the walk's sweep, keyed on a frame the walk then gave back. The visit now shoots down through the space's own `shoot_down`, which counts the change under the space's lock before the sweep can run. `clear_paged`'s and `futex_word`'s docs say so. The model's futex wait is two steps, as the kernel's is: `Translate(s, page)` notes the frame and the space's count; `Arm` keys the waiter on the frame where the count has not moved and otherwise looks at the leaf again. A visit's clear and an unmap move the count. The reviewer's sequence is `a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with the visit's clear uncounted (a checked patch, the source restored byte-identical) it is red on the model's oracle, "frame 101 given back with a futex waiter keyed on it". B14: `Unmapped`'s doc says only that no space maps anything, and the double blank line above it is gone. Teardown takes the space out of the process data, as the handles line above it does, before ending it, so the process data's lock is not held across the space's shootdowns and pager calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e model's futex wait translates and arms apart B13. `clear_paged` shot its span down through the tables directly, which leaves the space's count of changes where it was; #1253's `Translation::holds` trusts an unmoved count, so a futex wait that translated before a visit cleared its leaf could arm after the walk's sweep, keyed on a frame the walk then gave back. The visit now shoots down through the space's own `shoot_down`, which counts the change under the space's lock before the sweep can run. `clear_paged`'s and `futex_word`'s docs say so. The model's futex wait is two steps, as the kernel's is: `Translate(s, page)` notes the frame and the space's count; `Arm` keys the waiter on the frame where the count has not moved and otherwise looks at the leaf again. A visit's clear and an unmap move the count. The reviewer's sequence is `a_wait_translated_before_a_visit_does_not_arm_on_a_frame_given_back`; with the visit's clear uncounted (a checked patch, the source restored byte-identical) it is red on the model's oracle, "frame 101 given back with a futex waiter keyed on it". B14: `Unmapped`'s doc says only that no space maps anything, and the double blank line above it is gone. Teardown takes the space out of the process data, as the handles line above it does, before ending it, so the process data's lock is not held across the space's shootdowns and pager calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
SYS_FUTEX_WAITtranslated its word under the space's lock, and thenwait_until's predicate translated itagain, under the same lock and with an
Arcclone of the current space, on every check: before arming,after arming, after each wake and at the deadline. wakeperf's segments put that translation at 60 to 63 ns,
taken two to four times per wake round trip, inside
body_end:futex_waitof about 185 to 205 ns.Resulting behavior
Space::translate->Translation), which also reads the space's new countof leaf changes (
Usage::shot_down) in the same hold of the lock.AddressSpace::shoot_downincrements the count first, underthe lock, before the TLB shootdown, the futex revoke and the free.
build's unwinding anddiscard, whichcalled the tables' shootdown directly, now go through it, so their shootdowns also count in the test
kernel's
SPACE_REACHED(which onlymunmapdeltas read).Translation::holds: an acquire fenceand a load of the count. Unchanged, the address still maps the frame and the frame is not freed. Moved,
one hold of the lock asks whether the address still maps the frame — the check every call made before —
and takes the new count if it does. So a change elsewhere in the space (another range's
munmap,mprotect) neither ends the wait early nor turns its timeout into a wake.futex::watch_ofandscheduler::futex_waketake the physical address instead of a direct-map pointer.What keeps the key valid
Unchanged from before: an unmap ends the waits on the frames it frees (
futex::revoke_frames, under thebucket locks) after it counts the change, and a waiter arms under its bucket's lock before it checks. Either
the revoke finds the waiter armed and ends it, or the arm came after the revoke, so the waiter's check
happens after the count moved and the lock-held look finds the address no longer mapping the frame. Reading
a frame freed meanwhile through the direct map is what the previous code also did between its check and its
read; at worst it is a spurious return, which the ABI allows. No loom model is added: the ordering is the
bucket lock's and the space lock's, which their own models cover; the count is only read and written under
or after them.
Not done
broadcast TLBI with no IPI, so nothing waits for a walker before tables are freed; it would need deferred
table freeing.
benchrowsfirst; this change adds none).Checks
Targeted per the testing policy.
kernel/srchas no host tests.2583d7de8:cargo clippy -p kernelwithbuild/clippy.rs's adopted lints,-D warnings,x86_64-unknown-none: default,boot-actuators,test-actuators, the instrument setaarch64-unknown-none-softfloat: default,boot-actuators,test-actuatorsbb5ae5397(review r1: one SAFETY comment):cargo check -p kernel --target x86_64-unknown-noneGuest rows:
No test matches filter "futex_wake_counts"before building aguest: six of its eight names are shared-boot members, which guest rows do not select. It established
nothing.
2583d7de8, green:std_threads_count,libc_children,affinity_pins,vector_state,and on AArch64 (
mm::spacesits beneath it)virt_smp,virt_unmap_touch. The head differs from2583d7de8in one SAFETY comment and an issue record only.Owed on metal
Both benches are down, so this lands on its review, host checks and guest rows. The speed-up is not yet
measured on metal.
issues/a-two-thread-ping-pong-costs-toyos-up-to-twice-linuxs-on-the-t14.mdrecordswhat is owed:
testcases,testcases-watchdogandshared-debugboots of main after this PR, everymember passing, among them
futex_wake_counts(itsorphaned_by_unmaparm is the revoke race),kill_ends_every_wait,exit_wait_storm,std_threading,std_threads,shm_release_reclaimsandtlb_shootdown_waits(which readsSPACE_REACHED, whichdiscardandbuild's unwinding now also count);osbench sched,time,files,ipc; four boots a side, rotated; exact two-sided Mann-Whitney over per-boot p50s), and Linux'sosbench schedon each bench. Claimed:pingpong.futex.same_cpuandpingpong.futex.cross_corelower ornot separated, the latter only from boots whose round trips parked; no other row 5% or more worse unless
this change explains it.
Not taken (review r1's optional suggestion 2): a shared-boot member asserting that a timed wait beside a
sibling's mmap/munmap churn answers 1. The ABI allows a spurious 0, so it would hold a quality of the answer
rather than a contract.
Risks
mmap/munmapchurn pays the lock-held look once per check after each change, whichis what every check cost before; between changes, a check is a load.
🤖 Generated with Claude Code