From 2978391fa4010163d72ec685154daa085aabcca7 Mon Sep 17 00:00:00 2001 From: "fabien.menager" Date: Fri, 20 Mar 2026 23:40:30 +0100 Subject: [PATCH 1/3] Remove all allocations when resolving and make it even faster. Also add a benchmark for constructor resolution --- .../ExpressionResolverBenchmark.cs | 11 ++++ .../Helpers/TestEntity.cs | 10 ++++ .../Services/ProjectionExpressionResolver.cs | 54 ++++++++++--------- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ExpressionResolverBenchmark.cs b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ExpressionResolverBenchmark.cs index 3ab54d7..e08d831 100644 --- a/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ExpressionResolverBenchmark.cs +++ b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/ExpressionResolverBenchmark.cs @@ -22,6 +22,9 @@ public class ExpressionResolverBenchmark private static readonly MemberInfo _methodWithParamMember = typeof(TestEntity).GetMethod(nameof(TestEntity.IdPlusDelta), new[] { typeof(int) })!; + + private static readonly MemberInfo _copyConstructorMember = + typeof(TestEntity).GetConstructor(new[] { typeof(TestEntity) })!; private readonly ProjectionExpressionResolver _resolver = new(); @@ -39,6 +42,10 @@ public class ExpressionResolverBenchmark public LambdaExpression? ResolveMethodWithParam_Registry() => _resolver.FindGeneratedExpression(_methodWithParamMember); + [Benchmark] + public LambdaExpression? ResolveCopyConstructor_Registry() + => _resolver.FindGeneratedExpression(_copyConstructorMember); + // ── Reflection path ─────────────────────────────────────────────────── [Benchmark] @@ -52,5 +59,9 @@ public class ExpressionResolverBenchmark [Benchmark] public LambdaExpression? ResolveMethodWithParam_Reflection() => ProjectionExpressionResolver.FindGeneratedExpressionViaReflection(_methodWithParamMember); + + [Benchmark] + public LambdaExpression? ResolveCopyConstructor_Reflection() + => ProjectionExpressionResolver.FindGeneratedExpressionViaReflection(_copyConstructorMember); } } diff --git a/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/Helpers/TestEntity.cs b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/Helpers/TestEntity.cs index 68a04d8..635c8ac 100644 --- a/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/Helpers/TestEntity.cs +++ b/benchmarks/EntityFrameworkCore.Projectables.Benchmarks/Helpers/TestEntity.cs @@ -8,6 +8,10 @@ namespace EntityFrameworkCore.Projectables.Benchmarks.Helpers { public class TestEntity { + public TestEntity() + { + } + public int Id { get; set; } [Projectable] @@ -18,5 +22,11 @@ public class TestEntity [Projectable] public int IdPlusDelta(int delta) => Id + delta; + + [Projectable] + public TestEntity(TestEntity other) + { + Id = other.Id; + } } } diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs index d00daad..76f2d84 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs @@ -199,43 +199,50 @@ private static bool ParameterTypesMatch( } /// - /// Sentinel stored in to represent + /// Sentinel stored in to represent /// "no generated type found for this member", distinguishing it from a not-yet-populated entry. /// does not allow null values, so a sentinel is required. /// - private readonly static Func _reflectionNotFoundSentinel = static () => null!; + private readonly static LambdaExpression _reflectionNullSentinel = + Expression.Lambda(Expression.Empty()); /// - /// Caches a pre-compiled Func<LambdaExpression> delegate per - /// so that Assembly.GetType, GetMethod, MakeGenericType, and - /// MakeGenericMethod are only paid once per member. All subsequent calls execute - /// native JIT-compiled code with zero reflection overhead. + /// Caches the fully-resolved per + /// for the reflection-based slow path. + /// On the first call per member the reflection work (Assembly.GetType, GetMethod, + /// MakeGenericType, MakeGenericMethod) is performed once and the resulting + /// expression tree is stored here; subsequent calls return the cached reference directly, + /// eliminating expression-tree re-construction on every access. + /// This is especially important for constructors whose object-initializer trees are + /// significantly more expensive to build than simple method-body trees. /// - private readonly static ConcurrentDictionary> _reflectionFactoryCache = new(); + private readonly static ConcurrentDictionary _reflectionCache = new(); /// /// Resolves the for a [Projectable] member using the /// reflection-based slow path only, bypassing the static registry. - /// Useful for benchmarking and for members not yet in the registry (e.g. open-generic types). + /// The result is cached after the first call, so subsequent calls return the cached expression + /// without any reflection or expression-tree construction overhead. + /// Useful for members not yet in the registry (e.g. open-generic types). /// public static LambdaExpression? FindGeneratedExpressionViaReflection(MemberInfo projectableMemberInfo) { - var factory = _reflectionFactoryCache.GetOrAdd(projectableMemberInfo, static mi => BuildReflectionFactory(mi)); - return ReferenceEquals(factory, _reflectionNotFoundSentinel) ? null : factory.Invoke(); + var result = _reflectionCache.GetOrAdd(projectableMemberInfo, + static mi => BuildReflectionExpression(mi) ?? _reflectionNullSentinel); + return ReferenceEquals(result, _reflectionNullSentinel) ? null : result; } /// - /// Performs the one-time reflection work for a member and returns a compiled native delegate - /// (or if no generated type exists). + /// Performs the one-time reflection work for a member: locates the generated expression + /// accessor (inline or external-class path), invokes it, and returns the resulting + /// . Returns null if no generated type is found. /// - /// We use Expression.Lambda<TDelegate>(...).Compile() rather than - /// Delegate.CreateDelegate because the generated Expression() factory method - /// returns Expression<TDelegate> (a subtype of ), and - /// CreateDelegate requires an exact return-type match in most runtime environments. - /// The expression-tree wrapper handles the covariant cast cleanly and compiles to native code. + /// Using MethodInfo.Invoke rather than a compiled delegate is correct here because + /// the result is cached in — the invocation cost is paid + /// exactly once per member regardless of how many EF Core queries reference it. /// /// - private static Func BuildReflectionFactory(MemberInfo projectableMemberInfo) + private static LambdaExpression? BuildReflectionExpression(MemberInfo projectableMemberInfo) { var declaringType = projectableMemberInfo.DeclaringType ?? throw new InvalidOperationException("Expected a valid type here"); @@ -295,7 +302,7 @@ private static Func BuildReflectionFactory(MemberInfo projecta if (expressionFactoryType is null) { - return _reflectionNotFoundSentinel; + return null; } if (expressionFactoryType.IsGenericTypeDefinition) @@ -307,7 +314,7 @@ private static Func BuildReflectionFactory(MemberInfo projecta if (expressionFactoryMethod is null) { - return _reflectionNotFoundSentinel; + return null; } if (projectableMemberInfo is MethodInfo mi && mi.GetGenericArguments() is { Length: > 0 } methodGenericArgs) @@ -315,12 +322,7 @@ private static Func BuildReflectionFactory(MemberInfo projecta expressionFactoryMethod = expressionFactoryMethod.MakeGenericMethod(methodGenericArgs); } - // Compile a native delegate: () => (LambdaExpression)GeneratedClass.Expression() - // Expression.Call + Convert handles the covariant return type (Expression → LambdaExpression). - // The one-time Compile() cost is amortized; all subsequent calls are direct native-code invocations. - var call = Expression.Call(expressionFactoryMethod); - var cast = Expression.Convert(call, typeof(LambdaExpression)); - return Expression.Lambda>(cast).Compile(); + return (LambdaExpression)expressionFactoryMethod.Invoke(null, null)!; } /// From 988b65339bfb34d5f97aebf67a871fb5fefb9a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20M=C3=A9nager?= Date: Sat, 21 Mar 2026 00:02:23 +0100 Subject: [PATCH 2/3] Update src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Services/ProjectionExpressionResolver.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs index 76f2d84..958feed 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs @@ -237,9 +237,11 @@ private static bool ParameterTypesMatch( /// accessor (inline or external-class path), invokes it, and returns the resulting /// . Returns null if no generated type is found. /// - /// Using MethodInfo.Invoke rather than a compiled delegate is correct here because - /// the result is cached in — the invocation cost is paid - /// exactly once per member regardless of how many EF Core queries reference it. + /// Using MethodInfo.Invoke rather than a compiled delegate is appropriate here because + /// the result is cached in — the invocation cost is paid only + /// on cache misses, and subsequent EF Core queries reuse the cached expression. Under + /// contention the value factory may be invoked more than once, but only a single expression + /// instance is ultimately stored per member. /// /// private static LambdaExpression? BuildReflectionExpression(MemberInfo projectableMemberInfo) From 3872b0da25788cdf1287af84ee5d1a31d992b79f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20M=C3=A9nager?= Date: Sat, 21 Mar 2026 00:05:09 +0100 Subject: [PATCH 3/3] Update src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Services/ProjectionExpressionResolver.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs index 958feed..59d7240 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectionExpressionResolver.cs @@ -324,7 +324,7 @@ private static bool ParameterTypesMatch( expressionFactoryMethod = expressionFactoryMethod.MakeGenericMethod(methodGenericArgs); } - return (LambdaExpression)expressionFactoryMethod.Invoke(null, null)!; + return expressionFactoryMethod.Invoke(null, null) as LambdaExpression; } ///