perf(pipeline): copy stage state without dataclasses.replace - #546
Conversation
Every stage returns a copy of the frozen ParseState, and classify, assign, group and post_rules also copy tokens one at a time. All of those went through dataclasses.replace, which walks fields() and calls __init__ on each copy: 18 copies per parse of the reference name, at three frames each on 3.11/3.12 and four from 3.13. copy_with in _pipeline/_state.py copies the fields directly. For a dataclass whose generated __init__ only assigns its fields that builds the same object, and WorkToken, ParseState and PendingAmbiguity are all that kind. It checks this once per class and refuses a class with __post_init__ or an init=False field. Parse cost drops 36 calls on 3.11/3.12 and 54 from 3.13, so the _CALL_BASELINE rows and the 3.11 _LINK_BASELINE row move down, each re-measured on its own interpreter (decisions.md#parse-cost). Differential gate output is unchanged at all five baselines.
5c56d2e to
d665457
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #546 +/- ##
=======================================
Coverage 98.78% 98.79%
=======================================
Files 45 45
Lines 3703 3719 +16
=======================================
+ Hits 3658 3674 +16
Misses 45 45 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Thanks @akamick86, this is a nice find. The frame accounting holds up exactly: I reproduced every call count in your Should fix
Smaller things These are about your new 2026-09-26 bullet under
I'm happy to make these changes for you. If you'd like, reply here and I'll push a fix commit to your branch (edits by maintainers are enabled). Otherwise, feel free to take them on yourself. |
… exact guard - mypy sees copy_with as `from dataclasses import replace as copy_with`, so the dataclass plugin checks its keywords again. An assignment (`copy_with = dataclasses.replace`) does not: the plugin keys on the callee's full name, and a misspelled or wrong-typed field passes. - _COPY_FIELDS is a read-only table built at import over the three pipeline classes, replacing the lazily filled dict. copy_with refuses any other class, and the zero-field falsy-cache wrinkle is gone. - _copyable_fields checks the class's own __dataclass_params__ and a generated __init__, so a validating __init__ and an undecorated subclass are refused. The redundant params.init test is dropped. - The refusal test records what replace builds and what an unguarded copy would build for each refused shape; each guard clause fails its own row when removed. - Release log states the saving in calls; the decisions entry gives the 6 + 12 copy breakdown and why the rows drop by 40 and 58. - Continuation lines realigned, _segment's double blank line removed.
|
Thanks for the careful review, and for reproducing the counts. I've taken these on myself in a2edd3f. Should fix
Smaller things
Full suite is green on 3.11 to 3.15, call counts are unchanged from the first commit, and the gate still matches master at all five baselines. I've updated the PR description too. |
|
Merged. Looks great. thanks for the contribution @akamick86! |
What
Every stage returns a copy of the frozen
ParseState, and some stages also copy tokens one at a time. All of those copies go throughdataclasses.replace, which walksfields()and calls__init__each time. One parse of the benchmark's reference name makes 18 of them: six of the state and twelve of tokens, from classify and assign.Change
copy_within_pipeline/_state.pycopies the fields directly. That builds the same objectreplacedoes for a dataclass that is decorated itself, keeps the generated__init__, and has no__post_init__and noinit=Falsefield._copyable_fieldschecks exactly those four, and a read-only table built at import runs it overWorkToken,PendingAmbiguityandParseState. So a class that stops qualifying fails at import, andcopy_withrefuses anything else. An unknown field still raisesTypeError.For mypy,
copy_withisfrom dataclasses import replace as copy_with, so the dataclass plugin keeps checking field names and types at every call site.The 28 call sites in the stage modules switch over. Nothing outside
_pipelinechanges.Numbers
Calls per parse of the reference name,
uv run python tools/perf/call_count.py --against e0f1a2f, parse / facade:That is 18 copies times the frames each one saves: two on 3.11 and 3.12, three from 3.13. On wall clock I measured 8 to 13% per name on synthetic names.
Behavior
No change. The differential gate exits 0 at all five baselines, and its report is identical to master's line for line apart from the header that names the checkout path.
Docs & tests
_CALL_BASELINErows lowered for 3.11 to 3.15, and_LINK_BASELINEfor 3.11 (2587 to 2301), each measured on its own interpreter. The rows drop by 40 and 58 because master already sat 4 under every row.decisions.md#parse-costentry with the numbers and how to recompute them.tests/v2/pipeline/test_state.py:copy_withbuilds whatreplacebuilds for each pipeline class, rejects an unknown field and any other class, and the guard refuses four shapes a field copy would get wrong. That test records whatreplacebuilds and what an unguarded copy would build for each, and each guard clause fails its own row when removed.Full suite: 9994 passed, 324 skipped, 3 xfailed on 3.11, and 9993 passed, 325 skipped, 3 xfailed on each of 3.12 to 3.15.