Skip to content

Scheduler stage 3: job fairness, with per-CPU two-level EEVDF and creation authority - #979

Merged
Japabu merged 4 commits into
mainfrom
wt/toyos-jobs
Oct 21, 2026
Merged

Japabu merged 4 commits into
mainfrom
wt/toyos-jobs

Conversation

@Japabu

@Japabu Japabu commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Stage 3 of the scheduler plan (owner decision J1). CPU time was shared out per PID: every process, helper or not, got its own pot, kept against one global vruntime frontier, with ready threads queued at the key their pot held when they were inserted. A build's helper processes each bought a share, and the frozen keys let a process run ahead by one dispatch per runnable thread.

What changes

Jobs. A job is the owner a process's threads and its children share CPU time under (kernel/pure/sched/job.rs, kernel object Job).

  • A spawn puts its child in the spawner's job, unless it endows a job handle under JOB_LABEL with WRITE.
  • Only a system capability's new JOBS right makes a job (SYS_JOB_CREATE). Names and pids grant nothing.
  • SYS_JOB_OWN answers a handle to the caller's own job. A launch carries it (or the spawn's endowed job), and the supervisor starts the program in that job. Launched programs are therefore their caller's helpers unless the caller holds JOBS.
  • The supervisor makes every service a job of its own. The compositor makes every program it starts one, and its system.toml row now holds jobs.
  • A job lives while a thread of it runs or a handle to it is held, not past both. There is no hierarchy above it.

Scheduling. Each CPU's fair band runs EEVDF over the job groups it holds, then EEVDF over each group's threads (kernel/pure/sched/queue.rs), with one Eevdf implementation at both levels.

  • A group weighs its job's weight times its share of the job's runnable threads, refreshed at each decision, so a helper buys no entitlement.
  • Lag goes with whatever leaves the queue: a thread's to its next queue, a dissolving group's with its last thread. Both are clamped to LAG_BOUND_NS, so no sleeper credit beyond it.
  • A CPU decides again at three points: a request's end, an earlier-due group becoming eligible, and a wake due first. The last two wait until the running thread has run MIN_SLICE_NS.
  • J1's compositor fix: every change to a CPU's queues happens in that CPU's own pass, and every pass arms the timer last. A remote steal therefore re-arms it just as a wake does.
  • Busy CPUs ask the heaviest CPU, by summed group weight, for the thread whose move narrows the difference. That happens at most once per BALANCE_NS per CPU, with a margin so floor rounding cannot bounce an odd split. Idle steals and the push are unchanged.
  • A yield ends the yielder's request at both levels.
  • A running fair thread lent real time leaves the fair band at the lend, and its real-time turn starts then. Without this, a lend could buy one fair request plus one quantum.
  • The Eevdf queue is now an AVL tree ordered by deadline, each node holding its subtree's least virtual time. Pick, eligibility query and update are each O(log n); before, every operation scanned a vector.
  • The real-time band is unchanged and is stage 4's.

Parameters, and why. These are engineering values, the stage-1 model's under which it met the J1 criteria.

constant value bounds
SLICE_NS 4 ms longest uninterrupted fair run, and what a waiting group waits out
MIN_SLICE_NS 250 µs floor on switches a stream of wakes can force
LAG_BOUND_NS 4 ms lag carried in or out, in either direction
BALANCE_NS one slice how often a busy CPU asks for a thread

QUANTUM_NS (10 ms) remains the real-time band's turn.

Deleted:

  • fair.rs (FairShare, Frontier, ShareState), the PID share table and its removal hook.
  • SYS_SCHED_INFO/SchedInfo, PROTECTED_RUN_NS, FairOrder and its gates.
  • sched_stress's vruntime-lag check, and abuse_page_straddle's SchedInfo case (its ProcessStats case covers the same copy-out).
  • Five issues/ records whose mechanism was the frozen keys or the PID shares. The commit message lists each and why. The audit split the busy-CPU wake record into no-row-holds-a-wake-beside-seven-spinners-to-100-us and a-woken-thread-waits-out-a-sibling-spinners-quantum-when-every-cpu-is-busy. Both now describe the minimum-run cut in place of the protected run, and the second's mechanism, the frozen keys, is gone, which leaves only its measurement owed.

PR boundaries. One PR. The job is the scheduler's accounting object, and the authority is how a job comes to exist, so neither is useful without the other. The deletions are required to land together. The tree rewrite of Eevdf is a separate first commit.

Checks

check command result
host checks cargo run -- --ci host at 731df67c4 (rebased onto main eeffc8b38, after #978 and audit batches #982, #985 and #986) exit 0, 87 steps green (log)
scheduler core cargo test -p kernel --lib sched:: 149 passed
simulator cargo test --release -p kernel-sim all suites pass
image cargo run -- --build-only at 28ef305a2 exit 0
QEMU: spawn, launch, desktop, switching cargo test --test toyos-build -- console_image_boots desktop_without_x2apic a_launch_gives_each_unsent_stdio_slot_a_null libc_children vector_state_survives_a_switch the_compositor_drives_the_virtio_gpu a_drives_row_holds_one_process_per_function 7 passed at 28ef305a2

Simulator. The fairness invariants are restated for the new policy:

  • I5: equal jobs on a machine holding them alike, against cpus × 2 × (slice + lag bound + kernel section + 2 chunks).
  • I13: runnable-thread progress, meaning the service a ready thread's CPU owed it while it waited, against the model's 3 × slice + 2 × lag bound plus decision lateness at both levels.
  • I6: each job's runnable count against the bands.

Gates: fair_job_per_thread and fair_double_charge red on I5 alone; fair_lowest_key_within_group reds on I13 alone.

fairness_storm, 200 seeds per width, worst spread / bound:

CPUs 1 2 3 4 6 8 12 16 24 32
I5, ms 4/20 10/40 14/60 18/80 24/120 26/160 38/240 42/320 52/480 62/640

I13's worst is under 9.3 ms of its 24 ms bound at every width. Before this change I5 crossed its own bound at four of these widths.

The policy suite is restated for the new bounds. A single-threaded process now finishes one slice late against every swarm from 2 to 64 threads; it used to fall N+1 quanta behind.

Negative controls

  • Mutation due (decide only on a recomputed decision; begin's charge renews the request): a_fair_thread_is_decided_again_at_its_armed_request_end reds, exit 101. The simulator found the defect first, as an I9 lend held past its bound.
  • Mutation: pop_surplus may hand over the loaded task. a_cpu_does_not_hand_over_the_context_it_is_still_standing_on reds at hand_off's assertion.
  • Mutation: every spawn makes a fresh job, which is what PID shares were. job_fairness under QEMU gives the heavy job 0.70, 0.78 and 0.78 of the machine at 2, 4 and 8 vCPUs and fails at each width. Unmutated it measures 0.329–0.338.

Evidence for the stage's exit ("two independent builds versus helper-heavy jobs, at multiple CPU widths"). job_fairness (new) starts three jobs with the jobs right:

  • a build of one busy helper process per CPU;
  • a build of one process with a busy thread per CPU;
  • a helper-heavy job of four helper processes per CPU.

It measures each job's CPU time over 3 s of a saturated machine. Each is owed a third; the tolerance is 0.1 either way.

  • Host model at the T14 and AMD shapes: 0.333/0.333/0.334 (two_builds_and_a_helper_heavy_job_split_the_machine_in_thirds).
  • QEMU, exploratory and not a registration: 0.338/0.333/0.329 at 2 vCPUs, 0.333/0.333/0.335 at 4, 0.333/0.333/0.334 at 8.
  • Metal, the registered verdict. Run by the orchestrator, isolated, at 110877546: the jobs commit plus process_tree's Launch field, before the rebase onto fd: one descriptor table numbers every raw fd, and a child's descriptors are its fd:<n> endowments #978. The testcases boot judges job_fairness, wake_onto_busy and wake_storm_cost; the latencycase boot judges latency_wake.
bench job_fairness wake_onto_busy wake_storm_cost latency_wake
T14 (LENOVO 20W0003AMZ), 8 CPUs PASS, 0.333/0.334/0.333 PASS, p99 7 µs PASS PASS, p99 16 µs, p99.9 2466 µs
AMD (LENOVO 82A2), 16 CPUs PASS, 0.333/0.333/0.333 PASS, p99 28 µs PASS PASS, p99 4 µs, max 6 µs

The T14's latency_wake tail is not this change's. Booted back to back with the base (6f4d8dbdb), head gives p99.9 2466 µs and max 4465 µs, and base 2529 µs and 4527 µs. Judges: T14 head, T14 base, AMD. The first round's job_fairness and latency_wake gave the same results (T14, AMD).

  • At the rebased head 28ef305a2, which has 731df67c4's code: on the T14, process_tree and launch_toctou PASS on the proctreecase boot, with boot complete in 182 ms. Run by the orchestrator, and covering the supervisor's launch decode, which now splits off a job handle.
  • Boot time is unchanged. Back to back on the T14, the same latencycase row completes in 670 ms at base and 671 ms at head, equal stage by stage. The osbench probe's 693 ms against benchcase's 182 ms record comes from the probe's base, whose kernel still enumerated USB (486 ms), which usbd drives every xHCI controller; the kernel drives no USB #971 removed.

Logs: index, host checks, simulator, I5/I13 widths, QEMU job_fairness, its mutant, control: armed instant, control: loaded hand-over, control: no margin, control: blind commit gate. The index states each log's revision.

Risks and open items

  • Not in this PR, each recorded with its exit on the scheduler track:
    • Explicit latency requests have no syscall; the queue gives every thread one request size. The orchestrator deferred them until a first caller needs one, and stage 4's audio workers are the expected caller.
    • sshserver starts its sessions as its own helpers, so all remote logins share its job.
    • std and libc reach a new job only through CommandExt::endow(JOB_LABEL, …). This waits on the fd work that rewrites spawn.
  • old_commit_before_pass is exact again, but reaches the window in 415 of 500 schedules. In the other 85, every consumer block finds a token waiting (507 at entry, 2 at the re-check), so no block commits at the call site. The gate now asserts that a schedule fails if and only if it commits at the call site. A reach floor of three in four keeps the workload from going quiet. Pointing the gate at the fused shape, which the harness cannot see, fails at seed 1.
  • Two simulator corpus traces were re-shrunk. The old decision streams no longer reach their failures under the new scheduling decisions.
  • Busy balancing moves more threads than cpu0's surplus. lopsided_placement at 8 CPUs and 64 threads moves up to 113 tasks against 63 stealable. A trace of every move shows causes, not bounces. Before the first exit there are start-up chains (an idle CPU steals from a neighbour busy balancing just gave a second thread) and end-of-drain corrections (cpu0 answers a burst of simultaneous requests and drops below a CPU that then hands one on); after it, at most one move per exit. The test asserts both: at most even_drain + 2·(cpus − 1) moves before an exit, and at most one per exit after it. The time-proportional ceiling, which could not see a ping-pong, is gone. Dropping pop_surplus's quarter-thread margin fails the new bound with 21 moves after 8 exits.
  • pop_surplus's own refresh is not exercised by these tests. Removing it passes, because pick_fair refreshed earlier in the same pass. It matters on a pass the real-time band picks, which the lopsided workload never has.
  • Shared cache lines. A job's runnable count is an atomic that every CPU holding its threads writes. This replaces the global frontier entry in per-cpu-scheduler-and-pmm-state-shares-lines-every-cpu-writes.

🤖 Generated with Claude Code

@Japabu Japabu changed the title sched: a job owns its threads' CPU time, and each CPU runs EEVDF over its job groups Scheduler stage 3: job fairness, with per-CPU two-level EEVDF and creation authority Oct 21, 2026
@Japabu

Japabu commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Metal, head 110877546, run by the orchestrator, one bench at a time with no competing load on the machine:

  • T14 (ThinkPad T14 Gen 2, 8 CPUs): the latencycase and testcases images booted (rc 0). job_fairness and latency_wake PASS. job_fairness: cpus=8 window=3000ms ran=23997ms of 24000ms shares build=0.333 threaded=0.333 heavy=0.333
  • AMD (Lenovo 82A2, Ryzen 7 4800U, 16 CPUs): the latencycase and testcases images booted (rc 0). job_fairness and latency_wake PASS. job_fairness: cpus=16 window=3000ms ran=47996ms of 48000ms shares build=0.333 threaded=0.333 heavy=0.333

@Japabu

Japabu commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1: PR #979 at 110877546 (base 6f4d8dbdb)

I read all three commits, the full diff, each changed scheduler file in full (eevdf.rs, queue.rs, job.rs, the cpu.rs changes), the stage-1 model (job_sched.rs), the kernel object, spawn and syscall paths, the SDK, supervisor and compositor plumbing, the simulator invariants and tests, the deleted issues, the evidence index and the metal readbacks under <scratch>/agents/jobs/evidence/jobs-fairness/metal/. I ran nothing.

Architecture

The design matches the owner's plan. A process holds one job, and threads and children inherit it. A spawn can only name another job by moving a typed handle. Creating a job needs JOBS on a SysCap. A job lives as long as its Arc references from threads, ProcessData and handles. Nothing sits above jobs. The same Eevdf runs at both levels. The weight formula is JOB_WEIGHT × here / runnable. Lag is clamped to ±4 ms each way. The decision points are the same as in the model, and the timer is armed last in every pass. The kernel RunQueue follows job_sched.rs almost line for line on enter, leave, group-lag carry, refresh, the wake-cut and the decision. There are two deliberate differences: the request size is fixed because there are no latency requests, and dispatched_at survives when the loaded thread is picked again. The deletions are complete: no symbol from fair.rs, Frontier, SchedInfo/SYS_SCHED_INFO, PROTECTED_RUN_NS or FairOrder is left outside issues/, except the comment in finding 4. Line counts: production +1,460/−1,045, of which about +340 is the AVL tree and its differential test. Tests +916/−1,558. Issues +39/−337.

Authority (Q1), verified by inspection. PendingHandles::commit (kernel/src/loader/start.rs:159-165) types the JOB_LABEL handle with get_sent::<JobObject>(handle, WRITE). It refuses a second JOB_LABEL and checks TRANSFER before anything moves, so a refused spawn leaves the caller's table as it was. SYS_JOB_CREATE goes through demand_syscap(JOBS). SYS_JOB_OWN can only name the caller's own job. Rights only narrow through the generic dup path. A job can be forged only through the gated create, and a holder cannot widen its rights. The supervisor passes the client's job handle through unchanged, so the kernel's typed check is still the boundary. handle_count()'s +1 keeps rest.len() - 1 (system/supervisor/src/main.rs:1904) from underflowing.

EEVDF (Q2), checked by hand. The join placement v = V − l/W − l/w is exact. pick()'s "nothing eligible" assertion is a sound invariant, because sum is maintained exactly and so the entity with the least v always satisfies total·(v−base) ≤ sum. overtaken_in's single descent is correct. here ≤ all in group_weight holds under Relaxed RMWs: each CPU's own changes are coherence-ordered ahead of its own read, and no CPU's net contribution is ever negative. J1 is implemented. Every queue change, including an answered steal, happens in the owning CPU's pass. That pass arms the timer last, for min(request end, max(overtake, since + MIN_SLICE), cut).


Required

  1. kernel/sim/tests/policy.rs:662: the replacement migration ceiling cannot detect the defect it replaced. migration_ceiling = stealable + cpus × (elapsed / BALANCE_NS + 1) grows with the length of the run. The final logs (sim-all-5.log) show runs of about 3.8 s at 8×64 and about 0.9 s at 4×16. That makes the ceilings about 7,600 and about 920, against 113 and 23 measured. A ping-pong regression 50× worse would still pass. The old assertion ("no task moved twice") was exact. The explanation for the extra moves ("CPUs whose threads finish early take threads from busier ones") is also unverified. At 4×64 the run moved 103 tasks where an even split needs 48. The w + w/4 margin in pop_surplus means a single exit should not trigger a move, so tail moves should number about half the exits or fewer. Replace the ceiling with a bound tied to what can legitimately cause a move. The cheapest option: assert that busy moves before the first exit stay within even_drain, and that the total stays within stealable + threads (at most one move per exit). Alternatively, show from a trace that the extra moves follow exits.

  2. kernel/sim/tests/scenarios.rs:200-212 and :22: old_commit_before_pass was relaxed to three schedules in four with an unverified mechanism, and the module table still says "I1, on every seed". The comment's account (a CPU left un-stepped, so a consumer's request ends before it took a step and no block finds the queue empty) is plausible, but nobody checked it. A fixed 3/4 threshold would also hide a real loss of sensitivity anywhere between 375 and 415 of 500. The cheapest check is to classify the 85 schedules that were not caught. If none of them reached a call-site commit racing a remote wake, replace the threshold with that exact claim: every schedule that reaches the shape is caught. If some did, that is a harness gap to investigate. Either way, correct the table row.

  3. kernel/pure/sched/cpu.rs:1746-1749: a comment states a false invariant. "This pass's own queue changes can only bring the decision forward" is wrong. A debtor leaving lowers V, a creditor joining lowers V, and reweights shift it as well, so each can postpone eligibility. The code is still correct: a stale armed instant at worst preempts early and picks again, and dispatched_at is kept. The reason to keep checking the armed instant is that begin's charge has already reissued the expired request. State that, and drop the false claim.

  4. kernel/pure/sched/windows.rs:78 cites the deleted fair::Frontier::new as its reason. Write the reason in place: every record is a static, which only a const fn can build.

  5. issues/the-scheduler-and-the-clocks-never-talk.md:92-97: the three deferred items have no exits. They are the latency-request syscall, sshserver sessions running as its helpers, and std/libc reaching a new job only through endow(JOB_LABEL). Root's "No silent debt" requires an exit for each. Two of them touch the approved plan. "Bounded explicit latency requests" is a named element of the owner's EEVDF decision. "The same generic authority for all launchers" leaves out sshserver, a launcher of remote logins whose sessions all share one job. Deferring these is defensible: there is no caller yet, the model's desktop scenario misses no frame without latency requests, and no shipped image starts sshserver. But it is a stage-scope decision, so the orchestrator should confirm it with the owner, not take it as settled.

  6. Evidence: two of the brief's registered verdicts were not judged, and one changed path has not run.

    • The metal judges ran only job_fairness and latency_wake. wake_onto_busy and wake_storm_cost ran in the same testcases boots at 110877546: T14 p99 6 µs, AMD p99 29 µs, both under 1,000 µs, and wake_storm_cost exited 0 on both benches. Judging the existing readbacks touches no machine.
    • The supervisor's launch decode changed (one more handle, a new split). process_tree, whose Launch literals the head commit edits, and launch_toctou run only on the proctreecase metal arm, which has not run. The QEMU smoke covers the Place+job path only indirectly.
    • The PR body still says metal is pending. Both benches pass job_fairness at 0.333/0.333/0.333, at 8 and 16 CPUs. The T14 latency_wake tail (p99.9 2.4 ms, max 4.4 ms) matches its October 10-12 readings, so it is not a regression.

Optional

  • kernel/pure/sched/job_sched/checkpoint.rs: the new thirds test exercises the stage-1 model, not queue.rs. The PR labels it "host model" honestly, but it adds no evidence about the implementation. job_fairness.rs's header still cites it as the oracle. Either drop it or put the helper-heavy shape into kernel/sim against CpuSched.
  • queue.rs:275, cpu.rs:1965: a busy request uses a load captured when it was posted, and it occupies the one probe node. If the thief goes idle before the answer, the request can be refused on that stale load while the idle probe cannot post. Recovery then depends on the push. Reading cpus.get(thief).weight() at the victim would close this.
  • eevdf.rs:98: order and pos exist only to iterate in join order, which the model's byte-identical reports and the tests rely on. slots could serve keys() and len() in the kernel.
  • timer.rs:36-38: the quantum_end parameter now carries the fair decision; decision would be the accurate name. The same applies to "quantum" in wake_onto_busy's judge text (tests/toyos.rs:6609-6630).
  • sdk/toyos/src/endow.rs:72: Job => Job in from_handle! has no caller.
  • Add a cheap guest abuse case for the authority boundary: SYS_JOB_CREATE without JOBS, and JOB_LABEL carrying a non-job, a job without WRITE, or appearing twice. Today nothing would catch a regression in these refusals.
  • sdk/toyos/src/spawn.rs:217: the job uses one of the five launch extras, so a shell provided four connectors can no longer forward them. No current caller is affected.
  • The track's "Stage 3 is in the kernel" paragraph describes what landed. A track should hold the remaining work; that history belongs in the commit.
  • Sequencing (orchestrator): wt/toyos-fd-table and wt/toyos-fd-served rewrite sdk/toyos/src/spawn.rs, launch.rs and the supervisor's launch path.

On the specific questions

  • (4a) The gate is weaker. The defect it guards would read about 0/500 if the harness went blind, so 3/4 still catches that, but the threshold and its explanation need finding 2.
  • (4b) The extra moves may be legitimate, but the test can no longer tell (finding 1).
  • (4c) Acceptable. The counter is written on fair enter and leave for that job's threads only, which is strictly less contention than the global frontier written on every pick, and it is recorded accurately in per-cpu-scheduler-and-pmm-state-shares-lines-every-cpu-writes.md.
  • (4d) and (4e) Acceptable as recorded debt, once finding 5 gives each an exit.
  • (5) sdk/std and sdk/libc are untouched. std's behaviour changes through toyos::spawn: a launch now carries the caller's job, and a JOB_LABEL endowment no longer forces a direct spawn.
  • Deleted issues: all five deletions are justified. The owner's J1 decision supersedes per-process-fair-split-is-the-policy (rejected). The owner declined the hierarchy that fairness-is-per-thread-so-thread-count-wins proposed. The restated I5 and I13 replace the bounds granularity-bound-crossed-at-four-widths and i13s-margin-at-32-cpus-is-down-to-two-milliseconds were about, and both are met at widths 1 to 32. For interactive-mix-crosses-the-fairness-bound-at-two-cpus, the commit says honestly that I5 opens no window there (reach 0%) and that I13 holds the sleeper's waits to 8.7 ms of 24 over 2,000 seeds. INTERACTIVE_I5_SAMPLE is gone, and no slug citations remain.

SEND BACK

Japabu added a commit that referenced this pull request Oct 21, 2026
… exact

Review r1 of #979.

The lopsided placement cases bounded migrations by the run's length,
so a ping-pong fifty times worse still passed. A trace of every move
over the 20 seeds per width shows two causes before the first exit,
neither a bounce. At start-up, an idle CPU steals from a neighbour that
busy balancing has just given a second thread. At the end of the
drain, cpu0 answers a burst of requests posted in one instant, drops
below a CPU that took threads earlier, and that CPU hands one on. Each
other CPU sources at most two moves before an exit. After the first
exit, only an exit opens a pair more than one thread apart, the
quarter-thread margin's threshold. The bounds are now at most
even_drain + 2 * (cpus - 1) moves before an exit and at most one move
per exit after it. Measured worst: even + 8 before (8 CPUs, 64
threads), and 53 moves after 64 exits (4 CPUs, 64 threads). Dropping
pop_surplus's margin fails it: 21 moves after 8 exits at 2 CPUs.

old_commit_before_pass passed only on a 3/4 threshold, with an
unverified account of the misses. Counting call-site commits that go on
to park splits the 500 schedules exactly. All 415 caught schedules
commit at least once. None of the 85 others does: every consumer block
there found a token waiting (507 at entry, 2 at the re-check). The gate
now asserts that a schedule fails if and only if it commits at the call
site, and keeps a reach floor so the workload cannot go quiet. Pointing
it at the fused shape, which the harness cannot see, fails at seed 1.

Also: the decision comment in preempt_if_due no longer claims a pass's
queue changes only bring the decision forward. windows.rs no longer
cites the deleted Frontier. The scheduler track's three stage-3
leftovers each get an exit.

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

Japabu commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Review r2: PR #979 at 28ef305a2 (base a8ea09d20)

I read the r1-fix commit 28ef305a2, the range-diff of the three rebased commits against 110877546, and the rebased start.rs, spawn.rs, launch.rs, supervisor and compositor in full where they changed. I ran nothing.

The r1 blockers are resolved

  1. Migration bound. assert_moves_have_causes (kernel/sim/tests/policy.rs) replaces the time-proportional ceiling with two bounds: at most even_drain + 2·(cpus−1) moves before the first exit, and after ≤ exits once threads start exiting. The counters are wired up correctly: migrations_before_exit is counted in check_retires while vm.exits == 0, and exits is incremented in the exit path. The bounds are tight against measurement (53 moves after 64 exits at 4×64). The commit records a control: dropping pop_surplus's margin fails it (21 moves after 8 exits at 2 CPUs). A bounce with no exit between its moves now breaks one of the two bounds.
  2. old_commit_before_pass. Each seed now asserts that the run fails if and only if call_site_commits > 0. Coverage is a separate reach floor of 3/4. The counter is incremented in the shared CommitAtCallSite | CommitAtCallSiteFused arm, so pointing the gate at the fused shape fails the iff check; the commit says it fails at seed 1. The classification is recorded: all 415 caught schedules commit at the call site, and none of the other 85 does. The table row is fixed.
  3. cpu.rs:1746. The comment now gives the true reason: begin's charge reissues the request, and a stale armed instant only decides early.
  4. windows.rs:78. The deleted citation is gone.
  5. Track. Each of the three items now has an exit. Recording the latency-request deferral as an orchestrator decision is honest. Whether stage 3 may close without that element of the owner's EEVDF decision is still the owner's call.
  6. Evidence.
    • The coordinator reports wake_onto_busy and wake_storm_cost passing on both benches at 110877546.
    • proctreecase ran at 28ef305a2 on the T14: process_tree and launch_toctou pass.
    • The T14 latency tail matches main's base (p99.9 2466 µs vs 2529 µs on main).
    • Host CI, --build-only and the 7 QEMU rows pass at 28ef305a2. I took this from the handoff and did not check the logs.
    • Reusing the 110877546 fairness metal at the new head is sound. The range-diff shows the scheduler unchanged by the rebase. In start.rs only an import changed, and the desktop QEMU rows exercise the compositor's JOB_LABEL launch on top of fd: one descriptor table numbers every raw fd, and a child's descriptors are its fd:<n> endowments #978's fd endowments.

Rebase resolutions are correct

  • Gitlinks: git diff origin/main 28ef305a2 --raw lists no gitlink change. rust is a56b4057c in both trees.
  • start.rs: with fd: one descriptor table numbers every raw fd, and a child's descriptors are its fd:<n> endowments #978 the slot map is gone, and the JOB_LABEL check sits in the same validation loop, before any removal. job is taken before drop(data) and drop(displaced). The console-minting path is untouched.
  • launch.rs: slots became fds throughout, and the +1 for the job is in batch, encode, decode and handle_count. The new test "a descriptor and the job" is correct.
  • spawn.rs: the stdio-length check now runs before job_copy exists, so an early refusal cannot leak it. Every later failure releases it: the place-copy failure, the fd-dup loop through release, and NotSent.
  • Supervisor: the fds/extras/place/job split is correct, and start puts the job in held before any later fallible step.
  • Stale names: none remain. build/kernelkeys.rs:202's KShare is a literal string in the parser's own self-test.

Your question on wake.onto_busy (AMD p50 7.0 → 16.3 µs)

Nothing in #979's placement change explains it.

  • The forward path is unchanged in substance. forward_target is untouched, and spawn placement still reads load. place()'s behind_fair condition changed spelling, not meaning: it is still a fair wake behind a running fair thread that is not a corpse. Both probes report ran_on_busy=0: every wake was forwarded to an idle CPU, as before.
  • The new per-wake cost is too small. Each fair enter and leave now allocates and frees a group, and reweights twice. That is a sub-microsecond cost, not +9 µs.
  • The two runs placed the test on different CPUs. Main's run used busy 7, waker 8, with a tight distribution (min 6.2 µs, max 7.8 µs). The Scheduler stage 3: job fairness, with per-CPU two-level EEVDF and creation authority #979 probe used busy 6, waker 5, with a wide one (min 4.8 µs, p90 26.9 µs). A minimum below main's and a long upper tail look like the forwarded-to CPU sometimes exiting a deeper idle state. That points to the CPU pair and idle depth, not the pick.
  • The registered row has always read in Scheduler stage 3: job fairness, with per-CPU two-level EEVDF and creation authority #979's range. It is wake_onto_busy in testcases, measuring the same thing. Across about twenty earlier AMD readbacks from other branches (topology, kwx, usbd), it reads p50 13–16 µs and p90 23–28 µs. At 110877546 it reads 16/28 µs. On the registered row, main reads in the same range as Scheduler stage 3: job fairness, with per-CPU two-level EEVDF and creation authority #979; the osbench 7.0 µs figure is the outlier.
  • The probe's base predates usbd drives every xHCI controller; the kernel drives no USB #971. It drives xHCI from the kernel; its boot spends 486 ms before storage ready. That explains its benchcase boot-complete failures (T14 693 vs 182 ms, AMD 588 vs 241 ms). The proctreecase boot at 28ef305a2 is 182 ms.
  • Cheapest way to settle it: rerun osbench wake.onto_busy on main and on 28ef305a2, same image base, with the busy/waker pair pinned the same in both.

Still optional, carried from r1 and not required

The model-only checkpoint test, the busy-steal stale load, Eevdf's order and pos fields, the quantum_end parameter name, the unused Job FromHandle impl, a guest abuse case for the authority refusals, and the launch-extras capacity note.

New, unrelated (from the handoff): readbacks show the kernel's usb-storage line writes a USB stick's serial number into /log. The handoff says this is not in issues/ yet. It belongs in an issue of its own, and it is not part of this PR.

LAND

@Japabu
Japabu marked this pull request as ready for review October 21, 2026 19:59
@Japabu
Japabu added this pull request to the merge queue Oct 21, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 21, 2026
Japabu added a commit that referenced this pull request Oct 21, 2026
… exact

Review r1 of #979.

The lopsided placement cases bounded migrations by the run's length,
so a ping-pong fifty times worse still passed. A trace of every move
over the 20 seeds per width shows two causes before the first exit,
neither a bounce. At start-up, an idle CPU steals from a neighbour that
busy balancing has just given a second thread. At the end of the
drain, cpu0 answers a burst of requests posted in one instant, drops
below a CPU that took threads earlier, and that CPU hands one on. Each
other CPU sources at most two moves before an exit. After the first
exit, only an exit opens a pair more than one thread apart, the
quarter-thread margin's threshold. The bounds are now at most
even_drain + 2 * (cpus - 1) moves before an exit and at most one move
per exit after it. Measured worst: even + 8 before (8 CPUs, 64
threads), and 53 moves after 64 exits (4 CPUs, 64 threads). Dropping
pop_surplus's margin fails it: 21 moves after 8 exits at 2 CPUs.

old_commit_before_pass passed only on a 3/4 threshold, with an
unverified account of the misses. Counting call-site commits that go on
to park splits the 500 schedules exactly. All 415 caught schedules
commit at least once. None of the 85 others does: every consumer block
there found a token waiting (507 at entry, 2 at the re-check). The gate
now asserts that a schedule fails if and only if it commits at the call
site, and keeps a reach floor so the workload cannot go quiet. Pointing
it at the fused shape, which the harness cannot see, fails at seed 1.

Also: the decision comment in preempt_if_due no longer claims a pass's
queue changes only bring the decision forward. windows.rs no longer
cites the deleted Frontier. The scheduler track's three stage-3
leftovers each get an exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu and others added 4 commits October 21, 2026 22:21
… least virtual time

The queue kept its entities in a vector and scanned it for every pick,
eligibility query, charge and lag read. Every operation is now O(log n): an
AVL tree ordered by (virtual deadline, issue order) whose nodes carry their
subtree's least virtual time answers the pick (the first eligible entity in
deadline order) and `overtaken_in` (the least virtual time among entities due
before the running one) by one descent, and a key-to-slot map replaces the
linear lookups. Semantics are unchanged, tie-breaks included.

`end_request` ends an entity's request where its service stands, which a
voluntary yield gives up.

Checks:
- `the_tree_answers_as_a_scan_of_every_entity_does`: 20,000 random joins,
  leaves, rejoins, charges, reweights and slice changes, each followed by the
  tree's shape and augmentation checked whole and its pick and `overtaken_in`
  compared against a scan.
- The stage-1 model (`job_sched`) prints the same reports to the byte with the
  tree as with the vector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the job groups it holds

Stage 3 of the scheduler plan (owner decision J1): the per-PID fair shares,
the global vruntime frontier and the run queue's frozen insertion keys go
together, replaced by jobs and two-level per-CPU EEVDF.

Jobs. A job is the owner a process's threads and its children share CPU time
under (`kernel/pure/sched/job.rs`, kernel object `Job`). A spawn puts its child
in its own job unless it endows another under `JOB_LABEL`; making a new job
takes a system capability's `JOBS` right (`SYS_JOB_CREATE`), so names and pids
grant nothing. `SYS_JOB_OWN` answers a handle to the caller's job, which a
launch carries so the supervisor starts the program in its caller's job. The
supervisor makes every service a job of its own, and the compositor every
program it starts (its row now holds `jobs`). A job lives while a thread of it
runs or a handle to it is held. There is no hierarchy above it.

Scheduling. Each CPU's fair band is EEVDF over the job groups it holds and
EEVDF over each group's threads (`kernel/pure/sched/queue.rs`). A group weighs
its job's weight times its share of the job's runnable threads, refreshed at
each decision, so a helper buys no entitlement. Lag goes with whatever leaves:
a thread's to its next queue, a dissolving group's with its last thread,
clamped to `LAG_BOUND_NS`. A CPU decides again at a request's end, at an
earlier-due group's eligibility, and at a wake due first — the last two once
the running thread has run `MIN_SLICE_NS`. Every change to a CPU's queues
happens in its own pass and every pass arms the timer last, so a remote steal
re-arms it as a wake does (the J1 compositor fix). Busy CPUs ask the heaviest
CPU for the thread whose move narrows their summed group weight, once per
`BALANCE_NS`; idle steals and the push stay. A yield ends the yielder's
request at both levels; a running fair thread lent real time leaves the fair
band at the lend, its real-time turn starting then.

Parameters: `SLICE_NS` 4 ms, `MIN_SLICE_NS` 250 us, `LAG_BOUND_NS` 4 ms,
`BALANCE_NS` one slice — the stage-1 model's, under which it met the J1
criteria; `QUANTUM_NS` (10 ms) stays the real-time band's turn.

Deleted: `fair.rs` (`FairShare`, `Frontier`, `ShareState`), `SYS_SCHED_INFO`
and `SchedInfo`, `PROTECTED_RUN_NS`, `FairOrder`, the PID share table and its
removal hook, sched_stress's vruntime-lag check and abuse_page_straddle's
`SchedInfo` case (its `ProcessStats` case covers the same copy-out).

Simulator. I5 is now equal jobs on a machine holding them alike, against
`cpus × 2 × (slice + lag bound + kernel section + 2 chunks)`; I13 is runnable-
thread progress, the service a ready thread's CPU owed it while it waited,
against the model's `3 × slice + 2 × lag bound` plus two levels of decision
lateness; I6 checks each job's runnable count against the bands. Gates:
`fair_job_per_thread` and `fair_double_charge` red on I5 alone,
`fair_lowest_key_within_group` on I13 alone. `fairness_storm` from 1 to 32
CPUs, 200 seeds each: I5 worst 4/20, 10/40, 14/60, 18/80, 24/120, 26/160,
38/240, 42/320 and 52/480 ms at 1, 2, 3, 4, 6, 8, 12, 16 and 24; I13 worst
under 9.3 ms of 24 at every width. The policy suite is restated for the new
bounds: a single-threaded rival finishes one slice late at every swarm size
from 2 to 64, where it used to fall N+1 quanta behind.

`old_commit_before_pass` is caught in 415 of 500 schedules rather than all:
the explorer may leave a CPU un-stepped while it holds a task, so a consumer's
request can end before it took a step and no block finds the queue empty. Two
corpus traces were re-shrunk for the new decisions. Step budgets for the wide
workloads follow the measured pass counts.

Closed, their mechanism gone with the frozen keys:
per-process-fair-split-is-the-policy, granularity-bound-crossed-at-four-widths,
i13s-margin-at-32-cpus-is-down-to-two-milliseconds, and
interactive-mix-crosses-the-fairness-bound-at-two-cpus (no crossing in 2,000
seeds of either stream with its allowance removed; I5 opens no window there
now, and its sleeper's waits are I13's, at worst 8.7 ms of 24).
fairness-is-per-thread-so-thread-count-wins is answered by jobs, and the
hierarchy it proposed the owner declined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A launch now carries a job, and the two process_tree builds by hand gave
none, which only the guest build compiles.

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

Review r1 of #979.

The lopsided placement cases bounded migrations by the run's length,
so a ping-pong fifty times worse still passed. A trace of every move
over the 20 seeds per width shows two causes before the first exit,
neither a bounce. At start-up, an idle CPU steals from a neighbour that
busy balancing has just given a second thread. At the end of the
drain, cpu0 answers a burst of requests posted in one instant, drops
below a CPU that took threads earlier, and that CPU hands one on. Each
other CPU sources at most two moves before an exit. After the first
exit, only an exit opens a pair more than one thread apart, the
quarter-thread margin's threshold. The bounds are now at most
even_drain + 2 * (cpus - 1) moves before an exit and at most one move
per exit after it. Measured worst: even + 8 before (8 CPUs, 64
threads), and 53 moves after 64 exits (4 CPUs, 64 threads). Dropping
pop_surplus's margin fails it: 21 moves after 8 exits at 2 CPUs.

old_commit_before_pass passed only on a 3/4 threshold, with an
unverified account of the misses. Counting call-site commits that go on
to park splits the 500 schedules exactly. All 415 caught schedules
commit at least once. None of the 85 others does: every consumer block
there found a token waiting (507 at entry, 2 at the re-check). The gate
now asserts that a schedule fails if and only if it commits at the call
site, and keeps a reach floor so the workload cannot go quiet. Pointing
it at the fused shape, which the harness cannot see, fails at seed 1.

Also: the decision comment in preempt_if_due no longer claims a pass's
queue changes only bring the decision forward. windows.rs no longer
cites the deleted Frontier. The scheduler track's three stage-3
leftovers each get an exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit b9e4c58 Oct 21, 2026
4 checks passed
@Japabu
Japabu deleted the wt/toyos-jobs branch October 21, 2026 21:01
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