perf(compiler): arity-specialise the positional creator call - #420
Merged
Conversation
`len(pos)` is fixed when a resolver is compiled, so a factory with 0 or 1 provider dependencies now compiles to a closure that names its argument and calls the creator directly -- no list build, no CALL_FUNCTION_EX unpack, and below 3.12 no comprehension frame either. Arity 2+ keeps the generic star-call. Measured against main on CPython 3.14: g1_transient -33.0%, g3_chain -28.1%, g4_wide -20.5%. The wide case improves even though its own factory stays on the generic path, because its arity-0 leaves do not. The ladder stops at 1 on evidence, not taste. Every rung is a full copy of the closure -- override guard, scope hop, closed-target reopen, both error handlers -- so each one multiplies the branch set tests must reach, not just the binding. Arity 0 and 1 are where every measured win lives; a 0-3 ladder was built and measured first and bought nothing further, while taking coverage from 100% to 87%. Teardown order of unretained transient dependencies changes on CPython below 3.12 as a result (front-to-back instead of back-to-front; unchanged on 3.14). architecture/resolution.md now states that this order is not part of the contract -- modern-di manages no finalizer for an object the creator drops, and the previous consistency was an accident of the intermediate list. _CALLS_PER_NODE loses its version conditional: the chain nodes the frame-budget test measures are arity 1, so they cost 2 calls on every interpreter now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review's HIGH finding was right and the claim was mine: architecture/ resolution.md said the ladder changes unretained-transient teardown order on 3.10. It does not. The shipped ladder caps at arity 1, so it never holds more than one named local and there is no order to alter -- verified main-vs-head on 3.10 and 3.14, arities 1 to 3, identical throughout. That paragraph described the 0-3 ladder that was built to measure and then cut. The policy sentence stays, now stated as a rule in advance of the case that would test it, so a future arity-2+ rung is a performance change rather than a breaking one. Also adds the guard the review asked for: nothing pinned which closure the positional path selects. The rungs are semantically identical to the generic star-call and from 3.12 PEP 709 hides the frame difference, so deleting a rung was green on four of the six CI interpreters. The new test asserts on __code__.co_name and fails everywhere. Two stale comments corrected (the ladder is 0-1 plus generic for 2+, not 0-3 plus generic for 4+), the navigate test now says what it actually pins, and performance.md's chain figure matches the measurement (-28%, not -26%). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Benchmark
Details
| Benchmark suite | Current: ec94fc1 | Previous: 7f55f03 | Ratio |
|---|---|---|---|
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type |
2276846.541656665 iter/sec (stddev: 3.0185343312491743e-7) |
2295057.5943520525 iter/sec (stddev: 2.477924662167445e-7) |
1.01 |
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry |
3123093.942195286 iter/sec (stddev: 4.898415979294182e-8) |
3106167.9622164024 iter/sec (stddev: 6.438408988129139e-8) |
0.99 |
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve |
24987.96090476353 iter/sec (stddev: 0.00001969410821086861) |
24713.104901661758 iter/sec (stddev: 0.000022868321499111332) |
0.99 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] |
429.96074984089785 iter/sec (stddev: 0.000057251906449292374) |
413.09161381497364 iter/sec (stddev: 0.00005042963022442274) |
0.96 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] |
407.02616938542803 iter/sec (stddev: 0.00004882060749899673) |
383.4927081757524 iter/sec (stddev: 0.00018277505176802526) |
0.94 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] |
367.9742412030899 iter/sec (stddev: 0.00005136884338569824) |
346.30869432449407 iter/sec (stddev: 0.0002241198977665019) |
0.94 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] |
2309.189106308829 iter/sec (stddev: 0.000021521308135524023) |
2230.799107747824 iter/sec (stddev: 0.00003471157723067548) |
0.97 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] |
1630.6708975533422 iter/sec (stddev: 0.00030808760541747453) |
1578.0462832313435 iter/sec (stddev: 0.0003681845712738398) |
0.97 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] |
1160.952410529504 iter/sec (stddev: 0.00003003667091185055) |
1131.9743735719787 iter/sec (stddev: 0.00003659473061876335) |
0.98 |
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container |
685919.493774569 iter/sec (stddev: 4.837666305423177e-7) |
676794.6367644009 iter/sec (stddev: 5.643683532124717e-7) |
0.99 |
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope |
654401.8140464262 iter/sec (stddev: 4.3599195196964145e-7) |
626280.309390803 iter/sec (stddev: 5.085169609354341e-7) |
0.96 |
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch |
2363.856068808091 iter/sec (stddev: 0.000011669253159199248) |
2274.659776954733 iter/sec (stddev: 0.00001464510575467446) |
0.96 |
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control |
62691.24705127847 iter/sec (stddev: 0.0000017963998665956632) |
61957.80377036186 iter/sec (stddev: 0.0000024906514198048712) |
0.99 |
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale |
44961.18785214712 iter/sec (stddev: 0.0000037141369126515837) |
45141.9595450775 iter/sec (stddev: 0.0000021236200041330786) |
1.00 |
benchmarks/test_guard_resolve.py::test_g1_transient_resolve |
1345985.4721209616 iter/sec (stddev: 4.7543045026757105e-7) |
1320911.0672164438 iter/sec (stddev: 4.2515223184363403e-7) |
0.98 |
benchmarks/test_guard_resolve.py::test_g2_cached_resolve |
3284588.9540896043 iter/sec (stddev: 5.89233745695595e-8) |
3304335.6830659998 iter/sec (stddev: 5.8957136300947385e-8) |
1.01 |
benchmarks/test_guard_resolve.py::test_g3_deep_chain |
662621.3200623099 iter/sec (stddev: 4.883211758189919e-7) |
508147.4371612656 iter/sec (stddev: 9.233782926271046e-7) |
0.77 |
benchmarks/test_guard_resolve.py::test_g4_wide_resolve |
382581.7613295835 iter/sec (stddev: 7.001249797097095e-7) |
322709.3708331218 iter/sec (stddev: 6.50508554498725e-7) |
0.84 |
benchmarks/test_guard_resolve.py::test_g5_cross_scope |
1434892.5294801414 iter/sec (stddev: 2.708569728136716e-7) |
1127053.2180166484 iter/sec (stddev: 8.766473431181968e-7) |
0.79 |
benchmarks/test_guard_resolve.py::test_g9_context_resolve |
747371.1731502282 iter/sec (stddev: 4.6846949418190295e-7) |
735341.8273441993 iter/sec (stddev: 5.112925859473325e-7) |
0.98 |
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve |
495532.6865919711 iter/sec (stddev: 3.131354086813528e-7) |
389987.7860240243 iter/sec (stddev: 4.87202235934519e-7) |
0.79 |
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain |
27720.811510119023 iter/sec (stddev: 0.0000034976038163168573) |
27207.46003885525 iter/sec (stddev: 0.000005633799438326581) |
0.98 |
benchmarks/test_guard_validate.py::test_g11_validate_wide |
17224.84237280646 iter/sec (stddev: 0.0000043166787689960554) |
16704.857218432375 iter/sec (stddev: 0.000007571759175927327) |
0.97 |
This comment was automatically generated by workflow using github-action-benchmark.
4 tasks
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.
Why
The positional fast path built its arguments with a list comprehension and star-called the creator, even though
len(pos)is fixed the moment a resolver is compiled. Retiresplanning/deferred/2026-08-01-arity-specialised-creator-call.md.Design
Arity 0 and 1 compile to a dedicated closure that names its argument and calls the creator directly — no list build, no
CALL_FUNCTION_EXunpack, and below 3.12 no comprehension frame. Arity 2+ falls through to the unchanged generic star-call.The ladder stops at 1 on evidence, not taste. I built the 0-3 ladder the deferred item proposed and measured it first. It bought nothing beyond arity 1 — both benchmark scenarios use only arities 0 and 1 (
Deptakes none;Serviceand every chain node take one) — while taking coverage from 100% to 87%. That is the cost the item understated: a rung is not one extra binding site, it is a full copy of the closure's whole branch set (override guard, scope hop, closed-target reopen, both error handlers), each needing its own tests.Cutting to 0-1 measured better than the 4-rung version, because my first prototype branched on arity inside the closure; separate closures have no runtime branch.
Non-goals
architecture/resolution.mdnow states in advance that unretained-transient teardown order is not a contract, so such a rung is a performance change rather than a breaking one.Verification
just test-ci: 502 passed, 100% line coverage on both 3.14 and 3.10.just lint-ci: clean.Measured against
mainwith the repo's A/B/A harness, CPython 3.14:g1_transientg3_chain(depth 6)g4_wideg4_wideimproves even though its own factory stays on the generic star-call — its arity-0 leaves do not._CALLS_PER_NODEloses its version conditional: the chain nodes the frame-budget test measures are arity 1, so they cost 2 calls on every interpreter now.What the review caught
An independent review ran 110 differential cases across two interpreters (22 creator kinds × 5 scenarios) and found the closures byte-identical to the generic path — including that the two
tryblocks are not merged, which would have misattributed a dependency'sTypeErroras a creator-call error. It then found three defects, all mine, none in the closures:architecture/resolution.mdclaimed the ladder flips teardown order for unretained transient dependencies on 3.10. It does not: capping at arity 1 means there is never more than one named local, so there is no order to alter. I verified main-vs-head on 3.10 and 3.14 at arities 1-3 — identical throughout. That paragraph described the 0-3 ladder I built to measure and then cut. This also means the ruling that licensed this change was made on a premise the shipped version does not carry; the policy statement is kept deliberately, as a rule stated ahead of the case that would test it.__code__.co_name, failing everywhere.Before merging
architecture/performance.md(the frame budget and why the ladder stops at 1) andarchitecture/resolution.md(positional path, and the teardown-order rule).just lint-ciandjust test-cipass, on 3.14 and 3.10.