低延迟系统开发#9

循环优化

循环变换有十几种,大部分 GCC 在 -O3 下自己会做。这篇按“每轮多余的事、迭代间依赖、访存走法”三类整理:编译器会做的(外提、强度削弱、旋转、展开、unswitching),挡住它的(浮点求和不能换顺序、指针重叠、调用没内联),它不会替你做的(多个累加器、分块、除法换乘倒数),累加器拆几个(K = 延迟 × 吞吐)、看不见的假依赖,以及怎么用 -fopt-info 确认。汇编都是 GCC 13 的真实输出。

约 18 分钟
本页目录

先看一个点积:

float dot(const float* v1, const float* v2, int n) {
    float sum = 0.0f;
    for (int i = 0; i < n; i++) sum += v1[i] * v2[i];
    return sum;
}

用 GCC 13 -O3 -fopt-info-vec 编译,报告写着 loop vectorized using 16 byte vectors。打开汇编看循环体:

.L4:
    movups  (%rcx,%rax), %xmm1      # 读 4 个 v1
    movups  (%rsi,%rax), %xmm3      # 读 4 个 v2
    addq    $16, %rax
    mulps   %xmm3, %xmm1            # 4 个乘法一次做完
    addss   %xmm1, %xmm0            # 下面 4 个加法一个等一个
    ...shufps / unpckhps 取出第 2、3、4 个积...
    addss   %xmm2, %xmm0
    addss   %xmm2, %xmm0
    addss   %xmm1, %xmm0
    cmpq    %rdi, %rax
    jne     .L4

乘法确实 4 个一起做了,加法还是 4 条 addss 排着队,每条都要等上一条的结果。报告说“向量化了”,循环的瓶颈却一点没动。

循环变换的名字有十几个(展开、剥离、交换、旋转、融合、分裂、分块……),大部分 GCC 在 -O2 / -O3 下会自己做。值得你操心的是两类事:一是有什么挡住了编译器,像上面这个浮点求和;二是编译器不会替你做的,比如改数据的走法、分块、改算法。下面按循环慢的原因分三类讲,最后说怎么确认编译器到底做了什么。文中的汇编和 -fopt-info 输出都是 GCC 13.3、x86-64 默认 SSE2(一条向量指令处理 4 个 float)的真实结果;“快几倍”是按指令延迟估算的,没有计时。

1. 循环慢在哪:三类原因

一个循环慢,通常是下面三件事之一,每种变换都是冲着其中一件去的:

  • 每轮做了多余的事:不变的值每轮重算、循环里判断一个不变的条件。对应外提、强度削弱、unswitching(第 2 部分)。
  • 迭代之间有依赖,没法并行:这一轮要等上一轮的结果。对应多个累加器、软件流水(第 3 部分)。
  • 访存走法差:跳着读、同一批数据被反复从内存搬进来。对应交换、分块、融合(第 4 部分)。

常听说展开能省掉“循环判断 i < n 和 i++ 的开销”,这笔开销在现代 CPU 上其实很小:cmp + jne 会合成一条微操作,这个分支几乎每次都猜对。展开的价值在于给后面的变换腾地方(2.4 节)。

2. 编译器自己会做的变换

2.1 循环不变量外提(LICM):只算一次的东西搬到循环外

循环里每轮结果都一样的计算(数组长度、常量系数、成员变量),编译器会提到循环前只算一次。#8 的 1.2 节里 sum_crtp 就是例子:mult 被提到循环外只读一次。

挡住它的两种情况:

  • 调用没内联:编译器看不见函数体,就不敢假设被调函数没改那个值。#8 里的 sum_virtual 每轮都在被调函数里重读 mult。
  • 指针可能重叠:循环里通过 int* q 写,编译器不能确定没写到 *p,*p 就得每轮重读(const 和 constexpr 里有例子)。

2.2 强度削弱(strength reduction):乘法换成累加

把 a[i] = 10 * i 里的乘法换成每轮加 10,手写出来是这样:

void fill_10i(int* a, int n) {          // 原写法
    for (int i = 0; i < n; i++) a[i] = 10 * i;
}

void fill_10i_sr(int* a, int n) {       // 强度削弱后
    int x = 0;
    for (int i = 0; i < n; i++) {
        a[i] = x;                       // x 始终等于 10 * i
        x += 10;
    }
}

这一步编译器自己会做。实测 -O2 下两个函数编出来的循环一模一样,都没有乘法:

.L3:
    movl  %eax, (%rdi)      # a[i] = x
    addl  $10, %eax         # x += 10
    addq  $4, %rdi
    cmpl  %edx, %eax
    jne   .L3

看上去 x += 10 让每一轮都依赖上一轮的 x,好像没法并行了,其实不会:x 是归纳变量(每轮按固定步长变化的变量),编译器认得它,知道第 i 轮的值就是 $10i$。-O3 下两种写法都向量化了,一次写 4 个:

  • fill_10i_sr:一个寄存器里放 {0, 10, 20, 30},每轮整体加 40(paddd)。
  • fill_10i:寄存器里放 {0, 1, 2, 3},每轮整体加 4,再用移位和加法算出 ×10:pslld $2(×4)、paddd(加上自己,得 ×5)、pslld $1(×2)。

所以这一条不用手写,直接写 10 * i 就行。

2.3 循环旋转(loop rotation):for 变成 do-while 加一个前置判断

for (i = 0; i < n; i++) body;     →     if (0 < n) { i = 0; do { body; i++; } while (i < n); }

前面那个 if 叫 loop guard。旋转之后,进到 do 里就保证循环体至少执行一次。这样把循环体里的不变计算提到 do 前面才安全:如果 n 是 0,原来的循环一次都不执行,提出来的计算也不能执行(比如它会读一个可能无效的指针)。所以它是给外提打基础的。你在汇编里看到的循环几乎都是这个形状:开头先 testl %edx, %edx; jle 跳过,循环判断放在末尾。

2.4 循环展开(unrolling):少判断几次,但不缩短依赖链

把循环体复制几份,每轮处理几个元素,i < n 和 i++ 就少执行几次:

float dot_unroll4(const float* v1, const float* v2, int n) {
    float sum = 0.0f;
    int i = 0;
    for (; i < n - 3; i += 4) {
        sum += v1[i] * v2[i];
        sum += v1[i+1] * v2[i+1];
        sum += v1[i+2] * v2[i+2];
        sum += v1[i+3] * v2[i+3];
    }
    for (; i < n; i++) sum += v1[i] * v2[i];   // 剩下的 0–3 个
    return sum;
}

这段展开了,但只有一个 sum:四个加法还是一个等一个。实测 -O3 下它和开头那个不展开的版本结构一样:乘法合成 mulps,加法仍是串行的 addss。展开真正有用,是和多个累加器、向量化一起用(3.3 节);而 -O3 下编译器会自己展开,单纯手写展开一般没必要。

展开的宽度要和向量宽度对上:SSE 一次 4 个 float,AVX / AVX2 一次 8 个,AVX-512 一次 16 个。

2.5 循环外提判断(loop unswitching):不变的 if 提到循环外

循环里判断一个整个循环都不变的条件:

void add_or_sub(float* a, const float* b, int n, bool do_add) {
    for (int i = 0; i < n; i++) {
        if (do_add) a[i] += b[i];
        else        a[i] -= b[i];
    }
}

把判断提到循环外、拆成两个各自干净的循环,叫 loop unswitching。手写出来就是:

void add_or_sub_unswitched(float* a, const float* b, int n, bool do_add) {
    if (do_add) {
        for (int i = 0; i < n; i++) a[i] += b[i];
    } else {
        for (int i = 0; i < n; i++) a[i] -= b[i];
    }
}

-O2 不做。 判断留在循环里,每轮都要 testb 一次,一次只算一个:

.L5:
    movss  (%rdi,%rax), %xmm0     # 读 a[i]
    movss  (%rsi,%rax), %xmm1     # 读 b[i]
    testb  %cl, %cl               # 每轮判断一次 do_add
    jne    .L10                   # 真:去 addss 那边
    subss  %xmm1, %xmm0
    movss  %xmm0, (%rdi,%rax)
    ...

-O3 替你做了。 -fopt-info-loop 报 unswitching loop 1 on 'if' with condition: do_add。函数开头只判断一次,然后分成两份循环,各自向量化:

    testb  %cl, %cl               # 只判断这一次
    jne    .L3                    # 真:走加法那份
    ...                           # 假:走减法那份(结构一样,用 subps)
.L16:                             # 加法那份的主循环,一次 4 个
    movups (%rax,%rcx), %xmm0
    movups (%rsi,%rcx), %xmm3
    addps  %xmm3, %xmm0
    movups %xmm0, (%rax,%rcx)
    addq   $16, %rcx
    cmpq   %rdi, %rcx
    jne    .L16

每一份进循环之前,还会先比一下 a 和 b 的地址:两者重叠就走逐个算的版本,不重叠才走向量版。这是#10 的 1.2 节讲的循环多版本,和 unswitching 是两回事,只是碰巧叠在一起。

这个分支本来也不会猜错(do_add 整个循环不变),所以 unswitching 省下的不是猜错的代价,是每轮那次判断,更重要的是换来了向量化:判断留在循环里时,编译器很难把 4 轮并成一条指令。代价是代码多了一份。在 -O2 下照上面手写(或者把 do_add 做成模板参数,在编译期分成两个函数),能去掉每轮的判断,但实测仍然不向量化,还是逐个 addss / subss:-O2 的向量化很保守(第 6 部分)。要两样都拿到,就用 -O3。

3. 依赖链:耗时由最长的那条链决定

现代 CPU 能同时执行好几条指令,但一条指令要等它的输入都算出来才能开始。所以决定耗时的不是指令条数,而是最长的那条依赖链有多长:链上的指令只能一条接一条地等。循环里最要紧的是跨迭代的链:这一轮要等上一轮的结果,几轮就没法重叠。

3.1 同样的运算,换个结合顺序

// 一条链:mul -> add -> add,每一步都等上一步
double chained(double a, double b, double c, double d) {
  return ((a * b) + c) + d;
}

// c + d 不依赖乘法,可以和 a * b 同时算
double parallel(double a, double b, double c, double d) {
  double sum = c + d;
  return (a * b) + sum;
}

乘法和加法的延迟都按 4 个周期算:

$$\text{chained} = 4 + 4 + 4 = 12,\qquad \text{parallel} = \max(4,\,4) + 4 = 8$$

运算一样多,parallel 少了三分之一的时间。做法就是把互不依赖的计算拆开,让 CPU 能同时算。 浮点加法换了顺序,结果可能差最后几位,所以编译器默认不会替你这样改,要手写(3.3 细说)。

3.2 迭代间依赖:写后读的真依赖才挡路

两条语句访问同一个位置,按先后有三种关系:

  • RAW(写后读,真依赖):先写、后读,后面要用前面写的值。跨迭代的例子:a[i] = a[i-1] * 2 + b[i],第 i 轮要等第 i-1 轮写完 a[i-1]。这条链拆不掉,就不能把几轮并起来做。
  • WAR(读后写):先读、后写,例如同一轮里 a[i] = b[i]; b[i] = c[i];,只要读在写之前就行。
  • WAW(写后写):两次写同一个位置,最后留下哪个值要对。

WAR 和 WAW 是“名字冲突”,换个变量名、多用一个临时变量就能消掉。只有跨迭代的 RAW 是真正挡住向量化的。

3.3 浮点求和:编译器不敢换顺序,要多个累加器

开头那个点积里,每次加法都要等上一次加完,这是一条贯穿整个循环的 RAW 链。整数求和编译器可以随便换顺序,实测 long 求和在 -O3 下直接向量化(paddq);float 不行,因为浮点加法不满足结合律,(a + b) + c 和 a + (b + c) 舍入结果可能不同,编译器默认不能改你的求和顺序。一次浮点加法延迟约 3–4 个周期,开头那个循环每 4 个元素就要 12–16 个周期。

改法是多个累加器:

float dot_acc4(const float* v1, const float* v2, int n) {
    float s0 = 0, s1 = 0, s2 = 0, s3 = 0;
    int i = 0;
    for (; i + 3 < n; i += 4) {
        s0 += v1[i] * v2[i];
        s1 += v1[i + 1] * v2[i + 1];
        s2 += v1[i + 2] * v2[i + 2];
        s3 += v1[i + 3] * v2[i + 3];
    }
    for (; i < n; i++) s0 += v1[i] * v2[i];
    return (s0 + s1) + (s2 + s3);
}

四条链互不依赖,编译器把四个累加器装进一个寄存器,循环体变成一条 mulps + 一条 addps,每 4 个元素只等一次加法,约 3–4 个周期,估算约快 4 倍。累加器再多(比如 8 个,两个向量寄存器轮流加),还能把加法单元的几条流水线填满。

代价是求和顺序变了,结果可能和逐个加差最后几位。这是你明确接受的,所以要自己写。另一条路是 -ffast-math(或只开 -fassociative-math),允许编译器换顺序,实测开头的版本也变成 addps。但它是整个编译单元的开关,还会改别的浮点行为(例如 4.3 节的除法),交易系统里一般不全局开。

3.4 累加器该拆几个:K = 延迟 × 吞吐

3.3 的 dot_acc4 用了 4 个累加器。拆几个才够,这一节给出算法,下面是通用的写法:

// 一个累加器:每次 += 都要等上一次,一条完全串行的链
double dot_serial(const double *a, const double *b, size_t n) {
  double acc = 0.0;
  for (size_t i = 0; i < n; ++i) acc += a[i] * b[i];
  return acc;
}

// K 个累加器:K 条互不依赖的链,只在最后合并一次
template <size_t K>
double dot_unrolled(const double *a, const double *b, size_t n) {
  double acc[K] = {};
  size_t i = 0;
  for (; i + K <= n; i += K)
    for (size_t k = 0; k < K; ++k)
      acc[k] += a[i + k] * b[i + k];
  for (; i < n; ++i) acc[0] += a[i] * b[i];   // 余下的几个
  double total = 0.0;
  for (double v : acc) total += v;
  return total;
}

K 要大到:一条链还在等上一次乘加的结果时,别的链能把执行单元填满。

$$K = \text{一次乘加的延迟(周期)} \times \text{每周期能发射的乘加数}$$

以乘加延迟 4 个周期、每周期能发射 2 个乘加(两个 FMA 单元)的核为例:

K每个元素平均周期每周期乘加数卡在哪
140.25一条链,每 4 个周期出一个结果
411填满一个 FMA 单元
80.52两个 FMA 单元都满了
160.52不再提升,瓶颈从依赖链变成了执行单元

这是只看算术的上限。 这个点积每次乘加要先读 a[i]、b[i] 两个数:每周期能读 2 个数的核,每周期最多做 1 次乘加,K = 4 就碰到读的上限;每周期能读 3 个数的较新的核,最多 1.5 次。数据不在 L1 里时,内存带宽会更早卡住。这就是 roofline 模型说的“算力受限还是访存受限”。公式给出算术上限,实际达到多少用 perf 测;K 再往上加没有收益,还会碰上 3.5 的寄存器溢出。

3.5 拆得太多:寄存器不够,就溢出到栈上

拆依赖链就是同时保留更多独立的中间值,每个都要占一个寄存器。x86-64 有 16 个通用寄存器,浮点和向量值放在 16 个 XMM/YMM 寄存器里(AVX-512 有 32 个 ZMM)。拆得太多,寄存器放不下,编译器就把多出来的值溢出到栈上,每轮多出几次读写内存。拆链本来是为了少等,拆过头反而又加上了内存访问。

3.6 看不见的依赖:popcnt 的目标寄存器

有些指令会把目标寄存器当成输入,尽管结果和它原来的值无关。典型的是 Intel 好几代核(Sandy Bridge 到 Skylake 一带)上的 popcnt、lzcnt、tzcnt。一个每轮都写同一个目标寄存器的循环,就被串成了一条跨轮的链:

; rax 被当成输入,这一轮要等上一轮的 popcnt 写完 rax
loop:
    popcnt rax, rbx
    ...
    jmp loop

; 先把目标寄存器清零:CPU 认得 xor 自己这个写法,知道它不依赖旧值,链就断了
loop_fixed:
    xor eax, eax
    popcnt rax, rbx
    ...
    jmp loop_fixed

这种依赖在源码里看不出来,GCC、Clang 在针对这些核调优时会自动插入这条 xor。手写汇编或者 intrinsics 时,遇到一个“不该是链”的循环停顿得说不清,可以往这个方向查。

3.7 软件流水与 MII:吞吐上限由资源或依赖环决定

软件流水(software pipelining)是让相邻几轮迭代重叠执行:第 i 轮还在算,第 i+1 轮已经开始读数据。启动间隔(II,initiation interval)是相邻两轮开始之间隔几个周期,II 越小吞吐越高。它有一个下界 MII,由两件事决定:

$$ \text{MII} = \max(\text{ResMII},\ \text{RecMII}) $$

ResMII(资源约束):每轮要用几次某类执行单元,除以这类单元有几个,取所有类里最大的:

$$ \text{ResMII} = \max_{\text{每类单元}} \left\lceil \frac{\text{每轮这类指令条数}}{\text{这类单元个数}} \right\rceil $$

例如每轮 3 条加法、2 条乘法,有 2 个加法单元、1 个乘法单元:加法 $\lceil 3/2 \rceil = 2$,乘法 $\lceil 2/1 \rceil = 2$,ResMII = 2。

RecMII(依赖环约束):跨迭代的依赖环上,所有指令延迟加起来,除以这个环跨了几轮:

$$ \text{RecMII} = \max_{\text{每个依赖环}} \frac{\text{环上延迟之和}}{\text{环跨越的迭代数}} $$

例如 a[i] = a[i-1] * 2 + b[i]:环是“乘、加、得到 a[i]、下一轮拿来用”。假设乘法 2 个周期、加法 1 个周期,RecMII = 3;ResMII 只有 2,所以瓶颈在依赖环,执行单元再多也没用。(这里的延迟是为了算式好看假设的;x86 上 * 2 会被编成加法或移位,a[i-1] 也会留在寄存器里,不真去读内存。)

在 x86 上这件事主要由硬件做:乱序执行同时挂着好几轮迭代,自动把它们重叠起来,编译器很少显式做软件流水。但这套算法照样有用,它就是本节开头说的关键路径:看循环快不快,先找跨迭代的依赖链(RecMII),再数每轮占用的执行单元(ResMII),大的那个就是上限。 3.3 节的点积就是被 RecMII 卡住的:一条加法链,每轮至少等一次加法延迟;多个累加器把一个环拆成四个互不相干的环,RecMII 变成原来的 1/4。

4. 访存:让数据少搬几次

4.1 循环交换(interchange):内层沿着内存连续的方向走

C++ 二维数组按行存,m[i][j] 和 m[i][j+1] 挨着。内层循环走 j,就是顺着内存读,一条缓存行 64 B 用满、预取器跟得上;内层走 i,每步跳一整行,每次都可能是一条新的缓存行。交换内外层就能从后者变成前者,#6 里“按列遍历慢”就是这个。编译器只在能证明结果不变时才自己交换,通常要你写对。

4.2 循环分块(tiling):有重复使用才有用

分块是把大数组切成小块,一块处理完再处理下一块,让块内的数据在被反复使用期间一直留在缓存里。前提是数据有重复使用:矩阵乘法里同一行、同一列要用很多次;转置时按行读、按列写,按块走能让读和写都留在缓存里:

constexpr int N = 256, T = 16;
void transpose_tiled(const float* a, float* b) {
    for (int i = 0; i < N; i += T)
        for (int j = 0; j < N; j += T)
            for (int ti = i; ti < i + T; ti++)
                for (int tj = j; tj < j + T; tj++)
                    b[tj * N + ti] = a[ti * N + tj];
}

块内的 tj 循环还是跨步写 b,这算不算缓存颠簸?不算。转置每个元素也只碰一次,重复使用的是缓存行,不是元素:b 的一条行装 16 个 float,要分 16 次写满。

  • 不分块:内层走 j,写 b[j*N+i],每步换一条新的行。同一条行的第 2 次写,要等写完一整列 N 条别的行之后。N 大时这 N 条行装不下 L1;这里 N = 256 虽然只有 16 KiB,但行与行相隔 1 KiB,按组号 $(\text{地址} / 64) \bmod 64$ 算只落进 4 个组,8 路的 L1 最多留 32 条,行早被挤掉了,一条行要被搬进来 16 次。
  • 分块:块内只碰 16 条 b 的行(1 KiB)。ti 走 16 遍,每遍给这 16 条行各写一个 float,16 遍后正好写满、再离开。16 条行落在 4 个组里,每组 4 条,8 路装得下。跨步写打在 L1 里的行上,和顺序写一样便宜;每条行只从下一级搬进来 1 次。

常见的“再优化”是先把 a 的块拷进栈上的 tile[T][T],再从 tile 按行写 b。在 CPU 上这不会更快(按访存量分析,没有计时):a、b 每条行照样各搬 1 次,只是多了一遍 1 KiB 的拷贝;跨步也没有消失,只是从写 b 挪到了读 tile。这种写法来自 GPU:GPU 上跨步写显存不能合并成一笔访问,要先在 shared memory 里倒一手;CPU 上 L1 已经替你做了这件事。CPU 上真正的下一步是寄存器里的转置:一次读 4 行各 4 个 float 进 4 个 xmm,用 unpcklps / shufps 交换成 4 列(_MM_TRANSPOSE4_PS),再整行写回,读写都从一次一个 float 变成一次 16 B。

反过来,每个元素只碰一次的循环,分块没有用。拿“清零一个矩阵”来说,按行一路写下去本来就是最好的走法:实测 GCC -O2 把不分块的版本整个编成一次 memset;硬套 16×16 分块,反而只能每行写 64 B、再跳 2 KiB 到下一行。

4.3 融合与分裂(fusion / fission):一遍还是几遍

  • 融合:对同一批数据做的几件事放进一个循环,数据只从内存搬一次。代价是循环体变大,寄存器可能不够、可能挡住向量化,也占更多指令缓存。
  • 分裂:反过来,一个循环拆成几个,每个循环体简单、好向量化,但数据要扫几遍。

怎么选看数据有多大:数据放得进 L2 / L3 时,多扫几遍还是命中缓存,分开写更好;数据远大于缓存时,每多扫一遍就是多一遍内存带宽,融合更好。拿不准就用 perf stat 对比两种写法的 icache 和 LLC 未命中。

一个常见的误用是把“减均值、除标准差”拆成两遍:v[i] = (v[i] - mean) / denom 这个单循环本来就能向量化,实测 -O3 编出 subps + divps,一次 4 个。拆成“先全部减、再全部乘”两遍,没有让哪个循环更好向量化,只是多扫了一遍数组。这个例子里真正省时间的是把除法换成乘倒数:除法延迟约 10–15 个周期、吞吐也慢好几倍,乘法约 3–4 个周期。一遍做完就行:

void normalize(float v[], int n, float mean, float var, float eps) {
    float inv_denom = 1.0f / sqrtf(var + eps);
    for (int i = 0; i < n; i++) v[i] = (v[i] - mean) * inv_denom;
}

编译器默认不会替你做这一步,因为乘倒数和直接除的结果可能差最后一位;-ffast-math 下它会做(实测 divps 变成 mulps)。

4.4 扁平化(collapsing):两层循环并成一层

1. 场景。 rows 个品种,每个存 cols 档价格,一行接一行连续存放,全部乘一个系数。档数运行时才知道,比如 3:

void scale_nested(float* m, int rows, int cols, float s) {
    for (int i = 0; i < rows; i++)
        for (int j = 0; j < cols; j++)
            m[i * cols + j] *= s;
}

2. 慢在哪。 -O3 向量化的是内层:mulps 一次乘 4 个,内层不够 4 个就走余数处理。cols = 3 时每一行都是这样(GCC 13 实测,按执行顺序摘出):

    cmpl    $2, %r12d       # 不够 4 个 → 跳过 4 个一组的主循环
    jbe     .L24
    ...
    mulps   %xmm3, %xmm1    # 余数:先乘 2 个
    ...
    mulss   %xmm0, %xmm1    # 再乘 1 个
    ...                     # 更新行号、行首地址,进下一行

一行约 30 条指令、4 个条件跳转,只做 2 次乘法。开销来自每行都要重新进一次内层循环,行越短占比越大。

3. 并成一层。 行间没有空隙,每个数做的事一样,就当成一个长 rows * cols 的数组:

void scale_flat(float* m, int rows, int cols, float s) {
    int total = rows * cols;
    for (int k = 0; k < total; k++)
        m[k] *= s;
}

主循环是 movups / mulps / movups 加 3 条循环控制,4 个数 6 条指令,余数只在数组末尾处理一次。一组 4 个跨两行也没关系。按指令数估算,每个数从约 10 条降到约 1.5 条(没有计时)。

4. 什么时候不用手写、不能并。

  • 列数是编译期常量(写死 3):GCC 先展开内层、再向量化外层,一轮 4 行 12 个数,等于自己扁平化了。
  • 行间有空隙(每行补齐到 64 B、vector<vector<float>>):不能并。
  • 做的事和行号有关(每行乘自己的 s[i]):不能直接并,用 k / cols 算回行号多一次除法,不划算。

5. 边界:剥离、余数、哨兵

5.1 剥离(peeling)与余数循环:把不规整的几轮单独拿出来

向量化的主循环一次处理 4 个(或 8 个),n 不是 4 的倍数时,剩下的 0–3 个要单独处理,这段叫余数循环(remainder,也叫 epilogue),2.4 节的第二个 for 就是。剥离是把开头或结尾的几轮单独拿出来,例如先单独做几个元素,让主循环的起始地址对齐到 16 B 或 32 B。两者目的一样:主循环里不再有“这一轮是不是特殊情况”的判断。编译器向量化时会自动生成余数循环,开头那段汇编 .L4 之后就是一段逐个 mulss 的收尾。

5.2 哨兵(sentinel):在末尾放一个必然命中的值,循环里少一个判断

在数组里找第一个负数,正常要每步判断两件事:i < n 和 a[i] < 0。在末尾多留一格放一个负数,循环一定会在它那里停下,就只用判断 a[i] < 0:

int find_first_negative(int* a, int n) {   // a 至少有 n + 1 格
    int saved = a[n];
    a[n] = -1;
    int i = 0;
    while (a[i] >= 0) ++i;
    a[n] = saved;
    return i;                              // == n 表示没找到
}

条件是数组末尾真的多出一格、可写,而且没有别的线程同时在读这个数组。

6. 怎么查编译器做了什么

不用猜,让 GCC 自己说:

  • -fopt-info-vec:哪些循环向量化了、用多宽的向量。
  • -fopt-info-vec-missed:哪些没向量化、为什么(指针可能重叠、有调用、浮点不能换顺序……)。
  • -fopt-info-loop:展开、unswitching 这类循环变换。
  • 再配合 -S 看循环体。开头的点积说明,“报告说向量化了”不等于加法链变短了,最后还是要看指令。

GCC 12 起 -O2 也开了向量化,但只用最保守的代价模型。实测在 -O2 下,这篇里普通写法的循环(开头的点积、整数求和、fill_10i、归一化、add_or_sub)都没有向量化;只有手写成每轮 4 条相同语句的两个点积版本,被合成了向量乘法(多累加器版连加法也成了 addps)。这叫 SLP 向量化:把一轮里几条一样的语句并成一条向量指令,不是把几轮循环并起来。-O3 下这些循环都向量化了。热路径上的关键循环,要么用 -O3 编,要么对着报告确认。

应用

3.3、3.4 节的多个累加器,在实际优化里对应“B1 串行累加”这张卡片:在 perf 里看到什么、怎么改、怎么验证。

B1 串行累加

  • 信号:annotate 上一条 vaddss/vfmadd 占大头(样本常记在它的下一条指令上,要往前看一条);IPC ≪ 1,缓存缺失却很低,这个组合是关键。
  • 原因:每次 += 都要等上一次的结果,速度卡在浮点加的延迟(4–5 周期)上,而不是吞吐(约 0.5 周期),差 8–10 倍。
  • 修法:拆成多个独立累加器。纯 C 拆 4 个约快 4 倍;SSE2 向量累加快 4–8 倍;AVX2 向量加 4 个累加器快 8–15 倍。
  • 累加器个数:$\text{延迟} \div \text{吞吐} = 4 \div 0.5 = 8$。一般 4 个够,紧循环用 8 个。
  • 尾巴:不满一组的尾巴也要用多累加器,否则小输入又退回串行。
  • 前提:运算满足结合律(加、乘、min、max)。结果会差一个舍入误差;要求逐位一致就不能用。
  • 验证:输出在舍入误差内一致,IPC 至少翻倍。
  • 原理:#9 循环优化 3.3;累加器个数的公式见 #9 3.4;编译器替不替你拆,见内存延迟与带宽 1.5.1;实测见 perf 专题 4.6。