Skip to content

perf(alias): call the source's compiled resolver directly - #413

Merged
lesnik512 merged 9 commits into
mainfrom
perf/alias-source-binding
Aug 3, 2026
Merged

perf(alias): call the source's compiled resolver directly#413
lesnik512 merged 9 commits into
mainfrom
perf/alias-source-binding

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Why

Alias was the one compiled closure that did not reach its dependency's resolver by direct reference. Every Factory closure holds its dependencies' resolvers directly; the alias instead called Alias._find_sourceProvidersRegistry.find_provider, then re-entered Container.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 container argument and inlines both lookups, then calls the source's compiled resolver directly:

    try:
        registry = container.providers_registry
        source = registry._providers.get(source_type)
        if source is None:
            source = find_source(container)  # raises AliasSourceNotRegisteredError
        source_resolver = registry._resolvers.get(source.provider_id)
        if source_resolver is None:
            source_resolver = registry.resolver_for(source)
        return source_resolver(container)
    except _STEP_ERRORS as exc:
        exc.prepend_step(resolution_step())
        raise

This is the inline-the-fast-path / keep-the-method-for-the-miss pattern already used at Container.resolve_provider and 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 the modern-di-pytest mock pattern; doing the lookup after the override front-guard makes the deferred item's blocker moot. Full reasoning in planning/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_provider from 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_provider and _compile_container_provider already rely on this) and the RecursionErrorCircularDependencyError conversion, which now happens at the entry frame instead of an interior hop. That is strictly safer: _handle_recursion_error walks 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

  • No binding, eager or lazy — declined with reasoning in planning/decisions/, with a revisit trigger.
  • No permanent benchmark scenario. 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 one planning/deferred/2026-07-28-cold-miss-guard-benchmark.md already tracks.
  • No change to 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-based sys.setprofile count 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>.resolve against 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_warning and test_mutual_alias_cycle_raises_circular_dependency_at_runtime — characterization, written before the change, pinning what resolve_provider contributed.

Measurement, timeit 200k iterations × 15 repeats, median, A/B/A across a main worktree and this branch on CPython 3.14:

median frames per hop
main ~322 ns 4
this branch ~252 ns 1

-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 dict gets 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

  • Behaviour changed? architecture/providers.md (the Alias paragraph) and architecture/performance.md (third "Inlined memo hits" row, plus the counts that row invalidated) hand-edited in this PR.
  • Rejected an alternative? planning/decisions/2026-08-03-alias-binds-nothing.md — both bind variants and the registry capture, each with a revisit trigger.
  • Found real work you are not doing now? The missing alias benchmark scenario is noted under Non-goals rather than filed, since planning/deferred/2026-07-28-cold-miss-guard-benchmark.md already tracks the same gap in the same harness.
  • New or sharpened domain term? None.
  • just lint-ci and just test-ci pass.

Retires planning/deferred/2026-08-01-alias-source-binding.md.

lesnik512 and others added 8 commits August 2, 2026 14:30
…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>

@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: 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>
@lesnik512
lesnik512 merged commit fc19673 into main Aug 3, 2026
9 checks passed
@lesnik512
lesnik512 deleted the perf/alias-source-binding branch August 3, 2026 10:24
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