Skip to content

sched: a spawned thread starts on a free core in its spawner's LLC - #1153

Merged
Japabu merged 3 commits into
mainfrom
sched-spawn-near
Nov 4, 2026
Merged

Japabu merged 3 commits into
mainfrom
sched-spawn-near

Conversation

@Japabu

@Japabu Japabu commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

On main (after #1117). Three commits: d487ed227 (the near term), 7913b5bda (round 2: a spawn passes over the CPUs its process's threads call home) and 8c9738802 (review r1: the homes walk is bounded and shared). Fixes the defect #1151 described; #1151 closed unmerged, so this PR carries the record and there is no issue file to delete.

Problem

Spawn placement (CpuHandles::place) ranked CPUs by queue, sleep, busy SMT siblings and wake depth, and broke ties by a rotation over the whole machine. A thread spawned to work with its spawner therefore started in either LLC.

On the AMD bench, ab9 measured pipe.throughput, whose writer is a new thread each round. In all six boots of both arms, every round that the rotation put in the other LLC from its reader read slow, without exception: about 2.0, 3.5 and 4.8 GB/s at 4096, 16384 and 65536 bytes per write, against about 5.0, 8.4 and 10.0 in one LLC (judgment).

Behaviour

  • CpuHandles::place(start, now, mask, near) takes Near { spawner, homes }. homes is the CPUs the process's threads call home, from cpu::homes, which process::spawn_thread and the simulator's Op::Spawn share. It reads siblings newest first, one state word each, and stops once the homes cover every CPU of the new thread's mask on the CPUs online, or after HOMES_READ = 64 siblings. The key is (queue, not a sleeper with no kick coming, busy SMT siblings, a CPU a thread of the process calls home, outside the spawner's LLC, wake depth).
    • A free core no sibling holds in the spawner's LLC comes first, then one in another LLC, then a sibling's home, near first.
    • A shared core comes last. ab6 measured a pair on one core as slow as a split pair.
    • Why homes: a parked thread raises no published load. With the LLC term alone, a burst of threads that park all saw the spawner's LLC as free. ab10's wake.storm.drain.w16 read +17% from that, and the simulator reproduced it (mechanism): 16 parked threads packed 2–4 to a CPU on cpu0–5, where without the term they covered 14 CPUs.
  • Only a thread spawned into its own process passes near (process::spawn_thread, as the CPU it runs on). A new process's first thread, a kernel thread and a mask relocation pass None and place as before. A new process shares no address space with its spawner, and work.build_shaped's children are meant to spread.
  • A near CPU that is awake does not beat a far sleeper. The sleep term still ranks first, because an awake CPU with an empty queue may be running something. The simulator showed the effect: a spawn issued before the spawner's LLC had gone idle went far.
  • The simulator's Op::Spawn passes the spawning step's CPU, as the kernel does, so its placement stays the kernel's (kernel/sim/src/vm.rs).

Checks

Evidence: pr-1153/r4 (review r1), and pr-1153/r2 (round 2: logs, controls, the storm and process-spawn mechanisms, ab11's rule and request), and pr-1153/r1 at 6b42a7b7: logs, control patches, ab10's rule, request and judge.

check revision result
cargo run -- --ci host 791d0a23b, clean tree 109 steps, all green, exit 0
cargo run -- --ci host 8c9738802 (review r1, on main), clean tree 109 steps, all green, exit 0 (log)
T14 metal testcases (orchestrator) 8c9738802 passed, abuse_thread_table exit=0 (4096 spawns, 166 ms CPU)
homes_stop_once_they_cover_the_mask, homes_read_at_most_their_bound; cargo test -p kernel --lib --features sched-check, cargo test -p kernel-sim 8c9738802 pass; 449 passed; every simulator suite green
QEMU q35: std_threads_count_the_cpus_and_give_back_ended_threads (MAX_THREADS + 8 detached threads) 8c9738802 pass, exit 0
abuse_thread_table (4095 zombies, then refusals) 8c9738802 staged as a one-job T14 shared testcases boot. It has no QEMU entry: a shared-boot member the harness filters only on metal
cargo run -- --ci host 501c228b3, clean tree 109 steps, all green, exit 0 (log)
cargo test -p kernel --lib --features sched-check, cargo test -p kernel-sim 501c228b3 445 passed; every simulator suite green
a_spawned_thread_starts_on_a_free_core_in_its_spawners_llc (host, two LLCs of two SMT cores) 791d0a23b pass
a_spawned_thread_passes_over_cpus_its_siblings_call_home (host) and a_burst_of_parked_threads_is_spread_over_free_cores (simulator, spawned_burst, 40 seeds; Outcome::placed records each spawn's CPU) 501c228b3 pass
a_spawned_partner_starts_in_its_spawners_llc (simulator, AMD shape, 40 seeds: a thread moves itself to cpu8 and spawns its turn-taking partner; no kick crosses the LLCs but its own move) 791d0a23b pass
cargo test -p kernel --lib --features sched-check, cargo test -p kernel-sim 791d0a23b 444 passed; every simulator suite green
QEMU q35 (2 CPUs, one LLC): std_threads_count_the_cpus_and_give_back_ended_threads, affinity_pins_hold_on_every_cpu, libc_children 791d0a23b 3 passed, exit 0

The QEMU guest has one LLC, so the new term never changes a choice there. Those runs show only that every spawn path still places and runs threads. The placement claim is the host and simulator tests' and ab10's.

Controls, each a checked patch, applied, run and reversed in the same run:

mutation reds
no LLC term in the key the host test (cpu0 instead of cpu3), and the simulator test (11 crossing kicks)
the simulator's Op::Spawn passes no spawner the simulator test (11 crossing kicks)
no homes term (501c228b3) the host homes test (cpu3 for cpu0), and the burst test (placed on [1, 2, 3, 4, 5, 0, 2, 3, 8, 9])
no cap (take(HOMES_READ) removed) homes_read_at_most_their_bound
no early stop homes_stop_once_they_cover_the_mask
no LLC term (501c228b3) both near host tests and the partner test, again

Metal

ab10 (12 AMD boots, 3 for this change, all passed; judgment). Base is c9f88d36e + diagnostic. The decision rule was fixed before the run.

  • S1 PASS: all 63 timed pipe rounds stayed in one LLC. p50 5177, 8330 and 10171 MB/s against base's same-LLC 4961, 8339 and 10141.
  • S3 PASS: work.build_shaped.j16 0.996.
  • Faster:
    • thread.spawn_join 20.4–20.5 µs in every boot, against base's 20.4, 27.2 and 27.3: the 27 µs mode is a joined thread in the other LLC.
    • wake.storm.call.w1/w4/w16 −6 to −9%.
  • S2 FAILS by the rule's letter:
    • wake.storm.drain.w16 +17% and .w4 +4%, not explained.
    • fs.mem.seq_read.4096 +4.7%, not explained.
    • proc.posix_spawn_wait.tiny +23% and proc.spawn_wait_loop.100 +21%. These spawn processes, which this change places as before. proc.spawn_wait.tiny is bimodal on base too. Inference: they read where the parent sat.
    • Several rows that base itself shows in two modes.
  • Not landable as measured.
  • Round 2's findings:

ab11 (6 AMD boots, base 520532b89 against bbf88b647 = 501c228b3 + diagnostic, all passed; judgment). LAND by the rule fixed before staging:

  • M1, the storm's mechanism: every w4 waiter has its own CPU; at w16 at most 2 share one, in both arms.
  • M2, the storm's drain: wake.storm.drain.w16 0.894, .w4 0.844.
  • S1, pipe throughput: 0 split rounds in every boot.
  • S2, everything else: no non-moded row more than 3% slower.
  • S3, process spawns: work.build_shaped.j16 0.974.
  • Faster: thread.spawn_join 0.777, pipe.throughput.* 2.1–2.4× against base's split draws.
  • Reported, not judged: thread.spawn_join.default +3.8% and .stack64k +4.2%, about 0.8 µs per spawn and join, cause not established.

Risks

  • Threads a process spawns to spread (a worker pool) fill free cores no sibling holds, the spawner's LLC first. ab11's wake.storm rows are the check, and they are faster.
  • thread.spawn_join.default and .stack64k read about 0.8 µs slower per spawn and join in ab11, cause not established.
  • The T14 has one LLC, so nothing changes there.
  • A process of more than 64 threads has its oldest siblings unread, so a CPU only they call home can look free to a new thread. Exited threads that have not been reaped still count toward the 64, so a process with many of them and few live threads can miss its live siblings' homes. The bound is what keeps a spawn's cost under the global process table lock, with preemption off, at no more than 64 state-word reads. Before review r1, an unbounded walk made growing a process to N threads O(N²).
  • A sibling's home can change during the walk (a migration or exit), which leaves a stale home. That is harmless for a placement heuristic. The spawner's own CPU is read with preemption off, under the same lock hold as the placement, so it is exact.
  • The thread.spawn_join.default and .stack64k cost (+4%) is not attributed. The walk is a step or two at those rows' one or two threads, so it is not a plausible cause. More likely is where the thread lands: the homed and near terms now rank above wake depth, so a deeper sleeper in the near LLC can beat a shallower far one. That is an inference; ab11's readbacks do not record a thread spawn's CPU or its wake depth.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1153 at 501c228b3 (commits 791d0a23b, 501c228b3 over #1117 c9f88d36e)

Architecture

The placement rule stays in CpuHandles::place, the one place the kernel and the simulator share. Callers supply the new input as Option<Near>. Only process::spawn_thread passes Some. The loader, kthread and relocate pass None and behave exactly as before. That is the right owner and the right boundary, and I am not reopening the approved key order. Production growth is about 35 lines; test and simulator growth is about 150.

Points checked and found sound:

  • Lock order. The walk loads one atomic per thread and takes no lock. place already ran under PROCESS_TABLE before this PR, because enqueue_new sits inside the guard. No new lock edge.
  • Races. Each state word is read atomically, under the table lock. A ThreadEntry and its sched Arc leave the table only under that lock, so nothing is freed during the walk. A sibling that migrates or exits during the walk yields a stale home, which is harmless for a heuristic. Dead maps to None.
  • Concurrent spawns. Two spawns into one process are serialized by the table lock. The second sees the first's InTransit/Ready home, because set_sched runs before drop(guard). So a burst from several spawners spreads too.
  • The spawner's CPU. percpu::cpu_id() is read with preemption off (sync::Lock::acquire, kernel/src/sync.rs:137), and stays off through enqueue_new. It is therefore exact.
  • Affinity (sched: a thread runs only on the CPUs its process's mask names #1116). mask.contains filters candidates before the key is computed. Near can only reorder CPUs inside the mask. If the spawner's own CPU is outside a freshly narrowed mask, it only affects the away distance.
  • RT and fair bands. No interaction. A new thread starts with RtState::default(), relocate (the path RT tasks take) passes None, and both new terms rank after load, sleep and busy siblings.
  • Host tests. I checked both by hand against two_llcs() (cpu1's LLC is {1,3,5,7}, its core {1,5}). Test 1 tells homed-before-away from the reverse order. Test 2 tells busy-siblings-before-homed. Each also fails if its own term is removed.
  • Controls. The logs show the controls failing where the PR says (control-homes*.log, control-away2.log).
  • Metal. ab11's M1 (every w4 waiter on its own CPU) checks the kernel-side walk on the real machine, independently of the host and simulator tests. Without that walk, ab10 packed the waiters.
  • Host CI. The run at 501c228b3 is clean (ci-spawn2.rev = 501c228b3, EXIT=0).

Findings

BLOCKER 1: kernel/src/process.rs:717-719. Each spawn walks every thread the process has, under the global PROCESS_TABLE ticket spinlock with preemption off. The PR neither bounds nor measures this.

  • MAX_THREADS is 4096, and the count includes unjoined zombies, which keep their sched. A 4096-thread process therefore makes one spawn do up to 4096 BTreeMap steps.
  • Each step dereferences an Arc to a per-task state line, which its home CPU writes on every block and wake. Each step also does a 128-byte CpuMask::one plus a union.
  • Estimate, not measured: at roughly 50–150 ns per remote line, that is 0.2–0.6 ms per spawn. During that time every other CPU contending for the table lock spins with preemption off.
  • Ramping a process to N threads becomes O(N²): about 8M reads to reach 4096. Before this PR, spawn under the lock was O(log N).
  • process_stats already walks one process's threads under the same lock, so the worst case per hold is not new. Making that cost part of every thread creation is new, and the PR's description ("one state word per thread") and its Risks do not mention it.
  • No run at 501c228b3 exercises the scale case. std_threads (MAX_THREADS+8 detached) ran only at 791d0a23b, before the walk existed. abuse_thread_table (4095 zombies, then refusals) does not appear in the evidence.
  • Cheapest sufficient fix, with no change in behaviour: stop the walk once homes covers every CPU of mask (in practice mask ∩ online). From that point the homed term is the same for every candidate, so place picks exactly the same CPU.
    • This bounds every spread process at roughly a few times cpu_count threads.
    • It leaves only a process whose threads crowd onto fewer CPUs than its mask allows, such as a pinned pool.
    • Set the bit in place (a CpuMask::insert, or FromIterator<CpuId>) instead of building a 128-byte mask per thread.
  • Then either state the remaining worst case honestly (in the PR Risks, or in issues/ if it is judged a weakness), or bound it harder. One harder bound: .rev().take(k) over the newest tids, which are the ones a burst creates. That is a policy choice, so it is the implementer's call.
  • Run std_threads_count_the_cpus_and_give_back_ended_threads and the abuse_thread_table test on QEMU at the fixed head. This shows that the scale paths still complete inside their ceilings. It is not a timing claim.

BLOCKER 2: none.

On the +4% thread.spawn_join.default / .stack64k (~0.8 µs)

  • The walk is not a plausible cause (inference). In those rows the process has one or two threads. The walk is then two BTreeMap steps, two loads of hot lines, two 128-byte mask operations, and a 136-byte larger NewTask copied by value. That is tens of nanoseconds, not 800.
  • An incremental per-process occupancy summary would not avoid it either. At n≈1 there is nothing to save. It would also move a cost onto every home change (migration, cross-CPU wake, exit) in the pure scheduler, which has no process reference today, and it duplicates state the task words already hold. I do not recommend it. The early exit above addresses the only real cost of the walk.
  • More plausible candidates (unverified):
  • Cheapest attribution: if ab11's diagnostic records each thread spawn's chosen CPU and that CPU's idle_exit, compare the two arms' distributions from the existing readbacks. That costs no boot. Optional. The pre-set rule classed these rows as moded, and the PR reports them.

SUGGESTIONS (optional)

  • kernel/src/process.rs:717-719,735: use crate::sched::driver::current_cpu() (already used beside current_mask()) and imports instead of five fully qualified kernel::sched::… paths.
  • The homes fold is written three times: kernel process.rs:717, simulator vm.rs:1539, and the host test helper. A FromIterator<CpuId> for CpuMask turns all three into collect(). A pure fn homes(states: impl Iterator<Item = TaskState>) -> CpuMask would also let the simulator share the kernel's derivation, in the same spirit as place's "a rule written twice" comment.
  • kernel/pure/sched/cpu.rs:2638-2645 (Near doc): "near which it starts" overstates the rule, which only prefers the spawner's LLC. The sentence about the burst could be cut to its constraint: a parked thread raises no load, so homes stands in for it.

PR description (for the orchestrator)

SEND BACK

Base automatically changed from sched-spawn-tiebreak to main November 4, 2026 06:46
Japabu and others added 3 commits November 4, 2026 07:47
Spawn placement ranked CPUs by queue, sleep, busy SMT siblings and wake
depth, and broke ties by a rotation over the whole machine, so a thread
spawned to work with its spawner started in either LLC. On the AMD bench
ab9 measured what that costs a thread streaming to its spawner: every
pipe.throughput round whose writer thread the rotation put in the other
LLC read about 0.42x at 16384 bytes per write and 0.48x at 65536, in
both arms and without exception
(issues/a-spawned-thread-starts-in-any-llc-not-its-spawners.md, #1151).

`CpuHandles::place` takes the spawner's CPU as `near` and, after the
queue, the sleep and the free core, prefers a CPU in its LLC; a free
core elsewhere still beats a shared core near, so a burst of threads
spreads over every free core before it doubles up. A thread spawned into
its own process passes its CPU; a new process's first thread, a kernel
thread and a relocation pass none and place as before. The simulator's
`Op::Spawn` passes the spawning step's CPU, as the kernel does.

Tests: `a_spawned_thread_starts_on_a_free_core_in_its_spawners_llc`
(host) and `a_spawned_partner_starts_in_its_spawners_llc` (simulator,
AMD shape, 40 seeds: no kick crosses the LLCs but the spawner's own
move). Without the LLC term both red; with the simulator passing no
spawner, the simulator test reds with 11 crossing kicks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll home

ab10 read wake.storm.drain.w16 17% slower with the near term, .w4 4%. The
storm spawns its waiters in bursts and each parks at once. A parked
thread raises no published load, so every waiter saw its spawner's LLC
as free cores, and ties broken from a rotating start wrapped onto the
LLC's first CPUs. The simulator reproduces it: a thread on cpu7 of the
AMD shape spawning 16 that park put them on cpu0-5, up to four to a CPU,
where without the near term they covered 14 CPUs. A storm then woke a
CPU's waiters one after another.

The spawn now carries `Near { spawner, homes }`. `homes` is every CPU
the process's threads call home, which `process::spawn_thread` reads
off the table it already holds, one state word per thread. Placement
ranks a CPU no thread of the process calls home ahead of the spawner's
LLC. A burst therefore fills free cores no sibling holds, near ones
first, before doubling up anywhere. A thread that works with its
spawner still starts beside it while that LLC has such a core.

Tests: `a_spawned_thread_passes_over_cpus_its_siblings_call_home`
(host) and `a_burst_of_parked_threads_is_spread_over_free_cores`
(simulator, `scenarios::spawned_burst`, 40 seeds). Each spawn's CPU is
now recorded as `Outcome::placed`. Without the homes term both red: the
burst shares cpu2 and cpu3. Without the LLC term the near tests still
red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or share it

A thread spawn walked every thread of its process under PROCESS_TABLE
with preemption off: up to 4096, unjoined zombies included, so growing a
process to N threads cost O(N^2) state-word reads.

`homes` (pure, shared by `process::spawn_thread` and the simulator's
`Op::Spawn`) now reads siblings newest first, sets each home bit in
place, and stops:
- once the homes cover every CPU of the new thread's mask on the CPUs
  online, from which the homed term ranks every candidate alike and the
  placement cannot change; or
- after HOMES_READ = 64 siblings. A process with no more threads than
  that places exactly as before. Past it, the oldest go unseen, so a CPU
  only they call home may look free: the bound trades that for a spawn's
  cost under the lock, at most 64 state-word reads.

`CpuMask` gains `insert`, `covers` and `FromIterator<CpuId>`, and the
kernel names `driver::current_cpu` and imports in place of full paths.
`Near`'s doc says the LLC is preferred, not that the thread starts beside
its spawner.

Tests `homes_stop_once_they_cover_the_mask` and
`homes_read_at_most_their_bound`; without the early stop, or without the
cap, each reds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 of #1153 at 8c9738802 (follow-up on r1's BLOCKER 1 only)

Assessment

The fix fits the existing ownership. cpu::homes is a pure function beside place. It is the one derivation both the kernel (process::spawn_thread) and the simulator (Op::Spawn) now call, so r1's "rule written twice" concern is gone as well. home_of became private again, CpuMask gained insert, covers and FromIterator<CpuId>, and r1's suggestions on paths and the Near doc were taken. Net growth is about +30 production lines and +25 test lines.

1. The bound holds under the lock: verified

  • homes applies take(HOMES_READ) before reading any state word, so each spawn does at most 64 state() loads and 64 covers checks under PROCESS_TABLE.
  • take counts after the caller's filter_map(|t| t.sched()…), so threads without sched are skipped uncounted. That skip is not unbounded. ThreadEntry::sched is None only between threads.insert and set_sched (process.rs:131), and both run in one hold of the table lock. The walk runs before the new thread's insert, so the iterator never skips anything. Zombies keep sched, so they count toward the 64.
  • IdMap::iter is now a BTreeMap iterator with DoubleEndedIterator. .rev() costs O(log N) to start and O(1) amortised per step, so the work under the lock is O(log N + 64).
  • IdMap keys are never reused and only increase (id_map.rs:4), so .rev() really does visit the newest first.
  • Evidence: the T14 readback at 8c9738802 (build line 8c973880241a…) shows test_rs_abuse_thread_table with 4096 spawns ending exit=0 in 166 ms CPU time.

2. Behaviour is identical up to 64 siblings: verified

  • At 64 siblings or fewer, take cuts nothing. The early stop is the only possible difference.
  • place uses near.homes only through homed(cpu), and only for candidates with mask.contains(cpu) in 0..handles.len().
  • handles.len() equals smp::cpu_count() (driver::init, driver.rs:372-376). In the simulator, scenario.cpus() plays the same role. Both callers pass mask.within(<that count>), using the same mask that goes to place.
  • So once homes.covers(within) holds, homed is true for every candidate. Reading more siblings could only set bits for CPUs outside the candidate set. The ranking does not change.
  • If within is empty, the walk stops after one read, and place would panic the same way as before.

3. Tests and Risks

  • homes_stop_once_they_cover_the_mask fails without the early stop, because cpu2 would be read.
  • homes_read_at_most_their_bound fails without the cap, because cpu64 would be read. CpuMask::ANY keeps the early stop out of the way, and the 65 CPU ids fit in MAX_CPUS.
  • Both make a meaningful claim cheaply, on the host.
  • The Risks entry for processes over 64 threads is accurate: the oldest siblings go unread, at most 64 state-word reads are done, and the cost was O(N²) before. The rewritten entries on the stale-home race and on spawn_join attribution match r1's analysis.

Findings

No blockers.

SUGGESTION: kernel/pure/sched/cpu.rs (doc of homes_read_at_most_their_bound) — the test says "newest first", but homes reads whatever order its caller gives. The newest-first order comes from the callers' .rev(), and this test cannot detect it. Drop "newest first" from the test's doc, or leave it.

SUGGESTION (Risks wording, for the orchestrator): "A process of more than 64 threads" includes unjoined zombies, which count toward the 64. A process with many zombies and a few live threads can therefore miss its live siblings' homes. This fits "threads" as the table counts them. Saying so would make the entry exact.

LAND

@Japabu
Japabu marked this pull request as ready for review November 4, 2026 07:57
@Japabu
Japabu added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit 1ed879c Nov 4, 2026
10 checks passed
@Japabu
Japabu deleted the sched-spawn-near branch November 4, 2026 08:15
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