simpleinjector / SimpleInjector

An easy, flexible, and fast Dependency Injection library that promotes best practice to steer developers towards the pit of success.
https://simpleinjector.org
MIT License
1.21k stars 155 forks source link

Is the performance overhead caused by hybrid lifestyle in container verification and instance creation expected? #945

Closed torerikh closed 1 year ago

torerikh commented 2 years ago

Background

My container has ~900 registrations, of which ~200 are reported as roots by container verification. Verification is done explicitly.

Due to challenges with excessive time spent in transient object creation I'm trying to make the move to scoped lifestyle (Scenario C). As I'm not able to establish scope in all parts of the codebase (parts are legacy winforms and is not easily refactored to always have a scope) I'm trying to use a hybrid scope but seeing some dramatic performance hits.

Currently using version 5.0.3, but have checked release-notes for newer versions and cannot find any explicit mention of fixes in this area.

NOTE: All timings below are taken using dotTrace using method level timings, meaning results might be worse than in a pure runtime scenario. NOTE: Memory consumption is measured using VS diagnostics toolwindow during debug (no profiler)

In general registrations are done without specifying any lifestyle e.g.

container.Register<IReportHelper, ReportHelper>();

Known facts

The end solution is most likely a combination of moving to singletons and/or always having a scope as that would reduce the overall amount of instanciations that needs to be made, but it looks like the current behaviour might be a bug / unexpected behaviour.

Performance hit by hybrid lifestyle in container verification

Scenario A - I'm using the following as a "baseline" for my scenarios (transient only)

var container = new Container();
container.Options.DefaultScopedLifestyle = new AsyncScopedLifestyle();
container.Verify();

Verification completes in ~13s under profiler. It shows the following where time is spent (stack is very simplified): Consumed memory during call to Verify in debug under debugger: ~16MB

SimpleInjector.Container.VerifyThatAllRootObjectsCanBeCreated(Scope) (12 293 ms)
    SimpleInjector.InstanceProducer.BuildAndReplaceInstanceCreatorAndCreateFirstInstance() (12 279 ms) (209 calls)
        Registered instances ..ctor (8 000 000 calls)
        SimpleInjector.Advanced.DefaultExpressionCompilationBehavior.Compile(Expression) (2 500 ms) (1285 calls)
        SimpleInjector.Internals.CompilationHelpers.ReduceObjectGraphSize(Expression, Container, Dictionary) (5 900 ms) (1 285 calls)
            SimpleInjector.Internals.CompilationHelpers+NodeReplacer.Visit(Expression) (3 638 ms) (8 000 000 calls)
SimpleInjector.Container.VerifyThatAllExpressionsCanBeBuilt() (351 ms)

Scenario B - I'm using this as a second "baseline" for my scenarios (async scoped only)

var container = new Container();
container.Options.DefaultScopedLifestyle = new AsyncScopedLifestyle();
container.Options.DefaultLifestyle = Lifestyle.Scoped;
container.Verify();

Verification completes in ~2.5s Consumed memory during call to Verify in debug under debugger: ~14MB

SimpleInjector.Container.VerifyThatAllRootObjectsCanBeCreated(Scope) (433 ms)
    SimpleInjector.InstanceProducer.BuildAndReplaceInstanceCreatorAndCreateFirstInstance() (420 ms) (209 calls)
        Registered instances ..ctor (1 call per constructor)
        SimpleInjector.Advanced.DefaultExpressionCompilationBehavior.Compile(Expression) (117 ms) (209 calls)
        SimpleInjector.Internals.CompilationHelpers.OptimizeScopedRegistrationsInObjectGraph(Container, Expression) (70 ms) (209 calls)
        SimpleInjector.Internals.CompilationHelpers.ReduceObjectGraphSize(Expression, Container, Dictionary) (1 ms) (209 calls)
SimpleInjector.Container.VerifyThatAllExpressionsCanBeBuilt() (1 750 ms)
    SimpleInjector.Registration.BuildTransientDelegate() (1615 ms) (581 calls)

Scenarion C - Hybrid lifestyle

var container = new Container();
container.Options.DefaultScopedLifestyle = new AsyncScopedLifestyle();
container.Options.DefaultLifestyle = Lifestyle.CreateHybrid(
    defaultLifestyle: new AsyncScopedLifestyle(), 
    fallbackLifestyle: Lifestyle.Transient);
container.Verify();

Verification completed in ~79s Consumed memory during call to Verify in debug under debugger: ~192MB

SimpleInjector.Container.VerifyThatAllRootObjectsCanBeCreated(Scope) (25 460 ms)
    SimpleInjector.InstanceProducer.BuildAndReplaceInstanceCreatorAndCreateFirstInstance() (25 442 ms) (209 calls)
        Registered instances ..ctor (1 call per constructor)
        SimpleInjector.Advanced.DefaultExpressionCompilationBehavior.Compile(Expression) (10 400 ms) (2 025 calls)
        SimpleInjector.Internals.CompilationHelpers.OptimizeScopedRegistrationsInObjectGraph(Container, Expression) (1 676 ms) (2 025 calls)
        SimpleInjector.Internals.CompilationHelpers.ReduceObjectGraphSize(Expression, Container, Dictionary) (23 500 ms) (2 025 calls)
            SimpleInjector.Internals.CompilationHelpers+NodeReplacer.Visit(Expression) (11 301 ms) (32 252 019 calls)
SimpleInjector.Container.VerifyThatAllExpressionsCanBeBuilt() (54 059 ms)
    SimpleInjector.Registration.BuildTransientDelegate() (53 542 ms) (582 calls)

As can be seen execution time and memory hit of the hybrid approach in verification is quite extreme. Is this expected?

Scenarion A vs Scenarion C in object instanciation

Scenario C has a very different behaviour when it comes to object instanciation. As part of the codebase I have the following

Private Property ControlRunners As IEnumerable(Of IControlRunner)

And I'm iterating over these. Not surprisingly when using transient lifestyle this creates quite a lot of overhead but what I'm seeing when using the hybrid lifestyle above when no scope is defined is that I'm getting a ~95% increase in execution time iteating this list. I'd expect very little overhead compared to the transient case. Especially since there are relatively few calls to GetInstance itself.

The increase comes from SimpleInjector.Lifestyles.HybridLifestyle+<>c__DisplayClass10_0.<CreateRegistrationCore>b__0()

100,00 %   ToList  •  135 006 ms  •  59 calls  •  System.Linq.Enumerable.ToList(IEnumerable)
  100,00 %   CopyTo  •  135 006 ms  •  59 calls  •  SimpleInjector.Internals.ContainerControlledCollection`1.CopyTo(TService[], Int32)
    100,00 %   GetInstance  •  135 005 ms  •  767 calls  •  SimpleInjector.Internals.ContainerControlledCollection`1.GetInstance(InstanceProducer)
      100,00 %   GetInstance  •  135 004 ms  •  767 calls  •  SimpleInjector.InstanceProducer.GetInstance
        44,10 %   <CreateRegistrationCore>b__0  •  59 535 ms  •  46 874 910 calls  •  SimpleInjector.Lifestyles.HybridLifestyle+<>c__DisplayClass10_0.<CreateRegistrationCore>b__0
          41,95 %   <CreateHybrid>b__0  •  56 637 ms  •  46 874 910 calls  •  SimpleInjector.Lifestyle+<>c__DisplayClass14_0.<CreateHybrid>b__0(Container)
            39,96 %   GetCurrentScope  •  53 951 ms  •  46 874 910 calls  •  SimpleInjector.ScopedLifestyle.GetCurrentScope(Container)
              38,05 %   GetCurrentScopeCore  •  51 366 ms  •  46 874 910 calls  •  SimpleInjector.Lifestyles.AsyncScopedLifestyle.GetCurrentScopeCore(Container)
                24,46 %   GetScopeManager  •  33 026 ms  •  46 874 910 calls  •  SimpleInjector.Lifestyles.AsyncScopedLifestyle.GetScopeManager(Container)
                  18,75 %   GetOrSetItem  •  25 313 ms  •  46 874 910 calls  •  SimpleInjector.ContainerScope.GetOrSetItem(Object, Func)
                    16,41 %   GetOrSetItem  •  22 161 ms  •  46 874 910 calls  •  SimpleInjector.Scope.GetOrSetItem(Object, Func)
                  3,29 %   [Garbage collection]  •  4 443 ms  •  239 calls
                10,80 %   GetCurrentScopeWithAutoCleanup  •  14 584 ms  •  46 874 910 calls  •  SimpleInjector.Lifestyles.ScopeManager.GetCurrentScopeWithAutoCleanup

(registration ..ctors are left out, but in the hybrid scenario, some of them (but not all) are instanciated inside LazyScopedRegistration and LazyScope

Is this 50% increase in instanciation time expected?

dotnetjunkie commented 2 years ago

Thank you for taking the time to do such a thorough analysis. I'm a bit baffled by the behavior you are experiencing, and have no good answer for you, except: although there are obviously performance differences between lifestyles, you shouldn't expect such bizarre difference when applying a hybrid lifestyle. This behavior certainly conflicts with Simple Injector's design principles which state Simple Injector should be fast by default.

I need to investigate this issue further before I'm able to state anything useful. Unfortunately, I'm the coming month will be quite busy for me, so I'm unsure whether I have the time to dive into this in a timely manner.

For the mean time, here are some ideas:

torerikh commented 2 years ago

Will it be of any value to you if I created a repro-case? I can probably recreate a similar dependency-graph. I would believe it needs to be of similar complexity to reproduce similar behaviour.

dotnetjunkie commented 2 years ago

That would certainly be very helpful.