6

我有大量这样的 C 结构实例:

struct mystruct
{
    /* ... */
    unsigned flag: 1;
    /* ... */
};
  • flag最初为 0,但在退出某个函数时必须为 1。

最简单的实现是:

void set_flag(struct mystruct *sp)
{
    sp->flag = 1U;
}

但是这样做对性能的可能影响是什么:

void set_flag(struct mystruct *sp)
{
    if (sp->flag == 0U)
    {
        sp->flag = 1U;
    }
}

我希望避免写入主存储器。第一个版本总是进行写入,第二个版本仅在尚未设置标志的情况下执行写入,但在绝大多数情况下,标志已经设置。

还有哪些其他因素(例如分支预测)可能会影响性能?

到目前为止,我已经看到了速度的小幅提升,我希望随着数据集变得更大,它会变得更加显着。

对于大型数据集,这种变化是否存在使程序变慢的风险,如果是这样,在什么情况下会发生这种情况?

4

9 回答 9

10

设置前的测试确实会有所不同,但它有多少取决于您的用例。

在任何一种情况下,数据都将在缓存行中结束(例如,只是写入或测试和设置)。

但是,如果您的缓存行被标记为脏(例如已修改)或干净,则会有所不同。脏的缓存行必须写回主存,而干净的缓存行可以被遗忘并填充新数据。

现在考虑您的代码会破坏大量数据,并且您只访问每个数据块一次或两次。如果是这样可以假设大多数内存访问是缓存未命中。如果在发生高速缓存未命中并且大多数高速缓存行是脏的那一刻,您的大部分高速缓存行都是脏的,会发生什么?

在将新数据加载到行之前,必须将它们写回主存储器。这比忘记缓存行的内容要慢。它还将使高速缓存和主内存之间的内存带宽增加一倍。

这对于一个 CPU 内核来说可能没有什么不同,因为现在内存很快,但是另一个 CPU(希望)也会做一些其他的工作。如果总线不忙于将缓存线移入和移出,您可以确定其他 CPU 内核将更快地执行所有操作。

简而言之:保持缓存行干净将使带宽需求减半,并使缓存未命中更便宜。

关于分支:当然:代价高昂,但缓存未命中更糟糕!此外,如果你很幸运,CPU 将使用它的乱序执行功能来抵消缓存未命中与分支的成本。

如果您真的想从这段代码中获得最佳性能,并且如果您的大多数访问都是缓存未命中,那么您有两种选择:

  • 绕过缓存:x86 架构为此目的具有非临时加载和存储。它们隐藏在 SSE 指令集中的某个地方,可以通过内部函数从 c 语言中使用。

  • (仅适用于专家):使用一些行内汇编程序,将 test-and-set 函数替换为使用 CMOV(条件移动)指令的汇编程序。这不仅可以保持缓存行干净,而且可以避免分支。现在 CMOV 是一条慢指令,只有在无法预测分支时才会优于分支。所以你会更好地对你的代码进行基准测试。

于 2009-06-08T12:24:46.140 回答
3

这是一个有趣的问题,Nils 关于缓存行的回答绝对是很好的建议。

我想强调分析代码以衡量实际性能的重要性——您能否衡量该标志在您遇到的数据中设置的频率?根据答案,性能可能会发生很大变化。

只是为了好玩,我用你的代码在一个 5000 万个元素数组上运行了 set 与 test-then-set 的小比较,其中填充了各种比例的 1。这是一个图表:

设置与测试然后设置的比较
(来源:natekohl.net

当然,这只是一个玩具示例。但是请注意非线性性能——这是我没有预料到的——当数组几乎完全被 1 填充时,测试然后设置变得比普通设置更快。

于 2009-06-08T17:25:26.750 回答
2

这些是我对您的要求的解释,

  • 你有单独初始化的标志
  • 它只设置一次(为 1),之后不会重置
  • 但是,这组尝试将在同一个标​​志上进行多次
  • 而且,您有很多这些标志实例(每个都需要相同类型的处理)

假如说,

  • 空间优化的权重远低于时间优化,

我建议以下几点。

  • 首先,在 32 位系统上,如果您担心访问时间,使用 32 位整数会有所帮助
  • 如果您跳过对标志“字”的检查,写入将非常快。但是,鉴于您有大量的标志,如果尚未设置,您将继续检查和设置,最好保留条件检查。
  • 但是,话虽如此,如果您的平台执行并行操作(例如,写入磁盘通常可以与您的代码执行并行发送),那么跳过检查是值得的。
于 2009-06-08T12:26:57.290 回答
1

当移动到更大的数据集时,这种优化可能不会导致速度下降。

读取值相同时的缓存抖动,分支预测惩罚也将相同,这些是此处优化的关键因素。

分支预测存储每个分支指令的历史记录,因此只要您使用不同地址的指令(例如内联函数)对它们进行分支,它就不会关心您有多少实例。如果您有一个单一的功能实体(未内联),那么您将拥有一条适用于所有人的分支指令,这将抑制分支预测,使其更频繁地错过并增加惩罚。

于 2009-06-08T12:02:44.733 回答
0

您可以随时进行分析,但我很确定第一个版本更快且不那么晦涩。

于 2009-06-08T11:55:03.703 回答
0

任何一种方法都需要将数据加载到缓存中,因此您唯一的节省将是读/写和写之间的差异。

我看不出这种更改如何使您的代码在使用更大的数据集时变慢,因此您在这方面可能足够安全。

这对我来说有点像过早的乐观。(除非您的分析已将此确定为瓶颈)

与所有与性能相关的事情一样,确定代码更改效果的最佳方法是对其进行测量。您应该能够相对轻松地创建大量测试数据。

于 2009-06-08T11:57:05.480 回答
0

如果您真的担心时间性能,请将标志更改为完整的 int 而不是位域。然后设置它只是一个写入而不是像位域那样的读写。

但正如已经指出的那样,这有微优化的味道。

于 2009-06-08T12:02:56.403 回答
0

设置前的测试没有任何意义 - 没有测试的代码更干净,也更快。

作为旁注 - 内联这样的函数是有意义的,因为函数调用的开销大于函数体,尽管优化编译器应该不加思索地做到这一点。

于 2009-06-08T12:03:09.240 回答
0

既然没人说,那我就说吧。

你为什么要使用位域?布局因编译器而异,因此它们对接口毫无用处。它们可能会或可能不会更节省空间;编译器可能只是决定将它们推入一个 32 位字段,以便有效地填充事物。不能保证它们会更快,实际上它们可能会更慢。

我已经禁止他们在工作中使用。除非有人能给我一个令人信服的理由,他们提供了任何额外的能力,否则不值得和他们一起玩。

于 2009-06-08T17:08:26.257 回答