Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,35 @@ opt-level = 0
debug = "line-tables-only"
split-debuginfo = "unpacked"

# mimalloc is the `#[global_allocator]` of every native binary in `crates/phase-ai/
# src/bin/` β€” the motivating pair is `ai_gate.rs` / `ai_perf_gate.rs`, but all of them
# pick this up. Its entire C core is one unity translation unit
# (`libmimalloc-sys-*/c_src/mimalloc/v3/src/static.c`) and inherits `profile.dev`'s
# `opt-level = 0`, so every allocation in a debug gate run walks an unoptimized malloc.
# Measured on the linked artifact:
# readelf --debug-dump=info target/debug/ai-gate | grep -A1 'GNU C23'
# reported `-O0` for exactly that file before this override and `-O2` after it.
# This is NOT a no-op on the gates' payloads, and the honest statement of why is worth
# more than a clean claim. The AI reads the wall clock on its live decision path:
# `phase-ai/src/projection.rs:110` is `TIME_CAP = 15ms` and `:139-142` bails on it, and
# unlike `search.rs`'s and `planner/mod.rs`'s deadlines it is NOT gated on measurement
# mode. It is reachable at the gate's default `AiDifficulty::Medium` β€” registry.rs
# registers `EvasionRemovalPriorityPolicy` unconditionally, and its `velocity_score`
# calls `AiSession::get_or_project` β†’ `project_to`, with the `projection_min_budget_ms`
# guard bypassed because a measurement-mode `Deadline` reports no remaining budget to
# compare against. A bail scores 0.0 where a completed projection scores up to +3.0, and
# that term picks the removal target. So making allocation faster lets more projections
# finish, which can change a target, a board, a winner and every counter downstream.
# That hazard is pre-existing and profile-wide (it fires on any faster or slower host);
# this override does not create it and cannot avoid it. It is recorded here because the
# obvious "allocator changes are invisible" claim is false, and the fix β€” gating
# `TIME_CAP` on measurement mode the way `search.rs:2012` does β€” belongs in its own
# change with its own baseline sign-off. `RandomState` (#4878) is a separate,
# already-documented source and is seeded from OS randomness, not allocation addresses.
# Scoped to the one package so nothing else loses debug fidelity.
[profile.dev.package.libmimalloc-sys]
opt-level = 2
Comment thread
coderabbitai[bot] marked this conversation as resolved.

[profile.test]
inherits = "dev"
codegen-units = 256
Expand Down
26 changes: 24 additions & 2 deletions crates/phase-ai/src/duel_suite/perf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,13 +325,35 @@ pub fn run_perf_suite(
) -> PerfReport {
let start = Instant::now();
let mut counters = PerfCounters::default();
for id in scenarios {
for (n, id) in scenarios.iter().enumerate() {
let spec = find_matchup(id)
.unwrap_or_else(|| panic!("perf scenario id '{id}' does not resolve via find_matchup"));
let (payload, _p0, _p1) = resolve_matchup(db, spec)
.unwrap_or_else(|err| panic!("perf scenario '{id}' failed to resolve decks: {err}"));
// Progress goes to STDERR only: the parent gate runs its children with
// `Stdio::null()` on stdout precisely so its own markdown table stays clean
// (`bin/ai_perf_gate.rs:185`), and stderr is inherited so these lines reach
// the CI log. Without them a killed sample leaves no evidence at all β€” the
// report is written once, after every scenario has finished.
let scenario_start = Instant::now();
eprintln!(
"perf scenario {n}/{total} '{id}' start (seed={seed} action_cap={action_cap})",
n = n + 1,
total = scenarios.len(),
);
let snapshot = run_perf_scenario(&payload, seed, action_cap);
counters.merge_add(&PerfCounters::from_snapshot(&snapshot));
let scenario_counters = PerfCounters::from_snapshot(&snapshot);
// One JSON line per scenario: a killed child still leaves a machine-readable
// partial payload for every scenario that did finish.
eprintln!(
"perf scenario {n}/{total} '{id}' done {ms}ms counters={json}",
n = n + 1,
total = scenarios.len(),
ms = scenario_start.elapsed().as_millis(),
json = serde_json::to_string(&scenario_counters)
.unwrap_or_else(|e| format!("<unserializable: {e}>")),
);
counters.merge_add(&scenario_counters);
}
let wall_clock_ms = start.elapsed().as_millis();

Expand Down
Loading
Loading