Fix Windows/macOS CI: profiler MSVC /WX warnings; drift-proof profiler-cost gate - #48
Merged
Merged
Conversation
…r-cost gate The M1-PROF-01 merge (#47) broke two P0 jobs on master: - windows-msvc (BUILD): profiler.cpp (new in the merge) had never been compiled under MSVC's /W4 /WX. The CI log's exact warning set: * C4244 (treated as error): the seven uint64_t counters implicitly converted to double at JsonValue::fromNumber in formatProfileJson (ticks, frames, draw_calls, texture_binds, net_bytes, sim_allocs, system runs; the uint32_t fields convert exactly and are left alone). Fixed with explicit static_cast<double> + the per-line LAIGE-DETERM-EXCEPTION marker for the double token (the determinism-lint policy; the existing stats.n cast precedent). * C4996 (treated as error): plain std::fopen in writeProfile. Fixed with a portable openProfileFile helper following the _fsopen(..., _SH_DENYNO) precedent of replay.cpp / config.cpp / logging.cpp (plain-fopen sharing semantics). - windows-msvc (TEST, latent): profiler_tests.cpp used plain std::fopen in readFile/fileExists — uncompiled in the failed run because the build stopped at profiler.cpp. Same _fsopen(_SH_DENYNO) precedent via an openReportFile helper (replay_record_tests.cpp precedent). - macos-arm64 (TEST): ProfilerCost.EnabledCostBoundedToOnePercent measured 2.14% against the 1% gate (on_p50=0.260291 off_p50=0.254833) while the canonical baseline of the same measurement is 0.28% — the profiler's fixed per-tick cost (two steady_clock reads + one O(1) ring write) is far below 2% of any supported platform's 10k-entity tick. Root cause: the original BLOCKED arm order (all ON runs before all OFF runs) measured the two arms in two different phases of the CI runner's frequency/ thermal drift under sustained load — the drift itself showed up as overhead, one direction only. Verified locally on the baseline's own CPU (Ryzen 7950X3D, sustained load): the blocked order spread -1.9%...+1.3% over 10 runs while the true overhead is ~0.03-0.08%. Fix: per-tick A/B interleaving — the profiler is enabled on every other tick (a runtime setEnabled, the DBG-002 switch), so each ON sample is adjacent (~1 ms) to an OFF sample; both subsequences sample the same machine-state trajectory, and the phase cancels at the adjacent-sample level. A transient CI stall slows at most one tick = one sample of 2000 in one window (no p50 effect — no best-of-N repetition needed). After the fix: 10/10 runs at -0.01%...+0.08% (12-30x inside the gate). The 1% gate, the workload, and the machine-greppable line format are unchanged. Verified: canonical g++ and clang++ trees build zero-warning, full ctest 89/89 green (incl. the profiler entry, api-real-tree, determinism-lint-real-tree, laige_run_smoke); the ASan tree builds zero-warning and the profiler suite passes there (the ProfilerCost gate stays excluded from sanitizer trees — the LAIGE_ALLOC_COUNTER gate, unchanged). include-lint + determinism-lint OK; no public API surface change (laige-api.json untouched). The 12 hello_* failures of the first local run were a race on the shared samples/hello/bin/ binaries (three local trees building the same source-tree path in parallel — CI checkouts are isolated); sequential per-tree runs are green. MSVC and AppleClang verified through the PR's ci:windows / ci:macos jobs.
Every non-instrumented P0 CI job enforces the 1% enabled-cost gate (the linux-gcc and linux-clang jobs AND the windows-msvc and both macOS jobs' ctest alike) — the macOS arm64 job's run of the gate is exactly what exposed the blocked-arm-order drift in #47's merge; the old 'the CI linux-gcc and linux-clang P0 jobs' wording understated the live scope.
offdev
added a commit
that referenced
this pull request
Sep 23, 2026
offdev
added a commit
that referenced
this pull request
Sep 23, 2026
…-budget evaluation, --budget-report, exit 3 (#49) * [M1-PROF-02] Frame graph / budget report: per-frame records, declared-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. * Record commit 0c7cf5c in the M1-PROF-02 changelog row (PR #49) * Regenerate laige-api.json: FrameBudgetRecorder::at doc summary (0..count() - 1) * Fix MSVC /WX in budget_report tests: _fsopen via openBudgetsFile (the #48 C4996 precedent) * No change: retrigger PR CI with the ci:windows P0 selection (one P0 OS per PR cadence) * Fix MSVC /WX in laige-run: envValue via getenv_s (the #48 C4996 class, laige-bench precedent)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the two P0 jobs broken by the M1-PROF-01 merge (#47):
windows-msvc(build) andmacOS arm64(test). All other jobs in the failed run were green.1. windows-msvc — build failure in the new profiler code
profiler.cpp(new in #47) had never been compiled under MSVC's/W4 /WX. The CI log's exact warning set:uint64_tcounters implicitly converted todoubleatJsonValue::fromNumberinformatProfileJson(ticks,frames,draw_calls,texture_binds,net_bytes,sim_allocs, systemruns; theuint32_tfields convert exactly and are left alone). Fixed with explicitstatic_cast<double>+ the per-lineLAIGE-DETERM-EXCEPTIONmarker for thedoubletoken (determinism-lint policy; the existingstats.ncast precedent).std::fopeninwriteProfile. Fixed with a portableopenProfileFilehelper following the_fsopen(..., _SH_DENYNO)precedent ofreplay.cpp/config.cpp/logging.cpp(plain-fopensharing semantics —fopen_swould open_SH_SECUREand deny re-open).profiler_tests.cppused plainstd::fopeninreadFile/fileExists— it never compiled on Windows in the failed run because the build stopped atprofiler.cpp. Same precedent via anopenReportFilehelper (replay_record_tests.cppprecedent).2. macos-arm64 —
ProfilerCost.EnabledCostBoundedToOnePercentat 2.14% vs the 1% gateon_p50=0.260291 off_p50=0.254833, while the canonical baseline of the same measurement is 0.28% — the profiler's fixed per-tick cost (twosteady_clockreads + one O(1) ring write) is far below 2% of any supported platform's 10k-entity tick. Root cause: the original BLOCKED arm order (all ON runs before all OFF runs) measured the two arms in two different phases of the CI runner's frequency/thermal drift under sustained load — the drift itself showed up as overhead, one direction only.Verified locally on the baseline's own CPU (Ryzen 7950X3D, sustained load): the blocked order spread -1.9% … +1.3% across 10 runs while the true overhead is ~0.03–0.08%. (Per-run best-of-N interleaving was tried first and still spread ±1% — the phase noise lives on a seconds scale, not a run-pair scale.)
Fix: per-tick A/B interleaving — the profiler is enabled on every other tick (runtime
setEnabled, the DBG-002 switch), so each ON sample is adjacent (~1 ms) to an OFF sample; both subsequences sample the same machine-state trajectory and the phase cancels at the adjacent-sample level. A transient CI stall slows at most one tick = one sample of 2000 in one window — no p50 effect, so no best-of-N repetition is needed. After the fix: 10/10 runs at -0.01% … +0.08% (12–30× inside the gate). The 1% gate, the workload, and the machine-greppableprofiler-costline format are unchanged.Verification (local)
profiler,api-real-tree,determinism-lint-real-tree,laige_run_smoke)profilersuite green (theProfilerCostgate stays excluded from sanitizer trees — theLAIGE_ALLOC_COUNTERgate, unchanged)include-lint+determinism-lintOK; no public API surface change (laige-api.jsonuntouched)hello_*failures of the first local run were a race on the sharedsamples/hello/bin/binaries (three local trees building the same source-tree path in parallel — CI checkouts are isolated); sequential per-tree runs are greenci:windows/ci:macosjobs (labelled in order; see run history)