SIMD (Single Instruction, Multiple Data) ,本质上就是一条指令同时处理多个数据,旨在实现数据级并行 (Data Level Parallelism)。

一个简单例子是向量加法。传统方式是用循环来对向量中每一对分量求和:

for (int i = 0; i < NUM_ELEMS; i++) {
    sum[i] = a[i] + b[i];
}

从算法层面讲,这已经是最优解了。但当考虑到更底层的原理时,还有很大的优化空间。

仔细分析循环,可以发现对于每个分量,都需要做以下工作:

  1. 迭代开销(计数器增加,条件跳转等)
  2. a中的分量从内存(缓存)中运到寄存器
  3. b中的分量从内存(缓存)中运到寄存器
  4. 用运算单元执行加法
  5. 将结果从寄存器中运回内存

优化程序性能,无非就是从这几个点下手。

对于第一点,向量有多少分量,for循环中的比较和自增就要做多少次。但如果把数据分成几组,每组作为一个整体运算,迭代开销就会成倍的减少,这就是循环展开(Loop Unrolling):

int i = 0;

for (; i <= NUM_ELEMS - 4; i += 4) {
    sum[i]     = a[i]     + b[i];
    sum[i + 1] = a[i + 1] + b[i + 1];
    sum[i + 2] = a[i + 2] + b[i + 2];
    sum[i + 3] = a[i + 3] + b[i + 3];
}

for (; i < NUM_ELEMS; i++) {
    sum[i] = a[i] + b[i];
}

循环展开前,程序需要进行NUM_ELEMS次迭代;循环展开后,迭代次数减少到了大约NUM_ELEMS / 4的水平。

循环展开减少了迭代开销和分支预测压力,但每次迭代还是做了四次运算,ALU 在每一时刻只能处理一对数字。观察循环体,每次运算都是取两个数据做加法,而且被分成一组的数在内存中都是连续的。如果有一个大寄存器,一次性可以存 $N$ 个数,从内存取数据操作的次数理论上就会减少到原来的 $1/N$。更进一步,如果能在这个大寄存器里做加法,运算单元的执行次数理论上也会减少到原来的 $1/N$。

这就是 SIMD 的基本思路。其中的大寄存器,叫做向量寄存器

过去几十年,Intel 的处理器在市场占主导地位,它在 SIMD 指令集领域做的工作如今依然是最被广泛应用的。

Intel 在 1999 年随 Pentium III 处理器发布了 SSE (Streaming SIMD Extensions) 指令集,引入了 8 个 128 位 XMM 寄存器;在后来的 x86-64 环境下,XMM 寄存器扩展为%xmm0-%xmm15 共 16 个。一个 128 位的寄存器可以存 4 个 32 位数据。

AVX (Advanced Vector Extensions) 在2011年随 Sandy Bridge 架构发布,向量寄存器扩展到 256 位 (%ymm),但主要支持浮点数运算。

2013年,AVX2 随 Haswell 架构发布,将 256 位支持扩展到了整数运算。

而 2017 年前后进入主流产品视野的 AVX-512 将向量寄存器扩展到了 512 位 (%zmm)。不过 AVX-512 的部署一直比较复杂:Intel 此前在部分 Xeon 和 Core X 系列上提供过支持,但在 Alder Lake 这一代消费级大小核桌面平台上基本移除了官方支持;AMD 也在后续 Zen 4 消费级处理器上提供了 AVX-512。

回过头看我们的向量加法,假设int是32位的,那么256位的%ymm寄存器可以一次性装 8 个int值,SIMD 版本现在就可以这样写:

int i = 0;

for (; i < NUM_ELEMS / 8 * 8; i += 8) {
    // 把 a[i : i + 7] 一次性装进 %ymm0
    // 把 b[i : i + 7] 一次性装进 %ymm1
    // 计算 %ymm0 和 %ymm1 的和,结果存入 %ymm2
    // 将结果一次性放进 sum[i : i + 7]
}

for (i = NUM_ELEMS / 8 * 8; i < NUM_ELEMS; i++) {
    sum[i] = a[i] + b[i];
}

其中NUM_ELEMS / 8 * 8是不大于NUM_ELEMS的最大可以被 8 整除的数。

AVX 提供了专门的汇编指令来完成这些工作。“一次性装进寄存“对应vmovdqu指令,其中v是 AVX 指令的前缀,movdqu表示 Move Double Quadword Unaligned,它可以直接在任意内存地址和%ymm寄存器之间移动 256 位的数据。既然有 “Unaligned” 版本,自然也有 “Aligned” 版本:vmovdqa,它要求内存必须对齐,但会带来微小的性能提升。

实际上,Double Quadword 指的是 128 位(SSE 时代的命名),AVX 扩展到了 256 位,但沿用了 Double Quadword 的名字

假设:

  • a的地址在%rsi
  • i的地址在%rax

那么"把 a[i : i + 7] 一次性装进 %ymm0"用汇编表示就是:

vmovdqu (%rsi, %rax, 4), %ymm0

(%rsi, %rax, 4)是经典的 x86 寻址方式,它计算a + i * 4,即a[i]的地址。

假设:

  • b的地址在%rdx

那么"把 b[i : i + 7] 一次性装进 %ymm1" 用汇编表示就是:

vmovdqu (%rdx, %rax, 4), %ymm1

求和需要用到另一个 SIMD 指令:vpaddd,其中p表示Packed,add表示加法,d表示 Doubleword,即32位。它可以对 8 个 32 位整数通道并行做加法:

vpaddd %ymm0, %ymm1, %ymm2

我们在 Linux 上用一个汇编程序的例子来仔细观察指令的行为:

# simd_int.s

.section .data
    .align 32   # 显式对齐
    array_a: .long 10, 20, 30, 40, 50, 60, 70, 80  # .long: 32 位整数,对应 C 中的 int

    .align 32
    array_b: .long 1, 2, 3, 4, 5, 6, 7, 8

    .align 32
    result:  .fill 8, 4, 0  # 8 个 4 字节空间,初始化为 0

.section .text
    .global _start

_start:
    # 1. 加载 8 个整数到 %ymm 寄存器
    vmovdqu array_a(%rip), %ymm0  # 非对齐版本依然可以操作对齐数据,但会损失微小性能
    vmovdqu array_b(%rip), %ymm1

    # 2. 执行整数 SIMD 加法
    vpaddd %ymm0, %ymm1, %ymm2   # ymm2 = ymm0 + ymm1, 结果为 11, 22, 33, 44, 55, 66, 77, 88  

    # 3. 将 8 个结果存回内存
    vmovdqa %ymm2, result(%rip)  # 对齐版本的 mov

这个程序定义了两个储存 8 个 32 位 int 整数的数组:array_aarray_b,把他们加载到ymm0ymm1,求和结果放入ymm2,再将结果传回内存。

汇编 + 链接 + gdb:

as -g -o simd_int.o simd_int.s
ld -o simd_int simd_int.o
gdb ./simd_int

gdb1

在 gdb 中只有四条指令:

vmovdqu 0xff8(%rip), %ymm0
vmovdqu 0x1010(%rip), %ymm1
vpaddd %ymm0, %ymm1, %ymm2
vmovdqa %ymm2, 0x1024(%rip)

0xff8(%rip)是汇编器 as 计算的 array_a 的地址,0x1010(%rip)则是 array_b 的地址,而0x1024(%rip)是结果 result 的地址。先执行第一条指令,看看vmovdqu是如何一次性搬运 8 个整数到%ymm0的:

(gdb) b _start
Breakpoint 1 at 0x401000: file simd_int.s, line 12.
(gdb) r
Breakpoint 1, _start () at simd_int.s:12
(gdb) si  # 执行 vmovdqu array_a(%rip), %ymm0

此时第一条指令执行完毕,array_a已被存入%ymm0,我们用info register ymm0来查看%ymm0的数据:

(gdb) info register ymm0
ymm0           {v16_bfloat16 = {0xa, 0x0, 0x14, 0x0, 0x1e, 0x0, 0x28, 0x0, 0x32, 0x0, 0x3c, 0x0, 0x46, 0x0, 0x50, 0x0}, v16_half = {0xa, 0x0, 0x14, 0x0, 0x1e, 0x0, 0x28, 0x0, 0x32, 0x0, 0x3c, 0x0, 0x46, 0x0, 0x50, 0x0}, v8_float = {0xa, 0x14, 0x1e, 0x28, 0x32, 0x3c, 0x46, 0x50}, v4_double = {0x140000000a, 0x280000001e, 0x3c00000032, 0x5000000046}, v32_int8 = {0xa, 0x0, 0x0, 0x0, 0x14, 0x0, 0x0, 0x0, 0x1e, 0x0, 0x0, 0x0, 0x28, 0x0, 0x0, 0x0, 0x32, 0x0, 0x0, 0x0, 0x3c, 0x0, 0x0, 0x0, 0x46, 0x0, 0x0, 0x0, 0x50, 0x0, 0x0, 0x0}, v16_int16 = {0xa, 0x0, 0x14, 0x0, 0x1e, 0x0, 0x28, 0x0, 0x32, 0x0, 0x3c, 0x0, 0x46, 0x0, 0x50, 0x0}, v8_int32 = {0xa, 0x14, 0x1e, 0x28, 0x32, 0x3c, 0x46, 0x50}, v4_int64 = {0x140000000a, 0x280000001e, 0x3c00000032, 0x5000000046}, v2_int128 = {0x280000001e000000140000000a, 0x50000000460000003c00000032}}

对于%ymm0硬件来说,它只是一个储存 256 位数据的寄存器,但对于 gdb 来说,这 256 位有非常多不同可能的表示,他可能是 4 个 64位的 double ,可能是 8 个 32 位的 int ,也可能是 16 个 16 位的 short 。所以 gdb 把他定义成一个 union,将这 256 位数据按照所有可能的数据类型都翻译了一遍。我们需要的是 8 个32 位 int 的解读,只需关注这一段:

v8_int32 = {0xa, 0x14, 0x1e, 0x28, 0x32, 0x3c, 0x46, 0x50}

他们就是 10,20,30… 的十六进制表示。由于 ymm0 本质上是一个 union,所以更好的方式是用p直接提取我们需要的解释:

(gdb) p $ymm0.v8_int32
$1 = {10, 20, 30, 40, 50, 60, 70, 80}

现在array_a已经加载进寄存器了,接下来是array_b

(gdb) si  # 执行 vmovdqu array_b(%rip), %ymm1
(gdb) p $ymm1.v8_int32
$2 = {1, 2, 3, 4, 5, 6, 7, 8}

两个寄存器已经准备就绪,执行vpaddd

(gdb) si  # 执行 vpaddd %ymm0, %ymm1, %ymm2
(gdb) p $ymm2.v8_int32
$3 = {11, 22, 33, 44, 55, 66, 77, 88}

可以看到ymm2已经成功得到了结果。

最后一步:把结果存入内存

(gdb) si  # 执行 vmovdqa %ymm2, result(%rip)

回顾result的地址是0x1024(%rip)。我们直接访问这段内存,看看数据有没有被成功存入:

(gdb) x/8 0x1024 + $rip
0x402040:  11  22  33  44
0x402050:  55  66  77  88

gdb2

成功的在内存中读取到了正确答案。

但是在生产环境中很少直接写汇编程序。这就引出了一个经典问题:编译器会不会帮我们向量化?

以一开始的例子为例:

// add.c

#define NUM_ELEMS ((1 << 16) + 10)

void add(int *sum, int *a, int *b) {
    for (int i = 0; i < NUM_ELEMS; i++) {
        sum[i] = a[i] + b[i];
    }
}

用 gcc 开启 O3 优化:

gcc -S -O3 add.c

循环内的代码是这样的:

.L3:
  movdqu  (%rsi,%rax), %xmm0
  movdqu  (%rdx,%rax), %xmm2
  paddd  %xmm2, %xmm0
  movups  %xmm0, (%rdi,%rax)
  addq  $16, %rax
  cmpq  $262176, %rax
  jne  .L3
  movq  262176(%rsi), %xmm0
  movq  262176(%rdx), %xmm1
  paddd  %xmm1, %xmm0
  movq  %xmm0, 262176(%rdi)
  ret

gcc 确实帮我们实现了向量化,但使用的是 SSE 的%xmm,而非宽度更大的%ymm。这是因为 gcc 需要保证所有硬件的兼容性,目前并非所有 x86 CPU 都支持 AVX2。为了让 gcc 生成 AVX2 的向量化汇编,我们需要用-mavx2-march=native显式指定架构:

gcc -S -mavx2 -O3 add.c
.L3:
  vmovdqu  (%rdx,%rax), %ymm0
  vpaddd  (%rsi,%rax), %ymm0, %ymm0
  vmovdqu  %ymm0, (%rdi,%rax)
  addq  $32, %rax
  cmpq  $262176, %rax
  jne  .L3
  vmovq  262176(%rsi), %xmm0
  vmovq  262176(%rdx), %xmm1
  vpaddd  %xmm1, %xmm0, %xmm0
  vmovq  %xmm0, 262176(%rdi)
  vzeroupper
  ret

熟悉的 AVX 指令又回来了。

编译器的优化已经十分强大,能应付 90% 的情况。但为了不出错,编译器采取的策略一直比较保守,有可能放弃本有潜力优化的空间。比如别名分析,当它不能确定两个指针是否指向同一块内存时,会选择安全的标量代码。这时,就需要我们手写 SIMD 指令。一种方法是内联汇编,直接操作汇编语言的效率自然是最高的,但是需要打破抽象层,而且在不同 ISA 的机器上无法移植。为了解决这个问题,Intel 在推出第一代 SIMD 指令集 MMX (Multi-Media eXtension) 时,和微软合作引入了 Intrinsic Functions

Intrinsic 提供了 SIMD 汇编的抽象,作为 C 语言的函数出现,但编译器会直接将其映射为对应的汇编指令。比如,%ymm寄存器代表的 256 位向量在 Intrinsic 中被抽象成了一个数据类型__m256i ,这是 AVX2 Intrinsic 的基石。vmovdqu被抽象成一个函数_mm256_loadu_si256,它接受一个内存地址,将其后 256 位的数据移动到%ymm寄存器,作为一个__m256i类型的值:

loadu_intr

vpaddd被抽象成函数_mm256_add_epi32它接受两个__m256i,返回它们的向量和:

add_intr

x86 所有的 Intrinsic 都可以在 Intel Intrinsic Guide 中找到。

那么,SIMD 究竟可以优化到什么程度?

考虑以下函数:

// Source: cs61c.org
// sum.c

#define NUM_ELEMS ((1 << 16) + 10)
#define OUTER_ITERATIONS (1 << 14)

long long int sum(int vals[NUM_ELEMS]) {
    clock_t start = clock();

    long long int sum = 0;
    for(unsigned int w = 0; w < OUTER_ITERATIONS; w++) {
        for(unsigned int i = 0; i < NUM_ELEMS; i++) {
            if(vals[i] >= 128) {
                sum += vals[i];
            }
        }
    }
    clock_t end = clock();
    printf("Time taken: %Lf s\n", (long double)(end - start) / CLOCKS_PER_SEC);
    return sum;
}

// main function

这个函数对一个长度为NUM_ELEMS的数组中所有大于等于 128 的数求和,总共求和OUTER_ITERATIONS次。

用 O0 关闭优化,看看在我这颗 12700KF 上需要多长时间执行:

gcc -O0 -mavx2 sum.c -o sum
./sum
Starting generate array
Starting naive sum
Time taken: 3.990946 s
The answer is 103161741312

用了近 4 秒。

我们用 AVX2 的 Intrinsic 对函数进行优化:

// sum_simd.c

#define NUM_ELEMS ((1 << 16) + 10)
#define OUTER_ITERATIONS (1 << 14)

long long int sum_simd(int vals[NUM_ELEMS]) {
    clock_t start = clock();
  
    // 将 256 位阈值向量切成 8 份,每份填入整数 127,用于代替 if
    // [127, 127, 127, 127, 127, 127, 127, 127]
    __m256i threshold = _mm256_set1_epi32(127);
    long long int final_sum = 0;

    for (unsigned int w = 0; w < OUTER_ITERATIONS; w++) {
        // 初始化局部累加向量
        __m256i sum = _mm256_setzero_si256();

        // 向量化主循环:步长为 8(因为一个寄存器装 8 个 int)
        // (NUM_ELEMS & ~7) 的作用是将数组长度向下取 8 的倍数,等于 NUM_ELEMS / 8 * 8
        for (unsigned int i = 0; i < (NUM_ELEMS & ~7); i += 8) {
            // 一次性搬运 8 个整数到 vals_vec 寄存器
            __m256i vals_vec = _mm256_loadu_si256((__m256i *) (vals + i));
            
            // 比较生成掩码:执行 vals_vec > 127
            // 如果条件成立,对应位置变为全 1 (0xFFFFFFFF);否则全 0 (0x00000000)
            __m256i mask = _mm256_cmpgt_epi32(vals_vec, threshold);
          
            // vals_vec = vals_vec & mask
            // 实现"只有大于 127 的数才参与后续加法"
            vals_vec = _mm256_and_si256(vals_vec, mask);
          
            // sum = sum + vals_vec
            sum = _mm256_add_epi32(sum, vals_vec);
        }

        // 将寄存器里的 8 个部分和导出到普通数组 temp 中
        int temp[8];
        _mm256_storeu_si256((__m256i *) temp, sum);
        
        // 将 8 个部分和加到最终结果 final_sum
        for (unsigned int i = 0; i < 8; i++) {
            final_sum += temp[i];
        }

        // 处理数组末尾不足 8 个的部分,即 NUM_ELEMS & ~7 截断后剩下的元素
        for (unsigned int i = NUM_ELEMS & ~7; i < NUM_ELEMS; i++) {
            if (vals[i] >= 128) {
                final_sum += vals[i];
            }
        }

    }

    clock_t end = clock();
    printf("Time taken: %Lf s\n", (long double)(end - start) / CLOCKS_PER_SEC);
    return final_sum;
}

// main function
gcc -O0 -mavx2 sum_simd.c -o sum_simd
./sum_simd
Starting generate array
Starting simd sum
Time taken: 0.393570 s
The answer is 103161741312

答案和 sum 一样,因为程序没有显式调用 srand() 设种子,所以在同一实现下通常会得到相同的伪随机序列,这也侧面印证了我们的计算是正确的。而时间用了近 0.4 秒,是 sum 的十分之一,比 SIMD 带来的 8 倍效率还要快。额外的 1.5-2x 提升来自用位运算掩码替代条件分支,消除了分支预测失败的开销。

我们用perf stat详细的看看 sum 运行的情况:

perf stat ./sum
Starting generate array
Starting naive sum
Time taken: 3.930958 s
The answer is 103161741312

 Performance counter stats for './sum':

                 0      context-switches:u               #      0.0 cs/sec  cs_per_second
                 0      cpu-migrations:u                 #      0.0 migrations/sec  migrations_per_second
               126      page-faults:u                    #     32.0 faults/sec  page_faults_per_second
          3,938.66 msec task-clock:u                     #      1.0 CPUs  CPUs_utilized
       495,185,828      cpu_core/branch-misses/u         #     23.0 %  branch_miss_rate         (99.72%)
     2,151,442,727      cpu_core/branches/u              #    546.2 M/sec  branch_frequency     (99.72%)
    19,663,072,544      cpu_core/cpu-cycles/u            #      5.0 GHz  cycles_frequency       (99.72%)
    14,500,973,667      cpu_core/instructions/u          #      0.7 instructions  insn_per_cycle  (99.72%)
       334,563,943      cpu_atom/branch-misses/u         #     23.1 %  branch_miss_rate         (0.15%)
     1,376,773,280      cpu_atom/branches/u              #    349.6 M/sec  branch_frequency     (0.15%)
     8,923,766,423      cpu_atom/cpu-cycles/u            #      2.3 GHz  cycles_frequency       (0.15%)
     6,682,252,453      cpu_atom/instructions/u          #      1.0 instructions  insn_per_cycle  (0.15%)
             TopdownL1 (cpu_core)                        #     56.3 %  tma_bad_speculation
                                                         #     23.0 %  tma_frontend_bound       (99.72%)
                                                         #      8.2 %  tma_backend_bound
                                                         #     12.5 %  tma_retiring             (99.72%)
             TopdownL1 (cpu_atom)                        #      7.6 %  tma_backend_bound        (0.15%)
                                                         #     44.9 %  tma_frontend_bound       (0.15%)
                                                         #     13.2 %  tma_bad_speculation
                                                         #     34.4 %  tma_retiring             (0.15%)

       3.940752101 seconds time elapsed

       3.934609000 seconds user
       0.000980000 seconds sys

Branch miss 数达到了 334,563,943 次,分支预测失败率达到了 23%,P 核的 IPC 只有可怜的 0.7。Bad speculation 有 56.3%,CPU 超过一半的时间都在做无用功。

再来看看 sum_simd 的情况:

perf stat ./sum_simd
Starting generate array
Starting simd sum
Time taken: 0.380635 s
The answer is 103161741312

 Performance counter stats for './sum_simd':

                 0      context-switches:u               #      0.0 cs/sec  cs_per_second
                 0      cpu-migrations:u                 #      0.0 migrations/sec  migrations_per_second
               125      page-faults:u                    #    326.0 faults/sec  page_faults_per_second
            383.43 msec task-clock:u                     #      1.0 CPUs  CPUs_utilized
            25,382      cpu_core/branch-misses/u         #      0.0 %  branch_miss_rate
       135,624,062      cpu_core/branches/u              #    353.7 M/sec  branch_frequency
     1,889,389,397      cpu_core/cpu-cycles/u            #      4.9 GHz  cycles_frequency
     4,838,841,161      cpu_core/instructions/u          #      2.6 instructions  insn_per_cycle
             TopdownL1 (cpu_core)                        #      0.0 %  tma_bad_speculation
                                                         #      2.4 %  tma_frontend_bound
                                                         #     53.7 %  tma_backend_bound
                                                         #     43.9 %  tma_retiring

       0.384798731 seconds time elapsed

       0.383394000 seconds user
       0.000975000 seconds sys

由于循环中没有使用 if 指令,分支预测失败率为可以忽略不计的 0%,IPC 现在提升到了 2.6,指令数也从 145 亿减少到了 48 亿,近三分之一。SIMD + 比较掩码给了我们 10 倍的性能提升。

这还远远不是优化的极限。作为能解决 90% 优化问题的方案,我们看看编译器的优化效率如何:

gcc -O3 -mavx2 sum.c -o sum_o3
./sum_o3
Starting generate array
Starting naive sum
Time taken: 0.161733 s
The answer is 103161741312

O3 优化的 naive 版本花费 0.16 秒,比 O0 的 SIMD 版本还要快两倍以上。为什么会这样?

揭示答案的自然还是汇编代码:

objdump -d sum_simd | less

以下是部分内侧循环的指令:

vmovdqu (%rax),%ymm0
vmovdqa %ymm0,-0x170(%rbp)
vmovdqa -0x170(%rbp),%ymm0
vmovdqa %ymm0,-0xb0(%rbp)
vmovdqa -0x190(%rbp),%ymm0
vmovdqa %ymm0,-0x90(%rbp)
vmovdqa -0xb0(%rbp),%ymm0
vmovdqa -0x90(%rbp),%ymm1
vpcmpgtd %ymm1,%ymm0,%ymm0
vmovdqa %ymm0,-0x150(%rbp)
vmovdqa -0x170(%rbp),%ymm0
...

在 O0 的条件下,编译器不会积极做寄存器分配和指令级优化,虽然 Intrinsic 仍会展开为对应指令,但中间结果常被频繁溢出到栈上再读回寄存器,于是出现了大量多余的vmovdqa。这才是效率低下最根本的原因。而 O3 版本,即使是最 naive 的方法,也不会出现此类问题:

objdump -d sum_o3 | less
vpbroadcastd (%rax),%ymm1    # broadcast 到所有通道
add    $0x4,%rax   # 每次加 4,说明一次只处理一个 32 位元素
vpcmpgtd %ymm6,%ymm1,%ymm0
vpmovsxdq %xmm1,%ymm3
vextracti128 $0x1,%ymm1,%xmm1
vpmovsxdq %xmm1,%ymm1
vpmovsxdq %xmm0,%ymm2
vextracti128 $0x1,%ymm0,%xmm0
vpmovsxdq %xmm0,%ymm0
vpand  %ymm3,%ymm2,%ymm2
vpand  %ymm1,%ymm0,%ymm0
vpaddq %ymm2,%ymm4,%ymm4
vpaddq %ymm0,%ymm5,%ymm5
cmp    %rax,%rdx
jne    1380 <sum+0x80>
add    $0x1,%ecx
cmp    $0x800,%ecx
jne    133f <sum+0x3f>
...

没有任何存数据到内存的无用操作,所有计算均在寄存器中进行,最终使我们的效率提升了两倍。

但就算是 O3,这里也做了一个保守的决定。回顾vals的数据类型是 32 位的int,而最终和的数据类型是 64 位的long long int,为了防止溢出,gcc 实际上每次迭代只处理一个元素,用 vpmovsxdq 将 32 位扩展到 64 位后再用 vpaddq 累加,而vpbroadcastd 将该元素广播到各通道以配合后续的 SIMD 扩展操作。如果不考虑溢出的情况,O3 的版本是次优的,编译器的局限性就体现出来了。

为什么有编译器优化,还需要手写 Intrinsic 的意义就在这里。对手写 Intrinsic 的版本进行 O3 优化:

gcc -O3 -mavx2 sum_simd.c -o sum_simd_o3
./sum_simd_o3
Starting generate array
Starting simd sum
Time taken: 0.065357 s
The answer is 103161741312

O3 版本的 sum_simd 花费 0.065 秒完成所有计算,比 O3 版本的 sum 快了近三倍。观察 sum_simd_o3 的汇编,没有因害怕溢出而进行的保守操作,只有纯粹的 SIMD:

objdump -d sum_simd_o3 | less
vmovdqu (%rax),%ymm0
add    $0x20,%rax
vpcmpgtd %ymm3,%ymm0,%ymm2
vpand  %ymm2,%ymm0,%ymm0
vpaddd %ymm1,%ymm0,%ymm0
vmovdqa %ymm0,%ymm1
cmp    %rdx,%rax
jne    15a0 <sum_simd+0x60>
...

最后看看 sum_simd_o3 的性能如何:

perf stat ./sum_simd_o3
Starting generate array
Starting simd sum
Time taken: 0.046438 s
The answer is 103161741312

 Performance counter stats for './sum_simd_o3':

                 0      context-switches:u               #      0.0 cs/sec  cs_per_second
                 0      cpu-migrations:u                 #      0.0 migrations/sec  migrations_per_second
               124      page-faults:u                    #   2527.5 faults/sec  page_faults_per_second
             49.06 msec task-clock:u                     #      0.9 CPUs  CPUs_utilized
            25,161      cpu_core/branch-misses/u         #      0.0 %  branch_miss_rate         (99.91%)
       135,448,448      cpu_core/branches/u              #   2760.9 M/sec  branch_frequency     (99.91%)
       199,548,588      cpu_core/cpu-cycles/u            #      4.1 GHz  cycles_frequency       (99.91%)
     1,079,758,372      cpu_core/instructions/u          #      5.4 instructions  insn_per_cycle  (99.91%)
                 0      cpu_atom/branch-misses/u         #      nan %  branch_miss_rate         (0.09%)
                 0      cpu_atom/branches/u              #      0.0 M/sec  branch_frequency     (0.09%)
                 0      cpu_atom/cpu-cycles/u            #      0.0 GHz  cycles_frequency       (0.09%)
                 0      cpu_atom/instructions/u          #      nan instructions  insn_per_cycle  (0.09%)
             TopdownL1 (cpu_core)                        #      0.4 %  tma_bad_speculation
                                                         #      4.3 %  tma_frontend_bound       (99.91%)
                                                         #     16.4 %  tma_backend_bound
                                                         #     78.9 %  tma_retiring             (99.91%)

       0.050319023 seconds time elapsed

       0.048218000 seconds user
       0.001995000 seconds sys

IPC 达到了 5.4,是个非常不错的数字,而 Retiring 达到了 78.9%,绝大部分时钟周期都在做有用的运算。指令数也减少到了十亿左右,是 O0 版本的将近四分之一。如果想更进一步榨干硬件的性能,可以对 Intrinsic 函数也应用循环展开,但对于一般的优化工作,学会串行使用 Intrinsic 已经足够了。

以下是基于同一台机器(12700KF)对前文分析的总结:

版本编译选项手写 SIMDTime taken (s)相对 sum -O0 加速比
sum-O0 -mavx23.9909461.0x
sum_simd-O0 -mavx2是(AVX2 Intrinsic)0.39357010.1x
sum_o3-O3 -mavx20.16173324.7x
sum_simd_o3-O3 -mavx2是(AVX2 Intrinsic)0.06535761.1x

并行计算是个很有趣的领域,有点像超市买一送多的优惠券,一次性给我们带来好几倍的免费性能提升。但代价也是有的,比如要求数据对齐,或者功耗过高会导致降频,正是这个原因导致我 12700KF 上的 AVX-512 被砍了。在复杂的逻辑业务中,编译器的优化 is all you need,但是在图形处理和深度学习领域,就好好的享受多件折上折吧。