CSAPP(lecture 10)

编译器优化、代码移动与冗余计算消除

前面我们已经能够从 C 语言代码出发,分析编译器生成的机器指令。例如,对于二维数组:

c 复制代码
double A[N][N];

访问:

c 复制代码
A[i][j]

时,需要计算:

Address(Aij)=Base(A)+8(iN+j) Address(Aij)=Base(A)+8(iN+j) Address(Aij)=Base(A)+8(iN+j)

这是因为每个 double\mathrm{double}double 占用 888 字节,而二维数组按照行优先顺序连续存储。

但是,能够正确计算地址,并不意味着程序已经足够高效。

例如,如果我们在一个循环中反复访问同一行的元素:

c 复制代码
for (j = 0; j < n; j++)
    a[n*i+j] = b[j];

那么:

n×i n\times i n×i

在每一次循环中都具有相同的值。

从程序语义来看,这个乘法只需要计算一次。但在实际机器代码中,编译器是否会自动消除重复计算?如果它没有完成优化,我们又应该怎样调整代码?

这正是程序优化需要研究的问题。

编译器优化的能力与限制

分析程序性能时,首先需要区分算法复杂度 和实际执行开销。

假设两个程序都需要遍历长度为 NNN 的数组,那么它们的时间复杂度可能都是:

O(N) O(N) O(N)

但这不意味着它们的实际运行时间相同。

例如,第一个程序每处理一个元素,需要执行多次乘法、访存和函数调用;第二个程序则将重复计算提前完成,只保留必要的操作。

虽然它们的渐近复杂度相同,但实际运行时间可能相差很大。

因此,可以将程序性能理解为两个层次:

首先,算法决定问题规模增长时,计算量如何变化。

其次,在给定算法的基础上,数据表示、机器指令、访存次数和处理器执行方式决定实际运行开销。

例如:

T1(N)=20N T_1(N)=20N T1(N)=20N

T2(N)=2N T_2(N)=2N T2(N)=2N

两者都是:

O(N) O(N) O(N)

但第二个程序的运行工作量可能明显更少。

这里的常数 202020 和 222 只是示意,并非真实处理器的周期数。

因此,程序优化并不只是分析时间复杂度,还需要考虑程序执行过程中实际发生了什么。

在现代 C/C++ 开发中,我们通常不需要为了得到高效程序而直接编写汇编代码。

编译器已经能够完成大量优化,例如:

  • 为频繁使用的数据分配寄存器。
  • 选择更合适的机器指令。
  • 调整部分指令的执行顺序。
  • 删除不会影响结果的无用计算。
  • 合并部分重复表达式。

例如:

c 复制代码
long f(long x)
{
    long a = x * 2;
    long b = a + 0;
    return b;
}

这里:

c 复制代码
a + 0

不会改变计算结果。

因此,优化编译器完全可能消除这一步,而不必按照源代码逐条生成对应指令。

但是,编译器并不擅长解决所有问题。

它通常不会自动替程序员选择复杂度更低的整体算法。

例如,如果我们选择了一种需要:

O(N2) O(N^2) O(N2)

次操作的算法,编译器一般不会自动把它改写为另一种:

O(Nlog⁡N) O(N\log N) O(NlogN)

的算法。

更重要的是,编译器对程序的理解存在边界。

编译器优化的根本约束,是不能随意改变原程序在语言规则下要求保留的可观察行为。

例如:

c 复制代码
int x = 10;
int y = x + 5;

将计算过程合并,一般不会带来问题。

但如果程序存在:

c 复制代码
*p = value;

这样的内存写入,编译器就需要考虑p可能指向什么对象,以及这次写入是否会影响后续操作。

同样,如果代码包含:

c 复制代码
result = func();

编译器也不能仅仅因为函数调用看起来重复,就直接删除它。

因为函数可能修改全局变量、访问外部状态,或者具有其他副作用。

所以,编译器在无法证明某种变换安全时,必须采取保守策略。

可以将这种约束概括为:

无法证明变换安全⇒不应随意执行该优化 \boxed{ \text{无法证明变换安全} \Rightarrow \text{不应随意执行该优化} } 无法证明变换安全⇒不应随意执行该优化

这种保守并不是编译器不够聪明,而是保证程序正确性的重要条件。

例如:

c 复制代码
int x;

从类型上看,它可以表示一个较大的整数范围。

但实际业务中,程序员可能知道:

0≤x≤100 0\le x\le100 0≤x≤100

如果这个限制没有 通过代码或其他编译器能够理解的信息体现出来,编译器就未必能够依赖它进行优化。

同样,编译器可能难以分析跨函数调用、复杂指针访问或者不同源文件之间的数据关系。

需要注意,以上描述主要反映传统编译器优化面临的问题。现代 GCC 和 Clang 已经支持更丰富的过程间分析、内联和链接时优化,但它们仍然受到程序语义与可证明信息的限制。

因此,提高程序性能时,我们需要理解编译器已经能够完成哪些优化,以及哪些信息只有程序员自己最清楚。

一个非常实用的方法是:先使用优化编译器生成汇编代码,观察它实际保留了哪些操作,再决定是否有必要修改源代码。

例如:

bash 复制代码
gcc -O1 -S example.c -o example.s

其中:

text 复制代码
-O1

启用一定程度的优化。

text 复制代码
-S

让编译过程输出汇编文件。

这样,我们就可以利用前面几讲学习的汇编知识,判断编译器是否删除了重复计算、是否把变量保存在寄存器中,以及是否产生了不必要的内存访问。

不过,优化并不是无限追求某一个处理器型号上的最高速度。如果为了某种特定微架构牺牲太多可读性和可维护性,代码可能在另一种处理器上不再具有相同收益。

因此,首先应该掌握那些在不同处理器和编译器环境中都具有普遍价值的优化方法。

其中最基本的三种是:代码移动、强度削减和公共子表达式消除。

代码移动与强度削减

首先考虑一个用于复制矩阵某一行的函数:

c 复制代码
void set_row(double *a, double *b,
             long i, long n)
{
    long j;

    for (j = 0; j < n; j++)
        a[n*i+j] = b[j];
}

其中:

a a a

指向以一维连续数组形式存储的矩阵。

b b b

指向准备写入的行数据。

i i i

表示目标行号。

n n n

表示每行包含的元素数量。

我们之前已经知道,二维矩阵中第 iii 行、第 jjj 列的位置可以表示为:

i×n+j i\times n+j i×n+j

因此:

c 复制代码
a[n*i+j] = b[j];

就是将:

bj bj bj

写入矩阵第 iii 行的第 jjj 个元素。

假设:

n=4,i=2 n=4,\qquad i=2 n=4,i=2

那么目标元素下标依次为:

2×4+0=8 2\times4+0=8 2×4+0=8

2×4+1=9 2\times4+1=9 2×4+1=9

2×4+2=10 2\times4+2=10 2×4+2=10

2×4+3=11 2\times4+3=11 2×4+3=11

观察这些计算可以发现:

2×4=8 2\times4=8 2×4=8

在整个循环中始终不变。

也就是说,真正变化的只是:

j j j

因此,没有必要在每次循环中重新计算:

n×i n\times i n×i

可以将代码改写为:

c 复制代码
void set_row(double *a, double *b,
             long i, long n)
{
    long j;
    long ni = n * i;

    for (j = 0; j < n; j++)
        a[ni+j] = b[j];
}

这里:

c 复制代码
long ni = n * i;

只执行一次。

而循环中直接使用:

c 复制代码
a[ni+j]

完成访问。

这种将循环内不变的计算移到循环外部的优化,称为:代码移动(Code Motion)。

对于循环中不会改变结果的表达式,它也常被称为循环不变代码外提(Loop-Invariant Code Motion)。

其基本思想是:

循环中反复执行相同计算→提前计算一次 \boxed{ \text{循环中反复执行相同计算} \rightarrow \text{提前计算一次} } 循环中反复执行相同计算→提前计算一次

不过,能否移动某个表达式,取决于它的结果是否真的在循环中保持不变,以及提前计算是否会改变程序语义。

例如,如果表达式读取了某块内存,而循环体可能修改这块内存,那么就不能简单认为它是循环不变量。

现在进一步看机器级实现。

编译器不仅可以提前计算:

ni=n×i ni=n\times i ni=n×i

还可以继续提前计算这一行的起始地址。

由于:

sizeof(double)=8 sizeof(double)=8 sizeof(double)=8

所以目标行起始地址为:

rowp=a+ni rowp=a+ni rowp=a+ni

对应的字节地址:

Address(rowp)=Base(a)+8ni Address(rowp)=Base(a)+8ni Address(rowp)=Base(a)+8ni

因此,可以把程序理解为:

c 复制代码
void set_row(double *a, double *b,
             long i, long n)
{
    long j;
    long ni = n * i;
    double *rowp = a + ni;

    for (j = 0; j < n; j++)
        rowp[j] = b[j];
}

对于这种操作,在 GCC -O1\texttt{-O1}-O1 下的一组汇编结果为:

c 复制代码
set_row:
    testq %rcx, %rcx
    jle   .L1

    imulq %rcx, %rdx
    leaq  (%rdi,%rdx,8), %rdx
    movl  $0, %eax

.L3:
    movsd (%rsi,%rax,8), %xmm0
    movsd %xmm0, (%rdx,%rax,8)
    addq  $1, %rax
    cmpq  %rcx, %rax
    jne   .L3

.L1:
    rep; ret

首先明确参数寄存器:

%rdi=a \%rdi=a %rdi=a

%rsi=b \%rsi=b %rsi=b

%rdx=i \%rdx=i %rdx=i

%rcx=n \%rcx=n %rcx=n

第一组指令:

asm 复制代码
testq %rcx, %rcx
jle .L1

检查:

n≤0 n\le0 n≤0

如果成立,则不需要执行循环,直接返回。

接下来:

asm 复制代码
imulq %rcx, %rdx

计算:

%rdx←i×n \%rdx\leftarrow i\times n %rdx←i×n

因此:

%rdx=ni \boxed{\%rdx=ni} %rdx=ni

最关键的是下一条:

asm 复制代码
leaq (%rdi,%rdx,8), %rdx

根据有效地址计算公式:

EA=Base+Index×Scale EA=Base+Index\times Scale EA=Base+Index×Scale

得到:

%rdx=a+8ni \%rdx=a+8ni %rdx=a+8ni

所以:

%rdx=&ani \boxed{\%rdx=\&ani} %rdx=&ani

这就是目标行的起始地址 。

注意,乘法与目标行地址的计算都发生在循环之前。

进入循环后:

asm 复制代码
movsd (%rsi,%rax,8), %xmm0

读取:

bj bj bj

因为:

%rax=j \%rax=j %rax=j

所以源地址为:

Base(b)+8j Base(b)+8j Base(b)+8j

接下来:

asm 复制代码
movsd %xmm0, (%rdx,%rax,8)

将读取到的值写入目标行:

rowpj rowpj rowpj

即:

ani+j ani+j ani+j

后面:

asm 复制代码
addq $1, %rax
cmpq %rcx, %rax
jne .L3

完成循环下标更新与终止判断。

整个循环体并没有再次执行整数乘法。

这说明编译器已经将原本写在循环表达式中的:

n×i n\times i n×i

提前计算,并利用行起始地址直接完成后续访问。

这里还要注意一个区别:

Code Motion 是一种优化思想,而不是要求程序员每次都必须亲自把表达式移出循环。

如果编译器已经能够识别循环不变量,它自己就可能完成这种变换。

但是,通过源代码提前表达这些不变关系,往往能让程序的计算结构更加明确。

在此基础上,再考虑另一类优化。

假设有一个整数计算:

c 复制代码
long y = 16 * x;

由于:

16=24 16=2^4 16=24

对于适用的整数语义,乘以 161616 可以对应左移四位:

16x=x≪4 \boxed{16x=x\ll4} 16x=x≪4

因此,编译器可能使用移位指令代替乘法指令。

这种用执行成本较低的操作替代较高成本操作的优化,称为:

强度削减(Strength Reduction)。

其基本思想是:

较昂贵的运算→较简单的运算 \boxed{ \text{较昂贵的运算} \rightarrow \text{较简单的运算} } 较昂贵的运算→较简单的运算

但不能简单认为乘法指令永远比移位慢。

例如,在早期某些处理器中,整数乘法的延迟相对较高;而现代处理器的乘法执行能力已经有很大改善。

因此,具体收益取决于目标处理器的指令延迟、吞吐量以及前后指令之间的依赖关系。

此外,C 语言中的有符号左移还受到取值范围等语义规则限制,不能在所有情况下机械地将:

c 复制代码
16 * x

改写成:

c 复制代码
x << 4

只有在变换符合目标数据类型的语义要求时,这种替换才是合理的。

强度削减还有另一种更普遍的形式:将循环中的重复乘法改成递增加法。

例如:

c 复制代码
for (i = 0; i < n; i++)
{
    long ni = n * i;

    for (j = 0; j < n; j++)
        a[ni+j] = b[j];
}

这里虽然已经把:

ni=n×i ni=n\times i ni=n×i

移到了内层循环之外,但外层循环每次仍然需要计算一次乘法。

观察外层循环变量:

i=0,1,2,...,n−1 i=0,1,2,\ldots,n-1 i=0,1,2,...,n−1

对应:

ni=0,n,2n,...,(n−1)n ni=0,n,2n,\ldots,(n-1)n ni=0,n,2n,...,(n−1)n

我们可以发现:

nii+1=nii+n \boxed{ni_{i+1}=ni_i+n} nii+1=nii+n

因此,完全可以不再根据:

n×i n\times i n×i

重新计算行起始下标,而是直接在上一次结果的基础上增加 nnn

c 复制代码
long ni = 0;

for (i = 0; i < n; i++)
{
    for (j = 0; j < n; j++)
        a[ni+j] = b[j];

    ni += n;
}

这样,外层循环中原来每次执行的乘法,就变成了加法。

需要注意,这两种优化并不完全相同。

代码移动 主要减少某个不变表达式被重复计算的次数。

强度削减则使用更简单的运算维护所需结果。

在这个矩阵例子中,它们能够连续应用:

内层重复乘法→外层每次一次乘法→通过加法维护行偏移 \boxed{ \text{内层重复乘法} \rightarrow \text{外层每次一次乘法} \rightarrow \text{通过加法维护行偏移} } 内层重复乘法→外层每次一次乘法→通过加法维护行偏移

不过,如果数据类型允许溢出,或者程序依赖特殊整数语义,变换前仍需要保证新旧程序行为一致。

公共子表达式消除

前面两种优化主要围绕循环展开。

接下来考虑另一种更普遍的问题:一段程序中多个不同表达式,可能包含相同的计算结果。

例如,我们使用一维数组保存一个:

n×n n\times n n×n

的矩阵。

其中第 iii 行、第 jjj 列元素的下标为:

in+j in+j in+j

现在假设需要读取某个元素周围的四个相邻元素:

c 复制代码
/* Sum neighbors of i, j */
up    = val[(i-1)*n + j];
down  = val[(i+1)*n + j];
left  = val[i*n + j-1];
right = val[i*n + j+1];

sum = up + down + left + right;

这里分别访问:

  • 上方元素:(i−1,j)(i-1,j)(i−1,j)。
  • 下方元素:(i+1,j)(i+1,j)(i+1,j)。
  • 左方元素:(i,j−1)(i,j-1)(i,j−1)。
  • 右方元素:(i,j+1)(i,j+1)(i,j+1)。

对应的线性数组下标为:

up:(i−1)n+j up:(i-1)n+j up:(i−1)n+j

down:(i+1)n+j down:(i+1)n+j down:(i+1)n+j

left:in+j−1 left:in+j-1 left:in+j−1

right:in+j+1 right:in+j+1 right:in+j+1

如果直接观察这些表达式,就会发现程序包含三种乘法:

(i−1)n (i-1)n (i−1)n

(i+1)n (i+1)n (i+1)n

in in in

但是,将它们展开:

(i−1)n+j=in+j−n (i-1)n+j=in+j-n (i−1)n+j=in+j−n

(i+1)n+j=in+j+n (i+1)n+j=in+j+n (i+1)n+j=in+j+n

因此,四个地址计算其实都可以从同一个基本下标出发:

inj=in+j \boxed{inj=in+j} inj=in+j

于是代码可以改写为:

c 复制代码
long inj = i * n + j;

up    = val[inj - n];
down  = val[inj + n];
left  = val[inj - 1];
right = val[inj + 1];

sum = up + down + left + right;

这次程序只需要显式计算一次:

i×n i\times n i×n

随后通过简单的加减法,就能够定位周围的四个元素。

这种识别并复用共同计算结果的优化,称为:

公共子表达式消除(Common Subexpression Elimination,CSE)。

其核心思想是:

多个表达式包含共同计算→计算一次并复用 \boxed{ \text{多个表达式包含共同计算} \rightarrow \text{计算一次并复用} } 多个表达式包含共同计算→计算一次并复用

接下来观察汇编中的区别。

对于未优化的表达式,一种实现方式是:

c 复制代码
leaq 1(%rsi), %rax
leaq -1(%rsi), %r8

imulq %rcx, %rsi
imulq %rcx, %rax
imulq %rcx, %r8

addq %rdx, %rsi
addq %rdx, %rax
addq %rdx, %r8

假设:

%rsi=i \%rsi=i %rsi=i

%rdx=j \%rdx=j %rdx=j

%rcx=n \%rcx=n %rcx=n

第一条:

asm 复制代码
leaq 1(%rsi), %rax

得到:

%rax=i+1 \%rax=i+1 %rax=i+1

第二条:

asm 复制代码
leaq -1(%rsi), %r8

得到:

%r8=i−1 \%r8=i-1 %r8=i−1

随后三条:

asm 复制代码
imulq %rcx, %rsi
imulq %rcx, %rax
imulq %rcx, %r8

分别执行:

in in in

(i+1)n (i+1)n (i+1)n

(i−1)n (i-1)n (i−1)n

因此,程序使用了三次乘法构造不同的行偏移。

但经过公共子表达式消除之后,可以得到:

c 复制代码
imulq %rcx, %rsi
addq  %rdx, %rsi

movq  %rsi, %rax
subq  %rcx, %rax

leaq  (%rsi,%rcx), %rcx

逐条分析。

首先:

asm 复制代码
imulq %rcx, %rsi

得到:

%rsi=in \%rsi=in %rsi=in

随后:

asm 复制代码
addq %rdx, %rsi

得到:

%rsi=in+j=inj \boxed{\%rsi=in+j=inj} %rsi=in+j=inj

接下来:

asm 复制代码
movq %rsi, %rax

复制基本下标:

%rax=inj \%rax=inj %rax=inj

然后:

asm 复制代码
subq %rcx, %rax

计算:

%rax=inj−n \boxed{\%rax=inj-n} %rax=inj−n

即上方元素的下标。

最后:

asm 复制代码
leaq (%rsi,%rcx), %rcx

计算:

%rcx=inj+n \boxed{\%rcx=inj+n} %rcx=inj+n

即下方元素的下标。

而左右两个元素的下标,只需要在:

inj inj inj

的基础上减去或加上 111。

因此,原先三次独立的乘法,被减少为一次乘法和若干次加减运算。

方案 主要乘法次数 计算特点
分别计算四个方向的下标 333 次 多次重复计算行偏移
先计算 inj=in+j\mathrm{inj}=in+jinj=in+j 111 次 复用中心位置的线性下标

当然,最终性能仍然受到寄存器分配、指令执行和内存访问等因素影响。这里的直接收益是减少了重复的地址计算。

而且,上面的源代码变换必须在访问下标有效、相关整数计算满足原程序语义的前提下使用。

对于真实矩阵运算,还需要确保:

i−1,i+1,j−1,j+1 i-1,\quad i+1,\quad j-1,\quad j+1 i−1,i+1,j−1,j+1

不会超出合法索引范围。

所以,这段代码展示的是地址计算优化,而不是包含完整边界处理的矩阵求和函数。

至此,我们已经学习了三种具有普遍价值的基础优化方式:

优化方式 主要问题 核心方法
Code Motion 循环内重复计算不变表达式 将计算提前到循环外
Strength Reduction 反复使用相对昂贵的运算 使用移位、加法或递增更新
Common Subexpression Elimination 多个表达式重复计算相同部分 计算一次并复用结果

它们具有一个共同特点:在不改变程序所需计算结果和可观察行为的前提下,减少实际执行的工作。

不过,到这里还存在一个更加关键的问题。

前面的 set_row\mathrm{set\_row}set_row 示例中,编译器已经能够自动完成代码移动,将乘法和行地址计算提前到循环外部。

但我们不能因此认为,只要程序中出现重复计算,编译器就一定能够自动删除。

例如,如果重复计算隐藏在函数调用中,编译器可能无法轻易判断函数的返回值是否每次相同,或者函数是否会产生副作用。

这意味着,即使程序员认为某个调用可以安全地提前执行,编译器也可能因为无法证明其安全性,而选择保留原始实现。

因此,接下来需要研究的就是优化障碍(Optimization Blockers)

首先分析循环中反复调用 strlen\mathrm{strlen}strlen 如何把一个看起来简单的字符串处理函数变成二次复杂度,再进一步理解指针别名为什么会阻止编译器消除重复的内存读写。

函数调用与内存别名带来的优化障碍

前面我们已经学习了三种常见的程序优化方法:代码移动、强度削减和公共子表达式消除。它们的共同目标,是在不改变程序行为的前提下,减少重复计算。

例如,对于循环:

c 复制代码
for (j = 0; j < n; j++)
    a[n*i+j] = b[j];

由于 iii 和 nnn 在循环中保持不变,因此:

n×i n\times i n×i

可以提前计算一次。

这似乎意味着,只要我们发现循环中存在重复计算,编译器就应该能够自动消除它。

但实际情况并没有这么简单。

编译器不仅需要判断某个计算是否重复,还必须证明删除或移动它不会改变程序行为。

如果重复计算涉及函数调用 ,编译器需要考虑函数是否具有副作用;如果重复计算涉及内存访问,编译器则必须考虑不同指针是否可能指向同一个位置。

这两种情况,就是常见的优化障碍(Optimization Blockers)。

函数调用

先考虑一个将字符串中的大写英文字母转换为小写的函数:

c 复制代码
void lower(char *s)
{
    size_t i;

    for (i = 0; i < strlen(s); i++)
        if (s[i] >= 'A' && s[i] <= 'Z')
            s[i] -= ('A' - 'a');
}

这段代码来自一次学生实验提交

从功能上看,它非常简单:遍历字符串,如果当前字符属于:

′A′∼′Z′ 'A'\sim'Z' ′A′∼′Z′

就将它转换成对应的小写字符。

由于 ASCII 编码中:

′A′=65,′a′=97 'A'=65,\qquad'a'=97 ′A′=65,′a′=97

所以:

′A′−′a′=−32 'A'-'a'=-32 ′A′−′a′=−32

因此:

c 复制代码
s[i] -= ('A' - 'a');

等价于:

c 复制代码
s[i] += 32;

这里的 323232 是 ASCII 大写字母与对应小写字母之间的编码差值。

例如:

′A′+32=′a′ 'A'+32='a' ′A′+32=′a′

′B′+32=′b′ 'B'+32='b' ′B′+32=′b′

程序逻辑没有什么特别之处:从第一个字符开始,逐个判断并转换,直到到达字符串末尾。

如果字符串长度为 NNN,似乎只需要遍历:

N N N

个字符。

因此,我们可能直觉地认为:

T(N)=O(N) T(N)=O(N) T(N)=O(N)

但实际测量显示,这个函数的运行时间随着字符串长度增加,呈现出近似二次增长的趋势。

当字符串长度变为原来的两倍时,运行时间接近增加到四倍。

这意味着:

T(2N)≈4T(N) T(2N)\approx4T(N) T(2N)≈4T(N)

这种增长关系正是二次复杂度的典型特征:

T(N)=O(N2) \boxed{T(N)=O(N^2)} T(N)=O(N2)

为什么一段看起来只包含一个循环的程序,会具有二次复杂度?

关键就在循环条件:

c 复制代码
i < strlen(s)

我们前面已经学过,for\texttt{for}for 循环可以转换成基于条件跳转的控制流。

因此,这段代码在逻辑上可以表示为:

c 复制代码
void lower(char *s)
{
    size_t i = 0;

    if (i >= strlen(s))
        goto done;

loop:
    if (s[i] >= 'A' && s[i] <= 'Z')
        s[i] -= ('A' - 'a');

    i++;

    if (i < strlen(s))
        goto loop;

done:
    ;
}

这样展开后,就能清楚地看到:

每执行一次循环,都需要再次调用strlen(s)判断是否已经到达字符串末尾。

这正是性能问题的根源。

现在进一步分析 strlen\texttt{strlen}strlen 自身的实现。

一个简单的实现如下:

c 复制代码
size_t strlen(const char *s)
{
    size_t length = 0;

    while (*s != '\0')
    {
        s++;
        length++;
    }

    return length;
}

C 字符串并不会额外保存一个表示字符串长度的整数。

例如:

c 复制代码
H E L L O \0

在内存中,字符串的结束位置通过'\0'标识。

因此,这个简单版本的 strlen\texttt{strlen}strlen 必须从字符串起始地址开始,逐字节寻找终止符。

对于长度为 NNN 的字符串,需要检查约 NNN 个普通字符和末尾的终止符。

所以:

Tstrlen(N)=O(N) \boxed{T_{\mathrm{strlen}}(N)=O(N)} Tstrlen(N)=O(N)

现代标准库中的 strlen\texttt{strlen}strlen 可能使用更高效的批量读取或向量指令,并不一定逐字节执行上述循环,但对于普通的未知长度字符串,它仍然需要确定终止符的位置。

现在再看外层的 lower\mathrm{lower}lower。

假设字符串长度为:

N=5 N=5 N=5

那么循环变量依次为:

i=0,1,2,3,4 i=0,1,2,3,4 i=0,1,2,3,4

每次检查:

c 复制代码
i < strlen(s)

都会重新计算字符串长度。

因此:

text 复制代码
第 1 次:strlen(s) 扫描整个字符串
第 2 次:strlen(s) 再扫描整个字符串
第 3 次:strlen(s) 再扫描整个字符串
第 4 次:strlen(s) 再扫描整个字符串
第 5 次:strlen(s) 再扫描整个字符串

实际上,还存在最后一次用于判断循环结束的条件检查。

如果每次 strlen\texttt{strlen}strlen 都需要扫描约 NNN 个字符,而它又被调用约 NNN 次,那么总工作量约为:

N×N N\times N N×N

因此:

T(N)=O(N2) \boxed{T(N)=O(N^2)} T(N)=O(N2)

需要特别区分,这并不是因为字符大小写转换本身复杂。

每个字符的转换仍然只需要常数次操作。

真正造成二次复杂度的,是循环条件中反复调用了一个本身具有线性开销的函数。

更一般地说:

循环执行 N 次+每次执行 O(N) 的操作⇒O(N2) \boxed{ \text{循环执行 }N\text{ 次} + \text{每次执行 }O(N)\text{ 的操作} \Rightarrow O(N^2) } 循环执行 N 次+每次执行 O(N) 的操作⇒O(N2)

因此,阅读程序时不能只统计最外层有几个循环,还要分析循环内部和循环条件所调用函数的实际成本

代码移动与函数副作用

既然问题出在反复调用 strlen\texttt{strlen}strlen,那么最自然的优化就是将它移到循环外面:

c 复制代码
void lower(char *s)
{
    size_t i;
    size_t len = strlen(s);

    for (i = 0; i < len; i++)
        if (s[i] >= 'A' && s[i] <= 'Z')
            s[i] -= ('A' - 'a');
}

现在:

c 复制代码
size_t len = strlen(s);

只执行一次。

假设字符串长度为 NNN,第一次计算长度需要:

O(N) O(N) O(N)

后面的循环也需要:

O(N) O(N) O(N)

因此,总时间复杂度为:

T(N)=O(N)+O(N) T(N)=O(N)+O(N) T(N)=O(N)+O(N)

得到:

T(N)=O(N) \boxed{T(N)=O(N)} T(N)=O(N)

从性能曲线来看,优化后的版本随着字符串长度增加,运行时间基本呈线性增长。

因此,当输入长度增加一倍时:

T(2N)≈2T(N) T(2N)\approx2T(N) T(2N)≈2T(N)

与原来接近四倍的增长形成鲜明对比。

而这个优化并没有使用复杂的数据结构,也没有直接编写汇编指令。

它只是把一个重复进行的计算提前执行一次。

这正是前面学习的代码移动(Code Motion)。

到这里,一个更值得思考的问题出现了。

既然编译器已经能够自动识别循环不变量,为什么不能直接发现:

c 复制代码
strlen(s)

在循环中反复计算相同的字符串长度,然后自动将它移动到循环外部?

原因是:普通函数调用并不天然等价于一次没有副作用的数学运算。

例如,考虑:

c 复制代码
int count = 0;

int func(void)
{
    count++;
    return count;
}

这个函数每调用一次,都会修改全局变量:

count count count

因此:

c 复制代码
func();
func();
func();

与:

c 复制代码
int x = func();

只执行一次,在程序行为上完全不同。

这类会修改外部可观察状态的行为称为:

Side Effect(副作用) \boxed{\text{Side Effect(副作用)}} Side Effect(副作用)

函数还可能依赖外部状态,即使参数相同,也不保证返回相同结果。

例如:

c 复制代码
int state = 0;

int func(int x)
{
    return x + state;
}

即使每次传入的参数都是:

x=10 x=10 x=10

只要全局变量 state\mathrm{state}state 发生变化,返回值就可能不同。

所以,编译器不能简单认为:

f(x)=一个永远不变的值 f(x)=\text{一个永远不变的值} f(x)=一个永远不变的值

现在回到 strlen\texttt{strlen}strlen。

从程序员的角度,我们知道标准的 strlen\texttt{strlen}strlen 只是计算字符串长度。

而且,在当前 lower\mathrm{lower}lower 中,我们只是将大写字母转换成小写字母,没有改变字符串长度,也没有移动末尾的'\0'

因此,在这个具体例子中,提前计算长度是合理的。

但是,假如编译器无法了解被调用函数的具体实现,就可能不知道:

  • 函数是否修改全局变量。
  • 函数是否依赖其他外部状态。
  • 函数是否与循环体的内存写入发生相互影响。
  • 重复调用是否具有程序要求保留的可观察效果。

例如,我们可以人为定义一个具有副作用的长度计算函数:

c 复制代码
size_t lencnt = 0;

size_t my_strlen(const char *s)
{
    size_t length = 0;

    while (*s != '\0')
    {
        s++;
        length++;
    }

    lencnt += length;
    return length;
}

这里每次调用:

c 复制代码
my_strlen(s)

不仅返回字符串长度,还会更新:

lencnt lencnt lencnt

假设字符串长度为 555,如果循环重复调用该函数,那么全局变量也会被反复修改。

但如果直接把函数调用移动到循环外部,只调用一次,那么:

lencnt lencnt lencnt

最终保存的数值就会不同。

因此,即使循环体本身的最终字符串内容相同,整个程序的可观察行为也可能发生变化。

这就是为什么编译器必须考虑函数调用的副作用。

从编译器分析角度看,当它无法了解某个函数的内部行为时,就可能将其视为一个黑盒(Black Box)。

对于这样的调用,编译器通常需要采取更保守的优化策略。

当然,现代编译器并不是对所有函数调用都完全无能为力。

如果函数能够被内联(Inlining),或者编译器已经掌握某些标准库函数的语义,就可能识别其中的不变计算并进一步优化。

因此,这里的关键不是"编译器永远无法优化 strlen\texttt{strlen}strlen",而是:

只有当编译器能够证明移动函数调用不会改变程序语义时,才能安全地执行这种优化。

一种解决方法是让编译器看到函数实现,例如通过合适的内联与过程间分析。

另一种更直接的方法,则是像前面的 lower2\mathrm{lower2}lower2 一样,在源代码中明确写出:

c 复制代码
size_t len = strlen(s);

这样,程序员将自己已经知道的不变关系直接表达出来,使编译器不必再独立推断这一点。

内存别名与重复访存

前面讨论的是函数调用带来的优化障碍。

现在再看另一种情况:程序根本没有调用复杂函数,只是通过指针读写内存,但编译器仍然无法消除某些看起来多余的操作。

考虑一个计算矩阵每行元素之和的函数:

c 复制代码
/* Sum rows of n × n matrix a
   and store in vector b */

void sum_rows1(double *a, double *b, long n)
{
    long i, j;

    for (i = 0; i < n; i++)
    {
        b[i] = 0;

        for (j = 0; j < n; j++)
            b[i] += a[i*n+j];
    }
}

这里:

a a a

指向一个以连续内存保存的:

n×n n\times n n×n

矩阵。

b b b

指向一个用于保存每行求和结果的数组。

程序的目标是:

bi=∑j=0n−1ain+j bi=\sum_{j=0}^{n-1}ain+j bi=j=0∑n−1ain+j

例如:

c 复制代码
矩阵 A:

1  2  3
4  5  6
7  8  9

正常情况下,希望得到:

b=6,15,24 b=6,15,24 b=6,15,24

现在观察内层循环:

c 复制代码
for (j = 0; j < n; j++)
    b[i] += a[i*n+j];

每一次迭代,都会更新:

bi bi bi

因此,从 C 语言的操作顺序看:

c 复制代码
读取 b[i]
读取 a[i*n+j]
执行浮点加法
将结果写回 b[i]

对于这种实现,一组内层循环汇编为:

c 复制代码
.L4:
    movsd (%rsi,%rax,8), %xmm0
    addsd (%rdi), %xmm0
    movsd %xmm0, (%rsi,%rax,8)

    addq $8, %rdi
    cmpq %rcx, %rdi
    jne .L4

这里只需要关注前三条:

asm 复制代码
movsd (%rsi,%rax,8), %xmm0

首先从:

bi bi bi

所在的内存位置读取当前累加结果,放入:

%xmm0 \%xmm0 %xmm0

接下来:

asm 复制代码
addsd (%rdi), %xmm0

读取当前矩阵元素:

ain+j ain+j ain+j

并加到累加结果上。

最后:

asm 复制代码
movsd %xmm0, (%rsi,%rax,8)

将计算结果重新写回:

bi bi bi

因此,每次循环都包含对:

bi bi bi

的读取和写入。

但从程序员的直觉来看,我们完全可以使用一个局部变量累加结果:

c 复制代码
double val = 0;

只在内层循环结束后,再将最终结果写入:

bi bi bi

这样似乎就能避免大量重复的内存访问。

那么,编译器为什么没有自动完成这种优化?

关键在于两个指针a,b可能指向重叠的内存区域。

这种情况称为:

内存别名(Memory Aliasing)。

所谓别名,是指两个不同的内存访问表达式,最终可能引用同一个实际内存位置。

例如:

c 复制代码
int x = 10;

int *p = &x;
int *q = &x;

虽然:

p p p

和:

q q q

是不同的指针变量,但它们都保存:

&x \&x &x

因此:

c 复制代码
*p = 20;

会影响随后:

c 复制代码
printf("%d", *q);

读取到的值。

因为:

∗p *p ∗p

与:

∗q *q ∗q

实际上访问同一个整数对象。

对于 sum_rows1\mathrm{sum\_rows1}sum_rows1,也存在类似情况。

如果:

a a a

和:

b b b

指向完全不同的内存区域,那么内层循环重复读写 bibibi 似乎没有必要。

但是,如果它们的内存区域发生重叠,事情就不同了。

例如:

c 复制代码
double A[9] = {
     0,  1,   2,
     4,  8,  16,
    32, 64, 128
};

double *B = A + 3;

sum_rows1(A, B, 3);

这里的关键是:

c 复制代码
double *B = A + 3;

注意,它并没有创建一个新的数组。

B\mathrm{B}B 只是一个指针,指向数组 A\mathrm{A}A 的第 444 个元素。

因此:

B0=A3 B0=A3 B0=A3

B1=A4 B1=A4 B1=A4

B2=A5 B2=A5 B2=A5

初始内存为:

text 复制代码
A:

索引    0  1  2  3  4   5   6   7    8
数值    0  1  2  4  8  16  32  64  128
                │  │   │
                └──┴───┴── B 指向这里

因此,B\mathrm{B}B 的初始值为:

B=4,8,16 \boxed{B=4,8,16} B=4,8,16

现在我们按照函数的真实执行顺序计算。

首先:

i=0 i=0 i=0

程序执行:

c 复制代码
b[0] = 0;

由于:

b0=A3 b0=A3 b0=A3

所以,这实际上将:

A3 A3 A3

从 444 改成了 000。

随后,对第一行求和:

A0+A1+A2 A0+A1+A2 A0+A1+A2

即:

0+1+2=3 0+1+2=3 0+1+2=3

因此:

B=3,8,16 \boxed{B=3,8,16} B=3,8,16

这一步还比较符合我们对矩阵求和的预期。

接下来:

i=1 i=1 i=1

程序首先执行:

c 复制代码
b[1] = 0;

由于:

b1=A4 b1=A4 b1=A4

因此,这条语句直接把第二行中间的元素:

A4=8 A4=8 A4=8

改成了:

A4=0 A4=0 A4=0

随后开始计算第二行:

A3+A4+A5 A3+A4+A5 A3+A4+A5

但注意,此时矩阵已经发生变化。

第一轮求和已经将:

A3 A3 A3

修改为:

3 3 3

而本轮初始化又将:

A4 A4 A4

修改为:

0 0 0

所以第二行实际参与计算的是:

3+0+16=19 3+0+16=19 3+0+16=19

但问题还没有结束。

因为每一次执行:

c 复制代码
b[1] += a[1*n+j];

都会立即写回:

B1=A4 B1=A4 B1=A4

于是,当遍历到第二行中的后续元素时,前面产生的部分和又可能影响后面读取到的数据。

我们逐次模拟:

jjj 当前读取的矩阵元素 更新后的 B1B1B1
初始化 B1=0B1=0B1=0 000
000 A3=3A3=3A3=3 333
111 A4A4A4 已经是 333 666
222 A5=16A5=16A5=16 222222

因此,第二轮结束时:

B=3,22,16 \boxed{B=3,22,16} B=3,22,16

而不是简单地得到:

19 19 19

这里最关键的一步是:

当:

j=0 j=0 j=0

时,程序将:

B1 B1 B1

更新为 333。

但由于:

B1=A4 B1=A4 B1=A4

所以下一次:

j=1 j=1 j=1

读取:

A4 A4 A4

时,得到的已经是前一步写入的 333,而不是初始值 000 或 888。

同一个内存位置既是累加结果的存储位置,又是后续矩阵访问的输入位置。

这就是 Memory Aliasing 真正影响程序行为的地方。

最后:

i=2 i=2 i=2

同样的过程会影响第三行求和。

程序首先:

c 复制代码
b[2] = 0;

将:

A5 A5 A5

设置为 000。

随后第三行元素为:

32, 64, 128 32,\ 64,\ 128 32, 64, 128

因此正常累加得到:

32+64+128=224 32+64+128=224 32+64+128=224

最终:

B=3,22,224 \boxed{B=3,22,224} B=3,22,224

这正是该程序实际表现出的结果。

现在可以回到最初的问题:为什么编译器不能随便消除每次循环中的内存写入?

因为如果它将:

c 复制代码
b[i] += a[i*n+j];

转换为"只在寄存器中累加,最后再写一次",就可能改变刚才这种指针重叠情况下的计算过程。

因此,对于可能发生别名的内存访问,编译器不能简单假设写入某个地址不会影响之后从另一个表达式读取的数据。

这就是第二类优化障碍。

局部累加变量

既然编译器无法保证两个指针不发生别名,那么可以由程序员重新组织计算过程:

c 复制代码
void sum_rows2(double *a, double *b, long n)
{
    long i, j;

    for (i = 0; i < n; i++)
    {
        double val = 0;

        for (j = 0; j < n; j++)
            val += a[i*n+j];

        b[i] = val;
    }
}

与前面的函数相比,主要变化只有一个:

原来:

c 复制代码
b[i] += a[i*n+j];

现在:

c 复制代码
val += a[i*n+j];

也就是说,内层循环不再反复更新输出数组,而是先使用局部变量:

val val val

保存累加结果。

直到这一行的所有元素计算完成后,才执行:

c 复制代码
b[i] = val;

将最终结果写回输出数组。

从机器级角度看,如果编译器将:

val val val

分配到 XMM 寄存器中,就可以让整个内层循环都在寄存器中完成累加。

例如,一组优化后的内层循环为:

c 复制代码
.L10:
    addsd (%rdi), %xmm0
    addq  $8, %rdi
    cmpq  %rax, %rdi
    jne   .L10

注意,与前面的 sum_rows1\mathrm{sum\_rows1}sum_rows1 相比,这里不再出现每次循环都从:

bi bi bi

加载当前结果的:

asm 复制代码
movsd ..., %xmm0

也没有每次循环把累加结果写回:

bi bi bi

的:

asm 复制代码
movsd %xmm0, ...

而只保留:

asm 复制代码
addsd (%rdi), %xmm0

表示:

%xmm0←%xmm0+当前矩阵元素 \%xmm0\leftarrow\%xmm0+当前矩阵元素 %xmm0←%xmm0+当前矩阵元素

因此,累加结果可以持续保存在:

%xmm0 \%xmm0 %xmm0

中。

直到循环结束,再将结果写回:

bi bi bi

这样显著减少了内层循环的内存读写次数。

可以把两种实现对比如下:

实现 内层累加方式 对 bibibi 的访问
sum_rows1\mathrm{sum\_rows1}sum_rows1 每次通过内存中的 bibibi 累加 每次读取并写回
sum_rows2\mathrm{sum\_rows2}sum_rows2 通过局部变量 valvalval 累加 每行结束后写入一次

这里还需要澄清一个容易误解的地方。

引入局部变量 val,并不意味着编译器从此证明了 a 和 b 永远不会发生别名。

它真正改变的是程序的数据依赖关系。

在内层循环中,累加结果已经不需要通过:

bi bi bi

这个内存位置保存。

因此,即使:

a a a

与:

b b b

指向重叠区域,编译器也可以在这个新程序中按照局部累加的语义生成代码。

但这同时意味着,如果调用者故意传入重叠区域 ,sum_rows1\mathrm{sum\_rows1}sum_rows1 和 sum_rows2\mathrm{sum\_rows2}sum_rows2 未必具有相同结果。

例如,继续使用:

c 复制代码
double A[9] = {
     0,  1,   2,
     4,  8,  16,
    32, 64, 128
};

double *B = A + 3;

对于 sum_rows1\mathrm{sum\_rows1}sum_rows1,我们刚才得到:

B=3,22,224 B=3,22,224 B=3,22,224

而对于 sum_rows2\mathrm{sum\_rows2}sum_rows2:

第一行求和:

0+1+2=3 0+1+2=3 0+1+2=3

写入:

B0=A3=3 B0=A3=3 B0=A3=3

第二行开始时,数组已经包含:

A3=3 A3=3 A3=3

但由于第二行内部不再提前修改:

A4 A4 A4

所以本行累加的是:

3+8+16=27 3+8+16=27 3+8+16=27

得到:

B1=27 B1=27 B1=27

第三行:

32+64+128=224 32+64+128=224 32+64+128=224

最终:

B=3,27,224 \boxed{B=3,27,224} B=3,27,224

因此:

sum_rows1≠sum_rows2 \boxed{ sum\_rows1\ne sum\_rows2 } sum_rows1=sum_rows2

在这个别名输入下,两种程序并不等价。

这正好说明,之前编译器为什么不能擅自把 sum_rows1\mathrm{sum\_rows1}sum_rows1 改成 sum_rows2\mathrm{sum\_rows2}sum_rows2。

对于明确约定输入矩阵和输出数组不重叠的应用场景,局部累加是一种有效的优化方式。

但如果程序允许输入与输出重叠,并且要求完全保留原来的逐步更新语义,那么就不能忽略这种差异。

所以,Memory Aliasing 不只是一个让程序变慢的技术细节,还涉及优化前后的程序语义是否一致。

至此,函数调用与内存别名这两种优化障碍,可以归结为一个统一的问题:

编译器无法证明某些看起来重复的操作可以安全删除。

对于函数调用,它担心函数可能具有副作用,或者返回值依赖外部状态。

对于内存访问,它担心不同指针可能指向同一位置,从而使某次写入影响后续读取。

因此,编译器必须保守。

但程序员往往更清楚具体算法的数据关系和使用条件,可以通过调整源代码,让这些关系更加明确。

例如:

c 复制代码
size_t len = strlen(s);

明确表示字符串长度只需要计算一次。

又例如:

c 复制代码
double val = 0;

明确表示内层累加结果先保存在局部变量中,而不是每次都更新输出数组。

这些改动虽然简单,却可以帮助编译器生成更高效的机器代码。

不过,任何这样的优化都必须首先确认其适用前提,不能为了减少指令而改变程序原本需要保留的行为。

下一步就可以进一步分析:即使重复计算和内存访问已经得到优化,处理器的执行速度为什么仍然可能受到限制?

因为现代处理器具有同时执行多条指令的能力,但如果后一条指令必须等待前一条指令产生结果,那么这些指令仍然无法充分并行。

这就进入了程序优化的下一个核心主题:

指令级并行(Instruction-Level Parallelism,ILP)

CPE、指令级并行与数据依赖

前面我们已经知道,编译器优化通常需要在不改变程序行为的前提下,减少重复计算和不必要的内存访问。

例如,在矩阵求和函数中,我们通过局部变量 val\mathrm{val}val 保存累加结果,避免每次循环都将中间结果写回内存。

但减少机器指令数量,并不等于程序一定能够达到处理器的最高执行速度。

现代处理器并不是严格按照"执行完一条指令,再执行下一条指令"的方式工作。只要不同指令之间不存在必须等待的数据依赖,处理器就可能让多条指令在时间上重叠执行。

这种能力称为指令级并行(Instruction-Level Parallelism,ILP)。

要理解它对程序优化的影响,我们先建立一个能够衡量循环执行效率的指标,再分析现代处理器如何安排指令。

CPE:衡量循环的实际效率

考虑一个非常常见的操作:计算向量中所有元素的和或乘积。

例如:

c 复制代码
int a[5] = {1, 2, 3, 4, 5};

如果计算求和:

1+2+3+4+5=15 1+2+3+4+5=15 1+2+3+4+5=15

如果计算乘积:

1×2×3×4×5=120 1\times2\times3\times4\times5=120 1×2×3×4×5=120

虽然两种运算的循环结构基本相同,但加法与乘法的机器指令可能具有不同的执行延迟,因此实际性能可能不同。

为了研究这个问题,定义一个向量类型:

c 复制代码
typedef struct {
    size_t len;
    data_t *data;
} vec;

typedef vec *vec_ptr;

这里:

len len len

表示向量中元素的数量,而:

data data data

指向实际存储数据的数组。

假设:

c 复制代码
vec v;

并且:

v.len=5 v.len=5 v.len=5

那么:

c 复制代码
v.data

指向包含五个元素的连续内存区域。

这种设计与前面学习的结构体和指针完全一致:结构体本身保存长度和数据指针,而实际数组可以位于另一块内存中。

为了方便研究不同类型的运算,将元素类型抽象为:

c 复制代码
data_t

它可以分别定义为:

c 复制代码
int
long
float
double

同时使用两个宏表示运算符和初始值:

运算 OP\mathrm{OP}OP IDENT\mathrm{IDENT}IDENT
求和 +++ 000
求积 ∗*∗ 111

这里的 IDENT\mathrm{IDENT}IDENT 称为单位元。

对于加法:

x+0=x x+0=x x+0=x

因此,加法的单位元是 000。

对于乘法:

x×1=x x\times1=x x×1=x

因此,乘法的单位元是 111。

这样,我们就能够使用同一份循环结构,分别测试整数加法、整数乘法、浮点加法和浮点乘法。

先考虑最直接的实现:

c 复制代码
void combine1(vec_ptr v, data_t *dest)
{
    long i;
    *dest = IDENT;

    for (i = 0; i < vec_length(v); i++)
    {
        data_t val;
        get_vec_element(v, i, &val);
        *dest = *dest OP val;
    }
}

这段代码的执行过程非常直接。

首先:

c 复制代码
*dest = IDENT;

初始化累加结果。

随后,每次循环通过:

c 复制代码
get_vec_element(v, i, &val);

读取向量的第 iii 个元素。

最后:

c 复制代码
*dest = *dest OP val;

将新元素合并到当前结果中。

如果:

OP=+ OP=+ OP=+

那么:

∗dest←∗dest+val *dest\leftarrow*dest+val ∗dest←∗dest+val

如果:

OP=∗ OP=* OP=∗

那么:

∗dest←∗dest×val *dest\leftarrow*dest\times val ∗dest←∗dest×val

但是,这个函数包含几个我们已经熟悉的性能问题。

首先:

c 复制代码
vec_length(v)

位于循环条件中,如果编译器无法消除重复调用,就会产生额外开销。

其次:

c 复制代码
get_vec_element(v, i, &val)

每次都要调用一个函数获取元素,并进行边界检查。

最后:

c 复制代码
*dest = *dest OP val;

每轮循环都可能涉及累加结果的内存读取和写入。

因此,程序执行一次有效加法或乘法,实际可能还需要完成许多辅助操作

问题是:怎样客观地衡量这些额外操作对程序性能的影响?

一个很有用的指标是:

CPE=Cycles Per Element \boxed{\mathrm{CPE}=\text{Cycles Per Element}} CPE=Cycles Per Element

即每处理一个元素平均需要多少个处理器时钟周期。

假设向量包含:

n n n

个元素。

程序总执行周期数为:

T(n) T(n) T(n)

当输入规模较大时,可以近似表示为:

T(n)=CPE×n+Overhead \boxed{T(n)=CPE\times n+Overhead} T(n)=CPE×n+Overhead

其中:

  • CPECPECPE 表示处理每个元素的平均周期成本。
  • OverheadOverheadOverhead 表示与元素数量无关或相对固定的额外开销。

因此,如果绘制:

n n n

与:

T(n) T(n) T(n)

之间的关系,得到近似直线:

T(n)=kn+b T(n)=kn+b T(n)=kn+b

那么直线斜率:

k=CPE \boxed{k=CPE} k=CPE

例如,对于两种不同实现:

T1(n)=9n+100 T_1(n)=9n+100 T1(n)=9n+100

T2(n)=6n+100 T_2(n)=6n+100 T2(n)=6n+100

它们的 CPE 分别为:

CPE1=9 CPE_1=9 CPE1=9

CPE2=6 CPE_2=6 CPE2=6

这表示,当输入足够大时,第二种实现平均每处理一个元素,比第一种少消耗约三个周期。

这里的 CPE 不是某一条机器指令固定消耗的周期数,而是整个循环在特定处理器上执行时,平均到每个元素的周期数。

尤其需要注意:

CPE<1 CPE<1 CPE<1

在现代处理器上也完全可能出现。

这并不意味着 CPU 能在不到一个时钟周期内完成某条指令的全部执行阶段,而是意味着多条指令可以重叠执行,使平均每元素成本低于一个周期。

现在观察 combine1\mathrm{combine1}combine1 的实际性能。

在所使用的测试环境中,得到以下结果:

实现 整数加法 整数乘法 双精度加法 双精度乘法
未优化的 combine1\mathrm{combine1}combine1 22.68 20.02 19.98 20.18
-O1\texttt{-O1}-O1 优化后的 combine1\mathrm{combine1}combine1 10.12 10.12 10.17 11.14

单位:Cycles Per Element

首先,可以看到启用:

bash 复制代码
gcc -O1

后,CPE 明显下降。

这说明编译器通过寄存器分配、指令选择和其他优化,减少了部分运行开销。

但即使如此,CPE 仍然在:

10 10 10

左右。

这意味着,处理每个元素平均仍然需要大约十个时钟周期。

我们已经知道,向量求和本身只需要一次加法,求积本身也只需要一次乘法。

因此,显然还有大量时间消耗在其他操作上。

要继续优化,就必须回到前面学习的代码移动、消除冗余检查和局部变量累加。

现在将函数改写为:

c 复制代码
void combine4(vec_ptr v, data_t *dest)
{
    long i;
    long length = vec_length(v);
    data_t *d = get_vec_start(v);
    data_t t = IDENT;

    for (i = 0; i < length; i++)
        t = t OP d[i];

    *dest = t;
}

这里有三个非常重要的变化。

首先:

c 复制代码
long length = vec_length(v);

将向量长度提前读取到循环外部。

因此,不需要在每次循环中重新调用长度查询函数。

其次:

c 复制代码
data_t *d = get_vec_start(v);

直接获取数据数组的起始地址。

这样,循环内部就可以使用:

c 复制代码
d[i]

访问元素,而不再通过:

c 复制代码
get_vec_element()

反复进行函数调用和边界检查。

最后:

c 复制代码
data_t t = IDENT;

定义局部累加变量。

在整个循环中:

c 复制代码
t = t OP d[i];

都只更新局部结果。

直到循环结束后,再执行:

c 复制代码
*dest = t;

将结果写回目标地址。

这避免了每处理一个元素就读写一次:

∗dest *dest ∗dest

所带来的开销。

因此,优化后的循环本质上只需要:

t←t OP di t\leftarrow t\ OP\ di t←t OP di

以及必要的循环控制操作。

现在观察性能变化:

实现 整数加法 整数乘法 双精度加法 双精度乘法
combine1\mathrm{combine1}combine1,-O1\texttt{-O1}-O1 10.12 10.12 10.17 11.14
combine4\mathrm{combine4}combine4 1.27 3.01 3.01 5.01

可以看到,优化后的整数加法 CPE 从:

10.12 10.12 10.12

下降到:

1.27 1.27 1.27

整数乘法从:

10.12 10.12 10.12

下降到:

3.01 3.01 3.01

双精度加法从:

10.17 10.17 10.17

下降到:

3.01 3.01 3.01

双精度乘法从:

11.14 11.14 11.14

下降到:

5.01 5.01 5.01

这表明,消除重复函数调用、边界检查和多余内存访问后,程序性能有了非常明显的改善。

但现在又出现一个新的现象:

为什么整数加法能够接近每元素一个周期,而双精度乘法仍然需要约五个周期?

如果我们已经消除了大部分无关开销,为什么不同运算之间还存在如此明显的差距?

答案与处理器执行指令的方式有关。

指令级并行:延迟与吞吐量

我们以前分析汇编代码时,通常是按照程序顺序,一条一条理解指令。

例如:

c 复制代码
long mult_eg(long a, long b, long c)
{
    long p1 = a * b;
    long p2 = a * c;
    long p3 = p1 * p2;

    return p3;
}

从 C 语言来看,程序执行三个乘法:

p1=ab p_1=ab p1=ab

p2=ac p_2=ac p2=ac

p3=p1p2 p_3=p_1p_2 p3=p1p2

如果假设一条乘法指令需要三个周期,而 CPU 只能一次执行一条指令,那么总时间似乎是:

3+3+3=9 3+3+3=9 3+3+3=9

个周期。

但现代处理器通常具备流水线(Pipelining) 能力。

为了理解流水线,假设一个乘法单元的执行过程可以划分为三个阶段:

Stage1→Stage2→Stage3 Stage_1\rightarrow Stage_2\rightarrow Stage_3 Stage1→Stage2→Stage3

每个阶段需要一个周期。

因此,一条乘法需要经过三个阶段才能产生结果。

但关键是:第一条乘法进入第二阶段之后,第一阶段就可以开始处理另一条彼此独立的乘法。

例如:

时钟周期 阶段 1 阶段 2 阶段 3
111 a×ba\times ba×b --- ---
222 a×ca\times ca×c a×ba\times ba×b ---
333 --- a×ca\times ca×c a×ba\times ba×b
444 --- --- a×ca\times ca×c
555 p1×p2p_1\times p_2p1×p2 --- ---
666 --- p1×p2p_1\times p_2p1×p2 ---
777 --- --- p1×p2p_1\times p_2p1×p2

在这个简化模型中,前两个乘法彼此独立,因此可以重叠执行。

但第三个乘法:

p3=p1p2 p_3=p_1p_2 p3=p1p2

必须等待前两个结果都可用。

所以,第三个乘法最早只能在:

p1, p2 p_1,\ p_2 p1, p2

都已经计算完成之后开始。

最终,这三个乘法可以在大约七个周期内完成,而不是九个。

这就是流水线提高执行效率的基本原理。

需要特别区分两个概念。

延迟(Latency) 指一条指令从开始执行,到其结果可以供依赖指令使用所需要的周期数。

例如:

Latencymul=3 Latency_{mul}=3 Latencymul=3

表示乘法结果需要经过三个周期才能可用。

吞吐量(Throughput) 则表示处理器在持续执行大量独立指令时,单位时间内能够开始或完成多少条指令。

例如,一个乘法单元可能需要三个周期才能得到单次乘法结果,但它可以每个周期接收一条新的独立乘法。

因此:

Latency=3 Latency=3 Latency=3

并不意味着每隔三个周期才能开始一条乘法。

如果流水线允许每个周期接收一条新指令,就可以做到:

每周期发起一条乘法 \boxed{\text{每周期发起一条乘法}} 每周期发起一条乘法

两者的区别可以理解为:

延迟描述单次运算需要多久得到结果,吞吐量描述连续处理大量独立运算时能够达到多高的速度。

这也是后面分析循环性能的关键。

现代处理器还具有另一种能力:超标量(Superscalar)执行。

所谓超标量处理器,是指它能够在一个时钟周期内发射并执行多条指令,而不只是让不同指令在同一个流水线中重叠。

例如,处理器内部可能包含不同类型的执行单元:

  • 整数运算单元。
  • 浮点加法单元。
  • 浮点乘法单元。
  • 加载单元。
  • 存储单元。
  • 分支处理相关单元。

因此,只要指令之间没有必须等待的数据依赖,并且相关执行资源可用,就可能同时执行多种不同的操作。

例如:

c 复制代码
x = a + b;
y = c * d;

如果两个表达式彼此独立,处理器可能同时安排加法和乘法。

但:

c 复制代码
x = a + b;
y = x * d;

就不同了。

因为第二条计算必须使用第一条计算产生的:

x x x

所以存在:

数据依赖(Data Dependency) \boxed{\text{数据依赖(Data Dependency)}} 数据依赖(Data Dependency)

即:

a+b⟶x⟶x×d a+b \longrightarrow x \longrightarrow x\times d a+b⟶x⟶x×d

第二条计算不能在需要的结果尚未产生时完成。

这说明,现代 CPU 虽然具有强大的并行执行能力,但程序中的数据依赖仍然可能限制实际执行速度。

以 Intel Haswell 处理器为例,相关运算的典型延迟和发射间隔如下:

运算类型 延迟(周期) 发射间隔(周期)
加载 / 存储 约 444 111
整数乘法 333 111
整数除法 333~303030 333~303030
单 / 双精度浮点乘法 555 111
单 / 双精度浮点加法 333 111
单 / 双精度浮点除法 333~151515 333~151515

这些是针对该处理器及具体运算模型给出的数值,不能直接视为所有现代 CPU 的固定性能参数。

但它们展示了一个重要现象:

对于整数乘法:

Latency=3 Latency=3 Latency=3

同时:

IssueInterval=1 IssueInterval=1 IssueInterval=1

也就是说,单次乘法结果需要约三个周期才能可用,但如果有大量彼此独立的乘法,处理器可能以每周期一条的速率持续发起运算。

因此,当程序能够提供足够多的独立指令时,实际平均 CPE 可能接近吞吐量限制。

而如果运算之间存在连续依赖,那么程序就可能受到指令延迟限制。

这两种限制分别称为:

Latency Bound \boxed{\text{Latency Bound}} Latency Bound

与:

Throughput Bound \boxed{\text{Throughput Bound}} Throughput Bound

串行依赖:累加器成为性能瓶颈

现在我们回到已经优化过的:

c 复制代码
void combine4(vec_ptr v, data_t *dest)
{
    long i;
    long length = vec_length(v);
    data_t *d = get_vec_start(v);
    data_t t = IDENT;

    for (i = 0; i < length; i++)
        t = t OP d[i];

    *dest = t;
}

假设:

OP=∗ OP=* OP=∗

那么内层循环就是:

c 复制代码
t = t * d[i];

编译后,对整数乘法的一种核心汇编为:

c 复制代码
.L519:
    imull (%rax,%rdx,4), %ecx
    addq  $1, %rdx
    cmpq  %rdx, %rbp
    jg    .L519

首先:

c 复制代码
imull (%rax,%rdx,4), %ecx

表示从数组中读取:

di di di

然后执行:

t←t×di \boxed{t\leftarrow t\times di} t←t×di

其中,累乘结果保存在:

%ecx \%ecx %ecx

中。

随后:

c 复制代码
addq $1, %rdx

更新循环下标。

再通过:

c 复制代码
cmpq %rdx, %rbp
jg .L519

判断是否继续循环。

可以发现,内层循环已经非常简洁。

没有重复的函数调用。

没有每次检查数组边界的额外函数。

也没有反复把累加结果写回内存。

但是,性能测量仍然显示:

运算 combine4\mathrm{combine4}combine4 的 CPE
整数加法 1.271.271.27
整数乘法 3.013.013.01
双精度浮点加法 3.013.013.01
双精度浮点乘法 5.015.015.01

现在将这些数值与 Haswell 的典型指令延迟比较:

运算 CPE 运算延迟
整数加法 1.271.271.27 111
整数乘法 3.013.013.01 333
双精度浮点加法 3.013.013.01 333
双精度浮点乘法 5.015.015.01 555

可以发现,除整数加法还有一些额外开销外,其余三种运算的 CPE 几乎与对应运算延迟相同。

为什么会这样?

原因在于:

c 复制代码
t = t * d[i];

包含一个连续的数据依赖。

假设向量包含:

d0,d1,d2,d3 d0,d1,d2,d3 d0,d1,d2,d3

并且初始值:

t=1 t=1 t=1

那么计算过程为:

t1=1×d0 t_1=1\times d0 t1=1×d0

t2=t1×d1 t_2=t_1\times d1 t2=t1×d1

t3=t2×d2 t_3=t_2\times d2 t3=t2×d2

t4=t3×d3 t_4=t_3\times d3 t4=t3×d3

注意:

t2 t_2 t2

必须等待:

t1 t_1 t1

计算完成。

同样:

t3 t_3 t3

必须等待:

t2 t_2 t2

计算完成。

因此:

t1→t2→t3→t4 \boxed{ t_1\rightarrow t_2\rightarrow t_3\rightarrow t_4 } t1→t2→t3→t4

形成一条连续的数据依赖链。

对于八个元素,完整表达式为:

((((((((1×d0)×d1)×d2)×d3)×d4)×d5)×d6)×d7) ((((((((1\times d0)\times d1) \times d2)\times d3) \times d4)\times d5) \times d6)\times d7) ((((((((1×d0)×d1)×d2)×d3)×d4)×d5)×d6)×d7)

我们可以将它表示为:

c 复制代码
t = 1;

t = t * d[0];  // 必须先完成
t = t * d[1];  // 依赖上一轮的 t
t = t * d[2];  // 依赖上一轮的 t
t = t * d[3];  // 依赖上一轮的 t
t = t * d[4];
t = t * d[5];
t = t * d[6];
t = t * d[7];

虽然处理器的乘法单元理论上能够每个周期接收新的独立乘法,但这里的每次乘法都要使用前一次乘法的结果。

因此,下一次乘法不能像独立乘法那样立即开始完成它的依赖计算。

如果一条乘法需要:

3 3 3

个周期才能产生结果,那么下一次依赖这个结果的乘法也必须等待相应的延迟。

所以,即使乘法单元具有较高吞吐量,整个循环仍然可能达到:

CPE≈3 \boxed{CPE\approx3} CPE≈3

对于双精度浮点乘法:

Latency=5 Latency=5 Latency=5

则可能得到:

CPE≈5 \boxed{CPE\approx5} CPE≈5

这正好与前面的性能数据相符。

因此,真正限制这个循环性能的,不再是函数调用或内存读写开销,而是累加结果形成的连续数据依赖链。

这就是延迟限制(Latency Bound)的典型例子。

到这里,我们可以将整个优化过程串联起来。

最初的 combine1\mathrm{combine1}combine1 包含大量与实际运算无关的开销,例如重复调用向量长度函数、逐元素获取函数和反复更新内存中的累加结果。

经过基本优化后,combine4\mathrm{combine4}combine4 已经把这些开销大幅削减,使 CPE 接近实际运算指令的延迟。

但是,程序仍然使用一个累加变量:

t t t

每一轮都依赖上一轮的结果。

因此,处理器虽然具备流水线和超标量执行能力,却无法充分发挥独立乘法的吞吐能力。

这也解释了一个非常重要的性能优化原则:

减少指令数量只是第一步。要进一步提升性能,还必须减少关键路径上的数据依赖,让处理器能够同时执行更多彼此独立的运算。

接下来,我们就可以利用这个结论,继续研究循环展开(Loop Unrolling)。

但要特别注意,仅仅把两次循环写在一起,不一定能够打破累加器的数据依赖。 真正能够进一步改善吞吐量的关键,是通过重新组织计算顺序,或者使用多个独立累加器,让处理器拥有足够多的独立运算可以并行执行。

循环展开、重新结合与多累加器并行

前面我们已经将向量合并函数优化为:

c 复制代码
void combine4(vec_ptr v, data_t *dest)
{
    long i;
    long length = vec_length(v);
    data_t *d = get_vec_start(v);
    data_t x = IDENT;

    for (i = 0; i < length; i++)
        x = x OP d[i];

    *dest = x;
}

这段代码已经消除了重复函数调用、冗余边界检查和不必要的累加结果访存。

但其内层循环仍然存在一个明显的限制:

c 复制代码
x = x OP d[i];

每次计算都依赖上一次计算产生的 xxx。

假设执行的是双精度乘法,并且每次乘法结果需要 555 个周期才能用于下一次乘法,那么处理器即使能够每个周期发起新的独立乘法,也无法让这条依赖链以相同速度推进。

因此:

CPE≈5.00 \boxed{CPE\approx5.00} CPE≈5.00

要继续提升性能,就不能只考虑减少循环中的指令数量,还需要改变运算之间的依赖关系。

循环展开

最直接的方法是循环展开(Loop Unrolling)。

考虑原来的循环:

c 复制代码
for (i = 0; i < length; i++)
    x = x OP d[i];

每次循环处理一个元素,需要执行一次合并运算,再更新循环变量并检查终止条件。

如果每次循环处理两个元素,就可以减少一部分循环控制开销。

对应实现为:

c 复制代码
void unroll2a_combine(vec_ptr v, data_t *dest)
{
    long length = vec_length(v);
    long limit = length - 1;
    data_t *d = get_vec_start(v);
    data_t x = IDENT;
    long i;

    /* 每次循环处理两个元素 */
    for (i = 0; i < limit; i += 2)
    {
        x = (x OP d[i]) OP d[i+1];
    }

    /* 处理剩余元素 */
    for (; i < length; i++)
    {
        x = x OP d[i];
    }

    *dest = x;
}

这种方式称为:

2×1 Loop Unrolling \boxed{\text{2×1 Loop Unrolling}} 2×1 Loop Unrolling

其中,222 表示每轮处理两个元素,111 表示仍然使用一个累加变量。

首先分析循环边界:

c 复制代码
long limit = length - 1;

主循环使用:

c 复制代码
i < limit

并且:

c 复制代码
i += 2

这是因为每次都需要访问:

c 复制代码
d[i]
d[i+1]

所以必须保证这两个下标都有效。

例如:

length=5 length=5 length=5

那么:

limit=4 limit=4 limit=4

主循环处理:

text 复制代码
第 1 轮:d[0]、d[1]
第 2 轮:d[2]、d[3]

此时:

i=4 i=4 i=4

不再满足:

i<4 i<4 i<4

于是退出主循环。

随后,第二个循环处理:

c 复制代码
d[4]

因此,所有元素都得到处理。

如果数组长度为偶数,第二个循环通常不需要执行。

如果长度为奇数,它负责处理最后一个剩余元素。

这里还需要注意一个 C 语言细节:这个示例假设长度处于合理的非负范围,并且计算 length - 1 不会产生有符号溢出。实际编写通用库函数时,应当结合长度类型和边界条件进行处理。

现在观察主循环:

c 复制代码
x = (x OP d[i]) OP d[i+1];

原来每处理两个元素,需要完成两次循环迭代。

现在只需要一次。

因此,循环变量更新次数和循环条件判断次数都减少了。

但关键问题是:这种方式是否真正消除了累加器的数据依赖?

先观察性能:

实现 整数加法 整数乘法 双精度加法 双精度乘法
combine4\mathrm{combine4}combine4 1.271.271.27 3.013.013.01 3.013.013.01 5.015.015.01
Unroll 2×1 1.011.011.01 3.013.013.01 3.013.013.01 5.015.015.01
运算延迟下界 1.001.001.00 3.003.003.00 3.003.003.00 5.005.005.00

单位:Cycles Per Element

结果非常有意思。

整数加法从:

1.27 1.27 1.27

下降到:

1.01 1.01 1.01

说明循环展开减少了循环控制开销,使性能更加接近整数加法的延迟下界。

但整数乘法仍然是:

3.01 3.01 3.01

双精度加法仍然是:

3.01 3.01 3.01

双精度乘法仍然是:

5.01 5.01 5.01

几乎没有任何改善。

原因就在于:

c 复制代码
x = (x OP d[i]) OP d[i+1];

虽然一轮处理两个元素,但两次运算仍然是连续依赖的。

假设:

OP=× OP=\times OP=×

那么:

x′=x×di x'=x\times di x′=x×di

随后:

x′′=x′×di+1 x''=x'\times di+1 x′′=x′×di+1

第二次乘法必须等待第一次乘法产生:

x′ x' x′

所以依赖关系仍然为:

text 复制代码
x
│
▼
× d[i]
│
▼
× d[i+1]
│
▼
下一轮 × d[i+2]

循环展开减少了循环控制指令,却没有缩短累加运算的关键依赖路径。

因此,当循环主要受运算延迟限制时,仅仅展开循环并不能突破该限制。

重新结合与独立累加器

既然简单的循环展开无法消除串行依赖,那么接下来需要考虑:能否调整运算之间的结合关系?

原来的循环为:

c 复制代码
x = (x OP d[i]) OP d[i+1];

现在改为:

c 复制代码
x = x OP (d[i] OP d[i+1]);

完整函数中的其他部分保持不变:

c 复制代码
void unroll2aa_combine(vec_ptr v, data_t *dest)
{
    long length = vec_length(v);
    long limit = length - 1;
    data_t *d = get_vec_start(v);
    data_t x = IDENT;
    long i;

    for (i = 0; i < limit; i += 2)
    {
        x = x OP (d[i] OP d[i+1]);
    }

    for (; i < length; i++)
    {
        x = x OP d[i];
    }

    *dest = x;
}

这种优化称为:

重新结合(Reassociation)。

从表面上看,只是改变了括号位置。

但对处理器来说,运算之间的依赖关系发生了明显变化。

先看原来的表达式:

(x×d0)×d1 (x\times d_0)\times d_1 (x×d0)×d1

其计算必须遵循:

x×d0→结果×d1 x\times d_0 \rightarrow 结果\times d_1 x×d0→结果×d1

两次乘法串行依赖。

而改写为:

x×(d0×d1) x\times(d_0\times d_1) x×(d0×d1)

之后:

d0×d1 d_0\times d_1 d0×d1

不再依赖当前累加器 xxx。

因此,处理器可以提前计算这两个数组元素的乘积。

假设后续还有:

d2,d3,d4,d5 d_2,d_3,d_4,d_5 d2,d3,d4,d5

那么:

d0d1 d_0d_1 d0d1

d2d3 d_2d_3 d2d3

d4d5 d_4d_5 d4d5

这些相邻元素的乘积彼此独立。

只要处理器执行资源允许,就可以让这些乘法重叠执行。

真正需要串行更新的部分变成:

x1=x0(d0d1) x_1=x_0(d_0d_1) x1=x0(d0d1)

x2=x1(d2d3) x_2=x_1(d_2d_3) x2=x1(d2d3)

x3=x2(d4d5) x_3=x_2(d_4d_5) x3=x2(d4d5)

与原来的每处理一个元素就依赖前一次结果相比,现在每处理两个元素,才需要进行一次依赖前一轮结果的累加乘法。

因此,关键依赖链的长度约减少了一半。

假设单次运算延迟为:

D D D

而需要处理:

N N N

个元素。

原来的串行依赖路径约需要:

ND ND ND

个周期。

重新结合后,主要累加依赖路径约包含:

N2 \frac{N}{2} 2N

次运算。

忽略循环开始与结束的固定开销,得到:

T(N)≈N2D T(N)\approx\frac{N}{2}D T(N)≈2ND

因此:

CPE≈D2 \boxed{CPE\approx\frac{D}{2}} CPE≈2D

例如,双精度乘法延迟为:

D=5 D=5 D=5

那么:

CPE≈52=2.5 CPE\approx\frac{5}{2}=2.5 CPE≈25=2.5

这与实际测量结果非常接近。

实现 整数加法 整数乘法 双精度加法 双精度乘法
Combine4 1.271.271.27 3.013.013.01 3.013.013.01 5.015.015.01
Unroll 2×1 1.011.011.01 3.013.013.01 3.013.013.01 5.015.015.01
Unroll 2×1a 1.011.011.01 1.511.511.51 1.511.511.51 2.512.512.51

可以发现,整数乘法、双精度加法和双精度乘法的 CPE 都接近下降到原来的一半。

整数加法的改善则不明显,因为它的延迟只有约一个周期,而且经过普通循环展开后,已经接近当前实现的延迟下界。

这说明,重新结合优化真正改善的不是循环控制,而是缩短了串行数据依赖链。

但这种方法存在一个必须认真对待的问题。

对于浮点数,改变运算结合顺序,可能改变最终结果。

我们之前已经知道,IEEE 754 浮点运算通常需要执行舍入。

因此:

(a+b)+c (a+b)+c (a+b)+c

不一定等于:

a+(b+c) a+(b+c) a+(b+c)

例如,使用单精度浮点数:

a=1020,b=−1020,c=3 a=10^{20},\qquad b=-10^{20},\qquad c=3 a=1020,b=−1020,c=3

按照第一种顺序:

(a+b)+c (a+b)+c (a+b)+c

先计算:

1020+(−1020)=0 10^{20}+(-10^{20})=0 1020+(−1020)=0

然后:

0+3=3 0+3=3 0+3=3

但按照第二种顺序:

a+(b+c) a+(b+c) a+(b+c)

先计算:

−1020+3 -10^{20}+3 −1020+3

由于 333 相对于 102010^{20}1020 过小,在单精度浮点表示中可能被舍入掉。

因此:

b+c≈−1020 b+c\approx-10^{20} b+c≈−1020

最后:

a+(b+c)≈0 a+(b+c)\approx0 a+(b+c)≈0

所以:

(a+b)+c≠a+(b+c) \boxed{(a+b)+c\ne a+(b+c)} (a+b)+c=a+(b+c)

这说明,浮点运算一般不满足严格的结合律。

同样,对于浮点乘法,改变结合顺序也可能因为舍入、上溢、下溢等问题改变结果。

因此,虽然重新结合能够提升性能,但并不意味着编译器可以在所有情况下自动改变浮点表达式的计算顺序。

整数运算也需要考虑具体语义。对于无符号整数,运算遵循模 2w2^w2w 的规则,因此加法和乘法的重新结合具有较好的代数基础;对于有符号整数,则需要考虑溢出的语言规则。

性能优化必须首先满足程序对数值结果的要求。

如果程序要求严格保留原来的浮点运算顺序,那么这种重新结合优化就不能随意使用。


现在进一步考虑另一种打破依赖链的方法。

既然一个累加器:

x x x

会形成一条串行依赖链,那么能否使用两个累加器,让它们分别处理不同的元素?

答案是可以。

例如:

c 复制代码
void unroll2a_combine(vec_ptr v, data_t *dest)
{
    long length = vec_length(v);
    long limit = length - 1;
    data_t *d = get_vec_start(v);

    data_t x0 = IDENT;
    data_t x1 = IDENT;
    long i;

    for (i = 0; i < limit; i += 2)
    {
        x0 = x0 OP d[i];
        x1 = x1 OP d[i+1];
    }

    for (; i < length; i++)
    {
        x0 = x0 OP d[i];
    }

    *dest = x0 OP x1;
}

这里最重要的变化是:

c 复制代码
data_t x0 = IDENT;
data_t x1 = IDENT;

我们不再使用一个累加变量,而是创建两个独立累加器。

随后:

c 复制代码
x0 = x0 OP d[i];
x1 = x1 OP d[i+1];

让第一个累加器处理偶数位置的元素,第二个累加器处理奇数位置的元素。

假设:

OP=× OP=\times OP=×

并且向量包含八个元素:

d0,d1,...,d7 d_0,d_1,\ldots,d_7 d0,d1,...,d7

那么第一个累加器的计算过程为:

x0=1 x_0=1 x0=1

x0←x0d0 x_0\leftarrow x_0d_0 x0←x0d0

x0←x0d2 x_0\leftarrow x_0d_2 x0←x0d2

x0←x0d4 x_0\leftarrow x_0d_4 x0←x0d4

x0←x0d6 x_0\leftarrow x_0d_6 x0←x0d6

而第二个累加器的计算过程为:

x1=1 x_1=1 x1=1

x1←x1d1 x_1\leftarrow x_1d_1 x1←x1d1

x1←x1d3 x_1\leftarrow x_1d_3 x1←x1d3

x1←x1d5 x_1\leftarrow x_1d_5 x1←x1d5

x1←x1d7 x_1\leftarrow x_1d_7 x1←x1d7

因此,可以得到两条独立的依赖链:

text 复制代码
第一条:

1 → ×d0 → ×d2 → ×d4 → ×d6
                              \
                               最终合并
                              /
第二条:

1 → ×d1 → ×d3 → ×d5 → ×d7

这里的关键是:

第一条依赖链不需要等待第二条依赖链,第二条依赖链也不需要等待第一条依赖链。

因此,处理器可以利用不同的执行阶段,重叠安排两条独立的运算流。

只有在循环结束后,才需要执行:

c 复制代码
*dest = x0 OP x1;

将两个部分结果合并。

这就把原来一条长度约为:

N N N

的依赖链,拆分为两条长度约为:

N2 \frac{N}{2} 2N

的依赖链。

如果单次运算延迟为:

D D D

并且硬件具有足够的执行资源,那么主要依赖路径的时间约为:

N2D \frac{N}{2}D 2ND

因此,同样可以得到:

CPE≈D2 \boxed{CPE\approx\frac{D}{2}} CPE≈2D

不过,这种估算需要满足吞吐量资源足够、内存访问不是更主要的瓶颈等条件。

与重新结合方式相比,多累加器具有一个明显特点:它直接将元素划分到不同的累加器中,而不是在每次循环中先计算相邻两个元素之间的结果。

因此,两种方案虽然都能够缩短依赖链,但具体运算次序和浮点舍入行为并不完全相同。

对浮点求和或求积而言,多累加器仍然可能改变结果的最后若干位。

现在观察性能:

实现 整数加法 整数乘法 双精度加法 双精度乘法
Combine4 1.271.271.27 3.013.013.01 3.013.013.01 5.015.015.01
Unroll 2×1 1.011.011.01 3.013.013.01 3.013.013.01 5.015.015.01
Unroll 2×1a 1.011.011.01 1.511.511.51 1.511.511.51 2.512.512.51
Unroll 2×2 0.810.810.81 1.511.511.51 1.511.511.51 2.512.512.51

可以发现:

整数乘法:

CPE=1.51 CPE=1.51 CPE=1.51

双精度加法:

CPE=1.51 CPE=1.51 CPE=1.51

双精度乘法:

CPE=2.51 CPE=2.51 CPE=2.51

都与两个独立依赖链的理论预测接近。

而整数加法进一步下降到:

0.81 0.81 0.81

这说明,当存在足够多独立运算时,处理器能够更充分地利用并行执行资源。

展开因子与性能上限

既然两个累加器能够缩短依赖链,那么继续增加累加器数量,是否可以进一步提升性能?

这就需要引入两个参数:

L=循环展开因子 L=\text{循环展开因子} L=循环展开因子

K=独立累加器数量 K=\text{独立累加器数量} K=独立累加器数量

例如,前面的双累加器程序属于:

L=2,K=2 \boxed{L=2,\qquad K=2} L=2,K=2

表示每轮处理两个元素,并使用两个独立累加器。

如果进一步采用:

L=4,K=4 L=4,\qquad K=4 L=4,K=4

那么每轮循环可以处理四个元素,并分别更新四个累加器:

c 复制代码
x0 = x0 OP d[i];
x1 = x1 OP d[i+1];
x2 = x2 OP d[i+2];
x3 = x3 OP d[i+3];

循环结束后,再合并:

c 复制代码
*dest = ((x0 OP x1) OP x2) OP x3;

因此,原来的一条累加依赖链被拆分为四条。

更一般地,如果使用:

K K K

个独立累加器,理想情况下每条依赖链只需要处理约:

NK \frac{N}{K} KN

个元素。

假设单次运算延迟为:

D D D

那么延迟带来的理论限制约为:

CPElatency≈DK \boxed{CPE_{\mathrm{latency}}\approx\frac{D}{K}} CPElatency≈KD

例如,双精度乘法:

D=5 D=5 D=5

如果:

K=1 K=1 K=1

那么:

CPE≈5 CPE\approx5 CPE≈5

如果:

K=2 K=2 K=2

那么:

CPE≈2.5 CPE\approx2.5 CPE≈2.5

如果:

K=5 K=5 K=5

那么:

CPE≈1 CPE\approx1 CPE≈1

如果:

K=10 K=10 K=10

那么:

CPE≈0.5 CPE\approx0.5 CPE≈0.5

但这里不能继续推导出:只要将累加器数量无限增加,CPE 就能无限接近零。

因为现代处理器虽然可以并行执行多条指令,但它拥有的执行单元、加载能力和指令发射能力仍然有限。

因此,当数据依赖已经不再是主要瓶颈时,程序就会逐渐受到吞吐量限制(Throughput Bound)。

例如,当前测试处理器的双精度浮点乘法吞吐量下界为:

0.50 CPE \boxed{0.50\text{ CPE}} 0.50 CPE

这意味着,即使拥有足够多的独立乘法,也不能期望普通标量双精度乘法循环突破对应的执行资源限制。

因此,一个简化的性能下界模型为:

CPE≳max⁡(DK,CPEthroughput) \boxed{ CPE\gtrsim \max\left( \frac{D}{K}, CPE_{\mathrm{throughput}} \right) } CPE≳max(KD,CPEthroughput)

这个公式非常重要。

它说明程序优化会经历两个阶段。

最初,独立累加器不足,性能主要受到:

DK \frac{D}{K} KD

限制。

随着:

K K K

不断增加,依赖链的限制逐渐下降。

但当它低于吞吐量下界时,继续增加累加器已经不能带来同等幅度的收益。

例如,对双精度乘法:

D=5 D=5 D=5

而:

CPEthroughput=0.5 CPE_{\mathrm{throughput}}=0.5 CPEthroughput=0.5

那么大约需要:

K=50.5=10 K=\frac{5}{0.5}=10 K=0.55=10

个独立累加器,才能使延迟限制下降到吞吐量限制附近。

现在观察实际测量结果:

累加器数量 KKK 展开因子 LLL 双精度乘法 CPE
111 111 5.015.015.01
222 222 2.512.512.51
333 333 1.671.671.67
444 444 1.251.251.25
666 666 0.840.840.84
888 888 0.630.630.63
101010 101010 0.510.510.51
121212 121212 0.520.520.52

可以发现,当:

K=1 K=1 K=1

时:

CPE≈5 CPE\approx5 CPE≈5

当:

K=2 K=2 K=2

时:

CPE≈2.5 CPE\approx2.5 CPE≈2.5

当:

K=4 K=4 K=4

时:

CPE≈1.25 CPE\approx1.25 CPE≈1.25

当:

K=10 K=10 K=10

时:

CPE≈0.51 CPE\approx0.51 CPE≈0.51

已经非常接近:

0.50 0.50 0.50

的吞吐量下界。

继续增加到:

K=12 K=12 K=12

反而得到:

CPE=0.52 CPE=0.52 CPE=0.52

没有继续改善。

这并不矛盾

因为此时处理器的浮点乘法执行资源已经接近饱和,单纯增加独立累加器,不能让处理器拥有更多乘法执行单元。

此外,过多累加器还可能带来额外开销,例如寄存器压力增加、指令数量增加,以及最终合并部分结果所需要的运算。

因此:

更多累加器≠无限提高性能 \boxed{\text{更多累加器}\ne\text{无限提高性能}} 更多累加器=无限提高性能

优化的目标应该是提供足够多的独立运算,让处理器接近其吞吐量上限,而不是无限增加源代码规模。

类似现象也出现在整数加法中。

整数加法的典型延迟为:

1 cycle 1\text{ cycle} 1 cycle

而该测试平台的吞吐量下界约为:

0.50 CPE 0.50\text{ CPE} 0.50 CPE

随着循环展开与累加器数量增加,整数加法性能也能够接近:

0.54 CPE 0.54\text{ CPE} 0.54 CPE

这说明,即使单次整数加法的延迟只有一个周期,处理器仍然可以通过多个独立执行单元以及指令重叠,实现平均每元素低于一个周期的执行效率。

不过,继续增加累加器可能导致性能反而变差。

例如,某些组合下 CPE 会重新增大。这说明循环展开并不是越多越好,它需要与处理器的执行资源、寄存器数量和程序规模相匹配。

综合比较四类操作,可以得到最终结果:

运算类型 最佳 CPE 延迟下界 吞吐量下界
整数加法 0.540.540.54 1.001.001.00 0.500.500.50
整数乘法 1.011.011.01 3.003.003.00 1.001.001.00
双精度加法 1.011.011.01 3.003.003.00 1.001.001.00
双精度乘法 0.520.520.52 5.005.005.00 0.500.500.50

这里最值得注意的是:

整数乘法的最佳 CPE:

1.01 1.01 1.01

已经接近吞吐量下界:

1.00 1.00 1.00

双精度加法同样如此。

而双精度乘法:

0.52 0.52 0.52

也已经非常接近:

0.50 0.50 0.50

因此,经过循环展开和多个累加器优化后,程序的性能限制已经从原来的串行依赖链,逐渐转变为处理器执行单元的吞吐能力。

这也是为什么最终性能能够比最初未经优化的代码显著提高。

对于这组测试,最佳实现相对于原始实现,最大加速比达到约:

42× 42\times 42×

但这个数值来自特定处理器、编译器和测试程序,不能直接视为所有应用都能够实现的加速比例。


通过整个优化过程,我们已经能够建立一条清晰的思路。

最初的循环包含大量函数调用、重复判断和不必要的内存操作,因此性能主要受到软件实现方式的影响。

当这些额外开销被消除后,程序开始受到累加器串行数据依赖的限制。

简单循环展开能够减少循环控制开销,却不能消除依赖链。

重新结合运算和使用多个独立累加器,则能够真正缩短关键依赖路径,让处理器重叠执行更多独立运算。

当独立运算数量足够多时,程序性能逐渐接近执行单元的吞吐量上限。

因此,程序优化的核心不只是:

减少指令数量 \text{减少指令数量} 减少指令数量

还包括:

缩短关键依赖链,提高可并行执行的运算数量 \boxed{\text{缩短关键依赖链,提高可并行执行的运算数量}} 缩短关键依赖链,提高可并行执行的运算数量

但到目前为止,我们仍然是在使用标量运算指令,也就是每条运算指令只处理一个整数或浮点元素。

前面第八讲已经学习过,x86-64 还提供 SIMD 向量运算能力,能够让一条指令同时处理多个数据元素。

因此,当标量程序已经接近普通执行单元的吞吐量上限时,还可以进一步利用宽向量寄存器与 SIMD 指令,提高每条指令处理的数据量。

这就进入下一部分的主题:AVX2 向量化优化。

SIMD 向量化与分支预测

前面我们已经知道,现代处理器可以通过流水线和超标量执行,让多条彼此独立的指令重叠运行。

对于使用单个累加器的循环:

c 复制代码
for (i = 0; i < length; i++)
    x = x * d[i];

由于每次乘法依赖上一次产生的 xxx,因此整个循环形成了连续的数据依赖链。

通过使用多个独立累加器,我们可以将一条较长的依赖链拆分成多条较短的依赖链,让处理器同时执行更多独立运算。

例如,双精度乘法的 CPE 可以从:

5.01 5.01 5.01

下降到:

0.52 0.52 0.52

这已经非常接近普通标量乘法的吞吐量下界。

但此时,每条标量乘法指令仍然只处理一个双精度浮点数。

如果希望继续提升性能,就需要进一步利用处理器的 SIMD 能力。

AVX2 与向量计算

我们在第八讲已经学习过 XMM 寄存器。

一个 XMM 寄存器具有:

128 bits 128\text{ bits} 128 bits

的宽度,可以同时保存:

4 4 4

个单精度浮点数,或者:

2 2 2

个双精度浮点数。

现在,AVX(Advanced Vector Extensions)引入了更宽的 YMM 寄存器。

在当前 x86-64 AVX2 编程模型中,共有 161616 个 YMM 寄存器:

%ymm0,%ymm1,...,%ymm15 \%ymm0,\%ymm1,\ldots,\%ymm15 %ymm0,%ymm1,...,%ymm15

每个寄存器宽度为:

256 bits=32 bytes \boxed{256\text{ bits}=32\text{ bytes}} 256 bits=32 bytes

因此,它可以按照不同的数据类型,被划分成不同数量的元素。

元素类型 单个元素宽度 一个 YMM 寄存器容纳的元素数
888 位整数 888 bits 323232
161616 位整数 161616 bits 161616
323232 位整数 323232 bits 888
单精度浮点数 323232 bits 888
双精度浮点数 646464 bits 444

例如,一个包含八个单精度浮点数的向量:

A=a0,a1,...,a7 A=a_0,a_1,\\ldots,a_7 A=a0,a1,...,a7

恰好可以存放在一个 YMM 寄存器中。

text 复制代码
                256-bit YMM

┌────┬────┬────┬────┬────┬────┬────┬────┐
│ a7 │ a6 │ a5 │ a4 │ a3 │ a2 │ a1 │ a0 │
└────┴────┴────┴────┴────┴────┴────┴────┘
        每个元素占用 32 bits

而对于双精度浮点数:

B=b0,b1,b2,b3 B=b_0,b_1,b_2,b_3 B=b0,b1,b2,b3

由于每个 double\mathrm{double}double 占用:

64 bits 64\text{ bits} 64 bits

所以一个 YMM 寄存器可以保存四个双精度浮点数。

text 复制代码
                256-bit YMM

┌─────────┬─────────┬─────────┬─────────┐
│   b3    │   b2    │   b1    │   b0    │
└─────────┴─────────┴─────────┴─────────┘
             每个元素 64 bits

这里需要区分两件事:

寄存器宽度决定一次能够容纳多少数据,而具体指令决定这些数据应该如何参与运算。

例如,一条标量双精度乘法指令只计算一个 double\mathrm{double}double。

而一条打包双精度乘法指令,可以同时计算四组 double\mathrm{double}double。

这就是 SIMD 的核心优势:不只是并行执行多条指令,还可以让一条指令同时处理多个数据元素。

现在考虑一个向量加法问题。

假设:

A=1,2,3,4 A=1,2,3,4 A=1,2,3,4

B=5,6,7,8 B=5,6,7,8 B=5,6,7,8

希望计算:

Ci=Ai+Bi C_i=A_i+B_i Ci=Ai+Bi

因此:

C=6,8,10,12 C=6,8,10,12 C=6,8,10,12

如果采用普通标量双精度浮点指令,就需要分别计算:

C0=A0+B0 C_0=A_0+B_0 C0=A0+B0

C1=A1+B1 C_1=A_1+B_1 C1=A1+B1

C2=A2+B2 C_2=A_2+B_2 C2=A2+B2

C3=A3+B3 C_3=A_3+B_3 C3=A3+B3

也就是四组加法。

但如果将四个双精度浮点数分别放进两个 YMM 寄存器,就可以使用:

c 复制代码
vaddpd %ymm0, %ymm1, %ymm1

其中:

vaddpd \mathrm{vaddpd} vaddpd

表示打包双精度浮点加法。

采用 AT&T 汇编语法时,三个操作数分别表示:

text 复制代码
源操作数 1
源操作数 2
目标操作数

因此:

c 复制代码
vaddpd %ymm0, %ymm1, %ymm1

可以理解为:

%ymm1←%ymm1+%ymm0 \%ymm1\leftarrow\%ymm1+\%ymm0 %ymm1←%ymm1+%ymm0

但这里的加法不是把整个 256256256 位寄存器当成一个整数进行运算,而是对其中四组 646464 位双精度浮点数分别执行加法。

因此:

c0=a0+b0c1=a1+b1c2=a2+b2c3=a3+b3 \boxed{ \begin{aligned} c_0&=a_0+b_0\\ c_1&=a_1+b_1\\ c_2&=a_2+b_2\\ c_3&=a_3+b_3 \end{aligned} } c0c1c2c3=a0+b0=a1+b1=a2+b2=a3+b3

可以表示为:

text 复制代码
%ymm0     [ 1.0 | 2.0 | 3.0 | 4.0 ]
              +     +     +     +
%ymm1     [ 5.0 | 6.0 | 7.0 | 8.0 ]
              │     │     │     │
              ▼     ▼     ▼     ▼
%ymm1     [ 6.0 | 8.0 |10.0 |12.0 ]

所以,原本四组独立的双精度浮点加法,现在可以通过一条向量运算指令表达。

如果处理单精度浮点数,可以使用:

c 复制代码
vaddps %ymm0, %ymm1, %ymm1

其中:

PS=Packed Single PS=\text{Packed Single} PS=Packed Single

表示打包单精度浮点数据。

由于一个 YMM 寄存器可以容纳八个单精度浮点数,因此:

c 复制代码
vaddps

可以一次处理八组单精度浮点加法。

而:

c 复制代码
vaddpd

一次处理四组双精度浮点加法。

指令 数据类型 一条 256 位指令处理的元素数
vaddps\mathrm{vaddps}vaddps 单精度浮点 888
vaddpd\mathrm{vaddpd}vaddpd 双精度浮点 444
vmulps\mathrm{vmulps}vmulps 单精度浮点 888
vmulpd\mathrm{vmulpd}vmulpd 双精度浮点 444

这里有一个容易混淆的地方:

c 复制代码
vaddsd

与:

c 复制代码
vaddpd

并不相同。

其中:

SD=Scalar Double SD=\text{Scalar Double} SD=Scalar Double

表示标量双精度浮点。

而:

PD=Packed Double PD=\text{Packed Double} PD=Packed Double

表示打包双精度浮点。

所以,只有后者才会对 YMM 中的四个双精度元素执行对应的打包向量加法。

现在进一步观察 SIMD 对实际程序性能的影响。

前面我们已经将标量版本的 CPE 优化到接近处理器的吞吐量下界。

但 SIMD 能够在一条指令中处理多个元素,因此有机会继续降低平均到每个元素的周期数。

例如,如果某条向量指令一次处理四个双精度浮点数,并且处理器能够以每周期一条的速度持续执行,那么理论上:

4 个元素/1 cycle 4\text{ 个元素}/1\text{ cycle} 4 个元素/1 cycle

对应:

CPE=0.25 \boxed{CPE=0.25} CPE=0.25

同样,如果某种运算能够每周期执行两条四元素向量指令,那么:

8 个元素/1 cycle 8\text{ 个元素}/1\text{ cycle} 8 个元素/1 cycle

就对应:

CPE=0.125 \boxed{CPE=0.125} CPE=0.125

注意,以上是在执行资源和数据供应均能够满足要求时的吞吐量估算。

实际程序还需要考虑数据加载、存储、运算依赖、剩余元素处理等开销。

现在观察向量化前后的性能数据:

运算类型 最佳标量 CPE 最佳向量 CPE 向量吞吐量下界
整数加法 0.540.540.54 0.060.060.06 0.060.060.06
整数乘法 1.011.011.01 0.240.240.24 0.120.120.12
双精度加法 1.011.011.01 0.250.250.25 0.250.250.25
双精度乘法 0.520.520.52 0.160.160.16 0.120.120.12

单位Cycles Per Element

这组数据有几个值得注意的地方。

首先,整数加法的最佳标量 CPE 为:

0.54 0.54 0.54

向量化后下降到:

0.06 0.06 0.06

由于向量指令能够同时处理多个整数,这种性能改善远不只是减少循环控制指令带来的收益。

其次,双精度加法从:

1.01 1.01 1.01

下降到:

0.25 0.25 0.25

约提高了4倍。

这与 256256256 位 YMM 寄存器能够同时处理四个 double\mathrm{double}double 的特点相吻合。

再次,双精度乘法从:

0.52 0.52 0.52

下降到:

0.16 0.16 0.16

性能同样进一步改善。

但它还没有达到:

0.12 0.12 0.12

的向量吞吐量下界。

这说明,具备向量运算指令并不意味着实际程序一定能够完全利用向量执行单元的理论吞吐能力。

实际性能还可能受到计算依赖、数据加载、指令调度等因素影响。

最后,整数乘法的向量版本:

CPE=0.24 CPE=0.24 CPE=0.24

也明显高于:

0.12 0.12 0.12

的吞吐量下界。

因此,分析 SIMD 性能时,不能简单地认为:

向量宽度扩大四倍⇒程序速度一定提高四倍 \text{向量宽度扩大四倍} \Rightarrow \text{程序速度一定提高四倍} 向量宽度扩大四倍⇒程序速度一定提高四倍

更准确的认识应该是:

向量化提高的是每条指令能够处理的数据量,但程序能否充分发挥这种能力,仍然取决于数据依赖、处理器执行资源和内存系统。

到这里,我们已经研究了两种不同层次的并行。

第一种是指令级并行:

多条独立指令重叠执行 \boxed{\text{多条独立指令重叠执行}} 多条独立指令重叠执行

例如,通过多个累加器让不同乘法之间不再形成一条连续依赖链。

第二种是 SIMD 数据级并行:

一条指令同时处理多个数据元素 \boxed{\text{一条指令同时处理多个数据元素}} 一条指令同时处理多个数据元素

例如,使用 vaddpd\mathrm{vaddpd}vaddpd 一次计算四组双精度浮点加法。

二者并不冲突。

实际高性能程序可以同时利用多个独立向量累加器,使不同向量指令之间也具有足够的独立性。

但是,即使程序已经具备大量独立运算,现代处理器仍然可能遇到另一类性能限制:条件分支。

分支预测与流水线恢复

前面我们已经知道,现代 CPU 通常将指令执行过程划分为多个阶段,并通过流水线让不同指令在时间上重叠执行。

例如,可以将一条指令的执行过程简化为:

text 复制代码
取指
 │
 ▼
译码
 │
 ▼
执行
 │
 ▼
提交结果

但真实的现代处理器远比这个示意复杂。

它们通常具有多种执行单元、乱序执行能力,以及专门管理指令提交的结构。

为了让执行单元持续工作,处理器必须提前获取、译码并准备大量后续指令。

这里就会遇到一个问题。

考虑:

c 复制代码
long absdiff(long x, long y)
{
    if (x > y)
        return x - y;
    else
        return y - x;
}

我们在机器级控制流中已经学习过,条件判断可以通过:

c 复制代码
cmpq
jle

等指令实现。

例如,下面这段汇编:

c 复制代码
404663: mov $0x0, %eax
404668: cmp (%rdi), %rsi
40466b: jge 404685
40466d: mov 0x8(%rdi), %rax

        ...

404685: repz retq

关键指令是:

c 复制代码
jge 404685

它表示按照前面的比较结果,决定是否跳转到:

0x404685 \texttt{0x404685} 0x404685

如果条件成立,程序应当跳转到目标地址。

如果条件不成立,程序则应该继续执行下一条:

0x40466d \texttt{0x40466d} 0x40466d

因此,处理器在遇到:

c 复制代码
jge

时,实际上存在两个可能的后续执行位置。

text 复制代码
           jge
            │
       ┌────┴────┐
       │         │
     Taken    Not Taken
       │         │
       ▼         ▼
   跳转目标    下一条指令

其中:

Taken Taken Taken

表示分支发生跳转。

Not Taken Not\ Taken Not Taken

表示分支没有跳转,继续顺序执行

问题是,处理器的取指单元不能一直等待条件判断结果确定后,才开始获取后面的指令

因为在等待期间,后续流水线可能没有足够的工作可以执行。

因此,现代处理器需要一种方法,在分支结果尚未确定时,提前决定后续应该从哪里取指。

这就是分支预测(Branch Prediction)。

分支预测的基本思想非常直接:

当处理器遇到尚未确定结果的条件分支时,先预测它会跳转还是不跳转,并沿预测路径继续执行。

例如:

c 复制代码
if (x > y)
    a = x + y;
else
    a = x - y;

在真正知道:

x>y x>y x>y

是否成立之前,处理器可能预测条件为真,于是提前获取并执行:

c 复制代码
a = x + y;

对应的机器指令。

但如果后面发现:

x≤y x\le y x≤y

那么预测就是错误的。

此时程序实际上应该执行:

c 复制代码
a = x - y;

而不是刚才预测路径上的计算。

那么,处理器如何保证错误预测不会改变程序的最终结果?

这里需要理解现代处理器中的推测执行(Speculative Execution)。

处理器可以沿预测路径提前执行部分操作,但不能将错误路径产生的架构状态更新当成程序最终结果提交。

例如,错误预测路径中计算出了:

a=x+y a=x+y a=x+y

但如果分支最终确定应该执行:

a=x−y a=x-y a=x−y

那么错误路径的结果就必须被丢弃,不能让程序在正常执行语义下观察到错误结果。

现代处理器通常通过寄存器重命名、重排序缓冲区以及按程序顺序提交等机制管理这种过程。

可以将其简化为:

text 复制代码
遇到条件分支
      │
      ▼
预测分支方向
      │
      ▼
沿预测路径执行
      │
      ▼
获得真实分支结果
      │
      ├── 预测正确
      │      │
      │      ▼
      │   继续执行
      │
      └── 预测错误
             │
             ▼
       丢弃错误路径结果
             │
             ▼
       恢复正确执行路径

这里需要特别区分:

执行某条指令,并不意味着这条指令的结果已经正式成为程序可观察的架构状态。

现代乱序处理器可以在结果提交之前推测执行指令。

如果预测错误,那么相关指令的架构效果就不会被正常提交。

不过,推测执行可能对缓存等微架构状态留下影响,这是与架构状态不同的问题,也是后来某些处理器侧信道问题的重要背景。当前主要关注它对程序性能的影响。

现在将分支预测放进一个循环中分析。

考虑向量乘法循环:

c 复制代码
for (i = 0; i < length; i++)
    x = x * d[i];

其核心机器指令可以表示为:

c 复制代码
401029: vmulsd (%rdx), %xmm0, %xmm0
40102d: add $0x8, %rdx
401031: cmp %rax, %rdx
401034: jne 401029

首先:

c 复制代码
vmulsd (%rdx), %xmm0, %xmm0

从内存读取当前双精度浮点数,并与累加器相乘:

x←x×di x\leftarrow x\times di x←x×di

随后:

c 复制代码
add $0x8, %rdx

让数据指针向后移动八个字节。

因为:

sizeof(double)=8 sizeof(double)=8 sizeof(double)=8

所以,这相当于移动到下一个数组元素。

接下来:

c 复制代码
cmp %rax, %rdx

比较当前指针位置和结束位置。

最后:

c 复制代码
jne 401029

如果尚未到达结束位置,就跳回循环体开头。

假设向量长度为:

100 100 100

那么,在绝大多数循环中:

c 复制代码
jne 401029

的结果都是:

Taken Taken Taken

因为程序还需要继续处理后面的元素。

例如,当:

i=98 i=98 i=98

时,分支仍然需要跳回循环开头。

而当:

i=99 i=99 i=99

完成最后一个元素之后,程序才应该退出循环。

因此,对于这种循环,分支结果通常具有非常明显的规律:

text 复制代码
Taken
Taken
Taken
...
Taken
Taken
Not Taken

现代分支预测器可以利用程序过去执行分支的行为,预测当前分支的结果。

对于这种大部分时间都会跳回循环体的分支,预测为:

Taken Taken Taken

通常具有较高的正确率。

但循环最终一定会结束。

因此,在最后一次检查时,预测器可能仍然按照前面的规律预测:

Taken Taken Taken

可真实结果却是:

Not Taken Not\ Taken Not Taken

这时就发生了:

Branch Misprediction \boxed{\text{Branch Misprediction}} Branch Misprediction

即分支预测错误。

为了理解预测错误造成的后果,我们进一步跟踪循环末尾的执行。

假设循环中:

%rdx \%rdx %rdx

保存当前数据指针,而:

%rax \%rax %rax

保存结束地址。

在处理最后一个元素时:

c 复制代码
vmulsd (%rdx), %xmm0, %xmm0

完成乘法。

然后:

c 复制代码
add $0x8, %rdx

将指针移动到数组末尾之后的位置。

此时:

%rdx=%rax \%rdx=\%rax %rdx=%rax

因此:

c 复制代码
cmp %rax, %rdx

会表明两个地址相等。

所以:

c 复制代码
jne 401029

实际上不应该发生跳转。

但假设处理器预测为:

Taken Taken Taken

于是取指单元可能继续从:

0x401029 \texttt{0x401029} 0x401029

获取循环体指令。

这就意味着,处理器可能推测执行下一次:

c 复制代码
vmulsd (%rdx), %xmm0, %xmm0

即尝试从数组末尾之后的位置读取数据。

这里有一个非常值得理解的现象:

即使预测路径上的指令发生了推测执行,也不意味着它一定会像普通已提交的越界访问一样,导致程序立即发生可见的异常。

因为处理器尚未正式提交这些错误路径上的指令。

当真实分支结果确定之后,处理器发现:

Not Taken Not\ Taken Not Taken

与原来预测的:

Taken Taken Taken

不一致,于是会丢弃错误路径上的推测结果。

之后,重新从正确的指令位置继续执行。

因此,程序最终仍然可以正确退出循环。

这一过程也说明,现代处理器不是机械地执行完一条指令后才开始下一条,而是会为了获得更高性能,提前处理未来可能需要的指令

但这种推测能力需要付出代价。

如果预测正确,提前执行的工作可以继续使用。

如果预测错误,之前沿错误路径完成的部分工作就会被浪费。

现在来看分支预测失败之后的恢复过程。

正确的循环结束路径为:

c 复制代码
401029: vmulsd (%rdx), %xmm0, %xmm0
40102d: add $0x8, %rdx
401031: cmp %rax, %rdx
401034: jne 401029
401036: jmp 401040

        ...

401040: vmovsd %xmm0, (%r12)

当:

c 复制代码
jne 401029

的真实结果确定为不跳转之后,处理器需要恢复正确控制流。

于是从:

c 复制代码
401036: jmp 401040

继续执行。

最后:

c 复制代码
401040: vmovsd %xmm0, (%r12)

将累加结果写入目标内存。

这里体现了分支预测失败的两个代价。

首先,错误路径上的部分指令已经被取出、译码,甚至执行,但结果不能使用。

其次,处理器需要重新恢复正确路径上的取指和执行,重新填充相关流水线阶段。

所以,一次分支预测错误可能浪费 多个时钟周期。

在现代深流水线处理器上,这种开销可能相当明显。

因此:

分支预测错误⇒错误路径工作被丢弃+正确路径恢复开销 \boxed{ \text{分支预测错误} \Rightarrow \text{错误路径工作被丢弃} + \text{正确路径恢复开销} } 分支预测错误⇒错误路径工作被丢弃+正确路径恢复开销

现在我们可以将分支预测与程序优化联系起来。

假设某个条件判断的结果具有很强规律性:

c 复制代码
if (x > 0)

如果程序中:

x>0 x>0 x>0

几乎总是成立,那么预测器通常更容易学习其行为。

相反,如果条件结果接近随机:

text 复制代码
True
False
True
True
False
False
...

那么预测器可能更难准确判断后续分支方向。

因此,两段执行相同数量机器指令的程序,也可能因为分支预测正确率不同而具有明显不同的运行时间。

这说明,程序性能不仅与运算指令、访存次数和数据依赖有关,还与控制流能否被处理器准确预测有关。

当然,不能因此认为所有条件分支都应该被删除。

例如,对于某些简单条件赋值:

c 复制代码
if (x > y)
    result = a;
else
    result = b;

编译器可能使用条件传送指令,避免实际控制流跳转。

但这种方式不一定总是更快。

如果两个分支分别包含非常昂贵的计算,或者某个分支涉及不能随意执行的内存访问,那么保留条件分支可能更合理。

因此,分支优化的目标不是机械地减少所有:

c 复制代码
if

而是结合实际分支行为、预测正确率和两条路径的执行成本,决定何种实现更加高效。

优化原则

通过第十讲,我们已经从最简单的代码移动,一直分析到指令级并行、SIMD 和分支预测。

这些内容虽然涉及不同层次,但实际上存在一条清晰的优化路径。

首先,选择合适的算法与编译配置 。

如果算法本身具有不必要的高复杂度,那么只优化几条汇编指令通常无法弥补算法层面的差距。

例如:

c 复制代码
for (i = 0; i < strlen(s); i++)

可能因为循环中反复执行线性时间的 strlen\mathrm{strlen}strlen,导致整个函数具有:

O(N2) O(N^2) O(N2)

的时间复杂度。

这种问题应当首先通过调整程序结构解决。

其次,编写有利于编译器优化的代码 。

例如,将循环不变计算移到循环外部,减少重复函数调用,尽量避免不必要的内存访问。

对于指针操作,还需要理解 Memory Aliasing,不能因为两个指针变量名称不同,就假设它们一定指向不同内存。

再次,重点检查程序最内层的热点循环 。

因为这些循环可能执行成千上万次。

例如:

c 复制代码
for (i = 0; i < n; i++)
    x = x * d[i];

虽然只有一条核心乘法,但如果每次运算都依赖前一次结果,就可能受到乘法延迟限制。

此时,应当考虑循环展开、重新结合以及多个独立累加器,减少关键依赖链长度。

进一步,利用目标处理器的执行能力。

现代 CPU 不仅可以同时执行多条独立指令,还可以通过 SIMD 在一条指令中处理多个数据元素。

因此,对于大量独立、规则的数据计算,向量化通常值得考虑。

最后,关注不可预测的条件分支和内存访问行为。

即使算术运算已经充分并行,如果处理器不断发生分支预测错误,仍然可能因为流水线恢复而浪费大量周期。

同样,程序还可能受到缓存命中率和内存带宽等因素限制。相关的缓存局部性知识将在后续章节继续研究。

因此,程序优化可以概括为:

减少不必要的计算⇓消除重复访存和函数调用⇓缩短关键数据依赖链⇓提高指令级与数据级并行⇓减少控制流和内存系统带来的停顿 \boxed{ \begin{aligned} &\text{减少不必要的计算}\\ &\Downarrow\\ &\text{消除重复访存和函数调用}\\ &\Downarrow\\ &\text{缩短关键数据依赖链}\\ &\Downarrow\\ &\text{提高指令级与数据级并行}\\ &\Downarrow\\ &\text{减少控制流和内存系统带来的停顿} \end{aligned} } 减少不必要的计算⇓消除重复访存和函数调用⇓缩短关键数据依赖链⇓提高指令级与数据级并行⇓减少控制流和内存系统带来的停顿

不过,整个优化过程中,有一个原则始终不能忽略:

性能优化必须建立在程序正确性之上。

例如,通过多个累加器改变浮点运算顺序,可能改变浮点舍入结果。

通过调整内存访问顺序,也可能在存在指针别名时改变程序行为。

因此,不能因为某个版本的 CPE 更低,就直接认为它在所有情况下都是更正确的实现。

更合理的优化过程是:

先明确程序需要保留的语义与数值精度,再测量性能,分析瓶颈,最后针对实际瓶颈进行优化并重新验证。

至此,我们已经能够从 C 语言源代码、编译器优化、汇编指令以及处理器执行机制四个层次,解释程序为什么会快或慢。

这也是学习机器级程序的最终价值之一:不只是知道一段 C 代码会被翻译成什么指令,还能够理解这些指令如何在真实处理器上执行,以及怎样通过合理的程序结构发挥硬件的性能。

相关推荐
超级架构师1 小时前
让 COBOL 长出 AI 大脑:原生 Agent 核心框架 praxo-core 深度剖析
c语言·开发语言·c++·人工智能·python·架构·ai编程
YLCHUP1 小时前
【模拟&STL】题解:AT_abc203_e [ABC203E] White Pawn_基础算法_数据结构_STL模板库_AtcoderABC_C++算法竞赛
数据结构·c++·算法·leetcode·动态规划·哈希算法·近邻算法
xiaolang_8616_wjl1 小时前
c++游戏_宝可梦大世界(源代码)
开发语言·c++·游戏
余额瞒着我当琳2 小时前
C++异常,异常的概念,异常抛出与捕获,RAII 和智能指针的设计思路,C++标准库一些指针的使用
java·开发语言·c++·算法
東隅已逝,桑榆非晚2 小时前
map和set的使用
数据结构·c++·笔记·学习
骑着蜗牛撵大象3272 小时前
大模型 SDK 编译就崩?用 CMakeLists 把 httplib、JSON 与 OpenSSL 装配成可复用的 ChatServer
android·java·c++·http·json·cmake·大模型sdk
邪修king2 小时前
Re:Linux系统篇(三十八):进程间通信ChapterSystem V 信号量深度解析|同步互斥、内核源码、C 指针多态、实战代码
java·linux·c语言
码小猿的CPP工坊3 小时前
C++跨平台开发之基于wxWidgets开发GUI程序简介-002
开发语言·c++
chenbingjie_c3 小时前
C++ 继承全解析(从三种继承方式到菱形继承与内存布局)
开发语言·c++