Uh oh!
There was an error while loading.Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork397
Performance: 70× faster Lambda invocation path and interpretation preferred for Eval.#370
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
base:master
Are you sure you want to change the base?
Uh oh!
There was an error while loading.Please reload this page.
Conversation
Enhance Eval and Lambda classes: introduce preferInterpretation flag for optimized expression evaluation
davideicardi commentedNov 18, 2025
Thank you@WAcry ! Super optimization! I will study a little bit further the code, but for now I don't see problems, just a little bit more complex 😄 . Just a curiosity: you cannot use the compiled delegate in your real scenario? If you want we can include the benchmark code somewhere? Maybe in |
WAcry commentedNov 22, 2025 • edited
Loading Uh oh!
There was an error while loading.Please reload this page.
edited
Uh oh!
There was an error while loading.Please reload this page.
In our real usage we unfortunately can’t meaningfully use
Because of that, we don’t have a static So in short: On the benchmark side: I’ve just pushed a small BenchmarkDotNet project under |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Pull request overview
This PR introduces significant performance optimizations for the Lambda invocation path, achieving a 70× speedup and 99% reduction in allocations for high-frequency cached lambda scenarios. The changes also make Eval() prefer interpretation over compilation for one-off expressions, providing a 1.7× performance improvement.
Key Changes:
- Introduced fast invoker path using compiled expression trees to replace DynamicInvoke in hot paths
- Pre-computed and cached parameter metadata in Lambda constructor to eliminate repeated LINQ allocations
- Modified Eval() to use interpretation by default instead of compilation for better one-off expression performance
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/DynamicExpresso.Core/Lambda.cs | Core optimization: adds fast invoker path, parameter caching, and type checking infrastructure for high-performance invocation |
| src/DynamicExpresso.Core/Interpreter.cs | Updates Eval() to prefer interpretation over compilation for one-off expressions |
| benchmark/DynamicExpresso.Benchmarks/Program.cs | New benchmark harness using BenchmarkDotNet |
| benchmark/DynamicExpresso.Benchmarks/LambdaBenchmarks.cs | Benchmark implementations for measuring Lambda invocation performance |
| benchmark/DynamicExpresso.Benchmarks/DynamicExpresso.Benchmarks.csproj | Benchmark project configuration |
| README.md | Adds documentation for running benchmarks |
| DynamicExpresso.sln | Integrates benchmark project into solution |
| .gitignore | Excludes BenchmarkDotNet artifacts from version control |
💡Add Copilot custom instructions for smarter, more guided reviews.Learn how to get started.
Uh oh!
There was an error while loading.Please reload this page.
src/DynamicExpresso.Core/Lambda.cs Outdated
| if(_usedCount==0) | ||
| { | ||
| return_fastInvokerFromDeclared.Value(Array.Empty<object>()); | ||
| } |
CopilotAINov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
[nitpick] When_usedCount == 0, the method returns early at line 194 without checking ifmatchedValues.Count == 0. This is correct behavior, but the code structure is confusing because it checksif (_usedCount == 0) after the matching loop that will naturally result in an emptymatchedValues list when_usedCount == 0.
Consider restructuring to check_usedCount == 0 before the matching loop to avoid the unnecessary loop execution and list allocation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
@WAcry It seems to be a valid suggestion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
good point.
src/DynamicExpresso.Core/Lambda.cs Outdated
| foreach(varusedin_usedParameters) | ||
| { | ||
| foreach(varactualinparamList) | ||
| { | ||
| if(actual!=null&& | ||
| used.Name.Equals(actual.Name,_parserArguments.Settings.KeyComparison)) | ||
| { | ||
| matchedValues.Add(actual.Value); | ||
| } | ||
| } | ||
| } |
CopilotAINov 24, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
[nitpick] The parameter matching logic mirrors the original LINQ-based implementation, but both allow duplicate parameter names in the inputparameters collection, which can lead to unexpected behavior. Ifparameters contains duplicate names (e.g.,[Parameter("a", 1), Parameter("a", 2)]) and the expression uses "a", both values will be added tomatchedValues, resulting in a count mismatch and fallback toInvokeWithUsedParameters with an incorrect array.
Consider adding validation to reject duplicate parameter names in the input, or documenting this behavior clearly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
@WAcry This seems to be a correct suggestion. What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
As Copilot suggests, the refactored code still mirrors the original behavior here: if the caller passes duplicate names (e.g. [Parameter("a", 1), Parameter("a", 2)]), we end up with multiple entries in the values array and ultimately let DynamicInvoke throw (e.g. due to argument count mismatch), just like before. Let me know if you think it's better to throw an exception directly instead.
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.
davideicardi left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Again thank you for the PR and the performance improvement! Really appreciated.
I'm a bit hesitant because the code seems quite a bit more complex than before.
I know that this is common when apply optimization... but if you are able to get similar improvements (maybe not all) but with a more maintainable code, I think it is better.
What do you think? Could it be possible?
P.S. I have executed Copilot Review, there are a couple of comments that seems to make sense to me. I have resolved the other ones, because irrelevant for me.
src/DynamicExpresso.Core/Lambda.cs Outdated
| privatereadonlyParameter[]_declaredParameters; | ||
| privatereadonlyParameter[]_usedParameters; | ||
| privatereadonlyParameterExpression[]_declaredParameterExpressions; | ||
| // For each used parameter index, which declared parameter index it corresponds to. | ||
| privatereadonlyint[]_usedToDeclaredIndex; | ||
| privatereadonlybool_allUsedAndInDeclaredOrder; | ||
| privatereadonlyType[]_effectiveUsedTypes; | ||
| privatereadonlybool[]_usedAllowsNull; | ||
| privatereadonlyint_declaredCount; | ||
| privatereadonlyint_usedCount; | ||
| // Fast path: declared-order object[] -> result. | ||
| privatereadonlyLazy<Func<object[],object>>_fastInvokerFromDeclared; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Do you think we can consolidate these various arrays/variables into one or more simpler aggregated objects (e.g. aLambdaInvocationContext ?) so we keep most of the performance while making the hot path easier to read and maintain?
The performance gains are great and the direction makes sense—this would just be about reducing branching and scattered variables.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Good point! I removed two less important arrays and moved all invocation-related state and logic into theInvocationContext class to better encapsulate the complexity
davideicardi commentedNov 24, 2025
@metoule What do you think? Suggestions or ideas? |
metoule commentedDec 1, 2025
Thanks for the PR! There was indeed a need for improvement. I would prefer to split the PR in two: one that keeps the current I also find it surprising that building a new delegate that calls the Invoke method of the first one can benefit that much. Can't we call the Invoke method directly? If it's really worth having a new delegate, we may be able to build it without having to compile two expression trees (the old delegate and the new one). |
Uh oh!
There was an error while loading.Please reload this page.
Thank you for your great work for maintaining such a high-quality open-source library. I've used it for a while, really appreciate all the effort that has gone into it.
In our scenario, we execute few same expressions under very high concurrency requirements — on the order of 100,000 invocations per second.
To support this, we already cache the
Lambdainstance and reuse it across calls. However, we found that the currentLambda.Invokepath leave some room for optimization, especially in extremely hot paths: TheLambdainvocation path involvesDynamicInvokeand repeated LINQ allocations.This PR removes a hot LINQ (to reduce allocations putting pressure on GC), introduces a fast invoker path for
Lambdato replace dynamic invoke, and adds a “prefer interpretation” option forEval, reducing allocations and improving performance in high-frequency scenarios.Benchmark
BenchmarkDotNet v0.14.0, Windows 11 (10.0.26200.7171)
11th Gen Intel Core i7-11800H 2.30GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.307
[Host] : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
DefaultJob : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
After:
Before:
We see a dramatic reduction in both latency (70x) and allocations (-99%) for this hot-path scenario after the fast invoker optimization.
Eval() is also 1.7x faster using interpretation instead of Compiling. We don't really care since we cache lambda, but it's simple to add. I see we had a discussion here too:#362
What This PR Changes
1. Optimize
Lambdainvocation path and reduce allocationsGoal: Avoid repeated LINQ allocations and heavy
DynamicInvokeusage on every call, especially when the same lambda is invoked extremely frequently with consistent argument shapes.Concretely:
Pre-snapshot and cache parameter metadata in
Lambda:Convert
DeclaredParameters/UsedParametersto arrays and cache the correspondingParameterExpressioninstances.Precompute the mapping “used parameter index → declared parameter index” so we don’t have to enumerate and look up parameters on each invocation.
Introduce a fast path for invocation in declared-parameter order:
Add a fast invocation delegate (e.g.
_fastInvokerFromDeclared) built from an expression tree that takesobject[]and performs strongly-typed invocation logic.When the number and types of arguments exactly match the expected parameters, we go through this fast path, avoiding:
DynamicInvokeRepeated boxing/unboxing
Extra allocations.
If the arguments do not match (wrong count or incompatible types), we safely fall back to the original
DynamicInvokepath to preserve behavior and exception semantics.Optimize
Invokeoverloads:Invoke(IEnumerable<Parameter>):Replace LINQ-based matching with an implementation based on the cached
_usedParametersmapping.When parameters fully match, route to the fast path; otherwise, fall back to the existing logic.
Invoke(object[] args):Build the invocation argument array directly in declared-parameter order and reuse the fast path.
Only fall back when argument types or counts do not match.
Overall, this significantly reduces per-call allocations and improves performance in high-frequency, cached-lambda scenarios.
2. Adjust
Evaldefault behavior to favor interpretationGoal: Improve performance for typical
Evalscenarios, which are often one-off evaluations where compilation overhead dominates.Changes:
Interpreter.Eval(string, Type, params Parameter[])is updated to:Call
ParseAsLambda(..., preferInterpretation: true).Then execute the resulting
Lambdavialambda.Invoke(parameters).From a library user’s perspective, the public API stays the same, but:
The default evaluation strategy for
Evalbecomes interpretation-first.This reduces IL generation and JIT overhead, which is especially beneficial when
Evalis used frequently in hot paths or in environments where startup latency and memory pressure matter.Compatibility
All changes are limited to internal constructors, private helpers, and invocation internals.
There are "almost" no breaking changes to the public API surface, unless I missed anything.
When the fast path cannot be used (e.g., argument count/type mismatch), the code falls back to the original
DynamicInvokelogic, preserving:Exception types
Observable behavior
Compatibility with existing code.
Thank you again for providing and maintaining this project. I hope these optimizations are useful and are happy to adjust the implementation if you have any suggestions or style preferences.