Skip to content

Conversation

@WAcry
Copy link

@WAcry WAcry commented Nov 17, 2025

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

This PR removes a hot LINQ (to reduce allocations putting pressure on GC), introduces a fast invoker path for Lambda to replace dynamic invoke, and adds a “prefer interpretation” option for Eval, reducing allocations and improving performance in high-frequency scenarios.


Benchmark Scenario

We used the following benchmark (BenchmarkDotNet) to measure the performance of a cached Lambda and repeatedly invoking it:

[MemoryDiagnoser]
public class LambdaCacheBenchmarks
{
    private Interpreter _interpreter = null!;
    private Lambda _cachedLambda = null!;
    private string _expr = null!;
    private object[] _args = null!;

    private const int A = 1;
    private const int B = 2;
    private const int C = 3;
    private const int D = 4;
    private const int E = 5;
    private const int F = 6;

    private readonly Parameter[] _declared =
    [
        new("a", typeof(int)),
        new("b", typeof(int)),
        new("c", typeof(int)),
        new("d", typeof(int)),
        new("e", typeof(int)),
        new("f", typeof(int))
    ];

    [GlobalSetup]
    public void Setup()
    {
        _interpreter = new Interpreter();
        _expr = "a + (b * c + d * (e + f)) + Math.Max(a, b)";
        _cachedLambda = _interpreter.Parse(_expr, typeof(int), _declared);
        _args = [A, B, C, D, E, F];
    }

    [Benchmark]
    public int Cache_Invoke()
    {
        int a = 0;
        for (int i = 0; i < 100000; i++)
        {
            a += (int)_cachedLambda.Invoke(_args);
        }

        return a;
    }
}

Results

Before optimization:

Method Mean Error StdDev Gen0 Allocated
Cache_Invoke 213.2 ms 3.83 ms 3.58 ms 21000.0000 260.93 MB

After optimization:

Method Mean Error StdDev Gen0 Allocated
Cache_Invoke 3.162 ms 0.0433 ms 0.0362 ms 187.5000 2.29 MB

We see a dramatic reduction in both latency (70x) and allocations (-99%) for this hot-path scenario after the fast invoker optimization.

Btw, 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

Method Mean Error StdDev Gen0 Gen1 Allocated
Eval compiled (default) 311.3 μs 3.28 μs 3.07 μs 15.6250 6.8359 197.18 KB
Eval interpreted (PreferInterpretation = true) 176.7 μs 2.54 μs 2.38 μs 14.6484 - 191.17 KB

What This PR Changes

1. Optimize Lambda invocation path and reduce allocations

Goal: Avoid repeated LINQ allocations and heavy DynamicInvoke usage 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 / UsedParameters to arrays and cache the corresponding ParameterExpression 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 takes object[] 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 original DynamicInvoke path to preserve behavior and exception semantics.

Optimize Invoke 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. Adjust Eval default behavior to favor interpretation

Goal: Improve performance for typical Eval scenarios, 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 Lambda via lambda.Invoke(parameters).

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

The default evaluation strategy for Eval becomes interpretation-first.
This reduces IL generation and JIT overhead, which is especially beneficial when Eval 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 original DynamicInvoke 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 in sample/benchmark?

@WAcry
Copy link
Author

WAcry commented Nov 22, 2025

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

In our real usage we unfortunately can’t meaningfully use Compile<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 via DetectIdentifiers.
  • The parameter types are inferred from the first runtime values, which we fetch from data sources as a Dictionary<string, object> and treat as (value?.GetType() ?? typeof(object)).

Because of that, we don’t have a static TDelegate that we can write in our own code which would match all these dynamically shaped cases. We’d still end up with a Delegate instance and have to invoke it in a general way, which is exactly the path this PR tries to optimize (removing DynamicInvoke, 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 on Lambda.Invoke(...) as the generic entry point.

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants