The M1 system framework's per-system time budget (M1-SYS-03; PRD
§9.3 G-R5, FR-11.1/11.2, FR-12.3; AGENTS CORE-001, PERF-002/003,
ARCH-009): measures every system's run time per tick into a fixed
rolling histogram, enforces the declared per-system budget with
structured warn/error events, and feeds the profiler and the frame
graph report (M1-PROF-01/02). Public header:
src/laige-sim/include/laige/sim/system.h (SystemTimingStats,
kSystemTimingWindowSamples, kBudgetCriticalMultiplier, the full
contract) plus the World::systemTimingStats/systemTimingWindow
members in src/laige-sim/include/laige/sim/entity.h; implementation:
src/laige-sim/system_timing.cpp (the check and the queries) and the
per-system measurement inside World::runSystems
(src/laige-sim/systems.cpp). Unit suite: ctest -R system_timing
(tests/laige-sim/system_timing_tests.cpp).
The loop (M1-LOOP-01, GameLoop) runs the schedule every tick, and
the timing is automatic — nothing per system is wired by the game:
GameLoop loop = GameLoop::create(world, sched, opts).value();
while (running) {
loop.frame(); // beginFrame once per frame; runSystems per tick —
// measures every system (M1-SYS-03)
// ... read the feed, e.g. for the frame graph:
// auto st = world.systemTimingStats(id);
}World::runSystems times each system's own run: the TimeIt
scope (the M0-CORE-08 steady_clock timer — monotonic, ms as a
double) starts before the run function is called and is read back
immediately after it returns. The per-tick, per-system bookkeeping
(the schedule dispatch, the SystemContext construction) is outside
the window — the measurement is the system's work, not the
scheduler's. The sample is handed to the system's rolling window and
the budget check in schedule order.
A run shorter than the platform's steady_clock tick measures as
exactly 0.0 ms (the start and end reads land on the same tick —
e.g. a sub-tick run on a platform whose clock tick is tens of
nanoseconds or more). 0.0 is a legitimate reading, not a failure
state: it records in the window like any sample, and the budget check
simply sees a run far under budget. Resolution is platform-sensitive
(ARCH-009), so consumers must not treat a 0.0 measurement as
"not measured".
One Histogram per system (M0-CORE-08): fixed capacity
kSystemTimingWindowSamples (64 samples — ~1.1 s at the default
60 Hz tick rate), rolling across ticks (beginFrame() does not
touch it — it is not a per-frame window). record() is O(1) and
allocates nothing; recording beyond the capacity drops the oldest
sample, and totalRecorded() keeps counting every sample ever
recorded, so the truncation is observable (the M0-CORE-08 contract).
The capacity is fixed at world construction (a setup-path
allocation); raising it is an ADR, not a knob.
stats() over the window (nearest-rank percentiles — the M0-CORE-08
definition) is a cold path: O(n log n) with no allocation. The
engine calls it only while a system is over budget (the warn/error
path).
After every measured run, the sample is compared against the system's
declared SystemDef budget (fpx16_16 ms, converted to double
exactly — raw/2^16 is a power-of-two scale, so the comparison
operands are exact):
| Condition | Event | Severity |
|---|---|---|
measured > 1 × budget |
system/budget_overrun |
Warn |
measured >= 3 × budget (kBudgetCriticalMultiplier) |
system/budget_critical |
Error |
The warn fires strictly above the budget (measured == budget is
legal — the G-R4 strictly-greater precedent); the error at 3× or more
("over 3× → error event"). A run that is 3× over fires both —
the warn first, then the error, in the same tick (they are separate
rate-limit keys).
Both events:
- follow the NFR-13.3 5-field message grammar
(
{code} | {what} | {why} | {fix} | {doc_anchor}) — build-stable message text; the dynamic values are structured fields, never message text (machine-parseable output stays build-stable); - are rate-limited per
(subsystem, event, severity)with the facade's default 1 s window (LOG-004) — a sustained overrun logs once per window plus arate_limitedsummary, never a log storm; - count separately in
SystemTimingStats(warns,errors) even when the event is suppressed — the counters are since-construction.
The fields on both events:
| Field | Meaning |
|---|---|
system |
the registered system name |
id |
the SystemId value |
measured_ms |
this tick's measured run time (ms) |
budget_ms |
the declared budget (ms, exact) |
p99_ms |
the rolling window's p99 after this sample |
window_samples |
the samples stored in the window |
An over-budget system is still run: the timing is observation and reporting, never an execution gate (the engine does not skip, defer, or cancel a system — the breach is surfaced, FR-12.3: never hidden).
World::systemTimingStats(id)—Result<SystemTimingStats, ErrorCode>: the run count, the last measured ms, and the warn/error counts. O(1), no allocation, no side effects; the per-frame pull for the profiler.World::systemTimingWindow(id)—const Histogram*(nullptr for an invalid id or a moved-from world): the rolling window itself, for the frame graph report'sbudgetCheck(the M0-CORE-08 check; cold path).
Both are pure queries: an invalid SystemId (0, above
systemCount()) or a moved-from world is
ErrorCode::InvalidArgument / nullptr — no log, no warn (the
World::system precedent).
The measured times are diagnostics only: wall-clock readings are platform-sensitive, so they never enter authoritative simulation state, state hashes, or replays. The only world state the timing adds is the window contents and the counters — observability, not sim state (the PRD's separation of authoritative from presentation/diagnostic state). The warn/error events carry the measurements as log output; the log stream is not replay state.
Per tick, per system (the hot path):
- two
steady_clock::now()reads (theTimeItscope); - one O(1) ring write (
Histogram::record); - two comparisons (warn and error thresholds);
- no allocation, no logging on the success path (PERF-003,
LOG-003). At the 10k-entity reference tick with ≤ 256 systems this
is a few hundred nanoseconds — far below the 3 ms
sim_tick_avgbudget (PRD §8.1).
The warn/error path is cold (a budget being breached): it performs one
window stats() pass — O(W log W) over the fixed W = 64 samples, no
allocation (the pre-allocated scratch buffer) — and constructs the
field values. Note the facade's level gate, not the rate state,
controls field construction: a system that stays over budget pays
this bounded cold cost on every tick while the breach persists (the
event itself is rate-limited to one per window). That is deliberate —
a persistently broken system is worth a few microseconds of
diagnostic overhead, and the cost disappears when the breach is
fixed.
World construction pays the setup cost once: the fixed
kMaxSystems record table plus one 64-sample Histogram per record
(256 records × 2 small allocations ≈ 256 KB, one-time, never a hot
path; the engine_base_rss 100 MB budget, PRD §8.1).
The timing state is owned by the world and written strictly on the
world's single owner thread (CONC-001; PRD §10.2: simulation is
single-threaded): checkSystemBudget runs inside runSystems; the
queries are pure reads of the same single-owner state (a const Histogram& read is safe on a fully built window, the M0-CORE-08
publish contract). The table travels with the world on move and
survives clear() (like the system registry); a moved-from world has
no timing state (the queries fail as pure queries).
Failure behavior: nothing in the timing path can fail at runtime — the checks are comparisons, the window cannot overflow (bounded), and the events are logged, not thrown (FR-12.1, NFR-8.10: no exceptions).
- The budget is declared at registration — a system that cannot finish within its declared budget will warn (and eventually error) every tick; either cut the work or declare a budget the system can actually meet (CORE-005: the budget is a named, honest number, not a hope).
- The window is not per-frame — do not expect
beginFrame()to reset it; the p99 always describes the last 64 ticks, wherever they fall in the frame cycle. - Do not treat the events as control flow — they never stop the
system; the fix is in the system's work or its budget (the event's
{fix}field says so). - Do not keep the
systemTimingWindowreference past the world (it is a non-owning view into the world's single-owner state).