Skip to content

Avoid descriptor-array allocations in collection lifetime queries (#92) - #104

Merged
AGiorgetti merged 1 commit into
developfrom
codex/92-collection-lifetime-perf
Oct 7, 2026
Merged

AGiorgetti merged 1 commit into
developfrom
codex/92-collection-lifetime-perf

Conversation

@AGiorgetti

Copy link
Copy Markdown
Contributor

Collection lifetime predicates currently materialize every matching descriptor before inspecting the last registration. Keyed predicates also allocate a second filtered array.

Use one private reverse-search helper for all six Type-based lifetime predicates; generic overloads continue to forward to them. Stop at the last descriptor matching keyed status, assignable service type and value-equal key, then compare its lifetime. Preserve argument validation and the public GetServiceDescriptors array contract.

Warmed allocation probes now report 0 B/query in every measured keyed/unkeyed case on .NET 8, 9 and 10. With 10,000 matching descriptors, unkeyed queries previously allocated 80,216–211,928 B and keyed queries 160,376–372,728 B. First-match/no-match cases still require a full scan. Reproduction and full results are in repro/Issue92.CollectionLifetime/README.md.

Validation: 15 new contract cases pass against both the baseline and candidate on net472/net8.0/net9.0/net10.0. All 1,324 tests pass per target; Release library targets include netstandard2.0. Tests cover mixed lifetimes, interleaved registrations, equal-but-distinct keys, assignable types, missing matches, mutations, null keys, AnyKey, validation precedence and descriptor-array order. Existing net472 test dependency support warnings remain. Staged whitespace check passed.

Closes #92.

Co-authored-by: Codex codex@openai.com

Co-authored-by: Codex <codex@openai.com>

Copy link
Copy Markdown
Contributor Author

Implemented solution for #92:

The six collection lifetime predicates now use FindLastServiceDescriptor, which walks the IServiceCollection indexer from Count - 1 toward zero. It returns the first descriptor matching keyed status, serviceType.IsAssignableFrom(descriptor.ServiceType), and—on keyed queries—Equals(descriptor.ServiceKey, serviceKey). The caller compares that descriptor's lifetime.

This preserves last matching registration wins: an earlier registration with the requested lifetime cannot override a later matching registration with a different lifetime. Keyed and unkeyed registrations remain separate; key matching keeps value equality. The helper reads the live collection on every query and uses no descriptor/result cache.

Argument validation and its precedence remain unchanged, including the previous null-collection "source" parameter name. The public GetServiceDescriptors API retains its array-returning filtering/order contract.

15 new contract cases passed against both the original and optimized implementation across net472, net8.0, net9.0 and net10.0. The full suite passes 1,324 tests per target. Production changes are confined to ServiceCollectionExtensions.Registration.cs; the changelog, tests and reproducible allocation probe accompany them.

Copy link
Copy Markdown
Contributor Author

Measured allocation gains for #92 (median bytes/query with 10,000 matching descriptors):

Query .NET 8.0.31 before → after .NET 9.0.20 before → after .NET 10.0.12 before → after
Unkeyed singleton lifetime 211,928 → 0 80,216 → 0 80,216 → 0
Keyed singleton lifetime 372,728 → 0 160,376 → 0 160,376 → 0

The optimized public APIs measured 0 B/query in all 32 cases per runtime, covering collection sizes 0/10/1,000/10,000, keyed/unkeyed registrations, all matches, a match only at the end, a match only at the beginning and no match. Array-based controls preserve the old implementation and reproduce the pre-change public API allocations. Every measured result is checked against the expected boolean.

Baseline: 4887f25. Candidate: d311a1c. Release builds, Microsoft DI 10.0.0, DOTNET_TieredCompilation=0; 1,000 warm-ups followed by seven samples per case. Each sample uses 100–10,000 iterations according to collection size, with collection/finalizer wait before sampling. GC.GetAllocatedBytesForCurrentThread measures allocations; setup, keys, delegates and sample storage are outside measurement.

The reverse scan returns immediately when the last descriptor matches. A match at the beginning or no match still requires a full scan. These are configuration-time manual allocation measurements in a shared environment, with no throughput, timing or net472 allocation claim. Custom key equality can have its own allocation costs.

Full table and runnable probe: repro/Issue92.CollectionLifetime/README.md.

@AGiorgetti
AGiorgetti merged commit c8af7f8 into develop Oct 7, 2026
1 check passed
@AGiorgetti
AGiorgetti deleted the codex/92-collection-lifetime-perf branch October 7, 2026 16:49
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.

Avoid descriptor-array allocations in collection lifetime queries with a reverse scan

1 participant