Compiling a Mathematical Expression from a String Dynamically at Runtime in C# (.NET)

In this article, I will demonstrate how to dynamically compile mathematical expressions from strings at runtime in C#, extremely simply and quickly. This solution supports various mathematical contexts, including Boolean expressions, scientific calculations, and C#, and also allows you to extend these contexts with custom variables, operators, and functions.

Achieving high performance when calculating mathematical expressions from a string requires using modern .NET capabilities and efficient algorithms. This is implemented in MathEvaluator library for .NET. In the previous article I described the approach behind it and also provided a complete list of all supported functions and operators in documentation on GitHub.

Version 2.0 added the ability to compile mathematical expressions from strings at runtime. This allows expressions to be compiled into delegates such as Func or Funcwhich significantly improves performance, especially in cases where the same expression needs to be evaluated multiple times. Although the compilation process can take time, it provides benefits in scenarios involving multiple evaluations, such as when the results depend on the input parameters.

Below I will demonstrate the capabilities and performance of MathEvaluator with examples, comparing it with the well-known NCalc library. We will use BenchmarkDotNet. Testing environment details:

BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.4112/23H2/2023Update/SunValley3)
11th Gen Intel Core i7-11800H 2.30GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.400
  [Host]   : .NET 6.0.33 (6.0.3324.36610), X64 RyuJIT AVX2
  .NET 6.0 : .NET 6.0.33 (6.0.3324.36610), X64 RyuJIT AVX2
  .NET 8.0 : .NET 8.0.8 (8.0.824.36612), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI

Example 1: Compiling a string of mathematical expressions

Let's start by calculating the total deposit amount with daily interest using the formula:

A = P * (1 + r/n)^d

Where:

  • P — initial amount (initial contribution),

  • r — annual interest rate (as a decimal fraction),

  • n — the number of interest accrual periods (days per year, usually 365 or 366),

  • d — the number of days that have passed.

We will compare the performance:

  1. Direct calculation of the formula for each value of d,

  2. Pre-compiling the formula and calling the compiled delegate,

  3. Calculation and compilation of the same expression using the NCalc library.

Here is the code used to test these scenarios:

using BenchmarkDotNet.Attributes;
using MathEvaluation.Context;
using MathEvaluation.Extensions;
using MathEvaluation.Parameters;
using NCalc;

[SimpleJob(BenchmarkDotNet.Jobs.RuntimeMoniker.Net80)]
[SimpleJob(BenchmarkDotNet.Jobs.RuntimeMoniker.Net60)]
[MemoryDiagnoser]
public class CompoundingInterestBenchmarks
{
    private int _count;

    private readonly IMathContext _mathContext = new ScientificMathContext();

    private readonly Func<CompoundInterestFormulaParams, double> _mathEvalCompiledFn;
    private readonly Func<CompoundInterestFormulaParams, double> _nCalcCompiledFn;

    public CompoundingInterestBenchmarks()
    {
        _mathEvalCompiledFn = MathEvaluator_Compile();
        _nCalcCompiledFn = NCalc_ToLambda();
    }

    [Benchmark(Description = "MathEvaluator evaluation")]
    public double MathEvaluator_Evaluate()
    {
        _count++;
        var n = 365;
        var d = _count % n + 1; //randomizing values

        var parameters = new MathParameters(new { P = 10000, r = 0.05, n, d });

        return "P * (1 + r/n)^d".Evaluate(parameters, _mathContext);
    }

    [Benchmark(Description = "NCalc evaluation")]
    public double NCalc_Evaluate()
    {
        _count++;
        var n = 365;
        var d = _count % n + 1; //randomizing values

        var expression = new Expression("P * Pow((1 + r/n), d)", ExpressionOptions.NoCache);
        expression.Parameters["P"] = 10000;
        expression.Parameters["r"] = 0.05;
        expression.Parameters["n"] = n;
        expression.Parameters["d"] = d;

        return Convert.ToDouble(expression.Evaluate());
    }

    [Benchmark(Description = "MathEvaluator compilation")]
    public Func<CompoundInterestFormulaParams, double> MathEvaluator_Compile()
    {
        return "P * (1 + r/n)^d".Compile(new CompoundInterestFormulaParams(), _mathContext);
    }

    [Benchmark(Description = "NCalc compilation")]
    public Func<CompoundInterestFormulaParams, double> NCalc_ToLambda()
    {
        var expression = new Expression("P * Pow((1 + r/n), d)", ExpressionOptions.NoCache);

        return expression.ToLambda<CompoundInterestFormulaParams, double>();
    }

    [Benchmark(Description = "MathEvaluator invoke fn(P, r, n, d)")]
    public double MathEvaluator_InvokeCompiled()
    {
        _count++;
        var n = 365;
        var d = _count % n + 1; //randomizing values

        var parameters = new CompoundInterestFormulaParams(10000, 0.05, n, d);

        return _mathEvalCompiledFn(parameters);
    }

    [Benchmark(Description = "NCalc invoke fn(P, r, n, d)")]
    public double NCalc_InvokeCompiled()
    {
        _count++;
        var n = 365;
        var d = _count % n + 1; //randomizing values

        var parameters = new CompoundInterestFormulaParams(10000, 0.05, n, d);

        return _nCalcCompiledFn(parameters);
    }
}

public record CompoundInterestFormulaParams(double P = 0, double r = 0, int n = 0, int d = 0);

Below are the results of the measurements:

Direct computation time: 724.03 ns
Compile time: 107,224.06 ns
Compiled function execution time: 26.68 ns

As you can see, precompilation makes sense in this case if the number of calculations exceeds 154, when the performance gain compensates for the compilation time.

Although decimal is generally preferred for financial formulas, we use double because the computational cost of raising a decimal to a power is so high that benchmarks will not show a difference between using a precompiled formula and evaluating it at runtime. While NCalc natively uses BigDecimal for the exponentiation function Pow during evaluation, it internally switches to double after compilation, making direct comparisons impossible. In MathEvaluator, you can create a context for decimal, and it will work with the compiled delegate, as shown in the code below:

var context = new MathContext();
context.BindOperandsOperator(
    (decimal b, decimal e) => (decimal)BigDecimal.Pow(b, new BigInteger(e)),
    '^',
    (int)EvalPrecedence.Exponentiation);

var fn = "P * (1 + r/n)^d".CompileDecimal(new CompoundInterestFormulaParams(), context);

var parameters = new CompoundInterestFormulaParams(10000m, 0.05m, 365, 1);
var value = fn(parameters);

var value2 = "P * (1 + r/n)^d".EvaluateDecimal(parameters, context);

Example 2. Compiling a logical expression string

Next, let's compare the compilation performance of the following logical expression A or not B and (C or B). Again, we compare the performance of direct evaluation, precompilation using MathEvaluator and with NCalc. The source code of the tests:

using BenchmarkDotNet.Attributes;
using MathEvaluation.Context;
using MathEvaluation.Extensions;
using MathEvaluation.Parameters;
using NCalc;

[SimpleJob(BenchmarkDotNet.Jobs.RuntimeMoniker.Net80)]
[SimpleJob(BenchmarkDotNet.Jobs.RuntimeMoniker.Net60)]
[MemoryDiagnoser]
public class BooleanBenchmarks
{
    private int _count;

    private readonly IMathContext _mathContext = new ProgrammingMathContext();

    private readonly Func<BooleanVariables, bool> _mathEvalCompiledFn;
    private readonly Func<BooleanVariables, bool> _nCalcCompiledFn;

    public BooleanBenchmarks()
    {
        _mathEvalCompiledFn = MathEvaluator_CompileBoolean();
        _nCalcCompiledFn = NCalc_ToLambda();
    }

    [Benchmark(Description = "MathEvaluator evaluation")]
    public bool MathEvaluator_EvaluateBoolean()
    {
        _count++;
        bool a = _count % 2 == 0; //randomizing values

        var parameters = new MathParameters();
        parameters.BindVariable(a, "A");
        parameters.BindVariable(!a, "B");
        parameters.BindVariable(a, "C");

        return "A or not B and (C or B)"
            .EvaluateBoolean(parameters, _mathContext);
    }

    [Benchmark(Description = "NCalc evaluation")]
    public bool NCalc_Evaluate()
    {
        _count++;
        bool a = _count % 2 == 0; //randomizing values

        var expression = new Expression("A or not B and (C or B)", ExpressionOptions.NoCache);
        expression.Parameters["A"] = a;
        expression.Parameters["B"] = !a;
        expression.Parameters["C"] = a;

        return (bool)expression.Evaluate();
    }

    [Benchmark(Description = "MathEvaluator compilation")]
    public Func<BooleanVariables, bool> MathEvaluator_CompileBoolean()
    {
        return "A or not B and (C or B)"
            .CompileBoolean(new BooleanVariables(), _mathContext);
    }

    [Benchmark(Description = "NCalc compilation")]
    public Func<BooleanVariables, bool> NCalc_ToLambda()
    {
        var str = "A or not B and (C or B)";
        var expression = new Expression(str, ExpressionOptions.NoCache);
        return expression.ToLambda<BooleanVariables, bool>();
    }

    [Benchmark(Description = "MathEvaluator invoke fn(a, b, c)")]
    public bool MathEvaluator_CompiledBoolean()
    {
        _count++;
        bool a = _count % 2 == 0; //randomizing values
        return _mathEvalCompiledFn(new BooleanVariables(a, !a, a));
    }

    [Benchmark(Description = "NCalc invoke fn(a, b, c)")]
    public bool NCalc_CompiledBoolean()
    {
        _count++;
        bool a = _count % 2 == 0; //randomizing values
        return _nCalcCompiledFn(new BooleanVariables(a, !a, a));
    }
}

public record BooleanVariables(bool A = false, bool B = false, bool C = false);

Below are the results of the measurements:

Direct computation time: 510 ns
Compile time: 90.048 ns
Compiled function execution time: 4.3 ns

In this case, precompilation makes sense if the number of calculations exceeds 178.

Additional examples of calculating logical expressions can be found in another article.

Conclusion

MathEvaluator is a powerful and flexible library for evaluating and compiling mathematical expressions in a wide range of contexts, be it a Boolean expression, scientific computing, or C# context. For more complex use cases, MathEvaluator's custom contexts provide extensibility. Precompilation provides performance benefits in scenarios where the same expression is evaluated many times, often 150 or more times.

If you find this project useful, please consider contributing support me on GitHubdon't be stingy with stars and share with colleagues. Your support helps me continue to improve the library and add new features.

If you have any questions or suggestions, feel free to leave them in the comments. Thank you!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *