先看一个点积:
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 | 每个元素平均周期 | 每周期乘加数 | 卡在哪 |
|---|---|---|---|
| 1 | 4 | 0.25 | 一条链,每 4 个周期出一个结果 |
| 4 | 1 | 1 | 填满一个 FMA 单元 |
| 8 | 0.5 | 2 | 两个 FMA 单元都满了 |
| 16 | 0.5 | 2 | 不再提升,瓶颈从依赖链变成了执行单元 |
这是只看算术的上限。 这个点积每次乘加要先读 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 里看到什么、怎么改、怎么验证。
- 信号: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。