【问题标题】:Odd optimisation problem under MSVCMSVC下的奇优化问题
【发布时间】:2010-02-05 13:01:26
【问题描述】:

我看过这个博客:

http://igoro.com/archive/gallery-of-processor-cache-effects/

第 7 部分中的“怪异”引起了我的兴趣。

我的第一个想法是“这只是 C# 很奇怪”。

下面的C++代码不是我写的。

volatile int* p = (volatile int*)_aligned_malloc( sizeof( int ) * 8, 64 );
memset( (void*)p, 0, sizeof( int ) * 8 );

double dStart   = t.GetTime();

for (int i = 0; i < 200000000; i++)
{
    //p[0]++;p[1]++;p[2]++;p[3]++;  // Option 1
    //p[0]++;p[2]++;p[4]++;p[6]++;  // Option 2
    p[0]++;p[2]++;                  // Option 3
}

double dTime    = t.GetTime() - dStart;

我在 2.4 Ghz Core 2 Quad 上的时间安排如下:

Option 1 = ~8 cycles per loop.
Option 2 = ~4 cycles per loop.
Option 3 = ~6 cycles per loop.

现在这令人困惑。我的差异背后的原因归结为我的芯片上的缓存写入延迟(3 个周期)以及缓存具有 128 位写入端口的假设(这纯粹是我的猜测)。

在选项 1 的基础上:它将增加 p[0](1 个周期)然后增加 p[2](1 个周期)然后它必须等待 1 个周期(用于缓存)然后 p[1](1 个周期) ) 然后等待 1 个周期(用于缓存)然后 p[3](1 个周期)。最后 2 个循环用于递增和跳转(虽然它通常实现为递减和跳转)。总共有 8 个周期。

在选项 2 中:它可以在一个循环中增加 p[0] 和 p[4],然后在另一个循环中增加 p[2] 和 p[6]。然后 2 个循环进行减法和跳转。无需等待缓存。总共 4 个周期。

在选项 3 中:它可以递增 p[0],然后必须等待 2 个周期,然后递增 p[2],然后减去并跳转。问题是,如果您将案例 3 设置为递增 p[0] 和 p[4],它仍然需要 6 个周期(这有点把我的 128 位读/写端口从水中吹出来)。

所以...谁能告诉我这里到底发生了什么?为什么案例 3 需要更长的时间?另外,我很想知道我在上面的想法中有什么问题,因为我显然有问题!任何想法将不胜感激! :)

看看 GCC 或任何其他编译器如何处理它也很有趣!

编辑:Jerry Coffin 的想法给了我一些想法。

我已经做了一些更多的测试(在不同的机器上,所以请原谅时间的变化)有和没有 nops 和不同的 nops 计数

 case 2 - 0.46  00401ABD  jne         (401AB0h)

 0 nops - 0.68  00401AB7  jne         (401AB0h) 
 1 nop  - 0.61  00401AB8  jne         (401AB0h) 
 2 nops - 0.636 00401AB9  jne         (401AB0h) 
 3 nops - 0.632 00401ABA  jne         (401AB0h) 
 4 nops - 0.66  00401ABB  jne         (401AB0h) 
 5 nops - 0.52  00401ABC  jne         (401AB0h) 
 6 nops - 0.46  00401ABD  jne         (401AB0h) 
 7 nops - 0.46  00401ABE  jne         (401AB0h) 
 8 nops - 0.46  00401ABF  jne         (401AB0h)
 9 nops - 0.55  00401AC0  jne         (401AB0h) 

我已经包含了跳转语句,因此您可以看到源和目标位于一个缓存行中。您还可以看到,当我们相距 13 个字节或更多字节时,我们开始出现差异。直到我们达到 16... 然后一切都出错了。

所以 Jerry 是不对的(尽管他的建议确实有点帮助),但是事情正在发生。我越来越有兴趣尝试弄清楚它现在是什么。它似乎更像是某种内存对齐异常,而不是某种指令吞吐量异常。

有人想为好奇的人解释一下吗? :D

编辑 3:Interjay 在展开时有一个点,将之前的编辑从水中吹走。使用展开的循环,性能不会提高。您需要添加一个 nop 以使跳转源和目标之间的差距与我上面的好 nop 计数相同。性能还是很烂。有趣的是,我需要 6 个 nop 来提高性能。我想知道处理器每个周期可以发出多少个 nop?如果它是 3,则说明缓存写入延迟......但是,如果是这样,为什么会出现延迟?

好奇者和好奇者 ...

【问题讨论】:

  • FWIW,很容易让 GCC 在几乎任何操作系统上运行以进行比较,并且您可以免费获得英特尔的编译器。在 Ubuntu 上安装 icc 对我来说非常简单,只要记住你必须有一个 Intel 芯片才能利用它的优化。
  • 我唯一能想到的是一些指令调度怪癖。由于循环较短,CPU 可能不得不在迭代之间暂停几个周期以等待写入完成,这必须由于某种原因导致 额外 减速,使其比较长的循环慢。缓存延迟似乎会平等地影响所有情况,就像您说的那样,R/W 端口宽度似乎也不是。我能想象到的唯一可能导致较短循环花费更长的因素是 CPU 中的某种调度限制。
  • @Jalf:GCi32 是有符号的 32 位整数。对不起,我应该说清楚。我已经编辑了代码。
  • 对我来说,一个危险信号是你使用 double 来计时整个 sha-bang。您应该使用 QueryPerformanceTimer 并使用整数数据类型来存储时间。
  • @Andreas:这有什么关系?

标签: c++ optimization assembly x86


【解决方案1】:

我强烈怀疑您所看到的是分支预测的奇怪之处,而不是与缓存有关。特别是,在相当多的 CPU 上,当分支的源和目标都在同一个缓存行中时,分支预测不起作用(以及 | 根本)。在循环中放入足够多的代码(甚至是 NOP)以将源代码和目标代码放入不同的缓存行中会大大提高速度。

【讨论】:

  • BP 必须在某种程度上工作,否则他会看到比每次迭代 6 个周期更糟糕的性能。但是,是的,好点。我也在另一条评论中提出了一些分支预测器问题,但我不知道“相同的缓存行”限制。听起来是个不错的猜测。
  • @jalf:如果没记错的话,“根本不工作”只出现在 Pentium MMX(也可能是原始 Pentium)中。在较新的处理器上,它至少在某种程度上可以工作,但仍然不如更长的跳跃。
  • 我尝试展开选项#3 的循环,使其与选项#1 和#2 的大小相同,并且时间保持完全相同。所以这一定不是原因。
  • 好一个杰瑞。我在添加周围添加了一堆“__asm nop;”(几乎忘记了 NOP 是单字节指令;))。现在我得到了同样的表现!谢谢! :)
  • @interjay 你真的检查过生成的汇编程序吗? __asm nop 技巧非常适合我 :)
【解决方案2】:

嗯,我与一位英特尔工程师就这个问题进行了简短的交谈,并得到了以下回复:

这显然与哪些指令最终会出现在哪个 执行单元,机器发现存储命中加载的速度 问题,以及它如何快速和优雅地处理展开 推测性执行以应对它(或者如果它需要多个周期 因为一些内部冲突)。但这就是说 - 你需要一个非常 详细的管道跟踪和模拟器来解决这个问题。预测 这些管道中的乱序指令处理太难了 即使是设计机器的人,也要在纸上做。为了 外行 - 地狱没有希望。对不起!

我想我会在这里添加答案并一劳永逸地关闭这个问题:)

【讨论】:

    【解决方案3】:

    这似乎与编译器无关。起初我认为这可能是由于循环展开等编译器技巧,但查看生成的程序集,MSVC 9.0 只是从 C++ 代码生成了一个简单的翻译。

    选项1:

    $LL3@main:
        add DWORD PTR [esi], ecx
        add DWORD PTR [esi+4], ecx
        add DWORD PTR [esi+8], ecx
        add DWORD PTR [esi+12], ecx
        sub eax, ecx
        jne SHORT $LL3@main
    

    选项 2:

    $LL3@main:
        add DWORD PTR [esi], ecx
        add DWORD PTR [esi+8], ecx
        add DWORD PTR [esi+16], ecx
        add DWORD PTR [esi+24], ecx
        sub eax, ecx
        jne SHORT $LL3@main
    

    选项 3:

    $LL3@main:
        add DWORD PTR [esi], ecx
        add DWORD PTR [esi+8], ecx
        sub eax, ecx
        jne SHORT $LL3@main
    

    【讨论】:

    • 是的,我得出了同样的结论。因此,我研究了缓存使用可能出现的异常情况,例如写入端口以及将缓存写入延迟引入游戏。
    【解决方案4】:

    x86 指令集不再代表 CPU 实际执行的操作。指令被翻译成内部机器语言,“微操作”这个词是在 486 天内创造出来的。再加上寄存器重命名、推测执行、多个执行单元以及它们与缓存的交互等内容,就无法预测某些事情需要多长时间。芯片制造商很久以前就停止发布周期时间预测。他们的设计是商业机密。

    【讨论】:

    • 虽然你是对的,但在某种程度上,这里的所有内容都应该在缓存之外运行。在我看来,这似乎是一个重要的优化警告,无论多么秘密,他们的周期时间是 50% 的打击,因为做一半的工作是一个很大的打击。这是英特尔之类的公司通常乐于向人们解释的事情,因为当人们编写闪电般快速的代码时,它会使他们的芯片看起来不错。我敢肯定它必须在某个地方进行解释。
    • @nobugz:Intel 和 AMD 仍然记录个别指令的延迟。当然,关于指令如何并行调度和执行,特别是关于内存/缓存子系统,还有很多注意事项。
    • @Goz:我怀疑这不是 50% 的命中,而是更长循环的 33% 加速。案例 3 的循环体太短了,您可能会遇到很多硬件限制(跳转指令之间必须有几个周期才能验证分支预测器所做的猜测。投入缓存延迟和加载/存储依赖项,我怀疑案例 2 的速度是由于一些特殊的优化启动,这通常不适用,并且由于某种原因不能用于较短的案例 3。
    • 而且我认为没有理由相信英特尔会在任何地方记录这种行为。他们记录的行为是 1) 有用的,2) 可靠的。您的代码没有显示“做两倍的工作将使您的代码运行速度提高 50%”。如果是这样,那将是值得记录的。相反,它只是表明“当循环变得足够短以致延迟占据主导地位并成为瓶颈时,事情开始变得不可预测”。除非您编写大量 4 周期循环,否则这些信息根本无关紧要,那么英特尔为什么要费心记录它呢?
    • 发布周期时间(和其他技术细节)主要取决于竞争。在 Pentium IV 时代,英特尔的 CPU 显然比 AMD 的要慢 - 您可以下载您可能要求的有关英特尔 CPU 的所有技术细节,包括完整的主板原理图。既然 Intel CPU 更快了,你必须先放弃你的生命,然后他们才会告诉你芯片有多少个引脚(好吧,我夸大了,但你明白了)。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-08-18
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多