Skip to content

Commit eb618fe

Browse files
authored
Merge pull request #179 from koenbeuk/feature/closure-benchmark
Add closure resolution benchmark
2 parents 7a3e20c + 67db00e commit eb618fe

1 file changed

Lines changed: 198 additions & 0 deletions

File tree

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
using System.Linq.Expressions;
2+
using BenchmarkDotNet.Attributes;
3+
using EntityFrameworkCore.Projectables.Benchmarks.Helpers;
4+
using EntityFrameworkCore.Projectables.Services;
5+
using Microsoft.EntityFrameworkCore;
6+
7+
namespace EntityFrameworkCore.Projectables.Benchmarks
8+
{
9+
/// <summary>
10+
/// Benchmarks two closure-capture scenarios that exercise
11+
/// <c>ProjectableExpressionReplacer.VisitMember</c>:
12+
///
13+
/// <list type="bullet">
14+
/// <item>
15+
/// <term>Value closure</term>
16+
/// <description>
17+
/// A local <c>int</c> is captured per iteration (<c>delta = i % 10</c>).
18+
/// In Full mode, <c>VisitMember</c> evaluates the closure field on <b>every</b>
19+
/// query execution to check whether it is an inlinable <c>IQueryable</c>.
20+
/// Previously this used <c>Expression.Lambda(...).Compile().Invoke()</c>
21+
/// (one JIT compilation per field per iteration); now it uses
22+
/// <c>FieldInfo.GetValue()</c> which is significantly cheaper.
23+
/// </description>
24+
/// </item>
25+
/// <item>
26+
/// <term>Sub-query closure</term>
27+
/// <description>
28+
/// An <c>IQueryable</c> sub-query is captured in a closure and inlined into
29+
/// the outer query at expansion time. Full mode pays this cost on every
30+
/// iteration; Limited mode pays it only on the first EF Core cache miss.
31+
/// </description>
32+
/// </item>
33+
/// </list>
34+
///
35+
/// <para>
36+
/// Three benchmark groups are provided, from least to most accurate:
37+
/// <list type="number">
38+
/// <item><c>*_ValueClosure</c> / <c>*_SubQueryClosure</c> — end-to-end via
39+
/// <c>ToQueryString()</c> (includes EF Core cache lookup + SQL formatting)</item>
40+
/// <item><c>Isolated_Replace_*</c> — calls <c>ProjectableExpressionReplacer.Replace()</c>
41+
/// directly with a pre-built expression tree; zero EF Core pipeline overhead</item>
42+
/// </list>
43+
/// </para>
44+
///
45+
/// Run with:
46+
/// <code>dotnet run -c Release -- --filter "*ClosureCapture*"</code>
47+
/// </summary>
48+
[MemoryDiagnoser]
49+
public class ClosureCaptureBenchmark
50+
{
51+
// ── DbContexts — created once in GlobalSetup, NOT per benchmark invocation ──
52+
// The original benchmark constructed a new TestDbContext inside each benchmark
53+
// method, adding ~N ms of DI/EF startup noise to every invocation.
54+
private TestDbContext _ctxBaseline = null!;
55+
private TestDbContext _ctxFull = null!;
56+
private TestDbContext _ctxLimited = null!;
57+
58+
// ── Sub-queries for Scenario B ────────────────────────────────────────
59+
// Stored as fields so DbContext lifetime is controlled by GlobalSetup/Cleanup.
60+
// Each benchmark method re-captures them into a *local variable* so the C#
61+
// compiler generates a <>c__DisplayClass closure — the exact type that
62+
// VisitMember checks for (NestedPrivate + CompilerGenerated).
63+
// Capturing a field directly would embed 'this' instead and skip the code path.
64+
private IQueryable<TestEntity> _subBaseline = null!;
65+
private IQueryable<TestEntity> _subFull = null!;
66+
private IQueryable<TestEntity> _subLimited = null!;
67+
68+
// ── Replacer + pre-built trees for isolated benchmarks ────────────────
69+
// The isolated benchmarks call Replace() directly, bypassing:
70+
// • EF Core compiled-query cache lookup
71+
// • SQL generation and string formatting
72+
// • ParameterExtractingExpressionVisitor
73+
// This isolates the pure expression-tree-rewrite cost.
74+
private ProjectableExpressionReplacer _replacer = null!;
75+
private Expression _valueClosureExpr = null!;
76+
private Expression _subQueryExpr = null!;
77+
78+
[GlobalSetup]
79+
public void Setup()
80+
{
81+
_ctxBaseline = new TestDbContext(false);
82+
_ctxFull = new TestDbContext(true, useFullCompatibiltyMode: true);
83+
_ctxLimited = new TestDbContext(true, useFullCompatibiltyMode: false);
84+
85+
_subBaseline = _ctxBaseline.Entities.Where(x => x.Id > 5);
86+
_subFull = _ctxFull.Entities.Where(x => x.IdPlus1 > 5);
87+
_subLimited = _ctxLimited.Entities.Where(x => x.IdPlus1 > 5);
88+
89+
// Build expression trees used by the isolated benchmarks.
90+
// Using local captures so the compiler generates the <>c__DisplayClass
91+
// closures that VisitMember expects.
92+
var delta = 5;
93+
_valueClosureExpr = _ctxFull.Entities.Select(e => e.IdPlusDelta(delta)).Expression;
94+
95+
var sub = _ctxFull.Entities.Where(x => x.IdPlus1 > 5);
96+
_subQueryExpr = _ctxFull.Entities.Where(e => sub.Any(s => s.Id == e.Id)).Expression;
97+
98+
_replacer = new ProjectableExpressionReplacer(new ProjectionExpressionResolver(), trackByDefault: false);
99+
}
100+
101+
[GlobalCleanup]
102+
public void Cleanup()
103+
{
104+
_ctxBaseline.Dispose();
105+
_ctxFull.Dispose();
106+
_ctxLimited.Dispose();
107+
}
108+
109+
// ── Scenario A: value closure (int captured per call) ─────────────────
110+
// A fixed delta=5 is used so the EF Core compiled-query cache is warm after
111+
// BDN's own warmup phase. All results are then per-single-query overhead,
112+
// directly comparable with the Isolated_Replace_* µs numbers below.
113+
114+
/// <summary>Baseline: no projectables, int closure captured per call.</summary>
115+
[Benchmark(Baseline = true)]
116+
public string Baseline_ValueClosure()
117+
{
118+
var delta = 5;
119+
return _ctxBaseline.Entities.Select(e => e.Id + delta).ToQueryString();
120+
}
121+
122+
/// <summary>
123+
/// Full mode: projectable method call with a closure-captured int argument.
124+
/// <c>VisitMember</c> evaluates the 'delta' field via reflection on every call.
125+
/// </summary>
126+
[Benchmark]
127+
public string Full_ValueClosure()
128+
{
129+
var delta = 5;
130+
return _ctxFull.Entities.Select(e => e.IdPlusDelta(delta)).ToQueryString();
131+
}
132+
133+
/// <summary>
134+
/// Limited mode: expansion runs only on EF Core cache miss (first call per
135+
/// query shape); per-call closure evaluation overhead is near zero.
136+
/// </summary>
137+
[Benchmark]
138+
public string Limited_ValueClosure()
139+
{
140+
var delta = 5;
141+
return _ctxLimited.Entities.Select(e => e.IdPlusDelta(delta)).ToQueryString();
142+
}
143+
144+
// ── Scenario B: IQueryable sub-query closure ──────────────────────────
145+
146+
/// <summary>Baseline: no projectables, sub-IQueryable captured in a closure.</summary>
147+
[Benchmark]
148+
public string Baseline_SubQueryClosure()
149+
{
150+
var sub = _subBaseline;
151+
return _ctxBaseline.Entities.Where(e => sub.Any(s => s.Id == e.Id)).ToQueryString();
152+
}
153+
154+
/// <summary>
155+
/// Full mode: sub-IQueryable captured in a closure; the sub-query itself uses
156+
/// a projectable property so both the inlining path and the projectable
157+
/// expansion path are exercised on every call.
158+
/// </summary>
159+
[Benchmark]
160+
public string Full_SubQueryClosure()
161+
{
162+
var sub = _subFull;
163+
return _ctxFull.Entities.Where(e => sub.Any(s => s.Id == e.Id)).ToQueryString();
164+
}
165+
166+
/// <summary>
167+
/// Limited mode: expansion occurs only on the first EF Core cache miss;
168+
/// subsequent calls hit the compiled-query cache directly.
169+
/// </summary>
170+
[Benchmark]
171+
public string Limited_SubQueryClosure()
172+
{
173+
var sub = _subLimited;
174+
return _ctxLimited.Entities.Where(e => sub.Any(s => s.Id == e.Id)).ToQueryString();
175+
}
176+
177+
// ── Isolated: pure ProjectableExpressionReplacer.Replace() cost ───────
178+
// No EF Core pipeline. No SQL generation. No string formatting.
179+
// Now on the same µs scale as the ToQueryString benchmarks above, so you
180+
// can directly subtract to get: EF Core pipeline cost = Full_* − Isolated_Replace_*
181+
182+
/// <summary>
183+
/// Pure replacer cost for a value-closure query (no EF Core involved).
184+
/// </summary>
185+
[Benchmark]
186+
public Expression Isolated_Replace_ValueClosure()
187+
=> _replacer.Replace(_valueClosureExpr);
188+
189+
/// <summary>
190+
/// Pure replacer cost for a sub-query-closure query, including the recursive
191+
/// visit of the inlined sub-query expression (no EF Core involved).
192+
/// </summary>
193+
[Benchmark]
194+
public Expression Isolated_Replace_SubQueryClosure()
195+
=> _replacer.Replace(_subQueryExpr);
196+
}
197+
}
198+

0 commit comments

Comments
 (0)