Document Option A bench result, post-A calculus, plans, and cost reports

performance.md additions:
- Batched Traversal Inference design decision (A vs B vs C with
  rationale).
- Option A bench result and structural ceiling (realized batch ~7.2,
  IPC overhead exceeds GPU gain at small model size).
- Post-A optimization calculus: why compile/TensorRT remain
  iter-neutral today and become meaningful only after model growth
  and/or denser eval. Sequencing matters; do not retest these on the
  current small model.
- Free-threaded Python (3.13t/3.14t) note: cleanest endpoint in
  principle, but PyTorch maturity + Cython nogil audit cost block
  near-term adoption.

docs/plans/ (4 plan documents for Codex execution):
- batched_traversal_inference_server.md (executed; deferred).
- amp_trainer.md.
- torch_compile.md.
- cython_safe_heuristic_bots.md (executed; first-pass landed).

docs/reports/ (3 cost reports):
- cost_pytorch_free_threaded_2026-05-07.md: WAIT 3-6 months;
  PyTorch wheels exist but our Cython is the gating cost.
- cost_cython_nogil_audit_2026-05-07.md: medium effort, traversal.pyx
  carries 90% of blockers; Steps 1-3 (cfr_math/encoding nogil
  keywords, TraversalStats cdef class) are safe and cheap, Steps
  4-6 wait for triggers.
- cost_pytorch_cuda_multithread_2026-05-07.md: risky;
  optimizer.step / load_state_dict race silently with concurrent
  forward; per-thread default streams unset means naive threading
  serializes on default stream anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:05:38 +09:00
co-authored by Claude Opus 4.7
parent a7ab94e096
commit befe29fc57
8 changed files with 2267 additions and 0 deletions
@@ -0,0 +1,444 @@
# Cython `nogil`-cleanliness Audit
Date: 2026-05-07
Scope: `game.pyx`, `traversal.pyx`, `encoding.pyx`, `cfr_math.pyx` (+ matching `.pxd`)
Trigger: free-threaded Python (3.13t) + threaded traversal as an alternative
to multiprocessing for Deep CFR. See
`docs/performance.md` "Option A Bench Result and Structural Ceiling" and
"Free-threaded Python (3.13t) note".
---
## 한 줄 결론
**Medium effort.** 게임 엔진(`game.pyx`)과 보조 산술(`cfr_math.pyx`,
`encoding.pyx` core encode 함수)은 이미 거의 nogil-clean이다. 진짜
blocker는 한 곳에 모여 있다: **`traversal.pyx``_traverse` 재귀 본체가
PyTorch forward, NumPy 배열 할당, Python `TraversalStats`/`TrainingSample`
객체 mutation, f-string, Python list/dict bucket 누적**을 모두 직접
한다는 것. 이걸 두 단계로 분리(순수 C 시뮬레이션 + Python에서 후처리)하지
않으면 `with nogil:` 블록을 의미 있게 키울 수 없다. 비용은 traversal 한
파일의 mid-scale 리팩터(추정 1–2주, 회귀 위험 큼) + 작은 게임 엔진 정리
(12일)이다.
---
## 파일별 현황
### 1. `game.pyx` / `game.pxd` — **거의 nogil-clean (small effort)**
게임 상태 데이터는 모두 raw `int*` (C 힙)에 살고, 핵심 동작(`_apply_*_c`,
`_undo_*_c`, `_legal_actions_c`, `_can_play_encoded_card_c`,
`_score_from_summary_c`, `_recompute_score_caches`,
`_swap_deck_cards_c`, `_push_action_c`, `_pop_action_c`)이 전부
`cdef ... noexcept`/`except *` 시그니처에 C 산술만 한다.
이미 nogil-callable 후보 (시그니처에 `nogil` 키워드만 추가하면 되는 것):
- `_is_legal_action_c` (game.pyx:869)
- `_legal_actions_c` (game.pyx:896)
- `_unified_legal_actions_c` (game.pyx:924)
- `_can_play_encoded_card_c` (game.pyx:953)
- `_fill_undo_c` (game.pyx:964)
- `_score_from_summary_c` (game.pyx:1267)
- `_has_any_legal_draw` (game.pyx:1281)
- `_hand_index` / `_expedition_len_index` / `_expedition_index` /
`_discard_index` / `_encode_card` / `_card_color` / `_card_rank`
(game.pyx:12931312)
- `_recompute_score_caches` (game.pyx:1229)
부분 GIL 필요 (작은 수정으로 nogil 가능):
- `_apply_action_unchecked_c` / `_apply_card_action` / `_apply_draw_action`
(game.pyx:1008, 1099, 1140) — 본문은 순수 C이지만 `except *`
exception propagation을 위해 GIL이 필요. nogil 컨텍스트에서 호출하려면
`noexcept` 또는 명시적 `nogil` + `with gil` 예외 블록이 필요. 본문에는
실제로 raise할 곳이 없으므로 시그니처를 `noexcept`로 바꾸는 게 가장 싸다.
단, `_apply_card_action`/`_apply_draw_action`은 invariant를 깨는 입력이
들어와도 silently 진행하게 되므로 호출 전 검증을 강화해야 한다.
- `_apply_action_with_undo_c`, `_push_action_c`, `_pop_action_c`,
`_swap_deck_cards_c`, `_ensure_undo_capacity_c` (game.pyx:10041055) —
`_ensure_undo_capacity_c``realloc` 실패 시 `MemoryError`를 raise.
`with gil:` 짧은 블록으로 분리하거나, traversal 진입 시
capacity를 미리 키워두면 nogil-clean하게 만들 수 있다.
- `_undo_*_c` (game.pyx:1161, 1169, 1203) — `raise ValueError("undo
... mismatch")` 가드가 들어 있음. Production 경로에서는 fire되지 않으므로
guard를 `assert` 또는 디버그 빌드 한정으로 빼면 nogil 가능.
완전 GIL 함수 (nogil 변환 비대상; 호출자가 GIL 가진 채로 부른다):
- `__init__`, `_configure`, `from_snapshot`, `to_snapshot`, `validate_invariants`
(Python config object/dict touch, dataclass, `Counter`, yaml, etc.)
- 모든 property: `phase`, `deck`, `hands`, `expeditions`, `discards` —
Python list-of-Card 생성. 전부 reporting/serialization용이라 hot path
아님.
- `clone()` (game.pyx:550) — `GameState(self.config)` 생성자 호출이
Python object instantiation. nogil 안에서 부르려면 별도 `cdef
GameState _clone_into(self, GameState dst) nogil` 같은 C-only fast clone을
추가해야 한다 (전부 `memcpy`이므로 trivial하지만 새 entry point 필요).
- `to_unified_action`, `hand_slots`, `sort_hand` 등 Python 인터페이스 —
hot path 아님.
요약: game.pyx는 **시그니처 정리 + 작은 helper 추가**로 hot path를 통째로
`nogil` 안에 넣을 수 있다. 게임 엔진 자체는 큰 비용이 아니다.
### 2. `cfr_math.pyx` / `cfr_math.pxd` — **이미 nogil-clean (zero effort)**
`regret_matching_c`, `normalize_legal_policy_c`, `sample_policy_c` 모두
`noexcept` + 순수 C 산술. `nogil` 키워드만 시그니처에 추가하면 끝.
(file:189). 파이썬 wrapper 3개(`regret_matching` 등, file:92146)는
NumPy 인터페이스라 GIL 필요하지만 hot path 아님 — traversal은 이미 C
함수 직접 호출 (traversal.pyx:11, `from ... cimport regret_matching_c`).
### 3. `encoding.pyx` / `encoding.pxd` — **거의 nogil-clean (small effort)**
C-only encoders (전부 `noexcept`/`except -1`, raw float buffer 출력):
- `_base_input_dim_c`, `input_dim_c`, `_input_dim_with_flags_c`,
`_numeric_value_c`, `_max_numeric_sum_c`, `_max_score_estimate_c`
(encoding.pyx:1257)
- `_color_playability_summary_c` (encoding.pyx:60) — 본문은 순수 C
(state의 C 필드 참조 + `abs()`), nogil 가능.
- `_append_derived_playability_features_c`,
`_append_slot_aware_playability_features_c` (encoding.pyx:156, 209) —
본문은 순수 C 산술. nogil 가능.
- `encode_info_state_c`, `_encode_info_state_with_flags_c`
(encoding.pyx:287413) — `except -1`로 Python `ValueError`를 raise할 수
있는 두 곳(file:319, 321)이 있지만 둘 다 정적 sanity 체크
(`player < 0`, `action_size > 64`). 호출 전에 검증되면 제거해도 안전.
`abs(state.expedition_penalty)` (encoding.pyx:53, 95): Cython이 `int`에
대해 C `abs`로 lower하므로 nogil-safe. `bool(encoding.derived_playability)`
(file:420, 432) 같은 건 Python wrapper에서만 호출되므로 무관.
요약: `_encode_info_state_with_flags_c`를 `noexcept nogil`로 바꾸고
input validation을 호출자로 옮기면 nogil-clean. 변환 매우 쉬움.
### 4. `traversal.pyx` / `traversal.pxd` — ****진짜 blocker가 모두 여기 있다 (medium-large effort)****
이미 nogil-callable인 helper들:
- `_next_u32`, `_next_double` (traversal.pyx:24, 29) — `noexcept`,
raw uint32 LCG. 사실상 nogil이지만 키워드 빠짐.
- `_sample_policy_from_actions_c` (traversal.pyx:33) — `noexcept`,
raw pointer.
- `_sampling_policy` (traversal.pyx:582) — `noexcept`, raw pointer.
- `_from_unified_action_c`, `_to_unified_action_c` (traversal.pyx:992, 997)
- `_opened_color_count` (traversal.pyx:766)
- `_self_play_bucket` (traversal.pyx:774) — `noexcept`이지만 `len(self.
league_advantage_networks)`를 본다 → Python list `__len__` (PyObject_Size).
이건 GIL 필요. 단순한 fix: 별도 `cdef int _league_size`를 캐싱.
- `_depth_bucket_start` (traversal.pyx:58)
- `random_rollout_value_c` (traversal.pyx:1092) — 본문은 순수 C이지만
`_push_action_c`, `_pop_action_c`, `_legal_actions_c`가 nogil이 되면
자동으로 nogil-callable. raise 두 줄(file:1106, 1108)을 호출 전 검증으로
옮기면 끝.
반면 hot path인 `_traverse` (traversal.pyx:259) 본문에는 다음과 같은
Python-object touch가 깔려 있다 (per-node, per-iteration):
1. **PyTorch forward 호출** — `_policy_from_networks` (file:439) /
`_policy_from_strategy_network` (file:519). NumPy `np.empty`,
`torch.as_tensor`, `networks[player](x)`, `.detach().cpu().numpy().
astype(np.float32)`. 이게 모든 `_policy` 호출(노드당 1회)에서 일어남.
2. **`stats` mutation** — 모든 카운터 증가가 Python attr 접근:
`stats.nodes += 1`, `stats.terminals += 1`, `stats.max_depth_reached`,
`stats.regret_fallback_*` 등 (file:289, 290, 298, 302, 386, 695752).
3. **f-string + dict bucket** — `_record_endpoint` (file:980), `_record_
fallback_depth_bucket` (file:753): `f"{start}_{start + width - 1}"`,
`stats.endpoint_depth_buckets[key] = ... .get(key, 0) + 1`. Python
string format + dict lookup.
4. **NumPy 배열 할당 per leaf** — `_record_strategy` (file:877), `_record_
advantage` (file:914), `_record_external_advantage` (file:948): `np.empty(
self.action_size, dtype=np.float32)`, `.append(TrainingSample(...))`.
Sample마다 두 개의 작은 NumPy array + dataclass 인스턴스화.
5. **Python list `.append`** — `self.advantage_samples.append(...)`,
`self.strategy_samples.append(...)` (file:903, 937, 969). list의
PyObject reference 갱신은 free-threaded Python에서도 atomic refcount
비용을 추가로 부담한다.
6. **`SafeHeuristicBot.act(state)`** — `_fixed_opponent_action`
(file:633, 652), `_rollout_value` (file:841). Python class
메서드 호출. `safe_heuristic` 옵션 사용 시만 핫.
7. **`league_advantage_networks` indexing** — `_self_play_snapshot_
networks` (file:802), `[-recent_count:]`, `[:max(0, ...)]` slicing
= Python list slicing.
8. **`f"invalid ..."` raises** — game state 검증 실패 시.
`_traverse`는 game state mutation(전부 C struct 통한
`_push_action_c`/`_pop_action_c`)과 위 Python object 작업을 한 함수에서
교차해서 한다. 즉 `with nogil:`로 감쌀 수 있는 자연스러운 chunk가
없다 — recursion 한 단계 안에서 GIL을 ~6번 release/re-acquire해야
하는데, 그 비용이 forward latency보다 크다.
---
## 주요 blocker 카탈로그
### B1. PyTorch forward 호출 (가장 큰 단일 blocker)
```python
# traversal.pyx:473-475
with torch.inference_mode():
x = torch.as_tensor(info_state, dtype=torch.float32, device=self.device).unsqueeze(0)
advantages = networks[player](x).squeeze(0).detach().cpu().numpy().astype(np.float32)
```
- 빈도: 노드당 1회 (~205k/iter, performance.md 참조).
- 변환 난이도: **High (구조 변경 필수)**. 핵심 통찰은: **이걸 nogil 만들
필요 없다.** PyTorch CUDA 호출 자체가 internally GIL을 잠깐 잡지만
`inference_mode` + CUDA dispatch는 잘 알려진 GIL-friendly 영역이다.
진짜 문제는 *traversal recursion이 forward 호출에서 sync-block*해서
배치가 안 모이는 것 (performance.md "Option B-shape refactor"). nogil로
단일 thread를 빠르게 만들기보다 **traversal을 resumable state machine
으로 깨고 N개 thread를 띄워 동시에 sync-block시키면**, free-threaded
Python 하에서 batch=N forward로 자연 합쳐진다. 즉 nogil-cleaning은
Option B/C와 같은 작업의 일부이지 독립 작업이 아니다.
- 권고: 이 blocker는 nogil audit 단독으로 고치지 말고, "traversal을
state-machine으로 해체" 작업 안에 묶는다.
### B2. Python `TraversalStats` attribute mutation (전 노드 핫)
```python
# traversal.pyx:289-294
stats.nodes += 1
if depth > stats.max_depth_reached:
stats.max_depth_reached = depth
if self.has_max_nodes and stats.nodes >= self.max_nodes:
stats.node_limit_cutoffs += 1
```
- 빈도: 매 노드. 합쳐서 노드당 515회 attr access.
- 변환 난이도: **LowMedium**. `TraversalStats`를 `cdef class`로 바꾸고
필드를 `cdef public long long`로 선언하면 attr access가 C struct field
store가 된다. 단, `stats.regret_fallback_depth_buckets` 같은 dict
필드는 별도로 처리(아래 B3).
- 위치: traversal.pyx:289, 290, 293, 298, 302, 331, 386, 695752, 853, 856,
912, 946, 978, 985990 + `_record_*` 전체.
### B3. dict bucket + f-string key (depth/color buckets)
```python
# traversal.pyx:986-990, 753-764, 702-705, 723-725, 742-744
key = f"{start}_{start + width - 1}"
stats.endpoint_depth_buckets[key] = stats.endpoint_depth_buckets.get(key, 0) + 1
```
- 빈도: 매 leaf/cutoff/regret-fallback 노드.
- 변환 난이도: **Medium**. dict + str key + format은 nogil 불가. 해법:
- bucket 인덱스로 미리 정해진 정수 array를 쓴다 (`endpoint_depth_bucket_max
/ endpoint_depth_bucket_width + 1` slot의 `cdef long[:]` 또는 raw
int64 array). string key는 마지막 reporting 단계에서만 생성.
- color/opened_color bucket도 모두 56개 정해진 슬롯이므로 `cdef
long[5]`로 충분.
### B4. NumPy 배열 + dataclass 인스턴스 per training sample
```python
# traversal.pyx:896-911, 926-945, 959-977
target = np.empty(self.action_size, dtype=np.float32)
legal_mask = np.empty(self.action_size, dtype=np.bool_)
...
self.advantage_samples.append(TrainingSample(info_state=..., target=..., ...))
```
- 빈도: leaf마다 1개 advantage sample + 노드별 strategy sample
(interval-gated).
- 변환 난이도: **Medium-High**. 두 가지 옵션:
- **(a) Buffer pre-allocate**: traverser가 큰 `cdef float[:, ::1]
advantage_targets`, `cdef uint8[:, ::1] advantage_legal`,
`cdef long[:] advantage_iteration` 등을 미리 잡아두고 row index만 늘린다.
drain 시점에 `TrainingSample` Python 객체로 wrap. **추천**.
- **(b)** PyObject 그대로 두고 `with gil:` 짧게 — sample 누적이
노드당 ~1회라 IPC overhead 분석 그대로 적용된다 (작은 hold라도 thread
contention 발생).
- 추가 고려: `info_state`(`np.empty(input_dim, dtype=np.float32)`)도 노드당
새 NumPy. buffer-pool 또는 batched encoder로 묶어야 한다.
### B5. PyTorch `state_dict()`-share, league list slicing
```python
# traversal.pyx:802-817
candidates = self.league_advantage_networks[-recent_count:]
...
candidates = self.league_advantage_networks[:max(0, len(self.league_advantage_networks) - recent_count)]
```
- 빈도: traversal 진입 시 한 번 (`traverse`에서 미리 픽), 재귀 안에서는
`active_self_play_networks`만 본다. 따라서 cold path. 변환 불필요.
### B6. `SafeHeuristicBot.act(state)` — Python bot
```python
# traversal.pyx:633, 652, 841
return int(self.safe_heuristic_opponent_bot.act(state))
```
- 빈도: `opponent_policy=safe_heuristic` 또는 `cutoff_rollout_policy=
safe_heuristic`일 때만. 현 default는 self_play_league + score_diff
cutoff (per memory의 opponent_policy_network_divergence note + AGENTS).
- 변환 난이도: **Medium-High** (Python class 전체를 cython화). 현 default
config에서는 핫 아님 — 시도하지 않는 게 합리.
### B7. `len(self.league_advantage_networks)`
```python
# traversal.pyx:782, 783, 803, 806, 811, 814
recent_count = min(len(self.league_advantage_networks), self.self_play_recent_window)
```
- 빈도: `_self_play_bucket`가 traversal 진입에 한 번, `_self_play_snapshot_
networks`가 한 번. 노드당이 아님 → cold path. 무시 가능 (단, 두 함수가
`_traverse` 안에서 직접 불리지 않음을 확인했음, file:244251).
### B8. `_apply_action_unchecked_c` `except *`
게임 엔진 쪽 game.pyx:1008. 본문에 raise 없음 → `noexcept`로 강등하면
`_traverse`의 `state._push_action_c` (game.pyx:1029, `except *`) 호출도
`noexcept`로 만들 수 있다. 단, `_ensure_undo_capacity_c`의 `MemoryError`만
별도 처리 필요.
### B9. Recursion이 그 자체로 `_traverse` (cdef method `except *`)
`_traverse`는 `cdef float ... except *` (file:259). nogil로 만들려면
재귀 호출도 nogil 컨텍스트여야 하고, 모든 파이썬 touch가 제거되어야 한다.
즉 **B1–B4가 전부 해결되기 전엔 `_traverse` 본체를 `nogil`로 못 만든다.**
---
## 작업 단계 (안전한 순서)
1. **단계 0 — 측정 인프라.** Cython annotate (`cython -a`)를 빌드 스크립트에
추가. `.html`에서 노란/빨간 줄 = Python interaction. 반복적으로 본다.
2. **단계 1 — 무비용 청소 (12일):**
- `cfr_math.pyx`의 3개 C 함수에 `nogil` 키워드 추가.
- `encoding.pyx`의 `_encode_info_state_with_flags_c`와 모든 helper의
검증을 호출자로 옮기고 `noexcept nogil`로.
- `game.pyx`의 `_legal_actions_c`, `_unified_legal_actions_c`,
`_can_play_encoded_card_c`, `_score_from_summary_c`,
`_has_any_legal_draw`, 모든 `_*_index`/`_card_*` 함수에 `nogil` 추가.
- 회귀 테스트: `uv run pytest -q`.
3. **단계 2 — 게임 엔진 mutation을 nogil로 (23일):**
- `_apply_card_action`, `_apply_draw_action`, `_apply_action_unchecked_c`
를 `noexcept`로 강등 (호출 전 legality check가 이미 `_traverse`에서
수행되므로 안전).
- `_undo_*_c`의 `ValueError` mismatch 가드를 debug 빌드 한정 (`IF
DEBUG:` 컴파일 디렉티브 또는 release 시 제거).
- `_ensure_undo_capacity_c`: traversal 진입 시점에 한 번 큰 capacity로
`realloc`해두고, hot path의 `_push_action_c`는 capacity 체크만
(`assert undo_stack_len < undo_stack_capacity` debug only)하게 분리.
- 결과: `_push_action_c`/`_pop_action_c`/`_swap_deck_cards_c` 모두
`nogil`.
4. **단계 3 — TraversalStats를 cdef class로 (35일):**
- 모든 정수 카운터를 `cdef public long long` 필드로.
- depth bucket / color bucket dict들을 fixed-size `cdef long[N]` array로
교체하고 reporting 단계에서만 dict로 변환.
- `_record_endpoint`, `_record_fallback_depth_bucket`,
`_record_regret_matching_decision`을 `noexcept nogil`로 다시 작성.
- 회귀 테스트: `metrics.jsonl`의 모든 키가 동일한 값으로 나오는지 비교.
5. **단계 4 — Sample buffer pre-allocate (35일):**
- traverser에 `cdef float[:, ::1] advantage_targets`,
`cdef uint8[:, ::1] advantage_legal_masks`,
`cdef float[:, ::1] advantage_info_states`,
`cdef long[:] advantage_iterations`, `cdef int[:] advantage_players`
등을 chunk-grow array로. row index만 nogil에서 늘림.
- `drain_samples()`에서만 GIL 잡고 `TrainingSample` 리스트로 wrap.
- 회귀 테스트: trainer가 받는 sample 분포 동일해야 함.
6. **단계 5 — Forward 호출 분리 (large, 다른 작업과 묶음):**
- `_traverse`를 "forward 직전까지" + "forward 결과 받은 후" 두 구간의
resumable state machine으로 재구성. forward 호출은 외부 batcher가
수행. 이게 Option B/C 본체이므로 별도 design doc 필요.
- 그제서야 `_traverse` 자체를 `nogil`로 선언할 의미가 생긴다.
7. **단계 6 — 검증:**
- `cython -a`로 hot path가 모두 흰색인지 시각 확인.
- micro-bench: 단일 thread에서 traversal 시간이 회귀 없는지.
- free-threaded Python (`uv run --python python3.13t ...`) 또는
`nogil`-제어 micro-bench로 N=2/4/8 thread scaling 확인.
---
## 위험
- **Silent slowdown (GIL re-acquisition)**: `with nogil:` 블록 안에서
Python 객체를 무심코 건드리면 Cython이 `with gil:` 블록을 자동 삽입
(또는 `noexcept nogil` 위반 시 컴파일 에러). 작은 attr touch 하나가
re-acquisition 비용을 부르고, 멀티스레드에선 contention으로 single-thread
대비 더 느려질 수 있다. 검증: `cython -a`가 진실의 원천. 모든 hot 경로가
흰색이어야 함. 추가로 `python -X dev`나 `PYTHONDEVMODE=1`로 thread state
체크.
- **Correctness regression on undo path**: 단계 2의 `_undo_*` 가드 제거가
invariant를 silently 위반시킬 수 있음. 검증: `tests/games/classic/test_
deep_cfr_trainer.py` + `validate_invariants()`를 `--set debug=true` 같은
모드에서 매 100노드마다 호출.
- **Sample buffer overflow**: 단계 4의 chunk-grow가 race 없는지
(single-traverser-per-thread 구조 유지) 확인. 두 thread가 같은 traverser
객체를 공유하면 안 됨.
- **`TraversalStats` API 변경**: `metrics.jsonl` 형식 변경 가능성. 단계
3에서 reporting 어댑터를 명시적으로 보존. 기존 dict 형식과 byte-wise
동일한 테스트 추가.
- **Cython `nogil` + cdef class 라이프타임**: `cdef class` 인스턴스의
refcount는 free-threaded Python에서 atomic이지만 deallocation이 nogil
컨텍스트 안에서 트리거되면 안 됨. 모든 cdef object는 함수 시작에 GIL
잡힌 채로 acquire, nogil 블록 안에서는 raw pointer/struct만 접근.
- **CUDA forward thread-safety**: PyTorch는 같은 device 위 동시
forward에 대해 internal lock을 사용한다. N=64 thread가 동시에 forward를
치면 합쳐주지 않으면 lock contention만 늘 수 있다. 단계 6의 batcher가
필수.
---
## 권고
**현 시점에는 단계 1–3까지만 기회 봐서 진행하고, 단계 4 이상은 보류.**
이유:
1. 단계 1–3은 **나중 단계와 무관하게 단일-thread traversal도 살짝 빠르게**
만들고, `cython -a`상의 visible Python interaction을 줄여 다음 작업의
기반이 된다. 비용 작음(~1주), 회귀 위험 낮음(테스트 충분).
2. 단계 4부터는 free-threaded Python이나 Option B/C 같은 호출자 측 변경이
같이 와야 의미가 있다. 현재 `default.yaml`은 single-process local
backend로 잘 돌고 있고(performance.md), 모델 크기·eval 비중·python3.13t
생태계 모두 트리거가 안 와 있음.
3. 단계 5/6는 Option B-shape refactor와 사실상 같은 작업이므로 **별도
설계 문서가 먼저** 필요하다. nogil audit이 그걸 정당화하는 근거는
되지만 단독 추진 사유는 안 된다.
**다시 볼 트리거** (둘 중 하나라도 만족):
- (a) **Free-threaded Python (3.13t)이 mainstream** 으로 가서 PyTorch 공식
지원이 stable이 되고, `uv`가 3.13t를 1차 시민으로 다룬다.
- (b) **Model이 커진다** — hidden=1024 / depth=6 등으로 forward가
단일 호출 ~수백 μs 영역에 들어가서, traversal 한 번에 한 forward를 GIL
잡고 부르는 게 명백히 bottleneck이 된다.
- (c) **Eval 비중이 dominant**해진다 (`eval_every=5`, `evaluation.games=
1000+`). Eval은 이미 batch-friendly이라 thread pool + nogil game engine만
으로도 큰 win.
위 세 가지가 모두 멀어 보일 때(현 상황)는 단계 1–3만 chip away 하고,
설계 측면에서는 Option B-shape (per-worker interleaved traversal) 쪽이
ROI가 더 높다 (performance.md "Re-enable A when one of these holds" 참조).
### 빠른 우선순위 1순위 (지금 당장 1일)
`cython -a` 빌드 옵션 추가 + cfr_math와 encoding hot path에 `nogil`
키워드만 다는 것. 이건 아무것도 안 깨고 tooling 인프라가 생긴다.
다음 nogil 작업할 때 진단 출발점이 됨.
---
## 참조
- `docs/performance.md`:571 (Option A Bench Result)
- `docs/performance.md`:672 (Free-threaded Python note)
- `src/coolrl_lost_cities/games/classic/game.pxd`
- `src/coolrl_lost_cities/games/classic/game.pyx`:550, 869, 924, 953, 1004,
1099, 1140, 1161, 1229, 1281, 1293
- `src/coolrl_lost_cities/games/classic/deep_cfr/traversal.pyx`:259 (`_traverse`),
439 (`_policy_from_networks`), 519 (`_policy_from_strategy_network`),
582 (`_sampling_policy`), 753 (`_record_fallback_depth_bucket`),
877 (`_record_strategy`), 914 (`_record_advantage`),
980 (`_record_endpoint`), 1092 (`random_rollout_value_c`)
- `src/coolrl_lost_cities/games/classic/deep_cfr/encoding.pyx`:287, 291,
416, 425
- `src/coolrl_lost_cities/games/classic/deep_cfr/cfr_math.pyx`:5, 37, 68