Skip to content

Cut module load cost, stop duplicating registrations into ApplicationModule - #36

Merged
ipjohnson merged 2 commits into
mainfrom
perf/module-load-and-auto-module-dedup
Aug 12, 2026
Merged

Cut module load cost, stop duplicating registrations into ApplicationModule#36
ipjohnson merged 2 commits into
mainfrom
perf/module-load-and-auto-module-dedup

Conversation

@ipjohnson

@ipjohnson ipjohnson commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Registering 200 services takes 6 microseconds. The first AddModules call took 4.16ms and the second 0.03ms — so essentially all of it was one-time JIT and type loading, not work that scales with the number of services. An empty module cost 2.9ms against a 0.62ms floor for a bare ServiceCollection.

That reframed the target: emit and execute less code on the path that runs once, rather than make the registration loop faster.

Measured

before after
empty module, first AddModules 2.92ms 1.81ms −38%
200 services, AddModules 4.44ms 3.17ms −29%
assembly IL, 200 services 17,763 B 12,337 B −31%
JIT-ed methods, empty / 200 svc 42 / 641 34 / 633
Native AOT binary 2,281,120 B 2,247,936 B −33 KB
Native AOT AddModules 0.02ms 0.02ms already free

Native AOT startup was never the problem — there is no JIT, so the fixed cost does not exist there. For AOT this is a size change.

Load path

Instrumented rather than guessed, which is how the top two items were found:

  • ProcessModuleEnvironment built a ConcurrentDictionary on every AddModules call to serve a cache most applications never read. Allocated on first process read instead. 0.363ms → 0.024ms.
  • Module discovery used List.Contains, which routes through EqualityComparer<IDependencyModule>.Default. Constructing that for an interface was the most expensive single thing in the load path, to compare a list that usually holds one item. Argument order in the replacement matches the comparer's, so a hand-written asymmetric Equals behaves as it did.
  • DecoratorRegistration was a readonly struct, so List<T> and OrderBy were instantiated fresh for it — 44 JIT-ed methods to sort three decorators. Now a sealed class, and the ordering is a stable insertion sort with no LINQ.
  • The interface defaults returned ArraySegment<T>.Empty and reached the empty case by building an enumerator. Array.Empty<T>() plus an ICollection.Count test instead.
  • The environment lookup and its guard walked the collection twice. One scan now, and the guard looks at the descriptor the container would actually resolve rather than any match.
  • Lists in DependencyRegistry<T> allocate on first use, and the System.Linq tokens in GetModules moved behind a non-inlined method so the assembly is not loaded for applications that never call AddModule.

Correctness fix

FindOrCreateEnvironment ran its guard before its lookup, so DependencyRegistry<T>.ApplyServices(sc), ApplyDecorators(sc) and the generated IDependencyModule.InternalApplyServices(sc) all refused a collection holding an environment registered in the only form they accept — reporting that it was "not registered as a singleton instance" when it was. No test passed an environment to those overloads, which is why it went unnoticed. Fixed, with regression tests covering both the fix and the guard still firing for by-type and factory registrations.

ApplicationModule duplication

A project with a Program.cs gets an ApplicationModule whether or not it declares a module of its own, and both are modules with no realm restriction — so both register every service in the compilation. The registrations, decorations and interceptions were each emitted twice, byte for byte.

The auto module now returns the declared one from InternalGetModules, and the runtime loads it, so AddModule<ApplicationModule>() registers exactly what it always did from one copy. It only defers to a module with no realm restriction and no constructor parameters; otherwise it keeps its own registrations. EntryModelUtil.RegistrationTargets is the single filter all four writers go through, so a new writer cannot reintroduce the duplicate by forgetting.

Behaviour changes to note

  • DecoratorRegistration struct → class is binary breaking for assemblies compiled against the current runtime. Fine at rc, but it wants a version bump and a CHANGELOG entry.
  • Loading ApplicationModule alongside the module it defers to now registers each service once; it previously registered everything twice. A fix, but a change.

Tried and rejected

  • Splitting the non-generic methods out of DependencyRegistry<T> — nine of them never touch T. Built and measured: 2.63ms vs 2.65ms, identical JIT counts.
  • AggressiveInlining across the load path — no effect; the cost is not per-call overhead.
  • Capability flags to skip the is IServiceCollectionConfiguration style tests — those measure at 0.000ms.

Testing

701 unit tests and 171 integration tests pass on net8.0 and net10.0, no warnings. New: AutoModuleDelegationTests (7) and environment-guard coverage in DependencyRegistryTests. The two public-API snapshots are updated for the intended surface changes.

Release

Stamped 1.0.0-rc9230. The last CHANGELOG heading was rc9210 and rc9220 was cut without one, so the accumulated [Unreleased] section is what rc9230 ships and is stamped as that rather than left to grow.

VersionSuffix in Directory.Build.props is the fallback local and CI builds use — verified dotnet pack produces DependencyModules.Runtime.1.0.0-rc9230.nupkg, and scripts/verify-packages.sh passes for net8.0 and net10.0. A release still takes its version from the tag, so cutting this needs git tag v1.0.0-rc9230 && git push origin v1.0.0-rc9230 after merge — nothing is tagged here, since that publishes to nuget.org where a version can be unlisted but never removed. Assembly and file versions carry no prerelease part and are unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_017SAoQBiTT2rmDAZsB9Keg2

Ian Johnson and others added 2 commits August 12, 2026 14:13
…Module

Registering 200 services takes 6us. The first AddModules call took 4.16ms and
the second 0.03ms, so essentially all of it was one-time JIT and type loading
rather than work that scales with the number of services. An empty module cost
2.9ms against a 0.62ms floor for a bare ServiceCollection.

Measured against that, on the load path:

  - ProcessModuleEnvironment built a ConcurrentDictionary on every AddModules
    call to serve a cache most applications never read. Allocated on first
    process read instead. 0.363ms -> 0.024ms.
  - Module discovery used List.Contains, which routes through
    EqualityComparer<IDependencyModule>.Default; constructing that for an
    interface was the single most expensive thing in the load path, to compare
    a list that usually holds one item.
  - DecoratorRegistration was a readonly struct, so List<T> and OrderBy were
    instantiated fresh for it - 44 JIT-ed methods to sort three decorators.
    It is a sealed class now, and the ordering is a stable insertion sort.
  - The interface defaults returned ArraySegment<T>.Empty, and the empty case
    was reached by building an enumerator. Array.Empty<T>() with an
    ICollection.Count test instead.
  - The environment lookup and its guard walked the collection twice. One scan,
    and the guard now looks at the descriptor the container would resolve.
  - Lists in DependencyRegistry<T> allocate on first use, and the System.Linq
    tokens in GetModules moved behind a non-inlined method so the assembly is
    not loaded for the applications that never call AddModule.

FindOrCreateEnvironment ran its guard before its lookup, so the three single
argument entry points - DependencyRegistry<T>.ApplyServices(sc),
ApplyDecorators(sc), and the generated IDependencyModule.InternalApplyServices(sc)
- refused a collection holding an environment registered in the only form they
accept, with a message saying it was not a singleton instance. No test passed
an environment to those overloads, which is why it went unnoticed.

A project with a Program.cs gets an ApplicationModule whether or not it declares
a module of its own, and both register every service in the compilation, so the
registrations, decorations and interceptions were all emitted twice byte for
byte. The auto module now returns the declared one from InternalGetModules and
the runtime loads it, so AddModule<ApplicationModule>() registers what it always
did from one copy. It only defers to a module with no realm restriction and no
constructor parameters, and keeps its own registrations otherwise.

  empty module, first AddModules   2.92ms -> 1.81ms
  200 services, AddModules         4.44ms -> 3.17ms
  assembly IL, 200 services        17,763B -> 12,337B
  JIT-ed methods, empty / 200svc   42/641 -> 34/633
  Native AOT binary                2,281,120B -> 2,247,936B

Native AOT startup was already 0.02ms and is unchanged; there is no JIT there,
so for AOT this is a size change.

Two behaviour changes: DecoratorRegistration going from struct to class is
binary breaking for assemblies compiled against the current runtime, and loading
ApplicationModule alongside the module it defers to now registers each service
once where it previously registered everything twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SAoQBiTT2rmDAZsB9Keg2
The last heading was 1.0.0-rc9210 and rc9220 was cut without one, so the
accumulated Unreleased section is what rc9230 ships; it is stamped as that
rather than left to grow further.

VersionSuffix is the fallback local and CI builds use. A release still takes its
version from the tag, so cutting rc9230 means pushing v1.0.0-rc9230. Assembly
and file versions carry no prerelease part and are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017SAoQBiTT2rmDAZsB9Keg2
@ipjohnson
ipjohnson merged commit d038c23 into main Aug 12, 2026
2 checks passed
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