Files
LithosAnanke/src/starkernel/heartbeat.c
T
Robert Allan JamesandClaude Sonnet 5 986d042aa7
Build / build-amd64-iso (push) Canceled after 0s
Build / build-aarch64-iso (push) Canceled after 0s
Build / build-riscv64-img (push) Canceled after 0s
Stage 3: timer-driven preemptive switching, live on all 3 arches (FABRIC-3.md §XXVIII)
Fourth stage of the preemptive context-switching plan, and the biggest.
LithosAnanke now genuinely, continuously preempts between Hera, Hermes,
and Artemis -- timer-driven, running live for the entire remainder of
every boot once the Tripod fleet registers, not a bounded probe.

A real design fork was resolved before writing code: the naive approach
(the timer ISR calling Stage 2's sk_vm_context_switch() directly) is
broken -- Stage 0's trap frame lives on whatever stack was active at
interrupt time, and jumping to a different stack via Stage 2's own
independent swap mid-handler would abandon that trap frame unresumed,
guaranteed corruption on the first tick. Chose the safer of two named
options: the ISR only ever sets a flag and returns completely normally
through its own full epilogue; the actual switch happens moments later,
via Stage 2's already-proven mechanism, at a safe cooperative checkpoint
on the mainline (execute_colon_word()'s per-word dispatch loop, checked
on literally every word, not throttled to the existing 256-word
heartbeat-tuning cadence) -- confirmed with the user that word-level
granularity is fine-grained enough given the eventual Zynq FPGA target
where a word is a mnemonic.

New capsule_vm_switch_signal.c/.h: a purpose-built run-readiness signal,
deliberately separate from capsule_vm_physics.c's execution-heat engine
(that one's own header documents itself as never touched from interrupt
context, by design). Slot table sized with headroom (8) rather than
hardcoded to today's 3 participants, so extending participation later is
another register() call, not a redesign -- per direct request to leave
room for swapping the participant set. Simple linear accumulate-then-
threshold for this first cut; a fancier law can replace it later without
touching the mechanism around it. heartbeat_tick() gains its one
deliberate, documented amendment to this file's own top-half/bottom-half
discipline -- the first time this codebase reaches into VM-scheduling
state from real ISR context.

Registration happens only after all three VMs are fully born, right
before the REPL starts -- no critical-section protection yet against
being switched away mid-birth-setup.

Known, flagged rough edge (not reconciled this pass): MSG-TICK's own
idle-pump and this new mechanism can still independently move control
between the same VMs; not observed to interact badly in verification,
but not fully unified either.

Verified interactively at the console on all 3 architectures with
continuous background preemption running throughout -- amd64 computed
`1 1 + .` -> 2, aarch64 computed `1 1 + dup DUP * . CR` -> 4, both
correct, REPL fully responsive, zero fault indicators over sustained
runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UNhH1mhi52i6Qihh7ZV5S
2026-09-13 19:32:36 -04:00

263 lines
8.4 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
StarForth — Steady-State Virtual Machine Runtime
Copyright (c) 2023–2025 Robert A. James
All rights reserved.
Licensed under the StarForth License, Version 1.0
*/
/**
* heartbeat.c - Shared M5 heartbeat / TIME-TRUST engine (punch-list item 0.8)
*
* One implementation of heartbeat_init()/heartbeat_ticks()/heartbeat_trust()/
* heartbeat_state(), shared by all three architectures. Each architecture's
* timer.c contributes only heartbeat_read_counter() — the one thing that is
* genuinely per-ISA (rdtsc / rdtime / CNTPCT_EL0).
*
* Top half / bottom half split (FABRIC-0.md §25.1 item 0.8, per the GAP-A1
* ruling in §16.4/§18.4): heartbeat_tick() is called from interrupt context
* and does nothing but read the counter, bump TIME-TICKS, and latch a
* pending sample. heartbeat_service() runs on the mainline (the REPL idle
* loop) and does the window/variance/trust work. Neither one feeds patron
* state — the engine stays on the virtual tick per §18.4, unchanged by this
* file.
*
* Adaptive re-arm period (FABRIC-0.md §26, ruled 2026-08-03): Loop #7
* (vm_runtime.c) computes an execution-derived stable/volatile signal and
* calls heartbeat_set_adaptive_period_ns() with it, rescaled to this file's
* kernel-appropriate base (10 ms, matching the 100 Hz rate item 0.1-0.7
* configured) rather than the hosted 10 µs HEARTBEAT_TICK_NS base. Each
* architecture's re-arm function reads heartbeat_next_period_ns() and
* converts it to raw counter units instead of using a fixed constant.
* Single writer (mainline, via vm_tick()'s Loop #7 site), single reader
* (the ISR's re-arm call) — no lock needed, per §21.1's finding that
* nothing here is actually concurrent on one hart.
*/
#include "starkernel/timer.h"
#include "starkernel/capsule_vm_switch_signal.h" /* FABRIC-3.md §XXVIII Stage 3 */
#if defined(ARCH_AMD64)
#define HEARTBEAT_HAS_VARIANCE 1
#else
#define HEARTBEAT_HAS_VARIANCE 0
#endif
/* Kernel-side adaptive-period base: 10 ms, matching the 100 Hz hardware
* rate established throughout items 0.1-0.7. Deliberately NOT
* HEARTBEAT_TICK_NS (include/starforth_config.h) -- that constant is the
* hosted pthread-worker's 10 µs base and is three orders of magnitude too
* fast for a bare-metal ISR period (FABRIC-0.md §26.3). Loop #7's decision
* logic is reused unmodified; only the base it scales differs. */
#define HEARTBEAT_BASE_PERIOD_NS 10000000ULL
static TimeTrustState g_heartbeat;
/* Top/bottom half handoff. */
static volatile uint64_t g_pending_counter;
static volatile int g_pending_valid;
/* Adaptive re-arm period, clamped to the same [1/4x, 4x] band Loop #7
* itself enforces on tick_target_ns, so a caller cannot runaway the ISR
* rate even if it forwarded an unclamped value. */
static volatile uint64_t g_adaptive_period_ns = HEARTBEAT_BASE_PERIOD_NS;
#if HEARTBEAT_HAS_VARIANCE
/**
* @brief Push a signed delta value into the heartbeat rolling window.
*/
static void window_push(TimeWindow *w, int64_t delta)
{
w->deltas[w->pos] = delta;
w->pos = (w->pos + 1) % TIME_WINDOW_SIZE;
if (w->count < TIME_WINDOW_SIZE) {
w->count++;
}
}
/**
* @brief Compute the relative variance of heartbeat deltas as a Q48.16 value.
*/
static q48_16_t window_variance_q48(const TimeWindow *w, uint64_t expected_delta)
{
if (w->count < 2 || expected_delta == 0) {
return 0;
}
int64_t sum = 0;
for (uint32_t i = 0; i < w->count; i++) {
sum += w->deltas[i];
}
int64_t mean = sum / (int64_t)w->count;
uint64_t sum_sq = 0;
for (uint32_t i = 0; i < w->count; i++) {
int64_t diff = w->deltas[i] - mean;
if (diff > 0x7FFFFFFF) diff = 0x7FFFFFFF;
if (diff < -0x7FFFFFFF) diff = -0x7FFFFFFF;
sum_sq += (uint64_t)(diff * diff);
}
uint64_t var_tsc = sum_sq / w->count;
uint64_t exp_sq = expected_delta;
if (exp_sq > 0xFFFFFFFF) {
var_tsc >>= 16;
exp_sq >>= 8;
}
exp_sq = exp_sq * exp_sq;
if (exp_sq == 0) return 0;
if (var_tsc > 0x0000FFFFFFFFFFFFULL) {
var_tsc = 0x0000FFFFFFFFFFFFULL;
}
return (var_tsc << 16) / exp_sq;
}
/**
* @brief Derive TIME-TRUST from a Q48.16 relative variance value.
*/
static q48_16_t variance_to_trust(q48_16_t variance)
{
q48_16_t denom = q48_add(Q48_ONE, variance);
if (denom == 0) {
return Q48_ONE;
}
return q48_div(Q48_ONE, denom);
}
#endif /* HEARTBEAT_HAS_VARIANCE */
/**
* @brief Initialise the M5 heartbeat / TIME-TRUST subsystem.
*
* Shared across all three architectures -- the fallback and calibration
* formula never differed between them, only what heartbeat_service() does
* with the resulting window did.
*/
void heartbeat_init(uint64_t tsc_hz, uint64_t tick_hz)
{
g_heartbeat.ticks = 0;
g_heartbeat.last_tsc = 0;
g_heartbeat.total_samples = 0;
g_heartbeat.variance = 0;
g_heartbeat.trust = Q48_ONE;
g_heartbeat.window.pos = 0;
g_heartbeat.window.count = 0;
for (int i = 0; i < TIME_WINDOW_SIZE; i++) {
g_heartbeat.window.deltas[i] = 0;
}
g_heartbeat.expected_delta = (tick_hz > 0 && tsc_hz > 0)
? (tsc_hz / tick_hz) : 10000000ULL;
g_pending_valid = 0;
g_adaptive_period_ns = HEARTBEAT_BASE_PERIOD_NS;
}
void heartbeat_tick(void)
{
g_pending_counter = heartbeat_read_counter();
g_heartbeat.ticks++;
g_pending_valid = 1;
/* FABRIC-3.md §XXVIII, Stage 3 (2026-09-13): the one deliberate,
* documented amendment to this file's own top-half/bottom-half
* discipline above. sk_vm_switch_signal_tick() reaches into VM
* scheduling state from real ISR context -- something this file's
* header comment previously said nothing here did. It stays cheap
* (bounded iteration over a small slot table, no locking needed for
* the same reason nothing else here needs it: single hart, nothing
* concurrent with this ISR while it runs) and it never itself
* switches anything -- it only ever sets a flag, consumed later at a
* safe cooperative checkpoint on the mainline
* (execute_colon_word()). The GAP-A1 split itself is not overturned:
* everything else in this function is exactly as minimal as before. */
sk_vm_switch_signal_tick();
}
void heartbeat_service(void)
{
if (!g_pending_valid) {
return;
}
uint64_t now = g_pending_counter;
g_pending_valid = 0;
TimeTrustState *s = &g_heartbeat;
s->total_samples++;
if (s->total_samples == 1) {
s->last_tsc = now;
return;
}
uint64_t actual_delta = now - s->last_tsc;
s->last_tsc = now;
int64_t deviation = (int64_t)actual_delta - (int64_t)s->expected_delta;
#if HEARTBEAT_HAS_VARIANCE
window_push(&s->window, deviation);
s->variance = window_variance_q48(&s->window, s->expected_delta);
s->trust = variance_to_trust(s->variance);
#else
/* RISC-V `time` / AArch64 CNTPCT_EL0 are architecturally invariant
* counters -- no statistical quality estimate needed (matches the
* per-arch rationale this file replaces). */
(void)deviation;
s->trust = Q48_ONE;
#endif
}
uint64_t heartbeat_ticks(void)
{
return g_heartbeat.ticks;
}
time_trust_t heartbeat_trust(void)
{
return g_heartbeat.trust;
}
const TimeTrustState *heartbeat_state(void)
{
return &g_heartbeat;
}
/**
* @brief Set the adaptive re-arm period (FABRIC-0.md §26).
*
* Called from vm_runtime.c's Loop #7 site on the mainline execution path
* (never interrupt context) with a value already rescaled to this file's
* HEARTBEAT_BASE_PERIOD_NS. Clamped defensively to the same [1/4x, 4x]
* band Loop #7 itself enforces around its own base, so a caller forwarding
* an unclamped or wrongly-scaled value cannot run the ISR away.
*
* @param ns Desired period in nanoseconds for the next re-arm.
*/
void heartbeat_set_adaptive_period_ns(uint64_t ns)
{
uint64_t lo = HEARTBEAT_BASE_PERIOD_NS / 4;
uint64_t hi = HEARTBEAT_BASE_PERIOD_NS * 4;
if (ns < lo) ns = lo;
if (ns > hi) ns = hi;
g_adaptive_period_ns = ns;
}
/**
* @brief Read the period the next hardware re-arm should use.
*
* Called from interrupt context by each architecture's re-arm function
* (apic_timer_rearm(), riscv64_timer_rearm(), the AArch64 equivalent) in
* place of the fixed constant those functions used before item 0.8.
*
* @return Current adaptive period in nanoseconds.
*/
uint64_t heartbeat_next_period_ns(void)
{
return g_adaptive_period_ns;
}