perf(alias): call the source's compiled resolver directly - #413
Merged
Conversation
…ailed resolve Guards the compiled alias closure's no-cache design: a source registered after an AliasSourceNotRegisteredError must resolve on the next attempt. Verified this fails against a closure that binds its source once and never rechecks.
…ming comment review fixes for the alias-hop docs: the "Inlined memo hits" paragraph still said "two"/"both" after the alias row was added, and the new test's comment claimed to catch any source-binding closure when it only catches one that survives registry invalidation.
…sed-over param _compile_alias closed over its registry parameter, and the registry memoizes that closure in _resolvers -- forming registry -> _resolvers -> closure -> cell -> registry, the only compiled resolver reclaimable solely by cyclic GC. Read it off the container argument each resolve instead, like every other closure in this module already does. Adds a regression test pinning that no compiled resolver's closure holds its registry.
…sing architecture/performance.md credited find_provider with raising the dangling-source error; find_provider only does the dict lookup and returns None -- Alias._find_source is what raises AliasSourceNotRegisteredError. docs/providers/alias.md said resolving an alias "delegates straight back through the container"; it now calls the source's compiled resolver directly and never re-enters Container.resolve_provider. The overrides/ caching guarantee that phrasing supported is unchanged. Also rewords a test comment that claimed to catch "caching on the provider instance" -- only a negative cache (remembering a miss) fails the test; a positive cache is undetectable by construction, since a registered provider's type can never be replaced and any registration clears the resolver memo anyway.
Record why the two binding variants were declined -- eager bind (-36%) escapes the override front-guard and compiles a subtree behind an active override, and both binds rest on _invalidate() clearing _resolvers, a second place a cross-cutting invariant has to hold. Also records why the closure reads the registry off the container rather than capturing it: the capture made it the one resolver forming a cycle with the memo holding it, and removing it measured free. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Benchmark
Details
| Benchmark suite | Current: 1e82dea | Previous: a16e464 | Ratio |
|---|---|---|---|
benchmarks/test_guard_by_type.py::test_g16_resolve_by_type |
2134212.7330975113 iter/sec (stddev: 2.329906923631136e-7) |
2157679.184026456 iter/sec (stddev: 2.2086925778916556e-7) |
1.01 |
benchmarks/test_guard_by_type.py::test_g17_resolve_by_type_large_registry |
2603980.7401522445 iter/sec (stddev: 5.774287681885966e-8) |
2561601.5739801666 iter/sec (stddev: 6.61663997625778e-8) |
0.98 |
benchmarks/test_guard_cold.py::test_g8_cold_first_resolve |
23823.135504572492 iter/sec (stddev: 0.000021817009775921742) |
24765.450124281982 iter/sec (stddev: 0.000020671180536251292) |
1.04 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[1] |
417.2620203573356 iter/sec (stddev: 0.00032098424975856687) |
427.3157906585653 iter/sec (stddev: 0.00004481761805912352) |
1.02 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[2] |
413.25168461432366 iter/sec (stddev: 0.00009455666646738342) |
383.9949511878359 iter/sec (stddev: 0.00026095131491098844) |
0.93 |
benchmarks/test_guard_concurrency.py::test_g14_concurrent_cached_hit[4] |
362.5634317836494 iter/sec (stddev: 0.0003044806214162895) |
345.7778787908238 iter/sec (stddev: 0.00013367893884017732) |
0.95 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[1] |
2271.153509594799 iter/sec (stddev: 0.00003204945652355996) |
2312.968321874918 iter/sec (stddev: 0.000025317909321053763) |
1.02 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[2] |
1608.4436644158425 iter/sec (stddev: 0.00034032073369183317) |
1675.977138666613 iter/sec (stddev: 0.00030852710726351075) |
1.04 |
benchmarks/test_guard_concurrency.py::test_g15_concurrent_first_resolve[4] |
1157.6458927336016 iter/sec (stddev: 0.00003816028203482761) |
1205.7598545379835 iter/sec (stddev: 0.00003147848844242947) |
1.04 |
benchmarks/test_guard_lifecycle.py::test_g6_build_child_container |
672264.8866307094 iter/sec (stddev: 5.129109267538693e-7) |
667302.5808093378 iter/sec (stddev: 5.422200398260847e-7) |
0.99 |
benchmarks/test_guard_lifecycle.py::test_g6b_build_child_container_auto_scope |
633368.4065873657 iter/sec (stddev: 4.3573769267104523e-7) |
636123.0698327791 iter/sec (stddev: 4.0528130147997476e-7) |
1.00 |
benchmarks/test_guard_lifecycle.py::test_g7_request_lifecycle_batch |
2344.6160098288215 iter/sec (stddev: 0.000012709602200606555) |
2385.918106371512 iter/sec (stddev: 0.000009336725258952414) |
1.02 |
benchmarks/test_guard_lifecycle.py::test_g7c_event_loop_floor_control |
62680.580087988266 iter/sec (stddev: 0.0000018150501869058381) |
60683.097664469766 iter/sec (stddev: 0.000002239612002727796) |
0.97 |
benchmarks/test_guard_lifecycle.py::test_g13_teardown_at_scale |
46111.826183987556 iter/sec (stddev: 0.000002147462105699537) |
46504.60787132248 iter/sec (stddev: 0.0000017649470045943241) |
1.01 |
benchmarks/test_guard_resolve.py::test_g1_transient_resolve |
1308967.2121238988 iter/sec (stddev: 3.378503846238133e-7) |
1331282.2338915556 iter/sec (stddev: 3.4110777527643375e-7) |
1.02 |
benchmarks/test_guard_resolve.py::test_g2_cached_resolve |
3324170.9466147157 iter/sec (stddev: 4.617484643336103e-8) |
3306581.116404159 iter/sec (stddev: 4.481967937007839e-8) |
0.99 |
benchmarks/test_guard_resolve.py::test_g3_deep_chain |
518441.1290136085 iter/sec (stddev: 5.5064992281798e-7) |
515908.14315672015 iter/sec (stddev: 4.676337443904223e-7) |
1.00 |
benchmarks/test_guard_resolve.py::test_g4_wide_resolve |
319422.61207261885 iter/sec (stddev: 6.117355775527118e-7) |
327636.68607242237 iter/sec (stddev: 6.620314641675254e-7) |
1.03 |
benchmarks/test_guard_resolve.py::test_g5_cross_scope |
1153973.6882053404 iter/sec (stddev: 3.720809834879079e-7) |
1164487.7989726416 iter/sec (stddev: 3.621474940944863e-7) |
1.01 |
benchmarks/test_guard_resolve.py::test_g9_context_resolve |
657638.6363960992 iter/sec (stddev: 4.2443425658749763e-7) |
658368.9068660116 iter/sec (stddev: 6.604247826704218e-7) |
1.00 |
benchmarks/test_guard_resolve.py::test_g12_override_active_resolve |
390343.1342516427 iter/sec (stddev: 7.429597958247748e-7) |
389619.24255500396 iter/sec (stddev: 3.96974341124412e-7) |
1.00 |
benchmarks/test_guard_validate.py::test_g10_validate_deep_chain |
27737.671929291286 iter/sec (stddev: 0.000003996848230905147) |
27925.19566129825 iter/sec (stddev: 0.000003597478246370592) |
1.01 |
benchmarks/test_guard_validate.py::test_g11_validate_wide |
16944.52812215858 iter/sec (stddev: 0.000004623954305118071) |
17382.119434395983 iter/sec (stddev: 0.000004193372478755576) |
1.03 |
This comment was automatically generated by workflow using github-action-benchmark.
A RecursionError tears down the trace function. On 3.12+ coverage uses sys.monitoring and is unaffected, but on 3.10 it traces, so the three assertion lines following the recursion ran unrecorded and failed the 100% gate -- with every test passing. Moving the assertion into pytest.raises(match=) leaves nothing after the recursion in the test body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
Aliaswas the one compiled closure that did not reach its dependency's resolver by direct reference. EveryFactoryclosure holds its dependencies' resolvers directly; the alias instead calledAlias._find_source→ProvidersRegistry.find_provider, then re-enteredContainer.resolve_provider. That is four Python frames per alias hop where a factory dependency costs one, on a construct whose whole purpose is to be transparent — re-exporting a concrete type under a protocol costs the user a 4x frame tax for the privilege.This was filed as
planning/deferred/2026-08-01-alias-source-binding.md; its remaining blocker is now moot rather than worked around (see Design).Design
The closure reads the registry off its
containerargument and inlines both lookups, then calls the source's compiled resolver directly:This is the inline-the-fast-path / keep-the-method-for-the-miss pattern already used at
Container.resolve_providerand in_compile_cached_factory;architecture/performance.md's "Inlined memo hits" table gains a third row.The trade: it caches nothing. Binding the source's resolver into the closure measured faster still (-36% vs -22%), but every bind variant is sound only because
_invalidate()clears_resolvers— a second place that invariant has to hold, stated and defended forever after. Re-reading the live registry cannot go stale by construction. The eager variant additionally compiles the alias's whole source subtree behind an active override (len(_resolvers)1 → 1+depth, cold cost +404%), which is exactly themodern-di-pytestmock pattern; doing the lookup after the override front-guard makes the deferred item's blocker moot. Full reasoning inplanning/decisions/2026-08-03-alias-binds-nothing.md.The closure also captures nothing. An earlier revision took the registry as a compile-time parameter to save an attribute load, which made it the only compiled resolver forming
registry → _resolvers → closure → cell → registry— a registry with an alias in it could then be freed only by cyclic GC. Reading it off the container removed the cycle and measured free (250 → 249 ns), and puts the alias back in the shape every other closure already uses.Removing
Container.resolve_providerfrom the hop drops two of its behaviours, both verified as still supplied: the closed-container reopen (every container reaching a dependency resolver was already closed-checked by its caller —_compile_context_providerand_compile_container_provideralready rely on this) and theRecursionError→CircularDependencyErrorconversion, which now happens at the entry frame instead of an interior hop. That is strictly safer:_handle_recursion_errorwalks the graph, and it now does so with an unwound stack. The rendered error for a mutual-alias cycle is byte-identical before and after.Non-goals
planning/decisions/, with a revisit trigger.benchmarks/has no alias scenario; this PR measures ad hoc rather than growing the guard harness alongside a compiler change. The gap sits next to the oneplanning/deferred/2026-07-28-cold-miss-guard-benchmark.mdalready tracks.Alias's semantics — scope transparency,redirect_target, validation, and override behaviour are untouched.Verification
just test-ci: 466 passed, 100% line coverage.just lint-ci: clean (ruff, ty, planning frontmatter, Markdown links/anchors).Tests added:
test_alias_hop_costs_exactly_one_resolver_frame— the gate. Difference-basedsys.setprofilecount against the same graph without the alias. Fails at 4 against the old body.test_no_compiled_resolver_closes_over_its_registry— pins the no-capture decision; fails naming_compile_alias.<locals>.resolveagainst the captured-registry revision.test_alias_picks_up_a_source_registered_after_a_failed_resolve— pins the no-cache decision. Catches a negative cache; a success-path cache is undetectable by construction (a registered type's provider can never be replaced, and any registration clears_resolvers).test_overridden_alias_compiles_nothing_of_its_source— guards front-guard ordering; fails under an eager-bind mutation.test_alias_resolves_from_a_closed_container_with_warningandtest_mutual_alias_cycle_raises_circular_dependency_at_runtime— characterization, written before the change, pinning whatresolve_providercontributed.Measurement,
timeit200k iterations × 15 repeats, median, A/B/A across amainworktree and this branch on CPython 3.14:main-22%. Runs: main 323.0 / 322.7 / 322.0; branch 251.9 / 249.0 / 260.2.
Concurrency spot-check: 6 threads × 664,133 alias resolves interleaved with 300 live
add_providers()calls — 0 errors, 0 wrong results. The closure performs the identical two dictgets in the identical order on the identical registry the old path used, so a resolve racing a registration has exactly the prior exposure.Before merging
architecture/providers.md(theAliasparagraph) andarchitecture/performance.md(third "Inlined memo hits" row, plus the counts that row invalidated) hand-edited in this PR.planning/decisions/2026-08-03-alias-binds-nothing.md— both bind variants and the registry capture, each with a revisit trigger.planning/deferred/2026-07-28-cold-miss-guard-benchmark.mdalready tracks the same gap in the same harness.just lint-ciandjust test-cipass.Retires
planning/deferred/2026-08-01-alias-source-binding.md.