Skip to content

Pre-size DI scope resolved-services dictionary (capped at 36)#128630

Draft
Copilot wants to merge 4 commits into
mainfrom
copilot/improve-di-allocations
Draft

Pre-size DI scope resolved-services dictionary (capped at 36)#128630
Copilot wants to merge 4 commits into
mainfrom
copilot/improve-di-allocations

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 27, 2026

DI scope resolution can spend a noticeable amount of allocation budget growing the per-scope ResolvedServices dictionary as scoped services are added. This change reduces those early resizes by sizing the dictionary from the container's scoped registrations instead of always starting from the default capacity, while capping the pre-allocated capacity to avoid regressions on sparse-resolution scopes.

  • Capacity selection

    • Count scoped registrations when building the ServiceProvider.
    • Cap the computed capacity at 36 (Math.Min(scopedServices, 36)), which lands on prime tier 37 via HashHelpers.GetPrime, bounding worst-case live allocation per scope to ~1.6 KB.
    • Store that capped count once and use it as the initial capacity for non-root scope ResolvedServices.
  • Scope behavior

    • Leave the root scope on the default dictionary capacity (allocated once per process; the cap does not need to apply there).
    • Only apply pre-sizing to child scopes, where the scoped cache actually grows per request / operation.
  • Coverage

    • Add focused tests that verify:
      • child scopes are initialized with the scoped-registration count (up to the cap)
      • the capacity is clamped to 36 when more than 36 scoped services are registered
      • the root scope keeps default sizing
private static int CalculateResolvedServicesCapacity(ICollection<ServiceDescriptor> serviceDescriptors)
{
    int scopedServices = 0;
    foreach (ServiceDescriptor serviceDescriptor in serviceDescriptors)
    {
        if (serviceDescriptor.Lifetime == ServiceLifetime.Scoped)
        {
            scopedServices++;
        }
    }
    return Math.Min(scopedServices, 36);
}

Copilot AI self-assigned this May 27, 2026
Copilot AI review requested due to automatic review settings May 27, 2026 10:03
Copilot AI review requested due to automatic review settings May 27, 2026 10:03
Copilot AI linked an issue May 27, 2026 that may be closed by this pull request
@rosebyte rosebyte changed the title [WIP] Improve DI allocations in Orchard CMS [WIP] Improve DI allocations by pre-allocating scope dictionary May 27, 2026
Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com>
Copilot AI requested review from Copilot and removed request for Copilot May 27, 2026 10:35
Copilot AI changed the title [WIP] Improve DI allocations by pre-allocating scope dictionary Pre-size DI scope resolved-services dictionary May 27, 2026
Copilot AI requested a review from rosebyte May 27, 2026 10:36
@rosebyte
Copy link
Copy Markdown
Member

@EgorBot -intel -amd --filter "Microsoft.Extensions.DependencyInjection.GetService*"

@rosebyte
Copy link
Copy Markdown
Member

@EgorBot -intel -amd

using System;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.Extensions.DependencyInjection;

BenchmarkSwitcher.FromAssembly(typeof(ScopeBench).Assembly).Run(args);

[MemoryDiagnoser]
public class ScopeBench
{
    private IServiceProvider _provider = default!;
    private Type[] _resolveTypes = default!;

    [Params(20, 50, 100, 200)]
    public int Registered;

    [Params(5, 20, 50)]
    public int Resolutions;

    [GlobalSetup]
    public void Setup()
    {
        Type[] types = EmitTypes(Registered);
        var services = new ServiceCollection();
        foreach (Type t in types)
            services.AddScoped(t);
        _provider = services.BuildServiceProvider();
        _resolveTypes = types.Take(Math.Min(Resolutions, Registered)).ToArray();
    }

    [Benchmark]
    public void CreateScopeAndResolve()
    {
        using IServiceScope scope = _provider.CreateScope();
        IServiceProvider sp = scope.ServiceProvider;
        for (int i = 0; i < _resolveTypes.Length; i++)
            sp.GetService(_resolveTypes[i]);
    }

    private static Type[] EmitTypes(int n)
    {
        AssemblyBuilder asm = AssemblyBuilder.DefineDynamicAssembly(
            new AssemblyName("ScopeBench.Svcs"), AssemblyBuilderAccess.Run);
        ModuleBuilder mod = asm.DefineDynamicModule("m");
        var types = new Type[n];
        for (int i = 0; i < n; i++)
        {
            TypeBuilder tb = mod.DefineType(
                $"Svc{i}", TypeAttributes.Public | TypeAttributes.Class);
            types[i] = tb.CreateType()!;
        }
        return types;
    }
}

Copilot AI review requested due to automatic review settings May 27, 2026 17:18
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adjusts Microsoft.Extensions.DependencyInjection scope initialization to reduce ResolvedServices dictionary growth by pre-sizing child-scope caches based on the number of scoped registrations in the container.

Changes:

  • Compute and store an internal ResolvedServicesCapacity in ServiceProvider based on the count of scoped ServiceDescriptors.
  • Initialize ServiceProviderEngineScope.ResolvedServices with that capacity for non-root scopes (root scope remains at default sizing).
  • Add tests to validate child-scope pre-sizing and root-scope default sizing.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceProvider.cs Adds ResolvedServicesCapacity and logic to compute it from scoped registrations during provider construction.
src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceLookup/ServiceProviderEngineScope.cs Uses ResolvedServicesCapacity to pre-size ResolvedServices for child scopes.
src/libraries/Microsoft.Extensions.DependencyInjection/tests/DI.Tests/ServiceProviderEngineScopeTests.cs Adds coverage asserting child vs. root scope dictionary sizing behavior.

Comment on lines 24 to 28
public ServiceProviderEngineScope(ServiceProvider provider, bool isRootScope)
{
ResolvedServices = new Dictionary<ServiceCacheKey, object?>();
ResolvedServices = new Dictionary<ServiceCacheKey, object?>(isRootScope ? 0 : provider.ResolvedServicesCapacity);
RootProvider = provider;
IsRootScope = isRootScope;
Comment on lines +54 to +58
var provider = new ServiceProvider(services, ServiceProviderOptions.Default);
var scope = new ServiceProviderEngineScope(provider, isRootScope: false);

Assert.Equal(new Dictionary<ServiceCacheKey, object?>(2).EnsureCapacity(0), scope.ResolvedServices.EnsureCapacity(0));
}
@rosebyte
Copy link
Copy Markdown
Member

@copilot , The benchmark results show the implementation is missing the cap that bounds per-scope over-allocation, so the patch regresses on sparse-resolution scopes (e.g. Registered=200, Resolutions=5 allocates ~13× more than main). Please apply the following changes.

  1. Cap CalculateResolvedServicesCapacity at 36.

In src/libraries/Microsoft.Extensions.DependencyInjection/src/ServiceProvider.cs, change the method to:

private static int CalculateResolvedServicesCapacity(ICollection serviceDescriptors)
{
int scopedServices = 0;
foreach (ServiceDescriptor serviceDescriptor in serviceDescriptors)
{
if (serviceDescriptor.Lifetime == ServiceLifetime.Scoped)
{
scopedServices++;
}
}
return Math.Min(scopedServices, 36);
}

The constant 36 lands on prime tier 37 via HashHelpers.GetPrime, bounding worst-case live allocation per scope to ~1.6 KB. This caps the regression observed on sparse-resolution scopes while still skipping the 3 → 7 → 17 resize sequence that dominates the garbage volume on main.

  1. Leave the rest of the patch as-is. Specifically:
  • Keep counting ServiceLifetime.Scoped only. Transients are not cached in ResolvedServices, so they must not be counted.
  • Keep isRootScope ? 0 : provider.ResolvedServicesCapacity in ServiceProviderEngineScope. Root is allocated once per process; the cap does not need to apply there.
  1. Update the unit test. In tests/DI.Tests/ServiceProviderEngineScopeTests.cs, the existing ScopeResolvedServicesIsPreSizedToScopedRegistrations test asserts capacity equals scoped count (currently 2), which still passes under the cap. Add a second case asserting that with 50 scoped registrations the capacity is clamped to 36 (compare against new Dictionary<ServiceCacheKey, object?>(36).EnsureCapacity(0)).

  2. Build and test before pushing. From the repo root:

./build.sh clr+libs -rc release
cd src/libraries/Microsoft.Extensions.DependencyInjection
dotnet build /t:test ./tests/DI.Tests/Microsoft.Extensions.DependencyInjection.Tests.csproj

All tests must pass.

…lation

Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 27, 2026

@copilot , The benchmark results show the implementation is missing the cap that bounds per-scope over-allocation, so the patch regresses on sparse-resolution scopes (e.g. Registered=200, Resolutions...

Done. Changes in the latest commit:

  1. CalculateResolvedServicesCapacity capped at 36return Math.Min(scopedServices, 36); bounds worst-case per-scope allocation while still skipping the early resize sequence for typical containers.

  2. New test ScopeResolvedServicesCapacityIsClampedAt36 — registers 50 scoped services and asserts provider.ResolvedServicesCapacity == 36, plus a #if NET-guarded dictionary capacity comparison.

  3. Fixed existing tests for cross-framework compilation — the prior EnsureCapacity calls didn't compile on net481; wrapped them in #if NET and added TFM-agnostic assertions (provider.ResolvedServicesCapacity / Count) so the tests run on both targets.

Copilot AI changed the title Pre-size DI scope resolved-services dictionary Pre-size DI scope resolved-services dictionary (capped at 36) May 27, 2026
@rosebyte
Copy link
Copy Markdown
Member

@EgorBot -intel -amd

using System;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.Extensions.DependencyInjection;

BenchmarkSwitcher.FromAssembly(typeof(ScopeBench).Assembly).Run(args);

[MemoryDiagnoser]
public class ScopeBench
{
    private IServiceProvider _provider = default!;
    private Type[] _resolveTypes = default!;

    [Params(20, 50, 100, 200)]
    public int Registered;

    [Params(5, 20, 50)]
    public int Resolutions;

    [GlobalSetup]
    public void Setup()
    {
        Type[] types = EmitTypes(Registered);
        var services = new ServiceCollection();
        foreach (Type t in types)
            services.AddScoped(t);
        _provider = services.BuildServiceProvider();
        _resolveTypes = types.Take(Math.Min(Resolutions, Registered)).ToArray();
    }

    [Benchmark]
    public void CreateScopeAndResolve()
    {
        using IServiceScope scope = _provider.CreateScope();
        IServiceProvider sp = scope.ServiceProvider;
        for (int i = 0; i < _resolveTypes.Length; i++)
            sp.GetService(_resolveTypes[i]);
    }

    private static Type[] EmitTypes(int n)
    {
        AssemblyBuilder asm = AssemblyBuilder.DefineDynamicAssembly(
            new AssemblyName("ScopeBench.Svcs"), AssemblyBuilderAccess.Run);
        ModuleBuilder mod = asm.DefineDynamicModule("m");
        var types = new Type[n];
        for (int i = 0; i < n; i++)
        {
            TypeBuilder tb = mod.DefineType(
                $"Svc{i}", TypeAttributes.Public | TypeAttributes.Class);
            types[i] = tb.CreateType()!;
        }
        return types;
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DI allocations improvements

3 participants