Repository navigation
sched: a thread runs only on the CPUs its process's mask names - #1116
Conversation
41aa876 to
aef1e41
Compare
Review of #1116 at
|
|
Review r1 is addressed in
I also took the suggestions: partners are unpinned once parked, before any round, in both benches; |
3c2fd57 to
0295551
Compare
|
Review r2 is addressed (stack head
|
0295551 to
b0a338f
Compare
b0a338f to
fd114da
Compare
Review r2 of #1116 at
|
fd114da to
9fa3070
Compare
3b7debc to
1b9e7c5
Compare
9fa3070 to
7d60f48
Compare
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>
7d60f48 to
d3be2ca
Compare
…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>
…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>
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_busystarted 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 nosched_setaffinity, std'savailable_parallelismignored any restriction, andcore_affinityhad no ToyOS arm.Behaviour
SYS_THREAD_SET_AFFINITY(tid, words, len)andSYS_THREAD_GET_AFFINITY(tid, buf, len): a bitmap of up to 1024 CPUs (16 words, Linux'sCPU_SETSIZE) by the kernel's dense index.tidnames a thread asthread_join's does, 0 the main thread; a thread names itself bycurrent_tid(), which reads its control block. There is no "self" sentinel.SYS_CURRENT_CPUreports the caller's CPU in the same numbering.NotFound.InvalidArgument. libc'ssched_setaffinityfirst drops CPUs past the online count, as Linux does, and answersEINVALonly if nothing is left.place);affinity_fence, andloom_affinitymodel-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", andbuild/ci.rsrequires that.cpuNfor the index andlapic=,apicormpidrfor hardware IDs. osbench's tail diary drops its APIC-to-index translation.Userland and fork:
available_parallelismcounts the mask.sdk/libcgainssched_setaffinity,sched_getaffinity,sched_getcpuandcpu_set_twith its macros.core_affinitygets atarget_os = "toyos"arm overtoyos-abi: ToyOSOrg/core_affinitytoyos-0.8.3, pinned at9b2f9bdintests/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.place::spawn_onis 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.
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.masked_siblingsreproduces it: I15 finds cpu0 and cpu1 idle beside work they may run (red before the fix, control).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.CpuMask::ANY, so the count is exact.offers_surplussays there is a surplus.answer_steal_requestskept 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.takesfrombest_victimredsmasked_siblingsand the host testa_steal_probe_passes_a_surplus_its_mask_leaves_out.takesfrombalance_busyredsa_busy_balance_passes_a_heavier_surplus_its_mask_leaves_out.a_probe_to_a_cpu_holding_only_pinned_tasks_is_dropped.Tests
single,within, the seqlock version; one per placement route).masked_stealscovers pinned, partial and self-moving masks plus a spawn under a mask, across 40 seeds.masked_siblingscovers masks across SMT siblings on the T14's shape, across 40 seeds.affinity_pinspins throughcore_affinityto 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_threadschecks that the mask is counted, and that a spawned thread and a spawned process each start with it.Checks
Evidence:
pr-1116/r1,r2,r3(review r1) andr4(review r2 and the rebase onto24a538c88).cargo run -- --ci host130237c670295551c9osbench_schedon 4 TCG CPUs (the r2 probe)a2fd3af47wake.onto_busymeasured (n=200),osbench_schedexit 0affinity_pins_hold_on_every_cpu,std_threads_count_…(TCG, 2 CPUs)130237c67(0295551c9touches only osbench)cargo run -- --ci host3c2fd574a, review r1a_masked_queue_past_a_siblings_is_stolenbest_victimandbalance_busyeach ignoring takersloom_affinity, and withaffinity-fence-relaxed4250cc57awake.onto_busyandmem.munmap_touched_remote.4kmeasured on every boot carrying the osbench fix, AMD and T14 (pr-1111/r2).a2fd3af47, sched: a thread runs only on the CPUs its process's mask names #1116 plus sched: a spawn wakes the shallowest of its equal free-core sleepers #1117 (ab4,pr-1116/r5): three boots, rc=0 each.osbenchpassed on both benches, andwake_onto_busypassed on the T14.wake.onto_busymeasured n=200 on both benches, andmem.munmap_touched_remote.4kmeasured n=2000 withwindows_refused=0/10. Every osbench group exited 0, and no row refused.boot:benchcasetook in. They say nothing about this change.0295551c9's power-reader fixes.b0a338fe2). That removed only the parker's mask check and its test. Host CI ran at the stack's top, sched: a woken partner follows its waker's LLC #1129'sb8bd0ea02, and passed all 108 steps; the merge queue reruns it after retargeting.8122a4ef5(after console: the VM's console is a userland virtio-serial driver's, and the UART goes quiet while it carries the log #1062, clock: the TSC keeps CPUID 15H's rate or one the HPET refines; a step the RTC confirms is slewed, a second demotes it #1104, sched: a wake its poster parks or exits behind runs where it was posted #1111, IPC phase 2: a connection carries whole messages #1121, paged objects: a file's pages are the kernel's, read by its holders without a request (memory stage 7a) #1123 and osbench: a row says where its processes ran #1126; headd3be2ca10). Main took 152–160 forSYS_CLOCK_NOW, the paged-object calls,SYS_MESSAGE_RECVandSYS_CONSOLE_CARRIED, so the affinity calls are now 161–163,SYSCALL_PROFILE_BINSis 165 and the compile guard namesSYS_CURRENT_CPU. Host CI at the stack's top (8a1357642): 109 steps, all green, exit 0 (log). Before that rebase: Every user names them by constant (kernel dispatch,toyos-abiwrappers, libc, std, and thecore_affinityfork through the patchedtoyos-abi), so nothing else changed. Host CI at the stack's top: 109 steps, all green, exit 0 (log).fd114da8b). One conflict, inSchedPass::beginandplace, where sched: a wake its poster parks or exits behind runs where it was posted #1111 now asserts the previous pass was disposed and splitsplacefor its held tasks. This PR's outbound relocation and mask check sit unchanged inside them;git range-diffshows no other change to its commits. Host CI at the stack's top, sched: a woken partner follows its waker's LLC #1129's1178599c4, passed all 108 steps (log).b0a338fe2andfd114da8b. Every metal boot of this PR (ab3's head28a55585cand ab4'sa2fd3af47) also carried sched: a spawn wakes the shallowest of its equal free-core sleepers #1117's spawn tie-break, which sits on top of it. The tie-break changes only which CPU a new thread starts on, among the CPUs its mask names. Every pin and every move still goes through this PR's mask path, and each bench verifies its pins against where the thread actually ran (affinity_pinsagainst the x2APIC ID, osbench'sspawn_onagainstcurrent_cpu). So the pin, refusal and measurement results hold without sched: a spawn wakes the shallowest of its equal free-core sleepers #1117.Metal A/B (base
0c7e62ca7, head28a55585c= #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_pinspassed 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.pingpong.*.same_cpureadsplaced_held41/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.wake_onto_busy(the rust test) passed on all 8 head boots. Base T14 boot 3 refused it (-5, the waker left its CPU).osbench_schedexited 1, refusingwake.onto_busy("no CPU but the waker's and the interrupts' to keep busy"), andmem.munmap_touched_remote.4kwas refused.sys::allowed_cpusread the calling thread's mask, so inside a pinned thread it named one CPU.wake.onto_busyexactly as metal did, and the fixed tree measured it (probe/).Risks
SeqCstfence (affinity_fence, anmfenceon 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_memunder TCG goes quiet aftermem.cpu.clockand hits the harness's quiet ceiling. It does so identically at the stack's base49559ddb2and 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_CPU0 in osbench andwake_onto_busyis the kernel's cpu0, because the kernel aims every MSI at APIC ID 0 (apic.rs'sMSG_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, sayspercpu: 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 ownMSG_DESTbefore it breaks these benches. That belongs toissues/every-interrupt-lands-on-the-boot-cpu.md.core_affinity's ToyOS arm needs atoyos-abiwith the new calls. The guest tests build it against the tree'stoyos-abi, and nothing is published yet.🤖 Generated with Claude Code