Подтвердить что ты не робот

Эффективно исключить общие подвыражения в дереве выражений .NET.

Я написал DSL и компилятор, который генерирует из него дерево выражений .NET. Все выражения внутри дерева не имеют побочных эффектов, и выражение гарантируется как выражение "без выражения" (без локалей, циклов, блоков и т.д.). (Изменить). Дерево может включать в себя литералы, права доступа к функциям, стандартные операторы и вызовы функций, которые могут быть полезными, например, внутри memoization, но внешне свободны от побочных эффектов).

Теперь я хотел бы выполнить оптимизацию "Общего исключения подвыражений" на нем.

Например, для дерева, соответствующего С# lambda:

foo =>      (foo.Bar * 5 + foo.Baz * 2 > 7) 
         || (foo.Bar * 5 + foo.Baz * 2 < 3)  
         || (foo.Bar * 5 + 3 == foo.Xyz)

... Я хотел бы сгенерировать древовидный эквивалент (игнорировать тот факт, что некоторые из короткозамкнутой семантики игнорируются):

foo =>
{
     var local1 = foo.Bar * 5;

     // Notice that this local depends on the first one.        
     var local2 = local1 + foo.Baz * 2; 

     // Notice that no unnecessary locals have been generated.
     return local2 > 7 || local2 < 3 || (local1 + 3 == foo.Xyz);
}

Я знаком с написанием expression-visitors, но алгоритм для этой оптимизации не сразу ясен для меня - я мог бы, конечно, найти "дубликаты" в дереве, но, очевидно, есть трюк для анализа зависимостей внутри и между суб-деревьями для эффективного и правильного устранения подвыражений.

Я искал алгоритмы для Google, но они кажутся довольно сложными для реализации. Кроме того, они кажутся очень "общими" и не обязательно учитывают простоту деревьев, которые я имею в виду.

4b9b3361

Ответ 1

Вы правильно заметили, что это не тривиальная проблема.

Классический способ обработки компиляторами представляет собой выражение Directed Acyclic Graph (DAG) выражения. DAG построена так же, как и абстрактное синтаксическое дерево (и может быть построено путем прохождения AST - возможно, задания для посетителя выражения, я мало знаю из библиотек С#), за исключением того, что словарь ранее выпущенных подграфов поддерживается. Прежде чем генерировать какой-либо заданный тип node с указанными дочерними элементами, обратитесь к этому словарю, чтобы узнать, существует ли он уже. Только если эта проверка завершилась неудачей, создается новая, а затем добавлена ​​в словарь.

Поскольку теперь node может опускаться от нескольких родителей, результатом является DAG.

Затем DAG сначала пересекает глубину, чтобы сгенерировать код. Поскольку общие подвыражения теперь представлены одним node, значение вычисляется только один раз и сохраняется в temp для других выражений, выпущенных позже в генерации кода для использования. Если исходный код содержит присвоения, эта фаза становится сложной. Поскольку ваши деревья свободны от побочных эффектов, DAG должен быть самым простым способом решить вашу проблему.

Насколько я помню, особенно привлекателен охват DAG в книге Драконов.

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

Добавление

У меня был некоторый Java-код, лежащий в основе студенческого проекта (я преподаю), так что он взломал небольшой пример того, как это работает. Это слишком долго для публикации, но см. Gist здесь.

Запуск на вашем входе печатает ниже DAG. Числа в parens (уникальный id, DAG parent count). Родительский счет необходим, чтобы решить, когда вычислять локальные временные переменные и когда просто использовать выражение для node.

Binary OR (27,1)
  lhs:
    Binary OR (19,1)
      lhs:
        Binary GREATER (9,1)
          lhs:
            Binary ADD (7,2)
              lhs:
                Binary MULTIPLY (3,2)
                  lhs:
                    Id 'Bar' (1,1)
                  rhs:
                    Number 5 (2,1)
              rhs:
                Binary MULTIPLY (6,1)
                  lhs:
                    Id 'Baz' (4,1)
                  rhs:
                    Number 2 (5,1)
          rhs:
            Number 7 (8,1)
      rhs:
        Binary LESS (18,1)
          lhs:
            ref to Binary ADD (7,2)
          rhs:
            Number 3 (17,2)
  rhs:
    Binary EQUALS (26,1)
      lhs:
        Binary ADD (24,1)
          lhs:
            ref to Binary MULTIPLY (3,2)
          rhs:
            ref to Number 3 (17,2)
      rhs:
        Id 'Xyz' (25,1)

Затем он генерирует этот код:

t3 = (Bar) * (5);
t7 = (t3) + ((Baz) * (2));
return (((t7) > (7)) || ((t7) < (3))) || (((t3) + (3)) == (Xyz));

Вы можете видеть, что числа temp var соответствуют узлам DAG. Вы можете сделать генератор кода более сложным, чтобы избавиться от ненужных скобок, но я оставлю это для других.

Ответ 2

Вы делаете ненужную работу, устранение общего подвыражения - это работа оптимизатора джиттера. Возьмем ваш пример и посмотрим на сгенерированный код. Я написал это следующим образом:

    static void Main(string[] args) {
        var lambda = new Func<Foo, bool>(foo => 
               (foo.Bar * 5 + foo.Baz * 2 > 7)
            || (foo.Bar * 5 + foo.Baz * 2 < 3) 
            || (foo.Bar * 5 + 3 == foo.Xyz));
        var obj = new Foo() { Bar = 1, Baz = 2, Xyz = 3 };
        var result = lambda(obj);
        Console.WriteLine(result);
    }
}

class Foo {
    public int Bar { get; internal set; }
    public int Baz { get; internal set; }
    public int Xyz { get; internal set; }
}

Излучение x86 генерирует этот машинный код для выражения лямбда:

006526B8  push        ebp                          ; prologue
006526B9  mov         ebp,esp  
006526BB  push        esi  
006526BC  mov         esi,dword ptr [ecx+4]        ; esi = foo.Bar
006526BF  lea         esi,[esi+esi*4]              ; esi = 5 * foo.Bar
006526C2  mov         edx,dword ptr [ecx+8]        ; edx = foo.Baz
006526C5  add         edx,edx                      ; edx = 2 * foo.Baz
006526C7  lea         eax,[esi+edx]                ; eax = 5 * foo.Bar + 2 * foo.Baz
006526CA  cmp         eax,7                        ; > 7 test
006526CD  jg          006526E7                     ; > 7 then return true
006526CF  add         edx,esi                      ; HERE!!
006526D1  cmp         edx,3                        ; < 3 test
006526D4  jl          006526E7                     ; < 3 then return true
006526D6  add         esi,3                        ; HERE!!
006526D9  mov         eax,esi  
006526DB  cmp         eax,dword ptr [ecx+0Ch]      ; == foo.Xyz test
006526DE  sete        al                           ; convert to bool
006526E1  movzx       eax,al  
006526E4  pop         esi                          ; epilogue
006526E5  pop         ebp  
006526E6  ret 
006526E7  mov         eax,1  
006526EC  pop         esi  
006526ED  pop         ebp  
006526EE  ret   

Я обозначил места в коде, где суб-выражение foo.Bar * 5 было удалено ЗДЕСЬ. Примечательно, что это не устранило суб-выражение foo.Bar * 5 + foo.Baz * 2, добавление было выполнено снова по адресу 006526CF. Для этого есть веская причина, хеттер x86 не имеет достаточного количества регистров для хранения промежуточного результата. Если вы посмотрите на машинный код, сгенерированный джиттером x64, то вы увидите его устраненным, регистр r9 сохранит его.

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

Не делайте этого.

Ответ 3

  • Сделайте SortedDictionary<Expression, object>, который может сравнивать произвольные Expression s.
    (Здесь вы можете определить свою собственную функцию сравнения - например, вы можете лексикографически сравнивать типы выражений, а если сравнивать друг с другом, то вы можете сравнивать их по одному.)

  • Пройдите через все листья и добавьте их в словарь; если они уже существуют, то они дубликаты, поэтому объедините их.
    (Это также хорошее время для испускания кода - например, создания новой переменной - для этого листа, если это первый экземпляр этого файла, вы можете сохранить испускаемый код внутри значения object в словаре.)

  • Затем перейдите к родителям всех предыдущих листов и добавьте их в словарь; если они уже существуют, то они дубликаты, поэтому объедините их.

  • Продолжайте идти вверх по уровню, пока не достигнете корня.

Теперь вы знаете, что все дубликаты, и где они происходят, и вы создали код для всех.

Ответ 4

Отказ от ответственности: я никогда не занимался такой проблемой, я просто выбрасываю идею, которая кажется разумно эффективной:

Для каждого node в дереве есть своего рода подпись. Хэш должен делать, с которыми можно столкнуться. Подпись должна сопоставлять все записи Foo.Bar с тем же значением.

Поверните дерево (O (n)), создав список подписи узлов INTERNAL (игнорируйте листья), сортируйте по объединенному ключу размера выражения и затем подписи (O (n log n)). Возьмите наиболее распространенный элемент наименьшего выражения в списке (O (n)) и перейдите к замене выражения локальной переменной. (Убедитесь, что они действительно совпадают в это время на всякий случай, когда мы столкнулись с хеш-столкновением. B)

Повторяйте это, пока не выполните ничего. Это не может превышать n/2 раза, тем самым ограничивая всю операцию O (n ^ 2 log n).

Ответ 5

Я согласен с hans-passant относительно практичности этого. Однако, если вы изучаете это в академическом плане, вас может заинтересовать алгоритм Quine-McCluskey. Остерегайтесь, это очень сложная проблема. Mathematica имеет очень хороший универсальный оптимизатор выражений, и в зависимости от ваших потребностей вы сможете просто использовать его - например, если вы передадите это выражение:

enter image description here

(foo.Bar = A, foo.Baz = B, foo.Xyz = X)