Skip to content

A thread's request follows its bursts, so a brief sleeper is due before a hog - #1266

Merged
Japabu merged 5 commits into
mainfrom
wakeperf-request
Nov 11, 2026
Merged

Japabu merged 5 commits into
mainfrom
wakeperf-request

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Draft, on main now that #1260 has landed. #1263 is rebased onto it. Head e24deb8, the commits of 9a1a013 rebased onto main after #1260 unchanged. The metal A/B is owed (below), the benches being down.

Problem

The manual promises that "a thread that sleeps and runs briefly gets the next slice without more total time". Every thread and every group asked for the same 4 ms request, and kernel-sim's interactive workload (a sleeper running a quarter millisecond every 3 ms beside hogs) showed the cost. In 400 seeds at four hogs, 12 runs had a second wake wait out a whole hog request, 4 to 5 ms against the test's 2.25 ms contract. The test's 40 seeds had not reached the case. Every breach has the same shape:

  • The sleeper ran a quarter or half millisecond while the hogs waited, so its group left owing for it. Under J1 that debt goes with its last thread and comes back with it.
  • Its wake lands in the pass that reissues the running hogs' request.
  • Asking for a full slice, the sleeper was then neither eligible nor due before the hogs' fresh request, so it waited that request out.

The first wake of each run had the same tie with no debt. Evidence: policy/ in https://github.com/ToyOSOrg/evidence/tree/444e752b2d490116c1d0d9f9144261c63fe39f79, recorded on #1263's branch as issues/a-wake-landing-as-the-running-request-ends-waits-a-whole-request.md. That record is closed here: #1263's rebase drops it.

What changes

The owner's decision on this defect, option (b): a thread's request follows its recent run, never below the minimum slice, at both levels.

  • The estimator (queue::next_request). A burst is the CPU time from a wake to the next park, taken off the task's own accounting (acct.cpu_ns at park, less its value at the wake). Each burst moves the request half way to it, the burst bounded to the minimum and the full slice.
    • It is simple: one add and one halving per park.
    • It is bounded by construction.
    • It decays: an old burst is forgotten within a few new ones, and one odd burst moves the request at most half way.
    • A thread that never sleeps (a hog, a yield loop) keeps the full slice.
    • A request that runs out while its thread still runs moves up (from the review): at each pass, a running thread's burst so far that has outrun its request moves it as a park would (RunningTask::outrun_request, RunQueue::set_running_request). A thread that slept briefly and then turns CPU-bound is back within 1% of the full slice a dozen requests on, instead of keeping a 250 µs request, and up to sixteen times the timer interrupts, for ever.
  • The thread's entity joins and rejoins its group's queue with its request.
  • A group asks for its shortest thread's request (RunQueue::resize, at each thread's entry and departure): it is due as soon as its most latency-sensitive thread.
  • Requests order EEVDF's queue and do not weigh in it. The paper (Stoica and Abdel-Wahab, ODU TR-95-22) makes the request size the latency knob, independent of share, and eevdf's own a_shorter_request_changes_order_not_share holds it.

Manual: the scheduling page now says how a request's length is chosen and that it changes order, not share.

Removed: queue/reference.rs and queue/differential.rs, as their header said: this change decides differently by design. eevdf's division test stays.

Proof

Latency:

  • Simulator. an_interactive_wake_waits_out_at_most_one_request now sweeps 400 seeds, about 10 s.
    • At this head it passes. At one CPU the worst wake is now 3 ms (it was 5) and every runner-up is at most 2 ms.
    • On main, with this 400-seed sweep, it is red at four hogs (the control).
    • Counted separately over 400 seeds of kernel-sim run interactive_mix:1:4: 12 runs breach at The run queue's operations cost less and decide as before #1260's head 9e31612 (main's scheduling, the control), 0 here.
  • Core. Both diagnosed wakes:
    • a_brief_sleeper_waking_as_the_running_request_ends_runs_at_once: a sleeper with no lag runs in the pass that reissues the hog's request.
    • a_brief_sleeper_owing_its_last_burst_runs_within_the_minimum_run: a sleeper owing for its burst runs within the minimum run of every wake, the first included. Under main's policy its waits read [4000000, 0, 0, …]: the first, owing wake waits out the hog's whole request.
    • a_thread_that_stops_sleeping_gets_back_to_the_full_slice.
  • One core test restated. a_wake_with_no_claim_ahead_waits_for_the_running_request stated the old rule. It is now a_brief_sleepers_wake_runs_after_the_minimum_run.

Share (a_short_request_buys_no_share, new simulator workload request_share):

  • A job of one or four threads running 1 ms bursts with a 50 µs nap between, against an equal job's hog, each doing 60 ms.
  • Run without and with a real-time job taking a sixth of the CPU.
  • The bursty job finishes no sooner than half the fair CPU allows, less one request and its chunks.
  • Main reads the same within a handful of milliseconds:
case bursty job's earliest finish, main at this head hog's latest finish, main at this head
1 thread 121.55 ms 121.55 ms 90.0 ms 90.0 ms
4 threads 120.15 ms 120.15 ms 118.0 ms 115.0 ms
1 thread, RT beside 142.05 ms 142.05 ms 105.5 ms 105.5 ms
4 threads, RT beside 140.65 ms 140.60 ms 137.0 ms 135.5 ms

Why the one-thread bursty job gets about a third of the CPU, on main and on this head alike: not the scheduler. The model runs a Run in 1 ms chunks and fires a timer due inside a chunk at its end (RUN_CHUNK_NS), so each 50 µs nap lasts a whole chunk of the hog's: the thread is runnable two thirds of the time and gets half of that. On hardware the nap is 50 µs plus the 10 µs one-shot floor. RUN_CHUNK_NS's doc now records that the model resolves nothing shorter than a chunk.

Negative controls, each a mutation applied to the head 8f2d2db, run against the named tests alone (a core test that panics aborts its binary, so each core test runs by itself) and restored:

mutation red
every request a whole slice (main's policy) a_brief_sleeper_owing…, waits [4000000, 0, …]; the 400-seed interactive test
a group keeps the whole slice a_brief_sleeper_owing…, waits [4000000, 0, …]; the 400-seed interactive test
a request never moves while its thread runs a_thread_that_stops_sleeping…, stuck at 264,648 ns
a short request also weighs more (group weight times slice over request) a_short_request_buys_no_share

Checks

Logs and the proofs' readings: https://github.com/ToyOSOrg/evidence/tree/ff32bf2c3f600cff86bed03b8088c72e3a4ca1d0.

check why result
cargo test -p kernel --lib, and with sched-check the touched modules' unit tests exit 0 at 9a1a013
clippy, the kernel's shapes of build/clippy.rs, and kernel-sim --all-targets the touched files and the simulator exit 0 each at 9a1a013
cargo run -- --ci host models kernel-sim's policy and scenarios and kernel-loom drive this queue at 9a1a013: exit 0, 79 steps green, every model control red as required. At 8f2d2db: exit 0, 79 steps green. At f543b1a it was 78 of 79: the old registration window's gate aborted, which 578c9df fixes (below)
guest rows 43099723883 at 9a1a013, whose commits e24deb8 carries unchanged on merged main (43097242501 and 43097843657 failed at startup on GitHub's side; 43090906916 cancelled; 43078179378 at 578c9df was green): std_threads_count_the_cpus_and_give_back_ended_threads, affinity_pins_hold_on_every_cpu, vector_state_survives_a_switch threads, sleeps and pins on a real two-CPU scheduler, as a smoke queued

A second commit: a pass preempts only a task whose word says it runs

With shorter requests a running thread's decision falls sooner. In kernel-sim's model of the old preemptible registration window, a pass then reached a thread mid-commit through the decision arm, and preempt's assertion fired with the task in hand. Its drop guard turned the unwind into an abort, so the gate that must catch the window took the test binary down instead of reporting it.

The kill and affinity arms already waited for the running word, preempt's precondition. Every arm now does, and the window is caught as before by the invariant walk's word check (a_pass_inside_the_registration_window_is_caught, green). In the kernel the running task's word reads Running at every pass, so nothing changes there.

Owed on metal

Both metal benches are down, so metal is no longer a landing condition here. The speed-up is not yet measured on metal. Recorded as owed:

  • One A/B of main from before and after this change on the T14 and the AMD bench, of sched, fair and time, with a Linux reference timed through the TSC (issues/a-wake-onto-a-busy-cpu-…): main must read the wake.* rows faster or unchanged, the fair.* share rows within 3%, time.yield within 3%, and no scheduler, wake, timer or fairness row slower. The latency change is measured in the simulator only.

Risks

  • A thread that sleeps briefly and then runs long is served short requests until its burst has outrun them a few times; a dozen requests on it is back to the full slice. Until then it costs more decisions, never share.
  • A group's request is its shortest thread's. A job that mixes an interactive thread with a hog decides more often while both are queued here.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1266 at 578c9df

Architecture. The policy sits where it belongs. The estimator is one pure function in the run queue's module (queue::next_request). The task carries its request and where its current burst began. The queue gives each entity its request and gives a group its shortest queued thread's request. In EEVDF the request enters only an entity's virtual deadline (vd = v + r/w) and the CPU's decision instant. Neither eligibility (v ≤ V) nor weight reads it. Deleting the frozen reference and the differential test is what #1260's header prescribes.

Size.

  • Production: about +41 lines (queue.rs +34/−11, task.rs +18; the preemption arms in cpu.rs are net zero) and +5 lines of manual.
  • Tests: about +156 (cpu.rs tests +92/−8, policy.rs +49/−12, the request_share workload +35).
  • Test-only deletions: −1,104 lines.
  • Net: −902 lines.

What I was asked to judge

No share change: holds by construction.

  • A thread cannot buy CPU by shaping its bursts. Service follows eligibility. An entity runs only while v ≤ V, and its debt when its request ends or it parks is at most one request. A shorter request therefore means less debt, not more service.
  • Take a thread that alternates short sleeps with long runs. Its long runs are served in requests as short as 250 µs, and at each one it must be eligible again. The lag it takes across a sleep is clamped to ±LAG_BOUND_NS exactly as before, and the request does not enter it.
  • The carried request_left (clamped to the new request on rejoin) positions only the deadline.
  • For a group, resize changes only slice, and set_slice reissues only to shorten. The group's weight is still Job::group_weight, so the shortest thread changes when the group is due, not what it gets.
  • eevdf's a_shorter_request_changes_order_not_share holds the property for the queue.
  • The simulator readings agree with the argument:
    • bursty job finishes: identical, or within 0.05 ms;
    • hog finishes: identical, or up to 3 ms earlier at this head.
  • S1 (weight scaled by slice over request) reds the share test.

The estimator.

  • midpoint(r, clamp(b, MIN, SLICE)) is closed on [250 µs, 4 ms] from a start of SLICE_NS. It cannot overflow or underflow: cpu_ns is monotonic and woke_cpu_ns is copied from it.
  • It forgets an old burst with a half-life of one burst, and one odd burst moves it at most halfway.
  • It is cheap, and its bounds are right.
  • Its one weakness is that it samples only at a park (suggestion 1).

The second commit: verified neutral in the kernel.

  • Ticket::register raises the preempt count before prepare writes Committing, and the count stays raised through pass_block.
  • In pass_block the commit either parks the task (dispose_block takes running) or puts the word back to Running (cancel_commit, recover_from_claim) before dispose_none.
  • do_preempt passes run only at count 0: preempt::enable's slow path checks it, and the ring-3/EL0 timer paths assert it.
  • So the guard is always true in the kernel. Loudness is kept where it matters: under sched-check, invariants::check_cpu still asserts the running word at the end of every pass. The commit also folds two copies of the guard into one.

The manual text. It names no code. It agrees with the implementation and with the bullet above it.

The observation (a third of the CPU). This is expected EEVDF behaviour and not a scheduler defect. The PR's reason is wrong, though: the cause is the model, not a property of a job that naps every millisecond.

  • The model. A Run step advances min(run_left, RUN_CHUNK_NS) = 1 ms whatever is armed (kernel/sim/src/vm.rs:1505-1516), and a timer due inside the chunk fires at its end. So the bursty thread's 50 µs nap lasts the hog's whole 1 ms chunk.
  • The cycle. Each 3 ms cycle has three 1 ms phases:
    1. the bursty thread runs;
    2. it is parked;
    3. it is runnable while the hog repays the 0.5 ms its burst cost.
  • The numbers. Its runnable fraction is 2/3, and it gets exactly half of its runnable time. The readings agree:
    • The hog's 60 ms are done at 90.0 ms, so the bursty job ran 30 ms of 90.
    • Its 30 remaining bursts, alone, take 30 × 1.05 ms, giving 121.55 ms.
    • The four-thread job is always runnable and reads about 48%.
  • On hardware. The nap is 50 µs plus the 10 µs one-shot floor, so the job is runnable about 95% of the time and is owed about half.
  • Verdict. No scheduler record is needed. The PR text and two comments (blocker 2) should say what it is.

BLOCKER

  1. kernel/pure/sched/cpu.rs:4423-4446: a_brief_sleeper_owing_its_last_burst_runs_within_the_minimum_run cannot fail under main's policy, so it does not hold the wake it names.
    • Trace under F0 (every request a whole slice; F1 is the same at the root, where it matters):
      • The first wake owes 125 µs and is neither eligible nor due before the hog's reissued request, so it waits out the hog's whole 4 ms.
      • That wait credits the sleeper +1.875 ms. Every later wake is then eligible and due first, and runs in its wake pass.
      • The waits are therefore [4 ms, 0, 0, …]. The assertion reads waits[2..], which skips the one owing wake that main gets wrong.
    • Why the logs don't contradict this. The logged F0/F1 runs show only a_brief_sleeper_waking… red: the binary aborts at the first panic when the dropped World's live tasks trip their drop guard. So this test's verdict under either mutation is unknown, not red.
    • Fix. Assert the owing wake that main fails, from the first (restructure if the head's early waits need it). Then run F0 and F1 against this test alone and publish the reds. Otherwise delete the test and stop listing it as proof. The 400-seed policy test still holds the defect meanwhile.
  2. Comments that are false or stale:
    • kernel/sim/src/scenarios.rs:1058-1059: "Each job wants nearly the whole CPU, so the instant each finishes is the share it was served at" is false for the one-thread job (runnable 2/3 in the model). Also, the bursty job's finish reads its total work; only the hog's finish reads the split.
    • kernel/sim/tests/policy.rs:349-350: "both want the CPU, so each is owed half": the same error.
    • kernel/sim/tests/policy.rs:417: "across forty runs" is stale at 400.
    • kernel/sim/tests/policy.rs:393-395: "With every request a whole slice this sample finds … which forty seeds did not reach" is history, and belongs in the commit.

SUGGESTION

  1. For the owner: the request recovers only at a park.
    • A thread whose bursts were short and which then stops parking keeps its last request indefinitely. Two CPU-bound threads, one of which once slept briefly, then alternate at that request (down to 250 µs) instead of every 4 ms. A lone such thread takes a timer interrupt per request, up to 16 times as many.
    • The Risks section says "runs long once". "A thread that never sleeps keeps the full slice" is true only from creation.
    • A request that runs out while its thread still runs could move it up (next_request of the burst so far, at the thread's reissue).
    • The staged A/B is unlikely to show this. Only a row whose CPU-bound threads once slept briefly would.
  2. kernel/pure/sched/queue.rs:213: resize walks every thread of the group at each enter and leave. The tree could carry each subtree's least slice beside min_v. If this costs anything, a many-waiter wake row in the A/B is where it would show.
  3. kernel/pure/sched/cpu.rs:4368: park_at duplicates World::park_running with an instant. An at (and a deadline) on park_running, as run_a_pass_at has, would also serve A pass skips the timer write and the ask swaps it does not owe #1263's two tests that inline the same sequence.
  4. RUN_CHUNK_NS's doc still argues from a 10 ms quantum. With requests and the minimum run at 250 µs, the model cannot resolve them. That limit is worth recording if the simulator is to judge sub-millisecond latency.

PR description, for the orchestrator

  • The observation should name the model's 1 ms chunk as its cause (see above).
  • "exit 0 at 578c9df". fix-checks.log's HEAD line reads f543b1a with two changed files, and the models log carries no revision. The tree was committed as 578c9df at 20:19:52; say so.
  • The mutation table. It should say that under F0 and F1 only a_brief_sleeper_waking… reported, because the binary aborted. F0's red there is the "request followed its bursts down" precondition; F1's is the behavioural assertion.
  • "12 of 400 … on main" was counted at The run queue's operations cost less and decide as before #1260's head 9e31612. That is equivalent to main, but say so.

Landing preconditions

  • Guest run 43078179378 green. Under the orchestrator's new rule it must also be green before request3.txt's boots are flashed, and the request should name it.
  • The fixes above are test and comment changes, so the staged images (f3207f7 = 578c9df + row) remain the code that lands.
  • The metal A/B under request3.txt's rule:
    1. all 16 boots pass;
    2. no scheduler, wake, yield, timer or fairness row is counted slower;
    3. no fair.* share row moves beyond 3% in either direction.

SEND BACK: blockers 1 and 2. Both are cheap and neither touches production code.

Japabu added a commit that referenced this pull request Nov 11, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1266, round 2, at 338d134 (code-identical to 8f2d2db)

Scope: round 1's findings, the run-out recovery the orchestrator added, and the commits since 578c9df (144002bad, 338d1347b; the rebase onto #1260's 47091b4 moved only issue records).

Architecture. The recovery sits where the estimator already lives:

  • the task computes its next request from its own accounting at each pass (RunningTask::outrun_request);
  • the queue applies it to the running entity and its group (RunQueue::set_running_request).

Requests only grow on this path, so set_slice never reissues and the share argument from round 1 is unchanged. The queue takes the new size from the next request on, one request behind the task. This round adds about 25 production lines, 30 test lines, and comment edits that net to roughly zero.

Round-1 blockers

  1. The owing-wake test: resolved. It now asserts every wake. Run alone under F0 and F1 at 8f2d2db, it reads [4000000, 0, …] and reds (fix-mutations2.log).
  2. Comments:
    • policy.rs "both want the CPU", "forty runs" and the history paragraph: fixed.
    • scenarios.rs: rewritten, and still false (blocker 2 below).

The orchestrator's run-out decision: implemented as asked. a_thread_that_stops_sleeping_gets_back_to_the_full_slice holds it. I checked by hand whether it also holds the queue half: with set_running_request a no-op, the decision span stays at 264,648 ns. The task's burst then grows only that much per pass, and twelve passes leave its request near 2.9 ms. So the 99% assertion reds through the decision instants as well, and the test covers both halves.

Manual: it matches the decision. A request follows recent runs within the minimum and the full slice, a group takes its shortest thread's request, and a request changes order, never share. That is the EEVDF latency knob. Reading "its recent runs" to include the current one covers the run-out recovery, so no change is needed.

Owed on metal: recorded in issues/a-wake-onto-a-busy-cpu-…, with its rows, benches, and what main must show, as the bench ruling requires. No timing claim rests on the simulator or a guest:

  • the simulator readings are model-time ordering, labelled "measured in the simulator only";
  • the guest rows are a smoke.

Checks I ran at 338d134 (detached worktree, host toolchain):

check result
cargo test -p kernel --lib exit 0, 460 passed
cargo test -p kernel --lib --features sched-check exit 0, 463 passed
clippy -p kernel --lib --tests --features sched-check exit 0
clippy with sched-check,protocol-port and the kernel controls exit 0
clippy -p kernel-sim --all-targets exit 0
clippy -p kernel --target x86_64-unknown-none (adopted lints, -D warnings) exit 0
cargo run -- --ci host models exit 0, 79 steps green

BLOCKER

  1. kernel/pure/sched/task.rs:1256-1263: outrun_request returns Some whenever the burst exceeds the request, whether or not the request moved.
    • Which threads. A thread that never parks (every hog, every yield loop) has a burst above SLICE_NS once it has run 4 ms without parking, and next_request(SLICE_NS, SLICE_NS) == SLICE_NS. A recovered thread stops at SLICE_NS - 1, because u64::midpoint rounds down, so burst > request stays true for it as well.

    • What it costs. Every pass of such a thread (cpu.rs:1643) runs set_running_request:

      • two set_slice calls, each with an i128 multiply;
      • resize's walk over the group's queued threads, which seeds a 48-entry stack.

      None of it changes anything. This is per-pass work on the path time.yield measures. This PR's owed A/B holds that row to 3%, A pass skips the timer write and the ask swaps it does not owe #1263's needs it faster by more than 3%, and with the benches down a cost here would land unmeasured. The cost is inferred from the code, not measured.

    • The doc. It also makes the doc's "the new request, where it moved" false.

    • The fix. Return Some only when next_request differs from the current request. Because set_slice with an unchanged value should not reissue, I expect the fix to leave every decision, and so the simulator evidence and A pass skips the timer write and the ask swaps it does not owe #1263's corpus, unchanged. The models will confirm that.

  2. kernel/sim/src/scenarios.rs:1058-1061: "the bursty job's is its work plus the time it was not runnable" is false.
    • It contradicts itself. Its own last clause has the thread runnable two thirds of the time, so it also waits while runnable, and the formula leaves that wait out.
    • The numbers. Round 1 split the one-thread case's 121.55 ms finish into 60 ms of work, about 31.5 ms parked (30 whole-chunk naps beside the hog, then 30 naps of 50 µs alone), and 30 ms runnable but waiting while the hog took its half. Work plus not-runnable time is about 91.5 ms.
    • The fix. Drop the clause and keep: "The hog's finish is the split it was served at. In this model a nap beside a running thread lasts a whole run chunk ([RUN_CHUNK_NS]), so one bursty thread is runnable two thirds of the time."

SUGGESTION

  • kernel/sim/src/vm.rs:44-47:
    • "The fair band's minimum run and shortest request are 250 µs" restates a constant's value; naming MIN_SLICE_NS keeps it true if the value changes.
    • "a nap below a chunk reads as a whole one" holds only while another thread runs a chunk. Alone, the 50 µs naps after the hog finished read as 50 µs, which is where the 121.55 comes from.

PR description, for the orchestrator

Nothing further. The mutation table's rows match fix-mutations2.log at 8f2d2db.

Landing preconditions

  • Both blockers fixed. Blocker 1 changes production code, so the code head moves. Under the bench ruling, the scheduler guest rows run again at the new head; 43090906916 at 8f2d2db is still queued.
  • cargo test -p kernel --lib and the models at the fixed head.
  • The metal A/B stays owed as recorded; it is not a landing condition.

SEND BACK: blockers 1 and 2. Each is a few lines.

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1266, round 3, at 9a1a013

Scope: 9a1a013, the one commit since 338d134, which is its parent. It touches three files, +8/−7. The only production change is the condition in outrun_request, net +1 line. The other edits are two doc comments.

Architecture. Unchanged. The task still computes its request and the queue still applies it. The fix only stops telling the queue about a request that did not move.

Round-2 blockers

  1. outrun_request: resolved on every path.
    • A request only ever lies within [MIN_SLICE_NS, SLICE_NS]. Its only writers are the initial SLICE_NS and next_request.

    • When the burst exceeds the request, next_request returns at least the current request. It returns the same value exactly when the clamped burst is the request plus one. That covers:

      • a thread at SLICE_NS (every hog and yield loop);
      • a recovered thread at SLICE_NS - 1, because u64::midpoint(3_999_999, 4_000_000) rounds down to 3,999,999.

      Both now return None, and the pass skips set_running_request. In every other case the request grows and the queue is told.

    • No decision changes, by inspection. The Some the fix drops always carried the unchanged value.

      • eevdf::Queue::set_slice reissues only when fine(slice) is below the request in hand. A request is never issued above its slice: issue floors, and rejoin and readmit clamp to fine(slice). So an unchanged slice never reissued.
      • resize then recomputed the same minimum.

      The removed calls were therefore no-ops on the queue. The measurements agree:

      • a_short_request_buys_no_share prints 121.55/90.0, 120.15/115.0, 142.05/105.5 and 140.60/135.5 ms, matching the PR's "at this head" column exactly;
      • the corpus test passes.
    • The doc's "the new request, where it moved" is now true.

  2. scenarios.rs: resolved. It uses the suggested text word for word and agrees with round 1's breakdown.

The vm.rs suggestion is half taken: the comment now names MIN_SLICE_NS. The other half was optional, and scenarios.rs now gives the precise condition, "beside a running thread". I do not reopen it.

The run-out recovery test still covers both halves. I mutated each half in my scratch worktree, ran the test alone, and restored the tree:

mutation at 9a1a013 a_thread_that_stops_sleeping_gets_back_to_the_full_slice
set_running_request a no-op (queue half) red, 2,911,257 ns
outrun_request always None (task half) red, 264,648 ns

The fix itself needs no control. It changes no decision, which the inspection above establishes, and its effect is only work the pass no longer does.

Nothing else changed. git diff 338d1347b 9a1a01330 touches only task.rs, scenarios.rs and vm.rs. The branch does not touch .github/.

Checks I ran at 9a1a013 (detached worktree, host toolchain):

check result
cargo test -p kernel --lib exit 0, 460 passed
cargo test -p kernel --lib --features sched-check exit 0, 463 passed
cargo run -- --ci host models exit 0, 79 steps green: the 400-seed interactive test, the share test and the corpus pass, and every control reds as required
clippy -p kernel --lib --tests --features sched-check, with the adopted lints and -D warnings exit 0
clippy with sched-check,protocol-port and the kernel controls exit 0
clippy -p kernel --target x86_64-unknown-none exit 0
clippy -p kernel-sim --all-targets, without and with placement-ignores-staleness exit 0

Guest run 43097843657: startup_failure, with no jobs, not queued. 43097242501 at this head failed the same way.

  • The workflow file is identical to main's.
  • In the same window, guest-rows runs on fileops-step1, server-admission and shared-boot-guest-rows also failed at startup.

So this is a GitHub-side failure, not this branch. But no guest has yet run the run-out recovery:

  • 43078179378 (green) at 578c9df predates it;
  • 43090906916 at 8f2d2db was cancelled.

BLOCKER

None.

SUGGESTION

  • kernel/pure/sched/task.rs:1260: burst > task.request && request != task.request is equivalent to request > task.request, because a burst at or below the request gives a next request at or below it. The single comparison says what the doc says.
  • kernel/sim/src/vm.rs:46: the edited line runs to 96 columns, against 71 to 79 for its neighbours. Rewrap it.

PR description, for the orchestrator

The guest-rows row reads "queued". 43097843657 has ended in startup_failure.

Landing preconditions

LAND, once the guest rows at 9a1a013 are green. No code change is required.

Japabu added a commit that referenced this pull request Nov 11, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from wakeperf-runqueue to main November 11, 2026 22:11
Japabu and others added 5 commits November 12, 2026 00:15
…re a hog

The manual promises that a thread that sleeps and runs briefly gets the
next slice without more total time. Every thread and group asked for the
same 4 ms request, so a sleeper that ran a quarter millisecond while the
hogs waited came back owing for it, was neither eligible nor due before
the hogs' freshly reissued request, and waited that whole request out. In
400 seeds of `kernel-sim`'s interactive workload at four hogs, 12 runs had
such a second wake.

A thread's request now follows its bursts, the CPU time from a wake to
its next park: each burst, bounded by the minimum and the full slice,
moves the request half way to it. A thread that never sleeps keeps the
full slice. A group asks for its shortest thread's request. Requests order
EEVDF's queue and do not weigh in it, so shares do not move: a new
simulator workload, a job of one or four bursty threads against a hog and
beside a real-time job or not, finishes as it does on main, and a
mutation that lets the shorter request weigh more reds it.

`an_interactive_wake_waits_out_at_most_one_request` now sweeps 400 seeds,
which finds the case on main; the fix has none. The core tests hold both
wakes the runs showed, and the manual says how a request's length is
chosen.

The run queue's frozen reference and its differential test go, as their
header said they would: this change decides differently by design. The
division test stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With shorter requests a running thread's decision falls sooner, and in
`kernel-sim`'s model of the old preemptible registration window a pass
now reached a thread mid-commit through the decision arm. `preempt`'s
assertion then fired with the task in hand, its drop guard aborted the
unwind, and the gate that must catch the window took the whole test
binary down. The kill and affinity arms already waited for the running
word, `preempt`'s precondition; every arm now does, and the window is
caught as before by the invariant walk's word check. In the kernel the
running task's word reads `Running` at every pass, so nothing changes
there.

The request's mean is `u64::midpoint`, and the core wake tests drive
their passes at the decision instants rather than the armed one-shot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s held from the first

A thread whose bursts brought its request down and which then stopped
parking kept that request for ever: up to sixteen times the decisions
and timer interrupts of a full slice. At every pass a running thread's
burst so far that has outrun its request now moves it as a park would,
and its group's request follows; such a thread is back within 1% of the
full slice a dozen requests on.

`a_brief_sleeper_owing_its_last_burst_runs_within_the_minimum_run`
skipped the first two wakes, and the first is the one owing for its
burst that main gets wrong; it asserts every wake now. The request-share
comments say what the model's nap is, a whole run chunk, and
`RUN_CHUNK_NS` records that the model resolves nothing shorter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s comment is true

A thread that never parks outruns its request at every pass once it has
run a whole slice, and the request then stays where it is: the pass still
set the thread's and its group's slice and walked the group's threads for
nothing, on the path a yield loop measures. The request now moves, and
the queue is told, only when `next_request` changes it.

The request-share workload's comment no longer says the bursty job's
finish is its work plus its time parked; it waits while runnable too.

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

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: landing under the benches-down rule. Review r3 LAND (9a1a013). Head e24deb8 is the same five commits rebased onto main after #1260 (git range-diff: all =). Guest run 43099723883 at 9a1a013 (scheduler rows): success. The A/B and Linux reference are owed on metal; the speed-up is not yet measured on metal.

@Japabu
Japabu marked this pull request as ready for review November 11, 2026 23:16
@Japabu
Japabu added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit acf0a5b Nov 11, 2026
10 checks passed
@Japabu
Japabu deleted the wakeperf-request branch November 11, 2026 23:36
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