2

我正在编写一些通用代码,它们基本上将有一个由一组控制器更新的对象向量。

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

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 实例一起使用,但我想提供一个默认策略,我无法决定哪个是在大多数情况下最有效。

4

2 回答 2

2

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

我从伟大的@Mysticial 的回答中记住了这一点:

Intel Compiler 11 做了一些神奇的事情。它交换两个循环,从而将不可预测的分支提升到外部循环。因此,它不仅不受错误预测的影响,而且速度也比 VC++ 和 GCC 生成的速度快两倍!

维基百科关于该主题的文章

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

于 2013-04-05T22:09:16.983 回答
1

缓存友好性接近虔诚

对单个控制器的方法如何表现一无所知update,我认为性能中最重要的因素是缓存友好性

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

关于缓存,这两个循环的行为如下:(当你关心性能时,事情从来都不是简单直接的,所以请耐心等待!)

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

因此,我通常建议以下策略,这需要您可能没有的信息:

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

对代码进行排序

如果可以的话,根据控制器的类型对控制器进行排序可能会有所帮助!您可以使用某种内部机制Controller或类似的东西typeid()或其他技术根据控制器的类型对控制器进行排序,因此连续update()传递的行为变得更加规律、可预测和良好。

无论您选择实现哪种循环顺序,这都是一个好主意,但它在循环B中会产生更大的影响。

但是,如果控制器之间的差异很大(即如果实际上所有控制器都是唯一的),这将无济于事。此外,显然,如果您需要保留应用控制器的顺序,您将无法执行此操作。

适应和即兴创作

实现这两种循环策略并在编译时(甚至运行时)根据用户提示或基于编译时可用的信息(例如 的大小TT; 如果 T 很小并且/ 或 POD,您可能应该使用循环A。)

您甚至可以在运行时执行此操作,根据对象和控制器的数量以及您可以找到的任何其他信息来决定。

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

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

关于分支预测

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

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

无论如何,我认为缓存友好性是迄今为止影响性能的最重要因素。

于 2013-04-06T11:04:55.653 回答