【问题标题】:Effectiveness of GCC optmization on bit operationsGCC 优化对位操作的有效性
【发布时间】:2010-01-11 02:22:08
【问题描述】:

以下是在 x86-64 上的 C 中设置单个位的两种方法:

inline void SetBitC(long *array, int bit) {
   //Pure C version
   *array |= 1<<bit;
}

inline void SetBitASM(long *array, int bit) {
   // Using inline x86 assembly
   asm("bts %1,%0" : "+r" (*array) : "g" (bit));
}

使用带有 -O3 -march=core2 选项的 GCC 4.3,当使用常量 bit 时,C 版本需要大约 90% 的时间。 (两个版本编译成完全相同的汇编代码,只是 C 版本使用or [1&lt;&lt;num],%rax 指令而不是bts [num],%rax 指令)

当与变量 bit 一起使用时,C 版本的性能更好,但仍然比内联汇编慢很多。

重置、切换和检查位具有相似的结果。

为什么 GCC 对这种常见操作的优化如此糟糕?我在 C 版本上做错了吗?

编辑:抱歉让您久等了,这是我用来进行基准测试的代码。它实际上是从一个简单的编程问题开始的......

int main() {
    // Get the sum of all integers from 1 to 2^28 with bit 11 always set
    unsigned long i,j,c=0;
    for (i=1; i<(1<<28); i++) {
        j = i;
        SetBit(&j, 10);
        c += j;
    }
    printf("Result: %lu\n", c);
    return 0;
}

gcc -O3 -march=core2 -pg test.c
./a.out
gprof
with ASM: 101.12      0.08     0.08                             main
with C:   101.12      0.16     0.16                             main

time ./a.out 也给出了类似的结果。

【问题讨论】:

  • 真的是“普通操作”吗?我看到使用位操作的最常见情况是人们认为他们会通过将一堆标志打包成一个字节来节省内存的过早优化。
  • 嗯...好点。尽管如此,这是一个非常简单的操作,并且在硬件驱动程序中很常见。无论如何,关键是性能下降了 90%。
  • 在驱动程序编程中,我很确定“左移 1 来确定我们的掩码”成语使用得不多。相反,您 or 带有预定义的标志。
  • 你是对的。当位为常数时,编译器会优化移位。真的很奇怪,它仍然慢了 90%。
  • 我对“慢 90%”的事情感到惊讶。编译器可能期望orbts 占用更少的时钟——这通常是正确的。

标签: c optimization assembly x86


【解决方案1】:

为什么 GCC 对这种常见操作的优化如此糟糕?

前奏:自 1980 年代后期以来,对编译器优化的关注已经从关注单个操作的微基准转移到关注人们关心速度的应用程序的macrobenchmarks。如今,大多数编译器编写者都专注于宏基准测试,而开发良好的基准测试套件是一件被认真对待的事情。

答案:gcc 上没有人使用基准测试,其中orbts 之间的差异对实际程序的执行时间很重要。如果能制作出这样的程序,说不定能引起gcc-land人的注意。

我是不是用 C 版本做错了什么?

不,这是非常好的标准 C。事实上,非常易读和惯用。

【讨论】:

    【解决方案2】:

    你能发布你用来做计时的代码吗?这种操作很难准确计时。

    理论上,这两个代码序列应该同样快,所以最可能的解释(在我看来)是某些原因导致您的时序代码给出虚假结果。

    【讨论】:

    • 是的。在我知道的所有 x86 CPU 上,像 OR 这样的基本 ALU 操作至少与像 BTS 这样的“奇异”操作一样快。一种可能性是计时循环中的增量+测试使用与OR 相同的CPU 执行单元,从而导致争用,而BTS 使用不同的单元。
    • 在最近的 x86 硬件上,or 可以分派到多个执行单元上,所以那里不应该有任何争用。
    【解决方案3】:

    对于这样的代码:

    #include <stdio.h>
    #include <time.h>
    
    int main() {
      volatile long long i = 0;
      time_t start = time (NULL);
      for (long long n = 0; n < (1LL << 32); n++) {
        i |= 1 << 10;
      }
      time_t end = time (NULL);
      printf("C took %ds\n", (int)(end - start));
      start = time (NULL);
      for (long long n = 0; n < (1LL << 32); n++) {
        __asm__ ("bts %[bit], %[i]"
                      : [i] "=r"(i)
                      : "[i]"(i), [bit] "i" (10));
      }
      end = time (NULL);
      printf("ASM took %ds\n", (int)(end - start));
    }
    

    结果是:

    C took 12s
    ASM took 10s
    

    我的标志是 (-std=gnu99 -O2 -march=core2)。没有挥发物,循环被优化了。 gcc 4.4.2.

    没有区别:

    __asm__ ("bts %[bit], %[i]"
                  : [i] "+m"(i)
                  : [bit] "r" (10));
    

    所以答案可能是 - 没人在乎。在 microbenchmark 中,唯一的区别是这两种方法之间的区别,但在现实生活中,我相信这样的代码不会占用太多 CPU。

    另外对于这样的代码:

    #include <stdio.h>
    #include <time.h>
    
    int main() {
      volatile long long i = 0;
      time_t start = time (NULL);
      for (long long n = 0; n < (1L << 32); n++) {
        i |= 1 << (n % 32);
      }
      time_t end = time (NULL);
      printf("C took %ds\n", (int)(end - start));
      start = time (NULL);
      for (long long n = 0; n < (1L << 32); n++) {
        __asm__ ("bts %[bit], %[i]"
                      : [i] "+m"(i)
                      : [bit] "r" (n % 32));
      }
      end = time (NULL);
      printf("ASM took %ds\n", (int)(end - start));
    }
    

    结果是:

    C took 9s
    ASM took 10s
    

    两个结果都“稳定”。测试 CPU 'Intel(R) Core(TM)2 Duo CPU T9600 @ 2.80GHz'。

    【讨论】:

    • long long 后缀是LL,而不是L
    • @LưuVĩnhPhúc 好点。已修复,但不会影响结果,因为 Linux 是 LP64 平台(可能会影响 Windows 或其他 LLP 平台)。
    • 看看你的第二个测试,这似乎不是一个“公平”的比较。您正在强制 i 位于内存中而不是寄存器中,而 C 代码可能不会这样做。 __asm__ ("bts %[bit], %[i]" : [i] "+r"(i) : [bit] "r" (n % 32)); 怎么样?
    • @DavidWohlferd 我也在 C 中通过使用 volatile 关键字强制它。您的代码只会生成额外的 mov 指令(由于 uops 优化,这可能不会影响任何事情)。
    • @MaciejPiechotka - 然而,当检查输出 (gcc 6.1 -O3 -m64) 时,这正是 C 代码所做的 (orq %r8, %rax ; movq %rax, 40(%rsp))。虽然很难说如此微小的性能变化,但“r”似乎确实快了一点(毫无疑问是由于你提到的 uops 优化)。但是(更重要的是)如果目标是比较 orbts 的性能,确保两段代码使用相同的形式似乎很重要。
    【解决方案4】:

    这是在资源受限的嵌入式系统上非常常见的操作。 10 Cycles vs 5 Cycles 在此类系统上是一个令人讨厌的性能损失。很多情况下,想要访问 IO 端口或使用 16 位或 32 位寄存器作为布尔位标志来节省内存。

    事实上,if(bit_flags&amp; 1&lt;&lt;12) 比等效的程序集更具可读性[和使用库实现时的可移植性]。 IO_PINS|= 1&lt;&lt;5; 也是如此,不幸的是这些速度要慢很多倍,所以尴尬的 asm 宏仍然存在。

    在许多方面,嵌入式应用程序和用户空间应用程序的目标是相反的。外部通信(对用户界面或机器界面)的响应性并不重要,而确保控制回路(相当于微基准)在最短的时间内完成是绝对关键的,并且可以制造或破坏选定的处理器或控制战略。

    显然,如果一个人能够负担得起数 GHz 的 cpu 以及支持它所需的所有相关外围设备、芯片组等,则根本不需要担心低级优化。实时控制系统中慢 1000 倍的微控制器意味着节省时钟周期 1000 倍更重要。

    【讨论】:

      【解决方案5】:

      我认为您对优化器的要求很高。

      您可以通过执行 `register long z = 1L

      但是,我假设多 90% 的时间,您的意思是 C 版本需要 10 个周期,而 asm 版本需要 5 个周期,对吗? -O2 或 -O1 的性能比较如何?

      【讨论】:

      • "register" 将被编译器忽略。
      • @Laurynas Biveinis:编译器可能会或可能不会忽略“注册”,毕竟这是一个提示
      • 这个想法是,做long |= register long 可能会鼓励编译器优化:)
      • @Hasturkun:如果不是所有当前编译器肯定会忽略它,那么大多数(GCC、MSVC、...)都会忽略它。
      猜你喜欢
      • 2023-03-30
      • 1970-01-01
      • 2017-09-21
      • 2018-07-23
      • 1970-01-01
      • 2011-05-14
      • 2015-11-23
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多