Skip to content

Commit 66c7093

Browse files
committed
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.
1 parent b7f65d6 commit 66c7093

4 files changed

Lines changed: 159 additions & 34 deletions

File tree

bench/Autofac.Benchmarks/BenchmarkSet.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public static class BenchmarkSet
2929
typeof(EnumerableResolveBenchmark),
3030
typeof(PropertyInjectionBenchmark),
3131
typeof(RootContainerResolveBenchmark),
32+
typeof(ResolvePipelineAllocationBenchmark),
3233
typeof(OpenGenericBenchmark),
3334
typeof(MultiConstructorBenchmark),
3435
typeof(LambdaResolveBenchmark),
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) Autofac Project. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
namespace Autofac.Benchmarks;
5+
6+
/// <summary>
7+
/// Tests the per-resolve allocation cost of running the resolve pipeline. Each
8+
/// resolve walks the built middleware chain, so a closure allocated per stage
9+
/// per invocation (rather than once at pipeline build time) shows up here as
10+
/// extra allocations that scale with graph depth. See issue #1493.
11+
/// </summary>
12+
public class ResolvePipelineAllocationBenchmark
13+
{
14+
private IContainer _container = default!;
15+
16+
[GlobalSetup]
17+
public void Setup()
18+
{
19+
var builder = new ContainerBuilder();
20+
builder.RegisterType<Service>().As<IService>();
21+
builder.RegisterType<Dependency>().As<IDependency>();
22+
builder.RegisterType<Consumer>().As<IConsumer>();
23+
_container = builder.Build();
24+
}
25+
26+
[Benchmark(Baseline = true)]
27+
public IService NoDependencies() => _container.Resolve<IService>();
28+
29+
[Benchmark]
30+
public IConsumer OneDependency() => _container.Resolve<IConsumer>();
31+
32+
public interface IService
33+
{
34+
}
35+
36+
public interface IDependency
37+
{
38+
}
39+
40+
public interface IConsumer
41+
{
42+
}
43+
44+
public sealed class Service : IService
45+
{
46+
}
47+
48+
public sealed class Dependency : IDependency
49+
{
50+
}
51+
52+
public sealed class Consumer : IConsumer
53+
{
54+
public Consumer(IDependency dependency)
55+
{
56+
_ = dependency;
57+
}
58+
}
59+
}

src/Autofac/Core/Resolving/Pipeline/ResolvePipelineBuilder.cs

Lines changed: 65 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -140,23 +140,34 @@ private static ResolvePipeline BuildPipeline(MiddlewareDeclaration? lastDecl)
140140
var current = lastDecl;
141141
var currentInvoke = _terminateAction;
142142

143-
Action<ResolveRequestContext> BuildMiddlewareChain(Action<ResolveRequestContext> next, IResolveMiddleware stage)
143+
while (current is not null)
144144
{
145+
var stage = current.Middleware;
146+
145147
// MetricsEnabled is static readonly (set once at startup), so checking here
146148
// at pipeline build time avoids a per-invocation branch in every middleware.
147-
return AutofacMetrics.MetricsEnabled
148-
? BuildMetricsMiddlewareChain(next, stage)
149-
: BuildStandardMiddlewareChain(next, stage);
149+
currentInvoke = AutofacMetrics.MetricsEnabled
150+
? BuildMetricsMiddlewareChain(currentInvoke, stage)
151+
: BuildStandardMiddlewareChain(currentInvoke, stage);
152+
current = current.Previous;
150153
}
151154

152-
Action<ResolveRequestContext> BuildMetricsMiddlewareChain(Action<ResolveRequestContext> next, IResolveMiddleware stage)
153-
{
154-
var stagePhase = stage.Phase;
155-
var stageName = stage.ToString()!;
155+
return new ResolvePipeline(currentInvoke);
156+
}
156157

157-
// Metrics are captured around each stage execution while preserving
158-
// diagnostics callbacks (if enabled for the current request).
159-
return context => ExecuteWithDiagnostics(context, stage, () =>
158+
private static Action<ResolveRequestContext> BuildMetricsMiddlewareChain(Action<ResolveRequestContext> next, IResolveMiddleware stage)
159+
{
160+
var stagePhase = stage.Phase;
161+
var stageName = stage.ToString()!;
162+
163+
// Metrics are captured around each stage execution while preserving
164+
// diagnostics callbacks (if enabled for the current request). This lambda
165+
// must only close over build-time state (next, stage, stagePhase, stageName)
166+
// so it is allocated once per pipeline build rather than once per resolve.
167+
// See issue #1493.
168+
return context =>
169+
{
170+
if (!context.DiagnosticSource.IsEnabled())
160171
{
161172
context.PhaseReached = stagePhase;
162173
var timer = ValueStopwatch.StartNew();
@@ -168,37 +179,66 @@ Action<ResolveRequestContext> BuildMetricsMiddlewareChain(Action<ResolveRequestC
168179
{
169180
AutofacMetrics.RecordMiddlewareExecution(stageName, timer.GetElapsedTime());
170181
}
171-
});
172-
}
173182

174-
Action<ResolveRequestContext> BuildStandardMiddlewareChain(Action<ResolveRequestContext> next, IResolveMiddleware stage)
175-
{
176-
var stagePhase = stage.Phase;
183+
return;
184+
}
177185

178-
// Hot path when execution metrics are disabled.
179-
return context => ExecuteWithDiagnostics(context, stage, () =>
186+
context.DiagnosticSource.MiddlewareStart(context, stage);
187+
var succeeded = false;
188+
try
180189
{
181190
context.PhaseReached = stagePhase;
182-
stage.Execute(context, next);
183-
});
184-
}
191+
var timer = ValueStopwatch.StartNew();
192+
try
193+
{
194+
stage.Execute(context, next);
195+
}
196+
finally
197+
{
198+
AutofacMetrics.RecordMiddlewareExecution(stageName, timer.GetElapsedTime());
199+
}
200+
201+
succeeded = true;
202+
}
203+
finally
204+
{
205+
if (succeeded)
206+
{
207+
context.DiagnosticSource.MiddlewareSuccess(context, stage);
208+
}
209+
else
210+
{
211+
context.DiagnosticSource.MiddlewareFailure(context, stage);
212+
}
213+
}
214+
};
215+
}
185216

186-
static void ExecuteWithDiagnostics(ResolveRequestContext context, IResolveMiddleware stage, Action action)
217+
private static Action<ResolveRequestContext> BuildStandardMiddlewareChain(Action<ResolveRequestContext> next, IResolveMiddleware stage)
218+
{
219+
var stagePhase = stage.Phase;
220+
221+
// Hot path when execution metrics are disabled. This lambda must only close
222+
// over build-time state (next, stage, stagePhase) so it is allocated once per
223+
// pipeline build rather than once per resolve. See issue #1493.
224+
return context =>
187225
{
188226
// Same basic flow in if/else, but doing a one-time check for diagnostics
189227
// and choosing the "diagnostics enabled" version vs. the more common
190228
// "no diagnostics enabled" path: hot-path optimization.
191229
if (!context.DiagnosticSource.IsEnabled())
192230
{
193-
action();
231+
context.PhaseReached = stagePhase;
232+
stage.Execute(context, next);
194233
return;
195234
}
196235

197236
context.DiagnosticSource.MiddlewareStart(context, stage);
198237
var succeeded = false;
199238
try
200239
{
201-
action();
240+
context.PhaseReached = stagePhase;
241+
stage.Execute(context, next);
202242
succeeded = true;
203243
}
204244
finally
@@ -212,16 +252,7 @@ static void ExecuteWithDiagnostics(ResolveRequestContext context, IResolveMiddle
212252
context.DiagnosticSource.MiddlewareFailure(context, stage);
213253
}
214254
}
215-
}
216-
217-
while (current is not null)
218-
{
219-
var stage = current.Middleware;
220-
currentInvoke = BuildMiddlewareChain(currentInvoke, stage);
221-
current = current.Previous;
222-
}
223-
224-
return new ResolvePipeline(currentInvoke);
255+
};
225256
}
226257

227258
private bool InsertRangeWithinExistingStages(

test/Autofac.Test/Core/Pipeline/PipelineBuilderTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,40 @@ public void CannotAddBadPhaseToPipelineInUseRangeExistingMiddleware()
425425
}));
426426
}
427427

428+
// Regression test for https://github.com/autofac/Autofac/issues/1493. In 9.2 the
429+
// built middleware chain wrapped each stage in a lambda that captured the
430+
// per-invocation ResolveRequestContext, so a fresh closure was allocated for every
431+
// stage on every resolve. The built pipeline should close only over build-time
432+
// state, so invoking it must not allocate on the hot path.
433+
[Fact]
434+
public void InvokingBuiltPipelineDoesNotAllocatePerInvocation()
435+
{
436+
var pipelineBuilder = new ResolvePipelineBuilder(PipelineType.Service);
437+
pipelineBuilder.Use(PipelinePhase.ResolveRequestStart, (context, next) => next(context));
438+
pipelineBuilder.Use(PipelinePhase.ScopeSelection, (context, next) => next(context));
439+
pipelineBuilder.Use(PipelinePhase.Sharing, (context, next) => next(context));
440+
441+
var built = pipelineBuilder.Build();
442+
var context = new PipelineRequestContextStub();
443+
444+
// Warm up so JIT compilation and any first-run allocations happen before we measure.
445+
for (var i = 0; i < 100; i++)
446+
{
447+
built.Invoke(context);
448+
}
449+
450+
const int Iterations = 1000;
451+
var before = GC.GetAllocatedBytesForCurrentThread();
452+
for (var i = 0; i < Iterations; i++)
453+
{
454+
built.Invoke(context);
455+
}
456+
457+
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
458+
459+
Assert.Equal(0, allocated);
460+
}
461+
428462
[SuppressMessage("CA1001", "CA1001", Justification = "This is an expedient test stub; we don't really care if proper disposal for internal stubs happens.")]
429463
private class PipelineRequestContextStub : ResolveRequestContext
430464
{

0 commit comments

Comments
 (0)