有讲C/C++代码优化和编译器优化的书或文章吗?
栏目:公司新闻 发布时间:2024-05-06
深度探索C++对象模型推荐AgnerFog的5本优化手册:OptimizingsoftwareinC++:AnoptimizationguideforWindows,LinuxandMacplatformsOpti

深度探索C++对象模型

推荐 Agner Fog 的 5 本优化手册:

  1. Optimizing software in C++: An optimization guide for Windows, Linux and Mac platforms
  2. Optimizing subroutines in assembly language: An optimization guide for x86 platforms
  3. The microarchitecture of Intel, AMD and VIA CPUs: An optimization guide for assembly programmers and compiler makers
  4. Instruction tables: Lists of instruction latencies, throughputs and micro-operation breakdowns for Intel, AMD and VIA CPUs
  5. Calling conventions for different C++ compilers and operating systems
Software optimization resources. C++ and assembly. Windows, Linux, BSD, Mac OS X

1,高级编译器设计与实现

2,计算机体系结构-量化研究方法

优化方向的九阴真经和九阳神功,前提是能看懂

编译器优化是通过改进运行时性能或最小化代码大小来提高已编译代码效率的过程。

在本视频中(转录文本见下文),Incredibuild 的开发推广工程师 Amir Kirsh 将重点讨论 C++ 编译器执行的一些特定的和有趣的优化。

【C++编译优化实战1-2】

C++编译优化实战1-2_哔哩哔哩_bilibili

简析C++编译优化以及Loop Unrolling功能演示

c++ 优化器是如何优化代码的呢?让我们深入研究一段代码。我将和大家分享我的 Coliru 在线编译器。

无锁多线程

在这个例子中,我给出了一个多线程程序,为什么向您展示多线程程序呢。这里有一个叫做“加法器”的函数,它可以将数字添加到全局变量和中,在这个程序中,全局变量和是由多个线程并行处理的,并没有锁定,这一点并不好,这是众所周知的问题。我们有一个竞争环境,结果可以是任何东西,但是,这个想法的目的是检查实际结果是什么。如上所述,和是一个全局变量,“加法器”运行一个循环,并将指数 “i” 和 x 的乘积加入和中。总的来说,我们启动了 10 个线程。每个线程都运行“加法器”,且所有 10 个线程都将数字相加到同一全局变量和中。

因此,如果我们想要检查结果会是什么,可能我们只需运行一次,就能得到预期的结果,我们可以计算并看看有锁时会发生什么。但是,如果我们多次运行程序,我们会发现结果确实并不总是相同的。所以我在这里做的是运行一个小的 bash 脚本。在这里,我将程序运行了 500 次,然后我对结果排序并计算唯一的结果,最终我得到了我得到每个特定输出的次数。我们可以看到,500 次中有 490 次,我得到了一些结果,这是预期的结果,这是正确的计算,如果我有锁的话。但是我们还有 10 个其他结果,则是另外一个结果。

它确实表明,我们有理由需要在这里使用一个原子整数或使用锁。但我的问题是,为什么我们得到的所有结果都是 50 的乘积呢?为什么我们没有其他的结果,比如 267355、267384、或者很多其他的结果?答案就是编译器优化,经过优化器的处理,结果成为了约为 10 的乘积或 50 的乘积,而优化器所作的这种处理就是循环展开。

循环展开

优化器看到这个循环,然后它说,好吧,我不确定你是否真的需要一个循环,也许我可以不用循环计算整个过程。所以函数加法器变成了一个单独的计算,循环没有了,这叫做循环展开。

一旦循环消失,那么线程之间的竞争,也就是竞争条件,就会出现在由优化器计算的整个计算中。循环中的和是多少,是 50 的乘积之类的。现在,我们能验证这个理论吗? 当然可以,我们试着将优化标志从 -O2 改为 -O0,并重新编译。因此,我在这里再次将优化标志更改为 -O0 编译。我们可以看到,结果现在更广泛地分布在各种数字中,因为现在没有循环展开。循环实际上在代码中。所以我们实际上进入了循环,竞争条件现在是循环中的每一个加法,而不是单次计算。大多数时候,我们得到的数据与之前相同,但其他的数据更加分散。

汇编

说到汇编代码,我们可以使用另一个在线工具 Compiler Explorer。我们可以看到相同代码上的 -O0 和 -O2 会导致不同的汇编代码。使用 -O0,我们在这里谈到的“加法器”函数确实有一个循环。我们可以看到,我们转到有一些计算的代码,然后在这一行进行比较,如果比较大于某个数字,则继续,否则跳转回循环。

这就是我们看到的循环,在使用 -O2 优化的代码中,汇编代码,在“加法器”函数中,汇编命令只是计算,一行或两行计算,两个计算命令,没有任何内部循环,这就是我们在这看到的循环展开。

以上我们谈论了由 C++ 编译器所做的优化,也就是循环展开。在我们的示例中,我们看到了它如何影响多线程应用程序,它并没有将我们从竞争条件中拯救出来,因为无论如何竞争条件都在“加法器”函数中,但是如果我们没有循环展开,竞争条件的表现会有所不同。这就是今天的内容。我们将在下一次会谈中继续讨论,看看优化器能为我们做些什么。

今天,我想继续我之前的讨论,并介绍一些更为简单的 C++ 编译器、优化器和循环展开。

Compiler Explorer

那么,我们就来看看 Compiler Explorer 吧。这是一款在线工具,用来查看被汇编的 C++ 代码。希望大家能够看到我的屏幕,我将写一个非常简单的函数。我们将它称为 foo 吧。Foo 得到一个整数并返回一个整数。在 foo 内部,我想要一个简单的循环。我想要在 I 上循环,当 i 达到 10 且增量为 i 时循环停止,最后,我想要返回 i。所以,当我们看这个函数时,嗯,这儿会有两个可能的结果,要么得到小于 10 的 i,在这种情况下,返回值为 10,要么得到大于或等于 10 的 i。然后我们只返回 i,所以要么 foo 将返回 i,要么 foo 将返回 10,但问题是循环是否会出现在实际汇编中,答案是,这取决于优化级别。

如果我们的优化级别为 -O2,那么确实不需要循环,也不会有循环。如果这儿我将其更改为 -O0,那么你会看到一个内部循环,之所以我们在这儿可以看到,是因为我们要求编译器不要做任何优化。实际上这里有一个循环,有人把这个值和 9 进行了比较。然后决定是回到循环还是结束循环。

让我们回到 -O2。这个函数变成了一个简单的 if 条件函数,一个简单的比较,我们取 10 并把它放在整数的返回值寄存器中。然后,我们将返回值寄存器中的值与我们得到的位于 EDI 寄存器中的参数值进行比较。然后我们会有一个条件转移。如果之前的比较结果为大于或等于,那么我们将 EDI 寄存器复制到返回值。否则,我们就不进行复制。我们继续使内部保持为 10。所以最终它会返回 10 或 i,这取决于 i 是否大于 10,这就是一个简单的 if 条件。在这里我们可以看到,编译器可以接受一个循环,并将其转换成不需要循环的情况。这就叫做循环展开。这是一个非常简单的例子,比我们在前一个视频中看到的要简单得多,尽管前一个更逼真,但这是一个虚构的例子。

现在我们在示例中添加一个 main,并在 main 中调用 foo。我们可以再次看到,main 中有一些与 foo 中相同的函数,如果要返回值,那么我们就返回 foo 的返回值。所以我们返回 foo ,我们可以看到实际上没有必要调用 foo。这由编译器或优化器决定,好吧,我们将返回 120,因为在某种程度上,foo 是内联的。结果是 120。如果我们把它变为 5,那么我们可以看到结果给出的是 10。实际上没有必要调用 foo,因为在某种程度上 foo 被内联至 main。优化器可以在编译时执行 if,并根据它们发送给 foo 的初始值决定是从 main 10 返回还是从其他值返回。

循环展开

我们在 main 中还可以做一些其他事情,比如,执行循环。我们将 int i 放入 main 中。然后看看并决定返回 i,所以我们有一些类似于在 foo 中所执行的操作,但是这里的 i 并不是我们作为参数而得到的变量,我们只是定义 i,我们有一个变量 i,它是一个局部变量。我们运行这个循环,然后返回 i。如果我们看一下汇编代码,我们会发现 main 仅返回 10,那 main 为什么会返回 10 呢?这是否意味着局部变量被初始化为零?不,并不是那样,而是意味着优化器假设了一些东西,我们这里有未定义的行为,而这个未定义的行为就是我们使用未初始化的变量。一旦你使用了未初始化的变量,优化器就能假设任何东西。这里优化器决定假设 i 小于 10 或小于等于 10,因为如果 i 大于 10,那么结果应该是 i,而事实并非如此。我们只是返回 10。所以优化器在这假设了一些东西。这没关系。这是合理的,因为一旦我们有了一个未定义的行为,比如,我们对一些未初始化的东西设置了条件,优化器就可以假设它需要或想要假设的任何东西来优化代码。在这个例子中,实际上并不需要检查 i,因为 i 并未初始化,我们假设它小于或等于 10。

我们能在 main 中看到实际的循环吗?好吧,如果我们返回到 -O0,那么我们可能会找到循环,即使是在 main 中,因为我们要求优化器或编译器不要优化我们的代码。我并不是告诉大家使用未定义的行为。不,一定要初始化你的变量,但是你必须要知道是的是,一旦你不初始化一个变量或一旦你的代码依赖于未定义的行为,这就意味着优化器和编译器可以假设一些东西。当你将代码移动至另一编译器或使用其它一些优化标志时,你的代码可能会有不同的行为,而这正是未定义行为的结果。

今天我们讨论了循环展开、C++ 优化器,以及关于未定义行为的一些内容。谢谢观看。再见!

点击了解 Incredibuild 加速 C++ 构建编译的解决方案,并获取试用 License!


本篇回答主要分为两部分,一是对 深度理解计算机系统(Computer Systems: A programmer's Perspective, CSAPP) 第五章的一个总结;二是深度学习编译器 MegCC 的简要介绍。

一个程序首先要保证正确性,在保证正确性的基础上,性能也是一个重要的考量。要编写高性能的程序,第一,必须选择合适的算法和数据结构;第二,应该编写编译器能够有效优化以转换成高效可执行代码的源代码,要做到这一点,需要了解编译器的能力和限制;第三,要了解硬件的运行方式,针对硬件特性进行优化。本文着重展开第二点和第三点。

要写出高性能的代码,首先需要对编译器有基础的了解,原因在于现代编译器有很强的优化能力,但有些代码编译器不能进行优化。对编译器有了基础的了解,才能写出编译器友好型高性能代码。

以 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,调试代码时推荐使用的优化级别。
gcc -Q –help=optimizer -Ox 可查看各优化级别开启的优化选项。 参考链接:gcc.gnu.org/onlinedocs/

为了保证程序运行的正确性,编译器不会对代码的使用场景做任何假设,所以有些代码编译器不会进行优化。下面举两个比较隐晦的例子。

1、memory aliasing

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),即每处理一个元素需要花费的周期数,可以表示程序性能并指导性能优化。 下面通过一个例子介绍几个优化程序性能的手段。首先定义一个数据结构 vector 以及一些辅助函数, vector 使用一个连续存储的数组实现,可通过 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,可以进行下面三个基础的优化。

1、对于多次执行返回同样结果的函数,使用临时变量保存 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;
    }
}

2、减少过程调用 过程(函数)调用会产生一定的开销,例如参数传递、clobber 寄存器保存恢复和转移控制等。所以可以新增一个函数 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];
    }
}

3、消除不必要的内存引用 在上面的实现中,循环中每次都会去读一次写一次 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

以上优化方法的效果可以通过 CPE 来度量,在 Intel Core i7 Haswell 的测试结果如下。从测试结果来看:

  • 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-O022.6820.0219.9820.18
combine1-O110.1210.1210.1711.14
combine2移动 vec_length -O17.029.039.0211.03
combine3减少过程调用 -O17.179.029.0211.03
combine3减少过程调用 -O21.603.013.015.01
combine4累积到临时变量 -O11.273.013.015.01


以上优化不依赖于目标机器的任何特性,只是简单地降低了过程调用的开销,以及消除一些“妨碍优化的因素”,这些因素会给编译器优化带来困难。要进行进一步优化,需要了解一些硬件特性。下图是 Intel Core i7 Haswell 的硬件结构的后端部分:

完整的 Intel Core i7 Haswell 的硬件结构见:en.wikichip.org/w/image

该 CPU 支持以下特性:

  • 指令级并行:即通过指令流水线技术,支持同时对多条指令求值。
  • 乱序执行:指令的执行顺序未必和其书写的顺序一致,可以使硬件达到更好的指令级并行度。主要是通过乱序执行、顺序提交的机制,使得能够获得和顺序执行一致的结果。
  • 分支预测:当遇到分支时,硬件会预测分支的走向,如果预测成功则能够加快程序的运行,但是预测失败的话则需要把提前执行的结果丢弃,重新 load 正确指令执行,会带来比较大的预测错误惩罚。

上图中,主要关注执行单元(EUs),执行单元由多个功能单元组成。功能单元的性能可以由延迟、发射时间和容量来度量。

  • 延迟:执行完一条指令需要的时钟周期数。
  • 发射时间:两个连续的同类型的运算之间需要的最小时钟周期数。
  • 容量:某种执行单元的数量。从上图可以看出,在 EUs 中,有 4 个整数加法单元(INT ALU)、1 个整数乘法单元(INT MUL)、1 个浮点数加法单元(FP ADD)和 2 个浮点数乘法单元(FP MUL)。

Intel Core i7 Haswell 的功能单元性能数据(单位为周期数)如下,引自《深入理解计算机系统》第五章:


运算延迟(int)发射时间(int)容量(int)延迟(float)发射时间(float)容量(float)
加法114311
乘法311512


这些算术运算的延迟、发射时间和容量会影响上述 combine 函数的性能,我们用 CPE 的两个界限来描述这种影响。吞吐界限是理论上的最优性能。

  • 延迟界限:任何必须按照严格顺序完成 combine 运算的函数所需要的最小 CPE,等于功能单元的延迟。
  • 吞吐界限:功能单元产生结果的最大速率,由容量/发射时间决定。若使用 CPE 度量,则等于容量/发射时间的倒数。

由于 combine 函数需要 load 数据,故要同时受到加载单元的限制。由于只有两个加载单元且其发射时间为 1 个周期,所以整数加法的吞吐界限在本例中只有 0.5 而非 0.25。


界限int +int *float +float *
延迟1.03.03.05.0
吞吐0.51.01.00.5


为了分析在现代处理器上执行的机器级程序的性能,我们引入数据流图,这是一种图形化表示方法,展现了不同操作之间的数据相关是如何限制它们的执行顺序的。这些限制形成了图中的关键路径,这是执行一组机器指令所需时钟周期的一个下界。 通常 for 循环会占据程序执行的大部分时间,下图是 combine4 的 for 循环对应的数据流图。其中箭头指示了数据的流向。可以将寄存器分为四类:

  1. 只读:这些寄存器只用作源值,在循环中不被修改,本例中的 %rax。
  2. 只写:作为数据传送的目的。本例没有这样的寄存器。
  3. 局部:在循环内部被修改和使用,迭代与迭代之间不相关,比例中的条件码寄存器。
  4. 循环:这些寄存器既作为源值,又作为目的,一次迭代中产生的值会被下一次迭代用到,本例中的 %rdx 和 %xmm0。由于两次迭代之间有数据依赖,所以对此类寄存器的操作通常是程序性能的限制因素

将上图重排,并只留下循环寄存器相关的路径,可得到简化的数据流图。

将简化完的数据流图进行简单地重复,可以得到关键路径,如下图。如果 combine4 中计算的是浮点数乘法,由于支持指令级并行,浮点数乘法的的延迟能够掩盖整数加法(指针移动,图中右半边的路径)的延迟,所以 combine4CPE 的理论下界就是浮点乘法的延迟 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。

假如增加临时变量的个数进一步增加循环展开次数,理论上可以提高指令并行度,最终达到吞吐界限。但是不能无限制地增加循环展开次数,一是由于硬件的功能单元有限,CPE 的下界由吞吐界限限制,达到一定程度后继续增加也不能提高指令并行度;二是由于寄存器资源有限,增加循环展开次数会增加寄存器的使用,使用的寄存器个数超过硬件提供的寄存器资源之后,则会发生寄存器溢出,可能会需要将寄存器的内存临时保存到内存,使用时再从内存恢复到寄存器,反而导致性能的下降,如下表中循环展开 20 次相较展开 10 次性能反而略有下降。幸运的是,大多数硬件在寄存器溢出之前已经达到了吞吐界限。


函数展开次数int +int *float +float *
combine520.811.511.512.51
combine5100.551.001.010.52
combine5200.831.031.020.68
延迟界限/1.003.003.005.00
吞吐界限/0.501.001.000.50


SIMD 是另外一种行之有效的性能优化手段,不同于指令级并行,其采用数据级并行。SIMD 即单指令多数据,一条指令操作一批向量数据,需要硬件提供支持。X86 架构的 CPU 支持 AVX 指令集,ARM CPU 支持 NEON 指令集。在我们开发的一款深度学习编译器 MegCC 中,就广泛使用了 SIMD 技术。MegCC 是旷视天元团队开发的深度学习编译器,其接受 MegEngine 格式的模型为输入,输出运行该模型所需的所有 kernel,方便模型部署,具有高性能和轻量化的特点。为了方便用户将其它格式的模型转换为 MegEngine 格式模型,旷视天元团队同时提供了模型转换工具 MgeConvert,您可以将模型转换为 onnx,然后使用 MgeConvert 转换为 MegEngine 格式模型。同时如果您想测试您设备上某条指令的吞吐和延迟,以指导您的优化,可以使用 MegPeak。 MegCC 中实现了许多高性能的深度学习算子,卷积和矩阵乘法是典型的计算密集型的算子,同时卷积也可以借助矩阵乘法来实现(im2col/winograd 算法等)。 MegCC 在 ARM 平台支持了 NEON DOTI8MM 指令实现的矩阵乘和卷积。一条 DOT 指令可完成 32 次乘加运算(16 次乘法和 16 次加法运算);一条 I8MM 指令可完成 64 次乘加运算(32 次乘法和 32 次加法运算)。这就是 SIMD 技术能够加速计算的原理。

  1. Randal E. Bryant, David R. O’Hallaron. Computer Systems: A Programmer’s Perspective, Chapter 5.
  2. Antonio González, Fernando Latorre, Grigorios Magklis. Processor Microarchitecture: An Implementation Perspective, Chapter 1.
  3. github.com/MegEngine/Me

更多 MegEngine 信息获取,您可以:查看文档GitHub 项目,或加入 MegEngine 用户交流 QQ 群:1029741705。欢迎参与 MegEngine 社区贡献,成为 Awesome MegEngineer,荣誉证书、定制礼品享不停。

平台注册入口