坚持使用久经考验且值得信赖的宏,即使我们都知道通常要避免使用宏。这些inline
功能根本不起作用。或者——特别是如果你使用 GCC——__builtin_expect
完全忘记并使用配置文件引导优化 (PGO) 来代替实际的分析数据。
非常特别的__builtin_expect
是,它实际上并没有“做”任何事情,而只是提示编译器最有可能采用哪个分支。如果您在不是分支条件的上下文中使用内置函数,编译器将不得不将此信息与值一起传播。直觉上,我本以为会发生这种情况。有趣的是,GCC和Clang的文档对此并不是很明确。然而,我的实验表明 Clang 显然没有传播这些信息。至于 GCC,我仍然需要找到一个真正关注内置的程序,所以我无法确定。(或者,换句话说,无论如何都无所谓。)
我已经测试了以下功能。
std::size_t
do_computation(std::vector<int>& numbers,
const int base_threshold,
const int margin,
std::mt19937& rndeng,
std::size_t *const hitsptr)
{
assert(base_threshold >= margin && base_threshold <= INT_MAX - margin);
assert(margin > 0);
benchmark::clobber_memory(numbers.data());
const auto jitter = make_jitter(margin - 1, rndeng);
const auto threshold = base_threshold + jitter;
auto count = std::size_t {};
for (auto& x : numbers)
{
if (LIKELY(x > threshold))
{
++count;
}
else
{
x += (1 - (x & 2));
}
}
benchmark::clobber_memory(numbers.data());
// My benchmarking framework swallows the return value so this trick with
// the pointer was needed to get out the result. It should have no effect
// on the measurement.
if (hitsptr != nullptr)
*hitsptr += count;
return count;
}
make_jitter
只是[− m , m ]return
范围内的一个随机整数,其中m是它的第一个参数。
int
make_jitter(const int margin, std::mt19937& rndeng)
{
auto rnddist = std::uniform_int_distribution<int> {-margin, margin};
return rnddist(rndeng);
}
benchmark::clobber_memory
是拒绝编译器优化对向量数据的修改的无操作。它是这样实现的。
inline void
clobber_memory(void *const p) noexcept
{
asm volatile ("" : : "rm"(p) : "memory");
}
的声明用do_computation
注释__attribute__ ((hot))
。事实证明,这会影响编译器应用了多少优化。
的代码do_computation
经过精心设计,使得任一分支都具有可比的成本,从而在未满足期望的情况下增加了一些成本。还确保编译器不会生成一个向量化循环,对于该循环来说,分支是无关紧要的。
对于基准测试,使用非确定性种子伪随机数生成器生成范围 [0, ]numbers
中的 100 000 000 个随机整数的向量和区间 [0, - ]INT_MAX
的随机base_threshold
形式(设置为 100)。(在单独的翻译单元中编译)被调用了四次,并测量了每次运行的执行时间。第一次运行的结果被丢弃以消除冷缓存效应。剩余运行的平均值和标准偏差与命中率(INT_MAX
margin
margin
do_computation(numbers, base_threshold, margin, …)
LIKELY
注释是正确的)。添加了“抖动”以使四次运行的结果不一样(否则,我会害怕过于聪明的编译器),同时仍然保持命中率基本固定。以这种方式收集了 100 个数据点。
我已经编译了三个不同版本的程序,GCC 5.3.0 和 Clang 3.7.0 都向它们传递了-DNDEBUG
,-O3
和-std=c++14
标志。版本仅在LIKELY
定义方式上有所不同。
// 1st version
#define LIKELY(X) static_cast<bool>(X)
// 2nd version
#define LIKELY(X) __builtin_expect(static_cast<bool>(X), true)
// 3rd version
inline bool
LIKELY(const bool x) noexcept
{
return __builtin_expect(x, true);
}
尽管在概念上是三个不同的版本,但我比较了 1 st与 2 nd以及 1 st与 3 rd。因此,第一次的数据基本上被收集了两次。第 2次和第 3次在图中被称为“暗示”。
以下图表的水平轴显示了LIKELY
注释的命中率,垂直轴显示了循环每次迭代的平均 CPU 时间。
这是 1 st与 2 nd的图。

如您所见,GCC 有效地忽略了提示,无论是否给出提示,都会生成性能相同的代码。另一方面,Clang 显然注意到了这个提示。如果命中率下降(即提示错误),代码将受到惩罚,但对于高命中率(即提示很好),代码的性能优于 GCC 生成的代码。
如果您想知道曲线的山形性质:那就是硬件分支预测器在起作用!它与编译器无关。另请注意,这种效果如何使 的效果完全相形见绌__builtin_expect
,这可能是不必过多担心它的原因。
相比之下,这里是 1 st与 3 rd的图。

两种编译器生成的代码基本上执行相同。对于 GCC,这并没有说太多,但就 Clang 而言,__builtin_expect
当包装在一个函数中时似乎没有考虑到,这使得它对 GCC 的所有命中率都松散了。
因此,总而言之,不要将函数用作包装器。如果宏编写正确,则没有危险。(除了污染名称空间。)__builtin_expect
已经表现得像一个函数(至少就其参数的评估而言)。将函数调用包装在宏中对其参数的评估没有令人惊讶的影响。
我意识到这不是你的问题,所以我会保持简短,但总的来说,我更喜欢收集实际的分析数据而不是手动猜测可能的分支。数据会更准确,GCC会更加关注。