通常,是否值得使用虚拟函数来避免分支?


21

似乎粗略地等效于指令,等同于分支未命中虚拟函数的代价具有类似的权衡:

  • 指令与数据缓存未命中
  • 优化障碍

如果您看到类似:

if (x==1) {
   p->do1();
}
else if (x==2) {
   p->do2();
}
else if (x==3) {
   p->do3();
}
...

您可能具有成员函数数组,或者如果许多函数都依赖于同一分类,或者存在更复杂的分类,请使用虚函数:

p->do()

但是,总的来说,虚拟函数和分支的代价是多少?很难在足够多的平台上进行测试以进行泛化,所以我想知道是否有人有一个粗略的经验法则(可爱if的是,断点只有4 s吗?)

总的来说,虚拟功能更加清晰,我倾向于它们。但是,我有几个非常关键的部分,可以在其中将代码从虚函数更改为分支。在进行此操作之前,我希望对此有所考虑。(这不是微不足道的更改,也不是易于在多个平台上进行测试)


12
那么,您对性能的要求是什么?您是否有困难要打,还是正在进行过早的优化?分支和虚拟方法在整体方案中都非常便宜(例如,与不良算法,I / O或堆分配相比)。
阿蒙2015年

4
做任何更具可读性/灵活性/不太可能的事情来阻碍将来的更改,一旦工作完成,进行性能分析,看看这是否真正重要。通常不会。
Ixrec 2015年

1
问题:“但是,总的来说,虚拟函数有多昂贵……”答案:间接分支(维基百科)
rwong

1
请记住,大多数答案都是基于对指令数量的计数。作为低级代码优化器,我不相信指令的数量。您必须在实验条件下在特定的CPU架构上(物理上)证明它们。这个问题的有效答案必须是经验和实验,而不是理论上的。
rwong 2015年

3
这个问题的问题在于,前提是它足够大,可以担心。在实际的软件中,性能问题会大量出现,例如,多种尺寸的披萨片。例如看这里。不要以为您知道最大的问题是什么-让程序告诉您。解决此问题,然后告诉您下一个是什么。这样做六次,您可能会陷入虚函数调用值得担心的地方。根据我的经验,他们从来没有。
Mike Dunlavey 2015年

Answers:


21

我想跳入这些已经非常出色的答案中,并承认我采用了一种丑陋的方法,即实际上向后工作,将反过来将多态代码更改为具有测得的增益switchesif/else分支的反模式。但是我并没有这样做,只是针对最关键的路径。它不必如此黑与白。

作为免责声明,我在诸如光线追踪这样的领域工作,在这些领域中,很难实现正确性(并且通常还是模糊和近似),而速度通常是所寻求的最具竞争力的质量之一。减少渲染时间通常是用户最普遍的要求之一,我们不断摸索,想出如何在最关键的测量路径上实现它。

条件的多态重构

首先,值得理解的是,为什么从可维护性的角度来看,多态性比条件分支(switch或一堆if/else语句)更可取。这里的主要好处是可扩展性

使用多态代码,我们可以在代码库中引入一个新的子类型,将其实例添加到某些多态数据结构中,并使所有现有的多态代码仍然可以自动进行,而无需进行进一步的修改。如果您的代码堆分散在整个大型代码库中,类似于“如果此类型为'foo',请执行此操作”的形式,那么您可能会感到负担重重,需要更新50个不同的代码部分以进行介绍一种新的事物,但最终仍然缺少一些东西。

如果您的代码库中只有几个甚至一个部分需要进行这种类型检查,那么多态性的可维护性优势自然就会减少。

优化壁垒

我建议不要过多地从分支和流水线的角度看待这一问题,而应该从优化障碍的编译器设计思想上多看待它。有两种方法可以改进适用于两种情况的分支预测,例如根据子类型对数据进行排序(如果适合序列)。

这两种策略之间最大的不同是优化器预先拥有的信息量。已知的函数调用可提供更多信息,而在编译时调用未知函数的间接函数调用会导致优化障碍。

当已知要调用的函数时,编译器可以清除结构并将其压缩为smithereens,内联调用,消除潜在的别名开销,在指令/寄存器分配方面做得更好,甚至可能重新排列循环和其他形式的分支,从而产生困难适当的情况switch下使用经代码编码的微型LUT(最近,GCC 5.3的一项声明使我感到惊讶,因为它使用硬编码的数据LUT作为结果而不是跳转表)。

当我们开始将编译时未知量引入混合时,其中的一些好处就失去了,例如间接函数调用的情况,而条件分支最有可能在其中提供优势。

记忆体最佳化

以视频游戏为例,该视频游戏包含在紧密循环中重复处理一系列生物。在这种情况下,我们可能会有一些这样的多态容器:

vector<Creature*> creatures;

注意:为简单起见,我unique_ptr在这里避免。

...其中Creature是多态基本类型。在这种情况下,多态容器的困难之一是它们经常希望分别/单独地为每个子类型分配内存(例如:operator new对每个生物使用默认投掷)。

通常,这将成为基于内存而不是分支的优化的优先级(我们需要)。这里的一种策略是为每个子类型使用固定的分配器,通过分配大块并为分配的每个子类型分配内存来鼓励连续表示。通过这种策略,绝对可以帮助creatures根据子类型(以及地址)对该容器进行排序,因为这不仅可能会改善分支预测,而且可以提高引用的局部性(允许访问同一子类型的多个生物)从逐出之前的单个缓存行开始)。

数据结构和循环的部分虚拟化

假设您经历了所有这些动作,但您仍然希望提高速度。值得注意的是,我们在这里迈出的每一步都在降低可维护性,而且我们已经处在金属研磨阶段,性能回报不断下降。因此,如果我们涉足这一领域,就需要一个相当大的性能需求,在该领域,我们愿意为获得越来越小的性能提升而进一步牺牲可维护性。

但是,下一步的尝试(并且如果没有帮助,总是愿意放弃所做的更改)可能是手动虚拟化。

版本控制提示:除非您比我更精通优化,否则在此时创建一​​个新分支并值得一去,如果我们的优化工作错过了,这很可能会失败,那么就值得放弃。对我来说,即使有了分析器,这一切都是经过反复尝试才能得出的结论。

不过,我们不必完全采用这种思维方式。继续我们的示例,到目前为止,假设该视频游戏主要由人类生物组成。在这种情况下,我们可以通过吊起人类动物并为其创建单独的数据结构来使其仅具有虚拟化。

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures

这意味着我们代码库中需要处理生物的所有区域都需要一个单独的特殊循环来处理人类生物。但这消除了迄今为止人类最常见的生物类型的动态调度开销(或更恰当地说,可能是优化障碍)。如果这些区域数量众多并且我们负担得起,我们可以这样做:

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures
vector<Creature*> creatures;        // contains humans and other creatures

...如果我们负担得起,那么次要的路径可以保持原样,并简单地抽象处理所有生物类型。关键路径可以humans在一个循环和other_creatures第二个循环中处理。

我们可以根据需要扩展此策略,并有可能以此方式挤压一些收益,但值得注意的是,我们在此过程中降低了可维护性。在此处使用功能模板可以帮助为人类和生物生成代码,而无需手动复制逻辑。

类的部分去虚拟化

我几年前所做的事情确实很糟糕,而且我什至不确定它是否有益(在C ++ 03时代),这是对类的部分虚拟化。在那种情况下,我们已经在每个实例中存储了一个类ID用于其他目的(通过非虚拟基类中的访问器访问)。在那里,我们做了类似的事情(我的记忆有些朦胧):

switch (obj->type())
{
   case id_common_type:
       static_cast<CommonType*>(obj)->non_virtual_do_something();
       break;
   ...
   default:
       obj->virtual_do_something();
       break;
}

... virtual_do_something实现了在子类中调用非虚拟版本的位置。我知道,做一个显式的静态下调来虚拟化函数调用是很麻烦的。我不知道这现在有多大益处,因为我已经多年没有尝试过这种事情了。接触到面向数据的设计后,我发现上述以热/冷方式拆分数据结构和循环的策略更加有用,为优化策略打开了更多的大门(并且丑陋得多)。

批发虚拟化

我必须承认,我到目前为止还没有应用优化思想,所以我不知道这样做的好处。在我只知道一组中央条件(例如:仅具有一个中央位置处理事件的事件处理),但从未开始采用多态心态并一直进行优化的情况下,我避免了预见中的间接功能。到这里。

从理论上讲,除了完全消除这些优化障碍之外,此处的直接收益可能是与虚拟指针相比可能更小的标识类型的方法(例如,如果您可以承诺存在256个或更少的唯一类型,则为单个字节)。 。

如果您只使用一个中央switch语句而不必根据子类型拆分数据结构和循环,或者在有顺序的情况下,在某些情况下也可能有助于编写易于维护的代码(与上面优化的手动去虚拟化示例相比)-在这种情况下,必须以精确的顺​​序处理事物(即使这会导致我们在整个分支机构分支),因此需要依赖。这适用于您没有太多需要做的地方的情况switch

我通常不建议这样做,即使是非常注重性能的心态,除非这样做相当容易维护。“易于维护”将取决于两个主要因素:

  • 没有真正的可扩展性需求(例如:肯定要处理8种类型的事物,并且永远不再需要)。
  • 您的代码中没有很多地方需要检查这些类型(例如:一个中心位置)。

...但是我建议在大多数情况下使用上述方案,并根据需要通过部分虚拟化进行迭代以找到更有效的解决方案。它为您提供了更多的喘息空间,以平衡可扩展性和可维护性需求与性能。

虚拟函数与函数指针

最重要的是,我在这里注意到有关虚拟函数与函数指针的一些讨论。确实,虚函数需要一些额外的工作来调用,但这并不意味着它们会变慢。违反直觉,它甚至可能使它们更快。

这在这里是违反直觉的,因为我们习惯于根据指令来衡量成本,而没有关注可能会产生更大影响的内存层次结构的动态性。

如果我们将a class与20个虚函数进行比较,而a 与struct存储20个函数指针的情况进行比较,并且都实例化了多次,则class在这种情况下,每个实例的内存开销在64位计算机上为8个字节的虚拟指针的开销struct为160个字节。

函数指针表与使用虚拟函数的类相比,使用强制函数表和使用虚拟函数的类可能会有更多的强制性和非强制性高速缓存未命中(以及可能在足够大的输入范围内出现页面错误)。这种成本往往使索引虚拟表所付出的额外工作变得微不足道。

我还处理过旧的C代码库(比我老),在其中structs填充了函数指针,并实例化了无数次,通过将它们转换为带有虚函数的类,实际上获得了显着的性能提升(超过100%的改进)。由于大量减少了内存使用,提高了缓存友好性等。

另一方面,当人们对苹果进行比较时,我同样发现从C ++虚拟函数思维方式转换为C风格函数指针思维方式的相反思维方式在以下类型的场景中很有用:

class Functionoid
{
public:
    virtual ~Functionoid() {}
    virtual void operator()() = 0;
};

...该类存储一个单一的,可覆盖的函数(如果我们计算虚拟析构函数,则存储两个)。在这些情况下,它绝对可以在关键路径上帮助您将其转化为:

void (*func_ptr)(void* instance_data);

...理想情况下是在类型安全的界面后面,以隐藏来回的危险转换 void*

在那些我们想将类与单个虚函数一起使用的情况下,它可以快速帮助您改用函数指针。一个很大的原因甚至不必降低调用函数指针的成本。这是因为,如果我们将每个单独的functionoid聚集到一个持久的结构中,就不再面临在堆的分散区域上分配每个单独的functionoid的诱惑。例如,如果实例数据是同质的,并且仅行为发生变化,则这种方法可以更轻松地避免堆相关的内存碎片开销。

因此,在某些情况下肯定可以使用函数指针来提供帮助,但是如果我们将一堆函数指针表与单个vtable进行比较(通常每个类实例只需要存储一个指针),通常我会发现另一种情况。该vtable经常会处于紧密循环中,位于一个或多个L1高速缓存行中。

结论

所以无论如何,这是我在这个话题上的小话题。我建议在这些区域进行冒险操作。信任度量不是本能,并且鉴于这些优化通常会降低可维护性的方式,只能尽您所能(并且明智的做法是在可维护性方面犯错误)。


虚函数是函数指针,仅在该类的可行对象中实现。调用虚拟函数时,首先在子级和继承链中对其进行查找。这就是为什么深度继承非常昂贵并且通常在c ++中避免使用的原因。
罗伯特·巴伦

@RobertBaron:我从未见过像您所说的那样实现虚拟功能(=通过类层次结构进行链式查找)。通常,编译器仅使用所有正确的函数指针为每种具体类型生成一个“扁平化” vtable,并且在运行时使用一次直接表查找即可解决该调用。深度继承层次结构不收取任何罚款。
Matteo Italia

Matteo,这是多年前技术主管给我的解释。当然,这是针对c ++的,因此他可能一直在考虑多重继承的含义。感谢您阐明我对如何优化vtable的理解。
罗伯特·巴伦

感谢您的好答案(+1)。我想知道其中有多少相同地适用于std :: visit而不是虚拟函数。
DaveFar

13

观察结果:

  • 在许多情况下,由于vtable查找是一个O(1)操作而else if()梯形图是一个O(n)操作,因此虚函数会更快。但是,这仅在案例分布均匀的情况下才成立。

  • 对于single而言if() ... else,条件查询速度更快,因为可以节省函数调用的开销。

  • 因此,当案件的分布均匀时,必须存在一个收支平衡点。唯一的问题是它的位置。

  • 如果使用梯形图或虚拟函数调用,switch()而不是else if()梯形图或虚拟函数调用,则编译器可能会生成更好的代码:它可以跳转到从表中查找但不是函数调用的位置。也就是说,您具有虚拟函数调用的所有属性,而没有所有函数调用的开销。

  • 如果一个频率比其他频率高得多,则从if() ... else该情况开始将获得最佳性能:您将执行一个在大多数情况下都可以正确预测的条件分支。

  • 您的编译器不知道个案的预期分布,并且将假定其为均匀分布。

由于您的编译器可能在何时将其编码switch()else if()梯形图或表查找方面具有一些良好的启发式方法。除非您知道案件分配有偏差,否则我倾向于相信它的判断。

因此,我的建议是:

  • 如果其中一种情况在频率上使其余的相形见fe,请使用排序 else if()梯子。

  • 否则使用 switch()语句,除非其他方法之一使您的代码更具可读性。确保不要以明显降低的可读性获得可忽略的性能提升。

  • 如果您switch()对性能仍然不满意,请进行比较,但准备发现switch()该速度已经是最快的方法。


2
一些编译器允许注释告诉编译器哪种情况更有可能是正确的,并且只要注释正确,这些编译器就可以生成更快的代码。
gnasher729

5
在现实世界中,O(1)操作不一定比O(n)甚至O(n ^ 20)更快。
whatsisname 2015年

2
@whatsisname这就是为什么我说“很多情况”的原因。通过定义O(1)O(n)存在k,这样的O(n)功能比越大O(1)的所有功能n >= k。唯一的问题是您是否可能有那么多案件。而且,是的,我看到过switch()很多情况下的语句,以至于else if()梯形绝对比虚拟函数调用或加载的调度慢。
cmaster-恢复莫妮卡

我对这个答案的问题是,只有在上一段的第二部分中隐藏了唯一的警告,该警告是基于完全不相关的性能提升来做出决定。此处的所有其他内容都假装基于性能来决定ifvs. switchvs.虚拟功能可能是一个好主意。在极少数情况下可能会,但在大多数情况下并非如此。
布朗

7

通常,是否值得使用虚拟函数来避免分支?

一般来说,是的。维护的好处是巨大的(测试分离,关注点分离,改进的模块性和可扩展性)。

但是,总的来说,虚拟函数和分支的代价是多少?很难在足够多的平台上进行测试以进行泛化,所以我想知道是否有人有一个粗略的经验法则(可爱的是,如果断点只有4个,那么这很简单)

除非您已概要分析代码并知道分支之间的调度(条件评估)要比执行的计算(分支中的代码)花费更多的时间,否则请优化执行的计算。

也就是说,正确的答案是“找出虚拟功能相对于分支的昂贵程度”,并加以确定。

经验法则:除非有上述情况(分支鉴别比分支计算贵),否则为维护工作而优化这部分代码(使用虚拟函数)。

您说您希望此部分尽快运行。那有多快?您有什么具体要求?

总的来说,虚拟功能更加清晰,我倾向于它们。但是,我有几个非常关键的部分,可以在其中将代码从虚函数更改为分支。在进行此操作之前,我希望对此有所考虑。(这不是微不足道的更改,也不是易于在多个平台上进行测试)

然后使用虚函数。这甚至可以让您在必要时针对每个平台进行优化,并且仍然保持客户端代码的清洁。


经过大量的维护编程,我要小心一点:正是由于您列出的优点,虚拟函数IMNSHO很难维护。核心问题是它们的灵活性。您几乎可以在其中贴任何东西...而人们却可以。静态地推理动态调度非常困难。但是在大多数特定情况下,代码并不需要所有的灵活性,而删除运行时灵活性可以使推理代码变得更加容易。但是我不想说你永远不应该使用动态调度。太荒谬了
伊蒙·纳邦

最好的抽象方法是很少使用的(即,代码库只有几个不透明的抽象方法),但超级duper健壮。基本上:不要仅仅因为动态分配抽象在某种情况下具有相似的形状而在动态分配抽象的后面添加一些东西;仅当您无法合理地想到任何理由来关心共享该接口的对象之间的任何区别时,才这样做。如果您做不到:拥有一个非封装的助手比泄漏的抽象更好。即使这样 在运行时灵活性和代码库灵活性之间需要权衡。
伊蒙·纳邦

5

其他答案已经提供了良好的理论依据。我想添加我最近进行的实验的结果,以估计使用大量switch的操作码来实现虚拟机(VM)还是将操作码解释为索引是一个好主意放入函数指针数组。虽然这与virtual函数调用不完全相同,但我认为它相当接近。

我编写了一个Python脚本,为VM随机生成C ++ 14代码,其指令集大小在1到10000之间随机选择(尽管不是均匀地选择,更密集地采样低范围)。生成的VM始终具有128个寄存器,并且没有内存。这些说明没有意义,都具有以下形式。

inline void
op0004(machine_state& state) noexcept
{
  const auto c = word_t {0xcf2802e8d0baca1dUL};
  const auto r1 = state.registers[58];
  const auto r2 = state.registers[69];
  const auto r3 = ((r1 + c) | r2);
  state.registers[6] = r3;
}

该脚本还使用一条switch语句生成调度例程。

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  switch (opcode)
  {
  case 0x0000: op0000(state); return 0;
  case 0x0001: op0001(state); return 0;
  // ...
  case 0x247a: op247a(state); return 0;
  case 0x247b: op247b(state); return 0;
  default:
    return -1;  // invalid opcode
  }
}

…以及一组函数指针。

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  typedef void (* func_type)(machine_state&);
  static const func_type table[VM_NUM_INSTRUCTIONS] = {
    op0000,
    op0001,
    // ...
    op247a,
    op247b,
  };
  if (opcode >= VM_NUM_INSTRUCTIONS)
    return -1;  // invalid opcode
  table[opcode](state);
  return 0;
}

为每个生成的VM随机选择生成哪个调度例程。

为了进行基准测试,操作码流是由随机种子(std::random_device)Mersenne twister随机引擎(std::mt19937_64)生成的。

每个VM的代码均使用-DNDEBUG-O3-std=c++14开关在GCC 5.2.0中进行了编译。首先,它使用-fprofile-generate为模拟1000条随机指令而收集的选项和配置文件数据进行编译。然后使用-fprofile-use允许基于所收集的概要文件数据进行优化的选项重新编译该代码。

然后,在5亿次循环中对VM进行了4次(在同一过程中)锻炼,并测量了每次运行的时间。第一次运行被丢弃以消除冷缓存效应。在两次运行之间没有重新植入PRNG,因此它们没有执行相同的指令序列。

使用此设置,每个调度例程收集了1000个数据点。数据收集在具有2048 KiB高速缓存,运行64位GNU / Linux的四核AMD A8-6600K APU上,而没有运行图形桌面或其他程序。下面显示的是每个VM的每条指令的平均CPU时间(带有标准偏差)的曲线图。

在此处输入图片说明

从这些数据中,我可以确信使用功能表是个好主意,但可能只需要很少的操作码。我没有针对这些异常值的解释switch 500到1000条指令之间。

可以在我的网站上找到基准测试的所有源代码以及完整的实验数据和高分辨率图表


3

除了我推荐的cmaster的好答案之外,请记住,函数指针通常严格比虚拟函数快。虚拟函数分派通常涉及首先跟随从对象到vtable的指针,适当地建立索引,然后取消引用函数指针。因此,最后一步是相同的,但是最初还有其他步骤。另外,虚函数总是以“ this”作为参数,函数指针更加灵活。

要记住的另一件事:如果关键路径涉及循环,则按调度目标对循环进行排序可能会有所帮助。显然,这是nlogn,而遍历循环只有n,但是如果要遍历许多遍,则值得这样做。通过按分发目标进行排序,可以确保重复执行相同的代码,从而使其在icache中保持高温,从而最大程度地减少缓存未命中。

需要牢记的第三种策略:如果您决定从虚拟函数/函数指针转向if / switch策略,则可以通过从多态对象切换到boost :: variant之类的东西(也提供切换)来很好地为您服务。访客抽象形式)。多态对象必须由基本指针存储,因此您的数据在缓存中无处不在。与虚拟查找的成本相比,这可能对您的关键路径的影响更大。鉴于变体以区别联集的形式内联存储;它的大小等于最大的数据类型(加上一个小的常数)。如果您的对象大小差异不大,这是处理它们的好方法。

实际上,如果提高数据的缓存一致性比最初的问题产生更大的影响,我不会感到惊讶,因此,我肯定会对此进行更多研究。


我不知道虚拟功能是否涉及“额外步骤”。鉴于该类的布局在编译时是已知的,因此它实际上与数组访问相同。也就是说,有一个指向类顶部的指针,并且该函数的偏移量是已知的,因此只需将其添加进去,读取结果即可,即地址。没有太多的开销。

1
它确实涉及额外的步骤。vtable本身包含函数指针,因此当您将其指向vtable时,已达到使用函数指针开始的相同状态。进入vtable之前的所有工作都是额外的工作。类不包含其vtable,它们包含指向vtable的指针,在该指针之后是一个额外的取消引用。实际上,有时会有第三次取消引用,因为多态类通常由基类指针保存,因此您必须取消引用指针以获取vtable地址(取消引用;-))。
尼尔·弗里德曼 Nir Friedman)

另一方面,vtable存储在实例外部的事实实际上可以对时间局部性有所帮助,例如,一堆不同的函数指针结构,其中每个函数指针都存储在不同的内存地址中。在这种情况下,具有一百万个vptr的单个vtable可以轻松击败一百万个函数指针表(仅从内存消耗开始)。在这里可能有些折腾-很难分解。通常,我同意函数指针通常要便宜一些,但将一个指针放在另一个指针上并不是那么容易。

我认为,换句话说,当您涉及大量对象实例时(其中每个对象将需要存储多个函数指针或单个vptr),虚拟函数开始快速且明显优于函数指针。如果您仅将一个函数指针存储在内存中,这将被称为“千载难逢”,那么函数指针往往会更便宜。否则,由于许多冗余地占用内存并指向同一地址而导致的数据冗余和高速缓存未命中,功能指针可能会开始变慢。

当然,对于函数指针,即使它们被一百万个单独的对象共享,您也仍然可以将它们存储在中央位置,以避免占用内存并导致大量的缓存未命中。但是随后它们开始变得等同于vpointer,涉及到对内存中共享位置的指针访问,以获取我们要调用的实际函数地址。这里的基本问题是:您是否将函数地址存储在距离您当前正在访问的数据更近的位置或中央位置?vtables只允许后者。函数指针允许两种方式。

2

我可以解释一下为什么我认为这是XY问题吗?(您并不孤单地问他们。)

我认为您的真正目标是节省总体时间,而不仅仅是了解有关缓存缺失和虚函数的知识。

这是真实软件中真实性能调整的示例。

在实际的软件中,无论程序员多么有经验,都可以做得更好。在编写程序并完成性能调整之前,还不知道它们是什么。几乎总是有不止一种方法来加速程序。毕竟,要说一个程序是最佳的,就是说在解决您的问题的可能程序的万神殿中,它们都不花费更少的时间。真?

在我链接的示例中,最初每个“作业”花费2700微秒。修复了一系列六个问题,围绕比萨饼逆时针旋转。第一次加速消除了33%的时间。第二个删除11%。但是请注意,第二个问题在发现时并不是11%,而是16%,因为第一个问题已经消失了。同样,第三个问题也从7.4%放大到13%(几乎翻倍),因为前两个问题都消失了。

最后,这个放大过程可以消除3.7微秒以外的所有时间。那是原始时间的0.14%,或730x的加速。

在此处输入图片说明

消除最初的大问题可以适度提高速度,但是它们为消除以后的问题铺平了道路。这些较早的问题最初可能是总数中不重要的部分,但是在消除了较早的问题之后,这些较小的问题就变得很大并可以产生较大的提速。(了解这一点很重要,要获得此结果,不能错过任何内容,并且这篇文章显示了它们的实现难度。)

在此处输入图片说明

最终程序是否最佳?可能不会。没有任何提速与缓存未命中有关。缓存未命中现在重要吗?也许。

编辑:我对参与OP的“高度关键部分”的人们感到不满。您不知道某件事情是“高度关键”的,直到您知道它占时间的比例。如果这些方法被调用的平均成本为10个周期或更多,则随着时间的流逝,与它们实际执行的操作相比,分派给它们的方法可能不是“关键”的。我一遍又一遍地看到了这一点,人们将“需要每一纳秒”视为精打细算和愚蠢的理由。


他已经说过,他有几个“非常关键的部分”,它们需要每纳秒的性能。因此,这不是他所提问题的答案(即使这对别人的问题来说是一个很好的答案)
gbjbaanb 2015年

2
@gbjbaanb:如果每隔十亿分之一秒计算一次,为什么这个问题从“一般”开始?废话 纳秒级计算时,您将无法找到一般的答案,无法查看编译器的功能,无法查看硬件的功能,可以尝试变化,还可以测量每个变化。
gnasher729

@ gnasher729我不知道,但是为什么它以“高度关键的部分”结尾?我想像slashdot一样,应该始终阅读内容,而不仅仅是标题!
gbjbaanb 2015年

2
@gbjbaanb:每个人都说他们有“非常关键的部分”。他们怎么知道 我不知道有什么要紧的,直到我拿出10个样本,然后在2个或更多样本中看到它。在这种情况下,如果所调用的方法使用10条以上的指令,则虚拟函数的开销可能微不足道。
Mike Dunlavey,2015年

@ gnasher729:好吧,我要做的第一件事就是获取堆栈样本,然后在每个样本上检查程序在做什么以及为什么。然后,如果将其所有时间都花在了调用树的叶子上,并且所有调用实际上都是不可避免的,那么编译器和硬件的工作是否重要。仅当样品落在方法分配过程中时,您才知道方法分配很重要。
Mike Dunlavey,2015年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.