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

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

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

Ниже я продемонстрирую возможности и производительность 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<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);

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

Время прямого вычисления: 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<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);

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

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

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

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

Заключение

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

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

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

Комментарии (17)


  1. zzzzzzerg
    10.09.2024 04:25
    +2

    Простите меня, но кроме рекламы ваша статья ценности не несет. Сделать статью только с бенчмарками и с таким заголовком и не рассказать как вы это делаете - это неуважение к сообществу. Потому что подходы давно известны - есть ли у вас ноу-хау - не понятно.


    1. AntonAntonov88 Автор
      10.09.2024 04:25

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


      1. AntonAntonov88 Автор
        10.09.2024 04:25

        Сложных архитектур я не строил, все основано на простых правилах математики по разбору формул и поиск на основе префиксного дерева. Чём проще тем лучше работает, такой философии я придерживаюсь. Какое ноу хао тут можно придумать, математика лежит в основе программирования а не наоборот, следуя простым правилам математики приоритет операции и т.п. удаётся вычислить любое выражение, и прекрасно это тем что это не нужно объяснять тем кто дружит с математикой.


      1. zzzzzzerg
        10.09.2024 04:25

        Прежде чем написать свой комментарий, я конечно же ознакомился с этой вашей ссылкой (которая указана в тексте), но в ней нет ничего про реализацию вашего эвалюатора. То что вы используете префиксное дерево конечно похвально, но мне не, например, не очевидно зачем.


  1. belch84
    10.09.2024 04:25

    Время прямого вычисления: 724.03 нс

    Время компиляции: 107,224.06 нс

    Время выполнения скомпилированной функции: 26.68 нс

    Эти данные кажутся мне несколько странными, по моим оценкам время компиляции должно быть примерно сопоставимо с временем прямого вычисления. Поскольку из вашей публикации абсолютно невозможно понять, в какое промежуточное представление вы компилируете выражения (я с ужасом подумал - неужто прямо в команды процессора?), невозможно и оценить ценность вашей библиотеки. Естественно, такого рода библиотека вряд ли может быть совсем новаторской, а бенчмарки совсем не помогают осознанию её преимуществ или хотя бы особенностей


    1. AntonAntonov88 Автор
      10.09.2024 04:25

      Ноу хау в том что сделано просто, поэтому работает быстро. Я не занимался оверинженирингом. В основе правило приоритета операции, рекурсия, ReadOnlySpan и префиксное дерево для поиска операторов и функциий, нужно объяснять как устроено префиксное дерево? в интернете полно материала по этому поводу. В статье есть ссылка на первую статью где это описано, читайте внимательно. Новаторство в том что я решил задачу как математик, переложив логику в код что позволяет разбирать такие формулы как sin-3/cos1 или -3^4sin(-π/2) без регулярных выражений и сложных архитектур. Это третья статья и здесь я описал именно то что во 2 версии появилась компиляция.


      1. AntonAntonov88 Автор
        10.09.2024 04:25

        Наиболее быстрый способ компиляции в C# это LambdaExpression.Compile, строю я expression tree по таким же правилам как и рассчитываю, только получаю Expression а не значение. При построении дерева уже не играет роли как быстро я это делаю так как основное время это компиляция этого дерева поэтому не повторял то что я описывал про быстрое вычисление.


        1. belch84
          10.09.2024 04:25

          строю я expression tree по таким же правилам как и рассчитываю, только получаю Expression а не значение.

          Поэтому и кажется странным, что это занимает в 150 раз больше времени


          1. AntonAntonov88 Автор
            10.09.2024 04:25

            Компиляция требует времени, скомпилированное выражение это как раз и есть IL код


        1. Malstream
          10.09.2024 04:25

          Если воспользуетесь оп-кодами вместо Expression, то сможете выиграть немного времени на построении.


      1. belch84
        10.09.2024 04:25

        префиксное дерево для поиска операторов и функциий, нужно объяснять как устроено префиксное дерево?

        Не совсем понял, при чем тут префиксное дерево? Это структура более сложная, чем двоичное дерево арифметического выражения.

        Binary algebraic expression tree equivalent to ((5 + z) / -8) * (4 ^ 2)
        Binary algebraic expression tree equivalent to ((5 + z) / -8) * (4 ^ 2)

        Такое дерево вы имеете в виду?


        1. AntonAntonov88 Автор
          10.09.2024 04:25

          Нет я имею ввиду именно префиксное дерево. Картинка по ссылке, можете сравнить

          https://en.m.wikipedia.org/wiki/Trie


          1. belch84
            10.09.2024 04:25

            А какие преимущества у префиксного дерева для вычисления значания выражения?


          1. AntonAntonov88 Автор
            10.09.2024 04:25

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


            1. belch84
              10.09.2024 04:25

              По сути я использую 2 дерева, первое то что вы прислали "строится" с помощью рекурсии, по сути его физически нет но расчёт идёт от ветвей к корню, префиксное дерево используется для поиска кастомных операторов и функций

              Если дерева "физически нет" - как вам удаётся организовать многократное вычисление одного и того же выражения? Может, ссылка на двоичное дерево выражения все-таки сохраняется?

              Зачем нужен поиск операторов, да еще с помощью префиксного дерева? Вроде бы проще при компиляции закодировать их кодами (например, числами) и выбирать нужную операцию по коду


              1. AntonAntonov88 Автор
                10.09.2024 04:25

                Вы перепутали компиляцию и evaluation. Скомпилированный делегат повторно не компилируется. Он используется как есть как в примерах в статье fn()


              1. AntonAntonov88 Автор
                10.09.2024 04:25

                Закодировть операторы это значит иметь их ограниченный набор, я могу расширять контексты и создавать новые, например для расчёта булевой алгебры, там внутри контекстов используется префиксное дерево.