129 lines
5.2 KiB
Rust
129 lines
5.2 KiB
Rust
|
|
//! CB-WP-0025 T04 — what one search node actually costs.
|
|||
|
|
//!
|
|||
|
|
//! **ADR-0013 D7 exists because two measurements disagreed by 5×.** The
|
|||
|
|
//! survey published 112–161 µs/node from a timer that bracketed two
|
|||
|
|
//! `setup`s and a whole greedy game (C1). The author's re-measurement said
|
|||
|
|
//! 3.0–4.1 µs with `Instant::now()` around each call; the adversarial
|
|||
|
|
//! reviewer's isolation said 15.6–20.4 µs. Both agreed the published
|
|||
|
|
//! figure was wrong by 1–2 orders and neither established which
|
|||
|
|
//! replacement was right.
|
|||
|
|
//!
|
|||
|
|
//! So the spec quotes **this** and nothing else. `criterion` handles the
|
|||
|
|
//! things hand-rolled timing gets wrong here: per-call clock overhead
|
|||
|
|
//! against a ~microsecond subject, warm-up, and run-to-run variance —
|
|||
|
|
//! which is what let the survey's figure move 161 → 112 between two runs
|
|||
|
|
//! of the same unmodified binary.
|
|||
|
|
//!
|
|||
|
|
//! Two subjects, because a search node is not one call:
|
|||
|
|
//!
|
|||
|
|
//! * `legal_commands` — enumerating a seat's options;
|
|||
|
|
//! * `validate + fold` — taking one branch, which any search does per
|
|||
|
|
//! child and which the survey never separated out.
|
|||
|
|
|
|||
|
|
use cb_game_runtime::{ScenarioGame, Setup};
|
|||
|
|
use cb_kernel::{Actor, Aggregate, PlayerId};
|
|||
|
|
use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
|
|||
|
|
use games_ground::bot::{legal_commands, play, GreedyPolicy, Policy};
|
|||
|
|
use games_ground::GroundState;
|
|||
|
|
use std::collections::BTreeMap;
|
|||
|
|
|
|||
|
|
fn setup(players: u8, seed: u64) -> GroundState {
|
|||
|
|
GroundState::setup(
|
|||
|
|
&Setup {
|
|||
|
|
players,
|
|||
|
|
preset: format!("standard-{players}p"),
|
|||
|
|
patch: BTreeMap::new(),
|
|||
|
|
},
|
|||
|
|
seed,
|
|||
|
|
)
|
|||
|
|
.expect("preset")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// A **mid-game state at a real decision point** for `seat`.
|
|||
|
|
///
|
|||
|
|
/// Not a fresh deal: at deal time most branches do not exist yet, and a
|
|||
|
|
/// node cost taken there would flatter any search proposal.
|
|||
|
|
///
|
|||
|
|
/// **And not a fixed step count either.** The first version stopped at
|
|||
|
|
/// step 20 for every seat count, which put 2p and 4p in a state where
|
|||
|
|
/// seat 0 had *no* legal commands at all — so the benchmark reported
|
|||
|
|
/// ~120 ns (the cost of returning an empty `Vec`) and silently skipped
|
|||
|
|
/// `validate_fold` because there was nothing to validate. A fixture that
|
|||
|
|
/// measures the empty case and calls it a node cost is the same defect
|
|||
|
|
/// class this whole pass exists to correct, one layer down.
|
|||
|
|
///
|
|||
|
|
/// So: advance until the seat genuinely has a choice, and assert it.
|
|||
|
|
fn midgame(players: u8, seed: u64, seat: PlayerId) -> GroundState {
|
|||
|
|
let mut ps: Vec<Box<dyn Policy>> = (0..players)
|
|||
|
|
.map(|_| Box::new(GreedyPolicy) as Box<dyn Policy>)
|
|||
|
|
.collect();
|
|||
|
|
let game = play(setup(players, seed), &mut ps).expect("a complete game");
|
|||
|
|
let mut state = setup(players, seed);
|
|||
|
|
let mut best: Option<GroundState> = None;
|
|||
|
|
for (i, (actor, cmd)) in game.steps.iter().enumerate() {
|
|||
|
|
// Past the opening, take the first state where the seat has a real
|
|||
|
|
// branch. `> 1` rather than `> 0`: a forced move is not a node.
|
|||
|
|
if i >= 8 && legal_commands(&state, seat).len() > 1 {
|
|||
|
|
best = Some(state.clone());
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
if let Ok(events) = state.validate(*actor, cmd) {
|
|||
|
|
for e in &events {
|
|||
|
|
state.fold(e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let state = best.expect("a mid-game state where the seat has a choice");
|
|||
|
|
assert!(
|
|||
|
|
legal_commands(&state, seat).len() > 1,
|
|||
|
|
"benchmark fixture has no branch to measure — it would time the empty case"
|
|||
|
|
);
|
|||
|
|
state
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn bench(c: &mut Criterion) {
|
|||
|
|
for players in [2u8, 3, 4] {
|
|||
|
|
let seat = PlayerId(0);
|
|||
|
|
let state = midgame(players, 7, seat);
|
|||
|
|
let width = legal_commands(&state, seat).len();
|
|||
|
|
println!(" fixture {players}p: {width} legal commands at the measured node");
|
|||
|
|
|
|||
|
|
c.bench_function(&format!("legal_commands/{players}p"), |b| {
|
|||
|
|
b.iter(|| std::hint::black_box(legal_commands(&state, seat)))
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// A search must COPY the state per branch (or undo, which we do
|
|||
|
|
// not have). `iter_batched` excludes setup from the timing, so
|
|||
|
|
// without this the budget would rest on an unmeasured span —
|
|||
|
|
// which is the exact mistake C1 caught in the survey.
|
|||
|
|
c.bench_function(&format!("clone/{players}p"), |b| {
|
|||
|
|
b.iter(|| std::hint::black_box(state.clone()))
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// One branch taken: what a search pays per CHILD, on top of
|
|||
|
|
// enumeration. The survey folded this into "us/node" without
|
|||
|
|
// separating it, and a search's real cost is enumeration once plus
|
|||
|
|
// this per child.
|
|||
|
|
let legal = legal_commands(&state, seat);
|
|||
|
|
if let Some(cmd) = legal.first() {
|
|||
|
|
c.bench_function(&format!("validate_fold/{players}p"), |b| {
|
|||
|
|
b.iter_batched(
|
|||
|
|
|| state.clone(),
|
|||
|
|
|mut s| {
|
|||
|
|
if let Ok(events) = s.validate(Actor::Player(seat), cmd) {
|
|||
|
|
for e in &events {
|
|||
|
|
s.fold(e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
std::hint::black_box(s)
|
|||
|
|
},
|
|||
|
|
BatchSize::SmallInput,
|
|||
|
|
)
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
criterion_group!(benches, bench);
|
|||
|
|
criterion_main!(benches);
|