Skip to content

sched: a spawn wakes the shallowest of its equal free-core sleepers - #1117

Merged
Japabu merged 9 commits into
mainfrom
sched-spawn-tiebreak
Nov 4, 2026
Merged

Japabu merged 9 commits into
mainfrom
sched-spawn-tiebreak

Conversation

@Japabu

@Japabu Japabu commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on the affinity PR (#1116), which is stacked on #1111. Review only the last two commits: 8277dc466 (the key) and c9f88d36e (review r1), as rebased onto main 8122a4ef5 with 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::place ranked 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_near already weighs the published idle exit for a wake forward; spawn placement ignored it.

Behaviour

  • The spawn key is now (load, !sleeping_unrung, busy_siblings, idle_exit).
    • Among free-core sleepers with equal queues, the shallowest one is woken.
    • A sleeper with a kick already on its way ranks as awake (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.
    • The rotation breaks only ties between equal exits.
    • A free core still comes before a shallow sibling of a running CPU.
  • This reverses Idle CPUs enter the deep states their firmware declares #1073's choice that a spawn reads no idle exit. That PR's rulings covered the wake-forward tiebreak, and I found no owner ruling on spawns. The basis for the change is the accepted scheduling model, under which placement weighs wake cost. Idle CPUs enter the deep states their firmware declares #1073's test a_spawn_lands_where_it_would_whatever_the_sleepers_exits is replaced by a_spawn_wakes_the_shallowest_free_core_sleeper.
  • The simulator plans C1, so every exit there is 0 and its scenarios place as before.
  • issues/the-scheduler-and-the-clocks-never-talk.md now says so, and idle_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 refuse pingpong.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/r2 at 100a3523; round 1 pr-1117/r1 at 3d29475d.

Metal A/B (base 0c7e62ca7, head 28a55585c, 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.std p50 535–545 µs without it against 410–467 µs with it, and .tiny 71–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.

  • AMD:
    • spawn_cost median: base 348–379 µs, head 277–307 µs, resolved (about −19%).
    • Also resolved: process.spawn_wait −21%, proc.posix_spawn_wait.std −21%, .tiny −15%.
  • T14:
    • spawn_cost: base 281–305 µs, head 262–281 µs, not resolved (the sides overlap at 281).
    • Resolved: process.spawn_wait −6% and posix_spawn_wait.std −4%.
    • proc.spawn_wait.large read +13%, not resolved (p 0.057).
  • The AMD's slow ipc and fs rows are not this commit's. In the 9-boot diagnostic, each row follows where its pair landed, in every arm. Without this commit 3 of 6 rows were split across the LLCs; with it, 4 of 6 (pr-1111/r2). Spawn placement has no LLC locality before this commit or after it.

Risks

  • Whether spawns reach shallow CPUs often enough to pay is read from spawn_cost in the bundled A/B. A missing gain on one bench would not by itself reject the rule.
  • On a machine with a single declared idle state every exit is equal, and nothing changes.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 of #1117 at e8e96821c

Round-1 required finding: fixed. The spawn key is (load, !sleeping_unrung, busy_siblings, idle_exit) (kernel/pure/sched/cpu.rs:2860-2863 at the stack top):

  • A rung sleeper now ranks as awake. A spawn raises no published load, but its adopt rings the target, so the next spawn in a burst skips that CPU.
  • Doorbell::sleeping_unrung (kernel/pure/sched/mailbox.rs:527) reads both bits in one load. forward_target shares it, which removes a duplicated two-load test rather than adding one.
  • a_spawn_passes_over_a_rung_sleeper states exactly that claim. Its control (r16/mut-1117-rung.log) reds only that test.
  • The masks from sched: a thread runs only on the CPUs its process's mask names #1116 are still honoured: the scan filters on mask before keying.

The rung-but-unloaded case ranks ahead of a loaded awake CPU through load, which is the right order: an idle CPU being woken still beats a queue.

No blocker, no suggestion. LAND.

@Japabu
Japabu force-pushed the sched-spawn-tiebreak branch 2 times, most recently from 132bc2c to e48eee9 Compare November 1, 2026 14:52
@Japabu
Japabu changed the base branch from sched-affinity to main November 1, 2026 14:59
@Japabu
Japabu marked this pull request as ready for review November 1, 2026 15:00
@Japabu
Japabu enabled auto-merge November 1, 2026 15:00
@Japabu
Japabu added this pull request to the merge queue Nov 1, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to invalid changes in the merge commit Nov 1, 2026
@Japabu
Japabu added this pull request to the merge queue Nov 1, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 1, 2026
Japabu and others added 5 commits November 1, 2026 22:35
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>
Japabu and others added 4 commits November 1, 2026 22:35
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
Japabu force-pushed the sched-spawn-tiebreak branch from e48eee9 to c9f88d3 Compare November 1, 2026 22:16
@Japabu
Japabu added this pull request to the merge queue Nov 1, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to invalid changes in the merge commit Nov 1, 2026
@Japabu
Japabu added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit 9d39d73 Nov 4, 2026
5 checks passed
@Japabu
Japabu deleted the sched-spawn-tiebreak branch November 4, 2026 06:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant