Cut module load cost, stop duplicating registrations into ApplicationModule - #36
Merged
Merged
Conversation
…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
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.
Registering 200 services takes 6 microseconds. The first
AddModulescall 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 bareServiceCollection.That reframed the target: emit and execute less code on the path that runs once, rather than make the registration loop faster.
Measured
AddModulesAddModulesAddModulesNative 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:
ProcessModuleEnvironmentbuilt aConcurrentDictionaryon everyAddModulescall to serve a cache most applications never read. Allocated on first process read instead. 0.363ms → 0.024ms.List.Contains, which routes throughEqualityComparer<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 asymmetricEqualsbehaves as it did.DecoratorRegistrationwas areadonly struct, soList<T>andOrderBywere instantiated fresh for it — 44 JIT-ed methods to sort three decorators. Now asealed class, and the ordering is a stable insertion sort with no LINQ.ArraySegment<T>.Emptyand reached the empty case by building an enumerator.Array.Empty<T>()plus anICollection.Counttest instead.DependencyRegistry<T>allocate on first use, and theSystem.Linqtokens inGetModulesmoved behind a non-inlined method so the assembly is not loaded for applications that never callAddModule.Correctness fix
FindOrCreateEnvironmentran its guard before its lookup, soDependencyRegistry<T>.ApplyServices(sc),ApplyDecorators(sc)and the generatedIDependencyModule.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.csgets anApplicationModulewhether 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, soAddModule<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.RegistrationTargetsis the single filter all four writers go through, so a new writer cannot reintroduce the duplicate by forgetting.Behaviour changes to note
DecoratorRegistrationstruct → class is binary breaking for assemblies compiled against the current runtime. Fine at rc, but it wants a version bump and a CHANGELOG entry.ApplicationModulealongside the module it defers to now registers each service once; it previously registered everything twice. A fix, but a change.Tried and rejected
DependencyRegistry<T>— nine of them never touchT. Built and measured: 2.63ms vs 2.65ms, identical JIT counts.AggressiveInliningacross the load path — no effect; the cost is not per-call overhead.is IServiceCollectionConfigurationstyle 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 inDependencyRegistryTests. 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.VersionSuffixinDirectory.Build.propsis the fallback local and CI builds use — verifieddotnet packproducesDependencyModules.Runtime.1.0.0-rc9230.nupkg, andscripts/verify-packages.shpasses for net8.0 and net10.0. A release still takes its version from the tag, so cutting this needsgit tag v1.0.0-rc9230 && git push origin v1.0.0-rc9230after 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