【CSAPP笔记】第五章 优化程序性能

467 阅读11分钟

5.1 优化编译器的能力和局限性

  • 编译器的优化的妨碍因素:内存别名使用函数调用等,只能人为优化

【例1】内存别名使用。由于编译器不知道xpyp是否相等,所以无法进行进一步优化

void twiddle1(long *xp, long *yp) {
    *xp += *yp;
    *xp += *yp;
}
void twiddle2(long *xp, long *yp) {
    *xp += 2* *yp;
}

【说明】以上两个函数看似一样,twiddle2()要比twiddle1()高效,但实际不同。考虑xp == yp的情况:

  • 当执行twiddle1(),结果是将xp的值增加到4倍
  • 当执行twiddle2(),结果是将xp的值增加到3倍

【例2】内存别名使用。由于编译器不知道p和q是否相等,所以无法进行进一步优化

x = 1000; y = 3000;
*q = y; *p = x;
t1 = *q;

【说明】以上代码看似t1 = 3000。考虑指针p和q指向同一位置,则结果t1 = 1000。编译器无法优化

【例3】函数调用

long counter = 0;
long f() {
    return counter++;
}
long func1() {
    return f() + f() + f() + f();
}
long func2() {
    return 4 *f ();
}

【说明】以上代码看似func1()等价于func2(), 实际上func1()返回6;func2()返回0。

  • 为了优化函数调用,可将f()转为内联函数,则编译期间将f()替换为函数内容
inline f() { return counter++; }
//相当于将f()的内容直接替换到func1()中。这样编译器可以进一步优化
long func1in() {
    long t = counter++;
    t += counter++;
    t += counter++;
    t += counter++;
    return t;
}

5.2 衡量程序性能

  • 引入CPE(Cycles Per Element, 每元素的周期数)表示程序性能
    • 个人理解:即每次循环,指令运行消耗的时间。可以指导我们改进代码

【例】计算数组a[]的前置和p[]

{p0=a0,i=0pi=pi1+ai,1i<n\left\{ \begin{array}{ll} p_0 = a_0, & i = 0\\ p_i = p_i-1 + a_i, & 1 \le i < n \\ \end{array} \right.
void psum1(float a[], float p[], long n) {
    long i;
    p[0] = a[0];
    for (i = 1; i < n; i++)
        p[i] = p[i-1] + a[i];
}
void psum2(float a[], float p[], long n) {
    long i;
    p[0] = a[0];
    for (i = 1; i < n-1; i+=2) {
        float mid_val = p[i-1] + a[i];
        p[i] = mid_val;
        p[i+1] = mid_val + a[i+1];
    }
    if (i < n)
        p[i] = p[i-1] + a[i];
}

【说明】psum2()使用循环展开,一次计算两个元素,效率有所提升 (具体在5.8节阐述原理)

  • 这里通过最小二乘拟合,计算函数执行的元素和周期关系。CPE=斜率值

image-20221211153403978.png

5.3~5.6 程序优化示例

【例】书中例子,向量元素累加/累乘,核心代码如下。Op代表加法或乘法,IDENT代表初值0或1

首先问一个问题:这段代码可以优化吗?

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;
    }
}

【优化1】消除不必要的函数调用

  • 每次循环都使用vec_length()计算向量v的大小,是不必要的,可优化
void combine2(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    *dest = IDENT;
    for (i = 0; i < length; i++) {
        data_t val;
        get_vec_element(v, i, &val);
        *dest = *dest Op val;
    }
}

【优化2】减少函数调用

  • 每次都要调用get_vec_element()获取向量v的元素值。可优化为直接访问数组方式
void combine3(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    data_t *data = get_vec_start(v);
    *dest = IDENT;
    for (i = 0; i < length; i++) {
        *dest = *dest Op data[i];
    }
}

【优化3】消除不必要的内存引用

  • *dest需要读写内存,浪费性能。使用临时变量acc可避免这一点
void combine4(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    data_t *data = get_vec_start(v);
    data_t acc = IDENT;
    for (i = 0; i < length; i++) {
        acc = acc Op data[i];
    }
    *dest = acc;
}

【说明】经过优化后,性能有了明显的提高。CPE值如下:

函数方法整数加法整数乘法浮点数加法浮点数乘法
combine1未优化22.6820.0219.9820.18
combine2移动vec_length7.029.039.0211.03
combine3直接数据访问7.179.029.0211.03
combine4累积在临时变量中1.273.013.015.01

【总结】可以通过降低函数调用的开销消除不必要的内存引用,大幅提升函数效率

5.7 理解现代处理器

  • 为进一步提高性能,需尽可能利用处理器的并行特性。现代处理器可以同时对多条指令求值,称为指令级并行
  • 程序性能的界限:延迟界限(latency bound)、吞吐量界限(throughput bound)
    • 延迟界限:程序优化后,一系列操作必须按照严格顺序执行 (指令串行运行的界限)
    • 吞吐量界限:处理器单元原始的计算能力,即程序性能的理论界限

5.7.1 现代处理器操作

image-20221211183840693.png

上图是现代处理器的简化示意图。包含指令控制单元(ICU)执行单元(EU)

  • ICU:负责从 指令高速缓存(instruction cache) 中读取指令,并根据指令序列生成对程序数据的基本操作。通常指令事先就读取到缓存中了。

    • 取指控制:从指令高速缓存中读取数据
    • 指令译码:对指令分解,加载到执行单元中
    • 退役单元:记录正在进行的处理,控制寄存器的更新。分支预测正确,则程序寄存器的更新实际执行;分支预测错误,则丢弃所有计算的结果
  • EU:执行这些指令(乱序执行,有很复杂的硬件支撑)。EU含多个功能单元,可流水线执行

    • 流水线操作:指令在执行期间,会被分解为多个功能单元,这些单元能够并行地执行多条指令的不同部分
    • 分支预测单元:当遇到分支指令时,出现两种可能:选择分支、不选择分支。此时处理器采用分支预测(branch prediction)技术,在确定分支预测正确前就执行这些操作(称为投机执行,speculative execution)。如果预测错误,则将状态重置回分支状态继续执行(预测错误会导致很大的性能开销)
    • 读写内存单元:由加载存储功能单元实现(图5-11右下角部分)
      • 加载单元:内存读数据到处理器。内含加法器完成地址计算
      • 存储单元:处理器写数据到内存。内含加法器完成地址计算
    • 算数运算单元:专门用来执行整数和浮点数操作。有些单元能进行整数运算+浮点运算,有些不行,与处理器的设计有关系
    • 转发机制:某个执行单元完成一个操作,可以将值直接传给另一个操作,不需要写入寄存器文件,节省开销(4.5.5节)
  • 书中使用Intel Core i7 Haswell处理器实验,含8个功能单元:

image-20221211221955616.png

5.7.2 功能单元的性能

根据Intel手册,不同的运算的耗时是不一样的。乘法耗时比加法耗时长,除法按照被除数和除数的不同,耗时也不同

  • 延迟(latency):完成运算的耗时
  • 发射(issue time):连续同类型运算的最小间隔
  • 容量(capacity):CPU中该功能单元的数量

image-20221211190001460.png

【说明】书中采用Intel Core i7 Haswell处理器,计算得到操作的延时。如做整数加法,完成运算需1个时钟周期,两次加法至少要间隔1时钟周期,CPU有4个单元可进行整数加法运算。另外,除法的延时=发射,也就是说除法操作要等上一次除法全部完成才能执行下一次除法。

  • 因此,可以得到延迟界限和吞吐量界限的CPE值
    • 整数加法:CPU有4个功能单元做加法器(5.7.1节)。加法指令只需执行1个时钟周期,因此连续执行加法时(延时界限)的CPE=1。虽然4个功能单元都可以执行加法,但是每个时钟周期只有2个加载单元能从内存读取数据值,所以吞吐量界限CPE=0.5
    • 整数乘法:CPU只有1个整数乘法器,而整数乘法指令需要执行3个时钟周期。因此,连续执行乘法指令时(延时界限)的CPE=3。而发射时间间隔是1个时钟周期,所以处理器每个时钟周期最多执行1个乘法运算,即吞吐量界限CPE=1
界限整数加法整数乘法浮点数加法浮点数乘法
combine41.273.013.015.01
延迟界限1.003.003.005.00
吞吐量界限0.501.001.000.50
  • 我们的最终目的是 增加指令级并行性,突破延迟界限,接近吞吐量界限

5.7.3 处理器操作的抽象模型(数据流图)

  • 数据流图:将处理器对寄存器、功能单元的操作整理成数据流图,可以更直观地看出性能瓶颈
    • 描述寄存器、功能单元的关系。上面的寄存器表示输入,下面的寄存器表示输出,箭头表示数据流向。
    • 其中,被访问的寄存器可以分为4类:
      • 只读寄存器:可以作为数据,也可以用来计算内存地址 。这些寄存器在循环中不会被更改(%rax
      • 只写寄存器:作为数据传送操作的目的寄存器(本例不包含)
      • 局部寄存器:在单个循环内部被修改和使用,迭代与迭代间不相关(条件码寄存器)
      • 循环寄存器:即作为源值,又作为目的,一次迭代产生的值会在另一个迭代中使用到(%rdx%xmm0)。

【例】书中例子,分析combine4()的数据流图

  • 由于combine4()耗时主要集中在循环部分,首先分析combine4()浮点数乘法循环部分的汇编代码
//data+i in %rdx, acc in %xmm0, data+length in %rax
.L25:
	vmulsd (%rdx), %xmm0, %xmm0		//acc = acc * data[i]
	addq   $8, %rdx					//data++
	cmpq   %rax, %rdx				//if i != length, loop
	jne    .L25
  • 数据流图:画出数据流图,经历了5个处理单元。combine4()中每次迭代要计算%xmm0,然后才能计算下一个%xmm0循环寄存器有依赖,所以成为性能瓶颈。

image-20221211225016755.png

  • 我们调整功能单元的位置,并只保留循环寄存器及关键操作,得到图5-14(b)。最后将n次迭代拼接在一起,得到图5-15。
  • 图5-15可以看出,存在两条关键路径:变量acc乘法路径、变量i加法路径。由于浮点乘法需要5个时钟周期,整数加法需要1个时钟周期。所以乘法链是影响性能的关键如果能够并行处理mul运算,就能突破延迟界限

image-20221212000918509.png

image-20221212002025586.png

5.8 循环展开

  • 循环展开:增加每次迭代计算元素的数量,减少循环的迭代次数
  • 为什么循环展开可以改善性能?
    • 减少程序结果无关操作的数量,比如对循环索引的计算(i++)和条件分支(i < length)
    • 可以进一步优化循环逻辑,从而减少整个计算中关键路径的操作数量

【例】2x1循环展开。第一个循环每次处理两个元素,第二个循环计算剩余元素。当然,扩展到kx1循环展开也是类似的,第一个循环处理k个元素,第二个循环计算剩余元素

/* 2x1 loop unrolling */
void combine5(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    long limit = length-1;
    data_t *data = get_vec_start(v);
    data_t acc = IDENT;

    /* Combine 2 elements at a time */
    for (i = 0; i < limit; i+=2) {
        acc = (acc Op data[i]) Op data[i+1];
    }
    /* Finish any remaining elements */
    for (; i < length; i++) {
        acc = acc Op data[i];
    }
    *dest = acc;
}
  • 计算得到下面的优化结果:
函数方法整数加法整数乘法浮点数加法浮点数乘法
combine4无展开1.273.013.015.01
combine52x1展开1.013.013.015.01
3x1展开1.013.013.015.01
延迟界限1.003.003.005.00
吞吐量界限0.501.001.000.50

【说明】发现combine5()更进一步接近了延迟界限。但是为什么不能突破呢?

  • 我们根据汇编代码画出数据流图(图5-20),发现mul运算并没有并行执行,所以没有突破延迟界限
  • 但是由于add运算次数减少,所以对性能有改善
//i in %rdx, acc in %xmm0, data in %rax, limit in %rbp
.L35:
	vmulsd (%rax,%rdx,8), %xmm0, %xmm0	//acc = acc * data[i]
	vmulsd 8(%rax,%rdx,8), %xmm0, %xmm0	//acc = acc * data[i+1]
	addq   $2, %rdx						//i += 2
	cmpq   %rdx, %rbp					//if limit > i, loop
	jg    .L35

image-20221212082336184.png

image-20221212082238744.png

5.9 提高并行性

5.9.1 多个累积变量

【优化1】2x2循环展开,奇数项和偶数项乘积分开运算

void combine6(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    long limit = length-1;
    data_t *data = get_vec_start(v);
    data_t acc0 = IDENT;
    data_t acc1 = IDENT;
    
    /* Combine 2 elements at a time */
    for (i = 0; i < limit; i+=2) {
        acc0 = acc0 OP data[i];
        acc1 = acc1 Op data[i+1];
    }
    
    /* Finish any remaining elements */
    for (; i < length; i++) {
        acc0 = acc0 Op data[i];
    }
    *dest = acc0 Op acc1;
}
  • CPE如下:
函数方法整数加法整数乘法浮点数加法浮点数乘法
combine4无展开1.273.013.015.01
combine52x1展开1.013.013.015.01
combine62x2展开0.811.511.512.51
延迟界限1.003.003.005.00
吞吐量界限0.501.001.000.50
  • 数据流图:看到两个关键路径并行计算乘法。两条路径的mul操作没有相关,理论上效率提升2倍

image-20221213074758052.png

【说明】将奇偶分开运算,速度进一步改进,并打破了延迟界限。那么,如果分的越细,是否运算更快呢?

  • 考虑kxk循环展开。如果展开次数k越大,则CPE会越接近吞吐量界限

image-20221213074935656.png

5.9.2 重新结合变换

【优化2】2x1a循环展开,利用加法/乘法结合律,重新结合运算

void combine7(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    long limit = length-1;
    data_t *data = get_vec_start(v);
    data_t acc = IDENT;
    
    /* Combine 2 elements at a time */
    for (i = 0; i < limit; i+=2) {
        acc = acc OP (data[i] Op data[i+1]);
    }
    
    /* Finish any remaining elements */
    for (; i < length; i++) {
        acc = acc Op data[i];
    }
    *dest = acc;
}
  • CPE如下:
函数方法整数加法整数乘法浮点数加法浮点数乘法
combine4无展开1.273.013.015.01
combine52x1展开1.013.013.015.01
combine62x2展开0.811.511.512.51
combine72x1a展开1.011.511.512.51
延迟界限1.003.003.005.00
吞吐量界限0.501.001.000.50
  • 数据流图:mul同样也是并行执行的

image-20221213075154521.png

【说明】我们看到,仅仅改变了加法/乘法结合的顺序,就打破了延迟界限。同样也可以扩展成kx1a循环展开,k越大,越接近吞吐量界限:

image-20221213075456966.png

5.9.3 SIMD

  • 标量指令:单指令处理一个数据。之前的例子都是标量指令
  • 向量指令:单指令处理多个数据,也称SIMD(Single-Instruction, Multiple-Data,单指令多数据),比如GPU都是用SIMD并行处理数据的
  • 向量保存在特殊的向量寄存器中(AVX寄存器%ymm0~%ymm15)。如%ymm0寄存器是256位寄存器,可以并行执行8组32位数值或4组64位数值的加法或乘法,性能提高8或4倍
方法long整数加法long整数乘法double浮点数加法double浮点数乘法
标量10x100.551.001.010.52
标量吞吐量界限0.501.001.000.50
向量8x80.131.510.250.16
向量吞吐量界限0.12性能不佳0.250.12

【注】AVX指令集不包含64位整数的乘法指令,因此GCC无法生成向量代码,性能不佳

【注】Intel官方提供了一套intrinsics函数支持SIMD操作,具体参考:www.intel.com/content/www…

5.11 一些限制因素

5.11.1 寄存器溢出

  • 循环展开并不是越多越好,比如20x20的循环展开要比10x10循环展开性能弱。这是因为20x20的循环展开,函数局部变量需要额外使用栈上的变量,带来内存访问的开销
// 20x20循环展开的汇编代码
vmovsd 40(%rsp), %xmm0
vmulsd (%rdx), %xmm0, %xmm0
vmovsd %xmm0, 40(%rsp)

5.11.2 分支预测和预测错误处罚

  • 错误分支对性能的惩罚会很大。为了尽可能避免,有以下通用原则:
    • 不要过分关心可预测的分支:现代处理器的分支预测逻辑非常强大,会尽可能优化
    • 书写适合用条件传送实现的代码:比如cmov指令

【例】书中例子

void minmax1(long a[], long b[], long n) {
    long i;
    for (i = 0; i < n; i++) {
        if (a[i] > b[i]) {
            long t = a[i];
            a[i] = b[i];
            b[i] = t;
        }
    }
}
//优化后,会编译为cmov
void minmax2(long a[], long b[], long n) {
    long i;
    for (i = 0; i < n; i++) {
        long min = a[i] < b[i] ? a[i] : b[i];
        long max = a[i] < b[i] ? b[i] : a[i];
        a[i] = min; b[i] = max;
    }
}

5.12 理解内存性能

  • 加载性能(读):测试得CPE=4.0,与L1级cache的4周期访问时间一致(见6.4节讨论)
  • 存储性能(写):单个存储功能单元的写入,测试得CPE=1.00
  • 如果写/读相关,那么性能会进一步降低。这是由于加载操作必须检查存储缓冲区的条目,是否有地址匹配

image-20221216075839704.png

【例】书中例子

void write_read(long *src, long *dst, long n) {
    long cnt = n;
    long val = 0;
    while (cnt) {
        *dst = val;
        val = (*src)+1;
        cnt--;
    }
}

【说明】

  • 使用write_read(&a[0], &a[1], 3),CPE=1.3
  • 使用write_read(&a[0], &a[0], 3),CPE=7.3,下降6个时钟周期

5.13 性能优化总结

  • 高级设计:选择适当的算法和数据结构
  • 基本编码原则:消除连续的函数调用、消除不必要的内存引用
  • 低级优化:指令集并行
    • 使用循环展开
    • 使用运算重新结合等技术(例如计算多个累积变量)
    • 使用SIMD并行计算
    • 编写出编译器能够有效优化的代码(如:条件传送cmov代替条件操作)

【注】性能优化的前提是不引入错误。另外,过分优化会导致代码膨胀和代码可读性降低,不利于维护

实验

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define OP_ADD

#ifdef OP_ADD      //加法运算
#define IDENT 0
#define Op +
#else              //乘法运算
#define IDENT 1
#define Op *
#endif

typedef long data_t;
typedef struct {
    long len;
    data_t *data;
} vec_rec, *vec_ptr;
typedef void(*func)(vec_ptr v, data_t *dest);

vec_ptr new_vec(long len) {
    vec_ptr result = (vec_ptr) malloc(sizeof(vec_rec));
    data_t *data = NULL;
    if (!result)  return NULL;
    result->len = len;
    if (len > 0) {
        data = (data_t *)calloc(len, sizeof(data_t));
        if (!data) {
            free((void *) result);
            return NULL;
        }
    }
    result->data = data;
    return result;
}

void get_vec_element(vec_ptr v, long index, data_t *dest) {
    *dest = v->data[index];
}

long vec_length(vec_ptr v) {
    return v->len;
}

data_t *get_vec_start(vec_ptr v) {
    return v->data;
}

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;
    }
}

void combine2(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    *dest = IDENT;
    for (i = 0; i < length; i++) {
        data_t val;
        get_vec_element(v, i, &val);
        *dest = *dest Op val;
    }
}

void combine3(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    data_t *data = get_vec_start(v);
    *dest = IDENT;
    for (i = 0; i < length; i++) {
        *dest = *dest Op data[i];
    }
}

void combine4(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    data_t *data = get_vec_start(v);
    data_t acc = IDENT;
    for (i = 0; i < length; i++) {
        acc = acc Op data[i];
    }
    *dest = acc;
}

void combine5(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    long limit = length-1;
    data_t *data = get_vec_start(v);
    data_t acc = IDENT;

    /* Combine 2 elements at a time */
    for (i = 0; i < limit; i+=2) {
        acc = (acc Op data[i]) Op data[i+1];
    }
    /* Finish any remaining elements */
    for (; i < length; i++) {
        acc = acc Op data[i];
    }
    *dest = acc;
}

void combine6(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    long limit = length-1;
    data_t *data = get_vec_start(v);
    data_t acc0 = IDENT;
    data_t acc1 = IDENT;

    /* Combine 2 elements at a time */
    for (i = 0; i < limit; i+=2) {
        acc0 = acc0 Op data[i];
        acc1 = acc1 Op data[i+1];
    }

    /* Finish any remaining elements */
    for (; i < length; i++) {
        acc0 = acc0 Op data[i];
    }
    *dest = acc0 Op acc1;
}

void combine7(vec_ptr v, data_t *dest) {
    long i;
    long length = vec_length(v);
    long limit = length-1;
    data_t *data = get_vec_start(v);
    data_t acc = IDENT;

    /* Combine 2 elements at a time */
    for (i = 0; i < limit; i+=2) {
        acc = acc Op (data[i] Op data[i+1]);
    }

    /* Finish any remaining elements */
    for (; i < length; i++) {
        acc = acc Op data[i];
    }
    *dest = acc;
}

void calc_time(vec_ptr v, data_t *dest, char *name, func f) {
    clock_t start, finish;
    start = clock();
    f(v, dest);
    finish = clock();
    printf("%s time: %lf\n", name, (double)(finish-start) / CLOCKS_PER_SEC);
}

int main() {
    vec_ptr vec = new_vec(100000000);
    data_t result;
    calc_time(vec, &result, "combine1", combine1);
    calc_time(vec, &result, "combine2", combine2);
    calc_time(vec, &result, "combine3", combine3);
    calc_time(vec, &result, "combine4", combine4);
    calc_time(vec, &result, "combine5", combine5);
    calc_time(vec, &result, "combine6", combine6);
    calc_time(vec, &result, "combine7", combine7);
    return 0;
}

【结果】在本人PC上实验(Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz),结果如下:

# long加法
combine1 time: 0.436511
combine2 time: 0.196069
combine3 time: 0.171531
combine4 time: 0.066199
combine5 time: 0.033421
combine6 time: 0.029695
combine7 time: 0.031934

# long乘法
combine1 time: 0.455121
combine2 time: 0.205159
combine3 time: 0.250897
combine4 time: 0.094907
combine5 time: 0.097381
combine6 time: 0.049683
combine7 time: 0.051188

# double加法
combine1 time: 0.438897
combine2 time: 0.287528
combine3 time: 0.288729
combine4 time: 0.128072
combine5 time: 0.128553
combine6 time: 0.063788
combine7 time: 0.064442

# double乘法
combine1 time: 0.444133
combine2 time: 0.291083
combine3 time: 0.285829
combine4 time: 0.128456
combine5 time: 0.127877
combine6 time: 0.063897
combine7 time: 0.063655