Repository navigation
The scheduler simulator runs at real machine shapes and holds work conservation as an invariant - #882
Conversation
…onservation Stage S0 of the scheduler design (#872): the simulator learns the shape of the machines it models, and work conservation becomes an invariant checked after every step. No kernel source changes. A scenario's machine is now a `topology::Shape`: each dense CPU's x2APIC id and the SMT, LLC and package widths, so that two CPUs' keys say the lowest level they share. Four shapes: the T14 and the AMD bench as their scouts read them (#865's measurement), a 4x8x2 tree and a 768-CPU tree of two packages of 24 eight-core LLCs, the last two listed under a seeded permutation. Every scenario runs at every shape, and each run reports what it did per level: migrations, wake placements, failed steals and IPIs, as counts. The bench shapes are checked against the scouts' own CPUID words. Invariant I15 is work conservation in its bounded form: no CPU stays idle for more than `cpus + 2` protocol rounds while another holds a task a probe would take, under the shipped push. A round completes when every awake CPU has passed once; a round with a kick or a message in flight to the idle CPU is not counted against it. The bound is derived for one victim; with several, the flat push bounds nothing, which is filed. The policies that claim no work conservation, the controls `policy.rs` prices the push against, carry no bound. A push one above the probe's threshold is the negative gate, and it fails on I15 alone. Running every scenario at wider shapes exposed a model gap: an awake CPU with nothing loaded could have its idle pass deferred by the explorer while the clock ran, which broke I14's bound on `rt_saturated_retire` at every shape wider than one CPU (an unreleased zombie on a CPU starved for 59 ms under PCT). Such a CPU now owes its pass like an unserved `need_resched`, under the new protocol; the old protocol's idle loop is its steal, which stays free. The change renumbers `old_migrate_kept_the_corpse`'s corpus trace, which is re-shrunk to the same violation and the same forward. The trace is drained once a step instead of read through a cursor, and the probe-gap instrument walks the machine once a step instead of once per CPU, which is what makes 768 CPUs affordable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
A mutation of `best_victim` that probes the other half of the machine only when that half's average surplus is the higher one — the group-imbalance bug — left the whole simulator suite green, at the base and on this branch: no workload put an idle CPU beside work in a half that looked busy. `scenarios::group_imbalance` stages that machine on the AMD bench's two CCXs: cpu8 idle while every other CPU of its CCX holds a task queued behind the one it runs, and cpu0 in the other CCX holding two. Under the shipped balance path it holds; under the mutation invariant I15 reds on cpu8, idle 19 rounds against a bound of 18. A `PlacementShape::Listed` adversary is what places the tasks, every CPU it names checked against the machine as `AllOn`'s is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
`Shape::level` read two CPUs as one when their keys matched, and SMT siblings share every key: each move, failed steal and kick between siblings was counted in the same-CPU column, and the core column could count nothing. The CPUs are compared by id, and a test holds the level of known pairs on both benches and across the 768-CPU tree's packages; it reds on the old comparison (`left: None, right: Some(Core)`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
…host EXIT=0, --build-only EXIT=0, the simulator, the mutations The head after the sibling-level fix; every head/ file is retaken here and replaces the one at c7bfcb6e6. head/ci-host.log: cargo run -- --ci host, EXIT=0, 73 steps all green. head/build-only.log: cargo run -- --build-only, EXIT=0. head/topology-tables.log: cargo test -p kernel-sim --test topology -- --nocapture --test-threads 1, EXIT=0, with the per-level counts at every shape. head/run-head.sh runs the rest in sequence: head/run-branch.*: the green arm EXIT=0, M1 EXIT=101, M2 EXIT=101. head/run-decisions.*: M3 EXIT=101 (I14 at the wider shapes, and the re-shrunk corpus trace), M4 EXIT=101 (I15 on wakeup_storm's 2-CPU seeds). head/multi-victim-measure.log: zz_measure.rs, a throwaway test not committed, EXIT=0.
… at 6d8c741e4 compare/policy-base.log: ToyOS 131970ac80c97bb809f51954c24b90928fa56fef, cargo test -p kernel-sim --test policy -- --nocapture --test-threads 1, EXIT=0. compare/policy-head.log: the same at 6d8c741e448203cd498569aeb51b8ff9617856ec, EXIT=0. compare/policy-diff.txt: the printed lines that differ, test names and the heads' own lines left out.
|
Review of #882 at Net size: +1027/−90. No shipped code changes. The simulator's source is +663/−78, its tests, fixtures and corpus +338/−12, and the issue +26. Controls weighed
BLOCKER
NOTE
SEND BACK |
…ees a declined probe The review of #882 found I15 blind on every two-CPU machine: the count paused for any kick pending at the step that completed a round, and the victim's push pokes the one sleeper in the same pass that completes each round there, so a CPU rung and declining to probe never accrued. Measured at 6d8c741 with the review's `best_victim` filter (`cpu.0 > self.cpu.id.0`) on `lopsided_placement(2, 3, 60 ms)` at `Shape::flat(2)`: green on 20 of 20 seeds, worst 1 round. A round now opens on every CPU that owes a pass: awake, or halted with an IPI on its way. A ring or an answer in flight holds the next round open until its target has passed, which is what the pause stood in for, so the pause goes and nothing stops the count. The bound stays cpus + 2, each term re-derived. The same filter run at this tree is red on 20 of 20 seeds, I15 at 5 rounds against 4. The committed gate is the decline staged from outside the core: `SurplusShape::Withheld` overwrites each pass's published surplus with zero after its push rang on the real one, so a rung CPU reads nothing worth a probe. `a_cpu_that_is_rung_and_declines_to_probe_is_caught` holds every schedule red on I15 alone at two CPUs and at both benches, the published surplus green beside it. "wakes placed" counted every `TraceKind::Wake`, which a retire also emits for a queued task it moves to the dying list, and missed deadline wakes, which trace `TimerFire`. It now counts tasks taken out of the acting CPU's parked set, placed where the waking pass left them. S1's debt, `Shape::level`'s key comparison and `scouted`'s CPUID decoding giving way to `Topology::from_keys` and the kernel's decoder, is recorded in the placement track. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
…1: --ci host EXIT=0, --build-only EXIT=0, the simulator, the controls; and the review case at 6d8c741e448203cd498569aeb51b8ff9617856ec
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
…a: cargo test -p kernel-sim EXIT=0, --ci host EXIT=0, --build-only EXIT=0
|
Review of #882 at Scope. It covers Size. Net +1101/−92 against
Round 1's BLOCKER
Round 1's NOTEs
Gates at the head (
BLOCKER None. NOTE None. LAND |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
Brings in #867, #878, #879, #880, #882 and #885. The one file both sides touch, kernel/src/object/ops.rs, merges without conflict: main's hunk is in `fstat` and this branch's are in `close` and `pipe_end_watch`. The branch's diff against main after the merge is its diff against the old base, 177e72a, line for line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
Three conflicts, all in the simulator #882 reshaped: - kernel/sim/src/hw_impl.rs: the kick keeps this branch's self-route (a kick aimed at the posting CPU is its own need_resched and no IPI) and takes main's `kicked` record of each IPI's target, pushed only where an IPI is sent. - kernel/sim/src/vm.rs: `execute` takes main's probing snapshot, actor, parked set, step trace and level counts, and wraps `execute_inner` in this branch's `posting_from` as before. - kernel/sim/corpus/old_migrate_kept_the_corpse_i14.trace: main's, with its provenance notes. Neither side's decisions replay on the merged tree; the next commit re-shrinks it. At this merge the simulator is red: main's I15 catches the steal's published count, which the next commits fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy
Stage S0 of the scheduler design in #872, accepted in #872 (comment). The simulator runs every scenario at the shapes of real machines, up to 768 CPUs, and reports what each run did per level of the cache tree. Work conservation becomes invariant I15, checked after every step and bounded in protocol rounds. No kernel source changes, and nothing here is in the image.
What changed, per decision
kernel/sim/src/topology.rs).Shapeis each dense CPU's x2APIC id plus the SMT, LLC and package widths, as a CPU reads them from leaf 0xB and the deterministic cache leaf. Two CPUs share a level when their keys at it are equal.Scenario::topologyreplacesScenario::cpusas the machine's one description;cpus()derives from it.topology::shapes()holds four machines:0,2,4,6,1,3,5,7, so SMT siblings are four ids apart;4x8x2, listed under a seeded permutation;2x24x8x2: 768 CPUs, two packages of 24 eight-core LLCs, also permuted. The design writes it as 48 LLCs × 8 × 2. The two packages are the research note's two-socket example, and they give the machine level something to count.Shape::flat(n): n single-thread cores in one LLC.Topology::from_keys. That is S1's and does not exist yet, so S0 compares keys in the simulator. The debt is recorded in the track S1 builds,issues/placement-is-blind-to-caches-and-topology.md: the stage that landsTopology::from_keysand the kernel's CPUID decoder movesShape::leveland the oracle test'sscouteddecoding onto them in the same change.Vm::round). A round opens on every CPU that owes a pass: awake, or halted with an IPI on its way. It completes when each has passed once. Rounds are counted in passes, not time, so a CPU whose pass the explorer defers holds its round open, and a CPU rung before a round opens holds that round open until its pass.cpus + 2rounds idle while another CPU holds a task a probe would take (fair_len >= PUSH_THRESHOLD), under the shipped push.cpus + 2is derived for one victim inwork_conservation_bound's doc:cpusrounds until the idle CPU is rung: the round open when it began counting, andcpus − 1passes of the victim's cursor;issues/the-push-bounds-an-idle-cpus-wait-only-for-one-victim.md, whose exit is S2.Pull,Noneand the bounded re-arm carry no bound. They are the controlspolicy.rsprices the push against, and they claim no work conservation.rt_saturated_retirefailed I14: 16 of 100 seeds at the T14, 24 of 100 at the AMD bench, 5 of 20 at4x8x2and 1 of 2 at 768 CPUs. M3 below reproduces exactly these at the head. In the run dumped, the zombie of the killed worker stayed unreleased on a CPU whose idle pass PCT deferred for 59 ms. At one CPU the real-time thread and the corpse share the CPU, which hid it.need_resched. This applies under the new protocol only: the old protocol's idle loop is its steal. Removing that condition turnsold_steal_port_is_caughtred, EXIT=101, with{"I1": 154, "I6": 154}and no I8 (log), so the condition stays.old_migrate_kept_the_corpse_i14.trace, which is re-shrunk to the same violation and the same forward.scenarios::group_imbalanceplaces the AMD bench's tasks with a newPlacementShape::Listedadversary: cpu8 idle, the rest of its CCX each one task deep, and cpu0 two deep in the other CCX.SurplusShape::Withheldoverwrites each pass's published surplus with zero after its push rang a sleeper on the real one, so the CPU it rang wakes, reads nothing worth a probe and sleeps again. It is the defect two spellings of one inequality write (best_victim's own doc), staged from outside the core, and the committed form of the review's run.Checks
Independent oracle: real hardware.
a_benchs_shape_is_what_its_scout_readdecodes the scouts' recorded CPUID words, committed verbatim askernel/sim/tests/scout/*.logfrom #865's measurement (#865 (comment), staged from042015035).Negative gates, committed.
a_push_that_waits_for_more_than_a_probe_takes_is_caughtsets the push one above the probe's threshold (PUSH_THRESHOLD + 1) on three threads spawned onto cpu0. I15 alone catches it in 4 of 20 schedules at each bench. The shipped threshold on the same machine holds on all 20.a_cpu_that_is_rung_and_declines_to_probe_is_caughtruns the same machine underSurplusShape::WithheldatShape::flat(2), the T14 and the AMD bench. Every one of the 20 schedules at each fails, on I15 alone. The published surplus on the same machines holds on all 20.The review's case (logs). The review's
best_victimfilter (.filter(|&cpu| cpu.0 > self.cpu.id.0)), onlopsided_placement(2, 3, 60 ms)atShape::flat(2), over the gate's 20 seeds:6d8c741e4, where the count paused for a kick in flight: green on 20 of 20, worst 1 round (log). The shipped push gives the same (log). I15 was blind there.26a61ebaa: EXIT=101, red on 20 of 20, eachI15: cpu1 has been idle for 5 rounds while cpu0 holds 2 queued fair task(s) (bound 4 rounds)(log). Without the filter: EXIT=0, green on 20 of 20 (log).policy.rs, the fairness storms and all four I15 cases also red.Mutations. Each is applied as a checked patch, run, reversed, and the tree shown as it was (patches, driver, summary). The head column is
26a61ebaa;8b597a5a5adds only #867's merge, which touches nothing underkernel/pure/orkernel/sim/. The whole change reverted is the base131970ac8, the branch's merge-base in round 1, under the same patches.26a61ebaa131970ac8, the whole change revertedbest_victimthat probes only inside a CPU's group of four idsthe_push_keeps_an_idle_cpu_beside_work_inside_its_boundandan_idle_cpu_whose_ccx_looks_busy_is_reached, pluspolicy.rs's three lopsided recoveriespolicy.rs's three lopsided recoveries (log)an_idle_cpu_whose_ccx_looks_busy_is_reachedalone, I15wakeup_stormseeds at two CPUs, 5 rounds against 4Head logs: M1, M2, M3, M4. M2 at
99033d06c, the green that made the staged machine a test: history/.The wake count, cross-checked against the trace. A throwaway patch asserts, at every step, that the parked-set count equals the trace's own: one
TimerFireper deadline wake and oneWakeper task that was parked when the step began or parked in it. Overtopology,scenariosandpolicy: EXIT=0 (log, patch).The level of a pair.
a_pair_is_at_the_lowest_level_it_sharesreds on the old comparison (left: None, right: Some(Core)).No new dependency. No guest test is added or changed, and none reaches the simulator.
Gates
At
8b597a5a5, the head:cargo test -p kernel-sim --no-fail-fast: EXIT=0. logcargo run -- --ci host: EXIT=0, 76 steps all green. logcargo run -- --build-only: EXIT=0. No file the image is built from changes. logAt
26a61ebaa, before #867's merge:cargo test -p kernel-sim --no-fail-fastEXIT=0 (log);cargo run -- --ci hostEXIT=0, 76 steps all green (log);cargo run -- --build-onlyEXIT=0 (log);cargo test -p kernel-sim --test topology -- --nocapture --test-threads 1EXIT=0, 18.37 s user with the host at load 67 (log).Measurements
What the shipped scheduler did at each shape over every scenario, summed over 100 seeds per scenario at the benches, 20 at
4x8x2and 2 at 768 CPUs (log). Each column is the lowest level the two CPUs share, so "package" is a crossing between LLCs of one package and "machine" a crossing between packages.4x8x22x24x8x2Rows not shown are zero. Every wake is placed on the CPU it parked on: today's only wake placement elsewhere is the real-time forward, and no run in the table took it.
I15's quantity, the worst rounds idle beside stealable work over 20 schedules (log, from a throwaway test not committed, patch, run at
0a4dc5f56, whosekernel/sim/andkernel/pure/the head's are):4x8x2Growth
git diff --shortstat origin/main...HEADat8b597a5a5: 18 files changed, 1101 insertions, 92 deletions.Unsure
all()'s workloads give each task a CPU of its own: the worst idle stretch there is 0 rounds. I15's teeth are the bench-shape cases and the two-CPU gate. The lopsided machine at 64 CPUs takes 18 s per 20 seeds and stops at its scenario's step ceiling, so it is not in the suite.policy.rs's numbers moved in round 1. Closing the model gap changed the explorer's step relation, andpolicy.rs's printed measurements moved inside their asserted bounds (comparison). One figure its doc tables quote moves:interactive cpus=2 hogs=16's worst per-run runner-up goes from 30 ms to 22 ms. Other rows of those tables already disagree with the base's own output (the push's 748 idle wakes onwakeup_storm(8,64)against the base's 667). This change does not retake them. Round 2 changes no step relation: rounds are an instrument the explorer does not read.Roast
fair_len >= PUSH_THRESHOLD. A wake to another CPU that is busy is drained at its next preemption point and runs within a bound #874 changes what the core counts as stealable. The predicate follows the constant, not a change in what is counted, so A wake to another CPU that is busy is drained at its next preemption point and runs within a bound #874's merge must change this predicate with it.policy.rs's numbers moved (above).PlacementShape::Listedexists forgroup_imbalancealone, andSurplusShape::Withheldfor the declined-probe gate alone. Each is the one way to stage what no workload produces, and without them the group-average mutation and the two-CPU decline land unseen.Shape::levelcompares keys itself. The track now says S1 moves both onto its own decoder andTopology::from_keys.🤖 Generated with Claude Code
https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy