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
@@ -0,0 +1,324 @@
# Cost / Risk Report: PyTorch CUDA from Multiple Threads in One Process
작성일: 2026-05-07
대상 질문: Deep CFR 파이프라인을 multiprocessing → threading(또는 free-threaded
Python)으로 옮길 경우, 같은 프로세스 안에서 여러 스레드가 동시에 PyTorch CUDA
연산을 호출하는 것이 얼마나 비싸고 위험한가?
---
## 한 줄 결론
**Risky — separate-module + 적절한 stream/lock 규율이 있으면 "safe with care",
공유 모듈에 대한 동시 forward/backward/load_state_dict 조합은
serialization 없이는 silent wrong outputs 또는 segfault를 일으킬 수 있음.**
지금 코드 형태(공유 advantage/strategy network + trainer가 매 N step마다
weight push) 그대로 threading으로 옮기면 weight-sync 경계에서 데이터 레이스가
거의 확실히 발생한다.
---
## CUDA 멀티스레드 모델 (PyTorch 2.x 기준, 2026 초 시점)
### 1) Current stream은 thread-local
PyTorch C++ 코어(`c10/cuda/CUDAStream.h`)가 명시적으로 보장한다:
> "the notion of 'current stream for device' is thread local (every OS thread
> has a separate current stream, as one might expect)"
> — [pytorch/c10/cuda/CUDAStream.h](https://github.com/pytorch/pytorch/blob/main/c10/cuda/CUDAStream.h)
즉 thread A가 `with torch.cuda.stream(s):` 안에서 띄운 커널은 thread B의
current stream 설정과 독립이다. 하지만 *기본* current stream은 **device의 default
stream**이며, 모든 스레드가 명시적으로 `set_stream`을 하지 않으면 같은 default
stream을 공유한다. 이 경우 GPU 측에서는 직렬화된다(병렬 launch가 안 됨).
### 2) Per-thread default stream(PTDS)는 PyTorch에서 *enable되지 않음*
CUDA driver-level의 `--default-stream per-thread` 컴파일 옵션은 PyTorch
배포 빌드에서 켜져 있지 않다. 관련 트래킹 이슈
[pytorch#25540](https://github.com/pytorch/pytorch/issues/25540) 은 2019년
오픈 이후 미해결 상태로 남아 있고, 결과적으로 "여러 스레드가 default stream을
사용하면 모두 같은 legacy default stream에 직렬화된다"가 현행 동작이다.
([cuda streams run sequentially #59692](https://github.com/pytorch/pytorch/issues/59692),
[default stream is not synchronous #101300](https://github.com/pytorch/pytorch/issues/101300)).
→ **결론: 진짜 GPU 동시성이 필요하면 각 스레드가 명시적으로
`torch.cuda.Stream()`을 만들고 `with torch.cuda.stream(s):` 컨텍스트로
감싸야 한다.** 그렇지 않으면 멀티스레드 = 멀티프로세스 대비 GIL/IPC만
줄어들 뿐 GPU 자체는 직렬화된다.
### 3) Python-level forward/backward 호출의 thread safety
공식 문서/포럼 발언을 종합하면:
- **Tensor 자체는 read-only thread-safe, write는 직렬화 책임이 사용자.**
Edward Yang: "PyTorch underlying C++ library is expected to be thread safe
(although the Tensor object is not thread-safe for multiple writers; you need
to synchronize that yourself)."
([forum #36540](https://discuss.pytorch.org/t/is-pytorch-supposed-to-be-thread-safe/36540))
- **Inference만 한다면 같은 nn.Module 인스턴스를 여러 스레드에서 동시
`forward()` 호출해도 OK** — 단 module state를 mutate하지 않는다는 전제.
([forum #88583](https://discuss.pytorch.org/t/is-inference-thread-safe/88583)).
주의: BatchNorm `train()` 모드, dropout state, lazy module init,
`register_buffer`로 EMA 갱신 같은 건 mutate에 해당.
- **TorchScript/JIT module은 단일 인스턴스를 동시에 forward하면 안 됨**
([pytorch#15210](https://github.com/pytorch/pytorch/issues/15210),
[pytorch#51452](https://github.com/pytorch/pytorch/issues/51452)).
우리는 JIT을 안 쓰지만, `torch.compile`로 래핑된 모듈은 내부 캐시 일관성에서
비슷한 위험을 가질 수 있다
([dev-discuss: compile + multithreading](https://dev-discuss.pytorch.org/t/impact-of-multithreading-and-local-caching-on-torch-compile/2498)).
- **C++ custom 모듈은 여러 스레드에서 동시 호출 시 segfault 보고**
([pytorch#19029](https://github.com/pytorch/pytorch/issues/19029)) — 우리
코드엔 직접 해당하지 않지만, 의존성 라이브러리에 비슷한 게 끼어들 수 있음.
### 4) Backward / autograd
- Autograd 엔진은 **device당 1 스레드**의 worker pool로 backward를 실행한다
([forum #36824](https://discuss.pytorch.org/t/only-1-thread-for-backward/36824),
[autograd notes](https://docs.pytorch.org/docs/stable/notes/autograd.html)).
즉 두 스레드가 *서로 다른* graph에 대해 동시에 `.backward()`를 호출하면
엔진은 receive-side에서 큐잉/locking으로 처리한다 — 코어는 thread-safe.
- 그러나 **두 스레드가 share된 graph 부분을 동시에 backward하면
파괴(graph free) 레이스로 다른 스레드가 crash**한다(같은 forum 답변).
Deep CFR에서 traversal worker는 backward를 안 부르므로 직접 적용은 적지만,
trainer + 동시 inference + replay sample이 동일 텐서를 retain하는 경우
주의해야 한다.
- `torch.no_grad()` / `torch.inference_mode()`는 **thread-local TLS 플래그**다.
스레드별로 따로 켜야 한다. trainer 스레드가 backward 도중에 inference 스레드가
같은 모듈을 forward하더라도 TLS가 분리되므로 모드 자체의 충돌은 없다.
하지만 module state(파라미터)는 공유되므로 아래 weight-update 위험은 그대로다.
### 5) CUDA caching allocator
`CUDACachingAllocator`는 **per-device mutex**로 보호된다
([zdevito guide](https://zdevito.github.io/2022/08/04/cuda-caching-allocator.html),
[CUDACachingAllocator.cpp](https://github.com/pytorch/pytorch/blob/main/c10/cuda/CUDACachingAllocator.cpp)).
`cudaEventCreate` 같은 비싼 호출은 EventPool을 둬서 멀티스레드
allocation rate가 높아도 안전하지만, 락 경합은 존재한다. 작은 모델 + 고빈도
forward 패턴(우리 traversal과 정확히 일치)은 allocator lock contention이
스루풋의 실질적 ceiling이 될 수 있다는 점이 위험 항목에 들어간다.
### 6) Free-threaded Python (3.13t / 3.14t) 상황
- PyTorch는 3.13t 빌드(`cp313t` nightly wheels)를 제공하지만 *partial
support*. 트래킹 이슈 [pytorch#130249](https://github.com/pytorch/pytorch/issues/130249).
- DataLoader가 thread-based로 가서 ImageNet iter +74% 같은 사례가 있지만
([Trent Nelson's notes](https://trent.me/articles/pytorch-and-python-free-threading/)),
**"competing Python threads feeding the same CUDA stream still need explicit
synchronization"** — GIL이 사라져도 stream/모듈 동기화 책임은 동일하다.
- 우리 의존성 중 **Cython 확장(`game.pyx`, `traversal.pyx`, `encoding.pyx`,
`cfr_math.pyx`)**은 `nogil` 정합성 감사가 안 된 상태(performance.md "Why not
Cython nogil + threading" 섹션). free-threaded 빌드에서 굴리는 것은
threading 이전 단계의 별도 risk.
---
## 우리 시나리오 매핑
현재 (multiprocessing 기준) GPU touchpoint 위치:
| 위치 | 파일:라인 | 역할 | 현재 격리 수준 |
| --- | --- | --- | --- |
| Trainer forward+backward+optimizer (advantage) | `trainer.py:981-987` | 매 iteration 학습 | 메인 프로세스, 단일 스레드 |
| Trainer forward+backward+optimizer (strategy) | `trainer.py:1028-1034` | 매 iteration 학습 | 메인 프로세스, 단일 스레드 |
| Trainer device 결정 + eval 모듈 deepcopy | `trainer.py:862-870` | eval용 모듈 복제 | 메인 프로세스 |
| Imitation 학습 step | `imitation.py:87-89` | 옵션 path | 메인 프로세스 |
| Policy gradient 학습 step | `policy_gradient.py:69-71` | 옵션 path | 메인 프로세스 |
| Eval forward (batched, inference_mode) | `evaluate.py:211, 249` | eval phase | 메인 프로세스 |
| Inference server forward (batched, inference_mode) | `inference_server.py:231-249` | option A 워커 inference | **별도 spawn 프로세스** |
| Inference server weight load | `inference_server.py:61-67, 173-192` | trainer→server weight push | **별도 프로세스, 큐 직렬화** |
| Worker network reconstruct (CPU only today) | `workers.py:119, 141` | per-worker state_dict 재생성 | 별도 fork/spawn 프로세스 |
핵심 관찰: **모든 GPU 쓰기 경로(optimizer.step, load_state_dict)는 오늘 단일
프로세스 내에서 단일 스레드에 의해 직렬로 발생**한다. Multiprocessing이
"무료로" 보장해주던 격리다.
### Hypothetical threaded design (worst case에 가까운 단순 변환)
| 스레드 | 호출하는 CUDA 연산 | 모듈 공유? | 위험 |
| --- | --- | --- | --- |
| Trainer thread | advantage/strategy forward+backward+`optimizer.step()` | 본인이 own | weight write |
| N traversal threads | advantage/strategy `forward()` (`inference_mode`) | trainer와 share | read-during-write race |
| Eval thread (옵션) | strategy `forward()`, deepcopy | trainer와 share | deepcopy 중 write |
| Weight-sync (없어짐 — 같은 in-process 객체) | `load_state_dict` (만약 league snapshot용으로 남으면) | share | 같음 |
가장 무서운 조합: **trainer가 `optimizer.step()` 도중**(파라미터 텐서가
in-place로 부분 갱신되는 시점)에 traversal 스레드가 같은 파라미터에 대해
`forward()`를 돌리는 경우. PyTorch는 파라미터 텐서 write에 대한 user-side
동기화를 요구하므로(위 §3) **결과는 silent wrong outputs**다 — segfault도
없고 에러도 없고, 그냥 일부 파라미터가 step 전, 일부는 step 후 값으로 섞여
forward가 진행된다. CFR regret 추정 자체에 노이즈를 더해서 학습 발산/품질
저하로 나타난다.
`load_state_dict`도 동일하게 **부분 갱신 + 동시 read** 위험이다.
[forum #224131](https://discuss.pytorch.org/t/thread-safety-between-model-state-dict-and-optimizer-step/224131)
의 동일한 우려가 그대로 적용된다.
---
## 위험 표
| # | 항목 | 종류 | 현실성 (우리 코드) | 증상 |
| --- | --- | --- | --- | --- |
| R1 | 공유 모듈에 대한 forward vs optimizer.step race | silent wrong outputs | **매우 높음** | 학습 noisy, 발산 가능. crash 없음. |
| R2 | 공유 모듈 forward vs `load_state_dict` race | silent wrong outputs | 높음 (league snapshot 갱신 시) | 부분 weight forward, sample contamination |
| R3 | 모든 스레드 default stream → GPU 직렬화 | perf collapse | 매우 높음 (default 동작) | 멀티스레드인데 GPU utilization 그대로 |
| R4 | Allocator lock contention (작은 텐서, 고빈도) | perf collapse | 중간 | trace상 `cudaMalloc`/free 락 대기 증가 |
| R5 | 두 스레드가 공유 autograd graph 일부에 backward | crash / wrong grad | 낮음 (우리는 backward를 trainer만 호출) | graph free race → segfault |
| R6 | `torch.compile`된 모듈 + 멀티스레드 캐시 일관성 | wrong output / 재컴파일 폭주 | 낮음 (현재 main에 compile 없음) | unexpected recompile, dispatcher TLS 충돌 |
| R7 | Cython 확장 `nogil` 미감사 | crash / heap 손상 | 매우 높음 (free-threaded 한정) | segfault, 재현 불가 버그 |
| R8 | CUDA context 상호작용 (단일 context는 OK, 그래도 set_device 누락 시 wrong device) | wrong device error | 낮음 | runtime error |
| R9 | Pinned-memory / `non_blocking=True` H2D 카피 + 다른 스레드의 source tensor mutate | data race on source | 중간 | non-deterministic input bytes ([forum #182924](https://discuss.pytorch.org/t/is-it-safe-to-use-tensor-cuda-non-blocking-true-in-a-thread/182924)) |
| R10 | TLS 누수: `inference_mode`/`no_grad`가 trainer 스레드에서 켜져 있는데 backward 호출 | 잘못된 grad 누락 | 낮음 (코드 명시적으로 with-block 사용) | grad가 안 나와서 학습 정지 |
가장 위험한 건 R1, R2, R3, R7. 이 넷은 "그냥 옮기기"의 직접 결과다.
---
## Mitigations
### M1. Per-thread module copy (제일 안전, 메모리 비용)
각 traversal 스레드가 모듈 deepcopy를 들고 있고, weight-sync 시점에만
`load_state_dict`로 갱신. trainer는 자기 모듈만 만진다.
- 비용: 모듈 사이즈 × N_threads VRAM. 우리 모델(512×3)은 작아서 무시 가능.
- 동기화 지점: weight-sync 때 traversal 스레드를 **잠시 quiesce**해야 안전.
현재 multiprocessing 패턴(`weight_sync_event`)이 그대로 매핑됨.
- 결과: R1, R2 제거. R5도 자동 제거.
### M2. Reader-writer lock on the shared module
Trainer write(step/load_state_dict)는 writer lock, traversal forward는 reader
lock. Pythonic하게는 `threading.RLock` + writer가 모든 reader 종료 대기.
- 비용: writer가 풀릴 때까지 모든 traversal 스레드 정지 → tail latency 증가.
CFR처럼 "조금 stale한 weight도 OK" 알고리즘에선 OK.
- 결과: R1, R2 제거. M1 대비 메모리 절약 / latency 손해.
### M3. Per-thread CUDA stream
각 스레드가 `s = torch.cuda.Stream(); with torch.cuda.stream(s):`로 forward를
감싼다. trainer도 본인 stream에서 step. weight-sync 시 `torch.cuda.synchronize()`
또는 stream event로 ordering 보장.
- 비용: 거의 없음 (stream 객체는 cheap). 코드 변경은 호출 사이트 추가.
- 결과: R3 제거 — 진짜 GPU 동시성 가능. **R1/R2는 해결하지 못함**(stream은
ordering이지 mutual exclusion이 아니다). 반드시 M1 또는 M2와 같이 써야 한다.
### M4. Serialize at boundary (가장 단순, 거의 multiprocessing 효과)
Trainer forward/backward/step 전체를 큰 lock으로 감싸고, traversal forward도
같은 lock으로 감싼다. = 사실상 GIL 흉내.
- 비용: 멀티스레드 의미 사라짐. GPU 사용률 = single thread.
- 결과: 모든 race 제거되지만 free-threaded Python으로 갈 이유가 없어짐.
*threading 도입 자체의 가치가 사라지는 시그널.*
### M5. 현재 inference-server 패턴을 프로세스 → 스레드로 단순 치환
`inference_server.py`는 1 spawn process + 워커가 큐로 RequestMessage 송신.
이를 1 server thread + N requester threads로 바꾸는 건 가장 minimal한 변경.
- 모든 GPU 호출이 server thread 1개로 모이므로 R1/R2/R5 자동 회피.
- IPC 비용 사라짐(shared memory가 그냥 메모리). performance.md "Option A
Bench Result"의 IPC 오버헤드(~수백 μs/call) 제거가 가능.
- 단점: server thread가 여전히 single GPU executor → R3와 같은 GPU 직렬화는
유지되나, 그게 *batching의 목표*이기 때문에 해롭지 않음. realized batch
size가 ceiling 8(현재) → free-threaded로 64+ 스레드면 ceiling 64로 올라감 →
performance.md가 기대했던 bs=64 regime에 진입.
이 시나리오에선 **GPU 호출은 여전히 1 스레드만 한다**. 멀티스레드 → CUDA의
복잡도 대부분이 사라진다. *권고 핵심.*
### M6. (보조) `torch.compile` 모듈을 공유 forward 대상에서 제외
향후 trainer가 compile을 다시 쓴다면 inference 스레드들에 노출하지 말 것
([dev-discuss: compile + multithreading](https://dev-discuss.pytorch.org/t/impact-of-multithreading-and-local-caching-on-torch-compile/2498)
의 캐시 일관성 이슈 회피).
---
## 권고
### 지금 위험 수준
코드 그대로(공유 모듈 + 동시 forward + 동시 step) threading 전환하면:
- R1, R2가 거의 확실히 발생 → 학습 품질에 silent regression.
- R3 때문에 GPU 활용도는 multiprocessing 대비 거의 개선 없음.
- Cython 코드(R7) 때문에 free-threaded 빌드에서 segfault 위험.
따라서 "**그냥 threading으로 옮기기**"는 **추천하지 않음**.
### 안전한 경로 (선호 순)
1. **M5 (server-thread pattern) + 기존 multiprocessing 워커 유지**.
가장 작은 변경으로 GPU 호출 스레드를 1개로 묶고, IPC 비용만 줄인다.
하지만 이건 threading으로의 *전환*이 아니라 "Option A의 in-process variant"
라는 점 명심.
2. **Free-threaded Python으로 가야 한다면**: 그 결정은
- (a) Cython 코드의 `nogil` 감사를 끝내고
- (b) 적어도 traversal worker가 thread가 되어 game logic을 동시에 굴리고
- (c) GPU 호출은 M5 패턴으로 단일 server-thread에 위임
세 조건이 동시에 충족될 때만 가치가 있다. performance.md "Free-threaded
Python 노트"의 결론(현재 미적용)과 일치.
3. **공유 모듈을 어쩔 수 없이 여러 스레드에서 호출해야 한다면** M1
(per-thread copy, 모델이 작으니 비용 미미) + M3 (per-thread stream) 조합이
안전. 단순히 lock(M2/M4)만 걸면 GPU 활용도는 single-thread와 동일해진다.
### 안전 검증 체크리스트 (전환 전)
- [ ] Module 공유 여부를 모든 forward 호출 사이트에 대해 표로 만들기.
"이 forward는 trainer가 weight write하는 모듈인가?"가 yes면 M1/M2 필수.
- [ ] 각 GPU-호출 스레드가 `torch.cuda.set_device` 명시.
- [ ] 각 GPU-호출 스레드가 자신의 `torch.cuda.Stream` 보유 + `with` 감싸기.
- [ ] Weight push 경로에서 reader fence (모든 inflight forward 완료 대기) 보장.
`weight_sync_event`와 동일한 의미를 in-process로 구현.
- [ ] `torch.compile`된 모듈은 공유 forward 대상에서 제외.
- [ ] Cython 확장이 free-threaded 빌드에서 동작/safe함을 회귀 테스트로 확인.
- [ ] `CUDA_LAUNCH_BLOCKING=1`로 한 번 돌려 silent wrong-output을 동기 에러로
변환해보고 race 미존재 확인.
- [ ] 결정성 테스트: 동일 seed로 multiprocessing 버전과 threading 버전의
iteration 1 forward outputs bit-exact 비교 (allocator/stream 비결정 제외).
- [ ] 부하 테스트: 동시 forward+step을 10⁵ 회 돌려 weight checksum 변화가
예상 범위 내인지 (R1 검출).
### 한 줄 정리
**현재 모델 사이즈에서는 multiprocessing → threading의 위험·복잡도 vs 이득
비율이 나쁘다.** 정말 단일 프로세스가 필요하다면 *모든* GPU 연산을 한
"server thread"로 모으는 M5 형태가 거의 모든 위험을 회피한다 — 그리고 그건
이미 우리가 가진 inference_server.py 구조의 thread 버전일 뿐이다.
---
## 참고 자료
- [PyTorch CUDA semantics docs](https://docs.pytorch.org/docs/stable/notes/cuda.html)
- [PyTorch Autograd mechanics](https://docs.pytorch.org/docs/stable/notes/autograd.html)
- [c10/cuda/CUDAStream.h — current stream is thread-local](https://github.com/pytorch/pytorch/blob/main/c10/cuda/CUDAStream.h)
- [c10/cuda/CUDACachingAllocator.cpp](https://github.com/pytorch/pytorch/blob/main/c10/cuda/CUDACachingAllocator.cpp)
- [zdevito: A guide to PyTorch's CUDA caching allocator](https://zdevito.github.io/2022/08/04/cuda-caching-allocator.html)
- [pytorch#25540 — per-thread default stream feature request, unresolved](https://github.com/pytorch/pytorch/issues/25540)
- [pytorch#59692 — streams sequentially serialized](https://github.com/pytorch/pytorch/issues/59692)
- [pytorch#101300 — default stream not synchronous](https://github.com/pytorch/pytorch/issues/101300)
- [pytorch#15210 — torch::jit::script::Module not thread-safe](https://github.com/pytorch/pytorch/issues/15210)
- [pytorch#19029 — C++ custom module not thread safe](https://github.com/pytorch/pytorch/issues/19029)
- [pytorch#51452 — JIT module forward thread safety](https://github.com/pytorch/pytorch/issues/51452)
- [pytorch#130249 — Python 3.13t free-threaded support tracking](https://github.com/pytorch/pytorch/issues/130249)
- [forum: Is inference thread-safe?](https://discuss.pytorch.org/t/is-inference-thread-safe/88583)
- [forum: Is PyTorch supposed to be thread-safe?](https://discuss.pytorch.org/t/is-pytorch-supposed-to-be-thread-safe/36540)
- [forum: Only 1 thread for backward?](https://discuss.pytorch.org/t/only-1-thread-for-backward/36824)
- [forum: state_dict vs optimizer.step thread safety](https://discuss.pytorch.org/t/thread-safety-between-model-state-dict-and-optimizer-step/224131)
- [forum: Tensor.cuda(non_blocking=True) in a thread](https://discuss.pytorch.org/t/is-it-safe-to-use-tensor-cuda-non-blocking-true-in-a-thread/182924)
- [forum: Threaded inference c10::CuDNNError](https://discuss.pytorch.org/t/threaded-inference-c10-cudnnerror/182191)
- [dev-discuss: torch.compile + multithreading caches](https://dev-discuss.pytorch.org/t/impact-of-multithreading-and-local-caching-on-torch-compile/2498)
- [Trent Nelson — PyTorch and free-threading](https://trent.me/articles/pytorch-and-python-free-threading/)
- 본 저장소: `docs/performance.md` "Option A Bench Result and Structural
Ceiling", "Free-threaded Python (3.13t) 노트"
- 본 저장소: `src/coolrl_lost_cities/games/classic/deep_cfr/inference_server.py`,
`trainer.py`, `workers.py`, `evaluate.py`
@@ -0,0 +1,249 @@
# Cost Report: Adopting Free-Threaded Python (3.13t / 3.14t) for PyTorch
**Date**: 2026-05-07
**Scope**: Evaluate the cost of migrating this project's Deep CFR training stack
to free-threaded Python (PEP 703 / `python3.13t` / `python3.14t`) so that the
multiprocessing traversal workers + inference server can be replaced by
threads, escaping the structural batching ceiling documented in
`docs/performance.md` ("Option A Bench Result and Structural Ceiling").
## 한 줄 결론
**WAIT (3-6개월).** PyTorch 측 free-threaded 지원은 이미 production 수준
(2.10 cp314t wheel, 2.11 experimental)이지만, **우리 코드 측 진짜 비용은
PyTorch가 아니라 Cython 게임 엔진의 `nogil` 정합성 작업**이다. 그 작업을
하지 않으면 free-threaded Python으로 전환해도 traversal 핫패스에서 GIL이
없어진 효과를 못 본다 — 단지 단일-스레드 perf 회귀(현재 ~20-40%, Python
3.14에서 ~5-10%로 개선 예정)만 떠안게 된다. 더 직접적이고 더 작은 우회로
(Option B-shape per-worker interleaving)가 같은 batch-size 목표를 더 적은
ecosystem-risk로 달성한다.
---
## PyTorch 측 상태 (2026-05 기준)
### Wheel 가용성
- **`torch` 2.10.0** (2026-01): cp313t / cp314t 정식 wheel을 PyPI에 게시.
Linux x86_64 / aarch64, Windows, macOS 모두 커버.
([PyPI torch](https://pypi.org/project/torch/),
[PyTorch Issue #156856](https://github.com/pytorch/pytorch/issues/156856))
- **`torch` 2.11.0** (2026-05 직전): cp314t를 "experimentally supported"로
명시. cp313t는 stable.
([PyTorch 2.11 Release Blog](https://pytorch.org/blog/pytorch-2-11-release-blog/),
[PyTorch 2.11 Release Notes](https://github.com/pytorch/pytorch/releases))
- `pip install torch` on `python3.13t` 환경에서 실제로 동작한다는 트래킹
페이지 보고가 있음.
([Free-threading Compatibility Tracking](https://py-free-threading.github.io/tracking/))
### CUDA wheel 주의사항
- **Python 3.14 (정규/free-threaded)** 의 CUDA wheel 배포 늦음 보고가 있다.
([PyTorch Issue #169929](https://github.com/pytorch/pytorch/issues/169929))
3.14t에서 CUDA를 쓰려면 wheel 인덱스/플랫폼을 확인해야 한다. 우리 RTX 3090
로컬은 cp313t + CUDA가 안전한 조합.
### 안전한 영역
- 기본 텐서 연산 (`torch.as_tensor`, `nn.Linear`, ReLU 등 우리 MLP에서
쓰는 모든 op)은 GIL을 이미 release한다. Quansight/Meta 보고에서
멀티스레드에서 잘 도는 것으로 확인.
([Quansight: free-threaded rollout](https://labs.quansight.org/blog/free-threaded-python-rollout))
- `torch.inference_mode()` 컨텍스트, `state_dict` load, MLP forward —
우리 inference server가 쓰는 모든 경로가 잘 알려진 멀티스레드-가능 영역.
### 위험 영역 / gated
- **`torch.compile`**: free-threaded 빌드에서 "기본 동작은 가능, 진짜
멀티스레드 사용은 미지원"으로 명시.
([PyTorch Issue #156856](https://github.com/pytorch/pytorch/issues/156856))
우리 프로젝트는 `torch.compile` 회귀가 이미 확인되어 비활성 상태이므로
이 제약은 영향 없음 (`docs/performance.md` "torch.compile 실험" 절).
- **`DataLoader`**: 멀티스레드 DataLoader는 SPDL/Meta 측에서 prototype 단계
시연이 있을 뿐, stable contract 아님. 우리 프로젝트는 DataLoader를 쓰지
않으므로 영향 없음.
- **CUDA 멀티스레드 컨텍스트 공유**: long-standing 주의 사항. 한 프로세스
안에서 여러 스레드가 같은 CUDA stream에 일을 던지면 race/순서 문제 가능.
현재 우리 inference server는 단일-스레드 디스패치 루프 (`run_inference_server`,
`inference_server.py:218-246`)이므로 free-threaded로 전환해도 forward
자체는 단일 스레드가 처리하면 안전.
([PyTorch Forums: thread safety + multiprocessing CUDA](https://discuss.pytorch.org/t/thread-safety-in-multiprocessing-cuda-tensors-dont-update-asynchronously/160151),
[CUDA semantics 2.11](https://docs.pytorch.org/docs/2.11/notes/cuda.html))
- **Autograd**: 멀티스레드 backward는 PyTorch에서 historically 락이 많음.
우리 trainer는 단일 스레드에서 backward를 돌릴 것이므로 영향 없음 —
단, 멀티스레드 inference + 동시 backward를 같은 모델에 시도하지 않아야
한다는 일반 원칙은 그대로.
### 단일-스레드 perf 회귀
- **3.13t**: specializing adaptive interpreter 비활성화로 단일-스레드
코드가 ~20-40% 느림.
([CodSpeed: State of 3.13](https://codspeed.io/blog/state-of-python-3-13-performance-free-threading))
- **3.14t**: specializing interpreter 재활성화. 회귀가 ~5-10%로 축소
(예상). 3.14는 PEP 779 통과 후 free-threaded가 "non-experimental,
officially supported"로 격상됨.
- **PyTorch 자체**: native code는 GIL을 이미 release하므로 free-threaded
빌드에서도 텐서 연산 perf는 거의 동일 (Trent Nelson 보고).
([Trent Nelson: PyTorch + free-threading](https://trent.me/articles/pytorch-and-python-free-threading/))
- 우리 traversal hot path는 Cython이라 Python 인터프리터 회귀의 직접
영향이 작지만, Python 콜백 (정책 호출 → numpy → torch) 빈도가 매우
높으므로 (~205k calls/iter) 회귀가 곱 effect로 누적될 수 있다.
### 알려진 이슈 / 트래커
- [pytorch#130249 — Python 3.13 support](https://github.com/pytorch/pytorch/issues/130249) —
지속 업데이트되는 메타 이슈.
- [pytorch#156856 — Python 3.14 support](https://github.com/pytorch/pytorch/issues/156856) —
3.14 / 3.14t 진행 상황.
- [pytorch#169929 — Python 3.14 CUDA wheel 누락 보고](https://github.com/pytorch/pytorch/issues/169929)
### 생태계 신호 (실제로 쓰는 사람들)
- **Optuna**: 3.13t를 정식 지원, 멀티스레드 trial 실행 검증.
([Optuna 3.13t support](https://medium.com/optuna/overview-of-python-free-threading-v3-13t-support-in-optuna-ad9ab62a11ba))
- **Meta SPDL** (DataLoader 대체): ImageNet 이터레이터에서 process →
thread 전환으로 +74% throughput / -50GB 메모리 보고 (8x A100).
단, 이건 고정-비용 비교가 아닌 cherry-picked benchmark.
- **SGLang**: 3.14t 지원 요청 issue가 열려 있음 (open).
([sglang#22889](https://github.com/sgl-project/sglang/issues/22889))
→ 즉, **메이저 LLM serving 프레임워크조차 아직 production 도입을 안
했다**는 신호.
- **Lightning / RLlib**: free-threaded 도입 공식 발표 없음 (검색 시점).
- 일반 보고: PyO3 dependent Rust extensions (pydantic, tiktoken 등) 일부가
free-threaded wheel 없음 → setup이 "fiddly". 우리 프로젝트는 이런
의존성이 사실상 없음 (Cython만 있음 — 이건 free-threaded 지원 wheel
배포 진행 중).
---
## 우리 코드 측 작업량
다음은 multiprocessing → threading 전환 시 만져야 할 곳 / 동시성 가정을
재검토해야 할 곳을 파일·함수 단위로 정리한 것.
### 파일·함수 인벤토리
| 파일 | 역할 | 현재 동시성 가정 | 스레드化 비용 |
| --- | --- | --- | --- |
| `src/coolrl_lost_cities/games/classic/deep_cfr/trainer.py` (`_run_traversal_iteration`, `_evaluate_iteration` 부근, line 461-525, 818-855) | `ProcessPoolExecutor(mp_context=spawn)`로 worker batch dispatch | 워커는 별 프로세스, fork-safe 가정 없음 (spawn) | `ThreadPoolExecutor`로 교체. 각 worker가 trainer의 model state에 read-only 접근 → state_dict copy 시점만 lock으로 보호. 중간. |
| `src/coolrl_lost_cities/games/classic/deep_cfr/workers.py` (`run_traversal_worker_batch`, `_configure_worker_torch_threads` line 28-46) | per-process `torch.set_num_threads(1)`, networks를 매 batch마다 `state_dict`로 load 후 eval | 프로세스마다 격리된 torch state, model copy 1쌍 | thread-shared model로 단순화 가능. `torch.set_num_threads(1)` 호출은 process-global이라 thread 환경에서는 1번만 호출하면 됨 — 약간 작업. **MODEL을 공유하는 순간 weight-update 동시성 문제 신규 발생** (현재는 매 batch 시작 시 state_dict 복사라 자연스레 안전). 중간-높음. |
| `src/coolrl_lost_cities/games/classic/deep_cfr/inference_server.py` (`InferenceServerController`, `run_inference_server`, line 218-326) | 별 프로세스 + spawn context, shared-memory tensor pool | 프로세스 격리 → thread 환경에서는 server 자체가 불필요. 같은 주소 공간에서 직접 호출. | 서버 자체 삭제 또는 in-process thread-pool 디스패처로 변환. **다만 이 변환이 free-threaded migration의 진짜 목적이므로 비용이라기보다 보상.** 중간. |
| `src/coolrl_lost_cities/games/classic/deep_cfr/inference_client.py` (`InferenceClient.forward`, `NetworkProxy.__call__`) | shared-memory slot 잡고 queue post → event wait | 슬롯 = 워커 1개 가정. 슬롯 free pool은 `mp.Queue` 기반. | 스레드化하면 그냥 직접 model forward 호출 가능. 작은 인터페이스 어댑터만 필요. 작음. |
| `src/coolrl_lost_cities/games/classic/deep_cfr/inference_buffers.py` (`InferenceBuffers`, line 45) | `mp.get_context("spawn")` 기반 shared memory + queues | mp 전용 | 스레드 환경에서는 통째로 불필요. 작음 (삭제). |
| `src/coolrl_lost_cities/games/classic/deep_cfr/traversal.pyx` (1179줄) | Cython 재귀 traversal, 정책 호출, regret 누적 | **GIL 보유 가정**. Python object 접근 다수. 자유 스레드化 시 race 위험 미평가. | **이게 진짜 비용.** `nogil` 클린업 audit 필요. 1179줄 + `cfr_math.pyx` + `encoding.pyx` 전부. **수일~수주 작업, high risk** (`docs/performance.md`도 같은 결론). |
| `src/coolrl_lost_cities/games/classic/deep_cfr/memory.py` (73줄, `TrainingSample` 추가 경로) | 현재 워커가 결과를 list로 반환, trainer가 단일 스레드에서 `memory.add(...)` | 스레드 추가 시 add 호출이 동시 발생 → 락 필요 | replay buffer add 경로에 `threading.Lock` 추가. 작음. |
| `src/coolrl_lost_cities/games/classic/deep_cfr/inference_server.py` (`run_inference_server` 디스패치 루프, `torch.inference_mode()` + `torch.as_tensor` line 231-246) | 단일 프로세스 단일 스레드 디스패치 | 스레드化 후에도 단일 디스패처 thread 1개로 유지 가능 (CUDA stream 안전) | 단일 thread 보장. 작음. |
| Cython `.pyx` (encoding, cfr_math, traversal) | numpy + Python object 빈번 | `nogil` cleanup 비용 매우 큼 | **자유 스레드化의 critical path.** |
### 새로 필요한 동시성 primitive 위치 추정
다음 곳에 명시적 락 또는 per-thread 격리가 필요해진다:
1. `trainer.py`: 모델 weight 업데이트 ↔ inference 디스패처 read 사이 —
현재는 `weight_queue`가 알아서 sync. 스레드化 후엔 RWLock 또는 epoch
기반 swap 필요. **1곳, 패턴 명확.**
2. `memory.py`: replay buffer `add` 경로. **1곳, 평범한 lock으로 해결.**
3. `traversal_stats.py`: stats 누적 — thread-local 후 머지가 깔끔. **1곳.**
4. `traversal.pyx`: Python-level state mutation (regret/strategy
accumulator dict 등) — `nogil` 영역에서 건드리면 안 됨. **이게 가장
많고 가장 어려운 곳.** 정확한 location 수는 audit 전엔 미상이지만
재귀 호출마다 등장.
### 요약 추정
- **PyTorch 사용 위치만 카운트하면**: 명시적 locking이 새로 필요한 자리는
3-5곳 정도로 매우 작다 (weight swap, replay add, stats merge).
- **진짜 작업량**: Cython 엔진 `nogil` cleanup. 이건 PyTorch 측 free-
threaded 지원과 무관하게 필요한 상수 비용이고, `docs/performance.md`
Decision A의 "free-threaded는 옵션 있지만 cleanup 비용 때문에 deferred"
와 일치한다.
---
## 위험 표
| # | 위험 | 발생 확률 | 영향도 | 검증 방법 |
| -: | --- | --- | --- | --- |
| 1 | Cython traversal 핫패스가 `nogil`-clean이 아니어서 스레드 추가가 곧 GIL 직렬화로 환원 | **높음 (사실상 확정)** | High — 마이그레이션 목적 자체가 사라짐 | `traversal.pyx` 함수에 `nogil` 어노테이션을 시험 적용 → 컴파일 에러로 Python-object 접근 위치 enumerate. 1-2일. |
| 2 | 단일-스레드 perf 회귀 (3.13t: 20-40%) → traversal 절대 시간이 미세하게 더 느려짐 | 높음 (3.13t에서) / 중간 (3.14t에서 ~5-10%) | Medium | 같은 머신에서 `python3.13` vs `python3.13t`로 현 traversal 벤치 (`scripts/bench_inference_backend.py`) 비교. 반나절. |
| 3 | PyTorch CUDA wheel이 cp314t에서 누락/늦음 → CUDA 트레이너에서 import 실패 | 중간 | High (이라면 즉시 블로커) | `pip install --pre torch --index-url https://download.pytorch.org/whl/nightly/cu126` on `python3.14t` 시도. |
| 4 | Cython 의존성 (numpy 등)이 free-threaded wheel 부재 또는 thread-unsafe op | 낮음-중간 | Medium | `pip install` smoke + import 테스트. numpy 2.3+는 free-threaded compat. |
| 5 | 같은 모델에 multi-thread inference + concurrent backward에서 autograd 락 경합 | 중간 (구조에 따라) | Medium | 우리 구조는 단일-스레드 backward라 회피 가능. 디자인 단계에서 "trainer step 동안 inference 중지" 약속만 지키면 됨. |
| 6 | 멀티스레드 CUDA stream 사용 시 race | 낮음 (단일 디스패처 thread 유지하면 0) | High if hit | inference server를 thread 1개로 제한. 검증: `torch.cuda.synchronize()` + functional test. |
| 7 | 생태계 미성숙 — wandb / pytest / ruff / 기타 dev 툴 free-threaded 호환성 | 중간 | Low (개발 환경만 영향, 학습은 OK) | 새 venv 만들어 `uv sync` 시도. |
| 8 | PEP 703 정책 변경 / Python 측 backout (낮지만 0 아님) | 매우 낮음 | High | 트래커 모니터. 3.14에서 phase 2 (officially supported)로 격상되어 위험 감소. |
| 9 | 우리가 적용한 시점이 아직 너무 일러 회귀 발생 시 upstream에 patch 못 받음 | 중간 | Medium | SGLang / Lightning 등 production 도입 신호 대기. 현재 미도입. |
---
## 권고
### 지금 시도하지 말 것
이유 3줄:
1. **PyTorch는 충분히 준비됐지만, 우리 병목은 PyTorch가 아니다.**
`docs/performance.md` Option A 분석은 batching ceiling이 *Cython
traversal의 sync-blocking 구조* 때문이라고 명시. free-threaded Python은
"워커를 스레드로 바꿀 수 있게" 해주지만, Cython이 `nogil`이 아니면
스레드끼리 GIL을 직렬-획득하므로 ceiling이 안 올라간다.
2. **단일-스레드 perf 회귀 (3.13t 20-40%)** 가 traversal 절대 시간을
악화시킬 수 있다. 3.14t에서 5-10%로 개선될 때까지 기다리는 편이 비용
대비 안전.
3. **Production 도입 신호가 아직 약하다**. SGLang은 issue가 open이고,
Lightning/RLlib는 발표 없음. 우리가 early adopter가 되어 디버깅 비용을
짊어질 가치는 단일 프로젝트 입장에서 낮다.
### 더 작은 우회로 (이미 plan에 있음)
`docs/performance.md` "Re-enable A when one of these holds" 항목 #2
**per-worker interleaved traversal (Option B-shape)** 가 같은 batch=64
목표를 free-threaded migration 없이 달성한다. Cython 재귀를 resumable
state machine으로 바꾸는 작업은 `nogil` audit보다 **로컬 영역이고 risk가
낮다** (단일 worker scope, 검증 가능, ecosystem 의존성 0). 자유-스레드
이주의 대안으로 Option B를 먼저 시도하는 것을 권장.
### 다시 보는 조건 (트리거)
다음 중 하나라도 충족되면 재평가:
- **(a) Cython 엔진을 다른 이유로 `nogil`-clean 화한다** — 그 경우
free-threaded Python은 거의 무료 부산물이 된다. 비용 대부분이 그쪽
작업에 흡수됨.
- **(b) Python 3.14.x patch release에서 free-threaded가 stable로 격상되고
단일-스레드 회귀가 ≤5%로 측정됨** + **메이저 ML 프레임워크 (Lightning,
RLlib, vLLM, SGLang 중 2개 이상)** 가 production 도입 발표.
- **(c) 모델이 1024-hidden / 6-layer 이상으로 커져서** GPU forward가
IPC 오버헤드를 흡수할 수 있는 영역으로 들어가면 — Option A 자체가
다시 살아나므로 free-threaded migration이 필요 없어진다.
- **(d) eval이 dominant phase가 됨** (eval_every=5, games=1000). eval은
이미 batch=64이므로 free-threaded와 무관하게 Option A 재활성화로 충분.
### 액션
1. **지금 (0 비용)**: `docs/performance.md`의 free-threaded note를 이
리포트로 cross-link. 트래킹 페이지
([py-free-threading.github.io/tracking](https://py-free-threading.github.io/tracking/))
를 분기별로 확인.
2. **다음 작업 후보**: Option B-shape per-worker interleaving 설계 검토.
3. **장기**: 모델 사이즈 결정이 끝나면 (`docs/performance.md` 권장
sequencing #2) Option A의 batch ceiling이 자연 해소되는지 재측정.
---
## 참고
- [PyTorch Issue #130249 — Python 3.13 support](https://github.com/pytorch/pytorch/issues/130249)
- [PyTorch Issue #156856 — Python 3.14 support](https://github.com/pytorch/pytorch/issues/156856)
- [PyTorch Issue #169929 — Python 3.14 CUDA wheel](https://github.com/pytorch/pytorch/issues/169929)
- [PyTorch 2.11 Release Blog](https://pytorch.org/blog/pytorch-2-11-release-blog/)
- [PyTorch 2.11 Release Notes](https://github.com/pytorch/pytorch/releases)
- [py-free-threading Compatibility Tracking](https://py-free-threading.github.io/tracking/)
- [Quansight Labs — Free-threaded rollout](https://labs.quansight.org/blog/free-threaded-python-rollout)
- [CodSpeed — State of Python 3.13 free-threading perf](https://codspeed.io/blog/state-of-python-3-13-performance-free-threading)
- [Trent Nelson — PyTorch + Free-Threading 실전](https://trent.me/articles/pytorch-and-python-free-threading/)
- [Optuna — 3.13t 지원](https://medium.com/optuna/overview-of-python-free-threading-v3-13t-support-in-optuna-ad9ab62a11ba)
- [SGLang Issue #22889 — 3.14t 지원 요청 (open)](https://github.com/sgl-project/sglang/issues/22889)
- [PyTorch CUDA semantics 2.11](https://docs.pytorch.org/docs/2.11/notes/cuda.html)
- 사내: `docs/performance.md` "Option A Bench Result and Structural Ceiling",
"Free-threaded Python (3.13t) note"