Skip to content

[M1-PROF-02] Frame graph / budget report: per-frame records, declared-budget evaluation, --budget-report, exit 3 - #49

Merged
offdev merged 6 commits into
masterfrom
feat/m1-prof-02-budget-report
Sep 23, 2026
Merged

offdev merged 6 commits into
masterfrom
feat/m1-prof-02-budget-report

Conversation

@offdev

@offdev offdev commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Implements M1-PROF-02 · Frame graph / budget report (roadmap M1-heartbeat.md; PRD FR-11.2, §9.1 S-6, §9.3 G-R5; depends on M1-PROF-01 #47 and M0-CORE-08). Every declared budget — each system's declared time budget, the total tick-time budgets, and the sim allocation-count budget — is measured vs declared with a pass/flag per budget, the over-budget systems are listed (the G-R5 event feed), and laige-run --budget-report prints the last-N-frames report in the AGENTS §12 field format with a --fail-on-budget CI gate (exit 3).

1. The report core (frame_budget.h / frame_budget.cpp, new)

  • FrameBudgetRecord — one completed frame's cheap scalars: the 0-based frame index, the completed tick count, the within-frame tick delta, the frame's sim work ms, the frame's sim allocation count (the World::archetypeStats().totalReservations delta; 0 = steady state), and the per-frame deltas of the G-R5 system/budget_overrun warn / system/budget_critical error counters.
  • FrameBudgetRecorder — fixed 32-frame ring (kFrameBudgetWindow): recordFrame is O(1) and allocates nothing (the hot path); recording beyond the window drops the oldest record and totalFrames() keeps counting (no silent truncation — CORE-008); at(i) reads oldest-first over the wrapping ring.
  • buildFrameBudgetReport(recorder, profiler, world, budgets, options) — cold format pass (allocates — reporting is never a hot path), the AGENTS §12 field format:
    • each system against its declared SystemDef::budgetMs (fpx16_16, exact — ADR 0002), measured as the M1-SYS-03 rolling window's p99 (the sustained-overrun signal; single recovered overruns stay visible in the per-frame records + the warns/errors counters);
    • sim_tick_avg / sim_tick_p99 (budgets.json, M0-CORE-08) against the Profiler's tick window (mean/p99) — via the new Profiler::tickWindow() cold accessor;
    • sim_heap_allocs (the PRD §8.1 hard-zero budget) against the retained frames' per-frame sim-alloc deltas (max);
    • the three budgetCheck blocks embedded verbatim (M0-CORE-08), a per-system section (one line per system, ascending id), the over_budget: list, and overall=PASS|FAIL.
    • Every failure state is loud: empty window → NO_SAMPLES, missing budgets.json entry → NO_ENTRY, and a broken harness folds overall to FAIL (a zero-tick run is never silent — CORE-008).

2. Engine wiring (engine.h / engine.cpp)

  • The per-frame accumulation is always on — two O(1) reads, two O(systemCount) G-R5 counter passes, one O(1) ring write per completed frame; no allocation, no logging (PERF-003, LOG-003). The run-setup allocation count stays exactly three one-shot objects — HeadlessFramePathAllocatesNothing still pins it (the ring is created in Engine::create, engine setup, not run setup).
  • startBudgetReport(budgetsPath, lastNFrames) — EVERY build (diagnostics, not replay state); loads the table at start and builds + caches the report at the run's end on every path (a failed run's report describes what happened; readable after shutdown — the world and the profiler are released in the ordered shutdown). A budget FAIL never fails the run (CORE-002): the caller gates.
  • budgetReportRequested(), lastBudgetReport(); budget/* structured events (NFR-13.3 5-field grammar); the new members move with the engine (move ctor/assign).
  • G-R8 exception markers on the raw double tokens — the measured times are wall-clock diagnostics that never enter sim state, hashes, or replays (ARCH-009); tools/laige-determinism-lint green.

3. CLI (laige-run)

  • --budget-report [N] — print the last N frames' budget report to stdout at the end of the run (1..32; omitted = all retained), even on a failed run (the run failure dominates the exit code).
  • --budgets <path> — the budgets.json file; resolution: flag → LAIGE_BUDGETS_PATH env → budgets.json in the working directory (the laige-bench order).
  • --fail-on-budget — exit 3 when the run completed but the report is overall=FAIL (the PRD §8.1 CI gate). Exit codes: 0 ok, 1 run failure, 2 usage/IO/config/profile-write/budget-start, 3 budget failure.

4. Tests + fixture

  • budget_report CTest entry (14 tests, in the TSAN list): the ring semantics; the synthetic over-budget system with correct numbers (a 2 ms burn vs a 0.1 ms fpx16_16 budget — 20×, both G-R5 multipliers exceeded on the nominal floor, so runs=10 warns=10 errors=10 and p99 ≥ 2.0 are exact and preemption-tolerant: the declared budget echoed, the over_budget: line, overall=FAIL); the healthy-world PASS; the loud NO_ENTRY / NO_SAMPLES (a zero-tick run); the engine's cached-after-shutdown report + the G-R5 events folded into the per-frame records (10 warns + 10 criticals exact, rate-limiting-off capture sink); the start validation (stopped/empty-path/double/unreadable/malformed); and the record path's zero-allocation (budget-report-recorder-zeroalloc frames=1000 allocs=0, non-sanitizer trees).
  • laige_run_budget CLI smoke (every P0 OS job): 30-tick run with --budget-report 4 --budgets <repo>/budgets.json --fail-on-budget; passes iff the report prints overall=PASS and the run exits 0.
  • Sample report committed as tests/laige-sim/fixtures/budget_report_sample.txt — a sample, not a golden (the report carries wall-clock values; the suite asserts the machine-greppable structure instead).

5. Docs, manifest, roadmap

  • New docs/api/frame_budget.md (declared budgets, report format + pass/flag semantics, Engine surface, CLI + exit 3, Performance, determinism, Testing/CI) + docs/api/engine.md (CLI flags, exit 3, per-frame cost, misuse, Testing) + docs/api/profiler.md cross-ref + the docs/README.md and src/laige-sim/README.md indexes.
  • laige-api.json regenerated (24 headers; new budget-surface symbols + Profiler::tickWindow + the three Engine methods); scanner drift check clean.
  • Roadmap: M1-PROF-02 checked, progress board M1 22/25, change log line.

Verification (local)

  • Canonical g++ Debug tree: zero-warning build; full ctest 91/91 (new budget_report + laige_run_budget entries green; profiler / engine / system_timing unchanged — the zero-allocation pin intact).
  • Clang cross-tree: zero-warning build.
  • ASan tree: budget_report / engine / system_timing green (leak-free — the ring and the cached report).
  • tools/laige-determinism-lint + tools/laige-include-lint: green.

Found during this step (not fixed — out of scope)

formatProfileSummaryLine (and formatProfileText, profiler.cpp) append entities_alive= without a leading space, so the profile summary line prints ...max=0.004549entities_alive=0 (the token runs into the previous field; M1-PROF-01 #47). Cosmetic in the one-line summary only; worth a one-line fix in the next profiler step.

…-budget evaluation, --budget-report, exit 3 (#49)

- New public header src/laige-sim/include/laige/sim/frame_budget.h (+ frame_budget.cpp):
  the FR-11.2 per-frame budget report — FrameBudgetRecord (one completed
  frame's cheap scalars: the tick delta, the frame's sim work ms, the
  pool-reservations sim-alloc delta, the G-R5 overrun/critical event
  deltas), the FrameBudgetRecorder fixed 32-frame ring (recordFrame O(1)
  allocation-free hot path, at(i) oldest-first over the wrapping ring,
  totalFrames() keeps counting — no silent truncation), and
  buildFrameBudgetReport (cold format pass, AGENTS 12 field format):
  every DECLARED budget measured vs declared with a pass/flag — each
  system's declared SystemDef budget vs its M1-SYS-03 window's p99,
  sim_tick_avg/sim_tick_p99 (budgets.json) vs the Profiler tick window,
  sim_heap_allocs (hard zero) vs the per-frame sim-alloc deltas; the
  M0-CORE-08 budgetCheck blocks embedded verbatim; loud NO_SAMPLES /
  NO_ENTRY / FAIL; the over-budget systems list; overall=PASS|FAIL.
  G-R8 exception markers on the raw double tokens (wall-clock
  diagnostics — never enter sim state, hashes, or replays, ARCH-009).
- profiler.h/.cpp: Profiler::tickWindow() (the M1-PROF-02 cold read of
  the tick-time window for the sim_tick_avg/sim_tick_p99 checks).
- engine.h/.cpp: always-on per-frame accumulation in runFrames (two O(1)
  reads + two O(systemCount) G-R5 counter passes + one O(1) ring write
  per frame — no allocation, no logging; the run-setup allocation count
  stays exactly three, HeadlessFramePathAllocatesNothing still pins it);
  startBudgetReport(path, lastNFrames) (EVERY build; loads the table,
  builds + CACHES the report at the run's end on every path — readable
  after shutdown; a budget FAIL never fails the run — CORE-002),
  budgetReportRequested(), lastBudgetReport(); budget/* structured
  events (NFR-13.3 5-field grammar); the new members move with the
  engine (move ctor/assign).
- laige-run.cpp: --budget-report [N] (1..32; omitted = all retained;
  printed to stdout after the profile summary line, even on a failed
  run), --budgets <path> (flag -> LAIGE_BUDGETS_PATH env -> budgets.json
  CWD — the laige-bench resolution order), --fail-on-budget (exit 3
  when the run COMPLETED but overall=FAIL; run failure stays 1,
  start/load failure 2); usage text updated for the new flags + exit 3.
- Tests: budget_report_tests.cpp (14 tests: the ring semantics, the
  SYNTHETIC OVER-BUDGET SYSTEM with correct numbers — 2 ms burn vs a
  0.1 ms fpx16_16 budget, both G-R5 multipliers exceeded on the nominal
  floor so runs/warns/errors are exact and preemption-tolerant: the
  declared budget echoed, measured p99 above it, the over_budget: line,
  overall=FAIL; the healthy-world PASS; the loud NO_ENTRY/NO_SAMPLES;
  the engine's cached-after-shutdown report + the G-R5 fold into the
  per-frame records (10 warns + 10 criticals exact, rate-limiting-off
  capture sink); the start validation; the record path's zero-
  allocation, non-sanitizer trees). CTest entries: budget_report (in
  the TSAN list) + laige_run_budget CLI smoke (every P0 OS job: 30-tick
  run, --budget-report 4 --budgets <repo>/budgets.json --fail-on-budget,
  passes iff overall=PASS + exit 0).
- Fixture: tests/laige-sim/fixtures/budget_report_sample.txt (a real
  0-system run's report — a sample, not a golden; the report carries
  wall-clock values).
- Docs: docs/api/frame_budget.md (new — the declared budgets, the
  report format + pass/flag semantics, the Engine surface, the CLI +
  exit 3, Performance, determinism, Testing/CI) + engine.md (CLI
  flags, exit 3, per-frame cost, misuse, Testing) + profiler.md
  cross-ref + the docs/README + sim README indexes; laige-api.json
  regenerated (24 headers); roadmap M1-PROF-02 checked + progress board
  (M1 22/25) + change log line.
- Verified: canonical g++ Debug tree zero-warning, full ctest 91/91
  (new budget_report + laige_run_budget entries green; the zero-
  allocation pin unchanged), clang cross-tree zero-warning, ASan tree
  budget_report/engine/system_timing green (leak-free),
  tools/laige-determinism-lint + tools/laige-include-lint green,
  laige-api-scanner drift check clean.
@offdev
offdev force-pushed the feat/m1-prof-02-budget-report branch from acda6d2 to 0c7cf5c Compare September 23, 2026 14:55
@offdev offdev removed the ci:macos label Sep 23, 2026
@offdev
offdev merged commit 2559149 into master Sep 23, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant