[Программирование, .NET, Алгоритмы, C#, Математика] Компиляция математических выражений

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
13-Мар-2021 13:30

Привет. В этом очерке расскажу, как я реализовывал компиляцию математических (численных и логических) выражений в делегат при помощи Linq Expression.Навигация: Проблема · Правила компиляции · Компилятор · Дефолтные правила · Красивый API · Производительность · Примеры работы · Заключение · СсылкиЧто мы хотим?Мы хотим скомпилировать выражение в функцию от произвольного числа аргументов произвольного типа, причем не только численного, но и булевого. Например,
var func = "x + sin(y) + 2ch(0)".Compile<Complex, double, Complex>("x", "y");
Console.WriteLine(func(new(3, 4), 1.2d));
>>> (5.932039085967226, 4)
var func = "x > 3 and (a implies b)".Compile<int, bool, bool, bool>("x", "a", "b");
Console.WriteLine(func(4, false, true));
>>> True
Что у нас есть?Так как я делаю это в рамках существующей библиотеки символьной алгебры, то мы сразу перейдем к компиляции, уже имя парсер и дерево выражений.У нас есть базовый класс Entity и дерево наследников.
Entity
|
+--Operators
  |
  +--Sumf
  |
  +--Minusf
  |
  ...
+--Trigonometry
  |
  +--Sinf
  |
  +--Cosf
  |
  ...
+--Discrete
  |
  +--Andf
  |
  +--Lessf
  |
Примерно так выглядит дерево типов. Дерево выражений - это просто граф, где дети ноды это операнды оператора/функции.Каждый тип либо абстрактный (служит лишь для обобщения типов), либо запечатанный. Последний - это как раз реальные операторы/функции/константы/другие сущности, которые встречаются в выражении (будь то плюс, синус, конъюкция, число, множество, и т. д.).Например, такопределен оператор суммы.Протокол компиляцииТак я назвал интерфейс/класс данных, который будет определять то, как именно преобразуются подтипы Entity в нативные структуры, операторы или функции. Так как мы не знаем, какие типы запросит пользователь в качестве входных и выходного, то пользователь должен будет предоставить эту информацию.Так он будет выглядеть:
public sealed record CompilationProtocol
{
  public Func<Entity, Expression> ConstantConverter { get; init; }
  public Func<Expression, Expression, Entity, Expression> BinaryNodeConverter { get; init; }
  public Func<Expression, Entity, Expression> UnaryNodeConverter { get; init; }
  public Func<IEnumerable<Expression>, Entity, Expression> AnyArgumentConverter { get; init; }
}
Важно: ConstantConverter, BinaryNodeConverter, и UnaryNodeConverter мы будем писать в конце статьи.Все эти лямбды наш компилятор будет вызывать внутри, когда будет преобразовывать унарные ноды, бинарные ноды, константные ноды, и множественные в Linq.Expression.То есть теперь мы знаем, по каким правилам мы будем преобразовывать каждую ноду. Настало время писать сам "компилятор", точнее алгоритм, который будет строить дерево.КомпиляторПрототип метода, который мы хотим написать, выглядит так:
internal static TDelegate Compile<TDelegate>(
            Entity expr,
            Type? returnType,
            CompilationProtocol protocol,
            IEnumerable<(Type type, Variable variable)> typesAndNames
            ) where TDelegate : Delegate
  • Entity expr - это выражение с переменными, которое мы компилируем.
  • Type? returnType - это возвращаемый тип. Мы не можем "вытащить" его из типа делегата, поэтому приходится передавать его отдельно.
  • CompilationProtocol protocol - это тот самый протокол правил, по которым мы будем преобразовывать каждый узел выражения
  • IEnumerable<(Type type, Variable variable)> typesAndNames - это набор пар тип-переменная, которые пользователь хочет передать в свой делегат. Например, если вместо x мы хотим подставлять целочисленное, а вместо y передавать комплексное, мы напишем new[] { (typeof(int), "x"), (typeof(Complex), "y") }
А вот и имплементация метода:
internal static TDelegate Compile<TDelegate>(Entity expr, Type? returnType, CompilationProtocol protocol, IEnumerable<(Type type, Variable variable)> typesAndNames) where TDelegate : Delegate
{
  // для каждого поддерева есть локальная переменная, которую мы держим здесь
  var subexpressionsCache = typesAndNames.ToDictionary(c => (Entity)c.variable, c => Expression.Parameter(c.type));
  // это параметры функции: те переменные, которые будут передаваться в нее, а не создаваться локально
  var functionArguments = subexpressionsCache.Select(c => c.Value).ToArray(); // copying
  // а это локальные переменные, мы их сохраняем в этом списке
  var localVars = new List<ParameterExpression>();
  // это список инструкций-присваиваний (о нем позже)
  var variableAssignments = new List<Expression>();
  // по всем данным строим дерево
  var tree = BuildTree(expr, subexpressionsCache, variableAssignments, localVars, protocol);
  // построив, создаем блок, в котором объявляем локальные переменные, используемые в дереве
  var treeWithLocals = Expression.Block(localVars, variableAssignments.Append(tree));
  // если пользователь передал returnType, то попытаемся явно скастить в него
  Expression entireExpresion = returnType is not null ? Expression.Convert(treeWithLocals, returnType) : treeWithLocals;
  // создаем лямбду с аргументами функции
  var finalLambda = Expression.Lambda<TDelegate>(entireExpresion, functionArguments);
  // компилируем
  return finalLambda.Compile();
}
Главная его задача - создать необходимые контейнеры для кеша поддеревьев, локальных переменных, и некоторых других вещей. Самая интересная тут функция это BuildTree. Она будет строить дерево linq-выражения по Entity. Вот так выглядит ее прототип:
internal static Expression BuildTree(
  Entity expr,
  Dictionary<Entity, ParameterExpression> cachedSubexpressions,
  List<Expression> variableAssignments,
  List<ParameterExpression> newLocalVars,
  CompilationProtocol protocol)
Подробнее про аргументы BuildTree
  • Entity expr - выражение или подвыражение, по которому строить дерево.
  • Dictionary<Entity, ParameterExpression> cachedSubexpressions - словарь закешированных поддеревьев (то есть тех, которые уже записаны в существующие локальные переменные).
  • List<Expression> variableAssignments - список присвоений уникальных поддеревьев локальным переменным.
  • List<ParameterExpression> newLocalVars - созданные функции BuildTree локальные переменные (для хранения результатов поддеревьев).
  • CompilationProtocol protocol - правила, по которым мы преобразовываем ноду Entity в ноду Linq.Expression. Он остается неизменным и просто передается во все вызовы BuildTree.
А вот и самая главная функция - BuildTree:
internal static Expression BuildTree(Entity expr, ...)
{
  // если поддерево встречалось, возвращаем переменную, в которую оно присвоено
  if (cachedSubexpressions.TryGetValue(expr, out var readyVar))
    return readyVar;
  Expression subTree = expr switch
  {
    ...
    // если константа - прогоняем через ConstantConverter
    // нашего протокола
    Entity.Boolean or Number => protocol.ConstantConverter(expr),
    // аналогичная логика с унарной, бинарной, и n-арными нодами
    IUnaryNode oneArg
      => protocol.UnaryNodeConverter(BuildTree(oneArg.NodeChild, ...), expr),
    IBinaryNode twoArg
      => protocol.BinaryNodeConverter(
        BuildTree(twoArg.NodeFirstChild, ...),
        BuildTree(twoArg.NodeSecondChild, ...),
        expr),
    var other => protocol.AnyArgumentConverter(
        other.DirectChildren.Select(c => BuildTree(c, ...)),
      expr)
  };
  // для этого поддерева создадим локальную переменную
  var newVar = Expression.Variable(subTree.Type);
  // добавим запись вида var5 = subTree
  variableAssignments.Add(Expression.Assign(newVar, subTree));
  // запомним, что для этого поддерева используется эта переменная
  cachedSubexpressions[expr] = newVar;
  // запомним, что мы вообще такую создали
  newLocalVars.Add(newVar);
  return newVar;
}
Я опустил большие куски для читаемости. Итак, в ней для каждого поддерева мы либо сразу возвращаем локальную переменную, отвечающую за это выражение, либо строим новое дерево Linq.Expression, запоминаем его в новую локальную переменную, и возвращаем ее.Собственно, это все, компилятор готов. Но мы не реализовали ни одного правила преобразования наших выражений в Linq.Expression, потому что эти правила мы ждем от пользователя. Но почему бы не предоставить какую-то возможность по умолчанию для встроенных типов?Остаток статьи будет о создании дефолтного протокола.Предположения (assumptions)Сам метод Compile<TDelegate>(Entity, Type?, CompilationProtocol, IEnumerable<(Type, Variable)>) будет предоставлен пользователю, но становится очевидно, что это очень длинная и неуклюжая конструкция, придется писать огромное количество логики, описывающее преобразование каждой ноды и константы, да и само объявление метода достаточно длинное и неочевидное.Поэтому я предполагаю, что по умолчанию мы можем предложить протокол компиляции, который позволит работать с некоторыми встроенными примитивами (bool, int, long, float, double, Complex, BigInteger).ConstantConverter:Это правило преобразует константу из Entity в Linq.Constant и выглядит следующим образом:
public static Expression ConverterConstant(Entity e)
  => e switch
  {
    Number n => Expression.Constant(DownCast(n)),
    Entity.Boolean b => Expression.Constant((bool)b),
    _ => throw new AngouriBugException("Undefined constant type")
  };
Число преобразуем в число в зависимости от его типа, булеву константу безусловно в примитивный bool.Подробнее о DownCastЭта функция преобразует Entity.Number в какой-то из встроенных типов и реализован следующим образом:
private static object DownCast(Number num)
{
  if (num is Integer)
    return (long)num;
  if (num is Real)
    return (double)num;
  if (num is Number.Complex)
    return (System.Numerics.Complex)num;
  throw new InvalidProtocolProvided("Undefined type, provide valid compilation protocol");
}
Возвращает object потому, что именно такой аргумент принимает Expression.Constant. А нам это на руку: иначе под еще каким типом объединить эти три?UnaryNodeConverter:Это поле протокола - делегат, преобразующий ноду с одним аргументом в Linq.Expression.
public static Expression OneArgumentEntity(Expression e, Entity typeHolder)
  => typeHolder switch
  {
    Sinf =>         Expression.Call(GetDef("Sin", 1, e.Type), e),
    ...
    Cosecantf =>    Expression.Call(GetDef("Csc", 1, e.Type), e),
    Arcsinf =>      Expression.Call(GetDef("Asin", 1, e.Type), e),
    ...
    Arccosecantf => Expression.Call(GetDef("Acsc", 1, e.Type), e),
    Absf =>         Expression.Call(GetDef("Abs", 1, e.Type), e),
    Signumf =>      Expression.Call(GetDef("Sgn", 1, e.Type), e),
    Notf =>         Expression.Not(e),
    _ => throw new AngouriBugException("A node seems to be not added")
  };
Я опустил некоторые большие блоки (весь код здесь). Итак, здесь мы рассматриваем возможные типы нашей ноды, и на каждую выбираем нужный оверлоад нужной функции. GetDef находит нужную функцию по имени.О GetDefСначала я думал загружать все нужные функции из модулей Math и Complex. Приходилось везде if-ать, в каких случаях Math, в каких Complex, в каких вообще BigInteger. А потом еще и оказывалось, что у Math нет int Pow(int, int), и получается приходилось кастить в любой непонятной ситуации.Поэтому я создал класс MathAllMethods(на T4), в котором сделал все нужные ноды для всех примитивов, поддерживаемых остальными дефолтными правилами.GetDef ищет нужный метод с данным числом аргументов и типом именно в этом классе. Это позволило сильно очистить код от мусора и красиво и кратко записать все обращения к нужным функциям по данным типам.BinaryNodeConverter:Это правило преобразует ноду с двумя аргументами в Linq.Expression.
public static Expression TwoArgumentEntity(Expression left, Expression right, Entity typeHolder)
{
  var typeToCastTo = MaxType(left.Type, right.Type);
  if (left.Type != typeToCastTo)
    left = Expression.Convert(left, typeToCastTo);
  if (right.Type != typeToCastTo)
    right = Expression.Convert(right, typeToCastTo);
  return typeHolder switch
  {
    Sumf => Expression.Add(left, right),
    ...
    Andf => Expression.And(left, right),
    ...
    Lessf => Expression.LessThan(left, right),
    ...
    _ => throw new AngouriBugException("A node seems to be not added")
  };
}
Тут есть upcast. Так как у нас могут встретиться два выражения разного типа, мы хотим найти наиболее примитивный тип, к которому кастятся оба операнда. Для этого каждому типу присвоим уровень. Я распределил так:
Complex:   10
double:     9
float:      8
long:       8
BigInteger: 8
int:        7
Если типы одинаковые, MaxType вернет один из них. Например, MaxType(int, int) -> int.Если у типа операнда A уровень выше, чем у типа операнда B, то B кастится в A. Например, MaxType(long, double) -> double.Если уровни одинаковые, а типы - нет, то находится ближайшая общая вершина, то есть любой такой тип, у которого уровень на один выше. Например, MaxType(long, float) -> double.Операнды, при необходимости, кастятся к выбранному типу, и далее мы просто находим нужный оверлоад или оператор. Например, для Sumf мы выберем Expression.Add, а для конъюкции, Andf, будет Expression.And.Осознаем.Отлично, мы определили все необходимые правила нашего протокола. Теперь, при создании мы сможем передать эти правила в нужные свойства протокола.Красивое APIНачали мы с метода, который в конечном API выглядит так:
public TDelegate Compile<TDelegate>(CompilationProtocol protocol, Type returnType, IEnumerable<(Type type, Variable variable)> typesAndNames) where TDelegate : Delegate
Он неудобный и требует много усилий. Но мы можем передавать наш дефолтный протокол И перегрузить этот метод для делегатов от одного аргумента, двух, трех, и так далее. Так как я уже присваиваюнаши правила свойствам протокола по умолчанию, то передавая протокол, мы просто создаем его инстанс. Со вторым чуть сложнее - я решил это генерацией кода с помощью T4 Text Template. Вот пример сгенерированного кода:
// указываем типы аргументов и выходной                             а здесь указываем соответствующие переменные из самого выражения
public Func<TIn1, TIn2, TIn3, TOut> Compile<TIn1, TIn2, TIn3, TOut>(Variable var1, Variable var2, Variable var3)
                                       // делегат генерируем сами        выходной тип известен   new() так как создаем правила по умолчанию
            => IntoLinqCompiler.Compile<Func<TIn1, TIn2, TIn3, TOut>>(this, typeof(TOut),         new(),
                new[] { (typeof(TIn1), var1), (typeof(TIn2), var2) , (typeof(TIn3), var3)  });
Он же в исходниках.Текст T4-темплейта для генерации
<# for (var i = 1; i <= 8; i++) { #>
        public Func<<# for(var t=1;t<=i;t++){ #>TIn<#= t #>, <# } #>TOut> Compile<<# for(var t=1;t<=i;t++){ #>TIn<#= t #>, <# } #>TOut>(Variable var1<# for(var t=2; t<=i; t++){ #>, Variable var<#= t #><# } #>)
            => IntoLinqCompiler.Compile<Func<<# for(var t=1;t<=i;t++){ #>TIn<#= t #>, <# } #>TOut>>(this, typeof(TOut), new(),
                new[] { (typeof(TIn1), var1)<# for(var t=2;t<=i;t++){ #>, (typeof(TIn<#= t #>), var<#= t #>) <# } #> });
<# } #>
Аналогичным образом создаем методы расширения. Вот пример сгенерированного:
public static Func<TIn1, TIn2, TOut> Compile<TIn1, TIn2, TOut>(this string @this, Variable var1, Variable var2)
  => IntoLinqCompiler.Compile<Func<TIn1, TIn2, TOut>>(@this, typeof(TOut), new(),
    new[] { (typeof(TIn1), var1), (typeof(TIn2), var2)  });
Теперь надо померить производительность.ПроизводительностьBenchNormalSimple - простая лямбда, объявленная прямо в коде.BenchMySimple - та же лямбда, но скомпилированная мною.BenchNormalComplicated - большая толстая лямбда с кучей идентичных поддеревьев, объявленная прямо в коде.BenchmyComplicated - та же лямбда, но скомпилированная мною.
|                 Method |       Mean |    Error |   StdDev |
|----------------------- |-----------:|---------:|---------:|
|      BenchNormalSimple |   189.1 ns |  3.75 ns |  5.83 ns |
|          BenchMySimple |   195.7 ns |  3.92 ns |  5.50 ns |
| BenchNormalComplicated | 1,383.0 ns | 26.82 ns | 35.80 ns |
|     BenchMyComplicated |   293.6 ns |  5.74 ns |  8.77 ns |
Простые штуки работают одинаково быстро, а там, где есть идентичные поддеревья, моя компиляция выигрывает. В общем-то предсказуемо, и ничего гениального тут нет.Бенчмарк тут.Примеры работы
var func = "sin(x)".Compile<double, double>("x");
Console.WriteLine(func(Math.PI / 2));
>>> 1
var func1 = "a > b".Compile<float, int, bool>("a", "b");
Console.WriteLine(func1(5.4f, 4));
Console.WriteLine(func1(4f, 4));
>>> True
>>> False
var cr = new CompilationProtocol()
{
    ConstantConverter = ent => Expression.Constant(ent.ToString()),
    BinaryNodeConverter = (a, b, t) => t switch
    {
        Sumf => Expression.Call(typeof(string)
            .GetMethod("Concat", new[] { typeof(string), typeof(string) }) ?? throw new Exception(), a, b),
        _ => throw new Exception()
    }
};
var func2 = "a + b + c + 1234"
    .Compile<Func<string, string, string, string>>(
        cr, typeof(string),
        new[] {
            (typeof(string), Var("a")),
            (typeof(string), Var("b")),
            (typeof(string), Var("c")) }
        );
Console.WriteLine(func2("White", "Black", "Goose"));
>>> WhiteBlackGoose1234
(Последний пример - пример того, как пользователь сам объявляет протокол вместо того, чтобы использовать существующий. И получает лямбду, которая конкатенирует строки).Заключение
  • Linq.Expression по истине гениальная вещь.
  • В этой небольшой статье мы, не особо напрягаясь, скомпилировали математическое выражение.
  • Чтобы обеспечить произвольность типов, мы придумали протокол, по которому происходит трансляция выражения.
  • Чтобы избежать написания одного и того же кода, мы предложили целый ряд оверлоадов с дефолтным протоколом, отлично работающим с целым рядом встроенных типов. Этот протокол автоматически апкастит типы в бинарных операторах и функциях до ближайшего общего типа, если есть необходимость.
Такой функционал можно использовать там, где хотелось бы в рантайме получать быстро-работающую математическую функцию из строки. Может быть, сообщество придумает еще какое-нибудь полезное применение. Кстати, компиляцию в рантайме я уже использовал в другом проекте, в котором динамическая компиляция вложенных циклов позволила избежать рекурсии (и сэкономить драгоценные наносекунды).Спасибо за внимание! Следующая статья возможно будет про символьные пределы или парсинг.Ссылки
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_programmirovanie (Программирование), #_.net, #_algoritmy (Алгоритмы), #_c#, #_matematika (Математика), #_csharp, #_matematika (математика), #_kompiljatsija (компиляция), #_expression, #_angourimath, #_linq, #_.net, #_programmirovanie (
Программирование
)
, #_.net, #_algoritmy (
Алгоритмы
)
, #_c#, #_matematika (
Математика
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 08:15
Часовой пояс: UTC + 5