From 66c7093f139336f661094284261b2c9a17615b7d Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Thu, 9 Jul 2026 09:44:19 -0700 Subject: [PATCH 1/6] Fix per-resolve closure allocation in resolve pipeline (#1493) Since 9.2, the built middleware chain wrapped each stage in an Action passed to a shared ExecuteWithDiagnostics helper. That inner lambda captured the per-invocation ResolveRequestContext, so a fresh closure was allocated for every middleware stage on every resolve, regressing allocations vs. 9.1. Inline the diagnostics/metrics logic into each build-time lambda so they close only over build-time state (next, stage, stagePhase, stageName) and are allocated once per pipeline build, restoring 9.1 allocation behavior. Both the standard and metrics-enabled chains are fixed. Add a ResolvePipelineAllocationBenchmark mirroring the reported repro, and a PipelineBuilderTests regression test asserting a built pipeline allocates zero bytes per invocation. --- bench/Autofac.Benchmarks/BenchmarkSet.cs | 1 + .../ResolvePipelineAllocationBenchmark.cs | 59 +++++++++++ .../Pipeline/ResolvePipelineBuilder.cs | 99 ++++++++++++------- .../Core/Pipeline/PipelineBuilderTests.cs | 34 +++++++ 4 files changed, 159 insertions(+), 34 deletions(-) create mode 100644 bench/Autofac.Benchmarks/ResolvePipelineAllocationBenchmark.cs diff --git a/bench/Autofac.Benchmarks/BenchmarkSet.cs b/bench/Autofac.Benchmarks/BenchmarkSet.cs index 7583dc043..316b1a0e7 100644 --- a/bench/Autofac.Benchmarks/BenchmarkSet.cs +++ b/bench/Autofac.Benchmarks/BenchmarkSet.cs @@ -29,6 +29,7 @@ public static class BenchmarkSet typeof(EnumerableResolveBenchmark), typeof(PropertyInjectionBenchmark), typeof(RootContainerResolveBenchmark), + typeof(ResolvePipelineAllocationBenchmark), typeof(OpenGenericBenchmark), typeof(MultiConstructorBenchmark), typeof(LambdaResolveBenchmark), diff --git a/bench/Autofac.Benchmarks/ResolvePipelineAllocationBenchmark.cs b/bench/Autofac.Benchmarks/ResolvePipelineAllocationBenchmark.cs new file mode 100644 index 000000000..054386690 --- /dev/null +++ b/bench/Autofac.Benchmarks/ResolvePipelineAllocationBenchmark.cs @@ -0,0 +1,59 @@ +// Copyright (c) Autofac Project. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace Autofac.Benchmarks; + +/// +/// Tests the per-resolve allocation cost of running the resolve pipeline. Each +/// resolve walks the built middleware chain, so a closure allocated per stage +/// per invocation (rather than once at pipeline build time) shows up here as +/// extra allocations that scale with graph depth. See issue #1493. +/// +public class ResolvePipelineAllocationBenchmark +{ + private IContainer _container = default!; + + [GlobalSetup] + public void Setup() + { + var builder = new ContainerBuilder(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + _container = builder.Build(); + } + + [Benchmark(Baseline = true)] + public IService NoDependencies() => _container.Resolve(); + + [Benchmark] + public IConsumer OneDependency() => _container.Resolve(); + + public interface IService + { + } + + public interface IDependency + { + } + + public interface IConsumer + { + } + + public sealed class Service : IService + { + } + + public sealed class Dependency : IDependency + { + } + + public sealed class Consumer : IConsumer + { + public Consumer(IDependency dependency) + { + _ = dependency; + } + } +} diff --git a/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs b/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs index b58ba64b8..32c9dca27 100644 --- a/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs +++ b/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs @@ -140,23 +140,34 @@ private static ResolvePipeline BuildPipeline(MiddlewareDeclaration? lastDecl) var current = lastDecl; var currentInvoke = _terminateAction; - Action BuildMiddlewareChain(Action next, IResolveMiddleware stage) + while (current is not null) { + var stage = current.Middleware; + // MetricsEnabled is static readonly (set once at startup), so checking here // at pipeline build time avoids a per-invocation branch in every middleware. - return AutofacMetrics.MetricsEnabled - ? BuildMetricsMiddlewareChain(next, stage) - : BuildStandardMiddlewareChain(next, stage); + currentInvoke = AutofacMetrics.MetricsEnabled + ? BuildMetricsMiddlewareChain(currentInvoke, stage) + : BuildStandardMiddlewareChain(currentInvoke, stage); + current = current.Previous; } - Action BuildMetricsMiddlewareChain(Action next, IResolveMiddleware stage) - { - var stagePhase = stage.Phase; - var stageName = stage.ToString()!; + return new ResolvePipeline(currentInvoke); + } - // Metrics are captured around each stage execution while preserving - // diagnostics callbacks (if enabled for the current request). - return context => ExecuteWithDiagnostics(context, stage, () => + private static Action BuildMetricsMiddlewareChain(Action next, IResolveMiddleware stage) + { + var stagePhase = stage.Phase; + var stageName = stage.ToString()!; + + // Metrics are captured around each stage execution while preserving + // diagnostics callbacks (if enabled for the current request). This lambda + // must only close over build-time state (next, stage, stagePhase, stageName) + // so it is allocated once per pipeline build rather than once per resolve. + // See issue #1493. + return context => + { + if (!context.DiagnosticSource.IsEnabled()) { context.PhaseReached = stagePhase; var timer = ValueStopwatch.StartNew(); @@ -168,29 +179,57 @@ Action BuildMetricsMiddlewareChain(Action BuildStandardMiddlewareChain(Action next, IResolveMiddleware stage) - { - var stagePhase = stage.Phase; + return; + } - // Hot path when execution metrics are disabled. - return context => ExecuteWithDiagnostics(context, stage, () => + context.DiagnosticSource.MiddlewareStart(context, stage); + var succeeded = false; + try { context.PhaseReached = stagePhase; - stage.Execute(context, next); - }); - } + var timer = ValueStopwatch.StartNew(); + try + { + stage.Execute(context, next); + } + finally + { + AutofacMetrics.RecordMiddlewareExecution(stageName, timer.GetElapsedTime()); + } + + succeeded = true; + } + finally + { + if (succeeded) + { + context.DiagnosticSource.MiddlewareSuccess(context, stage); + } + else + { + context.DiagnosticSource.MiddlewareFailure(context, stage); + } + } + }; + } - static void ExecuteWithDiagnostics(ResolveRequestContext context, IResolveMiddleware stage, Action action) + private static Action BuildStandardMiddlewareChain(Action next, IResolveMiddleware stage) + { + var stagePhase = stage.Phase; + + // Hot path when execution metrics are disabled. This lambda must only close + // over build-time state (next, stage, stagePhase) so it is allocated once per + // pipeline build rather than once per resolve. See issue #1493. + return context => { // Same basic flow in if/else, but doing a one-time check for diagnostics // and choosing the "diagnostics enabled" version vs. the more common // "no diagnostics enabled" path: hot-path optimization. if (!context.DiagnosticSource.IsEnabled()) { - action(); + context.PhaseReached = stagePhase; + stage.Execute(context, next); return; } @@ -198,7 +237,8 @@ static void ExecuteWithDiagnostics(ResolveRequestContext context, IResolveMiddle var succeeded = false; try { - action(); + context.PhaseReached = stagePhase; + stage.Execute(context, next); succeeded = true; } finally @@ -212,16 +252,7 @@ static void ExecuteWithDiagnostics(ResolveRequestContext context, IResolveMiddle context.DiagnosticSource.MiddlewareFailure(context, stage); } } - } - - while (current is not null) - { - var stage = current.Middleware; - currentInvoke = BuildMiddlewareChain(currentInvoke, stage); - current = current.Previous; - } - - return new ResolvePipeline(currentInvoke); + }; } private bool InsertRangeWithinExistingStages( diff --git a/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs b/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs index a8686a1ee..e2dbc2231 100644 --- a/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs +++ b/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs @@ -425,6 +425,40 @@ public void CannotAddBadPhaseToPipelineInUseRangeExistingMiddleware() })); } + // Regression test for https://github.com/autofac/Autofac/issues/1493. In 9.2 the + // built middleware chain wrapped each stage in a lambda that captured the + // per-invocation ResolveRequestContext, so a fresh closure was allocated for every + // stage on every resolve. The built pipeline should close only over build-time + // state, so invoking it must not allocate on the hot path. + [Fact] + public void InvokingBuiltPipelineDoesNotAllocatePerInvocation() + { + var pipelineBuilder = new ResolvePipelineBuilder(PipelineType.Service); + pipelineBuilder.Use(PipelinePhase.ResolveRequestStart, (context, next) => next(context)); + pipelineBuilder.Use(PipelinePhase.ScopeSelection, (context, next) => next(context)); + pipelineBuilder.Use(PipelinePhase.Sharing, (context, next) => next(context)); + + var built = pipelineBuilder.Build(); + var context = new PipelineRequestContextStub(); + + // Warm up so JIT compilation and any first-run allocations happen before we measure. + for (var i = 0; i < 100; i++) + { + built.Invoke(context); + } + + const int Iterations = 1000; + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < Iterations; i++) + { + built.Invoke(context); + } + + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(0, allocated); + } + [SuppressMessage("CA1001", "CA1001", Justification = "This is an expedient test stub; we don't really care if proper disposal for internal stubs happens.")] private class PipelineRequestContextStub : ResolveRequestContext { From 5988884586997221f5ab32dc35d0a62803d54a66 Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Thu, 9 Jul 2026 09:50:02 -0700 Subject: [PATCH 2/6] Address PR feedback: trim and prefix issue-1493 comments --- .../Resolving/Pipeline/ResolvePipelineBuilder.cs | 15 +++++++-------- .../Core/Pipeline/PipelineBuilderTests.cs | 7 ++----- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs b/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs index 32c9dca27..c363a0c6e 100644 --- a/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs +++ b/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs @@ -160,11 +160,10 @@ private static Action BuildMetricsMiddlewareChain(Action< var stagePhase = stage.Phase; var stageName = stage.ToString()!; - // Metrics are captured around each stage execution while preserving - // diagnostics callbacks (if enabled for the current request). This lambda - // must only close over build-time state (next, stage, stagePhase, stageName) - // so it is allocated once per pipeline build rather than once per resolve. - // See issue #1493. + // Metrics are captured around each stage execution while preserving diagnostics + // callbacks (if enabled for the current request). Issue 1493: this lambda must + // only close over build-time state so it is allocated once per pipeline build + // rather than once per resolve. return context => { if (!context.DiagnosticSource.IsEnabled()) @@ -218,9 +217,9 @@ private static Action BuildStandardMiddlewareChain(Action { var stagePhase = stage.Phase; - // Hot path when execution metrics are disabled. This lambda must only close - // over build-time state (next, stage, stagePhase) so it is allocated once per - // pipeline build rather than once per resolve. See issue #1493. + // Hot path when execution metrics are disabled. Issue 1493: this lambda must only + // close over build-time state so it is allocated once per pipeline build rather + // than once per resolve. return context => { // Same basic flow in if/else, but doing a one-time check for diagnostics diff --git a/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs b/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs index e2dbc2231..265330678 100644 --- a/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs +++ b/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs @@ -425,11 +425,8 @@ public void CannotAddBadPhaseToPipelineInUseRangeExistingMiddleware() })); } - // Regression test for https://github.com/autofac/Autofac/issues/1493. In 9.2 the - // built middleware chain wrapped each stage in a lambda that captured the - // per-invocation ResolveRequestContext, so a fresh closure was allocated for every - // stage on every resolve. The built pipeline should close only over build-time - // state, so invoking it must not allocate on the hot path. + // Issue 1493: a built pipeline must close only over build-time state, so invoking it + // must not allocate a per-invocation closure for each middleware stage on the hot path. [Fact] public void InvokingBuiltPipelineDoesNotAllocatePerInvocation() { From ee1c7bbe9fd0b8afd676ff44f5345fb56360ae11 Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Thu, 9 Jul 2026 09:51:14 -0700 Subject: [PATCH 3/6] Move issue-1493 test comment inside the test body --- test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs b/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs index 265330678..31c834a20 100644 --- a/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs +++ b/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs @@ -425,11 +425,11 @@ public void CannotAddBadPhaseToPipelineInUseRangeExistingMiddleware() })); } - // Issue 1493: a built pipeline must close only over build-time state, so invoking it - // must not allocate a per-invocation closure for each middleware stage on the hot path. [Fact] public void InvokingBuiltPipelineDoesNotAllocatePerInvocation() { + // Issue 1493: a built pipeline must close only over build-time state, so invoking it + // must not allocate a per-invocation closure for each middleware stage on the hot path. var pipelineBuilder = new ResolvePipelineBuilder(PipelineType.Service); pipelineBuilder.Use(PipelinePhase.ResolveRequestStart, (context, next) => next(context)); pipelineBuilder.Use(PipelinePhase.ScopeSelection, (context, next) => next(context)); From 22fa525b3dcef846303c240846a5b60b465d11cd Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Thu, 9 Jul 2026 09:54:33 -0700 Subject: [PATCH 4/6] Fix comments. --- .../Pipeline/ResolvePipelineBuilder.cs | 52 +++++++++++-------- .../Core/Pipeline/PipelineBuilderTests.cs | 8 +-- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs b/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs index c363a0c6e..c0f5d5bcc 100644 --- a/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs +++ b/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs @@ -13,18 +13,19 @@ namespace Autofac.Core.Resolving.Pipeline; /// /// /// -/// The pipeline builder is built as a doubly-linked list; each node in that list is a -/// , that holds the middleware instance, and the reference to the next and previous nodes. +/// The pipeline builder is built as a doubly-linked list; each node in that +/// list is a , that holds the middleware +/// instance, and the reference to the next and previous nodes. /// -/// /// -/// When you call one of the Use* methods, we find the appropriate node in the linked list based on the phase of the new middleware -/// and insert it into the list. +/// When you call one of the Use* methods, we find the appropriate node in the +/// linked list based on the phase of the new middleware and insert it into the +/// list. /// -/// /// -/// When you build a pipeline, we walk back through that set of middleware and generate the concrete call chain so that when you execute the pipeline, -/// we don't iterate over any nodes, but just invoke the built set of methods. +/// When you build a pipeline, we walk back through that set of middleware and +/// generate the concrete call chain so that when you execute the pipeline, we +/// don't iterate over any nodes, but just invoke the built set of methods. /// /// internal class ResolvePipelineBuilder : IResolvePipelineBuilder, IEnumerable @@ -108,8 +109,8 @@ public IResolvePipeline Build() /// public IResolvePipelineBuilder Clone() { - // To clone a pipeline, we create a new instance, then insert the same stage - // objects in the same order. + // To clone a pipeline, we create a new instance, then insert the same + // stage objects in the same order. var newPipeline = new ResolvePipelineBuilder(Type); var currentStage = _first; @@ -136,7 +137,8 @@ IEnumerator IEnumerable.GetEnumerator() private static ResolvePipeline BuildPipeline(MiddlewareDeclaration? lastDecl) { - // When we build, we go through the set and construct a single call stack, starting from the end. + // When we build, we go through the set and construct a single call + // stack, starting from the end. var current = lastDecl; var currentInvoke = _terminateAction; @@ -144,8 +146,9 @@ private static ResolvePipeline BuildPipeline(MiddlewareDeclaration? lastDecl) { var stage = current.Middleware; - // MetricsEnabled is static readonly (set once at startup), so checking here - // at pipeline build time avoids a per-invocation branch in every middleware. + // MetricsEnabled is static readonly (set once at startup), so + // checking here at pipeline build time avoids a per-invocation + // branch in every middleware. currentInvoke = AutofacMetrics.MetricsEnabled ? BuildMetricsMiddlewareChain(currentInvoke, stage) : BuildStandardMiddlewareChain(currentInvoke, stage); @@ -160,10 +163,11 @@ private static Action BuildMetricsMiddlewareChain(Action< var stagePhase = stage.Phase; var stageName = stage.ToString()!; - // Metrics are captured around each stage execution while preserving diagnostics - // callbacks (if enabled for the current request). Issue 1493: this lambda must - // only close over build-time state so it is allocated once per pipeline build - // rather than once per resolve. + // Metrics are captured around each stage execution while preserving + // diagnostics callbacks (if enabled for the current request). + // + // Issue 1493: this lambda must only close over build-time state so it + // is allocated once per pipeline build rather than once per resolve. return context => { if (!context.DiagnosticSource.IsEnabled()) @@ -217,14 +221,16 @@ private static Action BuildStandardMiddlewareChain(Action { var stagePhase = stage.Phase; - // Hot path when execution metrics are disabled. Issue 1493: this lambda must only - // close over build-time state so it is allocated once per pipeline build rather - // than once per resolve. + // Hot path when execution metrics are disabled. + // + // Issue 1493: this lambda must only close over build-time state so it + // is allocated once per pipeline build rather than once per resolve. return context => { - // Same basic flow in if/else, but doing a one-time check for diagnostics - // and choosing the "diagnostics enabled" version vs. the more common - // "no diagnostics enabled" path: hot-path optimization. + // Same basic flow in if/else, but doing a one-time check for + // diagnostics and choosing the "diagnostics enabled" version vs. + // the more common "no diagnostics enabled" path: hot-path + // optimization. if (!context.DiagnosticSource.IsEnabled()) { context.PhaseReached = stagePhase; diff --git a/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs b/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs index 31c834a20..56cd097e3 100644 --- a/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs +++ b/test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs @@ -428,8 +428,9 @@ public void CannotAddBadPhaseToPipelineInUseRangeExistingMiddleware() [Fact] public void InvokingBuiltPipelineDoesNotAllocatePerInvocation() { - // Issue 1493: a built pipeline must close only over build-time state, so invoking it - // must not allocate a per-invocation closure for each middleware stage on the hot path. + // Issue 1493: a built pipeline must close only over build-time state, + // so invoking it must not allocate a per-invocation closure for each + // middleware stage on the hot path. var pipelineBuilder = new ResolvePipelineBuilder(PipelineType.Service); pipelineBuilder.Use(PipelinePhase.ResolveRequestStart, (context, next) => next(context)); pipelineBuilder.Use(PipelinePhase.ScopeSelection, (context, next) => next(context)); @@ -438,7 +439,8 @@ public void InvokingBuiltPipelineDoesNotAllocatePerInvocation() var built = pipelineBuilder.Build(); var context = new PipelineRequestContextStub(); - // Warm up so JIT compilation and any first-run allocations happen before we measure. + // Warm up so JIT compilation and any first-run allocations happen + // before we measure. for (var i = 0; i < 100; i++) { built.Invoke(context); From bca905c4123a0743ae378188e6901d2a9c6029ef Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Thu, 9 Jul 2026 09:55:46 -0700 Subject: [PATCH 5/6] Adjust comments. --- bench/Autofac.Benchmarks/ResolvePipelineAllocationBenchmark.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bench/Autofac.Benchmarks/ResolvePipelineAllocationBenchmark.cs b/bench/Autofac.Benchmarks/ResolvePipelineAllocationBenchmark.cs index 054386690..82c9a2e98 100644 --- a/bench/Autofac.Benchmarks/ResolvePipelineAllocationBenchmark.cs +++ b/bench/Autofac.Benchmarks/ResolvePipelineAllocationBenchmark.cs @@ -7,7 +7,7 @@ namespace Autofac.Benchmarks; /// Tests the per-resolve allocation cost of running the resolve pipeline. Each /// resolve walks the built middleware chain, so a closure allocated per stage /// per invocation (rather than once at pipeline build time) shows up here as -/// extra allocations that scale with graph depth. See issue #1493. +/// extra allocations that scale with graph depth. /// public class ResolvePipelineAllocationBenchmark { From 9b84ca8660b7d70a288cc0268689d4292374542f Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Thu, 9 Jul 2026 09:57:37 -0700 Subject: [PATCH 6/6] Exclude the metrics-only path from coverage. --- src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs b/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs index c0f5d5bcc..22f77055a 100644 --- a/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs +++ b/src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs @@ -158,6 +158,7 @@ private static ResolvePipeline BuildPipeline(MiddlewareDeclaration? lastDecl) return new ResolvePipeline(currentInvoke); } + [ExcludeFromCodeCoverage] private static Action BuildMetricsMiddlewareChain(Action next, IResolveMiddleware stage) { var stagePhase = stage.Phase;