fix(doeff-vm): EffectBase.__reduce_ex__ の copyreg 解決を PyOnceLock で 1 回きりにする - #586
Open
proboscis wants to merge 3 commits into
Open
fix(doeff-vm): EffectBase.__reduce_ex__ の copyreg 解決を PyOnceLock で 1 回きりにする#586proboscis wants to merge 3 commits into
proboscis wants to merge 3 commits into
Conversation
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
force-pushed
the
feat/impl-doeff-vm-reduce-ex-imports-copyreg-per-call-86fac7
branch
from
August 20, 2026 16:14
da187e3 to
a6bff54
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
何を直したか
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 対応
__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)::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 件)が全通uv run pytest(正典 gate)= 1308 passed, 86 skipped, 0 failed(618s、CPython 3.14t。rebase 前の基底での実測)Verification deviations: なし。
TDD + semgrep(AGENTS.md の手順)
c77b2d68, 実装前・意図的に red): 改修前は 200 回の__reduce_ex__がcopyregを 200 回 import していた(__import__実測)。ソース 2 件も red。
.semgrep.yamlにdoeff-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.jsonのsemgrep_rulesを 251→252 に記帳(ADR-DOE-ENFORCE-001 R5)。
8ebdf4de): 実装。全 red が green。審査後の修正(2026-08-21)
前回審査の指摘 2 点に対応した。
(1) 基底が古く、merge すると台帳 gate が赤になる(blocking) — 旧 head
da187e32は merge base0698c742の上にあり、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 し直し、台帳を取り込み時点の実数へ解決:
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(
a6bff541、origin/main=84ada9b2の上)で再実測:rebase 前の基底での全量 gate:
make lintは最後のlint-packagesでpackages/doeff-agenticの pyright が35 errors で落ちるが、これは本 PR と無関係の既存 red(
@dogenerator の戻り型注釈まわり)。本 PR は
doeff-agenticの Python ソースを 1 行も触っていない。射程外(意図的に触っていない)
sys.meta_path番兵(着地済み)py.import箇所の一括置換 — なお本修正後、packages/*/srcの Rust からpy.import/PyModule::importは 0 箇所になった(grep 実測)ので、同種の残件は現状ない。
発注元: ACP の調査席 trace-python-memory-job(検収書
decision-hypha-bff-thread-wedge-acceptance-2026-08-10.html付録『上流へのfollow-up(未起票)』の裁定起票)。
🤖 Generated with Claude Code