有些背景知识是分析性能问题时需要了解的。比如硬件 cache;再比如操作系统内核。应用程序的行为细节往往是和这些东西互相牵扯的,这些底层的东西会以意想不到的方式影响应用程序的性能,比如某些程序无法充分利用 cache,从而导致性能下降。比如不必要地调用过多的系统调用,造成频繁的内核/用户切换等等。这边文档只做简单介绍和铺垫作用,关于调优还有很多东西要去研究。
当算法已经优化,代码不断精简,人们调到最后,便需要斤斤计较了。cache ,流水线等一类平时不大注意的东西也必须精打细算了。
page fault
计算机的物理内存是有限, 但应用程序的需求是无限的。为了解决这个矛盾,操作系统使用了虚拟内存的设计。简单的描述就是,给应用程序一个与物理内存无关的虚拟地址空间,并提供一套映射机制,将虚拟地址映射到物理内存。当然应用程序是不知道有这个映射机制存在的。
操作系统提供的映射机制是运行时动态进行虚拟地址和物理地址之间的映射的,通过MMU查找该虚拟地址在物理内存的映射。如果没有找到对应的物理内存时候,映射机制就分配物理内存,构建映射表,满足应用程序的需求,这个过程就叫page fault,如下图所示:
与直接访问物理内存不同,page fault过程大部分是由软件完成的,消耗时间比较久,所以是影响性能的一个关键指标。
MMU
MMU:Memory Management Unit内存管理单元,CPU中负责将虚拟地址映射为物理地址的单元,它将物理内存分割成多个pages,并管理进程的虚拟地址空间中的PAGE和物理内存中的PAGE之间的映射关系。
因为是映射,所以随时都可能发生变化,例如某个进程虚拟内存空间中的PAGE,在不同的时间点,可能出现在物理内存中的不同位置(当发生了页交换时)。
TLB
TLB:Translation Lookaside Buffer转换检测缓冲区,即旁路转换缓冲,或称为页表缓冲,里面存放的是一些页表文件(虚拟地址到物理地址的转换表),用于虚拟地址与实地址之间的交互,提供一个寻找实地址的缓存区,能够有效减少寻找物理地址所消耗时间。
TLB是一个小的虚拟寻址的缓存,其中每一行都保存着一个由单个PTE(Page Table Entry页表项)组成的块。如果没有TLB,则每次取数据都需要两次访问内存,即查页表获得物理地址和取数据。
分类
Linux page fault又分为:major page fault、 minor page fault、 invalidfault。
major page fault也称为hard page fault, 指需要访问的内存不在虚拟地址空间,也不在物理内存中,需要从慢速设备载入。从swap回到物理内存也是hard page fault。
minor page fault也称为soft page fault, 指需要访问的内存不在虚拟地址空间,但是在物理内存中,只需要MMU建立物理内存和虚拟地址空间的映射关系即可。(通常是多个进程访问同一个共享内存中的数据,可能某些进程还没有建立起映射关系,所以访问时会出现soft page fault)
invalid fault也称为segment fault, 指进程需要访问的内存地址不在它的虚拟地址空间范围内,属于越界访问,内核会报segment fault错误。
cache
存储器层次结构
存储器是分层次的,离CPU越近的存储器,速度越快,每字节的成本越高,同时容量也因此越小。寄存器速度最快,离CPU最近,成本最高,所以个数容量有限,其次是高速缓存(缓存也是分级,有L1,L2等缓存),再次是主存(普通内存),再次是本地磁盘。
寄存器的速度最快,可以在一个时钟周期内访问,其次是高速缓存,可以在几个时钟周期内访问,普通内存可以在几十个或几百个时钟周期内访问。
CPU先从对应寄存器读取数据,如果没有则从缓存中取;缓存中没有,则从内存中取到缓存;如果内存中没有,则先从磁盘读入内存,再读入缓存,再读入寄存器。
cache分成多个组,每个组分成多个行,linesize是cache的基本单位,从主存向cache迁移数据都是按照linesize为单位替换的。比如linesize为32Byte,那么迁移必须一次迁移32Byte到cache。相同的cache的linesize总是相同的。
所谓8路组相连( 8-way set associative)的含义是指,每个组里面有8个行。
举例来说,data cache: 32-KB, 8-way set associative, 64-byte line size
cache总大小为32KB,8路组相连(每组有8个line),每个line的大小linesize为64Byte,我们可以很轻易的算出一共有32K/8/64 = 64 个组。
cache的地址映射方式有直接映射、全相联映射和组相联映射,这里就不展开了,请参考主存到Cache直接映射
CPU缓存
CPU的缓存架构如下图:
-
**level-1 inst cache:**一级指令缓存(I$)
-
**level-1 data cache:**一级数据缓存(D$)
-
MMU:内存管理单元
-
**TLB:**转换检测缓冲区
-
**level-2 cache:**二级缓存(E$)
-
**level-3 cache:**三级缓存
CPU读取数据过程如下面两个图:
-
CPU根据虚拟地址尝试从一级缓存(存放的是虚拟地址的索引)中读取数据;
-
如果一级缓存中查找不到,则需向MMU请求数据;
-
MMU从TLB中查找虚拟地址的缓存;
-
如果TLB中存在该虚拟地址的缓存,则MMU将该虚拟地址转化为物理地址,如果地址转换失败,则发生缺页(图中的fault分支),由内核进行处理;如果地址转换成功,则从二级缓存(存放的是物理地址的索引)中读取;如果二级缓存中也没有,则需要从三级缓存甚至物理内存中请求;
-
如果TLB中不存在该虚拟地址的缓存,则MMU从物理内存中的转换表(translation tables,也称为页表page tables)中获取,同时存入TLB;(注意,这个操作是硬件实现的,可以由MMU通过硬件直接从物理内存中读取);
-
跳到第4步。
指令流水
提高性能最有效的方式之一就是并行。处理器在硬件设计时也尽可能地并行,比如流水线,超标量体系结构以及乱序执行。
处理器处理一条指令需要分多个步骤完成,比如先取指令,然后完成运算,最后将计算结果输出到总线上。在处理器内部,这可以看作一个三级流水线,如下图所示:
指令从左边进入处理器,上图中的流水线有三级,一个时钟周期内可以同时处理三条指令,分别被流水线的不同部分处理。
超标量(superscalar)指一个时钟周期发射多条指令的流水线机器架构,比如 Intel 的 Pentium 处理器,内部有两个执行单元,在一个时钟周期内允许执行两条指令。
此外,在处理器内部,不同指令所需要的处理步骤和时钟周期是不同的,如果严格按照程序的执行顺序执行,那么就无法充分利用处理器的流水线。因此指令有可能被乱序执行。
上述三种并行技术对所执行的指令有一个基本要求,即相邻的指令相互没有依赖关系。假如某条指令需要依赖前面一条指令的执行结果数据,那么 pipeline 便失去作用,因为第二条指令必须等待第一条指令完成。因此好的软件必须尽量避免这种代码的生成。
分支预测
分支指令对软件性能有比较大的影响。尤其是当处理器采用流水线设计之后,假设流水线有三级,当前进入流水的第一条指令为分支指令。假设处理器顺序读取指令,那么如果分支的结果是跳转到其他指令,那么被处理器流水线预取的后续两条指令都将被放弃,从而影响性能。为此,很多处理器都提供了分支预测功能,根据同一条指令的历史执行记录进行预测,读取最可能的下一条指令,而并非顺序读取指令。
分支预测对软件结构有一些要求,对于重复性的分支指令序列,分支预测硬件能得到较好的预测结果,而对于类似switch case一类的程序结构,则往往无法得到理想的预测结果。
上面介绍的几种处理器特性对软件的性能有很大的影响,然而依赖时钟进行定期采样的 profiler模式无法揭示程序对这些处理器硬件特性的使用情况。处理器厂商针对这种情况,在硬件中加入了 PMU (performance monitor unit)单元。
PMU 允许软件针对某种硬件事件设置 counter,此后处理器便开始统计该事件的发生次数,当发生的次数超过 counter 内设置的值后,便产生中断。比如 cache miss 达到某个值后,PMU 便能产生相应的中断。
捕获这些中断,便可以考察程序对这些硬件特性的利用效率了。
tracepoint
tracepoint 是散落在内核源代码中的一些 hook,一旦使能,它们便可以在特定的代码被运行到时被触发,这一特性可以被各种 trace/debug 工具所使用。perf就是该特性的用户之一。
假如您想知道在应用程序运行期间,内核内存管理模块的行为,便可以利用潜伏在 slab 分配器中的 tracepoint。当内核运行到这些 tracepoint时,便会通知 perf。perf将 tracepoint产生的事件记录下来,生成报告,通过分析这些报告,调优人员便可以了解程序运行时期内核的种种细节,对性能症状作出更准确的诊断。
CPU/IO密集型
CPU密集型(CPU bound)
CPU密集型也叫计算密集型,指的是系统的硬盘、内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU Loading 100%,CPU要读/写I/O(硬盘/内存),I/O在很短的时间就可以完成,而CPU还有许多运算要处理,CPU Loading很高。
在多重程序系统中,大部份时间用来做计算、逻辑判断等CPU动作的程序称之CPU bound。例如一个计算圆周率至小数点一千位以下的程序,在执行的过程当中绝大部份时间用在三角函数和开根号的计算,便是属于CPU bound的程序。
CPU bound的程序一般而言CPU占用率相当高。这可能是因为任务本身不太需要访问I/O设备,也可能是因为程序是多线程实现因此屏蔽掉了等待I/O的时间。
IO密集型(I/O bound)
IO密集型指的是系统的CPU性能相对硬盘、内存要好很多,此时,系统运作,大部分的状况是CPU在等I/O (硬盘/内存) 的读/写操作,此时CPU Loading并不高。
I/O bound的程序一般在达到性能极限时,CPU占用率仍然较低。这可能是因为任务本身需要大量I/O操作,而pipeline做得不是很好,没有充分利用处理器能力。
CPU密集型 vs IO密集型
我们可以把任务分为计算密集型和IO密集型。
计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。
计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,不太适合计算密集型任务。对于计算密集型任务,C****语言编写是一个比较好的选择。
第二种任务的类型是IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。
IO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是例如Python是一个比较好的选择。
总之,计算密集型程序适合C语言多线程,I/O密集型适合脚本语言开发的多线程。
很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是例如Python是一个比较好的选择。
总之,计算密集型程序适合C语言多线程,I/O密集型适合脚本语言开发的多线程。