跳转至

libc++ 的 uniform_int_distribution 性能问题

背景

前段时间,@lwpie 发现一段 C++ 代码在 macOS 下,分别用自带的 Clang 编译和用 Homebrew 的 GCC 编译,性能差距接近一个数量级,下面是运行时间:

  • GCC-13 Homebrew: 300
  • Apple Clang: 2170

代码如下:

#include <algorithm>
#include <array>
#include <chrono>
#include <iostream>
#include <memory>
#include <random>

constexpr size_t FILE_N = 1e8;
constexpr size_t DATA_R = (1 << 23);

int main() {
  for (size_t i = 0; i < 4; ++i) {
    auto start = std::chrono::high_resolution_clock::now();
    std::random_device rd;
    std::mt19937_64 gen(rd());
    std::uniform_int_distribution<> dis(0, DATA_R);
    std::shared_ptr<std::array<size_t, FILE_N>> data(
        new std::array<size_t, FILE_N>());
    std::generate(data->begin(), data->end(),
                  [&dis, &gen]() { return dis(gen); });
    auto end = std::chrono::high_resolution_clock::now();
    std::cout << std::chrono::duration_cast<std::chrono::milliseconds>(end -
                                                                       start)
                     .count()
              << std::endl;
    auto mean = std::accumulate(data->begin(), data->end(), 0.0) / FILE_N;
    std::cout << mean << std::endl;
  }
  return 0;
}

首先上结论:GCC-13 Homebrew 用的是 libstdc++,而 Apple Clang 用的是 libc++;libstdc++ 优化了 uniform_int_distribution 的实现,而 libc++ 采用的是朴素的实现,同时参数的选取正好触发了朴素实现的最坏情况,因此性能差距巨大。

探究

从现象上来看,看起来是 GCC 和 Clang 的性能差异很大,但由于这里涉及到了 STL 的实现,因此控制变量很重要:经过测试,发现 Clang + libstdc++ 性能好,GCC + libstdc++ 性能好,Clang + libc++ 性能差。

因此问题大概可以定位在 libc++ 上。那么,就去找 libc++ 的 uniform_int_distribution 实现:

// https://github.com/llvm/llvm-project/blob/9b2dfff57a382b757c358b43ee1df7591cb480ee/libcxx/include/__random/uniform_int_distribution.h#L233-L257
typename uniform_int_distribution<_IntType>::result_type
uniform_int_distribution<_IntType>::operator()(_URNG& __g, const param_type& __p)
_LIBCPP_DISABLE_UBSAN_UNSIGNED_INTEGER_CHECK
{
    static_assert(__libcpp_random_is_valid_urng<_URNG>::value, "");
    typedef __conditional_t<sizeof(result_type) <= sizeof(uint32_t), uint32_t, __make_unsigned_t<result_type> >
        _UIntType;
    const _UIntType __rp = _UIntType(__p.b()) - _UIntType(__p.a()) + _UIntType(1);
    if (__rp == 1)
        return __p.a();
    const size_t __dt = numeric_limits<_UIntType>::digits;
    typedef __independent_bits_engine<_URNG, _UIntType> _Eng;
    if (__rp == 0)
        return static_cast<result_type>(_Eng(__g, __dt)());
    size_t __w = __dt - std::__countl_zero(__rp) - 1;
    if ((__rp & (numeric_limits<_UIntType>::max() >> (__dt - __w))) != 0)
        ++__w;
    _Eng __e(__g, __w);
    _UIntType __u;
    do
    {
        __u = __e();
    } while (__u >= __rp);
    return static_cast<result_type>(__u + __p.a());
}

可以看到,它的实现思路是,为了生成 [a, b] 之间的均匀整随机数,它先找一个比 b-a 大的二的幂,然后生成随机数,模这个二的幂,接着采用拒绝采样:如果采样得到的值大于 b-a,那就重新采样。

但是前面测试的代码中,正好区间的大小就是二的幂,那么向上取整到二的幂次的时候,相当于每次采样有一半的概率需要重试。这样需要重新生成随机数的次数很多,而且分支预测错误率也很高。@Harry-Chen 用 perf 测试得到的数据:

  • branch-misses: 30% of all branches
  • Top-down: 44.6% Bad Speculation

说明分支预测确实成为了瓶颈。那么 libstdc++ 是怎么实现的,为什么它没有这个问题?经过搜索,发现了一篇博客:Doubling the speed of std::uniform_int_distribution in the GNU C++ library (libstdc++)

论文 Fast Random Integer Generation in an Interval 提出了新的 uniform_int_distribution 实现,比原来的实现得到了两倍的性能提升,并且合并到了 libstdc++ 的实现当中。

所以到这里,问题就比较清晰了:libstdc++ 实现了更好的算法,同时 libc++ 的算法遇到了最坏情况,二者合起来,就观测到了巨大的性能差距。

如果把代码里 DATA_R 改成 (1 << 23)-1,那么 libc++ 算法的采样失败概率就会降到最小,此时的性能情况是:

  • GCC-13 Homebrew: 253
  • Apple Clang: 490

可见这里就是大概两倍的性能差距,这个差距就来源于 libstdc++ 实现的更好的采样算法。

解决方法就是,等 libc++ 也实现更好的算法,或者在需要用 uniform_int_distribution 的时候,避免链接 libc++,或者自己实现更好的算法。

评论