diff --git a/docs/performance.md b/docs/performance.md index c6a1559..da523d3 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -357,3 +357,32 @@ revisiting if the trainer model grows substantially or after the batched-traversal-inference work in Optimization Priorities #5 lands — that is the change that would put compile on the dominant phase, not just on the trainer's optimization steps. Not enabled on `main`. + +### GPU forward profiling for batched traversal (2026-05-07, decision support) + +To decide whether Optimization Priorities #5 (batched traversal inference) is +worth implementing, profiled `DeepCFRMLP` from `default.yaml` +(input_dim=365, output_dim=22, hidden=512, 3 layers, ReLU) on an RTX 3090 in +`eval()` + `inference_mode`, with 10-iter warm-up and 1000-iter measurement +per batch size. Script: `scripts/profile_gpu_forward.py`. + +| Batch size | μs/call | μs/state | Speedup vs bs=1 | +| ---: | ---: | ---: | ---: | +| 1 | 80.07 | 80.074 | 1.00× | +| 4 | 81.20 | 20.299 | 3.94× | +| 16 | 91.30 | 5.706 | 14.03× | +| 64 | 93.61 | 1.463 | 54.75× | +| 256 | 88.34 | 0.345 | 232.03× | +| 1024 | 161.95 | 0.158 | 506.30× | + +Policy-call supply from +`runs/tmp/2026-05-07_181155_deep-cfr-default/metrics.jsonl`: mean +`traversal/nodes` ≈ 205,810 over 280 traversals/player → ~368 policy calls per +traversal (rough upper bound on batchable states), ~200k per iteration across +560 traversals. + +Verdict: **Priority #5 is worth pursuing.** Per-state cost drops from 80 μs at +bs=1 to 0.34 μs at bs=256 (>230×). The available supply of ~368 states per +traversal sits comfortably in the bs=64–256 range where μs/call plateaus near +90 μs. End-to-end gain will be bounded by encoding and worker-GPU coordination +overhead, but the GPU forward is not the limiter once batching is in place. diff --git a/scripts/profile_gpu_forward.py b/scripts/profile_gpu_forward.py new file mode 100644 index 0000000..83ecd9b --- /dev/null +++ b/scripts/profile_gpu_forward.py @@ -0,0 +1,83 @@ +"""Profile GPU forward-pass throughput for the Deep CFR trainer network. + +Builds the same DeepCFRMLP that ``DeepCFRTrainer.__init__`` constructs from +``configs/deep_cfr/default.yaml``, then measures average forward-pass time on +CUDA across a sweep of batch sizes. The goal is to decide whether batched +traversal inference (Optimization Priorities #5) is worth implementing. +""" + +from __future__ import annotations + +import time +from pathlib import Path + +import torch +from coolrl_lost_cities.games.classic.deep_cfr.encoding import input_dim +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 + +REPO_ROOT = Path(__file__).resolve().parent.parent +CONFIG_PATH = REPO_ROOT / "configs" / "deep_cfr" / "default.yaml" + +BATCH_SIZES = [1, 4, 16, 64, 256, 1024] +WARMUP_ITERS = 10 +MEASURE_ITERS = 1000 + + +def main() -> None: + if not torch.cuda.is_available(): + raise SystemExit("CUDA is not available; this script requires a CUDA-capable GPU.") + + cfg = load_config(CONFIG_PATH) + + game_config = cfg.rules.to_lost_cities_config(seed=cfg.run.seed) + probe = GameState.new_game(game_config, seed=cfg.run.seed) + in_dim = input_dim(probe, cfg.encoding) + action_size = 2 * probe.config.hand_size + 1 + probe.config.n_colors + + device = torch.device("cuda") + torch.manual_seed(cfg.run.seed) + network = DeepCFRMLP.from_config(in_dim, action_size, cfg.network).to(device) + network.eval() + + print( + f"Network: DeepCFRMLP input_dim={in_dim} output_dim={action_size} " + f"hidden_size={cfg.network.hidden_size} num_layers={cfg.network.num_layers} " + f"activation={cfg.network.activation}" + ) + print(f"Device: {torch.cuda.get_device_name(0)}") + print(f"Warmup iters: {WARMUP_ITERS} Measure iters: {MEASURE_ITERS}") + print() + + results: list[tuple[int, float, float]] = [] + with torch.inference_mode(): + for bs in BATCH_SIZES: + x = torch.randn(bs, in_dim, device=device) + + # Warm-up + for _ in range(WARMUP_ITERS): + network(x) + torch.cuda.synchronize() + + start = time.perf_counter() + for _ in range(MEASURE_ITERS): + network(x) + torch.cuda.synchronize() + elapsed = time.perf_counter() - start + + us_per_call = (elapsed / MEASURE_ITERS) * 1e6 + us_per_state = us_per_call / bs + results.append((bs, us_per_call, us_per_state)) + + bs1_us_per_state = results[0][2] + print(f"{'batch_size':>10} | {'μs/call':>10} | {'μs/state':>10} | {'speedup_vs_bs1':>14}") + print("-" * 56) + for bs, us_call, us_state in results: + speedup = bs1_us_per_state / us_state + print(f"{bs:>10} | {us_call:>10.2f} | {us_state:>10.3f} | {speedup:>13.2f}x") + + +if __name__ == "__main__": + main()