diff --git a/docs/gate-fleet-inventory.md b/docs/gate-fleet-inventory.md index c9d32a21..167863ed 100644 --- a/docs/gate-fleet-inventory.md +++ b/docs/gate-fleet-inventory.md @@ -249,7 +249,7 @@ of them with `just verify-vz`. | spec | `live-win-move` | 1 run / 26 assert | live-win-move.spec -- claim 0487 (milestone six, card G6 move/raise | | spec | `live-win-syscall` | 1 run / 18 assert | live-win-syscall.spec -- claim 0487 (milestone six, card G6) class-B | | spec | `live-wm-ipc` | 2 run / 9 assert | live-wm-ipc.spec -- M32 WMS7 (issue #627) app<->WM mailbox protocol (WM_RPC) on VZ | -| spec | `live-wm-pacing` | 1 run / 7 assert | live-wm-pacing.spec -- M53 card 1 (#1247): what the desktop's frame cadence | +| spec | `live-wm-pacing` | 1 run / 7 assert | live-wm-pacing.spec -- WMP (WM frame pacing) card 1 (#1247): what the | | spec | `live-wm1` | 1 run / 13 assert | live-wm1.spec -- Lane 1 WM1 (#707, claim 919) class-B gate: eight concurrent user windows | | spec | `live-wm3-taskbar` | 3 run / 13 assert | live-wm3-taskbar.spec -- M32 WM3 (Lane 1, #707): taskbar shows per-window entries, workspace-aware | | spec | `live-wm4-paint` | 2 run / 4 assert | live-wm4-paint.spec -- M32 WM4 (Lane 1, #707): WM rest policy blends unfocused, focused pure | diff --git a/kernel/src/main.zig b/kernel/src/main.zig index a6914c0c..93be8836 100644 --- a/kernel/src/main.zig +++ b/kernel/src/main.zig @@ -1892,7 +1892,10 @@ fn process_stdout(text: []const u8) void { /// Claim 9187 IRQ chain, registered as the exception module's dispatcher: /// ack from the GIC, handle the timer tick if the INTID is the timer's -/// PPI (re-arming the comparator), then EOI. Runs in IRQ context with a +/// PPI (re-arming the comparator), then EOI. WMP card 3 adds a second way a +/// timer PPI can arrive: a reschedule nudge, which runs the same rotation +/// without advancing the wall clock (see `timer.handle`'s return value). +/// Runs in IRQ context with a /// register frame on the stack — NO console access (the heartbeat prints /// from the shell idle loop, where a print cannot re-enter the polled /// virtio TX path mid-flush). Claim 5275: on a timer PPI the scheduler @@ -1912,15 +1915,22 @@ fn irq_dispatch() void { if (intid < 16) { smp.handle_sgi(intid); } else if (timer.is_ppi(intid)) { + // WMP card 3: `handle` reports whether this delivery was the 1 Hz + // period boundary or a reschedule nudge, and that answer decides + // whether the wall clock advances. A secondary core only re-arms, so + // its beat is a period boundary by construction (it does no + // timekeeping anyway — the `c == 0` guard in `tick` keeps that + // core-0's). + var period_tick = true; if (smp.core_id() == 0) { - timer.handle(); // core-0 timekeeping authority (tick record + re-arm) + period_tick = timer.handle(); // core-0 timekeeping authority (tick record + re-arm) } else { timer.arm(); // secondary core: re-arm only — no tick record } // SMP lift (claim 8477 follow-up): every core runs the tick; on // secondary cores it runs ONLY the switch machinery on its own // per-core staging (global timekeeping/registries stay core-0). - scheduler.tick(); + scheduler.tick(period_tick); } else { virtio_custom.note_irq(intid); } diff --git a/kernel/src/monitor.zig b/kernel/src/monitor.zig index f6375423..91f8d4ec 100644 --- a/kernel/src/monitor.zig +++ b/kernel/src/monitor.zig @@ -4012,6 +4012,19 @@ fn cmd_timer(m: *Monitor, args: []const []const u8) ExecError { m.console.print_u64(timer.irq_ticks); m.console.puts(" poll="); m.console.print_u64(timer.poll_ticks); + // WMP card 3: the nudge counters. `armed` > `served` means nudges were + // subsumed by the period boundary arriving first (harmless — the owed + // rotation ran from the period); `coalesced` is the surplus a wake burst + // did NOT pay for. `period_first` counts requests dropped because the 1 Hz + // boundary was already sooner than the 2 ms nudge would have been. + m.console.puts(" nudge_armed="); + m.console.print_u64(timer.nudge_armed_total); + m.console.puts(" nudge_served="); + m.console.print_u64(timer.nudge_served); + m.console.puts(" nudge_coalesced="); + m.console.print_u64(timer.nudge_coalesced); + m.console.puts(" nudge_period_first="); + m.console.print_u64(timer.nudge_period_first); m.console.puts(" acked="); m.console.print_u64(gic.acked_total()); // claim 7339: summed across cores m.console.puts(" first="); @@ -7213,6 +7226,24 @@ fn cmd_wm(m: *Monitor, args: []const []const u8) ExecError { m.console.print_u64(info.flush_avg_ns / 1000); m.console.puts(" flush_max_us="); m.console.print_u64(info.flush_max_ns / 1000); + // WMP card 3: the scheduling half of the same latency. The nudge is + // what makes a woken WM run in milliseconds rather than at the next + // 1 Hz boundary, so the counters belong on the row that reports the + // latency they buy. Reported here rather than behind a `sched` + // command because the pacing gate reads THIS row: a latency claim + // that cannot be attributed to nudges would be unfalsifiable. + // requests = wakes that owed a rotation + // coalesced = of those, the ones that found one already owed + // nudges = comparators pulled forward + // served = of those, the ones that actually delivered a nudge + m.console.puts(" resched_requests="); + m.console.print_u64(scheduler.resched_requests); + m.console.puts(" resched_coalesced="); + m.console.print_u64(scheduler.resched_coalesced); + m.console.puts(" nudge_armed="); + m.console.print_u64(timer.nudge_armed_total); + m.console.puts(" nudge_served="); + m.console.print_u64(timer.nudge_served); m.console.puts("\n"); // M32 WMS4 (issue #624): chrome observability — SET_WINDOW // submissions counted, the broadcast policy's chrome kind, and the diff --git a/kernel/src/scheduler.zig b/kernel/src/scheduler.zig index 1e4639dd..4f928aa2 100644 --- a/kernel/src/scheduler.zig +++ b/kernel/src/scheduler.zig @@ -95,6 +95,9 @@ const serial_ring = @import("serial_ring.zig"); // Arc5 #243: serial snapshot fo const virtio_file = @import("virtio_file.zig"); // Arc5 #243: tombstone write through the host file channel (HF6: the DATA partition is gone) const smp = @import("smp.zig"); const spinlock = @import("spinlock.zig"); +// WMP card 3: the reschedule nudge pulls this core's comparator forward so a +// woken task runs in milliseconds instead of at the next 1 Hz boundary. +const timer = @import("timer.zig"); const user_stack_section = if (builtin.object_format == .elf) ".userbss" else "__DATA,__userbss"; @@ -541,6 +544,17 @@ fn push_home_locked(id: usize) void { const daif = ring_locks[home].lock(); ready_rings[home].push(id); ring_locks[home].unlock(daif); + // WMP card 3: this is the single blocked->ready funnel, so it is also the + // single place a rotation can become owed. Placed AFTER the ring unlock + // so the nudge (which may immediately interrupt this core) can never + // observe a half-pushed ring; the task is already runnable and visible to + // the rotation by then. + // + // NOTE: the ROTATION does not come through here — `switch_context` pushes + // the preempted task back with a direct `ready_rings[c].push`. That is + // load-bearing: routing it through here would make every rotation request + // the next one, and the comparator would never stop firing. + request_resched(); } /// The ready-membership invariant, asserted by the host tests after every @@ -766,6 +780,79 @@ pub var user_stack: [task_stack_size]u8 align(4096) linksection(user_stack_secti /// sys_yield. It lives in the already-mapped user BSS aperture and exposes no /// privileged state beyond the fact that this task was preempted by a tick. pub var user_timer_preemptions: u64 align(8) linksection(user_stack_section) = 0; + +// --------------------------------------------------------------------------- +// WMP card 3: the reschedule request +// --------------------------------------------------------------------------- +/// A task became runnable while another is executing, so a rotation is OWED. +/// WMP card 1 (#1247) measured what the absence of this costs: the WM's +/// pointer response was 786-1216 ms typical and 3004 ms worst, because +/// round-robin evaluates preemption ONLY at the 1 Hz tick, so a woken task +/// waits a uniformly distributed 0-1 s to be scheduled. The request pulls the +/// core-0 comparator forward (`timer.nudge`), so the SAME IRQ rotation a +/// period tick uses runs ~2 ms later instead of ~1 s later. +/// +/// The flag is the coalescing rule: at most one nudge is in flight between +/// rotations, so a burst of wakes costs ONE extra comparator fire, not one +/// per wake. It is cleared by every rotation on core 0 (the end of `tick`). +pub var resched_requested: bool = false; +/// Requests that owed a rotation (one per wake that found none pending). +pub var resched_requests: u64 = 0; +/// Requests that arrived while one was already owed — the coalesced surplus. +/// Non-zero here is what proves the coalescing is load-bearing rather than +/// the wake rate simply being too low to matter. +pub var resched_coalesced: u64 = 0; +/// Rotations that discharged a request (a nudge that did its job, or a period +/// boundary that subsumed one). +pub var resched_discharged: u64 = 0; + +/// A task just became runnable while another is executing: ask the timer to +/// preempt us sooner than the next 1 Hz boundary. Called from the wake funnel +/// (`push_home_locked`), so it covers every blocked->ready transition — +/// event pushes (`sys_wait_event`), process-exit waiters, futex wakes, spawn, +/// and the app-timer/WM-pacing fires inside `on_tick`. Pure BSS writes plus a +/// comparator `msr`; safe in the SVC, IRQ and lock-held contexts those paths +/// run in (no console, no allocation, no lock). +/// +/// Deliberately narrow: +/// * a no-op until preemption is armed (`start`), so boot-time wakes do not +/// fire comparators before the shell loop is the running context; +/// * core 0 only — that is the core whose PPI carries the shell/desktop +/// rotation, and the only core whose `timer.handle` consumes a nudge +/// (a secondary core re-arms without inspecting it, so a nudge armed +/// there would never be served); +/// * a wake raised from INSIDE a rotation (`on_tick`'s app timers, WM +/// pacing, `wake_expired`) is discharged free by that same rotation, so +/// the common tick-driven wake costs no extra interrupt at all. +pub fn request_resched() void { + if (!enabled_flag) return; + if (smp.core_id() != 0) return; + if (resched_requested) { + resched_coalesced +%= 1; + return; + } + resched_requested = true; + resched_requests +%= 1; + timer.nudge(); +} + +/// A rotation ran on core `c`: any request it was serving is discharged. +/// +/// Split out of `tick` (rather than inlined at its tail) because `tick`'s +/// body is aarch64-only — it reads ELR_EL1/SPSR_EL1, which fault at EL0 — so +/// a host test cannot call it, while the coalescing rule below is precisely +/// what a host test must be able to pin. The tick wiring itself (and the +/// nudge's real comparator arithmetic) is proven live by the class-B +/// `live-wm-pacing` gate. +/// +/// Core-gated: only core 0 ever raises a request, so a secondary core's +/// rotation must not swallow core 0's pending one. +pub fn discharge_resched(c: usize) void { + if (c == 0 and resched_requested) { + resched_requested = false; + resched_discharged +%= 1; + } +} /// The idle task's static stack (BSS, like every other kernel global). var idle_stack: [task_stack_size]u8 align(16) = undefined; /// The monitor `spawn` command's dedicated demo stack; one spawn only, so @@ -2351,7 +2438,14 @@ fn spawn_demo_entry() void { /// stack (`exceptions.resume_frame[c]`); ELR_EL1/SPSR_EL1 still hold the /// interrupted PC/PSTATE. The switch itself only programs ELR/SPSR and the /// stub's restore frame — the stub does the register pop and eret. -pub fn tick() void { +/// `period_tick` is WMP card 3's distinction: TRUE when this timer PPI was +/// the 1 Hz period boundary (`timer.handle` returned true) and the wall clock +/// may advance, FALSE when it served a reschedule nudge. A nudge owes a +/// ROTATION and nothing else — the timekeeping beat (`on_tick`: tick_count, +/// the sleepers, app timers, WM pacing, CPU-limit accounting) must not run, +/// or scheduling latency would be paid for by silently running the clock +/// fast. The rotation below runs either way. +pub fn tick(period_tick: bool) void { if (comptime builtin.cpu.arch != .aarch64) return; if (!scheduling_active()) return; const c = smp.core_id(); // per-core staging @@ -2374,7 +2468,7 @@ pub fn tick() void { // here — skipped => one 1 s cadence loss (the pre-existing skip // semantic; claim 9498). Claim 881 slice 3: sched_lock no longer // spans the rotation below — only this timekeeping beat. - if (c == 0 and evk_taken != null and sched_lock.try_lock()) { + if (period_tick and c == 0 and evk_taken != null and sched_lock.try_lock()) { sched_lock_holder = smp.core_id(); on_tick(); sched_lock_release(); @@ -2443,6 +2537,13 @@ pub fn tick() void { if (c != 0 and next_runnable_for(current[c], c) == null and (spsr & 0xf) != spsr_el0t_irqs) return; timer_switch_context(exceptions.resume_frame[c], elr, spsr, exceptions.resume_sp_el0[c]); apply_pending(); + // WMP card 3: a rotation just ran, so any reschedule request it was + // serving is discharged. Clearing here rather than at entry is what makes + // a wake raised inside this same beat's `on_tick` (app timers, WM pacing, + // `wake_expired`) free: the request lands, then this discharges it, and + // no redundant nudge is armed. Nothing can wake between the rotation and + // this call — the IRQ handler is masked throughout. + discharge_resched(c); } /// Tick-only wrapper around the pure switch core. Keeping the source of the diff --git a/kernel/src/timer.zig b/kernel/src/timer.zig index cfa43c34..5f179300 100644 --- a/kernel/src/timer.zig +++ b/kernel/src/timer.zig @@ -35,6 +35,15 @@ pub const ppi_default: u32 = 30; pub const heartbeat_every: u64 = 5; /// One tick period: 1 second. pub const period_ns: u64 = 1_000_000_000; +/// WMP card 3: how long a RESCHEDULE NUDGE waits after a woken task becomes +/// runnable before the comparator fires. The 1 Hz period above is the wall +/// clock; this is the scheduling latency floor, and they are deliberately +/// different numbers. 2 ms sits far below a compositor frame (the measured +/// present's own transfer+flush is ~0.3 ms, a full WM loop is tens of ms) so +/// a woken task runs "now" in any user-visible sense, while staying long +/// enough that a wake burst coalesces into a single extra comparator fire +/// rather than an interrupt storm. +pub const nudge_period_ns: u64 = 2_000_000; // --------------------------------------------------------------------------- // State (module globals; read by the monitor `timer` command) @@ -52,7 +61,27 @@ pub var ticks: u64 = 0; pub var irq_ticks: u64 = 0; /// Ticks consumed by an explicit diagnostic comparator poll. pub var poll_ticks: u64 = 0; +// WMP card 3 observability. `nudge_armed_total` counts comparators pulled +// forward; `nudge_served` counts those that actually delivered a nudge +// (the remainder were subsumed by the period boundary arriving first); +// `nudge_coalesced` counts requests that found one already in flight — the +// number that proves the coalescing rule is doing its job rather than the +// load simply being light. BSS counters only, IRQ-safe (same discipline as +// the tick counters above). +pub var nudge_armed_total: u64 = 0; +pub var nudge_served: u64 = 0; +pub var nudge_coalesced: u64 = 0; +pub var nudge_period_first: u64 = 0; var period_ticks: u64 = 0; +/// Comparator delta for a reschedule nudge, derived once from CNTFRQ_EL0. +var nudge_ticks: u64 = 0; +/// Absolute CNTPCT value of the NEXT 1 Hz period boundary, as recorded by +/// `arm()`. A nudge moves the comparator off this value and `handle()` moves +/// it back, so the wall clock never loses or gains a second. +var period_deadline: u64 = 0; +/// A nudge is armed and the comparator is currently pulled forward of +/// `period_deadline`. At most one at a time (the coalescing rule). +var nudge_armed_flag: bool = false; var armed_flag: bool = false; var pending_heartbeat: bool = false; var pending_irq_report: bool = false; @@ -136,14 +165,11 @@ pub fn cntpct() u64 { return v; } -/// Arm (or re-arm) the comparator one period from now and enable the timer. -pub fn arm() void { - if (comptime builtin.cpu.arch != .aarch64) return; - if (period_ticks == 0) return; - const cval = cntpct() + period_ticks; +/// Program the comparator to an absolute CNTPCT value and enable the timer. +fn program_cval(target: u64) void { asm volatile ("msr cntp_cval_el0, %[v]" : - : [v] "r" (cval), + : [v] "r" (target), ); asm volatile ("msr cntp_ctl_el0, %[v]" : @@ -152,6 +178,52 @@ pub fn arm() void { asm volatile ("isb"); } +/// Arm (or re-arm) the comparator one period from now and enable the timer. +pub fn arm() void { + if (comptime builtin.cpu.arch != .aarch64) return; + if (period_ticks == 0) return; + const cval = cntpct() + period_ticks; + period_deadline = cval; + program_cval(cval); +} + +/// WMP card 3: the pure arming decision, split out so the coalescing rule is +/// host-testable (`cntpct()`/CNTFRQ are unreachable in a host test binary, so +/// the counter arithmetic is the only part that can be pinned there). Returns +/// the absolute value to program, or null when the request is dropped: +/// either a nudge is already in flight (COALESCE — one comparator pull per +/// rotation, not one per wake) or the 1 Hz boundary is already at least as +/// soon as the nudge would be (nothing to gain, and pulling the comparator +/// would only move the wall clock). +pub fn nudge_target(nudge_in_flight: bool, now: u64, deadline: u64, delta: u64) ?u64 { + if (nudge_in_flight) return null; + const target = now + delta; + if (target >= deadline) return null; + return target; +} + +/// WMP card 3: pull the comparator forward so the pending reschedule is +/// served in `nudge_period_ns` rather than at the next 1 Hz boundary. Called +/// by the scheduler from ordinary task context (an event push waking a +/// blocked task), never from the tick handler itself. IRQ-safe: BSS writes +/// plus two `msr`s, no console, no allocation, no lock. +/// +/// The nudge rides whatever core's comparator the caller is on, so the +/// SCHEDULER is the layer that decides which core is worth nudging (there it +/// is core 0, whose PPI carries the rotation for the shell/desktop). This +/// function only refuses when the timer was never programmed. +pub fn nudge() void { + if (comptime builtin.cpu.arch != .aarch64) return; + if (period_ticks == 0 or nudge_ticks == 0) return; + const target = nudge_target(nudge_armed_flag, cntpct(), period_deadline, nudge_ticks) orelse { + if (nudge_armed_flag) nudge_coalesced +%= 1 else nudge_period_first +%= 1; + return; + }; + nudge_armed_flag = true; + nudge_armed_total +%= 1; + program_cval(target); +} + /// Grant EL0 access to the counter registers (CNTPCT_EL0, CNTFRQ_EL0, /// CNTP_CTL_EL0) so EL0 processes can read time without a syscall slot. /// M24 K13/K14 (calc/dates.zig `now()`) read CNTPCT_EL0/CNTFRQ_EL0 @@ -197,6 +269,7 @@ pub fn init() void { freq = cntfrq(); if (freq == 0) return; period_ticks = freq * period_ns / 1_000_000_000; + nudge_ticks = freq * nudge_period_ns / 1_000_000_000; arm(); armed_flag = true; } @@ -243,10 +316,34 @@ pub fn on_tick() void { /// IRQ-context tick handler (called by the kernel's irq_dispatch when the /// acknowledged INTID matches `ppi`). Console-free by design. -pub fn handle() void { - if (comptime builtin.cpu.arch != .aarch64) return; +/// +/// Returns TRUE when this delivery was the **1 Hz period boundary** — the +/// caller may advance the wall clock (scheduler `on_tick`: tick_count, +/// sleepers, app timers, WM pacing, CPU accounting) — and FALSE when it +/// served a **reschedule nudge**, which must NOT be mistaken for a second +/// passing. WMP card 3 hangs the whole "a woken task runs promptly" change +/// on that distinction: the fix pays for scheduling latency out of shared +/// wall-clock ticks, so the period must stay exactly 1 Hz while extra, +/// uncounted comparator fires appear between them. +pub fn handle() bool { + if (comptime builtin.cpu.arch != .aarch64) return true; + if (nudge_armed_flag and cntpct() < period_deadline) { + // A nudge: the comparator was pulled forward to serve a pending + // reschedule, not to mark a second. Record NO tick — that is the + // whole point — and put the comparator back on the boundary the wall + // clock is keeping, so the next period arrives on schedule. + nudge_armed_flag = false; + nudge_served +%= 1; + program_cval(period_deadline); + return false; + } + // The period boundary. This also subsumes a nudge that was still armed: + // the rotation it owed is about to run anyway, so there is nothing left + // for it to serve and the flag must not survive into the next period. + nudge_armed_flag = false; record_tick(.irq); arm(); + return true; } /// True when `intid` is this timer's PPI. @@ -412,3 +509,58 @@ test "timer: wall_epoch tracks the boot epoch and local_time_of_day wraps (#1058 ticks = 20; try std.testing.expectEqual(@as(?u64, 10), local_time_of_day()); } + +// --------------------------------------------------------------------------- +// WMP card 3 — the reschedule nudge's arming rule +// --------------------------------------------------------------------------- + +// The nudge is the answer to WMP card 1's measurement: round-robin evaluated +// preemption only at the 1 Hz tick, so a woken WM waited 786-1216 ms typical +// and 3004 ms worst for its frame. Pulling the comparator forward serves the +// owed rotation in ~2 ms instead. +// +// Only the DECISION is reachable from a host test — `cntpct()` returns 0 +// under `builtin.is_test` and `arm()` never programs a comparator without a +// CNTFRQ — so the counter arithmetic (`nudge_target`) is pinned here and the +// real arming/delivery split is pinned live by the class-B `live-wm-pacing` +// gate, which reads `nudge_armed`/`nudge_served` off the pacing row. +test "timer: nudge_target pulls the comparator forward, coalesces, and yields to the period" { + const deadline: u64 = 10_000_000; + const delta: u64 = 48_000; // 2 ms at a 24 MHz counter + + // The ordinary case: nothing in flight and the period is far enough away + // that 2 ms from now genuinely beats it. This is the arm that makes a + // woken task run promptly. + try std.testing.expectEqual(@as(?u64, 1_000 + delta), nudge_target(false, 1_000, deadline, delta)); + + // COALESCE: a nudge is already in flight, so the caller's demand is + // absorbed rather than armed again. This is the rule that caps a wake + // burst at ONE extra comparator fire — without it every wake in a pointer + // storm would arm its own interrupt and the comparator would never stop. + try std.testing.expectEqual(@as(?u64, null), nudge_target(true, 1_000, deadline, delta)); + try std.testing.expectEqual(@as(?u64, null), nudge_target(true, 9_999_999, deadline, delta)); + + // The 1 Hz boundary is already at least as soon as the nudge would be: + // arming would move the wall clock for no scheduling gain, so the request + // is dropped and the period serves the owed rotation. + try std.testing.expectEqual(@as(?u64, null), nudge_target(false, deadline, deadline, delta)); + try std.testing.expectEqual(@as(?u64, null), nudge_target(false, deadline - 1, deadline, delta)); + + // Exactly touching the boundary is the edge: a target of `deadline` is + // NOT strictly sooner, so the period wins and no second is at risk; one + // tick earlier still arms. + try std.testing.expectEqual(@as(?u64, deadline - 1), nudge_target(false, deadline - delta - 1, deadline, delta)); + try std.testing.expectEqual(@as(?u64, null), nudge_target(false, deadline - delta, deadline, delta)); +} + +test "timer: the nudge period is far below the frame it serves and far above an interrupt storm" { + // The two constants are a deliberate pair and both must hold: the nudge + // has to be invisible next to the work it unblocks (a WM loop is tens of + // ms; even a present's own transfer+flush is ~0.3 ms), and it must not be + // so short that the comparator is pulled forward for every wake. + try std.testing.expect(nudge_period_ns <= period_ns / 100); + try std.testing.expect(nudge_period_ns >= 1_000_000); + // At the counter frequency the guest actually sees, the delta is not zero + // ticks — a zero delta would re-arm the comparator to "now" and spin. + try std.testing.expect(24_000_000 * nudge_period_ns / 1_000_000_000 > 0); +} diff --git a/kernel/tests/monitor_test.zig b/kernel/tests/monitor_test.zig index 64a6d0ff..89e90d0b 100644 --- a/kernel/tests/monitor_test.zig +++ b/kernel/tests/monitor_test.zig @@ -1338,7 +1338,7 @@ test "monitor: timer is registered and reports the unarmed host state" { // with the conventional PPI default. try std.testing.expectEqual(ExecError.none, exec(&mon, &.{"timer"})); try std.testing.expectEqualStrings( - "timer: armed=0 gic=none dist=0x0 ppi=0x1e freq=0x0 ticks=0 irq=0 poll=0 acked=0 first=0xffffffff\n", + "timer: armed=0 gic=none dist=0x0 ppi=0x1e freq=0x0 ticks=0 irq=0 poll=0 nudge_armed=0 nudge_served=0 nudge_coalesced=0 nudge_period_first=0 acked=0 first=0xffffffff\n", env.mock.contents(), ); } diff --git a/kernel/tests/scheduler_test.zig b/kernel/tests/scheduler_test.zig index 104874eb..c64fccad 100644 --- a/kernel/tests/scheduler_test.zig +++ b/kernel/tests/scheduler_test.zig @@ -46,6 +46,10 @@ const register_user = scheduler.register_user; const register_worker = scheduler.register_worker; const request_kill = scheduler.request_kill; const request_report = scheduler.request_report; +const request_resched = scheduler.request_resched; +// WMP card 3: the pure half of the rotation's discharge. `tick` itself reads +// ELR_EL1/SPSR_EL1 and cannot be called from a host test. +const discharge_resched = scheduler.discharge_resched; const reserved_fault_status = scheduler.reserved_fault_status; const reserved_kill_status = scheduler.reserved_kill_status; const ring_claim = scheduler.ring_claim; @@ -1094,3 +1098,118 @@ test "scheduler: teardown_pending gates the reaper off a mid-teardown zombie" { try std.testing.expectEqual(@as(usize, 2), register_user(0x3000, 0).?); try std.testing.expect(!scheduler.tasks[2].teardown_pending); } + +// --------------------------------------------------------------------------- +// WMP card 3 — the reschedule request +// --------------------------------------------------------------------------- +// +// WMP card 1 (#1247) measured what the absence of this costs: round-robin +// evaluated preemption ONLY at the 1 Hz tick, so a woken WM waited a uniformly +// distributed 0-1 s for its frame (786-1216 ms typical, 3004 ms worst) while +// the frame's own transfer+flush was ~0.3 ms. The wake funnel now asks the +// timer to pull the comparator forward, so the SAME rotation a period tick +// uses runs ~2 ms later instead of ~1 s later. +// +// The flag is the coalescing rule, and coalescing is the whole safety +// argument: at most one nudge is in flight between rotations, so a pointer +// storm costs one extra comparator fire, not one per wake. + +test "scheduler: a wake through the ready-ring funnel raises a reschedule request; a core-0 rotation discharges it" { + _ = init(); + _ = register_worker(0x2000).?; + _ = register_user(0x3000, 0).?; + start(); + // Explicit reset: these are module globals and the host test binary runs + // every scheduler test in one process. + scheduler.resched_requested = false; + scheduler.resched_requests = 0; + scheduler.resched_coalesced = 0; + scheduler.resched_discharged = 0; + + try std.testing.expect(!scheduler.resched_requested); + try std.testing.expectEqual(@as(u64, 0), scheduler.resched_requests); + + try std.testing.expect(yield_current()); // shell -> worker + try std.testing.expect(yield_current()); // worker -> user (slot 2) + + // The running user sleeps. Blocking stages a successor and takes the user + // off the rings — NO task became runnable, so nothing is owed. A user- or + // WM-initiated block must not request a preemption, or a GUI that blocks + // waiting for input would run the comparator hot for the whole wait. + try std.testing.expect(sleep_current(1)); + try std.testing.expect(is_blocked(2)); + try std.testing.expect(!scheduler.resched_requested); + try std.testing.expectEqual(@as(u64, 0), scheduler.resched_requests); + check_ready_membership(); + + // The deadline passes and the sleeper becomes runnable again. In a real + // boot this runs inside `tick`'s `on_tick`, so the rotation from the SAME + // beat discharges the request and the tick-driven wake costs no extra + // interrupt at all; here `on_tick` is driven standalone, which is what + // leaves the request observable. + on_tick(); + try std.testing.expect(!is_blocked(2)); + try std.testing.expect(scheduler.ready_rings[0].contains(2)); + try std.testing.expect(scheduler.resched_requested); + try std.testing.expectEqual(@as(u64, 1), scheduler.resched_requests); + check_ready_membership(); + + // COALESCE: while a request is owed, further demand is absorbed rather + // than armed. Two more demands leave `requests` at 1 — this is what caps + // a wake burst at one comparator fire instead of one per wake. + request_resched(); + request_resched(); + try std.testing.expectEqual(@as(u64, 1), scheduler.resched_requests); + try std.testing.expectEqual(@as(u64, 2), scheduler.resched_coalesced); + + // A SECONDARY core's rotation must not swallow core 0's pending request — + // only core 0 ever raises one, so only core 0 may clear it. + discharge_resched(1); + try std.testing.expect(scheduler.resched_requested); + try std.testing.expectEqual(@as(u64, 0), scheduler.resched_discharged); + + // The core-0 rotation does discharge it. (This is the pure half of + // `tick`; `tick`'s own wiring — that a NUDGE delivery skips the + // timekeeping beat while still rotating — is live-only, pinned by the + // `live-wm-pacing` class-B gate.) + discharge_resched(0); + try std.testing.expect(!scheduler.resched_requested); + try std.testing.expectEqual(@as(u64, 1), scheduler.resched_discharged); + + // Discharge must leave the mechanism ARMED, not latched off: the next + // wake owes a fresh rotation. (Without this, a single discharge would + // silence every later wake and the latency would quietly come back.) + try std.testing.expect(yield_current()); // idle -> shell + try std.testing.expect(yield_current()); // shell -> worker + try std.testing.expect(yield_current()); // worker -> user + try std.testing.expectEqual(@as(usize, 2), scheduler.current[0]); + try std.testing.expect(sleep_current(1)); + try std.testing.expect(!scheduler.resched_requested); + on_tick(); + try std.testing.expectEqual(@as(u64, 2), scheduler.resched_requests); + check_ready_membership(); +} + +test "scheduler: a wake before preemption is armed requests nothing" { + // The same boundary `start` draws for preemption itself. Boot-time wakes + // (process registration, the early service spawns) run before the shell + // loop is the running context, and must not pull comparators forward. + _ = init(); + _ = register_worker(0x2000).?; + _ = register_user(0x3000, 0).?; + scheduler.resched_requested = false; + scheduler.resched_requests = 0; + try std.testing.expect(!enabled()); + + request_resched(); + try std.testing.expect(!scheduler.resched_requested); + try std.testing.expectEqual(@as(u64, 0), scheduler.resched_requests); + + // ...and the identical call IS live once preemption is armed, so the + // guard is a gate and not a permanently dead path. + start(); + request_resched(); + try std.testing.expect(scheduler.resched_requested); + try std.testing.expectEqual(@as(u64, 1), scheduler.resched_requests); + discharge_resched(0); +} diff --git a/tools/gate/specs/live-wm-pacing.spec b/tools/gate/specs/live-wm-pacing.spec index b605ee93..29cb638d 100644 --- a/tools/gate/specs/live-wm-pacing.spec +++ b/tools/gate/specs/live-wm-pacing.spec @@ -1,5 +1,8 @@ -# live-wm-pacing.spec -- M53 card 1 (#1247): what the desktop's frame cadence -# and input latency ACTUALLY are, measured rather than assumed. +# live-wm-pacing.spec -- WMP (WM frame pacing) card 1 (#1247): what the +# desktop's frame cadence and input latency ACTUALLY are, measured rather than +# assumed. Carried forward by card 2 (#1250, present-on-input) and card 3 +# (the reschedule nudge), which is what turned it from an instrument into a +# gate with bounds. # # The question this gate exists to answer: in WM mode the kind-18 # COMPOSITE_TICK is 1 Hz (timer.zig `period_ns`), but `pointer_tick` returns @@ -12,11 +15,17 @@ # REQUEST_PRESENT -> transfer+flush complete (the kernel + GPU cost). The # `wm` monitor row reports both, plus the present and tick rates side by side. # -# This gate proves the INSTRUMENT is live and sane on real hardware. The -# measured VALUES are the card's finding and are reported on the issue, not -# frozen here as a threshold nobody has justified. +# WMP card 3: the gate now carries the BOUND the fix earned. Card 2 made the +# WM present when an input burst ends, which left the whole remaining term in +# the scheduler: round-robin evaluated preemption only at the 1 Hz tick, so a +# woken WM still waited a uniformly distributed 0-1 s to execute. The wake +# funnel now pulls the core-0 comparator forward (`scheduler.request_resched` +# -> `timer.nudge`), serving the owed rotation in ~2 ms instead. Measured +# 815 ms -> 2.5 ms average and 1005 ms -> 2.7 ms worst, with tick_avg_ms +# unchanged at 1000 — the nudge buys scheduling latency WITHOUT stealing +# wall-clock seconds, which is what the tick_avg_ms assertion defends. -vgate_name live-wm-pacing "M53 card 1: measured present cadence + input latency on VZ" +vgate_name live-wm-pacing "WMP: present cadence + input->present latency + reschedule nudge on VZ" vgate_share seed # The custom-virtio INPUT queue (the headless-safe pointer transport) is # SPIKE-gated, exactly like live-wnd-server run 02. @@ -58,9 +67,9 @@ vgate_assert 01 serial-contains 'wm: rate window_ms=' vgate_assert 01 serial-contains 'pacing-done' vgate_assert 01 serial-absent '[EXC] parking:' -# The instrument is live, tied to a real input burst, and physically sane. -# Deliberately loose: the observed VALUES are the finding (reported on #1247), -# and a tightened bound belongs in the follow-up card that fixes the cadence. +# The instrument is live, tied to a real input burst, physically sane, and as +# of card 3 carrying the bounds the fix earned — see the latency and +# non-vacuity assertions below. vgate_assert 01 python <<'PY' import os, re ser = open(os.environ["VG_SER"]).read() @@ -84,6 +93,12 @@ lat_avg_us = field("lat_avg_us", row) lat_max_us = field("lat_max_us", row) flush_n = field("flush_n", row) flush_max_us = field("flush_max_us", row) +# WMP card 3: the scheduling side of the same latency. If these are missing the +# instrument regressed; if they are zero the latency below cannot be attributed +# to the nudge and the assertion that follows is vacuous. +resched_requests = field("resched_requests", row) +nudge_armed = field("nudge_armed", row) +nudge_served = field("nudge_served", row) # The desktop presents, more than once (a cadence needs a positive window), # and the interval is stated rather than a rate that truncates to 0. @@ -100,18 +115,34 @@ assert 800 <= tick_avg_ms <= 1200, "tick_avg_ms=%d is not the 1 Hz heartbeat: %s # sample) and the right edge (the WM's present) both happened. assert lat_n >= 1, "no input->present latency sample was recorded: %s" % row assert lat_avg_us > 0, "latency recorded as zero — the clock never advanced: %s" % row -# The bound WMP card 2 (#1250) earned: before it, the WM presented only on -# every 2nd tick and the worst sample waited two intervals (3004 ms measured on -# the pre-fix tree). Now the frame is flushed when the input burst ends, so the -# remaining term is WAKE-TO-RUN — the scheduler round-robins on the 1 Hz timer -# (scheduler.zig "every tick preempts the current task"), so a woken WM still -# waits up to one tick to actually execute. One tick (~1.0-1.1 s on VZ) plus -# jitter is therefore the honest ceiling; 1.5 s catches a regression to the -# old 2-tick cadence without pinning the tick period itself (that belongs to -# the card that changes the quantum). -assert lat_max_us < 1_500_000, "latency regressed past one tick (%d us): %s" % (lat_max_us, row) +# WMP card 3: the bound the reschedule nudge earned. Before it the remaining +# term was WAKE-TO-RUN — round-robin preempted only at the 1 Hz tick, so a +# woken WM waited a uniformly distributed 0-1 s and BOTH aggregates sat in the +# hundreds of milliseconds (815 ms avg / 1005 ms max measured at card 2's tip). +# Now the owed rotation is served ~2 ms after the wake, so the aggregates are a +# few ms (2.5 / 2.7 measured). +# +# 50 ms / 100 ms are therefore not tight measurements of the new behaviour but +# REGRESSION bounds: they sit ~20-40x above what the fix achieves, and ~10x +# below a tick, so a return to tick-gated scheduling fails them immediately (a +# 0-1 s uniform wait exceeds 100 ms on all but ~10% of single samples, and the +# average of a burst cannot). Left deliberately loose of the observed value so +# a loaded runner does not flake it. +assert lat_avg_us < 50_000, "average latency regressed toward tick-gated scheduling (%d us): %s" % (lat_avg_us, row) +assert lat_max_us < 100_000, "worst-case latency regressed toward tick-gated scheduling (%d us): %s" % (lat_max_us, row) assert flush_max_us < 5_000_000, "impossible flush cost (%d us): %s" % (flush_max_us, row) +# NON-VACUITY, and the attribution without which the bounds above are +# unfalsifiable: the latency must have been bought by nudges that actually +# fired, and there must have been at least one nudge available to answer EVERY +# sample the latency aggregate counted. Zero nudges means the improvement came +# from somewhere else and this gate is not testing card 3 at all. +assert resched_requests >= 1, "no reschedule request was ever raised: %s" % row +assert nudge_armed >= 1, "no comparator was ever pulled forward: %s" % row +assert nudge_served >= 1, "no nudge ever delivered (raised but all subsumed by the period): %s" % row +assert nudge_served >= lat_n, \ + "%d latency samples but only %d nudges served — some sample was answered without a nudge: %s" % (lat_n, nudge_served, row) + # The latency samples must come from the INJECTED burst, not a stray sample: # `ptr_fan` is the kernel's own count of fanned pointer samples, printed in # the same dump's sibling row. Without this, one incidental sample would @@ -121,6 +152,6 @@ assert fan_rows, "no ptr_fan row: the input seam never reported" ptr_fan = field("ptr_fan", fan_rows[-1]) assert ptr_fan >= 4, "only %d pointer samples fanned — the burst did not land: %s" % (ptr_fan, fan_rows[-1]) -print("M53 pacing OBSERVED: window_ms=%d present_avg_ms=%d tick_avg_ms=%d ptr_fan=%d lat_n=%d lat_avg_us=%d lat_max_us=%d flush_n=%d flush_max_us=%d" - % (window_ms, present_avg_ms, tick_avg_ms, ptr_fan, lat_n, lat_avg_us, lat_max_us, flush_n, flush_max_us)) +print("WMP pacing OBSERVED: window_ms=%d present_avg_ms=%d tick_avg_ms=%d ptr_fan=%d lat_n=%d lat_avg_us=%d lat_max_us=%d flush_n=%d flush_max_us=%d resched_requests=%d nudge_armed=%d nudge_served=%d" + % (window_ms, present_avg_ms, tick_avg_ms, ptr_fan, lat_n, lat_avg_us, lat_max_us, flush_n, flush_max_us, resched_requests, nudge_armed, nudge_served)) PY