为啥我的性能慢到爬行我将方法移动到基类中?

Posted

技术标签:

【中文标题】为啥我的性能慢到爬行我将方法移动到基类中?【英文标题】:Why does my performance slow to a crawl I move methods into a base class?为什么我的性能慢到爬行我将方法移动到基类中? 【发布时间】:2010-03-11 04:23:46 【问题描述】:

我正在用 C# 编写不可变二叉树的不同实现,我希望我的树从基类继承一些常用方法。

不幸的是,从基类派生的类非常小慢。非派生类可以充分发挥作用。这里有两个几乎相同的 AVL 树实现来演示:

AvlTree:http://pastebin.com/V4WWUAyT DerivedAvlTree:http://pastebin.com/PussQDmN

这两棵树有完全相同的代码,但是我在基类中移动了DerivedAvlTree.Insert 方法。这是一个测试应用:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Juliet.Collections.Immutable;

namespace ConsoleApplication1

    class Program
    
        const int VALUE_COUNT = 5000;

        static void Main(string[] args)
        
            var avlTreeTimes = TimeIt(TestAvlTree);
            var derivedAvlTreeTimes = TimeIt(TestDerivedAvlTree);

            Console.WriteLine("avlTreeTimes: 0, derivedAvlTreeTimes: 1", avlTreeTimes, derivedAvlTreeTimes);
        

        static double TimeIt(Func<int, int> f)
        
            var seeds = new int[]  314159265, 271828183, 231406926, 141421356, 161803399, 266514414, 15485867, 122949829, 198491329, 42 ;

            var times = new List<double>();

            foreach (int seed in seeds)
            
                var sw = Stopwatch.StartNew();
                f(seed);
                sw.Stop();
                times.Add(sw.Elapsed.TotalMilliseconds);
            

            // throwing away top and bottom results
            times.Sort();
            times.RemoveAt(0);
            times.RemoveAt(times.Count - 1);
            return times.Average();
        

        static int TestAvlTree(int seed)
        
            var rnd = new System.Random(seed);

            var avlTree = AvlTree<double>.Create((x, y) => x.CompareTo(y));
            for (int i = 0; i < VALUE_COUNT; i++)
            
                avlTree = avlTree.Insert(rnd.NextDouble());
            

            return avlTree.Count;
        

        static int TestDerivedAvlTree(int seed)
        
            var rnd = new System.Random(seed);

            var avlTree2 = DerivedAvlTree<double>.Create((x, y) => x.CompareTo(y));
            for (int i = 0; i < VALUE_COUNT; i++)
            
                avlTree2 = avlTree2.Insert(rnd.NextDouble());
            

            return avlTree2.Count;
        
    

AvlTree:在 121 毫秒内插入 5000 个项目 DerivedAvlTree:在 2182 毫秒内插入 5000 个项目

我的分析器表明程序在BaseBinaryTree.Insert 中花费了过多的时间。任何有兴趣的人都可以查看我使用上面的代码创建的EQATEC log file(您需要EQATEC profiler 才能理解文件)。

我真的很想为我的所有二叉树使用一个通用的基类,但如果性能会受到影响,我就不能这样做。

是什么导致我的 DerivedAvlTree 表现如此糟糕,我可以做些什么来解决它?

【问题讨论】:

我实际上编写了自己的 AVL 树和红黑树的实现,并使用了与您抱怨的方法类似的方法(具有到抽象基类的继承链)。我从未对性能感到满意,但不久前将代码搁置一旁。你重新激发了我的好奇心。 【参考方案1】:

注意 - 现在这里有一个“干净”的解决方案,所以如果您只想要一个运行速度快且不关心所有侦探工作的版本,请跳到最终编辑。

导致速度变慢的似乎不是直接呼叫和虚拟呼叫之间的区别。这与那些代表有关;我不能完全解释它是什么,但是查看生成的 IL 会显示很多缓存的委托,我认为这些委托可能不会在基类版本中使用。但是两个版本之间的 IL 本身似乎没有显着差异,这让我相信抖动本身是部分原因。

看看这个重构,它将运行时间减少了大约 60%:

public virtual TreeType Insert(T value)

    Func<TreeType, T, TreeType, TreeType> nodeFunc = (l, x, r) =>
    
        int compare = this.Comparer(value, x);
        if (compare < 0)  return CreateNode(l.Insert(value), x, r); 
        else if (compare > 0)  return CreateNode(l, x, r.Insert(value)); 
        return Self();
    ;
    return Insert<TreeType>(value, nodeFunc);


private TreeType Insert<U>(T value, 
    Func<TreeType, T, TreeType, TreeType> nodeFunc)

    return this.Match<TreeType>(
        () => CreateNode(Self(), value, Self()),
        nodeFunc);

这应该(并且显然确实)确保每次插入只创建一次插入委托 - 它不会在每次递归时创建。在我的机器上,它将运行时间从 350 毫秒减少到 120 毫秒(相比之下,单类版本的运行时间大约为 30 毫秒,所以这仍然远未达到应有的水平)。

但这里变得更奇怪了——在尝试了上述重构之后,我想,嗯,也许它仍然很慢,因为我只做了一半的工作。所以我也尝试实现第一个代表:

public virtual TreeType Insert(T value)

    Func<TreeType> nilFunc = () => CreateNode(Self(), value, Self());
    Func<TreeType, T, TreeType, TreeType> nodeFunc = (l, x, r) =>
    
        int compare = this.Comparer(value, x);
        if (compare < 0)  return CreateNode(l.Insert(value), x, r); 
        else if (compare > 0)  return CreateNode(l, x, r.Insert(value)); 
        return Self();
    ;
    return Insert<TreeType>(value, nilFunc, nodeFunc);


private TreeType Insert<U>(T value, Func<TreeType> nilFunc,
    Func<TreeType, T, TreeType, TreeType> nodeFunc)

    return this.Match<TreeType>(nilFunc, nodeFunc);

你猜怎么着...这让它再次变慢!使用这个版本,在我的机器上,这次运行花费了 250 多毫秒。

这违背了所有可能将问题与编译的字节码相关的逻辑解释,这就是为什么我怀疑这个阴谋的抖动。我认为上面的第一个“优化”可能是(警告 - 提前推测)允许内联插入委托 - 众所周知,抖动不能内联虚拟调用 - 但还有其他东西这 没有 被内联,这就是我目前被难住的地方。

我的下一步是通过MethodImplAttribute 选择性地禁用某些方法的内联,看看它对运行时有什么影响——这将有助于证明或反驳这个理论。

我知道这不是一个完整的答案,但希望它至少能给你一些工作,也许对这种分解进行一些进一步的实验可以产生与原始版本接近的结果。


编辑:哈,在我提交这个之后,我偶然发现了另一个优化。如果将此方法添加到基类中:

private TreeType CreateNilNode(T value)

    return CreateNode(Self(), value, Self());

现在这里的运行时间下降到 38 毫秒,仅略高于原始版本。这让我大吃一惊,因为实际上并没有引用这个方法!私有Insert&lt;U&gt; 方法仍然与我回答中的第一个代码块相同。我打算将第一个参数更改为引用CreateNilNode 方法,但我不必这样做。抖动看到匿名委托与CreateNilNode 方法相同并共享主体(可能再次内联),或者......或者,我不知道。这是我见过的第一个实例,添加私有方法并且从不调用它可以将程序速度提高 4 倍。

你必须检查一下,以确保我没有意外引入任何逻辑错误 - 很确定我没有,代码几乎相同 - 但如果一切都检查出来,那么你就在这里,这个运行速度几乎与非派生的 AvlTree 一样快。


进一步更新

我能够提出一个基本/派生组合的版本,它实际上比单类版本运行得稍微。进行了一些哄骗,但它确实有效!

我们需要做的是创建一个专用的插入器,它可以只创建一次所有的委托,而不需要进行任何变量捕获。相反,所有状态都存储在成员字段中。把它放在BaseBinaryTree 类中:

protected class Inserter

    private TreeType tree;
    private Func<TreeType> nilFunc;
    private Func<TreeType, T, TreeType, TreeType> nodeFunc;
    private T value;

    public Inserter(T value)
    
        this.nilFunc = () => CreateNode();
        this.nodeFunc = (l, x, r) => PerformMatch(l, x, r);
        this.value = value;
    

    public TreeType Insert(TreeType parent)
    
        this.tree = parent;
        return tree.Match<TreeType>(nilFunc, nodeFunc);
    

    private TreeType CreateNode()
    
        return tree.CreateNode(tree, value, tree);
    

    private TreeType PerformMatch(TreeType l, T x, TreeType r)
    
        int compare = tree.Comparer(value, x);
        if (compare < 0)  return tree.CreateNode(l.Insert(value, this), x, r); 
        else if (compare > 0)  return tree.CreateNode(l, x, r.Insert(value, this)); 
        return tree;
    

是的,是的,我知道,使用可变的内部tree 状态非常不实用,但请记住,这不是树本身,它只是一个一次性的“可运行”实例。没有人说过 perf-opt 很漂亮!这是避免为每个递归调用创建新的Inserter 的唯一方法,否则会因为Inserter 及其内部委托的所有新分配而减慢此速度。

现在把基类的插入方法替换成这样:

public TreeType Insert(T value)

    return Insert(value, null);


protected virtual TreeType Insert(T value, Inserter inserter)

    if (inserter == null)
    
        inserter = new Inserter(value);
    
    return inserter.Insert(Self());

我已经公开了Insert 方法非虚拟;所有实际工作都委托给一个受保护的方法,该方法采用(或创建自己的)Inserter 实例。更改派生类很简单,只需将重写的 Insert 方法替换为:

protected override DerivedAvlTree<T> Insert(T value, Inserter inserter)

    return base.Insert(value, inserter).Balance();

就是这样。现在运行这个。这将花费与AvlTree 几乎完全相同的时间,通常在发布版本中少几毫秒。

速度变慢显然是由于虚拟方法、匿名方法和变量捕获的某些特定组合以某种方式阻止了抖动进行重要优化。我不太确定它是不是内联了,它可能只是在缓存代表,但我认为唯一能真正详细说明的是抖动人员自己。

【讨论】:

顺便说一句,您可能会注意到私有Insert 方法中的U 类型参数似乎没有做任何事情......只是尝试它出来,看着整个事情再次慢下来。为什么向私有方法添加冗余的泛型类型参数会使其运行得更快?你的猜测和我的一样好! 不管怎样,这在泛型、继承和函数式编程中是一个非常有趣的难题——但我只是不喜欢在微软的 voodoo 优化器周围工作,我尤其不喜欢就像解决方法对派生类不透明一样。我认为这里唯一的解决方案是放弃委托和模式匹配结构——C# 从未打算用于这种编程风格。但是还是谢谢你,我真的很感激! :) @Juliet:你还没有给 Aaronaught “答案”支票有什么原因吗?似乎他在这里超越了职责范围。 :) @Juliet:这肯定会超出主要面向对象语言的限制,特别是考虑到已经有一个面向对象的解决方案(访问者)。但是话又说回来,当您处理受 CPU 限制的代码时,这些都是与语言无关的问题。您不必完全放弃函数模型,只需执行我不久前所做的操作并创建一个 AnonymousVisitor,它的工作方式与我们这里的 Inserter 大致相同,但在构造函数中接受它的代表。 完全可以编写上面的代码而不将Inserter泄漏到派生树,只是为了确保线程安全有点棘手。【参考方案2】:

派生类调用原始实现,然后又调用Balance,这和派生类没有任何关系吧?

我认为您可能需要查看生成的机器代码,看看有什么不同。我从源代码中看到的是,您已经将许多静态方法更改为以多态方式调用的虚拟方法......在第一种情况下,JIT 确切地知道将调用什么方法并且可以执行直接调用指令,甚至可能排队。但是对于多态调用,它别无选择,只能进行 v-table 查找和间接调用。该查找代表了正在完成的工作的很大一部分。

如果您调用 ((TreeType)this).Method() 而不是 this.Method(),生活可能会好一些,但您可能无法删除多态调用,除非您还将覆盖方法声明为密封。即使这样,您也可能会为 this 实例的运行时检查付出代价。

将您的可重用代码放入基类中的通用静态方法中可能也会有所帮助,但我认为您仍然需要为多态调用付费。 C# 泛型的优化不如 C++ 模板。

【讨论】:

"派生类调用原始实现,然后又调用 Balance 没有任何关系,是吗?" - 根据我的分析器,打扰 AvlTree 和 DerivedAvlTree 对 Balance 进行相同数量的调用并在 Balance 方法中花费大约相同的时间,所以这可能可以忽略不计。当我查看 Reflector 中两个类的输出时,它们非常相似,但同时又存在细微差别:pastebin.com/8YpNmbW2。很难在该代码中看到任何尖叫“这里太慢了!” Reflector 不会向您显示机器代码,但 Visual Studio 调试器会(您可能必须打开本机调试,然后在断点处停止时右键单击并选择“Go To Assembly”在 Insert 方法中)。 C# 中的大多数优化都是由 JIT 在 MSIL -> 机器码转换期间完成的。【参考方案3】:

您是在 VS IDE 下运行的,对吧?它需要大约 20 倍的时间,对吧?

围绕它进行循环以迭代 10 次,因此长版本需要 20 秒。然后在它运行时,点击“暂停”按钮,然后查看调用堆栈。您会以 95% 的把握准确地看到问题所在。如果您不相信您所看到的,请多尝试几次。为什么它有效? Here's the long explanation 和 here's the short one。

【讨论】:

以上是关于为啥我的性能慢到爬行我将方法移动到基类中?的主要内容,如果未能解决你的问题,请参考以下文章

使用 autofac 将构造函数注入到基类中

基类中 $mdToast 的单个实例/注入

为啥我的类不继承其基类中定义的方法?

为啥不抽象字段?

是否可以通过在基类中添加新的虚函数来破坏代码?

为啥基类指针指向基类中的纯虚方法,而不是派生类中的覆盖方法?