Skip to content

Commit a97ee28

Browse files
authored
Merge pull request #189 from EFNext/optimize-resolver-again
Remove all allocations when resolving and make it even faster
2 parents b80ac37 + 3872b0d commit a97ee28

File tree

3 files changed

+51
-26
lines changed

3 files changed

+51
-26
lines changed

benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ExpressionResolverBenchmark.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public class ExpressionResolverBenchmark
2222

2323
private static readonly MemberInfo _methodWithParamMember =
2424
typeof(TestEntity).GetMethod(nameof(TestEntity.IdPlusDelta), new[] { typeof(int) })!;
25+
26+
private static readonly MemberInfo _copyConstructorMember =
27+
typeof(TestEntity).GetConstructor(new[] { typeof(TestEntity) })!;
2528

2629
private readonly ProjectionExpressionResolver _resolver = new();
2730

@@ -39,6 +42,10 @@ public class ExpressionResolverBenchmark
3942
public LambdaExpression? ResolveMethodWithParam_Registry()
4043
=> _resolver.FindGeneratedExpression(_methodWithParamMember);
4144

45+
[Benchmark]
46+
public LambdaExpression? ResolveCopyConstructor_Registry()
47+
=> _resolver.FindGeneratedExpression(_copyConstructorMember);
48+
4249
// ── Reflection path ───────────────────────────────────────────────────
4350

4451
[Benchmark]
@@ -52,5 +59,9 @@ public class ExpressionResolverBenchmark
5259
[Benchmark]
5360
public LambdaExpression? ResolveMethodWithParam_Reflection()
5461
=> ProjectionExpressionResolver.FindGeneratedExpressionViaReflection(_methodWithParamMember);
62+
63+
[Benchmark]
64+
public LambdaExpression? ResolveCopyConstructor_Reflection()
65+
=> ProjectionExpressionResolver.FindGeneratedExpressionViaReflection(_copyConstructorMember);
5566
}
5667
}

benchmarks/EntityFrameworkCore.Projectables.Benchmarks/Helpers/TestEntity.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ namespace EntityFrameworkCore.Projectables.Benchmarks.Helpers
88
{
99
public class TestEntity
1010
{
11+
public TestEntity()
12+
{
13+
}
14+
1115
public int Id { get; set; }
1216

1317
[Projectable]
@@ -18,5 +22,11 @@ public class TestEntity
1822

1923
[Projectable]
2024
public int IdPlusDelta(int delta) => Id + delta;
25+
26+
[Projectable]
27+
public TestEntity(TestEntity other)
28+
{
29+
Id = other.Id;
30+
}
2131
}
2232
}

src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -199,43 +199,52 @@ private static bool ParameterTypesMatch(
199199
}
200200

201201
/// <summary>
202-
/// Sentinel stored in <see cref="_reflectionFactoryCache"/> to represent
202+
/// Sentinel stored in <see cref="_reflectionCache"/> to represent
203203
/// "no generated type found for this member", distinguishing it from a not-yet-populated entry.
204204
/// <see cref="ConcurrentDictionary{TKey,TValue}"/> does not allow null values, so a sentinel is required.
205205
/// </summary>
206-
private readonly static Func<LambdaExpression> _reflectionNotFoundSentinel = static () => null!;
206+
private readonly static LambdaExpression _reflectionNullSentinel =
207+
Expression.Lambda(Expression.Empty());
207208

208209
/// <summary>
209-
/// Caches a pre-compiled <c>Func&lt;LambdaExpression&gt;</c> delegate per <see cref="MemberInfo"/>
210-
/// so that <c>Assembly.GetType</c>, <c>GetMethod</c>, <c>MakeGenericType</c>, and
211-
/// <c>MakeGenericMethod</c> are only paid once per member. All subsequent calls execute
212-
/// native JIT-compiled code with zero reflection overhead.
210+
/// Caches the fully-resolved <see cref="LambdaExpression"/> per <see cref="MemberInfo"/>
211+
/// for the reflection-based slow path.
212+
/// On the first call per member the reflection work (<c>Assembly.GetType</c>, <c>GetMethod</c>,
213+
/// <c>MakeGenericType</c>, <c>MakeGenericMethod</c>) is performed once and the resulting
214+
/// expression tree is stored here; subsequent calls return the cached reference directly,
215+
/// eliminating expression-tree re-construction on every access.
216+
/// This is especially important for constructors whose object-initializer trees are
217+
/// significantly more expensive to build than simple method-body trees.
213218
/// </summary>
214-
private readonly static ConcurrentDictionary<MemberInfo, Func<LambdaExpression>> _reflectionFactoryCache = new();
219+
private readonly static ConcurrentDictionary<MemberInfo, LambdaExpression> _reflectionCache = new();
215220

216221
/// <summary>
217222
/// Resolves the <see cref="LambdaExpression"/> for a <c>[Projectable]</c> member using the
218223
/// reflection-based slow path only, bypassing the static registry.
219-
/// Useful for benchmarking and for members not yet in the registry (e.g. open-generic types).
224+
/// The result is cached after the first call, so subsequent calls return the cached expression
225+
/// without any reflection or expression-tree construction overhead.
226+
/// Useful for members not yet in the registry (e.g. open-generic types).
220227
/// </summary>
221228
public static LambdaExpression? FindGeneratedExpressionViaReflection(MemberInfo projectableMemberInfo)
222229
{
223-
var factory = _reflectionFactoryCache.GetOrAdd(projectableMemberInfo, static mi => BuildReflectionFactory(mi));
224-
return ReferenceEquals(factory, _reflectionNotFoundSentinel) ? null : factory.Invoke();
230+
var result = _reflectionCache.GetOrAdd(projectableMemberInfo,
231+
static mi => BuildReflectionExpression(mi) ?? _reflectionNullSentinel);
232+
return ReferenceEquals(result, _reflectionNullSentinel) ? null : result;
225233
}
226234

227235
/// <summary>
228-
/// Performs the one-time reflection work for a member and returns a compiled native delegate
229-
/// (or <see cref="_reflectionNotFoundSentinel"/> if no generated type exists).
236+
/// Performs the one-time reflection work for a member: locates the generated expression
237+
/// accessor (inline or external-class path), invokes it, and returns the resulting
238+
/// <see cref="LambdaExpression"/>. Returns <c>null</c> if no generated type is found.
230239
/// <para>
231-
/// We use <c>Expression.Lambda&lt;TDelegate&gt;(...).Compile()</c> rather than
232-
/// <c>Delegate.CreateDelegate</c> because the generated <c>Expression()</c> factory method
233-
/// returns <c>Expression&lt;TDelegate&gt;</c> (a subtype of <see cref="LambdaExpression"/>), and
234-
/// <c>CreateDelegate</c> requires an exact return-type match in most runtime environments.
235-
/// The expression-tree wrapper handles the covariant cast cleanly and compiles to native code.
240+
/// Using <c>MethodInfo.Invoke</c> rather than a compiled delegate is appropriate here because
241+
/// the result is cached in <see cref="_reflectionCache"/> — the invocation cost is paid only
242+
/// on cache misses, and subsequent EF Core queries reuse the cached expression. Under
243+
/// contention the value factory may be invoked more than once, but only a single expression
244+
/// instance is ultimately stored per member.
236245
/// </para>
237246
/// </summary>
238-
private static Func<LambdaExpression> BuildReflectionFactory(MemberInfo projectableMemberInfo)
247+
private static LambdaExpression? BuildReflectionExpression(MemberInfo projectableMemberInfo)
239248
{
240249
var declaringType = projectableMemberInfo.DeclaringType
241250
?? throw new InvalidOperationException("Expected a valid type here");
@@ -295,7 +304,7 @@ private static Func<LambdaExpression> BuildReflectionFactory(MemberInfo projecta
295304

296305
if (expressionFactoryType is null)
297306
{
298-
return _reflectionNotFoundSentinel;
307+
return null;
299308
}
300309

301310
if (expressionFactoryType.IsGenericTypeDefinition)
@@ -307,20 +316,15 @@ private static Func<LambdaExpression> BuildReflectionFactory(MemberInfo projecta
307316

308317
if (expressionFactoryMethod is null)
309318
{
310-
return _reflectionNotFoundSentinel;
319+
return null;
311320
}
312321

313322
if (projectableMemberInfo is MethodInfo mi && mi.GetGenericArguments() is { Length: > 0 } methodGenericArgs)
314323
{
315324
expressionFactoryMethod = expressionFactoryMethod.MakeGenericMethod(methodGenericArgs);
316325
}
317326

318-
// Compile a native delegate: () => (LambdaExpression)GeneratedClass.Expression()
319-
// Expression.Call + Convert handles the covariant return type (Expression<TDelegate> → LambdaExpression).
320-
// The one-time Compile() cost is amortized; all subsequent calls are direct native-code invocations.
321-
var call = Expression.Call(expressionFactoryMethod);
322-
var cast = Expression.Convert(call, typeof(LambdaExpression));
323-
return Expression.Lambda<Func<LambdaExpression>>(cast).Compile();
327+
return expressionFactoryMethod.Invoke(null, null) as LambdaExpression;
324328
}
325329

326330
/// <summary>

0 commit comments

Comments
 (0)