Skip to content

fix(doeff-vm): EffectBase.__reduce_ex__ の copyreg 解決を PyOnceLock で 1 回きりにする - #586

Open
proboscis wants to merge 3 commits into
mainfrom
feat/impl-doeff-vm-reduce-ex-imports-copyreg-per-call-86fac7
Open

fix(doeff-vm): EffectBase.__reduce_ex__ の copyreg 解決を PyOnceLock で 1 回きりにする#586
proboscis wants to merge 3 commits into
mainfrom
feat/impl-doeff-vm-reduce-ex-imports-copyreg-per-call-86fac7

Conversation

@proboscis

@proboscis proboscis commented Aug 9, 2026

Copy link
Copy Markdown
Owner

何を直したか

EffectBase.__reduce_ex__(packages/doeff-vm/src/python_generator_stream.rs)が
呼び出しのたびに py.import("copyreg")getattr("__newobj__") していたのを、
静的 COPYREG_NEWOBJ: PyOnceLock<Py<PyAny>> によるプロセス 1 回きりの解決へ変更。
hot path から import と module 属性参照を外し、2 回目以降は refcount 加算だけにする。
返す reduce タプルの形は不変 — (copyreg.__newobj__, (cls,), __dict__)

fix_class: root-cause

診断した根因 = 所有層(pyclass 実装)が、プロセス不変の定数を per-call の仕事として
書いていたこと
copyreg.__newobj__ は起動後は変わらないのに、pickle hook が
呼ばれるたびに import machinery と module getattr を通していた。free-threaded
CPython 3.14 ではその 2 つがどちらも per-object 鍵を取るため、「全 effect の pickle」
という最も熱い経路が鍵の待ち行列に化ける。本 PR はその per-call の仕事そのものを
消す(静的 PyOnceLock で 1 回だけ解決)ので、下流の呼び手すべてが同時に正しくなる —
症状の抑制ではない。下流の対症策(ACP 側の worker pool / warmup / sys.meta_path
番兵 = agent-control-plane PR #223)は着地済みで、本 PR の射程外。

なぜ(実弾の証拠)

free-threaded CPython 3.14 では import も module 属性参照も per-object 鍵を取る。
2026-08-07・ACP の hypha 常駐 runtime で 948 threads が import 鍵で滞留し、
機体全体で +19.5 GiB の swap 押し出しが発生した。滞留スレッドの native stack は
_PyMutex_LockTimed → _PyParkingLot_Park → __psynch_cvwait、到達元は
PyImport_ImportModuleLevelObject → import_ensure_initialized
_Py_module_getattro_impl、呼び手は doeff_vm.cpython-314t-darwin.so
証跡 = /tmp/hypha-wedge-sample-20260807.txt(11.9MB)。

ACP 側には既に応答スレッドの有界化(worker pool 16 + queue 64 + 飽和 503)と起動時
import warmup + sys.meta_path 番兵が入っている(agent-control-plane PR #223)が、
あれは応答経路の対症であって、鍵を生む側は上流に残っていた。doeff を使う別の
常駐 runtime・並列 worker は同じ鍵を踏みうるため、こちらで根を断つ。

受入条件と shipped test の 1:1 対応

発注の受入条件 対応する shipped test
(1) 変更後、__reduce_ex__ の hot path に import が無いことを code で示す tests/test_effect_base_reduce_ex_hot_path.py::TestReduceExHotPathHasNoImport::test_source_has_no_per_call_import(本文に py.import( が無い)/ ::test_source_caches_newobj_in_once_lock(静的 PyOnceLock 経由)/ ::test_no_import_machinery_per_call(実行時: 200 回叩いて builtins.__import__ 呼び出し 0)
(2) pickle 往復の既存挙動が変わらない ::TestEffectBasePickle::test_reduce_ex_shape / test_pickle_roundtrip / test_pickle_roundtrip_all_protocols / test_cloudpickle_roundtrip / test_nested_effect、および既存の tests/test_pyclass_pickle.py(8 件)が全通
(3) doeff の既存テスト suite が緑 uv run pytest(正典 gate)= 1308 passed, 86 skipped, 0 failed(618s、CPython 3.14t。rebase 前の基底での実測)
(4) upstream 規律: ローカル検証で緑を確認してから main へ 本 PR は未 merge。下記「ローカル検証」の実測を添付

Verification deviations: なし。

TDD + semgrep(AGENTS.md の手順)

  • 第 1 相(commit c77b2d68, 実装前・意図的に red): 改修前は 200 回の
    __reduce_ex__copyreg を 200 回 import していた(__import__ 実測)。
    ソース 2 件も red。
  • 第 2 相: .semgrep.yamldoeff-vm-no-per-call-copyreg-resolution を新設。
    py.import("copyreg")getattr("__newobj__") を doeff-vm / doeff-vm-core の
    src で禁止。改修前の形を bad fixture として
    tests/semgrep/fixtures/rust/packages/doeff-vm/src/python_generator_stream.rs
    常設し、発火(6,7 行目)出荷中ソースでの非発火 の両方を assert
    (tests/semgrep/test_vm_failfast_semgrep_rules.py)。
    docs/adr/enforcement-ledger.jsonsemgrep_rules251→252 に記帳
    (ADR-DOE-ENFORCE-001 R5)。
  • 第 3 相(commit 8ebdf4de): 実装。全 red が green。

審査後の修正(2026-08-21)

前回審査の指摘 2 点に対応した。

(1) 基底が古く、merge すると台帳 gate が赤になる(blocking) — 旧 head
da187e32 は merge base 0698c742 の上にあり、main から 37 commit 遅れていた。
docs/adr/enforcement-ledger.json は実際に CONFLICT し、両側の値
(main 251 / PR 248)はどちらも merge 後の実数と合わないため
tests/test_enforcement_ledger.py::test_enforcement_inventory_matches_ledger
落ちる状態だった。→ origin/main(84ada9b2)へ rebase し直し、台帳を
取り込み時点の実数へ解決:

defadr_files 23 / semgrep_rules 252 / adr_deftest_enforcements 39 /
adr_defsemgrep_enforcements 47 / adr_laws 88

main との差分は semgrep_rules の 251→252(本 PR の新 rule 1 本)だけ。
なお main はこの区間で packages/doeff-vm*/src/**.rs を 1 行も変えていない
(git diff --stat 0698c742 origin/main -- packages/doeff-vm/ packages/doeff-vm-core/
= 空)ので、Rust 側の挙動は rebase の前後で同じ。

(2) 新 rule の include が src 直下にしか届かない(非 blocking の指摘)
**/packages/doeff-vm-core/src/*.rs は直下しか照合せず、doeff-vm-core が
dispatch / step / handler / invariants / var_store を置いている src/vm/
無防備だった。→ commit a6bff541 で include に src/**/*.rs の 2 行を追加し、
subdir 用の bad fixture
(tests/semgrep/fixtures/rust/packages/doeff-vm-core/src/vm/dispatch.rs)と
テスト 2 件を追加。意味のあるテストであることの実測: include を元の直下 2 行だけへ
戻すと test_copyreg_per_call_resolution_rule_reaches_vm_crate_subdirectories
assert set() == {6, 7} で red、追加後は green。rule の本数は 1 のままなので
台帳の semgrep_rules は 252 で不変。

指摘 (3) の make lint-ruff の 3 errors
(packages/doeff-agents/tests/test_provider_failure_markers.py,
tests/test_deadline_load_scaling.py)は素の main の checkout でも同一に再現する
既存 red で、本 PR は当該ファイルを触っていない。

ローカル検証(実測ログ)

rebase 後の tree(a6bff541origin/main = 84ada9b2 の上)で再実測:

pytest tests/test_enforcement_ledger.py \
       tests/test_effect_base_reduce_ex_hot_path.py \
       tests/test_pyclass_pickle.py \
       tests/semgrep/test_vm_failfast_semgrep_rules.py \
       tests/test_semgrep_gate.py            → 43 passed, 0 failed (93s)
make lint-semgrep                            → Ran 201 rules on 901 files: 0 findings
make sync (uv sync --group dev + maturin --release --features invariant-checks) → 成功
ruff check tests/semgrep/test_vm_failfast_semgrep_rules.py → All checks passed!

rebase 前の基底での全量 gate:

uv run pytest                → 1308 passed, 86 skipped, 34 warnings in 618.28s
make lint-ruff               → All checks passed!
make lint-pyright (doeff/)   → 0 errors
cargo check (doeff-vm)       → 通過(新規 warning なし)
cargo fmt --check            → 本 PR の変更ファイルは差分なし

make lint は最後の lint-packagespackages/doeff-agentic の pyright が
35 errors で落ちるが、これは本 PR と無関係の既存 red(@do generator の
戻り型注釈まわり)。本 PR は doeff-agentic の Python ソースを 1 行も触っていない。

射程外(意図的に触っていない)

  • ACP 側の worker pool / warmup / sys.meta_path 番兵(着地済み)
  • 他の py.import 箇所の一括置換 — なお本修正後、packages/*/src の Rust から
    py.import / PyModule::import0 箇所になった(grep 実測)ので、
    同種の残件は現状ない。

発注元: ACP の調査席 trace-python-memory-job(検収書
decision-hypha-bff-thread-wedge-acceptance-2026-08-10.html 付録『上流への
follow-up(未起票)』の裁定起票)。

🤖 Generated with Claude Code

proboscis and others added 3 commits August 21, 2026 01:03
free-threaded CPython 3.14 では import と module 属性参照が per-object 鍵を
取るため、`__reduce_ex__` が呼び出しのたびに copyreg を import していると
常駐 runtime で import 鍵が競合点になる。2026-08-07 の実測(ACP hypha 常駐
runtime): 948 threads が import 鍵で滞留し、機体全体で +19.5 GiB の swap
押し出し。native stack は _PyMutex_LockTimed→_PyParkingLot_Park→
__psynch_cvwait、到達元は PyImport_ImportModuleLevelObject→
import_ensure_initialized と _Py_module_getattro_impl、呼び手は
doeff_vm.cpython-314t-darwin.so。

TDD 第 1 相(実装前・意図的に red):

- tests/test_effect_base_reduce_ex_hot_path.py
  - 実行時の証明: warm-up 後に `__reduce_ex__` を 200 回叩き、
    builtins.__import__ の呼び出しが 0 であること
    (実装前 = copyreg が 200 回 → red)
  - ソース上の証明: `__reduce_ex__` 本文に py.import が無く、
    静的 PyOnceLock (COPYREG_NEWOBJ) 経由で解決していること
  - pickle 往復の既存挙動(reduce タプルの形・全 protocol・cloudpickle・
    入れ子・多スレッド同時実行)は不変であること
- .semgrep.yaml: doeff-vm-no-per-call-copyreg-resolution を新設
  (py.import("copyreg") / getattr("__newobj__") を doeff-vm 系 src で禁止)
- tests/semgrep/fixtures/rust/…/python_generator_stream.rs: 改修前の形を
  bad fixture として常設し、rule 発火と shipped source での非発火を assert
- docs/adr/enforcement-ledger.json: semgrep_rules 247→248
  (ADR-DOE-ENFORCE-001 R5 の記帳)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EffectBase.__reduce_ex__ の hot path から import と module 属性参照を外す。
静的 `COPYREG_NEWOBJ: PyOnceLock<Py<PyAny>>` に解決済み callable を保持し、
2 回目以降は refcount 加算だけで済ませる(free-threaded 3.14 の import 鍵・
module getattr 鍵に触れない)。返す reduce タプルの形は不変 —
(copyreg.__newobj__, (cls,), __dict__)。

前相の red がすべて green:
- __reduce_ex__ 200 回で builtins.__import__ 呼び出し 0(改修前 = 200)
- pickle 往復(全 protocol・cloudpickle・入れ子)は不変
- 8 スレッド × 200 回同時実行でも解決結果は copyreg.__newobj__ で一致
- doeff-vm/doeff-vm-core の Rust src から py.import は 0 箇所になった

ADR-DOE-ENFORCE-001 の TDD+semgrep 手順に従い、旧形は
doeff-vm-no-per-call-copyreg-resolution が恒久ガードする。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
新設した doeff-vm-no-per-call-copyreg-resolution の paths.include が
`**/packages/doeff-vm{,-core}/src/*.rs` だけだったため、src 直下しか
照合されず、doeff-vm-core が dispatch/step/handler/invariants/var_store を
置いている `src/vm/` が無防備だった(審査の非 blocking 指摘)。禁止形を
そこへ書いても rule は無音で素通りする。

- .semgrep.yaml: include に `src/**/*.rs` の 2 行を追加(直下の 2 行は残す)
- tests/semgrep/fixtures/rust/packages/doeff-vm-core/src/vm/dispatch.rs:
  subdir 用の bad fixture を常設(改修前の per-call 解決形、6・7 行目)
- tests/semgrep/test_vm_failfast_semgrep_rules.py:
  subdir の bad fixture で発火すること、出荷中の doeff-vm-core/src/vm では
  非発火であることを assert

意味のあるテストであることの実測: include を元の直下 2 行だけに戻すと
test_copyreg_per_call_resolution_rule_reaches_vm_crate_subdirectories が
`assert set() == {6, 7}` で red、追加後は green。

rule 総数は 1 のままなので enforcement 台帳 (semgrep_rules 252) は不変。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@proboscis
proboscis force-pushed the feat/impl-doeff-vm-reduce-ex-imports-copyreg-per-call-86fac7 branch from da187e3 to a6bff54 Compare August 20, 2026 16:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant