提高CPU执行效率的方法 2020-12-16

在实际应用中,经常会遇见如果提升程序执行效率的问题。这里给出一些具体方案
程序是由CPU执行的,CPU中的Cache(高速缓存)的使用情况对CPU的工作效率起着举足轻重的影响。
这里以Cache为核心,介绍CPU的程序优化

高速缓存 Cache

通常情况下,CPU和内存的运行速度并不一致,CPU访问内存需要200-300个时钟周期。为了弥补他们之间的性能差异,在CPU中引入了高级缓存Cache。

Cache使用SRAM,其价格比内存的DRAM高很多。(1MB的SRAM需要7美金,而1MB的DRAM只需要0.015美金)所以只在CPU中使用少量的SRAM,而内存中只能使用速度稍慢的DRAM。

在CPU中,Cache被分为三级: L1 Cache, L2 Cache, L3 Cache。
其中L1 Cache, 最小,也离CPU最近,可分为数据缓存指令缓存。通常它们的大小是一样的。
L1 Cache 和 L2 Cache 都是每个 CPU 核心独有的,而 L3 Cache 是多个 CPU 核心共享的。
可以使用一下命令查看:

cat /sys/devices/system/cpu/cpu0/cache/index0/size
32K # L1 Cache 数据缓存大小 单个CPU独有
cat /sys/devices/system/cpu/cpu0/cache/index1/size
32K # L1 Cache 指令缓存大小 单个CPU独有
cat /sys/devices/system/cpu/cpu0/cache/index2/size
256K # L2 Cache 大小 单个CPU独有
cat /sys/devices/system/cpu/cpu0/cache/index3/size
12288K ## L3 Cache 大小 多CPU共有

越是靠经CPU核心,其Cache的访问速度越快.

部件 CPU 访问所需时钟周期
L1 Cache 2~4
L2 Cache 10~20
L3 Cache 20~60
内存 200~300

程序执行时,会先将内存中的数据加载到共享的 L3 Cache 中,再加载到每个核心独有的 L2 Cache,最后进入到最快的 L1 Cache,之后才会被 CPU 读取。它们之间的层级关系,

Cache的读取过程

CPU从Cache中读取数据是按块来读取的。每一小块数据称为缓存块Cache Line。
可以使用一下命令查看:

cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
64

CPU在读取数据时,首先会访问Cache,只有当Cache中找不到数据时,才会把内存中的数据读入到Cache中,再从Cache中读取数据。
例如:
当CPU需要读取 int array[100]中的array[0],CPU会先检测Cache中有没有,如果没有就载入 array[0]到Cache。但是array[0]的大小为 4 字节,不足 64 字节,CPU 就会顺序加载数组元素array[0]~ array[15]到Cache中。在下次访问时这些数组元素时,会直接从 CPU Cache 读取,而不用再从内存中读取,提高了 CPU 读取数据的性能。

Cache中的数据结构

Cache 和内存之间都是按数据块读取的。数据快的大小为coherency_line_size,在内存称为内存块Block。所以在读取时所涉及的地址主要是内存块的地址。

直接映射 Cache Direct Mapped Cache

直接映射 Cache 的策略,就是把内存块的地址始终映射在一个 CPU Line(缓存块) 的地址。映射关系实现使用取模运算,取模运算的结果就是内存块地址对应的 CPU Line(缓存块) 的地址。
举个例子
内存共有 32 个块,Cache 共有 8 个 CPU Line,假设 CPU 想要访问第 15 号内存块,如果 15 号内存块中的数据已经缓存在 CPU Line 中的话,则是一定映射在 7 号 CPU Line 中,因为 15 % 8 的值是 7。

不同的内存块可能对应同一个CPU Line,所以在CPU Line中海需要一个组标记(Tag)用来记录CPU Line中的数据对应的内存块。
这样CPU Line中一共包含如下信息:

  • 组标记 (Tag)
  • 实际数据 (Data)
  • 有效位(Valid bit)

CPU在读取CPU Cache的数据时,并不读取整个数据快,而是读取需要的一个数据片段,称为字(Word)。字在CPU Line中的位置称为偏移量(offset)。

至此,一个内存的访问地址包含:

  • 组标记
  • CPU Line 索引
  • 偏移量
    一个CPU Cache里的数据结构包含:
  • 索引
  • 组标记
  • 数据快
  • 有效位
一个内存地址.png
CPU读取内存数据步骤

当内存中的数据已经在 CPU Cache 中后,CPU 访问一个内存地址会经历 4 个步骤:

  1. 根据内存地址中索引信息,计算在 CPU Cache 中的索引,也就是找出对应的 CPU Line 的地址;
  2. 判断对应 CPU Line 中的有效位,判断 CPU Line 中数据是否有效。如果无效,CPU 就会直接访问内存,并重新加载数据到CPU Line,如果有效,则往下执行;
  3. 对比内存地址中组标记和 CPU Line 中的组标记,确认 CPU Line 中的数据是我们要访问的内存数据,如果不是的话,CPU 就会直接访问内存,并重新加载数据,如果是的话,则往下执行;
  4. 根据内存地址中偏移量信息,从 CPU Line 的数据块中,读取对应的字。

如何让CPU 跑的更快——缓存命中

通过上面的介绍,CPU读取Cache的速度比读取内存的速度块了100多倍。如果要读的数据正好就在缓存里面,那数据读取速度将会有极大的提升。

所以代码跑的块的核心问题就是提高缓存命中率
CPU L1 Cache 通常包含数据缓存和指令缓存。

提高数据缓存命中率

在遍历数组时,按照内存布局顺序访问,可以有效的利用 CPU Cache,代码的性能就会得到很大的提升。
一个例子:

    const int N = 100;
    int array[N][N] = {};
    //形式1
    for (int i =0; i < N; i++)
    {
        for (int j = 0; j < N; j++)
        {
            array[i][j] = 0;
        }
    }
//形式2
for (int i =0; i < N; i++)
    {
        for (int j = 0; j < N; j++)
        {
            array[j][i] = 0;
        }
    }

实际上形式1比形式2的结果块了很多倍,因为用形式1访问数组的顺序和内存中数据的存放顺序一致。
当 CPU 访问 array[0][0] 时,由于该数据不在 Cache 中,于是会顺序把跟随其后的 3 个元素从内存中加载到 CPU Cache,这样当 CPU 访问后面的 3 个数组元素时,就能在 CPU Cache 中成功地找到数据,这意味着缓存命中率很高,缓存命中的数据不需要访问内存,这便大大提高了代码的性能。

当N=2时,array在内存中的位置:
{array[0][0], array[0][1], array[1][0], array[1][1]}

而形式2中,对内存的访问是跳跃式的,下一个要访问的元素很难在Cache中找到,所以每次都需要重新读内存中的数据。所以工作效率低。

提高指令缓存命中率

CPU 中含有分支预测器。对于if...else...条件语句,意味着至少可以选择跳转到两段不同的指令执行。如果分支预测可以预测到接下来要执行的是 if 里的指令,还是 else 里指令的话,就可以提前把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快。
所以在代码中,需要能够帮助提升分支预测器的预测成功率。

当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。

一个例子:

const int N = 100;
    int array[N];   
    for (int i =0; i < N; i++)
    {
        array[i] = rand() % 100;
    }
    //方法1
    for (int i =0; i < N; i++)
    {
        if (array[i] < 50)
        {
            array[i] = 0;
        } 
    }
    sort(array, array + N);
    //方法2
    sort(array, array + N);
    for (int i =0; i < N; i++)
    {
        if (array[i] < 50)
        {
            array[i] = 0;
        } 
    }

第一个方法,先判断赋值,再排序。第二个方法,先排序,再判断赋值。
对于这个例子,当array中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。
所以先排序再遍历速度会更快,这是因为排序之后,数字是从小到大的,那么前几次循环命中 if < 50 的次数会比较多,于是分支预测就会缓存 if 里的 array[i] = 0 指令到 Cache 中,后续 CPU 执行该指令就只需要从 Cache 读取就好了。

使用显示分支预测工具——宏 likely , unlikely

如果比较肯定代码中 if 的表达式判断为 true 的概率比较高,可以使用显示分支预测工具。如 C/C++ 中编译器提供了 likely 和 unlikely 这两种宏,如果 if 条件为 ture 的概率大,则可以用 likely 宏把 if 里的表达式包裹起来,反之用 unlikely 宏。

#include
#include

#define likely(x) __builtin_expect(!!(x), 1) //gcc内置函数, 帮助编译器分支优化
#define unlikely(x) __builtin_expect(!!(x), 0)

int main(int argc, char* argv[]){
    int x = 0;
    x = atoi(argv[1]);

    if (unlikely(x == 3)){  //告诉编译器这个分支非常不可能为true
        x = x + 9;
    }
    else{
        x = x - 8;
    }

    printf("x=%d\n", x);
    return 0;
}

多核CPU 绑定

单核 CPU,虽然只能执行一个进程,但是操作系统给每个进程分配了一个时间片,时间片用完了,就调度下一个进程,于是各个进程就按时间片交替地占用 CPU,从宏观上看起来各个进程同时在执行。
现在很多 CPU 都是多核心的,进程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个进程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反如果进程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问 内存的频率。

当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,我们可以把线程绑定在某一个 CPU 核心上,这样性能可以得到非常可观的提升。
在 Linux 上提供了 sched_setaffinity 方法,来实现将线程绑定到某个 CPU 核心这一功能。
CPU亲合力就是指在Linux系统中能够将一个或多个进程绑定到一个或多个处理器上运行.
一个进程的CPU亲合力掩码决定了该进程将在哪个或哪几个CPU上运行.在一个多处理器系统中,设置CPU亲合力的掩码可能会获得更好的性能.

一个CPU的亲合力掩码用一个cpu_set_t结构体来表示一个CPU集合,下面的几个宏分别对这个掩码集进行操作:
·CPU_ZERO() 清空一个集合
·CPU_SET()与CPU_CLR()分别对将一个给定的CPU号加到一个集合或者从一个集合中去掉.
·CPU_ISSET()检查一个CPU号是否在这个集合中.

设置、获取线程CPU亲和力状态的函数:

  • sched_setaffinity(pid_t pid, unsigned int cpusetsize, cpu_set_t mask)
    该函数设置进程为pid的这个进程,让它运行在mask所设定的CPU上。如果pid的值为0,则表示指定的是当前进程,使当前进程运行在mask所设定的那些CPU上。第二个参数cpusetsize是mask所指定的数的长度.通常设定为sizeof(cpu_set_t)。如果当前pid所指定的进程此时没有运行在mask所指定的任意一个CPU上,则该指定的进程会从其它CPU上迁移到mask的指定的一个CPU上运行。
  • sched_getaffinity(pid_t pid, unsigned int cpusetsize, cpu_set_t mask)
    该函数获得pid所指示的进程的CPU位掩码,并将该掩码返回到mask所指向的结构中。即获得指定pid当前可以运行在哪些CPU上。同样,如果pid的值为0,也表示的是当前进程。

磁盘读写的优化

比内存读写更慢的是磁盘。速度相差10倍以上。所以针对磁盘读写的优化操作也能提升程序的执行效率。针对磁盘的优化技术有L零拷贝,直接I/O,异步I/O,以及内核中的磁盘高速缓存。

一般的磁盘操作步骤如下:


CPU磁盘读写流程(转自小林coding).png

可以看出,这种磁盘读写方式需要CPU产于太多细节。

DMA

将I/O和内存之间数据传输任的控制交给一个专门的控制器,称为:直接内存访问计数 DMA(Direct Memory Access)。DMA的工作流程如下图所示:


DMA磁盘读写流程(转自小林coding).png

DMA 在收到磁盘的信号后,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时 CPU 被解放,可以执行其他任务;当DMA数据读取完成后,发送中断信号给CPU,使得CPU回来读取内核缓冲区的数据。

在代码实现层面,传统磁盘读、写多用read()write()函数。一般执行过程如下图所示:

一般磁盘读写函数执行过程.png

在这个过程中,

  1. 有4次CPU内核态,用户态之间的上下文切换,每次切换大约需要耗费几十纳秒到几微微秒。
  2. 有4次数据拷贝,2次DMA,2次CPU。

所以,这里考虑的优化方法为减少用户态和内核态的上下文切换减少内存拷贝次数

减少用户态,内核态的上下文切换次数——减少系统调用

之所以会静茹内核态,是因为用户空间没有磁盘和网卡的直接操作权限。用户程序只能调用系统函数来完成对应功能,让内核去执行对应的功能。然而,每一次系统调用必然会发生2次上下文的切换(用户态-1->内核态-2->用户态)。
所以减少系统调用的次数能够有效的减少上下文的切换

减少数据拷贝——零拷贝

零拷贝(zero-copy)是为了减少读写操作中的那4次拷贝操作。
一般所说的零拷贝是指没有在内存层面拷贝数据,全程由DMA来进行数据的搬运而非CPU。

其基本实现方式有如下2种:

  • mmap + write
    -sendfile
    -SG-DMA

mmap是用来替代read()函数,将内核缓冲区直接映射到了内存上,这样,在执行write操作的时候可以由CPU直接将内核的数据直接写入socket缓冲区。这样节约了缓冲区到内存的一次拷贝。这里可以看出,全过程中还是出现了2次系统条用,4次上下文切换,3次拷贝。

mmap过程.png

sendfile是Linux内核中的函数sendfile()

#include  
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
sendfile过程.png

它可以直接替代read()write()操作,用一次系统调用完成read()write()的功能。在的操作中,系统调用直接将数据从内核缓冲区拷贝到socket的缓冲区中。这里可以看出全过程中,只由一次系统调用,2次上下文切换,3次拷贝。

SG-DMA(The Scatter-Gather Direct Memory Access)是真正的零拷贝技术。SG-DMA的支持情况可以使用如下命令查询:

$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on

SG-DMA过程.png

对于内核版本>2.4时,sendfile()的工作方式为:DMA将数据直接拷贝到内核缓冲区,然后缓冲区的描述符和数据长度直接传到socket的缓冲区。最后网卡的SG-DMA的控制器直接将内核中昂的数据拷贝到网卡的缓冲区里。这样,就直接跳过了socket的缓冲区,只进行了2次拷贝。

由此可以看出使用零拷贝计数,将上下文切换次数由4次减少为2次,数据拷贝次数由4次减少为2次,并且过程中都不需要CPU产于,由DMA直接实现。由此可见零拷贝可以将文件传输性能提高至少1倍以上

PageCache

参考:

https://www.cnblogs.com/xiaolincoding/p/13836230.html
https://www.cnblogs.com/xiaolincoding/p/13719610.html

你可能感兴趣的:(提高CPU执行效率的方法 2020-12-16)