Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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

Open
WAcry wants to merge6 commits intodynamicexpresso:master
base:master
Choose a base branch
Loading
fromWAcry:david/perf

Conversation

@WAcry
Copy link

@WAcryWAcry commentedNov 17, 2025
edited
Loading

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 theLambda instance and reuse it across calls. However, we found that the currentLambda.Invoke path leave some room for optimization, especially in extremely hot paths: TheLambda invocation path involvesDynamicInvoke and repeated LINQ allocations.

This PR removes a hot LINQ (to reduce allocations putting pressure on GC), introduces a fast invoker path forLambda to 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:

MethodMeanErrorStdDevGen0Gen1Allocated
'Invoke cached lambda (object[])'2.497 ms0.0179 ms0.0167 ms187.5000-2.29 MB
'Invoke cached lambda (IEnumerable<Parameter>)'90.553 ms0.3137 ms0.2620 ms3000.0000-37.38 MB
'Eval (IEnumerable<Parameter>)'16,550.422 ms237.4673 ms222.1270 ms1436000.000027000.000017184.89 MB

Before:

MethodMeanErrorStdDevGen0Gen1Gen2Allocated
'Invoke cached lambda (object[])'216.8 ms1.53 ms1.43 ms21666.6667--260.93 MB
'Invoke cached lambda (IEnumerable<Parameter>)'179.8 ms1.36 ms1.27 ms16000.0000--191.5 MB
'Eval (IEnumerable<Parameter>)'23,721.9 ms147.18 ms122.90 ms1436000.0000373000.00007000.000017176.89 MB

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. OptimizeLambda invocation path and reduce allocations

Goal: Avoid repeated LINQ allocations and heavyDynamicInvoke usage on every call, especially when the same lambda is invoked extremely frequently with consistent argument shapes.

Concretely:

Pre-snapshot and cache parameter metadata inLambda:

ConvertDeclaredParameters /UsedParameters to arrays and cache the correspondingParameterExpression instances.
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:

DynamicInvoke
Repeated boxing/unboxing
Extra allocations.

If the arguments do not match (wrong count or incompatible types), we safely fall back to the originalDynamicInvoke path to preserve behavior and exception semantics.

OptimizeInvoke overloads:

Invoke(IEnumerable<Parameter>):

Replace LINQ-based matching with an implementation based on the cached_usedParameters mapping.
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. AdjustEval default behavior to favor interpretation

Goal: Improve performance for typicalEval scenarios, which are often one-off evaluations where compilation overhead dominates.

Changes:

Interpreter.Eval(string, Type, params Parameter[]) is updated to:

CallParseAsLambda(..., preferInterpretation: true).
Then execute the resultingLambda vialambda.Invoke(parameters).

From a library user’s perspective, the public API stays the same, but:

The default evaluation strategy forEval becomes interpretation-first.
This reduces IL generation and JIT overhead, which is especially beneficial whenEval is 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 originalDynamicInvoke logic, 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.

Enhance Eval and Lambda classes: introduce preferInterpretation flag for optimized expression evaluation
@davideicardi
Copy link
Member

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 insample/benchmark?

@WAcry
Copy link
Author

WAcry commentedNov 22, 2025
edited
Loading

Just a curiosity: you cannot use the compiled delegate in your real scenario?

In our real usage we unfortunately can’t meaningfully useCompile<TDelegate>():

  • At the call site we don’t know either the number or the CLR types of the parameters at compile time.
  • The expression text itself comes from runtime configuration, not from code.
  • The set of variables in the expression is discovered viaDetectIdentifiers.
  • The parameter types are inferred from the first runtime values, which we fetch from data sources as aDictionary<string, object> and treat as(value?.GetType() ?? typeof(object)).

Because of that, we don’t have a staticTDelegate that we can write in our own code which would match all these dynamically shaped cases. We’d still end up with aDelegate instance and have to invoke it in a general way, which is exactly the path this PR tries to optimize (removingDynamicInvoke, avoiding LINQ allocations, etc.).

So in short:Lambda.Compile<TDelegate>() is great if we know the signature up front, but in our scenario the shape is only known at runtime, so we rely onLambda.Invoke(...) as the generic entry point.

On the benchmark side: I’ve just pushed a small BenchmarkDotNet project underbenchmark/DynamicExpresso.Benchmarks.

Copy link

CopilotAI left a 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
FileDescription
src/DynamicExpresso.Core/Lambda.csCore optimization: adds fast invoker path, parameter caching, and type checking infrastructure for high-performance invocation
src/DynamicExpresso.Core/Interpreter.csUpdates Eval() to prefer interpretation over compilation for one-off expressions
benchmark/DynamicExpresso.Benchmarks/Program.csNew benchmark harness using BenchmarkDotNet
benchmark/DynamicExpresso.Benchmarks/LambdaBenchmarks.csBenchmark implementations for measuring Lambda invocation performance
benchmark/DynamicExpresso.Benchmarks/DynamicExpresso.Benchmarks.csprojBenchmark project configuration
README.mdAdds documentation for running benchmarks
DynamicExpresso.slnIntegrates benchmark project into solution
.gitignoreExcludes BenchmarkDotNet artifacts from version control

💡Add Copilot custom instructions for smarter, more guided reviews.Learn how to get started.

Comment on lines 192 to 195
if(_usedCount==0)
{
return_fastInvokerFromDeclared.Value(Array.Empty<object>());
}

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.

Copilot uses AI. Check for mistakes.
Copy link
Member

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.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

good point.

Comment on lines 180 to 190
foreach(varusedin_usedParameters)
{
foreach(varactualinparamList)
{
if(actual!=null&&
used.Name.Equals(actual.Name,_parserArguments.Settings.KeyComparison))
{
matchedValues.Add(actual.Value);
}
}
}

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.

Copilot uses AI. Check for mistakes.
Copy link
Member

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?

Copy link
Author

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.

Copy link
Member

@davideicardidavideicardi left a 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.

Comment on lines 25 to 38
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;
Copy link
Member

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.

Copy link
Author

@WAcryWAcryNov 25, 2025
edited
Loading

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

metoule reacted with thumbs up emoji
@davideicardi
Copy link
Member

@metoule What do you think? Suggestions or ideas?

@metoule
Copy link
Contributor

Thanks for the PR! There was indeed a need for improvement.

I would prefer to split the PR in two: one that keeps the currentDynamicInvoke behavior with the rest of the improvements (preferInterpretation, _usedToDeclaredIndex, etc) but without the new _fastInvokerFromDeclared. I think it'll will already bring major benefits, while being safer to release.

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).

Sign up for freeto join this conversation on GitHub. Already have an account?Sign in to comment

Reviewers

Copilot code reviewCopilotCopilot left review comments

@davideicardidavideicardidavideicardi requested changes

@metoulemetouleAwaiting requested review from metoulemetoule is a code owner

Requested changes must be addressed to merge this pull request.

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

3 participants

@WAcry@davideicardi@metoule

[8]ページ先頭

©2009-2025 Movatter.jp