【问题标题】:clang insanely fast bitfield operations in loop循环中疯狂的快速位域操作
【发布时间】:2021-09-28 10:50:07
【问题描述】:

我正在尝试一些位域操作并按照this post 中的信息对它们进行基准测试。我使用的代码基本相同,如下所示。

我已经编译了代码

❯ g++ bench.cpp -std=c++20 -march=native -O3 -o g++bench.out
❯ clang++ bench.cpp -std=c++20 -march=native -O3 -o clang++bench.out

结果:

❯ ./g++bench.out
operations on struct in memory
bitfields: 0.00443397
570425344
separate ints: 0.00320708
570425344
explicit and/or/shift: 0.0721971
570425344

operations on struct larger than memory
bitfields: 0.202714
570425344
separate ints: 0.127191
570425344
explicit and/or/shift: 0.102186
570425344

❯ ./clang++bench.out
operations on struct in memory
bitfields: 0.00304556
570425344
separate ints: 0.00291514
570425344
explicit and/or/shift: 0.00276303
570425344

operations on struct larger than memory
bitfields: 0.00350051
570425344
separate ints: 0.116294
570425344
explicit and/or/shift: 0.0909704
570425344

主要让我印象深刻的是,大向量中位域的 clang 代码比使用单独的整数或显式和/或/移位的 clang 版本快近 30 倍,比位域的 g++ 编译版本快 58 倍。

由于内存中结构的操作代码都在同一时间运行,我怀疑操作本身没有特殊优化,但 clang 正在做一些聪明的内存获取或循环展开。

谁能解释一下为什么这种情况下的 clang 位域代码如此之快(或者可能只是基准测试中的一个错误)?

我还想知道是否可以调整基准代码,以便 g++ 能够获得相同的加速。

#include <time.h>
#include <iostream>
#include <vector>

struct A
{
    void a(unsigned n) { a_ = n; }
    void b(unsigned n) { b_ = n; }
    void c(unsigned n) { c_ = n; }
    void d(unsigned n) { d_ = n; }
    unsigned a() { return a_; }
    unsigned b() { return b_; }
    unsigned c() { return c_; }
    unsigned d() { return d_; }
    unsigned a_:1,             
             b_:5,
             c_:2,
             d_:8;
};

struct B
{
    void a(unsigned n) { a_ = n; }
    void b(unsigned n) { b_ = n; }
    void c(unsigned n) { c_ = n; }
    void d(unsigned n) { d_ = n; }
    unsigned a() { return a_; }
    unsigned b() { return b_; }
    unsigned c() { return c_; }
    unsigned d() { return d_; }
    unsigned a_, b_, c_, d_;
};

struct C
{
    void a(unsigned n) { x_ &= ~0x01; x_ |= n; }
    void b(unsigned n) { x_ &= ~0x3E; x_ |= n << 1; }
    void c(unsigned n) { x_ &= ~0xC0; x_ |= n << 6; }
    void d(unsigned n) { x_ &= ~0xFF00; x_ |= n << 8; }
    unsigned a() const { return x_ & 0x01; }
    unsigned b() const { return (x_ & 0x3E) >> 1; }
    unsigned c() const { return (x_ & 0xC0) >> 6; }
    unsigned d() const { return (x_ & 0xFF00) >> 8; }
    unsigned x_;
};

struct Timer
{
    Timer() { get(&start_tp); }
    double elapsed() const {
        struct timespec end_tp;
        get(&end_tp);
        return (end_tp.tv_sec - start_tp.tv_sec) +
               (1E-9 * end_tp.tv_nsec - 1E-9 * start_tp.tv_nsec);
    }
  private:
    static void get(struct timespec* p_tp) {
        if (clock_gettime(CLOCK_REALTIME, p_tp) != 0)
        {
            std::cerr << "clock_gettime() error\n";
            exit(EXIT_FAILURE);
        }
    }
    struct timespec start_tp;
};

template <typename T>
unsigned f()
{
    int n = 0;
    Timer timer;
    T t;
    for (int i = 0; i < 1024*1024*32; ++i)
    {
        t.a(i & 0x01);
        t.b(i & 0x1F);
        t.c(i & 0x03);
        t.d(i & 0xFF);
        n += t.a() + t.b() + t.c() + t.d();
    }
    std::cout << timer.elapsed() << '\n';
    return n;
}

template <typename T>
unsigned g()
{
    int n = 0;
    Timer timer;
    std::vector<T> ts(1024 * 1024 * 16);
    for (size_t i = 0, idx = 0; i < 1024*1024*32; ++i)
    {
        T& t = ts[idx];
        t.a(i & 0x01);
        t.b(i & 0x1F);
        t.c(i & 0x03);
        t.d(i & 0xFF);
        n += t.a() + t.b() + t.c() + t.d();
        idx++;
        if (idx >= ts.size()) {
            idx = 0;
        }
    }
    std::cout << timer.elapsed() << '\n';
    return n;
}

int main()
{
    std::cout << "operations on struct in memory" << std::endl;
    std::cout << "bitfields: " << f<A>() << '\n';
    std::cout << "separate ints: " << f<B>() << '\n';
    std::cout << "explicit and/or/shift: " << f<C>() << '\n';
    std::cout << std::endl;

    std::cout << "operations on struct larger than memory" << std::endl;
    std::cout << "bitfields: " << g<A>() << '\n';
    std::cout << "separate ints: " << g<B>() << '\n';
    std::cout << "explicit and/or/shift: " << g<C>() << '\n';
    std::cout << std::endl;
}

【问题讨论】:

  • 不要为 C++ 问题标记 C。

标签: c++ g++ clang++ bit-fields


【解决方案1】:

好问题!

查看两个循环的Godbolt 输出,看起来clang 优化得更好。

  add     rcx, 1
  add     edx, 64
  add     r10d, 2
  add     r9d, 256

更新: 在 C++ 中,这就像拥有

   for (size_t i_field1=0, size_t i_field2=0,size_t i_field3=0, size_t i_field4=0, size_t i = 0, idx = 0;
      i < 1024*1024*32; 
      i_field1+=1, i_field2+=2, i_field3+=64, i_field4+256=,++i, ++idx)
    {
        T& t = ts[idx];
        i_field1&=1; 
        i_field2&=(31<<1); 
        i_field3&=(3<<6); 
        i_field4&=(255<<8);  
        t = i_field1+i_field2+i_field3+i_field4;
        n += t;
        if (idx >= ts.size()) {
            idx = 0;
        }
    }

它为每个位字段分配了一个计数器,并在每次循环中递增它们。这些位字段中的每一个都使用其位字段掩码每个循环“与”,这在 ASM 术语中是尽可能快的。 GCC 和 Clang 都使用了一种技巧,即位字段加起来为 16,因此它可以假装它是一个 16 位无符号数来读取/写入。 GCC 代码更直接地实现了代码,使用单个索引,每次迭代都会移动/屏蔽。

与使用更多寄存器的所有优化一样,并不是说 clang 代码更好,因为它使用更多空间那么简单,但我会说 +1 为 clang。

至于如何使 GCC 使用相同的技巧,您可以尝试使用 C++ 编写 clang 技巧,看看 GCC 是如何变化的。但是,这在一般情况下会有多大用处是值得怀疑的。

更新:
这意味着 clang 在优化方面比 GCC 更好,它改变了每个字段的增量,而不是增加一个为每个字段移动的计数器。 clang 编译器已经基本理解了代码的意图,并以不同的方式实现,得到相同的结果,这是编译器允许做的事情。

【讨论】:

  • 我的组装技能是不存在的,但我会认为这是“程序运行良好,但没有达到程序员的预期”。我更改了程序,因此它将从随机数数组中初始化位域中的值,而不是直接从循环计数器中初始化。这有意义吗?Clan 位域版本仍然比 g++ 位域版本快 2 倍,也比 clang int 版本快 3 倍
  • 啊,我将编辑我的答案以使其更清晰。感谢您的反馈
  • 感谢更新。这让我更清楚。我正在研究位域,因为我想建立一个神经网络,其中将有一整类(和大量)只需要 16 个“状态”的神经元。想法是将为此所需的 4 位打包到一个更大的结构中。所以速度对我来说很重要。当然输入不会来自简单的计数器。也在 opencl 中尝试这个,遗憾的是它不支持位域,所以我希望改进显式和/或/移位代码。将研究您的答案,看看我是否可以为此获得一些好主意。
  • 明白了,你的方法对我来说似乎不错,我在许多网络/游戏应用程序中都使用了位域,它们运行良好。将位数保持为 2 的幂将有助于保持代码速度。不过有一些问题需要小心。不同编译器的打包顺序可能不同,请注意大小为 1 的签名字段(有效值为 0/-1,而不是 0/1,如您所料)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-03-05
  • 2017-06-19
  • 1970-01-01
  • 2011-08-17
相关资源
最近更新 更多