Skip to content

A pass skips the timer write and the ask swaps it does not owe - #1263

Merged
Japabu merged 8 commits into
mainfrom
wakeperf-pass
Nov 12, 2026
Merged

Japabu merged 8 commits into
mainfrom
wakeperf-pass

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Draft, on main now that #1260 and #1266 have landed. Head 49095e6, on main 75bdec3: its own code is 9ca0126's. It lands on its run 43090913024 at 9ca0126 and #1266's 43099723883, which covers the only base code that differs. #1266 fixes the policy defect this branch's timer skip had surfaced, so the record this branch carried for it is dropped (closed there). The metal A/B is owed (below), the benches being down.

Problem

Every scheduler pass wrote its timer and swapped all sixteen of its ask words (one per 64 CPUs), whether or not anything had changed. The wakeperf diagnosis's segment probes (main 422915e, one boot a bench) put a yield with nothing to yield to at 620 ns on the T14 and 541 on the AMD bench. Of that:

T14 AMD
the timer: the clock read and conversion, the write, the trace record 111 ns 94 ns
the mailbox drain with nothing in it, which is the sixteen swaps 75 ns 87 ns

The same two items are paid by every pass of pingpong.* and ipc.reqrep.*. Evidence: https://github.com/ToyOSOrg/evidence/tree/3ddc686338d20b13528b597a7437e3b027479728/diagnosis (tables/fine-split.txt, tables/*-seg-grouped.txt).

What changes

  • A running CPU leaves a one-shot that is still to fire and due no later than it needs (SchedPass::apply_timer_no_later_than). An early fire is a spurious pass, never a missed deadline, so the timer is written once a request, after a fire, or when something is owed sooner. cpu.armed stays what the hardware holds, so invariant T (the armed instant is no later than the earliest event owed) and the simulator's I3 hold as before.
    • Whether the last arm has fired is Machine::timer_pending. Both architectures keep a per-CPU flag: arm_one_shot sets it, and stop_timer and every timer entry clear it (x86-64's rearm_last and ring0_fire, AArch64's rearm and rearm_in_kernel). arm_within leaves it, since what it arms is never later than what it found. The simulator answers from its modelled one-shot, which a fire disarms. A machine that cannot tell answers false and writes every arm.
    • The flag goes up before the hardware write (from the review): a pass from a system call runs with interrupts open, so a fire taken between a write and a later store would have cleared a flag the store then raised over the quantum the fire re-armed, and every later pass would have skipped, up to 10 ms late. Raised first, a fire between the two clears it and the next pass writes. AArch64's comparator write is a nomem asm, so a compiler fence holds the store before it. The setter is pub(super) on both. No host model can see this order: the simulator derives timer_pending from its modelled one-shot.
    • The skip also needs the armed instant still ahead of the pass's now: a one-shot already due, whose interrupt is latched but not yet taken, is armed again rather than relied on.
    • A CPU going idle arms exactly, so nothing wakes it early from a deep state. Its arm and stop are unchanged.
  • An ask word read empty is not swapped (CpuHandle::take_asks). An ask sets its bit and then rings the doorbell. A pass clears the doorbell's edge before it reads the words. So an ask either lands before that clear, and its bit is visible to the load, or it rings after it and owes a later pass, which takes it. kernel-loom's loom_sleep drives exactly this race.
  • A task's mask and its reach are one value (affinity::LoadedMask, one constructor), from The run queue's operations cost less and decide as before #1260's review. The queue can no longer count a reach its mask has moved from; The run queue's operations cost less and decide as before #1260's mutation M6 is now unrepresentable.

issues/the-timer-is-a-lapic-count-re-armed-every-pass-never-a-tsc-deadline.md becomes issues/the-timer-is-a-lapic-count-never-a-tsc-deadline.md.

What this does of the TSC-deadline track:

  • On a running CPU the arm is now once per request, not once per pass, so a deadline's saving there is per arm.
  • What a deadline would still save is the idle path's arm and stop, and the boot's 10 ms calibration spin, on the T14 alone (AMD documents no deadline mode).
  • The track's own measurement and exit are unchanged.

Not changed: the pass's other per-pass work. That is the run queue's pick and put-back (#1260), the published load, weight and stamp (the stamp feeds the staleness every placement reads, so its cadence is a decision), and the drain's mailbox. The direct hand-off within a job is designed after this A/B, not built here.

Checks

At 9ca0126, rebased onto #1266's head 8f2d2db (logs: the evidence's checks/pr2r-*).

check why result
cargo test -p kernel --lib, and with --features sched-check the touched modules' unit tests, as CI runs them exit 0
cargo clippy on the kernel library's two host shapes, on -p kernel for x86_64-unknown-none and aarch64-unknown-none-softfloat (each default and boot-actuators,test-actuators), and on kernel-sim --all-targets, the adopted lints, -D warnings the shapes of build/clippy.rs that compile these files, both architectures' timer code included exit 0 each
the mutations below the new tests' negative controls 5 red (before the rebase; the rebase moved none of the lines they mutate)
cargo run -- --ci host models kernel-sim runs passes against its modelled one-shot and asserts I3, and kernel-loom's loom_sleep drives the ask race exit 0 at 9ca0126, 79 steps green, every model control red as required
guest rows 43090913024 at 9ca0126: ring0_timer_in_syscall, and on AArch64 virt_timer_preempts, virt_ring0_timer_in_syscall, virt_timer_floor, virt_irq_storm the flag's writers are each architecture's timer entries, which no host build has passed

New tests:

  • a_running_cpu_leaves_a_pending_one_shot_due_no_later: nothing is written while the request's end stands; a fired one-shot is armed again; a sleeper's earlier deadline is written.
  • a_cpu_going_idle_writes_its_deadline_over_a_pending_earlier_one_shot.
  • a_taken_ask_is_gone.

Mutations, each applied to the committed head, run and restored:

mutation result
the skip ignores whether the one-shot fired red (a_running_cpu_leaves…)
an idle CPU leaves a pending earlier one-shot too red (a_cpu_going_idle…)
the skip leaves a one-shot due later than needed red (a_running_cpu_leaves…)
an ask word read set is not cleared red (a_taken_ask_is_gone)
a set ask word is read as empty red: the core test binary aborts at a_steal_probe_goes_to_surplus_and_not_to_a_cpu_full_of_corpses, its first failing test, and loom_sleep's an_ask_is_taken_by_the_pass_its_ring_owes fails

Nothing on the host drives the flag's architecture writers: a missing clear would leave a running CPU's timer unwritten after a fire. The guest rows above boot both architectures' timer paths, and the A/B's 16 boots run every row on the benches.

The policy test, and the corpus

  • an_interactive_wake_waits_out_at_most_one_request was red here before the rebase, a defect on main this skip's extra timer steps let 40 seeds reach. A thread's request follows its bursts, so a brief sleeper is due before a hog #1266 fixes it (a thread's request follows its bursts), and the test now sweeps 400 seeds: green on the rebased head.
  • The skip, and A thread's request follows its bursts, so a brief sleeper is due before a hog #1266's request changes, change which timer steps the simulator offers, so committed corpus traces can stop driving their old protocols into the failures they were kept for. Each failing trace now names its invariant (# expect: fail I14), and the corpus test checks it: a header naming another invariant reds it. At 9ca0126:
    • old_steal_port_i8 is found again: a search of kernel-sim run old_steal_port <seed> for the first run whose violations include I8 (seed 5), then shrink.
    • old_migrate_kept_the_corpse_i14 still replays to I14's case, TaskKey(4) was killed and then migrated to cpu1; it was regenerated at the first rebase from seed 70, the first that kernel-sim find reported failing, which was I14.
    • old_steal_port_i1 still fails I1.

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 system, sched, ipc and time, with a Linux reference timed through the TSC (issues/the-timer-is-a-lapic-count-never-a-tsc-deadline.md): main must read time.yield, pingpong.* and ipc.reqrep.* faster by more than 3% on both, the timer and periodic-wake rows within 3%, and no scheduler, IPC, wake, yield, spawn or timer row slower.

Risks

  • A spurious timer interrupt per request on a running CPU replaces a write per pass. At a 4 ms request that is one interrupt in 4 ms against thousands of writes, but a thread whose request keeps ending sooner sees no saving.
  • The flag is per-CPU state the timer entries must keep. A future timer writer that arms without going through arm_one_shot and leaves the flag set could leave a running CPU waiting for a fire later than it needs. Every writer today is named above.

🤖 Generated with Claude Code

Japabu added a commit that referenced this pull request Nov 11, 2026
Found while #1263's timer skip turned the interactive policy test red: main
breaks the same bound in 12 of 400 explored runs, the skip in 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu changed the base branch from wakeperf-runqueue to wakeperf-request November 11, 2026 18:23
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1263 at 000bc1d (on #1266's 578c9df)

Architecture. The responsibilities are split sensibly.

  • The core decides the skip from three facts:
    • a task is running;
    • its recorded cpu.armed lies in (now, at];
    • the hardware says the arm is still to fire.
  • Each architecture owns the last fact, where its arms and timer entries live, behind Machine::timer_pending. The per-CPU flag is cheaper than reading TIMER_CURRENT or CNTV_CTL.
  • The rest.
    • The idle path still arms exactly.
    • take_asks reads before it swaps. That rests on the doorbell's AcqRel edge: an ask whose bit the load misses rang after begin_pass's clear and owes a later pass. loom_sleep covers the race.
    • LoadedMask makes The run queue's operations cost less and decide as before #1260's M6 unrepresentable.

Size.

  • Production: about +125/−29.
    • core: affinity, apply_timer, take_asks, hw, task, timer;
    • architecture: +54/−3 across apic, irqchip, both percpu and hw.rs;
    • simulator: +6.
  • Tests: about +91.
  • Records: +14/−5.

What I was asked to judge

The skip's logic.

  • No lost timer from the core.
    • cpu.armed ≤ at always holds, so invariant T and the simulator's I3 hold.
    • After any fire the flag is false and the next pass writes.
    • A fire during a pass sets need_resched, which survives enable_no_resched and buys the next pass.
    • A one-shot that is latched but not yet taken is taken when interrupts open.
  • The other timer writers are safe.
    • arm_within (the idle re-arm, the diagnostic tick, the selftests) only shortens.
    • timer_one_shot runs only at APIC enable.
    • The AArch64 storm and floor actuators end in stop_timer.
  • Early fire. It happens by design, only on a running CPU, at most once per write, and the pass it buys writes the exact instant.

The architectures.

  • x86-64. Both benches use the LAPIC count, and the tree has no TSC-deadline mode. The record tracks that.
  • AArch64. The timer is the virtual timer (cntv_*), not CNTP.

The interrupt-versus-flag race. It is real, and is blocker 1.

The corpus.

  • old_steal_port_i8: verified. the_new_protocol_survives_the_schedule_that_breaks_the_old_one replays it, asserts an I8 violation on the old protocol, and asserts that the new protocol survives. It is green at 000bc1d.
  • old_migrate_kept_the_corpse_i14: unverified. That is blocker 2.

BLOCKER

  1. kernel/src/arch/x86_64/apic.rs:382-383 and kernel/src/arch/aarch64/irqchip.rs:498-499: the flag goes up after the hardware write.
    • Why the window is reachable. A pass from a syscall runs with interrupts open: syscall_handler enables them (kernel/src/arch/x86_64/syscall.rs:110), and the pass raises only the preempt count. So a timer interrupt recognised just after the write can be taken before the flag store. That is a one-shot whose count reaches zero while the wrmsr or MMIO store executes, or one pending at compare_at's isb.
    • The failure sequence.
      1. The timer entry clears the flag and re-arms one quantum on: ring0_fire (apic.rs:418-423), or rearm_in_kernel (irqchip.rs:567-572).
      2. It sets need_resched.
      3. arm_one_shot then raises the flag again, now over a timer that holds the quantum.
      4. cpu.armed is the new at. Every later pass that needs nothing sooner sees now < armed ≤ at and a pending flag, and skips.
    • The consequence. The running request's end is missed until the quantum re-arm fires, up to 10 ms late. Before this change the need_resched pass rewrote the timer unconditionally, so the overwrite cost nothing. The window is narrow, the consequence bounded, but it is a defect the skip introduces.
    • The fix. Set the flag before the write. An entry taken between the two then clears it, and the next pass writes again; every interleaving becomes conservative.
      • On AArch64, also put a compiler_fence between the relaxed store and compare_at, whose asm! is nomem.
      • Add one line at each site saying why the order matters.
      • The host models cannot see this race: the simulator derives timer_pending from its modelled one-shot.
  2. kernel/sim/corpus/old_migrate_kept_the_corpse_i14.trace: nothing shows that the regenerated trace still fails I14's "killed and then migrated" half, which its kept note claims.
    • shrink preserves only the seed's set of violation kinds.
    • corpus_traces_still_do_what_they_were_committed_for checks only that the trace fails.
    • The commit says the traces were found by "a search for I8", which cannot be the I14 trace's criterion.
    • kernel/sim/src/shrink.rs records this very failure mode: old_steal_port_i1 silently became an I8.
    • The cheapest check. Replay the trace at the head and publish its violations. If it fails otherwise, find a seed whose run includes the I14 migration message (kernel/sim/src/invariants.rs:293), or correct the name and note.

SUGGESTION

  • kernel/src/arch/x86_64/percpu.rs:692: make set_timer_pending pub(super), as AArch64's is, so that "arch::apic is the only writer" holds by visibility.
  • issues/the-timer-is-a-lapic-count-never-a-tsc-deadline.md:36: "which leaves it the idle path's arm and stop and the boot's calibration spin" omits a running CPU's once-per-request arm, which a deadline would still save. The PR body says so.
  • Let a corpus header carry the expected violation kind, such as # expect: fail I14, so the corpus test checks it. Blocker 2 would then be a test, not a manual replay.

PR description, for the orchestrator

  • "A search for I8". The body and commit attach it to both traces. Say what each search looked for.
  • Mutation A2. The log's lines show only the kernel binary's panics. Its "1 binary red" is presumably loom's verdict, since the kernel binary aborted. Show an_ask_is_taken_by_the_pass_its_ring_owes's line or say so.

Landing preconditions

  • Guest run 43078185332 at 000bc1d green. Under the orchestrator's new rule it gates flashing request2.txt's images, and the request should name it.

  • The staged A/B may stand. Blocker 1's fix reorders two stores and changes no timing a row can see.

  • The fixed head gets the timer rows again, because its architecture timer code changed: ring0_timer_in_syscall, virt_timer_preempts, virt_ring0_timer_in_syscall, virt_timer_floor and virt_irq_storm.

  • The metal A/B under request2.txt's rule:

    1. all 16 boots pass;
    2. no scheduler, IPC, wake, yield, spawn or timer row is counted slower;
    3. time.yield is counted faster on both benches, or the body and the yield record state the measured result.

    With A thread's request follows its bursts, so a brief sleeper is due before a hog #1266 underneath, ping-pong threads ask for about 250 µs, so their spurious fire comes about every request rather than every 4 ms. The pingpong.* rows measure that.

SEND BACK: blockers 1 and 2.

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 #1263, round 2, at 489a142 (code-identical to 9ca0126)

Scope: round 1's findings, the order question, and the commits since 000bc1d: 7b9ac08e2 (the flag order), 97cd777f4 (corpus expectations), and 489a14247 (the owed record). The range-diff shows the earlier five commits unchanged by the rebase onto #1266.

Architecture. It is unchanged since round 1, and the fixes are local:

  • each architecture's arm_one_shot stays the only place that raises timer_pending, and the setter is now pub(super) on both;
  • a corpus trace now states the invariant it exists for, and the corpus test checks it.

This round adds +5/−2 lines of architecture code, +9 in shrink::decode, +8 in the corpus test, and the record line.

Round-1 blockers

  1. The flag after the write: resolved.
    • x86-64. The order holds at apic.rs:384. The gs store is an asm! without nomem, and OneShot::arm follows with another gs asm and then the wrmsr asm or the volatile MMIO store. The compiler keeps them in order.
    • AArch64. The order holds at irqchip.rs:501-502: a relaxed store, then compiler_fence(SeqCst), then compare_at's nomem asm. The fence is what keeps the store ahead of the asm. No hardware barrier is needed, because the only reader that matters is this CPU's own exception handler. It is taken at an instruction boundary after the write and sees the earlier store in program order.
    • Every interleaving now errs towards a write. A fire between the store and the write leaves the flag clear over the new arm, so the next pass writes again. A fire after the write clears the flag as before.
    • The comment at each site says why the order matters, in two lines.
  2. The I14 trace: resolved.
    • Replays. I replayed each committed trace at 489a142 with kernel-sim replay:
      • old_migrate_kept_the_corpse_i14 fails "I14: TaskKey(4) was killed and then migrated to cpu1";
      • old_steal_port_i1 fails I1;
      • old_steal_port_i8, re-found, fails I8;
      • the baseline passes.
    • Tests. corpus_traces_still_do_what_they_were_committed_for and the_new_protocol_survives_the_schedule_that_breaks_the_old_one are green. The header's control, the I14 trace relabelled I8, reds (pr2-corpus-control.txt).

The orchestrator's question: does the flag order need more than the guest timer rows? No.

  • Inspection settles it. The property is the program order of two statements on one CPU, plus a compiler barrier on AArch64, and the code shows both.
  • A guest run cannot reach it. It cannot aim a timer interrupt at a window a few instructions wide.
  • A host model would add nothing. The simulator answers timer_pending from its modelled one-shot, so modelling this order would mean a model of the architecture layer built only to restate these two lines.
  • What the rows can hold, they do. The timer rows check that every timer entry clears the flag on both architectures: a missing clear would leave a running CPU unpreempted after a skip.

Owed on metal: recorded in the TSC-deadline record, with rows, benches, what main must show, and "the speed-up is not yet measured on metal". No timing claim rests on the simulator or a guest:

  • the Problem section's numbers are main's metal segment probes, cited to the evidence;
  • the Risks section's interrupt-per-request comparison is arithmetic, not a measurement;
  • the guest rows are named as boot coverage of the flag's writers.

Checks I ran at 489a142:

check result
cargo test -p kernel --lib exit 0, 463 passed
the same with sched-check exit 0, 466 passed
cargo test -p kernel-sim --test scenarios -- corpus_traces the_new_protocol exit 0, 2 passed
clippy -p kernel --lib --tests --features sched-check exit 0
clippy -p kernel-sim --all-targets exit 0
clippy -p kernel --target x86_64-unknown-none and aarch64-unknown-none-softfloat, each default and boot-actuators,test-actuators (adopted lints, -D warnings) exit 0 each
cargo run -- --ci host models exit 0, 79 steps green

BLOCKER

None.

SUGGESTION

  • kernel/sim/src/shrink.rs:85,120: two gaps in what the corpus can hold.

    • encode still writes a bare # expect: fail, so a trace that kernel-sim shrink prints carries no invariant until someone adds one by hand.
    • The check matches the kind alone, so the I14 trace's note ("it fails I14's 'killed and then migrated' half") is not held against I14's latency half.

    Letting shrink write the kind it preserved, and letting the header carry a message prefix to match, would close both. This is optional.

PR description, for the orchestrator

  • The Checks table's models row reads "exit 0 at 000bc1d". The implementer's pr2r-models.log came from pr2r-all.sh immediately after pr2r-checks.log, whose HEAD line is 9ca0126. My own run at 489a142 is also green. The row can say 9ca0126.

Landing preconditions

LAND: no blocker remains in this PR's own changes, subject to the preconditions above.

Japabu added a commit that referenced this pull request Nov 11, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Japabu added a commit that referenced this pull request Nov 11, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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-request to main November 11, 2026 23:36
Japabu and others added 8 commits November 12, 2026 01:42
Every pass wrote its timer and swapped all sixteen ask words, whether or
not anything had changed. On the T14 the probes put the timer's clock
read, conversion, write and trace at about 110 ns a pass and the empty
drain, which is the ask swaps, at about 75; a yield with nothing to yield
to probed 620 ns.

- A running CPU leaves a one-shot that is still to fire and due no later
  than it needs. An early fire is a spurious pass, never a missed
  deadline, so the timer is written once a request, after a fire, or when
  something is owed sooner. `Machine::timer_pending` says whether the last
  arm's interrupt is still to be taken: both architectures keep a per-CPU
  flag that the arm sets and every timer entry and stop clears, and the
  simulator answers from its modelled one-shot. A CPU going idle still
  arms exactly, so nothing wakes it early.
- An ask word read empty is not swapped. An ask landing after the read
  rings the doorbell after it, and the pass that ring owes takes it.
- A task's mask and its reach are one value with one constructor, so the
  queue cannot count a reach its mask has moved from.

The TSC-deadline record loses "re-armed every pass" from its name: what
a deadline would save on a running CPU is now per arm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading an ask word before swapping it made a take that leaves the word
set representable, and no test held that a taken ask is gone.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A running CPU that leaves an earlier one-shot pending fires it where it
used to be rewritten, so the simulator offers timer steps where it did
not, and two committed decision traces no longer drove their old
protocols into the failure they were recorded for. They are found again
(`kernel-sim find`, a search for I8, and `shrink`): `old_steal_port_i8`
from seed 10, `old_migrate_kept_the_corpse_i14` from seed 70, its notes
kept. Both fail as committed again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pass from a system call runs with interrupts open, so a fire taken
between the hardware write and the flag's store cleared a flag that the
store then raised again, over a timer the fire had re-armed a quantum on:
every later pass that needed nothing sooner skipped its write, and the
running request's end waited for the quantum, up to 10 ms late. The flag
is now raised first, so a fire between the two clears it and the next pass
writes again. AArch64's comparator write is a `nomem` asm, so a compiler
fence holds the store before it. The setter is `pub(super)` on both
architectures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`# expect: fail I14` and its kind say which invariant a failing trace
must still break; the corpus test checks it, so a replay that fails only
some other way is no longer read as the case it was kept for. The three
old-protocol traces name theirs. `old_steal_port_i8` is found again at
this head (seed 5, shrunk); the other two still break theirs, I1 and
I14's killed-then-migrated task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 r2 LAND. Head 49095e6 is the same eight commits rebased onto main after #1266 (git range-diff: all =). Guest runs: 43090913024 at 9ca0126 (timer rows, x86-64 and AArch64): success; 43099723883 (#1266's scheduler rows, the base code that differed): success. The A/B is 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:43
@Japabu
Japabu added this pull request to the merge queue Nov 11, 2026
Merged via the queue into main with commit 2c8d8bb Nov 12, 2026
10 checks passed
@Japabu
Japabu deleted the wakeperf-pass branch November 12, 2026 00: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