Компиляция математического выражение из строки динамически во время выполнения в C# (.NET)

В этой статье я продемонстрирую, как динамически компилировать математические выражения из строк в runtime в C#, исключительно просто и быстро. Это решение поддерживает различные математические контексты, включая логические выражения, научные вычисления и C#, а также позволяет расширять эти контексты пользовательскими переменными, операторами и функциями.

Достижение высокой производительности при вычислении математических выражений из строки требует использования современных возможностей .NET и эффективных алгоритмов. Это реализовано в библиотеке MathEvaluator для .NET. В предыдущей статье я описал подход, лежащий в основе, а также предоставил полный список всех поддерживаемых функций и операторов в документации на GitHub.

В версии 2.0 была добавлена возможность компилировать математические выражения из строк во время выполнения. Это позволяет компилировать выражения в делегаты, такие как Func или Func, что значительно повышает производительность, особенно в случаях, когда одно и то же выражение нужно вычислить несколько раз. Хотя процесс компиляции может занять время, он приносит преимущества в сценариях, связанных с многократными вычислениями, например, когда результаты зависят от входных параметров.

Ниже я продемонстрирую возможности и производительность MathEvaluator на примерах, сравнивая его с известной библиотекой NCalc. Мы будем использовать BenchmarkDotNet. Подробности окружения для тестирования:

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

Пример 1: Компиляция строки математического выражения

Начнем с расчета общей суммы вклада с ежедневными процентами по формуле:

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

где:

  • P — начальная сумма (первоначальный вклад),

  • r — годовая процентная ставка (в виде десятичной дроби),

  • n — количество периодов начисления процентов (дни в году, обычно 365 или 366),

  • d — количество прошедших дней.

Мы сравним производительность:

  1. Прямого вычисления формулы для каждого значения d,

  2. Предварительной компиляции формулы и вызова скомпилированного делегата,

  3. Вычисления и компиляции того же выражения с помощью библиотеки NCalc.

Вот код, используемый для тестирования этих сценариев:

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 _mathEvalCompiledFn;
    private readonly Func _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 MathEvaluator_Compile()
    {
        return "P * (1 + r/n)^d".Compile(new CompoundInterestFormulaParams(), _mathContext);
    }

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

        return expression.ToLambda();
    }

    [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);

Ниже приведены результаты замеров:

7c6586a894455a2844aef7617090ead7.jpg

Время прямого вычисления: 724.03 нс
Время компиляции: 107,224.06 нс
Время выполнения скомпилированной функции: 26.68 нс

Как видите, предварительная компиляция имеет смысл в этом случае, если количество вычислений превышает 154, когда прирост производительности компенсирует время компиляции.

Хотя для финансовых формул обычно предпочтительнее использовать тип decimal, мы используем double, так как вычислительные затраты на возведение decimal в степень настолько высоки, что в тестах не будет видно разницы между использованием предварительно скомпилированной формулы и ее вычислением во время выполнения. В то время как NCalc изначально использует BigDecimal для функции возведения в степень Pow во время вычисления, он внутренне переключается на double после компиляции, что делает прямые сравнения невозможными. В MathEvaluator вы можете создать контекст для decimal, и он будет работать с скомпилированным делегатом, как показано в коде ниже:

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

Пример 2. Компиляция строки логического выражения

Далее давайте сравним производительность компиляции следующего логического выражения A or not B and (C or B). Опять же, мы сравним производительность прямого вычисления, предварительной компиляции с использованием MathEvaluator и с NCalc. Исходный код тестов:

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 _mathEvalCompiledFn;
    private readonly Func _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 MathEvaluator_CompileBoolean()
    {
        return "A or not B and (C or B)"
            .CompileBoolean(new BooleanVariables(), _mathContext);
    }

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

    [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);

Ниже приведены результаты замеров:

3b7182dc4eece46a5d570bf92bc310c7.jpg

Время прямого вычисления: 510 нс
Время компиляции: 90,048 нс
Время выполнения скомпилированной функции: 4.3 нс

В этом случае предварительная компиляция имеет смысл, если количество вычислений превышает 178.

Дополнительные примеры вычисления логических выражений есть в другой статье.

Заключение

MathEvaluator — это мощная и гибкая библиотека для вычисления и компиляции математических выражений в широком диапазоне контекстов — будь то логическое выражение, научные вычисления или контекст C#. Для более сложных случаев использования настраиваемые контексты MathEvaluator обеспечивают расширяемость. Предварительная компиляция обеспечивает преимущества в производительности в сценариях многократного вычисления одного и того же выражения, чаще когда это значение превышает 150 и более раз.

Если вы считаете этот проект полезным, рассмотрите возможность поддержать меня на GitHub, не скупитесь на звездочки и делитесь с коллегами. Ваша поддержка помогает мне продолжать улучшать библиотеку и добавлять новые функции.

Если у вас есть какие-либо вопросы или предложения, не стесняйтесь оставлять их в комментариях. Спасибо!

© Habrahabr.ru