Skip to content

sched: a thread runs only on the CPUs its process's mask names - #1116

Merged
Japabu merged 7 commits into
mainfrom
sched-affinity
Nov 1, 2026
Merged

Japabu merged 7 commits into
mainfrom
sched-affinity

Conversation

@Japabu

@Japabu Japabu commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #1111 (step 1): review only the commits past it.

Problem

ToyOS had no way to keep a thread on a CPU. osbench and wake_onto_busy started threads until one happened to land where they were wanted. That premise was false whenever the spawner sat on the wanted CPU, because spawn placement never chooses a running CPU while another one sleeps. diag2 showed exactly that on the T14: 3,480 and 18,192 tries in one boot, and seconds-long refusals elsewhere (issues/a-spawn-can-miss-a-cpu-holding-parked-threads-for-seconds-at-boot.md). C programs had no sched_setaffinity, std's available_parallelism ignored any restriction, and core_affinity had no ToyOS arm.

Behaviour

  • ABI (numbers 161–163, after main's clock, paged-object, message and console calls; the profile has 165 bins):
    • SYS_THREAD_SET_AFFINITY(tid, words, len) and SYS_THREAD_GET_AFFINITY(tid, buf, len): a bitmap of up to 1024 CPUs (16 words, Linux's CPU_SETSIZE) by the kernel's dense index. tid names a thread as thread_join's does, 0 the main thread; a thread names itself by current_tid(), which reads its control block. There is no "self" sentinel.
    • SYS_CURRENT_CPU reports the caller's CPU in the same numbering.
  • Authority: the thread id is resolved in the caller's own process only, so another process's thread reads NotFound.
  • No ceiling, and widening is allowed (owner ruling, 2026-10-28):
    • The mask is a placement choice, not an authority, so "rights only narrow" does not apply.
    • Any non-empty set of online CPUs is accepted, wider or narrower than the last.
    • A per-process ceiling waits until a real caller needs one.
  • Refusals: an empty mask, a CPU at or past the online count, or more than 16 words is InvalidArgument. libc's sched_setaffinity first drops CPUs past the online count, as Linux does, and answers EINVAL only if nothing is left.
  • Inheritance: a new thread or process starts with its spawning thread's mask, as on Linux. Boot spawns and kernel threads get every CPU.
  • Enforcement, at every route a task reaches a CPU by:
    • wake, adopt, deadline and retire (place);
    • wake forwards;
    • the pick;
    • a running task whose mask moved;
    • steals, where only a task the thief may run is handed over and a task pinned to one CPU is not published as surplus;
    • spawn placement.
  • How a change reaches the task: the owner CPU decides with an owned copy of the mask and refreshes it from the published one (a sequence lock) only while the task is in no queue. A setter publishes, fences and kicks the CPU the task's state names. An adopt fences before its refresh, so a move the setter's read missed still lands with the new mask. That store-buffering pair is one function, affinity_fence, and loom_affinity model-checks it: a task moving cpu0→cpu1 while its mask is set ends with cpu1 kicked or cpu1's refresh at the new version. Its control, affinity-fence-relaxed, reds it with "neither saw the other", and build/ci.rs requires that.
  • Unchanged:
    • A killed task unwinds where it is.
    • The per-CPU realtime allowance is charged where time runs, so a pin cannot escape it. A pinned RT task that queues behind another RT task waits there instead of being forwarded.
  • One CPU numbering: osbench names CPUs by the kernel index, as the kernel's records do; the kernel's own lines already say cpuN for the index and lapic=, apic or mpidr for hardware IDs. osbench's tail diary drops its APIC-to-index translation.
  • A machine past 1024 CPUs is refused at boot by name.

Userland and fork:

  • std's available_parallelism counts the mask.
  • sdk/libc gains sched_setaffinity, sched_getaffinity, sched_getcpu and cpu_set_t with its macros.
  • core_affinity gets a target_os = "toyos" arm over toyos-abi: ToyOSOrg/core_affinity toyos-0.8.3, pinned at 9b2f9bd in tests/guest/rust-tests/Cargo.lock. Its last commit names the calling thread by its id. It is upstream-quality: one new arm at the crate's existing dispatch, a toyos-only dependency, rationale in the commits.
  • osbench's place::spawn_on is one pin-then-verify implementation on both systems. wake.onto_busy's skip counters, wake_onto_busy's restaging and both statistical stagers are deleted.

Balancing reads the masks: each CPU publishes who may take its surplus

A steal that ignores masks livelocks, and this PR found the same bug three times.

  • The push, found by the simulator's masked_steals. cpu2 queued a task masked to {1, 2}, and the push rang cpu0. cpu0's probe was answered empty, and the probe's pass on cpu2 rang cpu0 again: 20,000 steps at one instant. On hardware that is an IPI storm against a busy CPU.
  • The pull, found in review. On the T14's cores {0,4} {1,5}:
    • cpu4 runs a thread masked {1,4} and queues a second; cpu5 does the same with {0,5}.
    • Each idle CPU probes its nearest victim, its SMT sibling, which holds only work it may not run, and is answered nothing. That victim's pass rings the other idle CPU, and the cycle repeats.
    • The simulator's new masked_siblings reproduces it: I15 finds cpu0 and cpu1 idle beside work they may run (red before the fix, control).
  • The fix: each CPU publishes, with its surplus, which CPUs may take it (CpuHandle::takes). That is the union of its movable queued tasks' masks. The probe, its re-check after SLEEPING, the busy balance and the push all filter on that one answer, so push and probe ask the same question.
  • The union's cost:
    • O(1) while any queued task's mask names every CPU, which the run queue counts. The setter stores a mask naming every online CPU as CpuMask::ANY, so the count is exact.
    • A queue whose movable tasks are all restricted is walked: O(queued fair tasks) with 16-word ORs, then 16 relaxed stores. Both happen only on a pass whose surplus reaches the threshold; a pass below it writes no takers, since every reader reads them only while offers_surplus says there is a surplus.
    • 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 that CPU's next pass with surplus.
  • A second livelock, of the same kind: answer_steal_requests kept a probe and asked for another pass whenever its fair band was non-empty. The premise was that the one task left was the one the CPU stood on, but with tasks pinned there nothing ever becomes movable. It now asks only while a movable task is queued.
  • Controls:
    • The push's: the simulator fails to terminate.
    • The probe's: dropping takes from best_victim reds masked_siblings and the host test a_steal_probe_passes_a_surplus_its_mask_leaves_out.
    • The busy balance's: dropping takes from balance_busy reds a_busy_balance_passes_a_heavier_surplus_its_mask_leaves_out.
    • The kept probe's: a_probe_to_a_cpu_holding_only_pinned_tasks_is_dropped.

Tests

  • Host: 12 new tests (mask validation, single, within, the seqlock version; one per placement route).
  • Simulator:
    • the core's per-pass invariant now asserts that no CPU runs a task outside its mask;
    • masked_steals covers pinned, partial and self-moving masks plus a spawn under a mask, across 40 seeds.
    • masked_siblings covers masks across SMT siblings on the T14's shape, across 40 seeds.
  • Guest:
    • affinity_pins pins through core_affinity to each CPU in turn, beside a spinner pinned there, across 1000 sleeps, against the CPU's own x2APIC ID. Then another thread pins the main thread by tid 0, and the main thread must run there. It runs on the shared boot (each bench) and on its own under TCG.
    • std_threads checks that the mask is counted, and that a spawned thread and a spawned process each start with it.
  • Mutations: one per route, each reds its own test (table in the evidence). Spawn placement ignoring the mask also hangs the relocate loop, which is itself a red.

Checks

Evidence: pr-1116/r1, r2, r3 (review r1) and r4 (review r2 and the rebase onto 24a538c88).

check revision result
cargo run -- --ci host the rebased stack 130237c67 107 of 108 green; the red is osbench clippy on Linux (main's new power reader), fixed in 0295551c9
that red step's two clippy commands the fix applied exit 0 each
osbench_sched on 4 TCG CPUs (the r2 probe) #1116 plus #1117 at a2fd3af47 wake.onto_busy measured (n=200), osbench_sched exit 0
affinity_pins_hold_on_every_cpu, std_threads_count_… (TCG, 2 CPUs) 130237c67 (0295551c9 touches only osbench) both PASS
cargo run -- --ci host 3c2fd574a, review r1 108 steps, all green
a_masked_queue_past_a_siblings_is_stolen before the fix; after it exit 101 (I15); exit 0
best_victim and balance_busy each ignoring takers mutations each reds its test, exit 101
loom_affinity, and with affinity-fence-relaxed 4250cc57a exit 0; exit 101
the 10 placement and livelock mutations the affinity commit before its fold each reds only its own tests

Metal A/B (base 0c7e62ca7, head 28a55585c = #1116 plus #1117, 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).

  • affinity_pins passed on all 8 head boots. On the T14 it held 8 CPUs, with cpu0=x2apic0 through cpu7. On the AMD it held 16, also with cpu0=x2apic0.
  • Pinned pairs now hold:
    • pingpong.*.same_cpu reads placed_held 41/41 on every head boot. Base read 0/41 on every boot of both benches, so base never measured a same-CPU pair at all.
    • Same-CPU latency on head: pipe −14% (T14) and −8% (AMD), futex −18% and −16%, all resolved. This is head measuring the stated pair, not the same pair getting faster.
    • The cross-core pairs held 41/41 on head. Base read 0, 8 or 11/41 on some boots.
  • wake_onto_busy (the rust test) passed on all 8 head boots. Base T14 boot 3 refused it (-5, the waker left its CPU).
  • This PR's osbench port had a defect: osbench was red on all 8 head boots.
    • osbench_sched exited 1, refusing wake.onto_busy ("no CPU but the waker's and the interrupts' to keep busy"), and mem.munmap_touched_remote.4k was refused.
    • Cause: ToyOS's sys::allowed_cpus read the calling thread's mask, so inside a pinned thread it named one CPU.
    • Fixed in "osbench: a placement reads the CPUs the process began with": both systems read the main thread's mask once at start.
    • On a 4-CPU TCG guest the unfixed tree refused wake.onto_busy exactly as metal did, and the fixed tree measured it (probe/).
    • Owed: both rows on metal with the fixed image.

Risks

  • Cost:
    • Every pass makes one atomic load of the running task's mask version.
    • A pass with surplus computes the takers union, O(1) in the common case and a walk otherwise, as above.
    • Every adopt pays a SeqCst fence (affinity_fence, an mfence on x86). An adopt is a cross-CPU move, which already pays for a mailbox post and usually an IPI. I have not measured the fence separately; my estimate is tens of cycles beside that.
  • osbench_mem under TCG goes quiet after mem.cpu.clock and hits the harness's quiet ceiling. It does so identically at the stack's base 49559ddb2 and at this head, so it is independent of this PR. Its first row, memcpy_sweep, prints nothing until it ends; a row slower than the ceiling under TCG is the likely cause, unverified. The fixed mem row is verified on metal only once the queued AMD and T14 boots run.
  • IRQ_CPU 0 in osbench and wake_onto_busy is the kernel's cpu0, because the kernel aims every MSI at APIC ID 0 (apic.rs's MSG_DEST) and calls that cpu0. Nothing asserts that the BSP's APIC ID is 0. Every one of the 2,442 boot logs in this job's readbacks, T14 and AMD bench alike, says percpu: BSP cpu_id=0 lapic_id=0, so both benches meet it. A machine whose BSP has another APIC ID would break the kernel's own MSG_DEST before it breaks these benches. That belongs to issues/every-interrupt-lands-on-the-boot-cpu.md.
  • core_affinity's ToyOS arm needs a toyos-abi with the new calls. The guest tests build it against the tree's toyos-abi, and nothing is published yet.

🤖 Generated with Claude Code

@Japabu

Japabu commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1116 at aef1e4138 (the 4 commits past sched-place 2ad4439e6)

Architecture

The design follows the owner's approval:

  • Each CPU decides with its own copy of the mask, refreshed from a seqlocked published copy only while the task's value is in no queue.
  • One realign kick sits on the setter side, paired with an affinity_fence on adopt.
  • The checks are host tests, the sim's per-pass invariant, a loom model with a required control, and a guest test against the CPU's own x2APIC ID.

The parts I checked hold:

  • Authority: own_thread resolves only in current_process(), so no path reaches another process's thread.
  • Refusals: an empty mask, an offline CPU and more than 16 words are refused. So is a GET buffer under 16 words, and a tid past u32 reads NotFound.
  • Inheritance: thread and process spawns take current_mask(), and kthreads get ANY. std_threads checks both spawns.
  • pinned_fair accounting: it is consistent. A value is counted on entry to the fair band and uncounted on leaving it, and the owned mask changes only on a value held outside the queue.
  • Fence: the fence pairing is a real store-buffering pair. loom_affinity reds under affinity-fence-relaxed ("neither saw the other", exit 101). The pair's files have not changed since the model was run. Its adopter restates TransitTask::adopt (pub(crate)) rather than calling it, as loom_retire already does. That is acceptable.
  • core_affinity fork: it is upstream-quality: one arm at the crate's existing dispatch, a toyos-only toyos-abi = ">=0.16, <1" (memmap2's pattern) and rationale in the commits.
  • Building the fork: inside the tree it builds through rust-tests' [patch]. Outside the tree it builds only once main's publish assigns the next minor of toyos-abi. Below that, the >=0.16 bound admits versions without calls 148–150. That is acceptable under build/sdkversion.rs's model.

Net growth: about +973/−112 in production files, of which roughly 270 are in-file host tests. Test trees add +641/−354, mostly the deleted staging.

Findings

BLOCKER 1: tid 0 means "the caller", but 0 is also the main thread's real tid.

  • Where: sdk/abi/src/syscall.rs:1191 and kernel/src/syscall/proc.rs:212.
  • Problem: kernel/src/process.rs:172 gives every main thread Tid(0).
  • Consequence: no other thread can set or read the main thread's mask. A worker that passes the main thread's real tid silently sets its own mask.
  • This contradicts the ABI's own doc ("NotFound for a tid that is not a live thread of this process"). It also contradicts thread_join(0), which means the main thread.
  • Fix: callers pass a real tid (toyos_abi::current_tid() reads the TCB), or the ABI uses a sentinel that is never a tid.

BLOCKER 2: the pull side is still mask-blind. A nearer victim's unusable surplus shadows usable work for good, and the push and probe can cycle.

  • Where: kernel/pure/sched/cpu.rs:2361 and kernel/pure/sched/cpu.rs:2400.
  • Problem: the push now rings only a sleeper in movable_mask(). The woken sleeper still probes best_victim(), the nearest CPU publishing a mask-blind surplus. best_victim's own doc warns about exactly this: "two spellings of one inequality is how a push that wakes a CPU its victim would refuse gets written".
  • Trace (inferred from the code, not run):
    • Machine: the T14's cores {0,4} {1,5}, with PushOnSurplus. cpu0 and cpu1 are idle.
    • cpu4 runs one thread masked {1,4} and queues a second.
    • cpu5 does the same with threads masked {0,5}.
    • cpu4's pass rings cpu1, the only sleeper in {1,4}.
    • cpu1's probe goes to its sibling cpu5 and is answered nothing. cpu5's pass rings cpu0.
    • cpu0 probes its sibling cpu4 and is answered nothing. cpu4 rings cpu1 again.
  • Consequence: the IPIs into the two busy CPUs never stop, and cpu1 never takes cpu4's queued thread. That is the same class of livelock this PR says it fixed, and one process can reach it with four sched_setaffinity calls.
  • Why the tests miss it: masked_steals runs on a flat 4-CPU shape, where no nearer victim exists.
  • Cheapest check: a host test in cpu.rs on an SMT topology (two cores of two threads) staging the two victims, or masked_steals at an SMT shape under I15.
  • Fix direction: push and probe should ask one question, for example a per-CPU published mask that best_victim filters on. This interacts with blocker 3.

BLOCKER 3: a per-pass O(queue) walk on the hot path, and a false cost claim.

  • Where: kernel/pure/sched/queue.rs:390, called at cpu.rs:2400.
  • Problem: movable_mask() walks every queued fair task and ORs 16 words for each. It runs on every pass whose surplus is at least 1 (PUSH_THRESHOLD = 1), before idle_near has found any sleeper. A saturated machine pays it on every pass for nothing.
  • False claim: the PR's Risks says "Per-pass cost is one atomic load of the running task's mask version".
  • Unmentioned cost: the new SeqCst fence on every adopt.
  • Fix: compute the union only once a sleeping, answering candidate exists, or measure the cost and correct the claim. The description correction is the orchestrator's.

BLOCKER 4: a false comment on a test that asserts nothing of its own.

  • Where: kernel/sim/tests/policy.rs:1441.
  • False comment: "the push wakes a sleeper for surplus without asking whose mask names it" describes the code before the fix.
  • No claim of its own: the test only prints numbers. Its partial arm repeats masked_threads_run_only_where_their_masks_name, and benchmarks are evidence, not regression tests.
  • Fix: delete the test, MASKED_STEALS_ANY and the partial parameter. The numbers are already in r1/checks/sim-pushcost.log.

BLOCKER 5: libc keeps a private copy of CPU_MASK_WORDS.

  • Where: sdk/libc/src/affinity.rs:9 (WORDS = 16, documented as "[toyos_abi::syscall::CPU_MASK_WORDS]").
  • Problem: libc and toyos-libc-copies (its dev-dependency) both have toyos-abi, so the copy is unnecessary.
  • Fix: use the shared declaration.

BLOCKER 6: the fixed osbench has never run to completion on any target.

  • The metal rerun of wake.onto_busy and mem.munmap_touched_remote with the fixed image is owed, as the PR says.
  • Unexplained stall: in both TCG probe runs osbench_mem went quiet and hit the stall ceiling (r2/README.md). That includes the fixed run, so the fixed mem row is verified nowhere. Nobody has established whether base stalls the same way, and the PR body leaves the stall out.
  • Cheapest check: the same probe at base, which decides whether the stall is independent of this PR. Then the metal rerun with osbench on both benches.

SUGGESTION: keep the unpin out of the timed window.

  • Where: tests/guest/osbench/src/wake.rs:251 and tests/guest/rust-tests/src/bin/wake_onto_busy.rs:212.
  • Problem: on ToyOS the unpin's realign kicks the busy CPU, where the partner is blocked, just before sent. Its pass overlaps the timed wake. Linux's sched_setaffinity on a sleeping task sends no IPI.
  • Fix: unpinning a parked partner is equivalent at any time before its wake. Unpin each partner once it has parked, or a round early, so both systems time only the wake. Worth doing before the owed rerun.

SUGGESTION: delete CPU_NAMING.

  • Where: tests/guest/osbench/src/sys/{linux,toyos}.rs.
  • It is now "cpu" on both systems, so the constant names nothing that differs.

SUGGESTION: tighten thread_affinity's doc.

  • "Errors as thread_set_affinity's, but for the mask" is unclear.
  • The raw call's refusal of a buffer under 16 words is undocumented at SYS_THREAD_GET_AFFINITY.

SEND BACK

@Japabu

Japabu commented Oct 31, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 is addressed in 3c2fd574a (the evidence is pr-1116/r3 at dc95a477, linked from the body).

  1. tid 0: there is no sentinel any more. tid names a thread as thread_join does, 0 the main thread, and a thread names itself by current_tid(). The callers changed with it: libc, std, osbench, the guest tests and core_affinity (fork 9b2f9bd, lock repinned). affinity_pins now has a worker pin the main thread by tid 0.
  2. Pull side: each CPU publishes which CPUs may take its surplus, and the probe, its re-check, the busy balance and the push all filter on that. masked_siblings (T14 SMT shape) reds I15 before the fix and passes after. A host test and a mutation that drops the filter cover it too.
  3. Cost: the takers union is O(1) while any queued task may run anywhere, using a counted ANY, and the setter makes "every online CPU" spell ANY. Otherwise it is a walk, on a pass with surplus only. The body's Risks now states that and the adopt fence's cost; the fence's cost is an estimate, not measured.
  4. The false-comment test is deleted, with MASKED_STEALS_ANY and the parameter.
  5. libc uses toyos_abi::syscall::CPU_MASK_WORDS.
  6. osbench_mem: it stalls identically under TCG at the base 49559ddb2, so the stall is independent of this PR. The metal rows are in the queued diagnostic.

I also took the suggestions: partners are unpinned once parked, before any round, in both benches; CPU_NAMING is gone; the GET doc is tightened. Host CI passed all 108 steps.

@Japabu

Japabu commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 is addressed (stack head a2fd3af47, on main 24a538c88; evidence pr-1116/r4 at 16f530f9).

  • R1: publish_surplus writes the takers only when the surplus reaches the threshold, since every reader is gated by offers_surplus. The field doc and the PR body now say that. They also say what a stale read costs: a needless probe, or a skipped one even after a ring, until the next pass with surplus.
  • R2:
    • The stack is rebased onto main. The affinity calls move to 152–154, after main's event calls.
    • Two of main's new osbench power readers used names this stack renamed. One was Linux's allowed_cpus, which host CI's clippy caught. The other was ToyOS's arch::core_of, which only the r2 TCG probe caught, since host CI does not build ToyOS osbench. Both are fixed in 0295551c9.
    • The probe at the new head measures wake.onto_busy (n=200), and osbench_sched exits 0.
    • Metal at the new head is staged: the T14's benchcase and testcases boots and the AMD's benchcase boot.
  • Suggestions taken:
    • a busy-balance host test, plus its mutation;
    • the probe test asserts after abandon;
    • the trailing blank line in policy.rs;
    • the probe logs record their revision.
  • ab3 (the pre-review code): osbench passed on all 11 boots, and the restored rows measured on every boot carrying the fix.

@Japabu

Japabu commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2 of #1116 at fd114da8b (rebase only)

git range-diff e938fe3e5..b0a338fe2 042b2b299..fd114da8b: commits 2–7 are identical. In commit 1, the only change is the conflict with #1111's place_behind/begin:

  • mut task moves to place_behind.
  • begin's disposed-pass assert now sits ahead of the outbound relocation.

Both are correct. Relocation runs before holding is set, and place_behind refreshes the mask and relocates before the hold, so a held task never waits outside its mask. Nothing reopened.

No blocker. LAND (in stack order, after #1111).

@Japabu
Japabu force-pushed the sched-place branch 2 times, most recently from 3b7debc to 1b9e7c5 Compare November 1, 2026 14:52
@Japabu
Japabu changed the base branch from sched-place to main November 1, 2026 14:59
@Japabu
Japabu marked this pull request as ready for review November 1, 2026 14:59
@Japabu
Japabu enabled auto-merge November 1, 2026 14:59
@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 2 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>
@Japabu
Japabu added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit 058bea2 Nov 1, 2026
5 checks passed
@Japabu
Japabu deleted the sched-affinity branch November 1, 2026 22:49
Japabu added a commit that referenced this pull request Nov 2, 2026
…se's display row is granted its window, and the PSR restart point is the durable brief

Rebased onto main c81fc83. #1062 took syscall 160 and #1116 took 161-163,
so SYS_DEVICE_FIRMWARE_WINDOW is 164, the first commit's message
notwithstanding. The profile has 166 bins, and the guard names 164.

#1149 runs tgl-display in benchcase "as images/system.toml runs it", so its
row carries the same `firmware = ["igd-opregion"]` grant.

The display track's PSR restart line now links the clean-room brief at its
durable evidence commit. Only facts.md there is an implementer's input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 2, 2026
…se's display row is granted its window, and the PSR restart point is the durable brief

Rebased onto main c81fc83. #1062 took syscall 160 and #1116 took 161-163,
so SYS_DEVICE_FIRMWARE_WINDOW is 164.
The profile has 166 bins, and the guard names 164.

#1149 runs tgl-display in benchcase "as images/system.toml runs it", so its
row carries the same `firmware = ["igd-opregion"]` grant.

The display track's PSR restart line now links the clean-room brief at its
durable evidence commit. Only facts.md there is an implementer's input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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