Three coordinated hygiene changes; none target the diagnosed
selection-bias bottleneck. They make the codebase honestly reflect the
pure-self-play stance and reduce dashboard noise.
Bot rename (drop the unhelpful safe_ prefix; suffixes describe behaviour):
- safe_heuristic_loose -> heuristic_aggressive
- safe_heuristic -> heuristic_balanced
- safe_heuristic_strict -> heuristic_cautious
- noisy_safe -> heuristic_noisy
- passive_discard -> discard_only
Class renames in bots/: SafeHeuristicBot -> HeuristicBot,
SafeHeuristicParams -> HeuristicParams, PassiveDiscardBot -> DiscardOnlyBot,
plus loose/strict parameter constants. Backwards compatibility was dropped
intentionally per user instruction; no aliases. Active configs, docs,
scripts, tests updated. Archive directories (configs/archive,
docs/archive, runs/archive) left intact and may still reference old
names per their read-only policy. The src/.../bots/passive.py module was
renamed to discard_only.py via git mv.
Analyze plot curation (deep_cfr/analyze.py):
- Added analysis_00_core.png as the canonical daily dashboard with 10
heuristic-free metrics (loss/{advantage,strategy}; vs heuristic_cautious:
avg_score_diff0, win_rate0, avg_opened_colors, positive_expedition_rate,
bonus_expedition_rate, score_per_opened_color, policy_entropy; vs random:
win_rate0).
- Removed analysis_05_open_quality.png (bad/weak/good open rates,
recoverable score) and analysis_07_calibration.png (calibration gap,
recoverable mean) - both relied on the heuristic recoverable_score
classifier already dropped from inputs.
- Removed SELECTIVITY_PLOTS and plot_selectivity (heuristic-laden).
- SUMMARY_EVAL_METRICS no longer includes bad_open_rate or
calibration_gap.
- PlotSpec gained an opponents allowlist so the new core section can pin
a specific opponent per panel without restructuring plot_section.
Tiered evaluation cadence (EvaluationConfig):
- Added extended_opponents and extended_eval_every (default 0 = disabled).
- opponents_for_iteration(iteration) returns the core list every
eval_every and appends extended_opponents (de-duplicated) when
iteration is also a multiple of extended_eval_every.
- default.yaml now uses 3 core opponents (random, discard_only,
heuristic_cautious) every 5 iterations and 3 extended opponents
(heuristic_balanced, heuristic_aggressive, heuristic_noisy) every 50
iterations. random is the floor sanity. discard_only is the
zero-pit detector / absolute-score reference (its score is always 0,
so eval/discard_only/avg_score_diff0 directly equals the model's raw
average score). heuristic_cautious is the ceiling and the
archive-comparable benchmark used in the prior diagnostic sections.
Net eval cost reduction: roughly 50% (3 opponents x every 5 iter, plus
6 opponents x every 50 iter, vs the prior 6 x every 5).
Documented in docs/plans/deep-cfr-selectivity.md section 9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
25 KiB
Plan: Revisit torch.compile on Deep CFR Trainer (and Inference Server Forward)
Status: Conditional — gated on a model-size bump. Do not implement against the current default.yaml (512 hidden / 3 layers); a regression has already been measured at that size.
Owner: Codex
Background: See docs/performance.md:
- "Experiments →
torch.compileon trainer networks (2026-05-07, regression)" — the prior attempt regressed iter time by +4.8% on the small default model. Implementation is preserved on branchexperiments/torch-compile. - "Post-A Optimization Calculus (forward-looking, 2026-05-07)" — argues compile/TensorRT become meaningful only after model size grows out of the dispatch-bound regime and/or eval density rises. This plan honors that sequencing.
- "Clarifying the traversal bottleneck: sync policy boundary, not SIMD" — the current traversal bottleneck is scheduling shape, not a compile-able model-kernel problem.
This plan is the follow-up referenced in step 3 of "Recommended sequencing".
Goal
Re-enable torch.compile on the Deep CFR trainer's networks at a model size where kernel work amortizes compile dispatch overhead, and (optionally, secondary) on batched eval/inference-server forward paths. The goal is iter-time speedup with no learning-curve drift.
Non-goals
- Do not enable compile on the current 512-hidden / 3-layer
default.yaml. The regression is already measured. - Do not compile the Cython traversal call-site networks (workers' CPU networks under
inference_backend: local). Their per-call shape isbatch_size == 1and dispatch-dominated; compile cannot help and recompilation triggers are higher-risk. - Do not introduce TensorRT here. TensorRT is a separate work item for batched inference/eval surfaces (see
docs/performance.md§ "Tooling split"). - Do not change network architectures. This plan picks up whatever larger model the project settles on in step 2 of the post-A sequencing.
- Do not change the public CLI surface. Compile toggles via config only.
- Do not enable
mode="max-autotune"by default. It is opt-in for benchmarking.
Success criteria
- Model-size precondition met. The active
default.yaml(or the targeted variant) hasnetwork.hidden_size ≥ 1024ornetwork.num_layers ≥ 6, or an architecture (e.g.color_sharedwith non-trivialcolor_attention_layers) whose per-call forward time exceeds ~150 μs at the trainer's training batch size on the target GPU. If neither condition holds, this plan is not merged; the branch is parked. - Iter-time improvement. With
compile.trainer.enabled: trueon the chosen larger model and the same seed, the 1000-iter projection improves by at least 5% vs the no-compile baseline on the same machine (measured onhome). Eval and checkpointing should be disabled for the bench window, matching the protocol used in the 2026-05-07 experiment. - No learning-curve drift. Over at least 100 iterations with
compile.trainer.enabled: truevsfalse(same seed, same config), the eval win-rate trajectories againstrandomandheuristic_balancedare within seed noise. If trajectories visibly diverge, the plan does not ship even if iter time improves. - No checkpoint-format break. Checkpoints saved with compile enabled must load cleanly when compile is disabled, and vice versa. (Handled via
_clean_state_dict(); see Risks.) - No multiprocessing-worker break. Whether
inference_backendislocalorserver, traversal workers must continue to receive uncompiledstate_dicts without_orig_mod.prefixes. - (Secondary) Inference-server forward. If step B below is taken, the server's
policy_network_secondsdecreases by at least 20% at the chosen model size, with no traversal-path correctness regression. If step B does not produce a measurable win, it is left disabled and the plan still ships with step A only. - All existing tests pass. Lint clean (
uv run ruff check .). The relevant Deep CFR test subset passes.
Why this is gated on a larger model (explicit dependency)
The 2026-05-07 experiment already isolated the failure mode: at 512 hidden / 3 layers / ReLU MLP, the per-call forward is ~80 μs at batch_size=1 and ~90 μs in the bs≤256 plateau (see "GPU forward profiling for batched traversal", docs/performance.md). Compile dispatch overhead, plus the cudagraph/decomposition path, costs more than the kernel-fusion benefit at that size. The trainer's training batches are larger than 1 (optimization.advantage_batch_size / optimization.strategy_batch_size), so the dispatch-dominated regime ends sooner there than for traversal — but the prior measurement shows it still does not pay at 512×3.
Compile becomes meaningful when one of these is true:
- Wider/deeper MLP.
hidden_size ≥ 1024ornum_layers ≥ 6. At that point the per-layer matmul is large enough that fused epilogue gains (linear+activation) and reduced Python-level dispatch overhead exceed compile's per-call cost. - Non-trivial attention.
color_sharedwithcolor_attention_layers ≥ 2introducesnn.TransformerEncoderLayer, which is one of the architectures wheretorch.compilereliably wins (LayerNorm + softmax + GEMM fusion). - Backward+optimizer fusion. Compile can fuse parts of the optimizer step on larger models. The trainer phase compiles forward+backward+optimizer together; this is where the biggest absolute wins live, but only above the dispatch-bound floor.
Threshold rule (hard gate): do not merge this plan unless at least one of the following is true on the config it targets:
network.hidden_size ≥ 1024, ornetwork.num_layers ≥ 6, ornetwork.kind == "color_shared"withcolor_attention_layers ≥ 2.
If none hold, park the branch and re-evaluate at the next model-size bump. This explicit gate is required by the "Post-A Optimization Calculus" reasoning in docs/performance.md.
Two compile targets (treated separately)
These have different dispatch profiles and different failure modes. Each is independently mergeable; step A is the primary objective, step B is secondary.
A. Trainer networks (forward + backward + optimizer)
Wraps advantage_networks[player] and strategy_network at trainer construction time with torch.compile(...). The compiled wrapper sits in front of the optimization loop in trainer.py. Workers (under inference_backend: local) and the inference server (under inference_backend: server) keep using uncompiled networks built from cleaned state_dicts.
Why this target: the trainer's optimization step has fixed batch shapes (no dynamic shapes), runs many steps per iteration, and exercises forward+backward+optimizer — the regime where compile pays best when the kernel is large enough.
B. Batched inference/eval forward (eval-mode, no grad)
Optional follow-up. The Option A inference server has landed and benchmarked,
but it is structurally capped by sync-blocking traversal and is not the default.
Only compile the server's model.forward path after either (a) Option B-style
interleaved traversal can feed meaningful batches, or (b) the target is
evaluation, which already has batching. The server/eval path uses eval() +
inference_mode(). Batches vary in size, which introduces a dynamic-shape
concern (see Risks).
Why this target is secondary: per docs/performance.md § "Why compile / TensorRT are negligible today but become meaningful later", small-model inference forward is not the iter-level limiter unless traversal can actually feed batched requests. At larger model sizes or denser evaluation, compile and TensorRT compete for the same role; this plan covers compile, and the TensorRT plan (separate, future) covers TensorRT.
Compile mode selection
Start with mode="default" for both targets. Evaluate mode="reduce-overhead" and mode="max-autotune" only after a baseline number is in.
default— safe. Lowest compile time. First measurement.reduce-overhead— uses CUDA graphs. Helps small-batch regimes by amortizing launch overhead. Incompatible with multiprocessing in non-trivial ways: the captured graph holds CUDA stream/state from the capturing process. The trainer process is the only place this mode would be used (workers do not compile); confirm the trainer's compiled call is not entangled with worker process spawn (it should not be — workers are already spawned with cleanedstate_dicts before any compiled call). Usereduce-overheadonly on the trainer phase, never on a target that crosses amp.spawnboundary.max-autotune— autotunes kernel selection. Long compile time (minutes). Only worth it on a stable, frozen model config that will be trained for many hours. Run as an A/B againstdefaultafter the headline result is established.
For the inference server (target B), default is the only safe mode initially. reduce-overhead requires fixed-shape inputs; the server's batch dimension varies up to max_batch, so cudagraphs would either recompile per-shape or require pre-padding to max_batch. Treat that as a separate experiment.
Key files
src/coolrl_lost_cities/games/classic/deep_cfr/trainer.py— owns trainer-side networks, optimizer, and the train loop. This is where target A'storch.compile(...)calls go, plus the_clean_state_dict()helper and the_orig_mod-routedload_state_dictshim. Reference implementation lives on branchexperiments/torch-compile(commit05cc02a).src/coolrl_lost_cities/games/classic/deep_cfr/networks.py— network classes. Not modified by this plan; compile wraps the constructed module from outside.src/coolrl_lost_cities/games/classic/deep_cfr/workers.py— multiprocessing traversal worker entry. Reconstructs CPU networks fromstate_dicts. Must keep receiving cleaned (no-_orig_mod.) state dicts.src/coolrl_lost_cities/games/classic/deep_cfr/inference_server.py— created by the archived Option A plan. Target B wrapsmodel.forwardhere only after Option B or eval batching provides large enough batches.src/coolrl_lost_cities/games/classic/deep_cfr/config.py— extend with aCompileConfigblock.
Reference implementation (branch experiments/torch-compile)
The prior implementation (commit 05cc02a on experiments/torch-compile) provides the working pattern. Specifically:
_clean_state_dict()helper strips the_orig_mod.prefix thattorch.compile'sOptimizedModuleadds tostate_dict()keys. All checkpoint writes and all weight pushes to multiprocessing workers / the inference server go through this helper.load_state_dicton a compiled wrapper is routed viamodule._orig_mod.load_state_dict(...)so it accepts clean state dicts (saved checkpoints have no prefix).
This plan reuses these helpers verbatim. Cherry-pick the trainer changes from that branch as the starting point and rebase onto current main.
Config schema
Extend config.py:
@dataclass
class CompileTrainerConfig:
enabled: bool = False
mode: Literal["default", "reduce-overhead", "max-autotune"] = "default"
fullgraph: bool = False # safer to start False; may flip True after stabilization
dynamic: bool = False # trainer batches are fixed shape
@dataclass
class CompileInferenceServerConfig:
enabled: bool = False
mode: Literal["default", "reduce-overhead"] = "default"
fullgraph: bool = False
dynamic: bool = True # batch dim varies up to max_batch
@dataclass
class CompileConfig:
trainer: CompileTrainerConfig = field(default_factory=CompileTrainerConfig)
inference_server: CompileInferenceServerConfig = field(default_factory=CompileInferenceServerConfig)
In default.yaml, leave both enabled: false. Add an explicit benchmarking variant (e.g. configs/deep_cfr/default_compile.yaml) that flips compile.trainer.enabled: true and bumps the model size to meet the threshold rule.
Checkpoint and weight-sync handling (the _orig_mod. prefix)
This is the single most error-prone part of the work. The contract is:
- Saved checkpoints are always uncompiled-shaped. Before saving, run
_clean_state_dict(module.state_dict())to strip_orig_mod.. Files saved with compile enabled must load fine when compile is disabled. - Loaded checkpoints are always uncompiled-shaped. When loading into a compiled wrapper, route through
module._orig_mod.load_state_dict(clean_dict). - Weights pushed to multiprocessing traversal workers (under
inference_backend: local) are always uncompiled-shaped. Workers reconstruct an uncompiledDeepCFRMLPand callload_state_dicton it. - Weights pushed to the inference server (under
inference_backend: server) are always uncompiled-shaped. The server may separately wrap its loaded model withtorch.compile(target B). The wire format is uncompiled. - Resume from a checkpoint trained with a different
compile.*setting must work in either direction. Test this explicitly.
These rules mean compile is purely a runtime optimization; it never appears in any persisted artifact and never crosses a process boundary.
Trainer integration (target A)
In trainer.py, after constructing advantage_networks[player] and strategy_network and binding their optimizers:
if config.compile.trainer.enabled:
compile_kwargs = dict(
mode=config.compile.trainer.mode,
fullgraph=config.compile.trainer.fullgraph,
dynamic=config.compile.trainer.dynamic,
)
self._advantage_networks = [
torch.compile(net, **compile_kwargs) for net in self._advantage_networks
]
self._strategy_network = torch.compile(self._strategy_network, **compile_kwargs)
Caveats:
- The training step calls
loss.backward()andoptimizer.step(). Compile fuses across the forward, but backward and optimizer paths are separately traced via Dynamo's autograd hooks. Confirm by inspectingtorch._dynamo.config.cache_size_limitis not being hit (recompilation noise). - All
state_dict()writes (checkpoint, weight push, eval snapshot) go through_clean_state_dict(). - All
load_state_dict()reads route through the_orig_modshim when targeting a compiled wrapper.
Inference-server integration (target B, optional)
In inference_server.py, after the server process loads the model on device and sets eval() + inference_mode():
if compile_cfg.inference_server.enabled:
self._model = torch.compile(
self._model,
mode=compile_cfg.inference_server.mode,
fullgraph=compile_cfg.inference_server.fullgraph,
dynamic=compile_cfg.inference_server.dynamic,
)
Caveats:
- Server already runs in a child process spawned via
torch.multiprocessing. Compile is invoked inside the child, never in the parent. This avoids the cudagraph-across-spawn issue. - The server batches request rows into a
[k, input_dim]tensor wherek ∈ [1, max_batch]. Setdynamic=Trueso Dynamo does not recompile perk. If recompilation is observed, pad batches up to a small set of bucket sizes (e.g. powers of two) before forward. mode="reduce-overhead"is not safe here without bucketing; cudagraphs require fixed shapes.- Weight sync: when the trainer pushes a fresh
state_dict, the server must apply it viaself._model._orig_mod.load_state_dict(clean_dict)if compile is enabled, elseself._model.load_state_dict(clean_dict).
Implementation steps (ordered, each independently mergeable)
Step 0: precondition check
- Before doing anything, confirm the active model config (or the variant being targeted) meets the threshold rule above. If it does not, stop. This plan does not ship against the small default.
Step 1: cherry-pick reference implementation (target A)
- Cherry-pick
experiments/torch-compile(commit05cc02a) onto a fresh branch. Resolve any conflicts against currentmain. - Move the unconditional
torch.compile(...)calls behindconfig.compile.trainer.enabled. Default isfalse. - Add the
CompileConfigandCompileTrainerConfigdataclasses toconfig.py. Wire--set compile.trainer.enabled=truethrough. - Confirm
_clean_state_dict()and the_orig_mod-routedload_state_dictpaths are correctly invoked on every checkpoint save, every checkpoint load, every weight push to workers, and every weight push to the inference server if target B is enabled.
Step 2: tests for state-dict round-tripping
- Unit test: build a
DeepCFRMLP, wrap withtorch.compile, call_clean_state_dict(model.state_dict()), build a fresh uncompiledDeepCFRMLP,load_state_dictfrom the clean dict, assert parameter equality. - Unit test: build a compiled model,
load_state_dictfrom a clean dict, assert no error and parameters match. - Integration test: short training run with
compile.trainer.enabled=true, save checkpoint, resume training withcompile.trainer.enabled=false, assert no parameter mismatch on load.
Step 3: precondition guardrail
- In
trainer.py, whencompile.trainer.enabled=true, log a warning at startup if the model fails the threshold rule (hidden < 1024 and layers < 6 and not color-attention). Do not error — operators may want to bench against the threshold — but make the regression risk explicit.
Step 4: bench (target A)
See "Bench plan" below. This is the gating step. If results do not clear success criterion 2 and 3, do not merge target A.
Step 5: trainer compile shipped behind config flag
- Once Step 4 is green, enable
compile.trainer.enabled=truein the larger-model config that became the newdefault.yaml(or the dedicateddefault_compile.yaml). Do not flip it on for any small-model config.
Step 6: (optional) inference-server forward compile (target B)
- Only if Option B or evaluation batching can feed large enough batches to make the inference-server forward a meaningful target. The archived Option A server exists, but the sync-blocking traversal path did not feed large batches.
- Add
CompileInferenceServerConfigwiring. Addtorch.compile(...)invocation inside the server process. Route weight sync through_orig_modwhen enabled. - Add a small inference-server bench script (or extend
scripts/bench_inference_backend.pyif it has landed) to measure server-sidepolicy_network_secondswith and without compile at the chosen larger model size. - Ship only if criterion 6 is met. Otherwise leave disabled.
Step 7: documentation
- Add a date-stamped experiment subsection to
docs/performance.mdrecording the bench result at the new model size, mirroring the existing 2026-05-07 entry's structure. - If the result is a regression at the chosen model size, document it and park the branch again with a note about which threshold to revisit.
Bench plan
Mirror the 2026-05-07 protocol exactly so results are comparable to the prior data point.
-
Hardware:
home(6-core + RTX 3090). Confirm baseline numbers onremoteseparately if/when applicable. -
Config: the larger-model variant that meets the threshold rule. Disable eval and checkpointing for the bench window (
--set checkpoint.save_latest=false --set checkpoint.save_every=0and an eval-disabling override). -
Length: at least 8 iterations measured, with iteration 1 dropped as compile warm-up. Replicate the 2026-05-07 table format:
iter mean 1000-iter projection Baseline (no compile) … … torch.compiletrainer (mode=default)… … Effect … … -
A/B: same seed both runs. Same machine, same GPU, no concurrent jobs (per AGENTS.md).
-
Drift check (criterion 3): run a separate 100-iter A/B with
eval.eval_every=25enabled, same seed, and overlay the eval win-rate trajectories frommetrics.jsonl. If the trajectories diverge beyond seed noise, target A does not ship even if the iter-time A/B looked good. -
Mode sweep: only after
mode="default"clears the bar, also benchmode="reduce-overhead"(trainer only) andmode="max-autotune"(trainer only). Record both.
For target B, bench policy_network_seconds from the inference-server side with and without compile at the chosen larger model size. The report does not need to wait for a learning-curve A/B — the server is eval-mode only and pushes uncompiled weights every iter, so it cannot drift training.
Risks
- Recompilation triggers. If
torch._dynamo.config.cache_size_limitis hit, the compiled wrapper falls back to eager and the run silently regresses. Mitigation: log Dynamo recompile events at startup; fail loudly if recompile count exceeds a small threshold during the bench window. Trainer batches are fixed-shape (optimization.*_batch_size), so this should not trigger for target A. For target B, setdynamic=Trueand watch for recompilation across batch sizes. _orig_mod.prefix leaking into checkpoints or worker state_dicts. Most likely bug. Mitigation: tests in Step 2 explicitly guard this. The reference implementation already handles it.reduce-overhead× multiprocessing. Cudagraphs inreduce-overheadmode capture CUDA stream/context state. They are safe inside the trainer process (no spawn after compile) and inside the inference-server child process (compile happens after spawn). They are not safe if compile is invoked beforemp.spawnand the child inherits compiled state. The plan only invokes compile after spawn boundaries.- Dynamic shapes on inference server (target B). Server batches vary in size up to
max_batch. Mitigation:dynamic=True. Fallback: pad to fixed bucket sizes. - Backward path not benefiting. Compile's biggest theoretical wins on the trainer phase come from fusing forward+backward+optimizer. In practice on simple MLPs the optimizer is already fused (e.g.
torch.optim.Adam(..., fused=True)if available); compile may add little on top. Mitigation: bench is the answer — if iter time does not move 5%, the plan does not merge. This is a real possibility. - CFR variable-length traversal does not feed compile. Confirmed: traversal call sites use
batch_size == 1and run on CPU workers (or via the inference server, which is target B not target A). Target A only sees the trainer optimization batches, which are fixed-shape. So dynamic-shape concerns do not apply to target A. - GPU contention with eval. Eval already runs on the trainer's device. Compile increases peak memory during compile/autotune phases (especially
max-autotune). Mitigation: compile happens once per process at startup; eval runs after warm-up. Bench with eval disabled to isolate iter time, then re-enable for the drift check. - AMP interaction. Trainer AMP is implemented but default-off after the 2026-05-07 smoke regression. If the model-size experiment later makes AMP attractive, re-bench compile with and without AMP because the two interact and prior numbers do not transfer.
Decision tree
- Threshold rule fails on the active model. Park the branch. Do not merge. Re-evaluate at the next model-size bump.
- Threshold rule passes; bench shows ≥5% iter improvement and no learning-curve drift. Ship target A behind the config flag, enable on the larger-model config.
- Threshold rule passes; bench shows iter improvement but learning-curve drift. Do not ship. Investigate determinism (compile mode, autograd path, optimizer fusion). Likely cause: optimizer-fusion change altering update order. If cause cannot be isolated within reasonable effort, park.
- Threshold rule passes; bench shows <5% iter improvement. Do not ship. The cost (state-dict gymnastics, recompile risk, checkpoint compatibility surface) is not justified by sub-5% wins. Park.
- Threshold rule passes; bench shows regression. Park the branch with a documented
docs/performance.mdentry. Note the model size at which the regression was observed and the next threshold to try. - Target A shipped; target B (inference-server compile) bench shows <20% server-forward improvement. Leave target B disabled. Revisit alongside the TensorRT plan, since they target the same surface.
Out-of-scope
- TensorRT. Separate plan. TensorRT and
torch.compileon the inference-server forward are alternatives covering the same surface (target B). This plan covers compile only; the TensorRT plan covers TensorRT. - Compiling the Cython traversal call-site networks. Per the 2026-05-07 experiment, this is dispatch-bound and unhelped by compile. Workers under
inference_backend: localwill keep using uncompiled CPU networks indefinitely. - Compiling the encoding path. Encoding is numpy/Cython, not a
torch.nn.Module. torch.export/ AOTInductor. Out of scope until the model architecture is frozen and an offline-compiled artifact is operationally needed.- Multi-GPU. Not relevant to the current single-GPU setup.
fullgraph=True. Defaultfullgraph=Falseuntil stabilization. Promoting tofullgraph=Trueis a follow-up after one full training run completes cleanly with compile enabled.