diff --git a/docs/adr/0022-com2-latency-scheduler-bound.md b/docs/adr/0022-com2-latency-scheduler-bound.md index 5114691..4b4e3a0 100644 --- a/docs/adr/0022-com2-latency-scheduler-bound.md +++ b/docs/adr/0022-com2-latency-scheduler-bound.md @@ -1,7 +1,33 @@ # 22. COM2 Round-Trip Latency Is Scheduler-Cadence-Bound Date: 2026-07-11 -Status: Accepted (2026-07-13; the deferral decision + both prerequisites are complete — the latency fix itself remains a deliberately deferred future epic) +Status: Accepted (2026-07-15; the latency fix SHIPPED — the I/O-priority boost, see the final update) + +> **Update (2026-07-15) — the deferred epic is implemented: the I/O-priority boost.** +> The option table's principled fix shipped, hardened by a 4-lens adversarial design panel +> (31 findings: 7 blockers, 15 majors — all folded in). Mechanism: a per-PCB `io_boost` flag; +> set (a) at the end of every successful `ipc_send` for a READY receiver (own irq bracket — +> ipc_send had no existing one) and (b) from `scheduler_tick` when the registered COM2 holder +> has RX pending. `pick_next`'s DEFAULT round-robin arm prefers a boosted READY process; +> the flag is consumed only at the dispatch COMMIT (pick_next stays pure — it is also used +> as a bare predicate by the CPUKILL pre-check). Guards the panel forced: a sticky 0xFF +> absent-device latch (an unbacked COM2 port reads all-ones — the boost would otherwise fire +> every tick of every COM2-less CI boot); `SCHED_BOOST_MAX_CONSEC=2` (a boost ping-pong would +> otherwise monopolize every yield-path reschedule AND, by resetting the quantum counter, +> keep quantum expiry itself from firing — starving the roster invisibly to every liveness +> floor); the COM2-holder registration is (pid, generation) + live-cap-validated at the boost +> site and cleared in process_destroy (a bare pid would boost whatever recycles it — the +> ADR-0023 lesson); `boost_count` counts HONORED picks at the commit (a set-side counter is +> the switch_count vacuity trap). Gates re-armed in the same increment: ci-smoke-latency now +> asserts PING median < 0.30 s (boost-off phase-locks to the full ~0.45 s rotation — fail-side +> structural on any host) AND a positive honored-boost delta (binding effect to mechanism), +> plus STATUS median < 0.6 s with a 3-of-5 success floor; ci-smoke-sched LOAD mode paces its +> ping load at a fixed cadence (boost-invariant load profile) and arms a max_gap <= 600-tick +> starvation ceiling — the only metric that can referee the boost's fairness guard. +> Revert-confirm: `SCHED_IO_BOOST 0` reddens both latency assertions while ci-smoke-sched +> stays green (the preempt band is structurally boost-invariant: the expiry increment precedes +> the pick). The boost carries no authority (caps still gate every action) and no audit event +> (it would flood the 256-entry ring); its instrument is the counter. > **Update (2026-07-11).** Prerequisite 1 below — a bounded latency assertion — **shipped** as > `ci-smoke-latency` (`scripts/test_qos_latency.py`, via the new `QosVM.ping()` one-hop primitive). diff --git a/kernel/include/kernel/com2_uart.h b/kernel/include/kernel/com2_uart.h index d8da40c..a159238 100644 --- a/kernel/include/kernel/com2_uart.h +++ b/kernel/include/kernel/com2_uart.h @@ -37,4 +37,13 @@ uint32_t com2_write(const uint8_t *buf, uint32_t len); * number of bytes read (0 if none waiting). */ uint32_t com2_read(uint8_t *buf, uint32_t len); +/* True iff a COM2 RX byte is waiting (ADR-0022 I/O boost). ONE LSR read, + * safe for scheduler_tick's IRQ context: reading LSR never consumes data. + * Returns 0 — permanently, via a sticky latch — when the device is ABSENT: + * an unbacked port reads 0xFF (all error bits + TEMT + THRE + DR at once, + * impossible on real silicon), and nearly every CI boot wires only COM1, so + * without the latch the boost would fire on every tick of every functional + * gate. Also 0 when COM2 is uninitialized or the TX-wedge latch tripped. */ +int com2_rx_pending(void); + #endif /* COM2_UART_H */ diff --git a/kernel/include/kernel/process.h b/kernel/include/kernel/process.h index e40fd19..7f7918d 100644 --- a/kernel/include/kernel/process.h +++ b/kernel/include/kernel/process.h @@ -102,6 +102,14 @@ typedef struct process { uint64_t last_scheduled; /* Timer tick at which the process was last picked (ADR-0022) */ uint64_t sched_picks; /* Count of times the scheduler has picked this process (ADR-0022 fairness) */ + /* ADR-0022 I/O boost: this process's awaited I/O (an IPC delivery, or a + * COM2 RX byte for the registered holder) arrived — pick it out of turn + * at the next reschedule. Advisory: pick_next still requires READY + + * context_valid; consumed (cleared) only at dispatch COMMIT in + * scheduler_reschedule/scheduler_kill_current, so a predicate call can + * never eat it and a process dispatched by ANY path sheds a stale flag. + * Zeroed for every incarnation by init_process_pcb's memset. */ + uint8_t io_boost; /* IPC integration - queues managed internally by PID via ipc_process_init() */ uint32_t port_count; /* Number of owned IPC ports */ diff --git a/kernel/include/kernel/scheduler.h b/kernel/include/kernel/scheduler.h index 785fc17..ef53513 100644 --- a/kernel/include/kernel/scheduler.h +++ b/kernel/include/kernel/scheduler.h @@ -20,6 +20,27 @@ /* Scheduling quantum in timer ticks (ticks are 10 ms at TIMER_DEFAULT_HZ) */ #define SCHED_QUANTUM_TICKS 5 +/* ADR-0022 I/O-priority boost (the deferred latency epic). When 1, a process + * whose awaited I/O just arrived — an IPC message delivered to its mailbox, + * or a COM2 RX byte for the registered COM2 holder — is flagged io_boost and + * picked out of turn at the next reschedule (default round-robin arm only), + * collapsing a COM2 hop from ~one full rotation (~450 ms at rest) to + * tick-detect (<=10 ms) + one quantum remainder (<=50 ms). 0 restores the + * exact pre-epic pick behavior — the latency gate's revert-confirm flag. */ +#define SCHED_IO_BOOST 1 + +/* Max CONSECUTIVE boosted picks before one plain round-robin pick is forced + * (the starvation guard). Two IPC-chatty peers ping-ponging sends would + * otherwise monopolize every yield-path reschedule AND — because each yield + * resets the global quantum counter — keep quantum expiry (and preempt_count, + * and the watchdog's turn) from ever firing. K=2 guarantees >=1 plain pick + * per 3, so a rotation of N runnable completes within ~3N picks and max_gap + * stays within a small multiple of today's envelope (refereed by the armed + * maxgap ceiling in ci-smoke-sched); a 1-boost PING hop rides through + * untouched and STATUS hops absorb at most a couple of plain slices, well + * inside the armed 0.6 s bound. */ +#define SCHED_BOOST_MAX_CONSEC 2 + /* Attach the scheduler to the timer tick. Call after process_init(). */ status_t scheduler_init(void); @@ -36,6 +57,29 @@ uint64_t scheduler_get_switches(void); * host-invariant quantity a scheduler baseline is built on (see scheduler.c). */ uint64_t scheduler_get_preempts(void); +/* HONORED I/O boosts (ADR-0022): counted only when a boosted pick is actually + * COMMITTED to the interrupt frame — never on flag-sets — so the counter is + * non-vacuous by construction (the switch_count lesson: a set-side counter + * reads nonzero while the pick path is dead). The latency gate asserts a + * positive delta across its ping batch to bind the measured latency win to + * the boost mechanism rather than an unrelated cadence change. */ +uint64_t scheduler_get_boosts(void); + +/* Flag `pid` for one out-of-turn pick iff it is currently READY (ADR-0022). + * Called by ipc_send after a successful enqueue (inside the caller's irq + * bracket) and by the COM2 RX check in scheduler_tick. Advisory only: worst + * case is one extra or one missed boost; never corrupts scheduling state. */ +void scheduler_boost_if_ready(uint32_t pid); + +/* Register/clear the COM2-holder for the RX boost, as a (pid, generation) + * pair — a bare pid would re-attach to whatever recycles it (the ADR-0023 + * lesson). Set at every start_slot grant_com2 mint (overwrite semantics, so + * a watchdog rebirth re-registers structurally); cleared in process_destroy. + * The boost site additionally validates the live COM2 device cap, keeping + * the capability table the single source of truth for the authority. */ +void scheduler_com2_holder_set(uint32_t pid, uint32_t generation); +void scheduler_com2_holder_clear(uint32_t pid); + /* ADR-0022 prereq-2 fairness/tail snapshot over the runnable ring-3 citizens. * Writes max reschedule gap (max now - last_scheduled), the run-count spread * (max - min sched_picks), and the runnable count. Read-only; `now` is the diff --git a/kernel/src/com2_uart.c b/kernel/src/com2_uart.c index 1a1e5e2..b1367b0 100644 --- a/kernel/src/com2_uart.c +++ b/kernel/src/com2_uart.c @@ -103,3 +103,25 @@ uint32_t com2_read(uint8_t *buf, uint32_t len) { } return n; } + +/* Sticky: set once LSR reads 0xFF — the unbacked-port signature (all error + * bits + TEMT + THRE + DR simultaneously, impossible on real silicon). QEMU + * boots with a single -serial (nearly every functional CI gate, and the + * qBraid/laptop boots) leave 0x2F8 unassigned, and unassigned I/O reads + * all-ones — so without this latch the ADR-0022 RX-boost check would see + * DATA_READY on EVERY tick of every COM2-less boot and hand swarm-svc a + * permanent unearned scheduling skew. Latched once; one io_inb saved per + * tick thereafter. */ +static uint8_t com2_absent = 0; + +int com2_rx_pending(void) { + if (!com2_ready || com2_dead || com2_absent) { + return 0; + } + uint8_t lsr = io_inb(COM2_PORT_BASE + UART_LSR); + if (lsr == 0xFF) { + com2_absent = 1; + return 0; + } + return (lsr & LSR_DATA_READY) ? 1 : 0; +} diff --git a/kernel/src/ipc/ipc.c b/kernel/src/ipc/ipc.c index 704f4b6..ae1f67c 100644 --- a/kernel/src/ipc/ipc.c +++ b/kernel/src/ipc/ipc.c @@ -13,6 +13,7 @@ #include #include #include +#include /* scheduler_boost_if_ready (ADR-0022 I/O boost) */ /* ============================================================================ * Internal Constants @@ -434,6 +435,20 @@ ipc_result_t ipc_send(uint32_t receiver_id, const ipc_message_t *msg, uint64_t t if (result == IPC_SUCCESS) { ipc_global_stats.total_sent++; + /* I/O-priority boost (ADR-0022): the receiver's awaited message just + * landed — flag it for one out-of-turn pick so the hop costs a + * reschedule, not a full round-robin rotation (~450 ms at rest). + * Only on SUCCESS (a dropped/full-queue send must not boost), only + * if READY (checked inside), and in its OWN irq bracket: ipc_send is + * otherwise unbracketed and today's callers are all cli'd syscalls, + * but IF=1 kernel-thread senders are documented-plausible (the + * health monitor / net thread pattern) and an unbracketed + * check-then-set racing the timer IRQ could stamp the flag on a PCB + * the CPUKILL latch just terminated. Advisory flag: worst case is + * one extra or one missed boost, never corrupted scheduling state. */ + uint64_t bflags = ipc_irq_save(); + scheduler_boost_if_ready(receiver_id); + ipc_irq_restore(bflags); } return result; diff --git a/kernel/src/process.c b/kernel/src/process.c index 90efba8..dd23301 100644 --- a/kernel/src/process.c +++ b/kernel/src/process.c @@ -16,6 +16,7 @@ #include #include #include /* timer_get_ticks() for the ADR-0022 sched timing seam */ +#include /* scheduler_com2_holder_clear (ADR-0022 RX boost) */ #include #include #include @@ -478,6 +479,12 @@ status_t process_destroy(uint32_t pid) { process->capability_root = CAP_ID_INVALID; process->capability_count = 0; + /* Drop the ADR-0022 COM2-RX boost registration if this pid held it — + * same pid-reuse funnel argument as the unlink above: a recycled pid + * must never inherit the holder's scheduling boost. (No-op for every + * process but the COM2 holder; start_slot re-registers on rebirth.) */ + scheduler_com2_holder_clear(pid); + /* Clear the intent manifest with the caps (epic #135). INVARIANT: this * must run before state=UNUSED below — every pid-reuse path funnels * through process_destroy, so clear-here + bind-before-schedule is what diff --git a/kernel/src/scheduler.c b/kernel/src/scheduler.c index 8682d3d..2c410b0 100644 --- a/kernel/src/scheduler.c +++ b/kernel/src/scheduler.c @@ -18,7 +18,8 @@ #include #include #include -#include /* early_console_write for the CPUKILL boot line */ +#include /* com2_rx_pending + DEVICE_ID_COM2 (ADR-0022 RX boost) */ +#include /* early_console_write for the CPUKILL boot line */ #ifdef SCHED_LOTTERY #include #endif @@ -37,6 +38,28 @@ static uint32_t quantum_counter = 0; * so is the quantity a SCHED_QUANTUM_TICKS change actually moves. */ static uint64_t preempt_count = 0; +#if SCHED_IO_BOOST +/* HONORED boosts only (ADR-0022): incremented at the dispatch COMMIT points + * below, never where a flag is set — a set-side counter would read nonzero + * with the pick path dead (the switch_count vacuity lesson, prereq-2). */ +static uint64_t boost_count = 0; +/* Consecutive boosted picks since the last plain pick (the starvation guard, + * see SCHED_BOOST_MAX_CONSEC). Single global counter; reset on any plain + * pick, checked before the boost scan. */ +static uint32_t consec_boost = 0; +/* Whether the LAST pick_next call selected via the boost scan. File-static + * rather than a pick_next side effect on the PCB: pick_next stays PURE (it + * is also used as a bare predicate by scheduler_tick's CPUKILL pre-check, + * which must never consume a boost), and the commit points read this + * immediately after their own pick_next call. */ +static uint8_t pick_was_boost = 0; +/* The registered COM2 holder for the RX boost, as (pid, generation) — a bare + * pid would boost whatever process recycles it (the ADR-0023 stale-pid + * lesson). generation 0 with pid 0 = no holder. */ +static uint32_t com2_holder_pid = 0; +static uint32_t com2_holder_gen = 0; +#endif + /* PID of the idle process (created second in process_init) */ #define IDLE_PROCESS_ID (KERNEL_PROCESS_ID + 1) @@ -82,6 +105,34 @@ static process_t *pick_next(uint32_t from_pid) { #else process_t *idle = NULL; +#if SCHED_IO_BOOST + /* Boost scan (ADR-0022): a READY citizen whose awaited I/O just arrived + * is picked out of turn — unless the consecutive-boost budget is spent, + * which forces a plain pick so a boost ping-pong can never starve the + * rotation (or, by resetting the quantum counter on every yield, keep + * quantum expiry itself from ever firing). READ-ONLY: the flag is + * consumed at the dispatch commit, never here — this function is also + * called as a bare predicate (the CPUKILL pre-check) and on the kill + * path twice, and a scan-side clear would eat boosts with no dispatch. + * Default round-robin arm ONLY: the RESONANT/LOTTERY arms above return + * first, so this is a compile-time no-op for the opt-in experiments. */ + pick_was_boost = 0; + if (consec_boost < SCHED_BOOST_MAX_CONSEC) { + for (uint32_t off = 1; off <= MAX_PROCESSES; off++) { + uint32_t pid = (from_pid + off) % MAX_PROCESSES; + process_t *p = process_get_by_pid(pid); + if (!p || p->state != PROCESS_STATE_READY || !p->context_valid || !p->io_boost) { + continue; + } + if (pid == IDLE_PROCESS_ID) { + continue; /* idle is never boost-eligible */ + } + pick_was_boost = 1; + return p; + } + } +#endif + for (uint32_t off = 1; off <= MAX_PROCESSES; off++) { uint32_t pid = (from_pid + off) % MAX_PROCESSES; process_t *p = process_get_by_pid(pid); @@ -102,7 +153,80 @@ static process_t *pick_next(uint32_t from_pid) { #endif } +#if SCHED_IO_BOOST +/* Consume a boost at the dispatch COMMIT: unconditionally shed the flag on + * every committed switch (a process dispatched by ANY path must not carry a + * stale boost into a later, unrelated pick), and account the boost budget / + * counter only when the boost scan actually made this pick. */ +static void boost_commit(process_t *next) { + next->io_boost = 0; + if (pick_was_boost) { + boost_count++; + consec_boost++; + } else { + consec_boost = 0; + } +} + +void scheduler_boost_if_ready(uint32_t pid) { + process_t *p = process_get_by_pid(pid); + if (p && p->state == PROCESS_STATE_READY) { + p->io_boost = 1; + } +} + +void scheduler_com2_holder_set(uint32_t pid, uint32_t generation) { + com2_holder_pid = pid; + com2_holder_gen = generation; +} + +void scheduler_com2_holder_clear(uint32_t pid) { + if (com2_holder_pid == pid) { + com2_holder_pid = 0; + com2_holder_gen = 0; + } +} + +/* COM2 RX boost check, run from scheduler_tick (IRQ context, IF=0). Cost at + * rest: one io_inb of LSR (com2_rx_pending; a sticky latch makes COM2-less + * boots free after the first read). The heavier validation below runs only + * when bytes are actually pending. */ +static void com2_rx_boost_check(void) { + if (com2_holder_pid == 0 || !com2_rx_pending()) { + return; + } + /* Generation guard (the ADR-0023 lesson): never boost whatever process + * recycled the holder's pid. */ + if (process_get_generation(com2_holder_pid) != com2_holder_gen) { + return; + } + /* Single source of truth: the registration is only an index hint — the + * AUTHORITY is the live COM2 device cap. A revoked cap (teardown in + * progress, or a future explicit revoke) must stop the boost with it. */ + if (cap_find_resource(com2_holder_pid, CAP_RESOURCE_DEVICE, CAP_READ, DEVICE_ID_COM2) != + CAP_SUCCESS) { + return; + } + process_t *p = process_get_by_pid(com2_holder_pid); + if (!p) { + return; + } + /* READY: normal case. RUNNING: the holder is the interrupted process + * itself — flag it anyway so an expiry on this same tick re-picks it + * instead of rotating it out with data waiting (saves one extra tick). */ + if (p->state == PROCESS_STATE_READY || p->state == PROCESS_STATE_RUNNING) { + p->io_boost = 1; + } +} +#endif + void scheduler_tick(cpu_state_t *state) { +#if SCHED_IO_BOOST + /* ADR-0022: flag the COM2 holder the moment host bytes are waiting, so + * the pick below (or the next reschedule from any path) delivers them at + * tick granularity instead of one full rotation later. */ + com2_rx_boost_check(); +#endif /* Manifest CPU accounting (epic #135): charge the interrupted process * one tick BEFORE the quantum early-return — after it, this would count * reschedules and undercount by SCHED_QUANTUM_TICKS x. IRQ context: IF @@ -173,6 +297,9 @@ void scheduler_reschedule(cpu_state_t *state) { /* Bookkeeping: current_process pointer, statistics */ process_switch_to(next); process_set_state(next->pid, PROCESS_STATE_RUNNING); +#if SCHED_IO_BOOST + boost_commit(next); /* consume the flag exactly when the switch commits */ +#endif /* Restore the next context onto the interrupt frame; the iretq at * the end of irq_common resumes it. Switch to the next process's @@ -221,6 +348,9 @@ void scheduler_kill_current(cpu_state_t *state) { /* Do not save the dead process's context */ process_switch_to(next); process_set_state(next->pid, PROCESS_STATE_RUNNING); +#if SCHED_IO_BOOST + boost_commit(next); /* same commit-point consume as scheduler_reschedule */ +#endif vmspace_switch(next->cr3 ? next->cr3 : vmspace_kernel_cr3()); *state = next->context; switch_count++; @@ -234,6 +364,29 @@ uint64_t scheduler_get_preempts(void) { return preempt_count; } +uint64_t scheduler_get_boosts(void) { +#if SCHED_IO_BOOST + return boost_count; +#else + return 0; +#endif +} + +#if !SCHED_IO_BOOST +/* Flag-off stubs keep the call sites (ipc_send, start_slot, process_destroy) + * unconditional — the revert-confirm build differs ONLY in pick behavior. */ +void scheduler_boost_if_ready(uint32_t pid) { + (void)pid; +} +void scheduler_com2_holder_set(uint32_t pid, uint32_t generation) { + (void)pid; + (void)generation; +} +void scheduler_com2_holder_clear(uint32_t pid) { + (void)pid; +} +#endif + /* ADR-0022 prereq-2 fairness/tail snapshot over the runnable ring-3 citizens * (state READY or RUNNING, excluding the kernel and idle processes): * *max_gap = max(now - last_scheduled) -- worst reschedule-gap tail diff --git a/kernel/src/service.c b/kernel/src/service.c index f1e8518..95b1109 100644 --- a/kernel/src/service.c +++ b/kernel/src/service.c @@ -300,6 +300,14 @@ static svc_result_t start_slot(service_slot_t *slot) { 0, &dcap) != CAP_SUCCESS) { boot_log("service: COM2 device cap grant failed"); boot_log(slot->info.name); + } else { + /* Register the holder for the ADR-0022 COM2-RX boost: (pid, + * generation) so a recycled pid can never inherit the boost + * (the ADR-0023 lesson), overwrite semantics so every watchdog + * rebirth re-registers structurally — this line re-executes + * inside the same cli window as the mint. The registration is + * only an index hint; the boost site re-validates the live cap. */ + scheduler_com2_holder_set(pid, process_get_generation(pid)); } } if (slot->def.grant_console) { diff --git a/kernel/src/syscall.c b/kernel/src/syscall.c index 4fbd585..7ea0e1d 100644 --- a/kernel/src/syscall.c +++ b/kernel/src/syscall.c @@ -584,6 +584,11 @@ static uint64_t sys_sysinfo(uint32_t pid, uint64_t op, uint64_t user_ptr, uint64 o = fmt_dec(tmp, o, sizeof(tmp), spread); o = fmt_str(tmp, o, sizeof(tmp), " runnable="); o = fmt_dec(tmp, o, sizeof(tmp), runnable); + /* boost= APPENDED LAST (ADR-0022 I/O boost): honored-boost picks. + * Trailing so every existing prefix-matched parser keeps working; + * the latency gate asserts a positive delta across its ping batch. */ + o = fmt_str(tmp, o, sizeof(tmp), " boost="); + o = fmt_dec(tmp, o, sizeof(tmp), scheduler_get_boosts()); o = fmt_str(tmp, o, sizeof(tmp), "\r\n"); produced = o; } else { diff --git a/scripts/qos_bridge.py b/scripts/qos_bridge.py index b5acef9..1009964 100644 --- a/scripts/qos_bridge.py +++ b/scripts/qos_bridge.py @@ -1301,12 +1301,16 @@ def sched(self, deadline_s=12.0): self._ensure_verified() text = self._collect(["sched"], deadline_s, "sched") m = re.search( - r"^SCHED: switches=(\d+) preempt=(\d+) ticks=(\d+) maxgap=(\d+) spread=(\d+) runnable=(\d+)", + r"^SCHED: switches=(\d+) preempt=(\d+) ticks=(\d+) maxgap=(\d+) spread=(\d+) runnable=(\d+)" + r"(?: boost=(\d+))?", text, re.M) if not m: raise QosError("sched: SCHED line not found in qsh output") return {"switches": int(m.group(1)), "preempt": int(m.group(2)), "ticks": int(m.group(3)), - "maxgap": int(m.group(4)), "spread": int(m.group(5)), "runnable": int(m.group(6))} + "maxgap": int(m.group(4)), "spread": int(m.group(5)), "runnable": int(m.group(6)), + # ADR-0022 I/O boost: HONORED boost picks (trailing, optional so a + # pre-boost kernel still parses). 0 when absent. + "boost": int(m.group(7)) if m.group(7) else 0} def qrand(self, deadline_s=8.0): """Draw 64 bits of quantum-seeded entropy via the qsh `qrand` command diff --git a/scripts/test_qos_latency.py b/scripts/test_qos_latency.py index d6261bd..f1ade3b 100644 --- a/scripts/test_qos_latency.py +++ b/scripts/test_qos_latency.py @@ -15,11 +15,15 @@ regression guard + baseline, NOT a tight perf tracker (that is ADR-0022's prerequisite 2 and needs historical tracking). -The ceiling is generous (CI runners are slower than a dev box) but non-vacuous: -REVERT-CONFIRM — bumping SCHED_QUANTUM_TICKS in kernel/include/kernel/scheduler.h -(e.g. 5 -> 80) multiplies the round-robin cycle and pushes the PING median well -past the ceiling, reddening this gate. That the real regression source trips it -proves it is load-bearing. +Since the ADR-0022 I/O boost (the latency fix) this gate is ARMED TIGHT and +non-vacuous twice over: the PING median must sit under the boosted ceiling AND +the kernel's honored-boost counter must advance across the batch (binding the +number to the mechanism — a bare median is gameable by an unrelated quantum +change, which ci-smoke-sched deliberately tolerates). +REVERT-CONFIRM — primary: SCHED_IO_BOOST 0 in kernel/include/kernel/scheduler.h +reddens BOTH assertions (median back to the phase-locked ~0.45s rotation floor; +boost delta 0). Secondary: bumping SCHED_QUANTUM_TICKS (e.g. 5 -> 80) multiplies +the boost-off cycle and reddens the gross ceiling as before. Run: python3 scripts/test_qos_latency.py (boots one VM; needs qemu) Gate: make ci-smoke-latency @@ -32,7 +36,27 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from qos_bridge import QosVM, QosError # noqa: E402 -PING_CEILING_S = 4.0 # median one-hop round-trip; ~9x the observed ~0.45s, catches a gross regression +PING_CEILING_S = 4.0 # median one-hop round-trip; gross-regression guard (kept — see below) + +# ADR-0022 I/O boost (the latency fix this gate was built to prove). With the +# boost, a hop costs RX-detect (<=10ms tick) + one quantum remainder (<=50ms) +# instead of a full round-robin rotation — expected median <=0.1s. The bound is +# safe on BOTH sides for structural reasons: boost-OFF pings are PHASE-LOCKED +# to a full rotation (the host re-sends within ~1ms of each PONG and swarm_svc +# yields right after its pass), and the rotation is guest PIT wall-clock time, +# host-invariant — so a reverted/rotted boost medians >=~0.45s on ANY host +# (1.5x fail-side margin); boost-ON is roster-size-invariant (the boost jumps +# the queue), so CI roster growth cannot erode the pass side (~3x margin). +PING_BOOSTED_CEILING_S = 0.30 +N_PINGS = 9 # median-of-9 tolerates 4 outliers (median-of-5 only 2) + +# STATUS (3 boosted hops: request-IPC boosts ghostd, reply-IPC boosts +# swarm_svc, COM2-RX boosts swarm_svc). Boost-off floor is >=2 phase-locked +# rotations (~0.9s); boost-on ~0.2-0.3s. Armed with a success floor: with the +# boost live, repeated ghostd-chain timeouts ARE an IPC-delivery defect, not +# weather. +STATUS_CEILING_S = 0.6 +STATUS_MIN_OK = 3 # of 5 def _median(xs): @@ -58,18 +82,29 @@ def main(): except QosError: pass + # Boost non-vacuity bracket, part 1 (ADR-0022): snapshot the HONORED-boost + # counter before the measured batch. The counter is incremented only at a + # committed boosted dispatch (never on flag-sets), so the delta assertion + # below binds the latency number to the boost MECHANISM — a median alone + # is gameable by an unrelated quantum change, and a set-side counter would + # read nonzero with the pick path dead. + boosts_before = vm.sched(deadline_s=10.0)["boost"] + # PING->PONG: the pure transport + scheduler-cadence one-hop floor. Generous # per-ping deadline so even an inflated (regressed) round-trip is MEASURED - # rather than timed out, letting the ceiling assertion catch it. This is the - # ASSERTED metric (reliable — no downstream hop). - pings = [vm.ping(deadline_s=10.0) for _ in range(5)] + # rather than timed out, letting the ceiling assertion catch it. Back-to-back + # sends are deliberate: they phase-lock a boost-OFF ping to a FULL rotation, + # which is what makes the tightened bound's fail side deterministic — do NOT + # add inter-ping sleeps. + pings = [vm.ping(deadline_s=10.0) for _ in range(N_PINGS)] ping_med = _median(pings) - # STATUS: adds the swarm_svc<->ghostd IPC hop (~2-3x PING). BEST-EFFORT - # baseline only (not asserted), so a transient ghostd hiccup reports "n/a" - # rather than flaking the gate — PING carries the pass/fail. + # STATUS: adds the swarm_svc<->ghostd IPC hop. Armed since the boost + # (previously recorded-only: at a ~1s boost-off round-trip any meaningful + # bound would have flaked on warm-up hiccups; boosted, the margin exists + # on both sides). stats = [] - for _ in range(3): + for _ in range(5): try: t0 = time.time() vm.status(deadline_s=10.0) @@ -78,14 +113,34 @@ def main(): pass status_str = f"{_median(stats):.3f}s (n={len(stats)})" if stats else "n/a" - print(f"COM2 latency baseline (ADR-0022): PING median {ping_med:.3f}s (n=5), " - f"STATUS median {status_str}") + boosts_after = vm.sched(deadline_s=10.0)["boost"] + boost_delta = boosts_after - boosts_before + + print(f"COM2 latency (ADR-0022): PING median {ping_med:.3f}s (n={N_PINGS}), " + f"STATUS median {status_str}, boost picks +{boost_delta}") if ping_med > PING_CEILING_S: _fail(f"PING round-trip median {ping_med:.2f}s exceeds {PING_CEILING_S}s ceiling — " "COM2 transport / scheduler-cadence latency has REGRESSED (see ADR-0022)") - print(f"OK: PING round-trip within bound (median {ping_med:.2f}s < {PING_CEILING_S}s ceiling)") - print("=== COM2 latency gate PASSED — one-hop round-trip bounded, baseline recorded ===") + if ping_med > PING_BOOSTED_CEILING_S: + _fail(f"PING round-trip median {ping_med:.2f}s exceeds the BOOSTED ceiling " + f"{PING_BOOSTED_CEILING_S}s — the ADR-0022 I/O boost is not delivering " + "(reverted, rotted, or mis-picked); boost-off phase-locks to a full " + "rotation >=~0.45s on any host") + if boost_delta <= 0: + _fail(f"boost-pick counter did not advance across the measured batch " + f"(+{boost_delta}) — the latency number is not coming from the I/O boost " + "(unrelated cadence change, or the boost path is dead); see ADR-0022") + if len(stats) < STATUS_MIN_OK: + _fail(f"only {len(stats)}/5 STATUS round-trips completed — with the IPC boost " + "live, repeated ghostd-chain timeouts are an IPC-delivery defect, not weather") + if _median(stats) > STATUS_CEILING_S: + _fail(f"STATUS round-trip median {_median(stats):.2f}s exceeds {STATUS_CEILING_S}s — " + "the 3-hop chain is not riding the IPC boost (see ADR-0022)") + print(f"OK: PING median {ping_med:.2f}s < {PING_BOOSTED_CEILING_S}s, " + f"STATUS median {_median(stats):.2f}s < {STATUS_CEILING_S}s, " + f"boost picks +{boost_delta} (mechanism live)") + print("=== COM2 latency gate PASSED — boosted round-trips bounded, boost mechanism proven ===") if __name__ == "__main__": diff --git a/scripts/test_qos_sched.py b/scripts/test_qos_sched.py index 663ba62..c0d128e 100644 --- a/scripts/test_qos_sched.py +++ b/scripts/test_qos_sched.py @@ -55,6 +55,11 @@ # raises P; q=2 -> ~500 still passes), only on pathological churn. PREEMPT_PER_1000T_FLOOR = 100.0 PREEMPT_PER_1000T_CEIL = 600.0 +# Max reschedule-gap ceiling in guest ticks, asserted in LOAD mode only (the +# starvation referee for the ADR-0022 I/O boost; idle-mode gaps are dominated +# by one-shot citizens finishing, so LOAD is where a monopoly would show). +# Baseline ~100-110 ticks; K=2 boost-cap bound ~220; 600 = ~3x margin. +MAXGAP_TICKS_CEIL = 600 # State-gated warmup by GUEST TICKS: the transient boot self-tests (echo, watched-svc, # quota-test, delegation-test, cpu-hog, qpu-test) run and exit inside the first few @@ -114,10 +119,20 @@ def main(): wall_deadline = time.time() + 90.0 # absolute safety bound on wall time while (last["ticks"] - s0["ticks"]) < WINDOW_TICKS and time.time() < wall_deadline: if LOAD: + # Fixed 0.5s cadence (ADR-0022 I/O boost): un-paced back-to-back + # pings would make the load profile a function of the reply + # latency itself — the boost cut ping latency ~5x, which would + # silently multiply the load behind the same "load baseline" + # label and shift the calibration table the maxgap ceiling below + # is armed against. Pacing makes the profile boost-invariant. + t_ping = time.time() try: vm.ping(deadline_s=10.0) except QosError: pass # a transient ping hiccup is not a scheduler fault; keep sampling + remain = 0.5 - (time.time() - t_ping) + if remain > 0: + time.sleep(remain) else: time.sleep(0.05) try: @@ -169,6 +184,19 @@ def main(): if p_preempt > PREEMPT_PER_1000T_CEIL: _fail(f"preemption rate {p_preempt:.1f}/1000t above ceiling {PREEMPT_PER_1000T_CEIL:.0f} " f"— pathological reschedule thrash. dpreempt={d_preempt} over dticks={d_ticks}.") + + # ARMED FAIRNESS CEILING (ADR-0022 I/O boost): max reschedule-gap over the + # window. This is the ONLY metric that can referee the boost's starvation + # guard — a boost ping-pong monopoly keeps every liveness floor green + # (starved processes stay READY; the pair switches plenty) and can even + # keep the preempt band green outside the monopoly window, while max_gap + # grows without bound. Baseline ~100-110 ticks (~1 rotation); the K=2 + # consecutive-boost cap bounds it at ~2-3 rotations (~220); ceiling 600 = + # ~3x margin. Revert-confirm: removing the SCHED_BOOST_MAX_CONSEC guard + # under LOAD drives the gap toward the whole 3000-tick window. + if LOAD and max_gap > MAXGAP_TICKS_CEIL: + _fail(f"max reschedule gap {max_gap} ticks exceeds {MAXGAP_TICKS_CEIL} — a citizen " + f"is being starved (I/O-boost monopoly? see ADR-0022 SCHED_BOOST_MAX_CONSEC)") print(f"OK: scheduler live ({d_switch} switches, {min_runnable} runnable over " f"{d_ticks} ticks); preemption rate {p_preempt:.1f}/1000t within " f"[{PREEMPT_PER_1000T_FLOOR:.0f}, {PREEMPT_PER_1000T_CEIL:.0f}].")