CPU程序性能优化
作者:MegEngine
链接:https://my.oschina.net/u/5265910/blog/10143748
简单认识编译器
编译器的优化选项
GCC
为例,GCC 支持以下优化级别:-O<number>,其中 number 为 0/1/2/3,数字越大,优化级别越高。默认为 -O0。
-Ofast,除了开启 -O3 的所有优化选项外,会额外打开 -ffast-math 和 -fallow-store-data-races。注意这两个选项可能会引起程序运行错误。
-ffast-math: Sets the options -fno-math-errno, -funsafe-math-optimizations, -ffinite-math-only, -fno-rounding-math, -fno-signaling-nans, -fcx-limited-range and -fexcess-precision=fast. It can result in incorrect output for programs that depend on an exact implementation of IEEE or ISO rules/specifications for math functions. It may, however, yield faster code for programs that do not require the guarantees of these specifications.
-fallow-store-data-races: Allow the compiler to perform optimizations that may introduce new data races on stores, without proving that the variable cannot be concurrently accessed by other threads. Does not affect optimization of local data. It is safe to use this option if it is known that global data will not be accessed by multiple threads.
-Og,调试代码时推荐使用的优化级别。
编译器的限制
void twiddle1(long *xp, long *yp) {
*xp += *yp;
*xp += *yp;
}
void twiddle2(long *xp, long *yp) {
*xp += 2 * *yp;
}
xp
和 yp
指向同样的内存(memory aliasing)时,twiddle1
和 twiddle2
是两个完全不同的函数,所以编译器不会尝试将 twiddle1
优化为 twiddle2
。如果本意是希望实现 twiddle2
的功能,应该写成 twiddle2
而非 twwidle1
的形式,twiddle2
只需要 2 次读 1 次写,而 twiddle1
需要 4 次读 2 次写。__restrict
修饰指针,表明不存在和被修饰的指针指向同一块内存的指针,此时编译器会将 twiddle3
优化为和 twiddle2
等效。可自行通过反汇编的方式观察汇编码进一步理解。void twiddle3(long *__restrict xp, long *__restrict yp) {
*xp += *yp;
*xp += *yp;
}
2、side effect
long f();
long func1() {
return f() + f() + f() + f();
}
long func2() {
return 4 * f();
}
f
的实现可能如下,存在 side effect
,所以编译器不会将 func1
优化为 func2
。如果本意希望实现 func2
版本,则应该直接写成 func2
的形式,可减少 3 次函数调用。long counter = 0;
long f() {
return counter++;
}
程序性能优化
每元素的周期数(Cycles Per Element, CPE)
,即每处理一个元素需要花费的周期数,可以表示程序性能并指导性能优化。typedef
来指定元素的数据类型 data_t
。typedef struct {
long len;
data_t *data;
} vec_rec, *vec_ptr;
/* 创建vector */
vec_ptr new_vec(long len) {
vec_ptr result = (vec_ptr)malloc(sizeof(vec_rec));
if (!result)
return NULL;
data_t *data = NULL;
result->len = len;
if (len > 0) {
data = (data_t*)calloc(len, sizeof(data_t));
if (!data) {
free(result);
return NULL;
}
}
result->data = data;
return result;
}
/* 根据index获取vector元素 */
int get_vec_element(vec_ptr v, long index, data_t *dest) {
if (index < 0 || index >= v->len)
return 0;
*dest = v->data[index];
return 1;
}
/* 获取vector元素个数 */
long vec_length(vec_ptr v) {
return v->len;
}
IDENT
和 OP
是宏定义,#define IDENT 0
和 #define OP +
进行累加运算,#define IDENT 1
和 #define OP *
则进行累乘运算。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;
}
}
combine1
,可以进行下面三个基础的优化。combine1
的实现在循环测试条件中反复调用了函数 vec_length
,在此场景下,多次调用 vec_length
会返回同样的结果,所以可以改写为 combine2
的实现进行优化。在极端情况下,注意避免反复调用返回同样结果的函数是更有效的。例如,若在循环结束条件中调用测试一个字符串长度的函数,该函数时间复杂度通常是 O(n)
,若明确字符串长度不会变化,反复调用会有很大的额外开销。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;
}
}
get_vec_start
返回指向数组的开头的指针,在循环中避免调用函数 get_vec_element
。这个优化存在一个 trade off,一方面可以一定程序提升程序性能,另一方面这个优化需要知道 vector 数据结构的实现细节,会破坏程序的抽象,一旦 vector 修改为不使用数组的方式存储数据,则同时需要修改 combine3
的实现。data_t *get_vec_start(vec_ptr v) {
return v->data;
}
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];
}
}
dest
,由于可能存在 memory aliasing
,编译器会谨慎地进行优化。下面分别是 -O1
和 -O2
优化级别时,combine3
中 for
循环部分的汇编代码。可以看到,开启 -O2 优化时,编译器帮我们把中间结果存到了临时变量中(寄存器 % xmm0),而不是像 -O1 优化时每次从内存中读取;但是考虑到 memory aliasing
的情况,即使 -O2 优化,依然需要每次循环将中间结果保存到内存。// combine3 -O1
.L1:
vmovsd (%rbx), %xmm0
vmulsd (%rdx), %xmm0, %xmm0
vmovsd %xmm0, (%rbx)
addq $8, %rdx
cmpq %rax, %rdx
jne .L1
// combine3 -O2
.L1
vmulsd (%rdx), %xmm0, %xmm0
addq $8, %rdx
cmpq %rax, %rdx
vmovsd %xmm0, (%rbx)
jne .L1
combine4
所示。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;
}
// combine4 -O1
.L1
vmulsd (%rdx), %xmm0, %xmm0
addq $8, %rdx
cmpq %rax, %rdx
jne .L1
combine1 版本不同编译优化级别,-O1 的性能是 -O0 的两倍,表明开启适当地编译优化级别是很有必要的。
combine2 将 vec_length 移出循环后,在同样的优化级别编译,相较 combine1 的性能有微小的提升。
但是 combine3 相比 combine2 并没有性能提升,原因是由于循环中的其它操作的耗时可以掩盖调用 get_vec_element 的耗时,之所以可以掩盖,得益于 CPU 支持
分支预测
和乱序执行
,本文的后面会简单介绍这两个概念。同样地,combine3 的 -O2 版本比 -O1 版本性能好很多,从汇编码可以看到,-O2 时比 -O1 每次循环减少了一次对 (% rbx) 的读,更重要的是消除了对 (% rbx) 写后读的访存依赖。
经过 combine4 将中间结果暂存到临时变量的优化,可以看到即使使用 -O1 的编译优化,也比 combine3 -O2 的编译优化性能更好,表明即使编译器有强大的优化能力,但是注意细节来编写高性能代码也是非常有必要的。
以下测试数据引用自《深入理解计算机系统》第五章。
函数 | 优化方法 | int + | int * | float + | float * |
---|---|---|---|---|---|
combine1 | -O0 | 22.68 | 20.02 | 19.98 | 20.18 |
combine1 | -O1 | 10.12 | 10.12 | 10.17 | 11.14 |
combine2 | 移动 vec_length -O1 | 7.02 | 9.03 | 9.02 | 11.03 |
combine3 | 减少过程调用 -O1 | 7.17 | 9.02 | 9.02 | 11.03 |
combine3 | 减少过程调用 -O2 | 1.60 | 3.01 | 3.01 | 5.01 |
combine4 | 累积到临时变量 -O1 | 1.27 | 3.01 | 3.01 | 5.01 |
指令级并行
完整的 Intel Core i7 Haswell 的硬件结构见:https://en.wikichip.org/w/images/c/c7/haswell_block_diagram.svg
硬件性能
指令级并行:即通过指令流水线技术,支持同时对多条指令求值。
乱序执行:指令的执行顺序未必和其书写的顺序一致,可以使硬件达到更好的指令级并行度。主要是通过乱序执行、顺序提交的机制,使得能够获得和顺序执行一致的结果。
分支预测:当遇到分支时,硬件会预测分支的走向,如果预测成功则能够加快程序的运行,但是预测失败的话则需要把提前执行的结果丢弃,重新 load 正确指令执行,会带来比较大的预测错误惩罚。
延迟
、发射时间
和容量
来度量。延迟:执行完一条指令需要的时钟周期数。
发射时间:两个连续的同类型的运算之间需要的最小时钟周期数。
容量:某种执行单元的数量。从上图可以看出,在
EUs
中,有 4 个整数加法单元 (INT ALU)、1 个整数乘法单元 (INT MUL)、1 个浮点数加法单元 (FP ADD) 和 2 个浮点数乘法单元 (FP MUL)。
运算 | 延迟 (int) | 发射时间 (int) | 容量 (int) | 延迟 (float) | 发射时间 (float) | 容量 (float) |
---|---|---|---|---|---|---|
加法 | 1 | 1 | 4 | 3 | 1 | 1 |
乘法 | 3 | 1 | 1 | 5 | 1 | 2 |
combine
函数的性能,我们用 CPE 的两个界限来描述这种影响。吞吐界限是理论上的最优性能。延迟界限:任何必须按照严格顺序完成
combine
运算的函数所需要的最小 CPE,等于功能单元的延迟。吞吐界限:功能单元产生结果的最大速率,由
容量/发射时间
决定。若使用 CPE 度量,则等于容量/发射时间
的倒数。
combine
函数需要 load 数据,故要同时受到加载单元的限制。由于只有两个加载单元且其发射时间为 1 个周期,所以整数加法的吞吐界限在本例中只有 0.5 而非 0.25。界限 | int + | int * | float + | float * |
---|---|---|---|---|
延迟 | 1.0 | 3.0 | 3.0 | 5.0 |
吞吐 | 0.5 | 1.0 | 1.0 | 0.5 |
处理器操作的抽象模型
数据流图
,这是一种图形化表示方法,展现了不同操作之间的数据相关是如何限制它们的执行顺序的。这些限制形成了图中的关键路径
,这是执行一组机器指令所需时钟周期的一个下界。combine4
的 for 循环对应的数据流图。其中箭头指示了数据的流向。可以将寄存器分为四类:只读:这些寄存器只用作源值,在循环中不被修改,本例中的
%rax
。只写:作为数据传送的目的。本例没有这样的寄存器。
局部:在循环内部被修改和使用,迭代与迭代之间不相关,比例中的条件码寄存器。
循环:这些寄存器既作为源值,又作为目的,一次迭代中产生的值会被下一次迭代用到,本例中的
%rdx
和%xmm0
。由于两次迭代之间有数据依赖,所以对此类寄存器的操作通常是程序性能的限制因素。
combine4
中计算的是浮点数乘法,由于支持指令级并行,浮点数乘法的的延迟能够掩盖整数加法 (指针移动,图中右半边的路径) 的延迟,所以 combine4
CPE 的理论下界就是浮点乘法的延迟 5.0,与上面给出的测试数据 5.01 基本一致。 循环展开
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 acc0 = IDENT;
data_t acc1 = IDENT;
for (i = 0; i < limit; i += 2) {
acc0 = acc0 OP data[i];
acc1 = acc1 OP data[i + 1];
}
for (; i < length; ++i) {
acc0 = acc0 OP data[i];
}
*dest = acc0 OP acc1;
}
combine5
的关键路径的数据流图如下,图中有两条关键路径,但两条关键路径是可以指令级并行的,每条关键路径只包含 n/2
个操作,因此性能可以突破延迟界限,理论上浮点乘法的 CPE 约为 5.0/2=2.5
。 函数 | 展开次数 | int + | int * | float + | float * |
---|---|---|---|---|---|
combine5 | 2 | 0.81 | 1.51 | 1.51 | 2.51 |
combine5 | 10 | 0.55 | 1.00 | 1.01 | 0.52 |
combine5 | 20 | 0.83 | 1.03 | 1.02 | 0.68 |
延迟界限 | / | 1.00 | 3.00 | 3.00 | 5.00 |
吞吐界限 | / | 0.50 | 1.00 | 1.00 | 0.50 |
SIMD(single instruction multi data)
SIMD
是另外一种行之有效的性能优化手段,不同于指令级并行,其采用数据级并行
。SIMD 即单指令多数据,一条指令操作一批向量数据,需要硬件提供支持。X86 架构的 CPU 支持 AVX 指令集,ARM CPU 支持 NEON 指令集。在我们开发的一款深度学习编译器 MegCC 中,就广泛使用了 SIMD 技术。MegCC 是旷视天元团队开发的深度学习编译器,其接受 MegEngine 格式的模型为输入,输出运行该模型所需的所有 kernel,方便模型部署,具有高性能和轻量化的特点。为了方便用户将其它格式的模型转换为 MegEngine 格式模型,旷视天元团队同时提供了模型转换工具 MgeConvert,您可以将模型转换为 onnx
,然后使用 MgeConvert 转换为 MegEngine 格式模型。同时如果您想测试您设备上某条指令的吞吐和延迟,以指导您的优化,可以使用 MegPeak。DOT
指令可完成 32 次乘加运算 (16 次乘法和 16 次加法运算);一条 I8MM
指令可完成 64 次乘加运算 (32 次乘法和 32 次加法运算)。这就是 SIMD 技术能够加速计算的原理。参考资料
Randal E. Bryant, David R. O’Hallaron. Computer Systems: A Programmer’s Perspective, Chapter 5.
Antonio González, Fernando Latorre, Grigorios Magklis. Processor Microarchitecture: An Implementation Perspective, Chapter 1.
https://github.com/MegEngine/MegCC
END
微信扫码关注该文公众号作者