Adds torch.autocast(fp16) + GradScaler around _train_advantage and _train_strategy when run.use_amp=true and device=cuda. CPU/non-CUDA falls back to fp32 no-op. Mitigations: - scaler.unscale_(optimizer) before grad_clip. - nonfinite-loss guard skips overflowing batches and counts them. - diff.float().square() in advantage loss to avoid fp16 overflow. - strategy mask/log_softmax kept in fp32. New metrics: amp/grad_scale, amp/nonfinite_loss_count. Tests: AMP CUDA smoke + CPU fallback in test_deep_cfr_trainer.py. Bench: scripts/bench_amp_trainer.py micro-benches train phases under synthetic replay memory. smoke.yaml result is fp32 3.22ms / AMP 3.92ms (0.82×, regression). 100-iter A/B on default.yaml deliberately skipped: smoke regression mirrors the 2026-05-07 torch.compile regression dynamic (dispatch overhead > kernel benefit at this model size) and re-confirming on the same size adds no information. Default stays run.use_amp: false. Re-enable trigger documented in docs/performance.md: hidden_size >= 1024 or num_layers >= 6, then run the bench script + 100-iter A/B before flipping default.
32 KiB
Deep CFR Performance Notes
This document tracks current runtime bottlenecks for the active Deep CFR training path. The numbers below are observational, not a benchmark contract.
Current Default Runtime
Source run:
runs/tmp/2026-05-07_171535_deep-cfr-default/metrics.jsonl
The run used configs/deep_cfr/default.yaml with CUDA enabled. At the time of
inspection, completed metrics covered iterations 70 through 95. The training
process was still running, so later rows may differ.
Non-evaluation iterations averaged:
| Metric | Mean | Share |
|---|---|---|
iteration_seconds |
17.85s | 100% |
traversal_seconds |
10.71s | 60% |
advantage_train_seconds |
4.72s | 26% |
strategy_train_seconds |
2.39s | 13% |
memory_add_seconds |
1.25s | 7% |
checkpoint_seconds |
0.02s | <1% |
batch_tensor_seconds averaged 4.60s. This is not an additional phase; it is
included inside the advantage and strategy training timers. It measures the cost
of building per-step tensors from sampled replay entries, including np.stack
and transfer to the trainer device.
One evaluation iteration was present in the inspected window:
| Iteration | iteration_seconds |
evaluation_seconds |
|---|---|---|
| 75 | 40.30s | 18.29s |
With the default evaluation.eval_every: 25, an 18.29s evaluation amortizes to
about 0.73s per training iteration. This makes evaluation noticeable in logs but
not the primary long-run wall-clock bottleneck.
If the same 100-game evaluation is run more often, the amortized cost becomes:
evaluation.eval_every |
Amortized eval cost |
|---|---|
| 25 | 0.73s/iteration |
| 10 | 1.83s/iteration |
| 5 | 3.66s/iteration |
If evaluation.games increases from 100 to 1000, evaluation cost should be
expected to grow roughly linearly unless fixed overhead or batching effects
dominate. Using the observed 18.29s eval as a rough base, a 1000-game eval would
be about 183s:
evaluation.games |
evaluation.eval_every |
Rough amortized eval cost |
|---|---|---|
| 1000 | 25 | 7.3s/iteration |
| 1000 | 10 | 18.3s/iteration |
| 1000 | 5 | 36.6s/iteration |
At that point evaluation becomes a first-order wall-clock concern.
Current Bottleneck Shape
Default training is split between CPU-heavy traversal and CUDA-backed network training:
- Traversal is the largest measured phase at roughly 60% of non-eval iteration time.
- Advantage and strategy training together are roughly 40% of non-eval iteration time.
- Tensor materialization is a major part of training time, so reducing pure GPU compute alone cannot remove all of the training cost.
The default traversal settings are:
traversal:
traversals_per_player: 280
num_workers: 8
worker_chunk_size: 8
This gives 560 traversals per iteration, split into 70 worker batches.
Device Use
The trainer constructs the advantage and strategy networks on run.device.
configs/deep_cfr/default.yaml sets:
run:
device: cuda
use_amp: false
Traversal workers currently reconstruct networks on CPU:
device = torch.device("cpu")
So, in the default multiprocessing traversal path, setting run.device: cuda
accelerates the trainer's optimization steps and trainer-device evaluation, but
does not move traversal worker inference to GPU.
On ROCm systems, AMD GPUs may still appear through PyTorch's cuda device API
if a compatible ROCm build is installed. The project does not have a separate
AMD-specific device path.
AMP Status
run.use_amp exists in configuration, but automatic mixed precision is not
currently wired into the training loop. There are no active autocast or
GradScaler calls in the Deep CFR trainer.
Practical implication: setting
--set run.use_amp=true
should be treated as a no-op until the trainer implements AMP explicitly.
If implemented, AMP would mainly target the network optimization phases:
advantage_train_secondsstrategy_train_seconds
It would not directly reduce traversal CPU time or replay tensor materialization overhead.
Batching Status
There are two separate meanings of batching in the current codebase.
Implemented:
- Worker batching via
traversal.worker_chunk_size. - Evaluation policy batching via
evaluation.batch_size. - Optimization batching via
optimization.advantage_batch_sizeandoptimization.strategy_batch_size.
Not implemented:
- Batched network inference inside traversal.
Traversal policy evaluation currently encodes one state and runs a single-row
network call, effectively batch_size == 1, then converts the result back to
CPU/Numpy for Cython-side policy logic. This means traversal is not structured
to feed many states to the GPU in a single inference call.
Evaluation batching is already implemented. During evaluation, active games that
need a policy-network action are grouped into chunks of
evaluation.batch_size, then evaluated together on the evaluation device.
Default config uses:
evaluation:
batch_size: 64
This makes evaluation much more suitable for GPU inference optimization than traversal is today. The remaining question is whether policy-network forward time is actually the dominant part of evaluation.
Evaluation Breakdown
The current source emits evaluation metrics as:
eval/<opponent>/<metric>
The inspected run uses an older flattened scheme:
eval_<opponent>_<metric>
For that run, iteration 75 had evaluation_seconds = 18.29s. Evaluation was
parallelized by opponent, so per-opponent elapsed_seconds values overlap and
must not be summed as wall-clock time. The slow safe-heuristic opponents
dominated the eval wall-clock.
Representative per-opponent breakdown:
| Opponent | Elapsed | Network | Postprocess | Opponent act |
|---|---|---|---|---|
random |
0.57s | 0.18s | 0.25s | 0.06s |
passive_discard |
0.36s | 0.13s | 0.17s | 0.00s |
safe_heuristic |
14.54s | 2.65s | 3.34s | 7.57s |
safe_heuristic_loose |
11.20s | 2.55s | 3.30s | 4.63s |
safe_heuristic_strict |
15.94s | 2.51s | 3.14s | 9.22s |
noisy_safe |
1.68s | 0.40s | 0.58s | 0.57s |
The important read is that safe-heuristic evaluation is not primarily GPU
network forward time. opponent_act_seconds and policy post-processing are
larger than policy_network_seconds for the slowest opponents.
Useful eval runtime keys to inspect:
eval_<opponent>_elapsed_seconds
eval_<opponent>_policy_network_seconds
eval_<opponent>_policy_encoding_seconds
eval_<opponent>_policy_postprocess_seconds
eval_<opponent>_policy_legal_mask_seconds
eval_<opponent>_opponent_act_seconds
eval_<opponent>_apply_action_seconds
eval_<opponent>_diagnostics_seconds
eval_<opponent>_final_scoring_seconds
For newer runs, replace eval_<opponent>_<metric> with
eval/<opponent>/<metric>.
Evaluation Optimization Options
The practical eval tuning levers are:
-
Tune
evaluation.batch_size. Try 128 or 256 if GPU memory allows. This helps most whenpolicy_network_secondsis a large fraction of opponent elapsed time. -
Tune
evaluation.num_workers. Multiple workers parallelize opponents, but they can also split GPU work across processes and duplicate model copies. Compare 1, 2, and 4 workers for CUDA eval instead of assuming the largest value is fastest. -
Split light and full evaluation. A useful schedule would run a small opponent/games set frequently and the full opponent suite less often. The current config has one eval schedule, so this would require a feature change.
-
Reduce frequent opponents. The safe-heuristic opponents dominate wall-clock in the inspected run. For frequent checks, evaluate against one or two representative opponents and run the full suite less often.
-
Add a diagnostics-light mode. Current eval records many action-quality and expedition diagnostics. The measured
diagnostics_secondsis small in the inspected run, but a basic win-rate/score-only mode would still make frequent large evals simpler and cheaper. -
Consider asynchronous evaluation. A separate process can evaluate checkpoints while training continues. This does not reduce total compute, and it can contend for GPU if run on the same device, but it removes eval pauses from the trainer wall-clock.
-
Consider eval-only AMP or compiled inference. This is simpler than training AMP because evaluation has no backward pass. It should be measured against
policy_network_seconds; it will not reduce opponent policy time or game-state transition time.
TensorRT Assessment
TensorRT is not an obvious high-priority optimization for the current default training loop.
Reasons:
- The largest phase is traversal, and default multiprocessing traversal runs on CPU workers.
- Traversal network inference is single-state, control-flow-heavy, and crosses between encoded state arrays, PyTorch tensors, and CPU/Numpy outputs.
- TensorRT mainly helps inference, while the CUDA-backed trainer phases are training steps with backward passes and optimizer updates.
- Evaluation can benefit from inference optimization in principle, but default evaluation is only every 25 iterations. Even making evaluation much faster has limited effect on long-run average iteration time.
For evaluation specifically, TensorRT is more plausible because GPU batching is
already implemented. It would replace or wrap the strategy-network forward pass
with a precompiled inference engine. Its maximum impact is bounded by
policy_network_seconds, not by total eval time.
In the inspected eval row, the slow safe-heuristic opponents spent about
2.5-2.6s in policy-network forward but 4.6-9.2s in opponent action selection and
about 3.1-3.3s in policy post-processing. That means TensorRT could help eval,
especially for larger evaluation.games, but it is not expected to collapse the
18.29s eval to a tiny number by itself.
TensorRT becomes more attractive if:
evaluation.gamesis raised substantially, such as 1000 games.evaluation.eval_everyis reduced to 5 or 10.policy_network_seconds / elapsed_secondsrises after batch-size and worker tuning.
TensorRT may also become relevant for traversal after a larger traversal redesign that batches many policy-needed states into GPU inference requests.
Optimization Priorities
Based on the current metrics, the more plausible performance work is:
-
Improve traversal throughput. Tune worker count and chunk size, then profile the Cython traversal hot path.
-
Reduce training tensor materialization cost.
batch_tensor_secondsis a large part of train time. More contiguous replay storage or tensor-ready sampled batches may help more than model-kernel tuning alone. -
Implement and test AMP. This should be gated by
run.use_ampand measured against loss stability and wall-clock, since it only targets the optimization phases. -
Consider
torch.compilefor the trainer networks. This should be measured separately from traversal because the default training loop has substantial non-kernel overhead. -
Consider batched traversal inference only as a structural project. This is the path that could make GPU inference accelerators more meaningful, but it requires changing traversal scheduling, not just swapping the network backend.
-
For eval-heavy runs, optimize the safe-heuristic opponents and policy post-processing before assuming TensorRT is the main lever. The inspected eval row shows those costs dominate the slowest opponents.
Experiments
torch.compile on trainer networks (2026-05-07, regression)
Wrapped both advantage networks and the strategy network with
torch.compile() at trainer construction time. Implementation also
required a _clean_state_dict() helper to strip the _orig_mod. prefix
that compiled modules add to state_dict(), plus a _orig_mod-routed
path for load_state_dict() so multiprocessing traversal workers and
checkpoint restoration could keep using the uncompiled DeepCFRMLP
class.
Measurement (8 iterations on default.yaml, eval and checkpoint
disabled, iteration 1 dropped as compile warm-up):
| iter mean | 1000-iter projection | |
|---|---|---|
| Baseline (no compile) | 17.93s | 4.98h |
torch.compile on trainer nets |
18.79s | 5.22h |
| Effect | +0.86s (+4.8%) | +14 min |
Net result: regression. Two reasons:
- Traversal is ~60% of iteration time and runs in CPU multiprocessing
workers that reconstruct networks from cleaned
state_dicts, so they bypass the compiled wrapper entirely. DeepCFRMLP(512-hidden, 3-layer) is small enough that the compiled call dispatch overhead exceeds the kernel-fusion benefit.
Implementation preserved on branch experiments/torch-compile for
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.
AMP on trainer networks (2026-05-07, regression)
Wrapped the trainer optimization phases with torch.autocast(fp16) and
torch.amp.GradScaler: _train_advantage and _train_strategy now run
their network forward/backward/optimizer step through the AMP path when
run.use_amp=true and the trainer device is CUDA.
Safety mitigations included in the implementation:
GradScaler.unscale_(optimizer)is called beforeclip_grad_norm_.- Non-finite loss guard increments
amp/nonfinite_loss_countand skips the bad step instead of applying it. - Advantage squared loss computes
diff.float().square()so the loss reduction is fp32 even when the forward path is autocast to fp16. - Strategy logits are cast back to fp32 before
masked_fillandlog_softmax. - Metrics now expose
amp/grad_scaleandamp/nonfinite_loss_count.
Measurement used the small smoke.yaml config with synthetic replay-memory
samples via:
uv run python scripts/bench_amp_trainer.py \
--config configs/deep_cfr/smoke.yaml \
--runs 3 \
--warmup 1 \
--device cuda
| mean ms/call | speedup vs fp32 | |
|---|---|---|
| fp32 | 3.22 | 1.00× |
| AMP (fp16) | 3.92 | 0.82× |
Net result: regression. This matches the same dispatch-overhead-vs-kernel
benefit dynamic as the torch.compile regression above: the current trainer
model and smoke workload are too small for AMP's lower-precision kernels to
pay back autocast and scaler bookkeeping overhead.
The full default.yaml 100-iteration A/B was intentionally skipped. Given
the small-model regression and the matching torch.compile precedent on the
same model family, there is no current evidence that spending GPU time on the
longer A/B would produce a different decision. The infrastructure is kept
merged but default-off: run.use_amp=false remains the default, and
re-enabling is a one-field config flip.
Re-measure AMP only after the model grows to at least hidden_size >= 1024
or num_layers >= 6. At that point run both the fast
scripts/bench_amp_trainer.py micro-bench and the formal 100-iteration
fp32-vs-AMP A/B. If AMP still provides less than 5% speedup at that larger
model size, keep it default-off and raise the next re-measure trigger to an
even larger model.
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.
Batched Traversal Inference: Design Decision (2026-05-07)
Three structural options were considered for Priority #5:
- A. Central inference server. Workers stay in multiprocessing and reconstruct nothing on GPU. A separate server process owns the model, batches incoming policy requests across workers, runs GPU forward, returns logits. Worker traversal logic and the Cython recursion are untouched.
- B. Per-worker batching. Each worker interleaves multiple traversals internally to form its own batches. GPU process count = worker count, so model copies and GPU contention scale with workers. Batching efficiency is bounded by per-worker in-flight count.
- C. Single-process vectorized traversal. Drop multiprocessing entirely.
Main process runs N traversals lockstep with explicit recursion stacks,
forming a natural batch dimension across traversal instances. Existing
recursive traversal can be kept and a new
traversal/batched.{py,pyx}added as a parallel backend gated by config; existing code is not modified.
Decision: A
Reasons:
- Hardware fit dominates. A central inference server keeps multiprocessing, so all available CPU cores stay productive on game logic. C is single-process, so on a 32-core remote machine with a weak GPU it wastes 31 cores while the weak GPU caps batching gains; A is strictly better there. On a 6-core / RTX 3090 box A and C are competitive but uncertain — C only wins when GPU forward is the dominant share of traversal, and game logic in CFR traversal is not negligible.
- C is not the "ultimate" answer on multi-core machines. A truly maximal
design would combine C's batched GPU forward with
nogilthreaded game logic, which is strictly more complex than C alone. Plain C, by being single-process, gives up CPU parallelism that the existing multiprocessing path already exploits. - A is mostly additive. New modules:
inference_server.py,inference_client.py, shared-memory tensor pool, weight-sync hook. Existing touches are small: worker policy call site (one line), worker spawn/teardown (server start/stop), trainer (periodic weight push). Cython traversal recursion, game engine, replay/training paths are unchanged. - The hard part is IPC tuning, not code volume. Latency budget vs GPU forward, weight-staleness window, backpressure, and shared-memory tensor layout. Code is small; the design surface is concentrated in one place.
IPC: what crosses the process boundary
Only the encoded policy input and its response cross IPC:
- Forward request: encoded state vector, ~365 floats ≈ 1.5KB.
- Forward response: action logits, ~22 floats ≈ 88 bytes.
Game state, traversal recursion stack, event log, CFR regret/strategy
accumulators, and chance-node sampling history all stay inside the worker
process. The policy network consumes a flat encoded state (input_dim=365),
so the server needs no game-tree context to answer a request.
The replay-buffer write path (workers shipping collected regret/strategy
samples to the trainer) is separate, already exists today, and is reflected in
memory_add_seconds ≈ 1.25s/iter; A does not add to it.
IPC mechanism: multiprocessing + shared memory
- Big payload (state, logits): shared-memory tensors. Either
torch.multiprocessingwithtensor.share_memory_()and a pre-allocated buffer pool indexed by slot id, ormultiprocessing.shared_memory.SharedMemorywith manual slot management. Pickle is bypassed for the data itself. - Control messages (slot index, request id): small
Queue. Pickle still happens here but only for ints/tuples, which is sub-microsecond and negligible against ~90 μs GPU forward. - The naive path (
multiprocessing.Queue(tensor)with default pickle) is the one that is slow and is what causes the "Python IPC is slow" reputation. With shared memory, multiprocessing IPC is effectively on par with thread shared-memory access for tensor traffic.
Why not Cython nogil + threading instead of multiprocessing
Threading would avoid IPC entirely, but it requires the game-engine hot path
to be genuinely nogil-clean — no Python objects touched anywhere on the path.
Whether the existing Cython traversal qualifies is unknown and likely no:
auditing and migrating it to be fully nogil-clean is a substantial,
high-risk change to existing code, contradicting A's "mostly additive"
property. Additional drawbacks: a single segfault kills all threads;
multi-threaded CUDA usage has subtle context-sharing pitfalls; tooling and
prior art are weaker than for the multiprocessing pattern. Revisit only after
free-threaded Python (PEP 703) stabilizes or if a future profile shows the
shared-memory IPC is itself the limiter.
Implementation plan
- Prototype A on the 6-core / 3090 host with a single worker: validate end-to-end correctness and measure IPC round-trip latency vs GPU forward.
- Scale to multiple workers; tune
batch_window_us,max_batch, andsync_every(weight push frequency). - Deploy to the 32-core / weak-GPU remote and confirm CPU-side scaling holds and the weak GPU is still the right place to keep the model.
- Defer C. Re-evaluate only if A's measurements show GPU forward is no longer
on the critical path and game-logic CPU cost dominates — in that case the
right next step is C with
nogilthreading, not plain C.
Post-A Optimization Calculus (forward-looking, 2026-05-07)
Once Option A lands, the bottleneck shape changes. This section records the expected sequencing for follow-up work. It is forward-looking and has not been measured yet — verify against bench numbers after A is benchmarked.
Why compile / TensorRT are negligible today but become meaningful later
Today (small model: 3-layer, 512 hidden):
torch.compileon the trainer's networks already regressed (see the 2026-05-07 experiment above). The model is too small for kernel fusion to beat compile dispatch overhead.torch.compile/ TensorRT on the inference-server forward (post-A) would shave ~30–50% off ~90μs/call → ~50–70μs/call. With forward share of an iter reduced to <1% by A's batching, the iter-level multiplier is ~1.00–1.01×. Negligible.
Two compounding shifts can flip this:
- Larger model. Going from 512 hidden / 3 layers to ~1024 hidden / ~6 layers pushes the forward call out of dispatch-bound territory into kernel-bound territory. Compile fusion and TensorRT both deliver real 1.5–2× on the forward call itself once the kernel is large enough to amortize launch overhead. Forward share of iter time also rebalances upward because per-call time scales with FLOPs while batching gain is fixed.
- Denser, larger evaluation. Moving toward
eval_every: 5andevaluation.games: 1000makes evaluation about half of iteration wall-clock (see the amortized eval table earlier in this doc). Eval is pure inference, so TensorRT on the inference-server's forward path applies directly.
When both shifts happen together, an illustrative future iter (rough order of magnitude only):
| Configuration | Iter time (rough) |
|---|---|
| Today (small model, eval_every=25) | 17.85s |
| + A (batched traversal inference) | ~14s |
| + larger model (≈4× FLOPs), no compile/TRT | ~50s |
| + dense eval (eval_every=5, games=1000) | ~70s |
| + compile (trainer) + TensorRT (inference) | ~45s |
That last row is where compile/TensorRT contributes ~1.5× iter — the same tooling that is iter-neutral today. The numbers above are illustrative; real ratios depend on model size, kernel autotune outcomes, and the eval-vs-train balance.
Tooling split
- TensorRT: applies only to inference (no backward). Targets:
- inference-server forward in traversal,
- inference-server forward in evaluation. Both are served by the same A-era server, so a single TensorRT integration covers both.
torch.compile: applies to trainer's advantage/strategy training (forward+backward+optimizer). The 2026-05-07 regression on a small model does not generalize — it must be re-measured on whatever larger model config we settle on. Do not conclude "compile is bad" from the small-model data point.
Recommended sequencing
Do this in order. Skipping ahead is the failure mode that creates misleading "compile/TRT didn't help" data.
- Now: benchmark A (
scripts/bench_inference_backend.py) and confirm thelocalvsservermultipliers onhomeandremote. Validate the iter 1.2–1.3× / traversal 1.5–2× working estimate. - Next: experiment with a larger network config. Measure compute vs
learning-curve trade-off with the existing toolchain (no compile/TRT yet).
This step decides the model size that future optimizations target.
It is also the prerequisite for revisiting AMP,
torch.compile, and TensorRT: all three are dispatch-overhead-bound on the current small model. - Then: re-measure
torch.compileon the trainer at the chosen model size. The earlier regression was size-bound; expect a different result. - Then: integrate TensorRT into the inference server (covers traversal
and eval forward simultaneously). Bound the gain by the post-step-2
policy_network_secondsshare, not the headline TensorRT speedup. - In parallel with 2–4: if denser eval is operationally useful, raise
evaluation.gamesand lowerevaluation.eval_every. This step does not require code changes but sharply increases the value of step 4.
Out of scope until A bench numbers are in: Option C, nogil threading, async
inference client, compiled encoding.
Option A Bench Result and Structural Ceiling (2026-05-07)
Option A (traversal.inference_backend: server) was implemented and
benchmarked. Result: regression. A is deferred. default.yaml stays on
local. The implementation is preserved behind the flag for future revisit.
Bench numbers
scripts/bench_inference_backend.py --device cuda --iterations 5 --warmup 1,
RTX 3090, after a per-call IPC fix (replaced multiprocessing.Manager()
queues/events with spawn-context primitives, slot reuse per worker batch,
shared memory confirmed in use for state/response payloads).
| Backend | iter | traversal | adv_train | strat_train | mem_add | batch_tensor |
|---|---|---|---|---|---|---|
local |
16.75s | 10.81s | 3.91s | 2.02s | 0.95s | 3.56s |
server |
57.61s | 51.61s | 3.96s | 2.02s | 0.50s | 3.57s |
| Speedup | 0.29× | 0.21× | 0.99× | 1.00× | 1.89× | 1.00× |
Raw: runs/bench/2026-05-07_193335_inference_backend/results.json.
Training and eval phases are unchanged (as expected — A only touches the
traversal forward path). The regression is contained in traversal_seconds,
which is ~5× worse.
Diagnosis
The server emits per-flush batch stats. Mean batch size: ~7.2–7.9, max 8. This is the structural ceiling, not a tunable misconfiguration:
- Traversal recursion is sync-blocking at the policy call site. Each worker has at most one in-flight policy request at a time.
- In-flight requests at the server ≤
num_workers= 8. - The server further splits each batch by
(network_kind, network_index), so the actual GPU forward group size is roughly half of that — about 4 rows per group.
Per-state cost at this realized batch size, from the GPU profile table:
| Realized batch | μs/state |
|---|---|
| 1 | 80.07 |
| 4 | 20.30 |
| 8 (extrapolated) | ~12 |
| 64 | 1.46 |
| 256 | 0.34 |
So the GPU is doing ~12μs per state instead of the projected ~1.5μs at bs=64. The IPC round-trip per call (queue post + server scheduler + event wakeup, even with shared-memory payload) is on the order of hundreds of μs per call, which exceeds both the local CPU forward (~80–200μs at bs=1 on this small MLP) and the marginal GPU gain. Net: per-call cost roughly doubles or triples, compounded across ~205k calls/iter, gives the observed 5× traversal regression.
batch_window_us and max_batch tuning cannot escape this ceiling —
there are simply not 64 concurrent in-flight requests to coalesce when only
8 workers are blocking-sync.
What this means for the headline GPU profile (scripts/profile_gpu_forward.py)
The earlier "230× speedup at bs=256" is a per-state GPU forward microbenchmark, not an end-to-end traversal speedup. Realizing that gain requires actually feeding the GPU with bs=64+ batches. Sync-blocking multi-worker traversal cannot do that. Reaching the bs=64 regime needs either:
- Per-worker traversal interleaving (worker advances
worker_chunk_sizetraversals concurrently, suspending at each policy call — Option B shape), which requires turning Cython traversal recursion into a resumable state machine. Same scope as a partial Option C, localized to worker scope. - Option C proper (single-process vectorized traversal).
Both require restructuring traversal. Option A's "additive, no traversal changes" property turned out to also mean "cannot drive the batch size up."
Why deferring A (not deleting) is the right call
- The plumbing (server process, shared-memory client, weight sync, config flag) is complete and tested. Re-enabling is a config flip.
- The fundamental issue at this model size is that GPU forward time is too small to amortize IPC overhead at any realistic batch size we can drive without restructuring traversal. Bigger model changes that arithmetic; the same plumbing then becomes useful.
- The
mem_add_secondsrow showed a real 1.89× win, suggesting the shared-memory replay-write path adopted along the way is worth keeping even withlocalbackend. (Confirm separately; this is a side effect.)
Re-enable A when one of these holds
- Model grows to ~1024 hidden / ~6 layers (compile/TRT discussion above). Forward time scales with FLOPs while IPC overhead is fixed; at some point IPC becomes a small fraction.
- Per-worker interleaved traversal ships (Option B-shape refactor). Drives realized batch toward 64 and reclaims the profile table's gains.
- Eval becomes the dominant phase (
eval_every: 5,evaluation.games: 1000). Eval is not sync-blocking traversal; it is already batch_size=64 in eval code. The same inference server can serve eval directly without the worker-side ceiling.
Free-threaded Python (3.13t) note
Free-threaded Python + Cython nogil would let many threads (well above
core count) run game logic concurrently in one process, with shared memory
and no IPC. With ~64 threads sync-blocking on policy calls, the server
would naturally see bs=64. In principle this is the cleanest endpoint.
In practice as of early 2026:
- Free-threaded Python is an opt-in build (
python3.13t), still experimental, with measurable single-thread overhead. - PyTorch's free-threaded compatibility is partial.
- Cython
nogil-cleanliness audit on the existing game engine is still required and was the original reasonnogilthreading was deferred in the design decision above. - No project-level adoption pressure on
python3.13ttoday.
So free-threaded Python does change the architectural answer, but it does
not unblock A now. Track the ecosystem; revisit when (a) python3.13t
becomes mainstream or (b) the Cython engine is nogil-cleaned for other
reasons.