From 67db00e63011f1742a7fd8841711a09a0e8542d7 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Thu, 12 Mar 2026 21:49:50 +0100 Subject: [PATCH] Add closure resolution benchmark --- .../ClosureCaptureBenchmark.cs | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ClosureCaptureBenchmark.cs diff --git a/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ClosureCaptureBenchmark.cs b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ClosureCaptureBenchmark.cs new file mode 100644 index 0000000..c962ff7 --- /dev/null +++ b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ClosureCaptureBenchmark.cs @@ -0,0 +1,198 @@ +using System.Linq.Expressions; +using BenchmarkDotNet.Attributes; +using EntityFrameworkCore.Projectables.Benchmarks.Helpers; +using EntityFrameworkCore.Projectables.Services; +using Microsoft.EntityFrameworkCore; + +namespace EntityFrameworkCore.Projectables.Benchmarks +{ + /// + /// Benchmarks two closure-capture scenarios that exercise + /// ProjectableExpressionReplacer.VisitMember: + /// + /// + /// + /// Value closure + /// + /// A local int is captured per iteration (delta = i % 10). + /// In Full mode, VisitMember evaluates the closure field on every + /// query execution to check whether it is an inlinable IQueryable. + /// Previously this used Expression.Lambda(...).Compile().Invoke() + /// (one JIT compilation per field per iteration); now it uses + /// FieldInfo.GetValue() which is significantly cheaper. + /// + /// + /// + /// Sub-query closure + /// + /// An IQueryable sub-query is captured in a closure and inlined into + /// the outer query at expansion time. Full mode pays this cost on every + /// iteration; Limited mode pays it only on the first EF Core cache miss. + /// + /// + /// + /// + /// + /// Three benchmark groups are provided, from least to most accurate: + /// + /// *_ValueClosure / *_SubQueryClosure — end-to-end via + /// ToQueryString() (includes EF Core cache lookup + SQL formatting) + /// Isolated_Replace_* — calls ProjectableExpressionReplacer.Replace() + /// directly with a pre-built expression tree; zero EF Core pipeline overhead + /// + /// + /// + /// Run with: + /// dotnet run -c Release -- --filter "*ClosureCapture*" + /// + [MemoryDiagnoser] + public class ClosureCaptureBenchmark + { + // ── DbContexts — created once in GlobalSetup, NOT per benchmark invocation ── + // The original benchmark constructed a new TestDbContext inside each benchmark + // method, adding ~N ms of DI/EF startup noise to every invocation. + private TestDbContext _ctxBaseline = null!; + private TestDbContext _ctxFull = null!; + private TestDbContext _ctxLimited = null!; + + // ── Sub-queries for Scenario B ──────────────────────────────────────── + // Stored as fields so DbContext lifetime is controlled by GlobalSetup/Cleanup. + // Each benchmark method re-captures them into a *local variable* so the C# + // compiler generates a <>c__DisplayClass closure — the exact type that + // VisitMember checks for (NestedPrivate + CompilerGenerated). + // Capturing a field directly would embed 'this' instead and skip the code path. + private IQueryable _subBaseline = null!; + private IQueryable _subFull = null!; + private IQueryable _subLimited = null!; + + // ── Replacer + pre-built trees for isolated benchmarks ──────────────── + // The isolated benchmarks call Replace() directly, bypassing: + // • EF Core compiled-query cache lookup + // • SQL generation and string formatting + // • ParameterExtractingExpressionVisitor + // This isolates the pure expression-tree-rewrite cost. + private ProjectableExpressionReplacer _replacer = null!; + private Expression _valueClosureExpr = null!; + private Expression _subQueryExpr = null!; + + [GlobalSetup] + public void Setup() + { + _ctxBaseline = new TestDbContext(false); + _ctxFull = new TestDbContext(true, useFullCompatibiltyMode: true); + _ctxLimited = new TestDbContext(true, useFullCompatibiltyMode: false); + + _subBaseline = _ctxBaseline.Entities.Where(x => x.Id > 5); + _subFull = _ctxFull.Entities.Where(x => x.IdPlus1 > 5); + _subLimited = _ctxLimited.Entities.Where(x => x.IdPlus1 > 5); + + // Build expression trees used by the isolated benchmarks. + // Using local captures so the compiler generates the <>c__DisplayClass + // closures that VisitMember expects. + var delta = 5; + _valueClosureExpr = _ctxFull.Entities.Select(e => e.IdPlusDelta(delta)).Expression; + + var sub = _ctxFull.Entities.Where(x => x.IdPlus1 > 5); + _subQueryExpr = _ctxFull.Entities.Where(e => sub.Any(s => s.Id == e.Id)).Expression; + + _replacer = new ProjectableExpressionReplacer(new ProjectionExpressionResolver(), trackByDefault: false); + } + + [GlobalCleanup] + public void Cleanup() + { + _ctxBaseline.Dispose(); + _ctxFull.Dispose(); + _ctxLimited.Dispose(); + } + + // ── Scenario A: value closure (int captured per call) ───────────────── + // A fixed delta=5 is used so the EF Core compiled-query cache is warm after + // BDN's own warmup phase. All results are then per-single-query overhead, + // directly comparable with the Isolated_Replace_* µs numbers below. + + /// Baseline: no projectables, int closure captured per call. + [Benchmark(Baseline = true)] + public string Baseline_ValueClosure() + { + var delta = 5; + return _ctxBaseline.Entities.Select(e => e.Id + delta).ToQueryString(); + } + + /// + /// Full mode: projectable method call with a closure-captured int argument. + /// VisitMember evaluates the 'delta' field via reflection on every call. + /// + [Benchmark] + public string Full_ValueClosure() + { + var delta = 5; + return _ctxFull.Entities.Select(e => e.IdPlusDelta(delta)).ToQueryString(); + } + + /// + /// Limited mode: expansion runs only on EF Core cache miss (first call per + /// query shape); per-call closure evaluation overhead is near zero. + /// + [Benchmark] + public string Limited_ValueClosure() + { + var delta = 5; + return _ctxLimited.Entities.Select(e => e.IdPlusDelta(delta)).ToQueryString(); + } + + // ── Scenario B: IQueryable sub-query closure ────────────────────────── + + /// Baseline: no projectables, sub-IQueryable captured in a closure. + [Benchmark] + public string Baseline_SubQueryClosure() + { + var sub = _subBaseline; + return _ctxBaseline.Entities.Where(e => sub.Any(s => s.Id == e.Id)).ToQueryString(); + } + + /// + /// Full mode: sub-IQueryable captured in a closure; the sub-query itself uses + /// a projectable property so both the inlining path and the projectable + /// expansion path are exercised on every call. + /// + [Benchmark] + public string Full_SubQueryClosure() + { + var sub = _subFull; + return _ctxFull.Entities.Where(e => sub.Any(s => s.Id == e.Id)).ToQueryString(); + } + + /// + /// Limited mode: expansion occurs only on the first EF Core cache miss; + /// subsequent calls hit the compiled-query cache directly. + /// + [Benchmark] + public string Limited_SubQueryClosure() + { + var sub = _subLimited; + return _ctxLimited.Entities.Where(e => sub.Any(s => s.Id == e.Id)).ToQueryString(); + } + + // ── Isolated: pure ProjectableExpressionReplacer.Replace() cost ─────── + // No EF Core pipeline. No SQL generation. No string formatting. + // Now on the same µs scale as the ToQueryString benchmarks above, so you + // can directly subtract to get: EF Core pipeline cost = Full_* − Isolated_Replace_* + + /// + /// Pure replacer cost for a value-closure query (no EF Core involved). + /// + [Benchmark] + public Expression Isolated_Replace_ValueClosure() + => _replacer.Replace(_valueClosureExpr); + + /// + /// Pure replacer cost for a sub-query-closure query, including the recursive + /// visit of the inlined sub-query expression (no EF Core involved). + /// + [Benchmark] + public Expression Isolated_Replace_SubQueryClosure() + => _replacer.Replace(_subQueryExpr); + } +} +