Repository navigation
sched: a spawn wakes the shallowest of its equal free-core sleepers - #1117
Merged
Merged
Conversation
This was referenced Oct 31, 2026
Japabu
force-pushed
the
sched-affinity
branch
from
October 31, 2026 19:15
41aa876 to
aef1e41
Compare
Japabu
force-pushed
the
sched-spawn-tiebreak
branch
3 times, most recently
from
November 1, 2026 00:47
4cc6389 to
a2fd3af
Compare
Japabu
force-pushed
the
sched-affinity
branch
from
November 1, 2026 00:47
3c2fd57 to
0295551
Compare
Japabu
force-pushed
the
sched-affinity
branch
from
November 1, 2026 02:30
0295551 to
b0a338f
Compare
Japabu
force-pushed
the
sched-spawn-tiebreak
branch
from
November 1, 2026 02:30
a2fd3af to
8ab5159
Compare
Japabu
force-pushed
the
sched-affinity
branch
from
November 1, 2026 11:26
b0a338f to
fd114da
Compare
Japabu
force-pushed
the
sched-spawn-tiebreak
branch
from
November 1, 2026 11:26
8ab5159 to
e8e9682
Compare
Collaborator
Author
Review r2 of #1117 at
|
Japabu
force-pushed
the
sched-affinity
branch
from
November 1, 2026 14:41
fd114da to
9fa3070
Compare
Japabu
force-pushed
the
sched-spawn-tiebreak
branch
2 times, most recently
from
November 1, 2026 14:52
132bc2c to
e48eee9
Compare
Japabu
force-pushed
the
sched-affinity
branch
from
November 1, 2026 14:52
9fa3070 to
7d60f48
Compare
Japabu
marked this pull request as ready for review
November 1, 2026 15:00
Japabu
enabled auto-merge
November 1, 2026 15:00
github-merge-queue
Bot
removed this pull request from the merge queue due to invalid changes in the merge commit
Nov 1, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to a conflict with the base branch
Nov 1, 2026
A process now sets, for each of its own threads, the CPUs it may run on: SYS_THREAD_SET_AFFINITY and SYS_THREAD_GET_AFFINITY take a bitmap of up to 1024 CPUs by the kernel's dense index, and SYS_CURRENT_CPU reports the CPU the caller runs on in that same numbering. The mask is a placement choice, not an authority: the kernel resolves the thread id in the caller's own process only, so no other process's thread can be named, and any non-empty set of online CPUs is accepted, wider or narrower than the last. An empty mask or one naming an offline CPU is InvalidArgument; libc's sched_setaffinity drops CPUs past the online count first, as Linux does. A new thread or process starts with its spawning thread's mask; boot spawns and kernel threads get every CPU. The scheduler core enforces it at every route a task reaches a CPU by. A task's owner CPU decides with an owned copy of the mask, taken from the published one (a sequence lock in TaskShared) when its version moves, and only while the task is in no queue: - place(): a wake, adopt, deadline or retire outside the mask is relocated to an idle CPU the mask names, else to where spawn placement would put it; a wake forward goes only inside the mask. - the pick sends on a queued task whose mask moved, deferring one this CPU still stands on to the next pass (CpuSched::outbound). - a running task whose mask moved off its CPU is preempted at the pass the setter's kick causes. - a steal hands over only a task whose mask names the thief, and a task pinned to one CPU is not published as surplus. - the push rings only a sleeper some movable queued task may run on, and a steal probe is kept for a later pass only while a movable task is queued. Without either, the simulator's masked_steals livelocks at one instant: a probe answered with nothing causes a pass that rings or re-asks for the same CPU again. - spawn placement chooses only inside the mask. A setter publishes, fences, reads the task's state word once and kicks the CPU it names. An adopt fences before its refresh, so a move the read misses is adopted with the new mask; a pass already running when the kick lands leaves its doorbell edge raised for one that reads it. A killed task unwinds where it is, mask or not, as hand_off already keeps it. The simulator's I15 counts only queued tasks whose masks name the idle CPU, since a task pinned elsewhere is no work for it. The RT allowance is per CPU and charged where time runs, so a pin cannot escape it. A machine past 1024 CPUs is refused at boot by name. Userland: std's available_parallelism counts the mask, as Linux's does; core_affinity gains a ToyOS arm (ToyOSOrg/core_affinity toyos-0.8.3). osbench and wake_onto_busy pin their threads instead of staging them statistically, and osbench names CPUs by the kernel's index, as the kernel's records do, so the APIC-against-index confusion that misled the onto_busy diagnosis cannot recur. affinity_pins holds the pins against the CPU's own x2APIC ID, on the shared boot and under TCG. Closes issues/no-test-can-hold-a-thread-on-a-named-cpu.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A setter stores the mask's version and then reads the task's state word to kick the CPU it names; an adopt stores the state word and then reads the version. That is a store-buffering pair, and its two SeqCst fences were argued rather than checked. They are now one function, affinity_fence, which both sides call, and loom_affinity models a task moving from cpu0 to cpu1 while its mask is set: either the setter kicks cpu1 or cpu1's refresh finds the new version. The control, affinity-fence-relaxed, makes the fence a release one; the model then reds with "neither saw the other", and build/ci.rs requires that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…value The simulator's work-conservation check, once it counted only the queued tasks whose masks name the idle CPU, found a victim for every CPU at every step by walking every queue's tasks, idle CPU or not. topology's every_scenario_holds_at_every_shape went from 19 s to past 14 minutes in host CI, most of it at the 768-CPU shape. It now gathers once per step the CPUs whose fair band is deep enough to probe, looks among only those and only for an idle CPU, and stops counting at the probe threshold. Under the same host load the test reads 13.2 s against origin/main's 25.8 s. scheduler::enqueue_new took the mask as an eighth argument, past clippy's limit; it takes the NewTask its three callers now fill in by name. The masked_steals scenario cloned a Copy op. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sys::allowed_cpus read the calling thread's mask on ToyOS. A mask is per thread, so inside a thread spawn_on had already pinned, it named that thread's one CPU. wake.onto_busy's waker then found no CPU to keep busy, and mem.munmap_touched_remote's initiator found no CPU for its sibling. All 8 metal boots of the A/B head refused both rows, and osbench_sched exited 1 on each. Both systems now read the main thread's mask once, in sys::start, before anything pins, and every placement and unpin asks that. Each arm's own reader is thread_cpus. Linux's arm already read the main thread's mask through getpid(), but only because nothing pinned that thread. On a 4-CPU TCG guest with osbench_sched, the unfixed tree refuses wake.onto_busy exactly as metal did, and the fixed tree measures it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… may take
- tid 0 was both "the caller" and the main thread's real id, so no other
thread could name the main thread, and a worker naming it silently set
its own mask. The affinity calls now name a thread as thread_join does,
0 the main thread. A thread names itself by current_tid(), which reads
its control block. The callers change with them: libc, std's
available_parallelism, osbench, and the guest tests. So does
core_affinity's ToyOS arm (ToyOSOrg/core_affinity toyos-0.8.3
9b2f9bd). affinity_pins now has another thread pin the main thread by
tid 0, and the main thread runs where it was pinned.
- The pull side read a mask-blind surplus. A nearer victim holding only
work the thief may not run was probed first, answered nothing, and its
pass rang the other idle sibling, so the IPIs never stopped. Each CPU
now publishes beside its surplus which CPUs may take it, the union of
its movable queued tasks' masks. The probe, the probe's re-check after
SLEEPING, the busy balance and the push all filter on that one answer
(CpuHandle::takes).
- The union is O(1) while any queued task's mask is every CPU, which a
run queue counts. The setter stores a mask naming every online CPU as
CpuMask::ANY, so that count is exact. Only a queue whose movable tasks
are all restricted is walked, and only on a pass with surplus.
- The simulator's new masked_siblings, on the T14's SMT shape, reds
before this change: I15 finds cpu0 and cpu1 idle beside work they may
run. It passes after it. So does a host test of the probe passing a
sibling whose surplus excludes the thief. Both red again when the
probe ignores the takers.
- The push-cost test asserted nothing and described the code before the
push read masks. It is deleted, with MASKED_STEALS_ANY and
masked_steals' parameter.
- libc takes CPU_MASK_WORDS from toyos-abi instead of a copy.
- wake.onto_busy and wake_onto_busy unpin each partner once it has
parked, before any round. The unpin's kick of the busy CPU then lands
outside every timed wake, as it does on Linux, where unpinning a
sleeping thread sends no IPI.
- CPU_NAMING named nothing that differs between the systems; it is gone.
- SYS_THREAD_GET_AFFINITY's doc states its refusal of a short buffer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pass below the surplus threshold published an empty takers mask, so every such pass on every CPU wrote all 16 words. Every reader reads them only while offers_surplus says the CPU has a surplus, so publish_surplus now writes the takers only when the surplus reaches the threshold. The field's doc now says what a stale read costs. A reader can pair the flag with words of another pass. It then probes a CPU that answers nothing, or skips one it could have taken from, even when that CPU's push, which reads its own pass's takers, rang it. Either lasts until the CPU's next pass with surplus. A host test now holds the busy balance to the same rule: it skips a heavier sibling whose surplus excludes it. Dropping takes() from balance_busy reds it. The probe test abandons its world before asserting, so a red is one panic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
osbench's energy readers landed on main beside this stack, written to the names it renamed: - Linux's lists the CPUs it reads through the module's own allowed_cpus. That now lives in sys::allowed_cpus, which reads the main thread's CPUs as the process began. - ToyOS's names each CPU's core through arch::core_of, which this stack calls arch::core_of_apic, since a ToyOS CPU is named by its kernel index and the core is derived from its APIC ID. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CpuHandles::place ranked a spawn's candidates by queue, sleep and busy SMT siblings, and broke every remaining tie by the rotation alone, so a spawn woke a CPU in its deepest declared state as readily as one in C1. The key now ends in the exit each idle CPU publishes, the cost idle_near already weighs for a wake forward: among free cores' sleepers with equal queues the shallowest is woken, the rotation decides only between equal exits, and a free core still comes before a shallow sibling of a running one. This reverses #1073's choice that a spawn reads no idle exit, under the accepted scheduling model that placement accounts for wake cost; its test is replaced by one that states the new order. The simulator plans C1, where every exit is 0, so its scenarios place as before. It was shelved because osbench's statistical staging could not reach a parked thread's CPU once that CPU idled deep; the benches now pin their threads, so a placement rule no longer decides whether a row can run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A kicked CPU keeps reading SLEEPING until its next pass and keeps its published exit until it wakes, and a spawn raises no published load. So once the spawn key preferred the shallowest sleeper, every spawn inside that window chose the same CPU: four threads started 20 us apart all went to the one CPU in C1 while three others slept, and the push recovered them one per pass. The rotation alone had spread them. The key now ranks a sleeper only while no kick is on its way, the test forward_target already made for wake forwards. Doorbell::sleeping_unrung reads both bits in one load and serves both. A host test rings the shallowest sleeper and places again; ranking plain SLEEPING reds it and nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu
force-pushed
the
sched-spawn-tiebreak
branch
from
November 1, 2026 22:16
e48eee9 to
c9f88d3
Compare
github-merge-queue
Bot
removed this pull request from the merge queue due to invalid changes in the merge commit
Nov 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on the affinity PR (#1116), which is stacked on #1111. Review only the last two commits:
8277dc466(the key) andc9f88d36e(review r1), as rebased onto main8122a4ef5with no conflict in this PR. Host CI at the stack's top (8a1357642): 109 steps, all green, exit 0 (log). Before that rebase: 109 steps, all green, exit 0 (log).Problem
CpuHandles::placeranked a spawn's candidates by(load, !sleeping, busy_siblings)and broke every remaining tie by the rotation. A spawn therefore woke a CPU in its deepest declared idle state as readily as one in C1.idle_nearalready weighs the published idle exit for a wake forward; spawn placement ignored it.Behaviour
(load, !sleeping_unrung, busy_siblings, idle_exit).Doorbell::sleeping_unrung, the test a wake forward uses). A kicked CPU keeps reading SLEEPING until its next pass and keeps its published exit until it wakes, and a spawn raises no published load, so without this a burst of spawns would all choose the one shallow sleeper.a_spawn_lands_where_it_would_whatever_the_sleepers_exitsis replaced bya_spawn_wakes_the_shallowest_free_core_sleeper.issues/the-scheduler-and-the-clocks-never-talk.mdnow says so, andidle_exit's doc names both readers.Why it was shelved, and why it can land now
On the T14, an earlier head (
161fc1e1c, on the base before affinity) had one boot refusepingpong.pipe.same_cpu: "the_ponger_was_not_placed". That row staged its ponger by spawning until a thread happened to land on the CPU where the pinger sat parked. Under this rule, once that CPU idled deep, no spawn chose it. The rule was right and the bench's premise was false. With the affinity PR, the benches pin instead of staging, so a placement rule no longer decides whether a row can run.Checks
Evidence:
pr-1117/r2at 100a3523; round 1pr-1117/r1at 3d29475d.Review r1 (
e8e96821c): host CI at the top of the stack (1178599c4, sched: a woken partner follows its waker's LLC #1129) passed all 108 steps, exit 0. The new host testa_spawn_passes_over_a_rung_sleeperrings the shallowest sleeper and places again. The control (the key ranking plain SLEEPING) reds it and nothing else, exit 101, applied and reversed in the same run.Host CI and QEMU at this commit (
28a55585c):cargo run -- --ci hostpassed all 106 steps, and the two affinity QEMU tests passed. Both exited 0, and both logs are in sched: a thread runs only on the CPUs its process's mask names #1116's evidence.Mutations at
28a55585c: dropping the exit from the key, and ranking the exit beforebusy_siblings. Each reds onlya_spawn_wakes_the_shallowest_free_core_sleeper, with exit 101.Rebased onto
49559ddb2: host CI (108 steps) and the QEMU pair are green; see sched: a thread runs only on the CPUs its process's mask names #1116.Metal A/B (base
0c7e62ca7, head28a55585c, 4 boots per image per side per bench, evidence)Every boot returned rc=0. A row resolves only when all 4 of one side's runs beat all 4 of the other's (p = 0.029).
The A/B measures the whole stack, so it does not isolate this commit. Spawn placement is what this commit changes.
The isolating reading is ab3's AMD arms without and with this commit, 3 boots each (
pr-1111/r2):proc.posix_spawn_wait.stdp50 535–545 µs without it against 410–467 µs with it, and.tiny71–79 µs against 45–64 µs. Every boot with the commit beat every boot without it in both rows (p = 0.1 at 3 a side). Those arms predate the kick check above.spawn_costmedian: base 348–379 µs, head 277–307 µs, resolved (about −19%).process.spawn_wait−21%,proc.posix_spawn_wait.std−21%,.tiny−15%.spawn_cost: base 281–305 µs, head 262–281 µs, not resolved (the sides overlap at 281).process.spawn_wait−6% andposix_spawn_wait.std−4%.proc.spawn_wait.largeread +13%, not resolved (p 0.057).pr-1111/r2). Spawn placement has no LLC locality before this commit or after it.Risks
spawn_costin the bundled A/B. A missing gain on one bench would not by itself reject the rule.🤖 Generated with Claude Code