【问题标题】:Efficiently eliminate common sub-expressions in .NET Expression Tree有效消除 .NET 表达式树中的常见子表达式
【发布时间】:2014-01-13 18:34:33
【问题描述】:

我编写了一个 DSL 和一个编译器,可以从中生成一个 .NET 表达式树。 树中的所有表达式都没有副作用,并且该表达式保证是“非语句”表达式(没有局部变量、循环、块等)。 (编辑:树可能包括文字、属性访问、标准运算符和函数调用——它们可能在内部做一些花哨的事情,比如记忆,但在外部没有副作用)。

现在我想对其进行“通用子表达式消除”优化。

例如,给定一棵对应于 C# 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);
}

我熟悉编写表达式访问器,但这种优化的算法对我来说并不是很明显 - 我当然可以在树中找到“重复项”,但显然有一些技巧可以分析其中的依赖关系和子树之间有效和正确地消除子表达式。

我在 Google 上寻找算法,但它们似乎很难快速实现。此外,它们看起来非常“笼统”,不一定考虑到我所考虑的树的简单性。

【问题讨论】:

  • 子树之间的依赖关系是什么意思?你不是说这些没有副作用吗?
  • @Mehrdad:是的,没有副作用。我所说的“依赖关系”只是指foo.Bar * 5 + foo.Baz * 2 依赖于foo.Bar 等等(即foo.Bar 存在于其子树中)。
  • 你认为(a + b) + c 等同于a + (b + c)吗?
  • @Mehrdad:不,算法没有必要根据关联性(或交换性等)等操作的“特殊”属性进行优化
  • 你让我评论这个问题;我不是这个优化方面的专家,所以除了:确保你清楚地了解你正在优化的资源之外,我没有太多要说的。 CSE 通过消除冗余计算减少了时间,但增加了空间使用。增加的空间使用会转化为寄存器争用、更多缓存未命中等,这可能会使事情变得更糟。这是一个棘手的优化,只有当我有强有力的证据表明与空间成本相比,时间优势很大时,我才会这样做。

标签: c# .net algorithm optimization expression-trees


【解决方案1】:

您正在做不必要的工作,常见的子表达式消除是抖动优化器的工作。让我们举个例子,看看生成的代码。我是这样写的:

    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 抖动为 lambda 表达式生成了以下机器代码:

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   

我在代码中用 HERE 标记了 foo.Bar * 5 子表达式被删除的位置。值得注意的是它没有消除foo.Bar * 5 + foo.Baz * 2 子表达式,在地址006526CF 处再次执行了加法。这是有充分理由的,x86 抖动没有足够的寄存器来存储中间结果。如果您查看 x64 抖动生成的机器代码,那么您确实看到它被消除了,r9 寄存器存储了它。

这应该有足够的理由重新考虑您的意图。你正在做不需要做的工作。不仅如此,您可能会生成更糟糕的代码,因为您没有时间估算 CPU 寄存器预算。

不要这样做。

【讨论】:

  • 谢谢,汉斯。我假设所有这些都是简单的字段支持属性?在我的问题中不是这种情况 - 可以很好地计算属性。事实上,也会有无副作用的方法调用。我不能依赖 JITter。
  • 显然您也忽略了内联优化,抖动可以告诉方法没有副作用。在您自己的代码中证明它仅在抖动无法分辨的情况下有用。这需要你处理委托和虚拟方法,祝你好运。
  • 但是汉斯,我知道树中的所有东西都没有副作用。抖动真的很难弄清楚——可能会调用许多纯函数。甚至可能正在进行记忆。
  • @HansPassant 如何检查给定 C# 代码将生成哪些机器代码?我知道您可以使用 ILSpy 等工具检查 MSIL。你如何进一步进入机器代码?
  • 使用调试 + Windows + 反汇编。只看Release build,使用Tools + Options,Debugging,General,取消勾选“Suppress JIT optimization”。
【解决方案2】:

你说得对,这不是一个小问题。

编译器处理它的经典方式是表达式的Directed Acyclic Graph (DAG) representation。 DAG 以与抽象语法树相同的方式构建(并且可以通过遍历 AST 来构建 - 可能是表达式访问者的工作;我不太了解 C# 库),除了以前发出的子图的字典得到维护。在生成具有给定子节点的任何给定节点类型之前,请查阅字典以查看是否已经存在。仅当此检查失败时,才会创建一个新的,然后将其添加到字典中。

由于现在一个节点可能来自多个父节点,因此结果是一个 DAG。

然后DAG先深度遍历生成代码。由于公共子表达式现在由单个节点表示,因此该值仅计算一次并存储在临时文件中,以供稍后在代码生成中发出的其他表达式使用。如果原始代码包含赋值,这个阶段就会变得复杂。由于您的树没有副作用,因此 DAG 应该是解决您的问题的最直接方法。

我记得,Dragon book 中 DAG 的覆盖率特别好。

正如其他人所指出的,如果您的树最终将由现有编译器编译,那么重做已经存在的东西是徒劳的。

加法

我有一些来自学生项目(我教的)的 Java 代码,所以我写了一个小例子来说明它是如何工作的。发帖太长了,请看the Gist here

在您的输入上运行它会打印下面的 DAG。括号中的数字是(唯一 id,DAG 父计数)。需要父计数来决定何时计算局部临时变量以及何时仅使用节点的表达式。

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 节点。您可以使代码生成器更复杂,以去掉不必要的括号,但我会把它留给其他人。

【讨论】:

  • 非常感谢!会看看。
【解决方案3】:
  1. 创建一个可以比较任意Expressions 的SortedDictionary&lt;Expression, object&gt;
    (您可以在这里定义自己的任意比较函数——例如,您可以按字典顺序比较表达式的类型,如果它们比较相等,则可以一一比较子项。)

  2. 遍历所有叶子并将它们添加到字典中;如果它们已经存在,那么它们就是重复的,所以合并它们。
    (这也是发出代码的好时机——比如创建一个新变量——如果它是这个叶子的第一个实例,那么你可以将发出的代码存储在字典中的 object 值中。)

  3. 然后遍历所有之前叶子的父节点,并将它们添加到字典中;如果它们已经存在,那么它们就是重复的,所以合并它们。

  4. 继续一层一层往上走,直到你到达根部。

现在您知道所有重复项是什么以及它们出现的位置,并且您已经为所有重复项生成了代码。

【讨论】:

  • 这看起来不错。它在理论上是否正确,即它是否适用于所有情况?此外,这会产生不必要的本地人,所以我将不得不再次通过“重新内联”只使用一次的本地人。另外,你能解释一下为什么字典需要排序吗?我可以只用一个标准的未分类的,是吗?为了平等,我可以使用stackoverflow.com/a/673246/412770
  • @Ani:是的,平等也可以正常工作,你可以只使用你链接的那个。我个人喜欢排序,因为我喜欢做保证,排序保证 O(log n) 查找,而散列只是基于运气。关于不必要的本地人,是的,所以你必须在之后重新内联本地人。但是,我认为如果您延迟生成本地人,直到您验证是否存在重复项,则可以避免这种情况——即,保留一个计数器,并且仅在计数器变为 >= 2 时才创建本地人。如果您向左走-向右,我认为这应该保持依赖关系的顺序正确。
  • 这里的遍历顺序很重要。如果我正确理解算法,那么任何遍历所有节点同时保证在父母之前访问孩子的顺序都足够好,是吗?我不明白从左到右的位以及为什么这是相关的。
  • @Ani:我想是的。我认为从左到右的位可能仅在您有多个语句(您没有)时才相关,在这种情况下,您要确保在使用之前声明变量。你可以在这里忽略它。
  • 而关于“重新内联”,这样做效率很低。考虑无重复的(foo.Bar + 3) * 6。我们将生成var l1 = foo.Bar; var l2 = 3, var l3 = l1 + l2; var l4 = 6; var l5 = l3 * l4; return l5; 它需要多次内联传递才能摆脱所有内容。当然我们可以设计一个不需要这个的集成算法吗?
【解决方案4】:

免责声明:我从来没有解决过这样的问题,我只是抛出一个看起来相当有效的想法:

对于树中的每个节点都有某种签名。哈希应该做,可以处理冲突。签名必须将所有 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 有一个非常好的通用表达式优化器,根据您的需要,您可以只使用它 - 例如if you feed it your expression:

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

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2013-07-01
      • 1970-01-01
      • 2021-02-11
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多