在通用对象更新循环中,更新每个控制器或每个对象更好吗?

时间:2013-04-05 22:06:22

标签: c++ performance templates c++11

我正在编写一些通用代码,它基本上会有一组控制器正在更新的对象。

代码在我的特定上下文中有点复杂,但简化如下:

template< class T >
class Controller
{ 
public:
    virtual ~Controller(){}
    virtual void update( T& ) = 0;
    // and potentially other functions used in other cases than update
}

template< class T >
class Group
{
public:
    typedef std::shared_ptr< Controller<T> > ControllerPtr;

    void add_controller( ControllerPtr );    // register a controller
    void remove_controller( ControllerPtr ); // remove a controller

    void update(); // udpate all objects using controllers

private:

    std::vector< T > m_objects;
    std::vector< ControllerPtr > m_controllers;
};

我故意不使用std :: function,因为我不能在我的特定情况下使用它。 我也故意使用共享指针而不是原始指针,这对我的问题实际上并不重要。

无论如何,这是我感兴趣的update()实现。 我可以用两种方式做。

A)对于每个控制器,更新所有对象。

template< class T >
void Group<T>::update()
{
    for( auto& controller : m_controllers )
        for( auto& object : m_objects )
            controller->update( object );
}

B)对于每个对象,通过应用所有控制器进行更新。

template< class T >
void Group<T>::update()
{
    for( auto& object : m_objects )
        for( auto& controller : m_controllers )
            controller->update( object );
}

“测量!测量!测量!”你会说,我完全同意,但我无法测量我不使用的东西。问题是它是通用代码。我不知道T的大小,我只是假设它不会很大,也许很小,可能还是有点大。真的,我不能假设T,除了它被设计为包含在矢量中。 我也不知道将使用多少个控制器或T实例。在我目前的用例中,会有很多不同的计数。

问题是:哪种解决方案通常是最有效的

我在考虑缓存一致性。此外,我假设此代码将用于不同的编译器和平台。

我的胆量告诉我,更新指令缓存肯定比更新数据缓存更快,这将使解决方案B)通常更有效。然而,当我对表现产生怀疑时,我学会了不相信我的阵风,所以我在这里问。

我得到的解决方案将允许用户选择(使用编译时策略)更新实现以用于每个Group实例,但我想提供默认策略而我无法决定哪一个对大多数情况来说是最有效的。

2 个答案:

答案 0 :(得分:2)

我们有一个活生生的证明,现代编译器(特别是英特尔C ++)能够交换循环,所以它对你来说并不重要。

我记得伟大的@Mysticial's answer

  

英特尔编译器11做了一些奇迹。它交换两个循环,从而将不可预测的分支提升到外循环。因此,它不仅能够免受错误预测的影响,而且速度也是VC ++和GCC能够产生的速度的两倍!

Wikipedia article about the topic

检测是否可以进行循环交换需要检查交换的代码是否真的会产生相同的结果。理论上,可以准备不会允许交换的类,但是再次,可以准备可以从更多版本中受益的类。

答案 1 :(得分:1)

缓存友善接近敬虔

对于各个控制器的update方法的行为方式一无所知,我认为性能中最重要的因素是缓存友好性

考虑到缓存有效性,两个循环之间的唯一区别是m_objects是连续布局的(因为它们包含在向量中)并且它们在内存中线性访问(因为循环是有序的)但是m_controllers仅指向此处,它们可以位于内存中的任何位置,而且,它们可以是不同类型,具有不同的update()方法,它们本身可以驻留在任何位置。因此,在循环它们时,我们会在内存中跳跃。

关于缓存,这两个循环的行为如下:(当你关注性能时,事情永远不会简单明了,所以请耐心等待!)

  • 循环 A :内部循环有效运行(除非对象很大 - 数百或数千个字节 - 或者它们将数据存储在自身之外,例如{{ 1}})因为高速缓存访​​问模式是可预测的,并且CPU将预取连续的高速缓存行,因此在读取对象的内存时不会停止。但是,如果对象向量的大小大于L2(或L3)高速缓存的大小,则外循环的每次迭代都需要重新加载整个高速缓存。但同样,缓存重新加载将是有效的!
  • 循环 B :如果控制器确实有许多不同类型的std::string方法,那么这里的内循环可能会导致内存中的疯狂跳跃,但所有这些不同的更新函数将处理缓存和可用的数据(特别是如果对象很大或它们本身包含分散在内存中的数据的指针。)除非update()方法本身访问这么多内存(因为,例如,他们的代码是 huge ,或者他们需要大量自己的 - 即控制器 - 数据),他们在每次调用时都会破坏缓存;在这种情况下,所有投注都是关闭的。

所以,我建议采用以下策略,这需要您可能的信息:

  • 如果对象很小(或很小!)和类似POD(不包含指针本身),那么肯定更喜欢循环 A
  • 如果对象很大和/或很复杂,或者有许多不同类型的复杂控制器(数百或数千种不同的update()方法),则首选 loop B < / em>的
  • 如果对象很大和/或很复杂,并且有很多对它们进行迭代将会多次颠簸缓存(数百万个对象),并且update()方法很多并且它们非常多大而复杂,需要大量其他数据,然后我说你的循环顺序没有任何区别,你需要考虑重新设计对象和控制器。

对代码进行排序

如果可以,根据类型对控制器进行排序可能会有所帮助!您可以在update()Controller或其他技术中使用某些内部机制来根据控件的类型对控制器进行排序,因此连续typeid()次传递的行为变得更加规则,可预测且更好

无论您选择实施哪种循环顺序,这都是一个好主意,但它会在循环 B 中产生更多效果。

但是,如果您在控制器之间有如此多的变化(即,如果几乎所有都是唯一的),这将不会有太大帮助。此外,显然,如果您需要保留应用控制器的顺序,您将无法做到这一点。

适应和即兴创作

根据用户提示或基于编译时可用的信息(例如,大小),实现两个循环策略并在编译时(或甚至运行时)之间进行选择应该不难。 update()T的某些特征;如果T很小和/或POD,您可能应该使用循环 A 。)

您甚至可以在运行时执行此操作,根据对象和控制器的数量以及您可以找到的有关它们的任何其他内容做出决定。

但是,这些&#34; Klever&#34;技巧可以让你陷入麻烦,因为你的容器的行为将取决于奇怪的,不透明的甚至令人惊讶的启发式和黑客攻击。此外,它们可能甚至会在某些情况下损害性能,因为还有许多其他因素干扰这两个循环的性能,包括但不限于数据的性质以及对象和控制器中的代码,确切的大小和配置高速缓存级别及其相对速度,CPU的体系结构以及处理预取,分支预测,高速缓存未命中等的确切方式,编译器生成的代码等等。

如果你想使用这种技术(实现两个循环并在它们之间切换是编译和/或运行时),我强烈建议你让用户做出选择。您可以接受关于要使用哪种更新策略的提示,作为模板参数或构造函数参数。您甚至可以拥有两个用户可以随意调用的更新功能(例如TupdateByController())。

关于分支预测

这里唯一有趣的分支是虚拟updateByObject()调用,并且作为通过两个指针(指向控制器实例的指针,然后是指向其vtable的指针)的间接调用,很难预测。但是,基于类型对控制器进行排序将对此有很大帮助。

还要记住,错误预测的分支将导致几个到几十个CPU周期的停顿,但是对于高速缓存未命中,停顿将在数百个周期内。当然,错误预测的分支也会导致缓存未命中,所以......正如我之前所说,在性能方面没有什么是简单明了的!

无论如何,我认为缓存友好性是目前性能中最重要的因素。