Add a microbench that separates game/encoding overhead, single-request policy boundary overhead, and batched PyTorch forward lower bounds. Record CPU/CUDA results and link the finding from the Julia port evaluation. Co-Authored-By: Codex <codex@openai.com>
402 lines
15 KiB
Python
402 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import random
|
|
import statistics
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import torch
|
|
from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state, input_dim
|
|
from coolrl_lost_cities.games.classic.deep_cfr.traversal import run_cython_traversal_batch
|
|
from coolrl_lost_cities.games.classic.game import GameState
|
|
|
|
from coolrl_lost_cities.games.classic.deep_cfr.config import load_config
|
|
from coolrl_lost_cities.games.classic.deep_cfr.networks import DeepCFRMLP
|
|
|
|
|
|
def _timer(device: torch.device | None = None) -> float:
|
|
if device is not None and device.type == "cuda":
|
|
torch.cuda.synchronize(device)
|
|
return time.perf_counter()
|
|
|
|
|
|
def _median(values: list[float]) -> float:
|
|
return float(statistics.median(values))
|
|
|
|
|
|
def _quantile(values: list[float], q: float) -> float:
|
|
ordered = sorted(values)
|
|
if not ordered:
|
|
return 0.0
|
|
idx = min(len(ordered) - 1, max(0, round((len(ordered) - 1) * q)))
|
|
return float(ordered[idx])
|
|
|
|
|
|
def _build_networks(cfg: Any, input_dim_value: int, action_size: int, device: torch.device):
|
|
networks = [
|
|
DeepCFRMLP.from_config(input_dim_value, action_size, cfg.network).to(device).eval()
|
|
for _ in range(2)
|
|
]
|
|
strategy = DeepCFRMLP.from_config(input_dim_value, action_size, cfg.network).to(device).eval()
|
|
return networks, strategy
|
|
|
|
|
|
def _make_corpus(cfg: Any, count: int, seed: int) -> list[GameState]:
|
|
rng = random.Random(seed)
|
|
game_config = cfg.rules.to_lost_cities_config(seed=seed)
|
|
states: list[GameState] = []
|
|
state = GameState.new_game(game_config, seed=seed)
|
|
while len(states) < count:
|
|
if state.terminal:
|
|
state = GameState.new_game(game_config, seed=rng.randrange(1, 2**31))
|
|
continue
|
|
states.append(state.clone())
|
|
legal = state.unified_legal_actions()
|
|
if not legal:
|
|
state = GameState.new_game(game_config, seed=rng.randrange(1, 2**31))
|
|
continue
|
|
state.push_unified_action(int(legal[rng.randrange(len(legal))]))
|
|
return states
|
|
|
|
|
|
def _regret_matching(values: np.ndarray, legal: list[int], epsilon: float) -> np.ndarray:
|
|
policy = np.zeros_like(values, dtype=np.float32)
|
|
positives = [max(float(values[action]), 0.0) for action in legal]
|
|
positive_sum = sum(positives)
|
|
if positive_sum <= epsilon:
|
|
uniform = 1.0 / max(1, len(legal))
|
|
for action in legal:
|
|
policy[action] = uniform
|
|
return policy
|
|
for action, positive in zip(legal, positives, strict=True):
|
|
policy[action] = positive / positive_sum
|
|
return policy
|
|
|
|
|
|
def bench_full_traversal(
|
|
cfg: Any,
|
|
*,
|
|
traversals: int,
|
|
runs: int,
|
|
warmup: int,
|
|
device: torch.device,
|
|
input_dim_value: int,
|
|
action_size: int,
|
|
) -> dict[str, Any]:
|
|
timings: list[float] = []
|
|
stat_rows: list[dict[str, Any]] = []
|
|
seed_base = int(cfg.run.seed)
|
|
game_config = cfg.rules.to_lost_cities_config(seed=seed_base)
|
|
|
|
for run_idx in range(runs + warmup):
|
|
networks, strategy = _build_networks(cfg, input_dim_value, action_size, device)
|
|
seeds = [seed_base + run_idx * 100_000 + idx for idx in range(traversals)]
|
|
start = _timer(device)
|
|
stats, advantage_samples, strategy_samples = run_cython_traversal_batch(
|
|
networks,
|
|
game_config,
|
|
seeds,
|
|
0,
|
|
1,
|
|
device=device,
|
|
action_size=action_size,
|
|
strategy_network=strategy,
|
|
encoding=cfg.encoding,
|
|
epsilon=cfg.traversal.regret_matching_epsilon,
|
|
strategy_sample_interval=cfg.traversal.strategy_sample_interval,
|
|
store_strategy_on_traverser_nodes=cfg.traversal.store_strategy_on_traverser_nodes,
|
|
store_strategy_on_opponent_nodes=cfg.traversal.store_strategy_on_opponent_nodes,
|
|
max_depth=cfg.traversal.max_depth,
|
|
max_nodes=cfg.traversal.max_nodes_per_traversal,
|
|
sampling_mode=cfg.traversal.sampling_mode,
|
|
outcome_sampling_epsilon=cfg.traversal.outcome_sampling_epsilon,
|
|
outcome_sampling_value_clip=cfg.traversal.outcome_sampling_value_clip,
|
|
outcome_unsampled_regret=cfg.traversal.outcome_unsampled_regret,
|
|
cutoff_value_mode=cfg.traversal.cutoff_value_mode,
|
|
cutoff_rollouts=cfg.traversal.cutoff_rollouts,
|
|
cutoff_rollout_policy=cfg.traversal.cutoff_rollout_policy,
|
|
cutoff_rollout_max_steps=cfg.traversal.cutoff_rollout_max_steps,
|
|
opponent_policy=cfg.traversal.opponent_policy,
|
|
all_negative_fallback=cfg.regret_matching.all_negative_fallback,
|
|
league_advantage_networks=None,
|
|
self_play_anchor_probability=cfg.self_play.anchor_probability,
|
|
self_play_current_weight=cfg.self_play.current_weight,
|
|
self_play_recent_weight=cfg.self_play.recent_weight,
|
|
self_play_older_weight=cfg.self_play.older_weight,
|
|
self_play_anchor_weight=cfg.self_play.anchor_weight,
|
|
self_play_recent_window=cfg.self_play.recent_window,
|
|
endpoint_depth_bucket_width=cfg.traversal.endpoint_depth_bucket_width,
|
|
endpoint_depth_bucket_max=cfg.traversal.endpoint_depth_bucket_max,
|
|
seed=seed_base + run_idx,
|
|
)
|
|
elapsed = _timer(device) - start
|
|
if run_idx >= warmup:
|
|
stats_dict = stats.to_dict()
|
|
stats_dict["advantage_samples_returned"] = len(advantage_samples)
|
|
stats_dict["strategy_samples_returned"] = len(strategy_samples)
|
|
timings.append(elapsed)
|
|
stat_rows.append(stats_dict)
|
|
del networks, strategy
|
|
|
|
policy_calls = [int(row["traversal_regret_matching_decisions"]) for row in stat_rows]
|
|
nodes = [int(row["traversal_nodes"]) for row in stat_rows]
|
|
return {
|
|
"seconds": timings,
|
|
"median_seconds": _median(timings),
|
|
"median_nodes": _median([float(value) for value in nodes]),
|
|
"median_policy_calls": _median([float(value) for value in policy_calls]),
|
|
"median_us_per_node": _median(
|
|
[sec * 1_000_000.0 / max(1, node) for sec, node in zip(timings, nodes, strict=True)]
|
|
),
|
|
"median_us_per_policy_call": _median(
|
|
[
|
|
sec * 1_000_000.0 / max(1, calls)
|
|
for sec, calls in zip(timings, policy_calls, strict=True)
|
|
]
|
|
),
|
|
"stats": stat_rows,
|
|
}
|
|
|
|
|
|
def bench_encode_legal(corpus: list[GameState], cfg: Any, repeats: int) -> dict[str, Any]:
|
|
durations: list[float] = []
|
|
counts: list[int] = []
|
|
total = 0.0
|
|
for _ in range(repeats):
|
|
for state in corpus:
|
|
start = time.perf_counter()
|
|
_ = encode_info_state(state, state.current_player, cfg.encoding)
|
|
legal = state.unified_legal_actions()
|
|
total += len(legal)
|
|
durations.append(time.perf_counter() - start)
|
|
counts.append(len(legal))
|
|
return {
|
|
"median_us": _median(durations) * 1_000_000.0,
|
|
"p95_us": _quantile(durations, 0.95) * 1_000_000.0,
|
|
"mean_legal_actions": float(statistics.mean(counts)),
|
|
"checksum": total,
|
|
}
|
|
|
|
|
|
def bench_push_pop(corpus: list[GameState], repeats: int) -> dict[str, Any]:
|
|
durations: list[float] = []
|
|
total = 0
|
|
for _ in range(repeats):
|
|
for state in corpus:
|
|
legal = state.unified_legal_actions()
|
|
if not legal:
|
|
continue
|
|
action = int(legal[total % len(legal)])
|
|
start = time.perf_counter()
|
|
state.push_unified_action(action)
|
|
state.pop_action()
|
|
durations.append(time.perf_counter() - start)
|
|
total += action
|
|
return {
|
|
"median_us": _median(durations) * 1_000_000.0,
|
|
"p95_us": _quantile(durations, 0.95) * 1_000_000.0,
|
|
"checksum": total,
|
|
}
|
|
|
|
|
|
def bench_policy_boundary(
|
|
corpus: list[GameState],
|
|
cfg: Any,
|
|
network: torch.nn.Module,
|
|
device: torch.device,
|
|
action_size: int,
|
|
repeats: int,
|
|
) -> dict[str, Any]:
|
|
durations: list[float] = []
|
|
checksum = 0.0
|
|
with torch.inference_mode():
|
|
for _ in range(repeats):
|
|
for state in corpus:
|
|
start = _timer(device)
|
|
info_state = encode_info_state(state, state.current_player, cfg.encoding)
|
|
legal = state.unified_legal_actions()
|
|
x = torch.as_tensor(info_state, dtype=torch.float32, device=device).unsqueeze(0)
|
|
advantages = network(x).squeeze(0).detach().cpu().numpy().astype(np.float32)
|
|
policy = _regret_matching(
|
|
advantages,
|
|
legal,
|
|
float(cfg.traversal.regret_matching_epsilon),
|
|
)
|
|
checksum += float(policy.sum()) + float(advantages[0])
|
|
durations.append(_timer(device) - start)
|
|
return {
|
|
"median_us": _median(durations) * 1_000_000.0,
|
|
"p95_us": _quantile(durations, 0.95) * 1_000_000.0,
|
|
"checksum": checksum,
|
|
}
|
|
|
|
|
|
def bench_torch_forward(
|
|
corpus: list[GameState],
|
|
cfg: Any,
|
|
network: torch.nn.Module,
|
|
device: torch.device,
|
|
batch_sizes: list[int],
|
|
repeats: int,
|
|
) -> dict[str, Any]:
|
|
states = np.stack(
|
|
[encode_info_state(state, state.current_player, cfg.encoding) for state in corpus]
|
|
).astype(np.float32)
|
|
results: dict[str, Any] = {}
|
|
with torch.inference_mode():
|
|
for batch_size in batch_sizes:
|
|
durations: list[float] = []
|
|
checksum = 0.0
|
|
for _ in range(repeats):
|
|
for offset in range(0, len(states), batch_size):
|
|
chunk = states[offset : offset + batch_size]
|
|
if len(chunk) != batch_size:
|
|
continue
|
|
x = torch.as_tensor(chunk, dtype=torch.float32, device=device)
|
|
start = _timer(device)
|
|
out = network(x)
|
|
checksum += float(out.detach().sum().cpu())
|
|
durations.append((_timer(device) - start) / batch_size)
|
|
if not durations:
|
|
continue
|
|
results[str(batch_size)] = {
|
|
"median_us_per_call": _median(durations) * 1_000_000.0,
|
|
"p95_us_per_call": _quantile(durations, 0.95) * 1_000_000.0,
|
|
"checksum": checksum,
|
|
}
|
|
return results
|
|
|
|
|
|
def _print_table(result: dict[str, Any]) -> None:
|
|
full = result["full_traversal"]
|
|
encode = result["encode_legal"]
|
|
push_pop = result["push_pop"]
|
|
boundary = result["policy_boundary_bs1"]
|
|
forward = result["torch_forward"]
|
|
|
|
print("Traversal policy-boundary microbench")
|
|
print(
|
|
f"config={result['config']} device={result['device']} torch_threads={result['torch_threads']}"
|
|
)
|
|
print(
|
|
f"full traversal: {full['median_seconds']:.3f}s, "
|
|
f"policy_calls={full['median_policy_calls']:.0f}, nodes={full['median_nodes']:.0f}"
|
|
)
|
|
print()
|
|
print("component median us/call p95 us/call")
|
|
print(
|
|
f"encode+legal {encode['median_us']:9.2f} {encode['p95_us']:9.2f}"
|
|
)
|
|
print(
|
|
f"push+pop {push_pop['median_us']:9.2f} {push_pop['p95_us']:9.2f}"
|
|
)
|
|
print(
|
|
f"policy boundary bs=1 {boundary['median_us']:9.2f} {boundary['p95_us']:9.2f}"
|
|
)
|
|
for batch_size, row in forward.items():
|
|
print(
|
|
f"torch forward bs={batch_size:<3} "
|
|
f"{row['median_us_per_call']:9.2f} {row['p95_us_per_call']:9.2f}"
|
|
)
|
|
print()
|
|
print("derived")
|
|
print(f"full traversal us/node {full['median_us_per_node']:.2f}")
|
|
print(f"full traversal us/policy_call {full['median_us_per_policy_call']:.2f}")
|
|
if boundary["median_us"] > 0:
|
|
print(
|
|
"full/policy-boundary ratio "
|
|
f"{full['median_us_per_policy_call'] / boundary['median_us']:.2f}x"
|
|
)
|
|
bs64 = forward.get("64")
|
|
if bs64 and bs64["median_us_per_call"] > 0:
|
|
print(
|
|
"policy bs=1 vs forward bs=64 "
|
|
f"{boundary['median_us'] / bs64['median_us_per_call']:.2f}x"
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--config", default="configs/deep_cfr/default.yaml")
|
|
parser.add_argument("--device", default="cpu")
|
|
parser.add_argument("--traversals", type=int, default=32)
|
|
parser.add_argument("--runs", type=int, default=5)
|
|
parser.add_argument("--warmup", type=int, default=1)
|
|
parser.add_argument("--corpus-size", type=int, default=512)
|
|
parser.add_argument("--component-repeats", type=int, default=4)
|
|
parser.add_argument("--forward-repeats", type=int, default=64)
|
|
parser.add_argument("--torch-threads", type=int, default=1)
|
|
parser.add_argument("--output", default="")
|
|
args = parser.parse_args()
|
|
|
|
torch.set_num_threads(max(1, int(args.torch_threads)))
|
|
cfg = load_config(args.config)
|
|
device = torch.device(args.device)
|
|
if device.type == "cuda" and not torch.cuda.is_available():
|
|
raise RuntimeError("--device cuda requested but CUDA is not available")
|
|
|
|
probe = GameState.new_game(
|
|
cfg.rules.to_lost_cities_config(seed=cfg.run.seed), seed=cfg.run.seed
|
|
)
|
|
input_dim_value = input_dim(probe, cfg.encoding)
|
|
action_size = 2 * probe.config.hand_size + 1 + probe.config.n_colors
|
|
networks, _strategy = _build_networks(cfg, input_dim_value, action_size, device)
|
|
corpus = _make_corpus(cfg, args.corpus_size, int(cfg.run.seed))
|
|
|
|
# Run a tiny warmup through the component path before timed loops.
|
|
_ = encode_info_state(corpus[0], corpus[0].current_player, cfg.encoding)
|
|
with torch.inference_mode():
|
|
_ = networks[0](torch.as_tensor(_, dtype=torch.float32, device=device).unsqueeze(0))
|
|
|
|
result = {
|
|
"config": args.config,
|
|
"device": str(device),
|
|
"torch_threads": torch.get_num_threads(),
|
|
"traversals": args.traversals,
|
|
"runs": args.runs,
|
|
"warmup": args.warmup,
|
|
"corpus_size": args.corpus_size,
|
|
"input_dim": input_dim_value,
|
|
"action_size": action_size,
|
|
"full_traversal": bench_full_traversal(
|
|
cfg,
|
|
traversals=args.traversals,
|
|
runs=args.runs,
|
|
warmup=args.warmup,
|
|
device=device,
|
|
input_dim_value=input_dim_value,
|
|
action_size=action_size,
|
|
),
|
|
"encode_legal": bench_encode_legal(corpus, cfg, args.component_repeats),
|
|
"push_pop": bench_push_pop(corpus, args.component_repeats),
|
|
"policy_boundary_bs1": bench_policy_boundary(
|
|
corpus,
|
|
cfg,
|
|
networks[0],
|
|
device,
|
|
action_size,
|
|
args.component_repeats,
|
|
),
|
|
"torch_forward": bench_torch_forward(
|
|
corpus,
|
|
cfg,
|
|
networks[0],
|
|
device,
|
|
[1, 4, 8, 64, 256],
|
|
args.forward_repeats,
|
|
),
|
|
}
|
|
_print_table(result)
|
|
|
|
output = Path(args.output) if args.output else Path(__file__).with_name("results.json")
|
|
output.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
print(f"\nwrote {output}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|