在通用对象更新循环中,是按控制器更新更好还是按对象更新更好

In generic object update loop, is it better to update per controller or per object?

本文关键字:更新 对象 更好 控制器 循环      更新时间:2023-10-16

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

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

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::函数,因为我不能在特定情况下使用它。我也有意使用共享指针而不是原始指针,这实际上对我的问题并不重要。

不管怎样,这里的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实例一起使用哪个更新实现,但我想提供一个默认策略,我无法决定哪一个策略在大多数情况下最有效。

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

我记得伟大的@Mysticial的回答:

"英特尔编译器11"创造了奇迹。它将两个环路互换,从而将不可预测的支路提升到外环路。因此,它不仅可以避免预测失误,而且速度是VC++和GCC生成速度的两倍!

关于主题的维基百科文章

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

缓存友谊接近神性

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

考虑到缓存的有效性,这两个循环之间的唯一区别是m_objects被连续地布置(因为它们包含在向量中),并且它们在存储器中被线性地访问(因为循环是有序的),但是m_controllers只指向这里,并且它们可以在存储器中的任何地方,此外,它们可以是具有不同CCD_ 4方法的不同类型。因此,当我们在它们上面循环时,我们会在记忆中跳跃。

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

  • LoopA:内部循环高效运行(除非对象很大——数百或数千字节——或者它们将数据存储在自己之外,例如std::string),因为缓存访问模式是可预测的,CPU将预取连续的缓存行,因此不会在读取对象的内存时出现太多停滞。然而,如果对象向量的大小大于L2(或L3)缓存的大小,则外循环的每次迭代都需要重新加载整个缓存。但是,缓存重新加载将是有效的
  • 循环B:如果控制器确实有许多不同类型的update()方法,这里的内部循环可能会在内存中引起剧烈的跳跃,但所有这些不同的更新函数都将处理缓存和可用的数据(特别是当对象很大或它们本身包含指向分散在内存中的数据的指针时)。)除非update()方法本身访问了太多内存(例如,因为它们的代码巨大,或者它们需要大量自己的数据,即控制器数据),否则它们在每次调用时都会对缓存进行猛烈抨击;在这种情况下,所有的赌注都会落空

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

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

对代码进行排序

如果可以的话,根据控制器的类型对其进行排序可能是有益的!您可以使用Controller中的一些内部机制或类似typeid()的机制,或其他技术根据控制器的类型对其进行排序,这样连续update()过程的行为就会变得更加规则、可预测和美观。

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

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

改编与即兴创作

实现这两种循环策略并在编译时(甚至运行时)根据用户提示或编译时可用的信息(例如T的大小或T的一些特性;如果T很小和/或POD,则可能应该使用循环a。)

你甚至可以在运行时这样做,根据对象和控制器的数量以及你能找到的关于它们的任何其他信息来做出决定。

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

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

关于分支预测

这里唯一有趣的分支是虚拟update调用,作为通过两个指针(指向控制器实例的指针和指向其vtable的指针)的间接调用,很难预测。然而,基于类型的排序控制器将极大地帮助实现这一点。

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

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