此文能够加强读者对于cpu和cache的工作原理的理解,这是实现高性能编程必备的知识点。文章不长,让我们从一个简单的问题说起,为什么一个程序死循环时它的cpu占用会达到100%?
这个问题虽然简单,但不一定人人都能答得出来。我们直接从问题说起,程序的CPU占用达到100%,说明在它的时间片内,CPU一直在运行指令。
这仿佛是句废话,但是反过来看,CPU有哪些时候不运行指令呢?进程阻塞的时候?上下文切换还真能让CPU空闲一会!不过这没有说到点子上。
让我们先想一下,CPU能和哪些存储单元通信?有基础的同学肯定都清楚,寄存器和L1 cache。也就是说,CPU要访问内存中的数据中的话,必须先将数据从内存中加载进缓存才行,在将数据从内存载入cpu缓存这个时间段中,CPU其实是一直都处于空转状态的(因为不知道要干啥...),这可是对CPU资源的浪费,为了充分利用CPU资源,人们创造了超线程技术,如单核2个线程,当一个线程缓存缺失时,运行另一个线程,基于此也才有了CPU物理核与逻辑核之分。
那么上面的问题,程序死循环时的CPU占用达到100%的原因就已经出来了,因为该程序的指令和数据都已经在cache中,因此在其时间片内,CPU能一直进行运算,那其CPU占用自然就是100%了。此点恰好是我们编写高性能程序的基础。也就是说,我们要尽可能的让CPU接下来要执行的指令和数据都恰好落在cache中(除了死循环还真的很难找到CPU占用能到100%的程序...)。
上面那句话其实隐含的信息量非常大。我接下来将针对指令和数据分别举例。在这之前,我们需要了解一下cache的基本结构。cpu cache是以cache line(缓存段)为单位来进行管理的,即当cpu要执行的指令或者数据不在cache中时,都会通过总线从内存中加载一个缓存段大小的数据(访问一次内存足够cpu执行几十到几百条指令了)。常见的缓存段大小有32byte,64byte,128byte,因cpu差异而定。缓存段又分为数据缓存段和指令缓存段(为什么这么分呢?其实拍脑袋也知道肯定是为了提高缓存命中,在这里先不解释为什么,读者看完这篇文章自然就明白了),下面我们可以讲讲如何利用cache工作原理了。
针对指令cache line
由于每次内存加载进cache时都是以一个缓存段为单位的,因此如果cpu接下来要执行的指令也位于这次加载进来的缓存段中的话,很明显这将提高缓存命中率,减少内存访问次数,进一步提升cpu的利用率。那么对于编程而言,如果程序的逻辑分支很少,那么其指令缓存命中率必然相应也会较高。道理很简单,实际上经常不经意就忽略了,我们看下面的例子
for(...){
if(...)
...
else
...
}
上面这种代码我们可以视为有2*N个逻辑分支,其中N为循环次数,2的来由是if-else判断。我们对其简单修改:
if(...)
for(...){
...
}
else
for(...){
...
}
这个代码的逻辑分支只有2条,即刚开始的if-else,其指令缓存命中率相对于第一个例子将会得到很大提升。
相信读者都知道c/cpp的内联函数,在学习内联函数的过程中,我们常看到这样一句话,内联函数的代码不能太长,否则可能反而会降低程序的性能。这是为何?在编译过程中内联函数会被展开,即少了压栈,跳转(是的,这还可能导致cache缺失),出栈等操作,当代码很短时自然能加快程序速度。但是当内联函数的代码很长时,由于每一处调用,其都将展开,这种冗余将会增加整体的指令数目(而以函数形式调用时,该函数将位于独立的缓存段中),因此当内联函数代码太长时,可能导致程序整体的指令缓存命中率下降,进而拉低程序的性能。
针对数据cache line
同样还是由于每次内存加载进cache时都是以一个缓存段为单位的,因此如果cpu接下来要访问的数据也位于这次加载进来的缓存段中的话,很明显这将提高缓存命中率,减少内存访问次数,进一步提升cpu的利用率。比如对于下面的代码
代码一:
int elts[1000][1000];
...//初始化
int i,j,sum = 0;
for(i = 0;i < 1000;i++)
for(j = 0;j < 1000;j++){
sum += elts[j][i];
}
代码二:
int elts[1000][1000];
...//初始化
int i,j,sum = 0;
for(i = 0;i < 1000;i++)
for(j = 0;j < 1000;j++){
sum += elts[i][j];
}
这是一个经常被拿来考的题目,在我校招笔试那年碰到过这样一个题目,上面两份代码哪份的运行效率更高呢?自然是第二份,因为二维数组的内存是连续的,第二份代码访问的是连续的内存,由于读取内存是以缓存段为单位进行读取的,这样大部分接下来要读写的内存都会落在cache中,从而减少了内存访问次数。这个例子虽然比较笨,但是最容易理解。
???
上面的几点其实还是非常基础的东西,对于有一定编程经验的读者想必都早已清楚。接下来我们讲一点更细节一点的东西。在谈及细节之前,先谈点其它的。我们知道现在我们的机器往往都不止一个CPU核,那么多核环境下,cache是被多个核共用的吗?答案是否定的,因为这样会导致每个指令周期只有一个CPU核能操作cache,其余CPU核必须等待才行(否则就全乱套了),从而使得整个系统都慢了下来。为了避免这种情况的发生,实际情况是,我们的每个CPU核都会有一套cache,但多套cache同样带来了一个问题,即如何保证数据的一致性(原来不止是分布式系统会有这个问题!反过来看其实分布式系统又何尝不是一个更大的CPU呢?)。这里并不想谈及如何保证数据一致性,有兴趣的读者可以可以阅读缓存一致性(Cache Coherency)入门一文。总之,在这里,我们只需要明白,当有多个CPU核时,对应的也有多套cache。下面我将从大到小,分别以进程和数据来分析如何优化我们的程序性能。
进程
由于多核的存在,默认创建出来的进程都是会在多个核中运行的。这会导致一个问题,比如在进程A的第一个时间片中在CPU1中运行,而其第二个时间片被调度到CPU2中去了,此时必须再将进程A的指令及数据加载进CPU2中的cache中来,从而带来无谓的损耗,毕竟我们已经在CPU1的cache中加载过一份了,并且这基本上会导致CPU1中的进程A的cache数据失效!对于此种情况,很明显,如果进程A一直运行在CPU1中的话,其cache的运用效率会得到大大提升,从而提升程序的性能!那么有什么方法能做到吗?是的,通过设置进程的CPU亲缘性,我们可以做到让某个进程只运行在某个CPU中(尽管有些牵强,但不知读者是否有联想到分布式系统中的一致性hash?),关于CPU亲缘性,这里不想多谈,有兴趣的读者可以自行搜索。但是,设置完CPU亲缘性之后同样会带来一个问题!假设现在进程A只在CPU1中执行,如果CPU1总是不幸有其它进程一起竞争的话,那程序的性能岂不是反而减少了(毕竟总运行时间缩短了)。如何处理这种情况呢?
其实也很简单,我们只需要把我们的服务的各个关键进程绑到不同的核上,再将它们的进程优先级设置的高一点即可。进程部分就讲到这里了。下面我们看数据部分。
数据
上面有提到cpu cache的MSEI协议,我们知道Share状态的缓存数据,是可能在多个cpu核的cache中存在的,一旦有CPU核需要修改时,需要将其变为Exclusive状态,而当此CPU核对缓存进行修改后,其它cpu核的cache都将失效。这对于我们编程有什么需要注意的地方吗?
当然是有的,比如对于如下一个结构
typedef struct{
char always_read[32];
char always_write[32];
}example;
假设我们开辟了一片共享内存(注意,共享内存映射到进程空间后的起始地址总是页面大小的整数倍,这其实是很关键的一个设计)用于存储example结构,系统中有多个进程要对其频繁进行访问。系统的各个进程对其进行访问时,对于always_read成员经常都是读操作,偶尔才有写操作,对于always_write成员总是读写操作。请问这样设计有问题吗?
对于有一定编程经验的读者来说,肯定有过类似上面这样设计的编程经验,可以大胆的说,这样当然没有问题。但却不是性能最优。回忆一下上面提到的知识点
1 缓存段的大小一般是64字节(32和128的也存在,64的最常见,跟CPU架构有关,可通过cat /sys/devices/system/cpu/cpu1/cache/index0/coherency_line_size 命令进行查看不同级别cache的缓存段大小)
2 每个cpu核都有一组cache
3 如果某个cpu核对某个cache进行修改后,其余cpu核的对于该cache的缓存段都失效了
对于上面的设计,example结构中的always_read和always_write恰好落在一个缓存段中,即每次有cpu核对always_write改写之后,连带着其余cpu核中的always_read也一同失效了,当其余cpu核要对其进行读操作时,必须重新从内存中加载该数据,这会造成很多无意义的缓存缺失情况,因为对于always_read成员的访问经常都是读操作而已。
如何解决这种尴尬的情况呢?其实也很简单,我们直接看代码
typedef struct{
char always_read[32];
char padding[32];
char always_write[32];
}example;
如上,我们只需要将always_read和always_write划分到不同的缓存段中即可。
结尾
文章内容差不多就到这里了。其实道理很简单,但是真正运用起来,并不是能轻易做到的,有些东西需要真正的理解通透了,且时刻都对程序性能有强烈的敏感度才能真正在编程过程中运用起来。希望每位程序员都能做到如此。