2

我正在研究一些定义的 C++ 代码

#define LIKELY(x)   (__builtin_expect((x), 1))

我想知道 - 为什么不是内联函数?即为什么不

template <typename T> inline T likely(T x) { return __builtin_expect((x), 1); }

(或者可能

inline int likely(int x) { return __builtin_expect((x), 1); }

因为 x 应该是某些条件检查的结果)

宏和函数应该做的基本一样吧?但后来我想知道:也许是因为__builtin_expect......当它在内联辅助函数中时,它的工作方式会有所不同吗?

4

2 回答 2

7

坚持使用久经考验且值得信赖的宏,即使我们都知道通常要避免使用宏。这些inline功能根本不起作用。或者——特别是如果你使用 GCC——__builtin_expect完全忘记并使用配置文件引导优化 (PGO) 来代替实际的分析数据。

非常特别的__builtin_expect是,它实际上并没有“做”任何事情,而只是提示编译器最有可能采用哪个分支。如果您在不是分支条件的上下文中使用内置函数,编译器将不得不将此信息与值一起传播。直觉上,我本以为会发生这种情况。有趣的是,GCCClang的文档对此并不是很明确。然而,我的实验表明 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_MAXmarginmargindo_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会更加关注。

于 2015-12-23T02:41:00.623 回答
1

不能保证编译器内联内联函数。大多数现代编译器inline仅将关键字视为提示。如果您强制使用__attribute__((always_inline))GCC(或__forceinlineMSVC)进行内联,则使用内联函数或宏都无关紧要(但甚至__forceinline 可能不起作用)。否则,该函数可能不会被内联。例如,GCC不会在优化关闭的情况下内联函数。在这种情况下,生成的代码会慢很多。为了安全起见,我会坚持使用宏。

于 2015-12-18T21:19:26.750 回答