在摩尔定律失效的情况下,处理器开始通过多核来提高性能,但这带来了一系列的问题,本文将聚焦处理器对于内存的访问,介绍处理器总线演进的过程(第一部分)、处理器核心是如何访问内存的(第二部分),以及知道这些知识后,我们能够在哪些方向优化我们对于CPU的使用(第三部分)。
一、CPU多核架构的演进
概念解释
- 电脑主板上的一个插槽(Socket)可以安装一个处理器(i5、i7,上图中的Processor);
- 因为摩尔定律逐渐失效,处理器往多核心的方向发展;在一个处理器中会有多个物理核心(Core),每一个物理核心中都有独立的一套ALU、FPU、Cache(L1、L2)等组件;
- 一个核心一般对应一个逻辑核(Processor,操作系统调度的基本单位是逻辑核),但在开启超线程的情况下,一个核心会对应多个逻辑核,当有多个计算任务时,可以让其中一个计算任务使用ALU(逻辑核),另一个则去使用FPU(逻辑核),这样就可以充分利用物理核中的各个部件,使得同一个物理核中,也可以并行处理多个计算任务(PS:两个逻辑核共享处理器的L1、L2Cache)。
我们在Linux上可以通过'lsCpu'看到这几个概念。
总线的演进
经典南桥北桥结构(总线型)
所有处理器通过前端总线(FSB)连接到北桥,北桥用于访问RAM,对于其他系统设备(SATA、USB、PCI-E)的IO访问,处理器需要经由北桥与南桥(也叫I/O桥)进行通信。
问题
这样的架构虽然简单,但是性能并不是随着处理器的增多而扩展的,因为Cores需要通过前端总线进行RAM等硬件的访问,而CPU与RAM的速度存在较大gap,这样整个系统的性能与吞吐受限于内存的带宽(因为L1、L2、L3 Cache是从内存中加载的),当内存带宽被打满时,即使再增加Core也是无用的。
优化-处理器独占总线(DHSI,专用高速互联)
让每个处理器独占一个总线在一定程度上缓解了公用总线的瓶颈问题,但是处理器独占总线一定程度上增加了维护多核数据一致性的成本,因为原本每个核只需要Snoop(嗅探)一条总线就行了,现在每个核需要Snoop多条总线,因此DHSI架构在芯片组上增加了Snoop Filter,每个处理器只处理自己关心的数据,架构如下图所示:
DHSI在一定程度上缓解了处理器与Cores的增加对于总线的压力,但是Cores增加又显现出对于内存的压力(Core越多,计算所需数据的内存空间越多),所以在NUMA下进一步优化了Core对于内存的访问方式。
QPI(QuickPath Interconnect,NUMA)
QPI相比于之前的架构,最主要的变化是内存控制器集成进了处理器,不再由北桥负责,每个处理器都会有一个独立的内存控制器IMC(integrated memory controllers, 集成内存控制器),分属于不同的处理器的IMC之间通过QPI link(interconnect)通讯
而属于同一个处理器的core通过类似SMP总线型进行通信,这条总线也叫做IMC bus(下图中的Processor是Core的意思):
此时物理架构从UMA演进为了NUMA;
- UMA: 统一内存地址访问,即所有Core对于所有内存的访问方式都是相同的;
- NUMA: 非统一内存地址访问,即所有Core对于不同区域内存的访问方式是不同的,有Local与Remote之分。
Linux中NUMA默认的内存分配策略是,Socket上的Core优先使用自身所在Socket内存控制器对应的内存,当自身内存不足时,再去其他内存控制器中申请内存,如果都没有再进行内存清理回收;
- Local Access: 如果Core访问自身内存控制器对应的内存,速度较快(100ns),称为Local Access;
- Remote Access: 如果Core访问其他处理器的内存控制器对应的内存,速度相对慢一些(160ns),称为Remote Access;
经过一系列的优化,Remote Access比Local Access的访问速度从慢7倍到现在只慢30%,目前是否Local Access已经不是着重需要考虑的点了;下述给出的访问ns数是基于QPI基本空闲(内存分配合理,跨Socket(Node)分配内存少)的情况下的测试结果。 性能参考:NUMA对性能的影响 - 知乎 (zhihu.com)
问题
NUMA架构的使用带来IPC(Instruction per cycle)的提升,缓解了内存总线带宽与处理器存在速度gap的问题(核越多,处理器计算需要的内存访问越多,问题越严重);但是也不可避免的加重了维护多核数据一致性的成本;
因此这些问题,我们需要先了解Core在NUMA下是如何访问内存的,然后再看看在应用层面,可以有哪些优化方式。
二、CPU访问内存的流程
三级Cache Hierarchy
多核处理器的Cache Hierarchy可以概括为:
- L1i Cache: Level1 Instruction Cache,CPU指令缓存;
- L1d CacheL Level1 Data Cache,数据缓存;
- L1i 与 L1d共用一个L2 Cache;
- L3 Cache被所有的cores共享,并通过总线与内存进行数据交互。
- 使用了超线程(HA)技术,那么一个物理Core上可以有两个逻辑线程(T0、T1),这两个逻辑线程共用所处物理核的资源-L1、L2Cache、ALU、FPU:
缓存加载流程
那么Cache是如何被填充的呢?是否L1 Cache中存在的数据,L2需要再存储一份? 设计多级cache可以有很多种方式,可以根据一个Cache的内容是否同时存在于其他级Cache来分类,即Cache inclusion policy(多级Cache的包含策略)。
- Inclusive Multilevel Cache :如果较低级别cache中的所有cacheline也存在于较高级别cache中,则称较高级别cache包含(inclusive) 较低级别cache;这是最常见的多级Cache的形式。
- 如果较高级别的cache仅包含较低级别的cache中不存在的cacheline,则称较高级别的cache不 包含(exclusive )较低级别的cache。
PS:Core Cache加载的基本单位是CacheLine,i7为64bytes。
对于Inclusive的形式,Core读写Cache的流程为:
- (a) 时刻为初始状态,L1、L2Cache中都没有CacheLine
- Core 读取变量X,Read Miss,Core处于Memory Install状态,因为是Inclusive的形式,因此CacheLine从主存需要同时被linefill到L1和L2中,如(b)所示;
- 接着Core读取变量Y,同样Read Miss,Core处于Memory Install状态,Y被同时加载到L1、L2,状态如(c)所示;
- 接着Core将X Evict(驱逐)出L1、此时只需要将L1中的X移除即可,L2的仍然可以保留,因此L2 inclusion L1,状态如(d)所示;
- 接着Core将Y Evict(驱逐)出L2,此时先将L2中的Y移除,接着需要向L1发送invalidation,最后将L1中Y移除,因为不能出现L1有Y而L2没有的情况,状态如(e)所示。
对于Exclusive的形式,Core读写Cache的流程为:
- (a) 时刻为初始状态,L1、L2Cache中都没有CacheLine
- Core 读取变量X,Read Miss,Core处于Memory Install状态,因为是Inclusive的形式,因此CacheLine从主存只需要被linefill到L1中,如(b)所示;
- 接着Core读取变量Y,同样Read Miss,Core处于Memory Install状态,Y只需要加载到L1,状态如(c)所示;
- 接着Core将X Evict(驱逐)出L1、此时只需要将L1中的X移除到L2中即可(这是 L2 linefill的唯一方式。),状态如(d)所示;
- 最后考虑如果此时再读取X,此时L1 Miss,那么需要将L2的CacheLine移动到L2中去。
对比
上面只考虑了Core本身的读写,没有考虑Core之间相互的内存访问;
对于exclusive的场景,如果Core1访问L3没有获取到某个CacheLine,因为L3中没有的,其他Core的L1、L2中可能存在,那么Core1需要尝试访问其他Core进行CacheLine获取,而对于inclusive的场景,Core只需要依次访问自身的L1、L2与共享的L3,L3中没有的CacheLine,其他Core的L1、L2则无需继续访问。
所以Inclusive的优缺点为:
- 优点:Core访问数据的效率高,对于硬件的要求不高(Core之间不需要额外的通信链路)
- 缺点:浪费珍惜的Cache空间,特别是在L1、L2、L3大小相差不大的情况下。
相反,exclusive的优缺点与Inclusive相反,Core访问效率低,但是节约Cache空间。
Cache Coherence (缓存一致性)
因为L1、L2是物理核私有的,而在一个物理核上修改的内存数据往往会先写到Write Buffer中,所以其他核不能直接感知到内存数据的更新,所以需要实现一套多核缓存一致的协议------MESI,并衍生出内存读写屏障,X|Y(Load|Store)的排列组合,这里因为篇幅关系,不展开叙述,可以参考synchronized与volatile是如何保证原子、可见、有序的?(博客重写计划Ⅰ) - 掘金 (juejin.cn)这篇文章。
Core访问流程
Core都是通过虚拟内存地址进行内存访问,而内存只有物理地址的概念,因此需要通过MMU进行虚拟地址->物理地址的翻译,翻译依据操作系统在内存中为每个进程存储的页表(PageTable),页表存储着映射关系,映射的粒度为Page,而如下图所示,访问内存对于Core而言是很浪费cycle的 Core需要处于Memory Stall状态等待内存数据加载到Cache再到寄存器中,所以操作系统对页表进行了内存缓存-即TLB;
-
因此MMU会首先访问TLB,TLB Miss后依据当前进程号去内存中依据虚拟地址查询对应的PageTable,然后再将对应的映射关系回写到TLB中。
-
拿到虚拟地址对应的物理地址后,Core会依据物理地址找到对应的CacheLine(Cache Hit),如果Cache中没有,则Core还是需要处于Memory Stall状态等待数据页从内存中加载到Cache中再进行查询,同样也会浪费很多时钟Cycle,因此如果Cache命中率高,Core处在Memory Stall的状态短,那么每个时钟周期中,Core能够执行的指令便越多,Core的利用率就越好。
操作系统如何为进程(线程)分配CPU资源
线程为存储着执行状态的指令序列,操作系统对于多个处于可运行状态的线程会依据调度算法进行Core资源的分配。
Linux经历了O(n)->O(1)->CFS的调度器演进过程,这里主要介绍一下目前Linux在使用的CFS调度器;
CFS(完全公平调度器)是Linux内核2.6.23版本开始采用的进程调度器,它的主要设计目标是在一个调度周期(sched_latency_ns
)中保证每个就绪进程(线程)至少有机会运行一次,换一种说法就是每个进程等待CPU的时间最长不超过这个调度周期;
它的基本原理是这样的:每个进程的累计运行时间保存在自己的vruntime字段里,哪个进程的vruntime最小就获得本轮运行的权利,由于进程的优先级即nice值不同,nice值越小(weight越大),优先级越高,vruntime增长的越慢,因此最小的概率越大,总体得到时间片便越多;进程k的weight在一轮sched_latency_ns
中获取到的时间片总长度计算公式如下:
多核NUMA架构下的Core调度
CFS只是针对一个Core进行就绪线程任务队列的调度,在多核系统中,按照Multi-Queue Multiprocessor Scheduling(MQMS)调度方式,每个核都会有自己的调度队列,并且按照调度队列进行任务调度(图中的CPU指操作系统调度的单位-逻辑核):
MQMS的优势在于遵从了Cache Affinity,如果按照Single-Queue进行调度,那么如果每次都分配到不同的核上,每次都需要重新load Cache,并且换核后在之前核上load的Cache也需要换出和失效(MESI的Invalid),这样Cache的成本提高而命中率下降:
但是MQMS的问题在于调度很可能不均衡,比如有四个进程,有一个进程需要的Core资源比其他三个加起来都多,但是MQMS还是会默认一个核分配两个进程,因此此时需要进行Process Migration,分为两种策略:Pull(类似ForkJoinPool的work-stealing)和Push
三、CPU使用优化
如何评估是否系统瓶颈在CPU?
首先需要观察CPU使用情况,对于CPU idle、Top命令的CPU%、perf的ipc(Instruction per cycle),这三者得到的数据一致吗?
Top和idle的指标会使用CPU处于非idle状态下的时间 / 整体时间 计算CPU使用百分比,这在一定程度上能反映CPU的状态,但是却不能给CPU调优提供更多的信息了;
对于perf的ipc指标,会考虑到CPU处于等待数据从Memory加载到Cache的耗时(CPU Install),一般情况下(cpu wide = 4),对于多核系统:
- IPC < 1,当前系统偏向处于Memory Bound,此时即使再增加CPU的效果对于系统性能的提升也是有限的;
- IPC > 1,当前系统偏向处于CPU Instructions Bound,即CPU处在相对较高的运行负荷上。
NUMA下如何优化
Memory Bound
对于Memory Bound,在内存带宽一定的情况下优先考虑:
- 优化代码设计,减少不必要的Memory IO;常见的方式比如:数据库的列式存储,对于Count()、Max()等列式聚合查询,相比于行式存储需要将全部完整行数据从db->mem->cache,而列式只需要将完整列进行load即可,对于内存带宽的压力减少非常多,而CPU处在Memory Stall的时间自然也会降低。
- 优化Cache的命中率,因为L2相比于Memory可以节省约230cycle ;常见的比如:代码Cache友好:缓存行填充(Disruptor),不过这种方式比较hardcode;另一种常见的方式比如:在一个CacheLine中可以存储数组中的多个值时,数组按行遍历相比于按列遍历是Cache友好的,在数据量和访问量较大时,性能会有50%以上的提升;相比于缓存行有更新原因不同的多种数据导致的Cache回写,在NUMA下更常见的是因为线程切核导致的Cache命中率降低,因此可以通过
taskset
或者通过Java框架Affinity
修改线程对核的亲和性来避免线程迁移核心执行,不过绑核的前提是需要认知不同CPU任务对于CPU资源的消耗程度,尽量使每个核上绑定的线程执行的任务量相近;
CPU Bound
对于CPU Bound,在CPU核心数、clock rate不变的情况下优先考虑:
- 使得一个CPU cycle可以处理更多的内存数据 ,常见的方式比如SIMD(Single Instruction Multiple Data),在Java9中对于String.indexOf()方法已经开始尝试使用SIMD进行字符串查找,相比于传统的for循环单字符匹配,性能要好上不少。
- 优化代码设计,减少不必要的CPU运算,在业务系统中常见的比如Json全量序列化在只取部分字段时,是否可以改为流式的获取方式;比如将经常重复计算的计算结果放到映射表中去;
- 优化线程模型,减少不必要的线程数 ,CFS调度器会保证每一个就绪线程在一次调度周期中会得到调度,线程越多,线程切换越多,每个调度周期便越长,维护成本也越高。
四、主要参考
- 进程调度器: 从几个问题开始理解CFS调度器 | Linux Performance、一文搞懂linux cfs调度器 - 知乎 (zhihu.com)、Linux中的任务和调度 [二] - 知乎 (zhihu.com)、Scheduling.pdf (neu.edu)、线程数与多核CPU的关系 - 掘金 (juejin.cn)
- CPU Cache使用相关:CPU架构浅析 (valleytalk.org)、多级cache的包含策略(Cache inclusion policy - 知乎 (zhihu.com)、cache之读写一致性 - 知乎 (zhihu.com)、cache之多核一致性(一) - 总线上没有秘密 - 知乎 (zhihu.com)
- CPU指标、性能优化相关:CPU Utilization is Wrong (brendangregg.com)、一篇论文讲透Cache优化 - 知乎 (zhihu.com)
- Inter CPU架构演进、NUMA核调度相关:Overview.book (intel.com)、NUMA 的平衡和调度 - 知乎 (zhihu.com)
- NUMA内存Access相关:十年后数据库还是不敢拥抱NUMA? - 知乎 (zhihu.com)、、每个程序员都应该了解的内存知识(What every programmer should know about memory) - 知乎 (zhihu.com)、What Every Programmer Should Know About Memory (freebsd.org)、深挖NUMA - 知乎 (zhihu.com)