【问题标题】:Is there a code that results in 50% branch prediction miss?是否有导致 50% 分支预测未命中的代码?
【发布时间】:2015-05-11 18:06:13
【问题描述】:

问题:

我正在尝试弄清楚如何编写代码(首选 C,仅当没有其他解决方案时才使用 ASM)在 50% 的情况下会导致分支预测失败

因此,它必须是一段代码,对与分支相关的编译器优化“免疫”,并且所有硬件分支预测不应超过 50%(抛硬币)。更大的挑战是能够在多 CPU 架构上运行代码并获得相同的 50% 未命中率。

我设法在 x86 平台上编写了一个达到 47% 分支未命中率的代码。我怀疑失踪者可能有 3% 来自:

  • 包含分支的程序启动开销(虽然非常小)
  • 分析器开销 - 基本上,每次读取计数器都会引发一个中断,因此这可能会增加额外的可预测分支。
  • 在后台运行的系统调用包含循环和可预测的分支

我编写了自己的随机数生成器,以避免调用可能具有隐藏可预测分支的 rand。如果可用,它也可以使用 rdrand。延迟对我来说并不重要。

问题:

  1. 我能比我的代码版本做得更好吗?更好意味着对所有 CPU 架构获得更高的分支错误预测和相同的结果。
  2. 此代码可以断言吗?这意味着什么?

代码:

#include <stdio.h>
#include <time.h>

#define RDRAND
#define LCG_A   1103515245
#define LCG_C   22345
#define LCG_M   2147483648
#define ULL64   unsigned long long

ULL64 generated;

ULL64 rand_lcg(ULL64 seed)
{
#ifdef RDRAND
    ULL64 result = 0;
    asm volatile ("rdrand %0;" : "=r" (result));
    return result;
#else
    return (LCG_A * seed + LCG_C) % LCG_M;
#endif
}

ULL64 rand_rec1()
{
    generated = rand_lcg(generated) % 1024;

    if (generated < 512)
        return generated;
    else return rand_rec1();
}

ULL64 rand_rec2()
{
    generated = rand_lcg(generated) % 1024;

    if (!(generated >= 512))
        return generated;
    else return rand_rec2();
}

#define BROP(num, sum)                  \
    num = rand_lcg(generated);          \
    asm volatile("": : :"memory");      \
    if (num % 2)                        \
        sum += rand_rec1();             \
    else                                \
        sum -= rand_rec2();

#define BROP5(num, sum)     BROP(num, sum) BROP(num, sum) BROP(num, sum) BROP(num, sum) BROP(num, sum)
#define BROP25(num, sum)    BROP5(num, sum) BROP5(num, sum) BROP5(num, sum) BROP5(num, sum) BROP5(num, sum)
#define BROP100(num, sum)   BROP25(num, sum) BROP25(num, sum) BROP25(num, sum) BROP25(num, sum)

int main()
{
    int i = 0;
    int iterations = 500000;    
    ULL64 num = 0;
    ULL64 sum = 0;

    generated = rand_lcg(0) % 54321;

    for (i = 0; i < iterations; i++)
    {
        BROP100(num, sum);
        // ... repeat the line above 10 times
    }

    printf("Sum = %llu\n", sum);
}

更新 v1:

按照 usr 的建议,我通过在脚本的命令行中改变 LCG_C 参数来生成各种模式。 我能够去 49.67% BP 错过。这对我的目的来说已经足够了,而且我有方法可以在各种架构上生成它。

【问题讨论】:

  • Why is processing a sorted array faster than an unsorted array? 的代码就是这样一个微型基准。除非编译器将代码替换为无分支等效代码。
  • 你怎么知道你只得到了 8% 的分支失误?我很好奇您使用什么检测工具来确定这一点。
  • 不确定是否相关,但rand 并不是一个好的 RNG。它可能是如此可预测,以至于分支预测器实际上能够以一致的方式预测行为。
  • 内联 rand() 调用,rng 不必很好,你只需要不进出它。
  • 如果你想学习一些启发性的东西,打印出你的 LCG 的前 20 个输出,全部减少模 2。

标签: c++ c performance compiler-optimization computer-architecture


【解决方案1】:

如果您知道分支预测器的工作原理,那么您可能会遇到 100% 的错误预测。每次只取预测器的预期预测值,反其道而行之。问题是我们不知道它是如何实现的。

我读过典型的预测器能够预测诸如0,1,0,1 等模式。但我确信模式的长度是有限度的。我的建议是尝试给定长度(例如 4)的每种模式,看看哪个最接近您的目标百分比。您应该能够同时瞄准 50% 和 100% 并且非常接近。需要对每个平台进行一次或在运行时进行此分析。

我怀疑分支总数的 3% 是否像您所说的那样在系统代码中。内核不会在纯 CPU 绑定的用户代码上占用 3% 的开销。将调度优先级提高到最大。

您可以通过生成一次随机数据并多次迭代相同数据来将 RNG 排除在游戏之外。分支预测器不太可能检测到这一点(尽管它显然可以)。

我将通过使用我描述的零一模式填充bool[1 &lt;&lt; 20] 来实现这一点。然后,您可以多次运行以下循环:

int sum0 = 0, sum1 = 0;
for (...) {
 //unroll this a lot
 if (array[i]) sum0++;
 else sum1++;
}
//print both sums here to make sure the computation is not being optimized out

您需要检查反汇编以确保编译器没有做任何聪明的事情。

我不明白为什么你现在的复杂设置是必要的。 RNG 可以排除在外,我不明白为什么需要这个简单的循环。如果编译器在玩诡计,您可能需要将变量标记为 volatile,这使得编译器(更好:大多数编译器)将它们视为外部函数调用。

由于 RNG 现在不再重要,因为它几乎从未被调用过,您甚至可以调用操作系统的加密 RNG 来获取(任何人)无法与真正的随机数区分的数字。

【讨论】:

  • 非常感谢您的回复。我选择将 RNG 留在代码中,但我听从了您的建议,并通过改变 LCG 生成了多种模式。我现在可以观察甜蜜点和低预测点。看看我的更新。 50% 就是我所需要的。用布尔值填充缓冲区并生成模式会使设置变得复杂,以便删除所有可预测的分支。
  • 一个问题是分支预测器可能以不可预测的随机状态开始,因此在您的流程或测试代码的一次运行中以 100% 错误预测结束的系列可能有 50% 或 0%下一个。这在更简单的预测器中不太常见,但是对于具有大量共享状态和决定如何进行预测的元预测器的更现代的预测器,有时会变得难以重现。
  • 使用 TAGE 的现代预测器(例如,最近的 Intel)具有大约 20 个分支的历史长度,因此可以完美地预测大约该长度的大多数重复模式。除此之外,它们仍将几乎完美地预测更长长度的重复 random 模式,因为它们有效地使用最后约 20 个分支作为历史表的键。这至少是约 1,000,000 个唯一键,因此原则上,周期最多可以说是该数量的一半的模式可以很好地预测,因为大多数键将是“唯一的”。
  • ...当然,实际预测器没有足够的存储空间来实际维护 100 万个唯一历史记录的条目,因此在实践中,一旦开始达到分支预测器 - 但你不能真正用“分支历史长度”来描述它。
【解决方案2】:

用字节填充数组,并编写一个循环来检查每个字节并根据字节的值进行分支。

现在非常仔细地检查您的处理器的架构及其分支预测。填充数组的初始字节,以便在检查它们之后,处理器处于可预测的已知状态。从该已知状态,您可以确定是否预测下一个分支。设置下一个字节,使预测错误。再次,判断下一个分支是否被预测,并设置下一个字节以使预测错误,依此类推。

如果您也禁用中断(这可能会改变分支预测),您可能会接近 100% 错误预测的分支。

作为一个简单的例子,在具有强/弱预测的旧 PowerPC 处理器上,经过三个采用分支后,它将始终处于“强采用”状态,而一个未采用的分支将其更改为“弱采用”状态。如果您现在有一系列交替的未采用/采用分支,则预测总是错误的,并且在弱未采用和弱采用之间切换。

这当然只适用于特定的处理器。大多数现代处理器会认为该序列几乎 100% 可预测。例如,他们可能使用两个单独的预测器;一个用于“最后一个分支被采用”的情况,一个用于“最后一个分支未被采用”的情况。但是对于这样的处理器,不同的字节序列将给出相同的 100% 误预测率。

【讨论】:

  • 嗯...问题是我需要一个通用代码,一个在统计上会在所有架构上产生 50% 的分支缺失的代码。我也想知道,如果我关闭中断,那么我就无法测量分支相关的计数器......对吗?
  • 再次感谢您。您的回答也正确,但 usr 的回答更详细一些,并由观众投票。
【解决方案3】:

避免编译器优化的最简单方法是在另一个翻译单元中使用 void f(void) { }void g(void) { } 伪函数,并禁用链接时优化。这将迫使 if (*++p) f(); else g(); 成为一个真正不可预测的分支,假设 p 指向一个随机布尔数组(这回避了 rand() 内部的分支预测问题 - 只需在测量之前执行此操作)

如果for(;;) 循环给您带来问题,只需输入goto

请注意,评论中的“循环展开技巧”有些误导。您实际上是在创建数千个分支。每个分支都将被单独预测,但很可能没有一个分支会被预测,因为 CPU 根本无法容纳数千个不同的预测。这可能对您的真正目标有益,也可能不会。

【讨论】:

  • 我相信你的例子实际上是完全可以预测的。这是一种交替的开/关模式。
  • @ZanLynx:这完全取决于p 指向的随机数据数组。即使编译器使用 两个 条件分支(这是一个糟糕的实现),两个分支也将完全依赖于 p 的最后一个值,这使得两个预测同样毫无意义。
  • 感谢您的回答。所以你建议在共享库之类的东西中有 2 个函数 f 和 g,并随机调用它们。这可能会奏效。我会试一试。关于 goto,我仍然需要退出模拟循环,所以我需要用分支检查一些东西。
  • 还有一件事。你说手动展开循环会使cpu溢出它的分支目标缓冲区。我想知道分支是否只执行一次。我认为在我的情况下,一个新分支只会占用一个已被驱逐的分支的条目,因为它没有历史记录。
  • @VAndrei:不要试图跳出循环。我的意思是写一个无限循环。调用 TerminateThread 或您的操作系统从另一个监控线程使用的任何内容。
猜你喜欢
  • 2018-11-30
  • 2017-06-12
  • 1970-01-01
  • 2018-07-02
  • 2015-11-04
  • 2014-07-31
  • 2014-01-25
  • 1970-01-01
  • 2015-04-03
相关资源
最近更新 更多