Profile GPU forward to evaluate batched traversal inference

Measured DeepCFRMLP forward at bs={1,4,16,64,256,1024} on RTX 3090.
Per-state cost drops 232× from bs=1 (80 µs) to bs=256 (0.34 µs) while
per-call latency stays near 90 µs through bs=256. Policy-call supply
from a real run is ~368 states per traversal and ~200k per iteration,
well above the bs=64–256 plateau, so batched inference is not
supply-limited. GPU forward is not the limiter once batching exists.

Verdict: Optimization Priorities #5 (batched traversal inference) is
worth pursuing. End-to-end gain will still be bounded by encoding and
worker-GPU coordination overhead.

- scripts/profile_gpu_forward.py: standalone profiling script
- docs/performance.md: new "GPU forward profiling for batched traversal"
  experiment section with table, supply estimate, and verdict

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 18:35:28 +09:00
co-authored by Claude Opus 4.7
parent 83460fe6e0
commit 4014e49168
2 changed files with 112 additions and 0 deletions
+29
View File
@@ -357,3 +357,32 @@ revisiting if the trainer model grows substantially or after the
batched-traversal-inference work in Optimization Priorities #5 lands — batched-traversal-inference work in Optimization Priorities #5 lands —
that is the change that would put compile on the dominant phase, not that is the change that would put compile on the dominant phase, not
just on the trainer's optimization steps. Not enabled on `main`. 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=64256 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.
+83
View File
@@ -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()