diff --git a/docs/plans/librarian.md b/docs/plans/librarian.md index fa5effe..7cc24df 100644 --- a/docs/plans/librarian.md +++ b/docs/plans/librarian.md @@ -235,6 +235,33 @@ non-promotable), or `.ERROR.txt` (CLI failure). caps the number of archives processed per run, useful for smoke tests or cost control. +### Smoke test results (2026-05-08, gemini, --max 3) + +3 drafts, 0 skips, 0 errors. Spot-check verified: + +- All cited file paths exist; line numbers and function names + resolve to within 1–2 lines of the actual symbols + (`game.pyx:217` `cdef class GameState`, `evaluate.py:220` + batched-entropy block, `trainer.py:892` `_evaluate_parallel`, + etc.). +- Numbers cross-checked against archives match (61.83/38.92/14.83 + eval seconds; 4.05/2.88 advantage train seconds). +- Conclusions preserved. +- One systemic weakness: gemini did not run `git` to resolve HEAD, + leaving `commit ` placeholder, literal `commit + \`HEAD\``, or omitting the commit field. Fixed in two places: + the three drafts were patched manually before acceptance, and + both `librarian_promote.py` and `librarian_survey.py` now + post-process the LLM's output to rewrite the `**Last verified:**` + line with the real short SHA from `git rev-parse --short HEAD` + before writing to disk. Future runs converge to the right header + deterministically. + +All three drafts accepted into `docs/research/`: +`classic-port-notes.md`, `deep-cfr-batched-evaluation.md`, +`deep-cfr-evaluation-profile.md`. 12 archive entries remain +unprocessed for the next survey run. + ## Stage 2 remaining - MEMORY.md drift fixup mode (read drift report, propose one-line diff --git a/docs/research/classic-port-notes.md b/docs/research/classic-port-notes.md new file mode 100644 index 0000000..11ac850 --- /dev/null +++ b/docs/research/classic-port-notes.md @@ -0,0 +1,31 @@ +# Classic Game Port Architecture + +**Last verified:** 2026-05-08, commit `b0b3855` +**Source:** docs/archive/classic-port-notes.md + +The "classic" game port serves as the fundamental layer of the `coolrl-lost-cities` project, representing a focused extraction of the Lost Cities game from the legacy `coolrl` repository. This architectural foundation was established to isolate the core mechanics of the two-player card game from the experimental "tiers" (simplified variants tier0 through tier3) used in earlier research. By centering the repository on the full classic ruleset, the project provides a stable, high-performance API that serves both as a playable game and a rigorous training environment. + +## Question: Architectural Scope and Design +How is the Lost Cities "classic" game structured and scoped to serve as the foundation of the repository while remaining decoupled from specific training algorithms? + +## Code Reference +- `src/coolrl_lost_cities/games/classic/game.pyx`: The core high-performance game logic and state management. +- `src/coolrl_lost_cities/games/classic/env.py`: The standard environment wrapper for algorithmic interaction. +- `src/coolrl_lost_cities/games/classic/pygame_pvp.py`: The graphical interface for human-to-human play and debugging. + +## Analysis and Derivation +The transition from the legacy `coolrl` codebase to this standalone repository involved a deliberate narrowing of scope. The primary design goal was to treat the classic game as the initial concrete implementation under `coolrl_lost_cities.games`, avoiding the complexity of a variant registry or broad abstraction layers before they were strictly necessary. + +A key technical decision was the use of an in-process Cython implementation for the game state. By defining `cdef class GameState` (see `src/coolrl_lost_cities/games/classic/game.pyx:217`), the project achieves C-level performance for state transitions, legal action masking, and scoring. This efficiency is critical for compute-intensive search algorithms like Deep Counterfactual Regret Minimization (Deep CFR), where the overhead of pure Python state management would be prohibitive. + +The architecture strictly separates the game rules from the training infrastructure. While the classic game provides the necessary hooks for reinforcement learning—such as observation vectors and reward signals—it does not depend on any specific learning library. This separation ensures that the game logic remains verifiable and readable, centered on the rules and state transitions rather than the requirements of a particular neural network architecture. + +Furthermore, the "classic" designation is specifically applied to the five-expedition version of the game. This naming convention leaves room for future variants, such as six-expedition versions, without requiring a breaking change to the core package structure. The removal of separate native backends in favor of a single, highly-optimized Cython backend simplifies the build process and ensures consistency between local play and large-scale training runs. + +## Practical Implication +The resulting package structure allows developers to interact with the game through multiple interfaces: a raw Cython API for high-performance search, a Gym-like environment for reinforcement learning, and a Pygame-based GUI for manual verification. This modularity means that improvements to the game logic (e.g., scoring optimizations in `score_expedition` at `src/coolrl_lost_cities/games/classic/game.pyx:188`) automatically benefit all downstream consumers, from the training loops to the interactive bots. + +## References +- `src/coolrl_lost_cities/games/classic/game.pyx`: Core logic, state cloning, and legal action generation. +- `src/coolrl_lost_cities/games/classic/env.py`: Environment state management and step logic. +- `docs/archive/classic-port-notes.md`: Original design notes regarding the extraction and scoping. \ No newline at end of file diff --git a/docs/research/deep-cfr-batched-evaluation.md b/docs/research/deep-cfr-batched-evaluation.md new file mode 100644 index 0000000..0e690d4 --- /dev/null +++ b/docs/research/deep-cfr-batched-evaluation.md @@ -0,0 +1,33 @@ +# Batched and Parallel Evaluation in Deep CFR + +**Last verified:** 2026-05-08, commit `b0b3855` +**Source:** docs/archive/deep-cfr-batched-evaluation-2026-05-07.md + +## Question + +Evaluation of Deep CFR strategies against heuristic opponents can be a major bottleneck during training, especially when using CUDA for network inference. How can batched inference and parallel execution be leveraged to minimize this cost without introducing synchronization overhead? + +## Code reference + +- `src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py`, `StrategyNetPolicy.select_actions_batch` (line 174): Implements batched policy network inference, allowing multiple games to share a single GPU forward pass. +- `src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py` (line 220): Performs batched entropy calculation directly on the GPU using Torch tensors. +- `src/coolrl_lost_cities/games/classic/deep_cfr/trainer.py`, `DeepCFRTrainer._evaluate_parallel` (line 891): Orchestrates parallel evaluation across different opponents using `ProcessPoolExecutor`. + +## Analysis + +The primary bottleneck in CUDA-based evaluation is the overhead of launching small GPU kernels for single-state network inference. By batching evaluation games, we can saturate the GPU's compute units more effectively. Empirical results from May 2026 show that increasing the evaluation batch size to 64 reduced evaluation time from approximately 61.8 seconds to 14.8 seconds per iteration. + +A critical refinement in the batched implementation was the handling of policy entropy. Initial versions that calculated entropy per-row on the CPU incurred significant synchronization penalties because each row required a GPU-to-CPU transfer. Moving the entropy calculation into the Torch post-processing pipeline—specifically calculating it directly on the `probs_tensor` (line 220)—ensures that the computation remains on the device and only the final results are transferred back to the host in bulk. + +Once network inference is batched, the remaining bottleneck often shifts to the CPU-bound logic of heuristic opponents (e.g., `safe_heuristic_strict`). Parallelizing the evaluation across multiple workers allows the trainer to evaluate against multiple opponents simultaneously. For a single iteration profile, using 4 parallel workers reduced wall-clock evaluation time from 14.8 seconds to 6.4 seconds, achieving a ~2.3x speedup. + +## Practical implication + +- **Enable Batching:** For GPU-accelerated training, always configure `evaluation.batch_size` (typically 64 or 128) to minimize kernel launch overhead and maximize throughput. +- **Consolidate Device Operations:** Keep post-inference operations (legal masking, softmax, entropy) in Torch tensors to avoid blocking the GPU with frequent host-device synchronizations. +- **Parallelize Opponents:** Set `evaluation.num_workers` to match the number of opponents being evaluated (up to the available CPU cores). This effectively hides the latency of slower heuristic bots behind the network inference of others. + +## References + +- `docs/archive/deep-cfr-batched-evaluation-2026-05-07.md` +- `docs/performance.md` \ No newline at end of file diff --git a/docs/research/deep-cfr-evaluation-profile.md b/docs/research/deep-cfr-evaluation-profile.md new file mode 100644 index 0000000..b11ab50 --- /dev/null +++ b/docs/research/deep-cfr-evaluation-profile.md @@ -0,0 +1,52 @@ +# Deep CFR Evaluation Performance + +**Last verified:** 2026-05-08, commit `b0b3855` +**Source:** `docs/archive/deep-cfr-evaluation-profile-2026-05-07.md` + +## Question + +Why is CUDA evaluation significantly slower than CPU evaluation in the current Deep CFR implementation, and how can evaluation throughput be improved? + +Short answer: **Model forward latency (batch size 1) dominates evaluation wall-clock.** For the current small-model architecture, CUDA kernel launch and synchronization overhead outweighs its parallel processing advantage. Evaluation currently executes serial games with single-sample policy requests, making CPU the faster device by a factor of ~1.6x. + +## Code reference + +`src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py`, function `action_distribution` (around line 250): + +```python +with torch.inference_mode(): + x = torch.as_tensor(info, dtype=torch.float32, device=self.device).unsqueeze(0) + logits = self.strategy_network(x).squeeze(0).detach().cpu().numpy() +``` + +This single-sample forward pass is the tightest loop in evaluation. In a typical 100-game evaluation run against a variety of opponents, this function is called hundreds of thousands of times (e.g., ~236k calls in the 2026-05-07 profile run). + +## Performance Analysis + +Profiling data reveals a sharp divide between training and evaluation efficiency when using CUDA: + +| Phase | CPU Time | CUDA Time | Speedup (CUDA) | +| :--- | :--- | :--- | :--- | +| **Advantage Train** | 4.05s | 2.88s | 1.40x | +| **Strategy Train** | 1.64s | 1.37s | 1.20x | +| **Evaluation** | 38.92s | 61.83s | **0.63x (Slower)** | + +The discrepancy arises because training uses large batches (e.g., `batch_size: 512`), which allows the GPU to saturate and amortizes kernel launch overhead. Evaluation, however, steps through games one action at a time. + +On CPU, the `network / turn` cost is approximately **0.074 ms**. On CUDA, this rises to **0.162 ms**. This 2x increase in per-turn latency is typical for small MLP models on CUDA, where the compute time is shorter than the host-to-device synchronization and kernel scheduling latency. + +### Secondary Bottlenecks +- **Post-processing:** Moving tensors back to CPU (`.cpu().numpy()`) and calculating entropy adds measurable overhead on CUDA that is largely absent on CPU. +- **Opponent Logic:** Heuristic opponents (e.g., `safe_heuristic`) contribute significant `opponent_act_seconds` (up to 3.5s per eval iteration). Since this logic is pure Python/Cython and runs on the CPU, it does not benefit from GPU acceleration, further diluting any potential CUDA wins. + +## Practical Implications + +- **Device Choice:** For the current serial evaluation implementation, always use `--device cpu` for evaluation. If training on CUDA, transferring weights to a CPU-based evaluation worker is significantly more efficient than evaluating on the GPU. +- **Batched Evaluation:** To make CUDA evaluation viable, the implementation must be refactored to use `select_actions_batch` across multiple concurrent games. This would move the evaluation pattern closer to the training pattern, allowing the GPU to process multiple info-states in a single kernel launch. +- **Model Scaling:** As the strategy network size increases (e.g., larger hidden layers or more blocks), the relative overhead of CUDA will decrease. At a certain model scale, the compute advantage will eventually overcome the latency penalty even at batch size 1. + +## References + +- `docs/archive/deep-cfr-evaluation-profile-2026-05-07.md` (Profiling source) +- `docs/performance.md` (Top-level performance log) +- `src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py` (Implementation) \ No newline at end of file diff --git a/scripts/librarian_promote.py b/scripts/librarian_promote.py index 094738a..d3196be 100644 --- a/scripts/librarian_promote.py +++ b/scripts/librarian_promote.py @@ -24,7 +24,6 @@ from __future__ import annotations import argparse import os import re -import shutil import subprocess import sys from datetime import datetime @@ -37,6 +36,7 @@ LLM_COMMANDS = { } DATE_SUFFIX = re.compile(r"-\d{4}-\d{2}-\d{2}$") +LAST_VERIFIED = re.compile(r"^\*\*Last verified:\*\*[^\n]*$", re.MULTILINE) def _repo_root() -> Path: @@ -47,6 +47,34 @@ def _repo_root() -> Path: raise RuntimeError("pyproject.toml을 찾을 수 없어 repository root를 판정할 수 없습니다.") +def _current_commit_sha(root: Path) -> str: + """Resolve HEAD to a short SHA. Returns 'unknown' if git is unavailable.""" + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + check=False, + cwd=root, + ) + if result.returncode != 0: + return "unknown" + return result.stdout.strip() or "unknown" + + +def _post_process_draft(content: str, commit_sha: str) -> str: + """Normalize a draft before writing to disk. + + The LLM is unreliable about running `git` itself in headless mode; it + tends to leave `` placeholders or literal `HEAD` strings. + Rewrite the `**Last verified:**` line in place with today's date and + the real short SHA. If the line is missing entirely, leave the draft + untouched so the omission stays visible during review. + """ + today = datetime.now().strftime("%Y-%m-%d") + new_line = f"**Last verified:** {today}, commit `{commit_sha}`" + return LAST_VERIFIED.sub(new_line, content, count=1) + + def _assemble_prompt( system_prompt: str, rel_archive: Path, @@ -167,7 +195,9 @@ def main() -> int: print(result.stderr, file=sys.stderr) return result.returncode - draft_path.write_text(result.stdout, encoding="utf-8") + commit_sha = _current_commit_sha(root) + draft_text = _post_process_draft(result.stdout, commit_sha) + draft_path.write_text(draft_text, encoding="utf-8") print(f"Draft written to: {draft_path.relative_to(root)}") if args.accept: @@ -177,7 +207,7 @@ def main() -> int: file=sys.stderr, ) return 1 - shutil.copyfile(draft_path, target) + target.write_text(draft_text, encoding="utf-8") print(f"Copied to: {rel_target}") print("Next: review the diff and `git add` + commit if you're satisfied.") else: diff --git a/scripts/librarian_survey.py b/scripts/librarian_survey.py index 5e16c86..17836b0 100644 --- a/scripts/librarian_survey.py +++ b/scripts/librarian_survey.py @@ -40,6 +40,7 @@ LLM_COMMANDS = { } DATE_SUFFIX = re.compile(r"-\d{4}-\d{2}-\d{2}$") +LAST_VERIFIED = re.compile(r"^\*\*Last verified:\*\*[^\n]*$", re.MULTILINE) def _repo_root() -> Path: @@ -78,6 +79,34 @@ def _assemble_prompt( ) +def _current_commit_sha(root: Path) -> str: + """Resolve HEAD to a short SHA. Returns 'unknown' if git is unavailable.""" + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + check=False, + cwd=root, + ) + if result.returncode != 0: + return "unknown" + return result.stdout.strip() or "unknown" + + +def _post_process_draft(content: str, commit_sha: str) -> str: + """Normalize a draft before writing to disk. + + The LLM is unreliable about running `git` itself in headless mode; it + tends to leave `` placeholders or literal `HEAD` strings. + Rewrite the `**Last verified:**` line in place with today's date and + the real short SHA. If the line is missing entirely, leave the draft + untouched so the omission stays visible during review. + """ + today = datetime.now().strftime("%Y-%m-%d") + new_line = f"**Last verified:** {today}, commit `{commit_sha}`" + return LAST_VERIFIED.sub(new_line, content, count=1) + + def _has_counterpart(archive_stem_no_date: str, research_stems: set[str]) -> bool: """True if a research note already covers this archive entry. @@ -150,6 +179,7 @@ def main() -> int: return 1 system_prompt = (root / "scripts" / "librarian-prompt.md").read_text(encoding="utf-8") + commit_sha = _current_commit_sha(root) timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S") out_dir = root / "runs" / "tmp" / f"librarian-survey-{timestamp}" @@ -186,9 +216,10 @@ def main() -> int: skips += 1 print(f" SKIP ({output[:80]})", file=sys.stderr) else: - (out_dir / f"{stem_out}.md").write_text(output, encoding="utf-8") + normalized = _post_process_draft(output, commit_sha) + (out_dir / f"{stem_out}.md").write_text(normalized, encoding="utf-8") drafts += 1 - print(f" draft ({len(output)} chars)", file=sys.stderr) + print(f" draft ({len(normalized)} chars)", file=sys.stderr) print() print(f"Survey complete: {drafts} drafts, {skips} skips, {errors} errors.")