Skip to content

perf(compiler): arity-specialise the positional creator call - #420

Merged
lesnik512 merged 2 commits into
mainfrom
perf/arity-call
Aug 3, 2026
Merged

perf(compiler): arity-specialise the positional creator call#420
lesnik512 merged 2 commits into
mainfrom
perf/arity-call

Conversation

@lesnik512

Copy link
Copy Markdown
Member

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. Retires planning/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_EX unpack, 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 (Dep takes none; Service and 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

  • No rung at arity 2+. If one is ever added, architecture/resolution.md now 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.
  • The cached-factory cold-miss builder is untouched — off the warm path, and the item notes it was never measured.

Verification

just test-ci: 502 passed, 100% line coverage on both 3.14 and 3.10. just lint-ci: clean.

Measured against main with the repo's A/B/A harness, CPython 3.14:

scenario before after
g1_transient 353.6 ns 236.9 ns -33.0%
g3_chain (depth 6) 940.6 ns 676.7 ns -28.1%
g4_wide 1661 ns 1321 ns -20.5%

g4_wide improves even though its own factory stays on the generic star-call — its arity-0 leaves do not.

_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.

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 try blocks are not merged, which would have misattributed a dependency's TypeError as a creator-call error. It then found three defects, all mine, none in the closures:

  • I documented a behaviour change that this PR does not make. architecture/resolution.md claimed 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.
  • Nothing pinned which closure the path selects. The rungs are semantically identical to the generic call, and from 3.12 PEP 709 hides the frame difference — so deleting a rung was green on four of the six CI interpreters, and the two that failed blamed an extracted helper. Now asserted on __code__.co_name, failing everywhere.
  • Two stale comments (the ladder is 0-1 + generic for 2+, not 0-3 + generic for 4+) and a chain figure that understated the win.

Before merging

  • Behaviour changed? architecture/performance.md (the frame budget and why the ladder stops at 1) and architecture/resolution.md (positional path, and the teardown-order rule).
  • Rejected an alternative? The 0-3 ladder — measured, rejected, and recorded in the commit and here rather than as a decision doc, since it is a sizing choice within a shipped change.
  • Found real work you are not doing now? No.
  • just lint-ci and just test-ci pass, on 3.14 and 3.10.

lesnik512 and others added 2 commits August 3, 2026 17:30
`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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@lesnik512
lesnik512 merged commit af6394c into main Aug 3, 2026
9 checks passed
@lesnik512
lesnik512 deleted the perf/arity-call branch August 3, 2026 15:19
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