Linux的NUMA机制详解
2020-05-30 17:09:26 阿炯

NUMA(Non-Uniform Memory Access)字面直译为“非一致性内存访问”,对于Linux内核来说最早出现在2.6.7版本上。这种特性对于当下大内存+多CPU为潮流的X86平台来说确实会有不少的性能提升,但如果配置不当的话,也是一个很大的问题。本文就说说Linux下关于CPU NUMA特性的配置和调优。

最早Intel在Nehalem架构上实现了NUMA,取代了在此之前一直使用的FSB前端总线的架构,用以对抗AMD的HyperTransport技术。一方面这个架构的特点是内存控制器从传统的北桥中移到了CPU中,排除了商业战略方向的考虑之外,这样做的方法同样是为了实现NUMA。

在SMP多CPU架构中,传统上多CPU对于内存的访问是总线方式。是总线就会存在资源争用和一致性问题,而且如果不断的增加CPU数量,总线的争用会愈演愈烈,这就体现在4核CPU的跑分性能达不到2核CPU的2倍,甚至1.5倍!理论上来说这种方式实现12core以上的CPU已经没有太大的意义。Intel的NUMA解决方案,Litrin始终认为它来自本家的安腾。他的模型有点类似于MapReduce。放弃总线的访问方式,将CPU划分到多个Node中,每个node有自己独立的内存空间。各个node之间通过高速互联通讯,通讯通道被成为QuickPath Interconnect即QPI。

这个架构带来的问题也很明显,如果一个进程所需的内存超过了node的边界,那就意味着需要通过QPI获取另一node中的资源,尽管QPI的理论带宽远高于传统的FSB,比如当下流行的内存数据库,在这种情况下就很被动了。


x86平台经历了一场从“拼频率”到“拼核心数”的转变,越来越多的核心被尽可能地塞进了同一块芯片上,各个核心对于内存带宽的争抢访问成为了瓶颈;此时软件、OS方面对于SMP多核心CPU的支持也愈发成熟;再加上各种商业上的考量,x86平台也顺水推舟的搞了NUMA(Non-uniform memory access, 非一致性内存访问)。在这种架构之下,每个Socket都会有一个独立的内存控制器IMC(integrated memory controllers, 集成内存控制器),分属于不同的socket之内的IMC之间通过QPI link通讯。


然后就是进一步的架构演进,由于每个socket上都会有多个core进行内存访问,这就会在每个core的内部出现一个类似最早SMP架构相似的内存访问总线,这个总线被称为IMC bus。


很明显在这种架构之下,两个socket各自管理1/2的内存插槽,如果要访问不属于本socket的内存则必须通过QPI link。也就是说内存的访问出现了本地/远程(local/remote)的概念,内存的延时是会有显著的区别的。这也就是之前那篇文章中提到的为什么NUMA的设置能够明显的影响到JVM的性能。

NUMA 的解决方案模型有点类似于 MapReduce,将 CPU 划分到多个 Node,每个 Node 有自己独立的内存空间,不同的 Node 之间通过高速互联通讯,通道被成为 QuickPath Interconnect, QPI 。当然,这种方式引入的问题是:如果一个进程 (内存数据库) 所需的内存超过了 Node 的边界,那么就意味着需要通过 QPI 获取另一 Node 中的内存资源,尽管 QPI 的理论带宽远高于传统的 FSB ,但是仍然会导致性能的波动。



回到当前世面上的CPU,工程上的实现其实更加复杂了。以Xeon 2699 v4系列CPU的标准来看,两个Socket之间通过各自的一条9.6GT/s的QPI link互访。而每个Socket事实上有2个内存控制器。双通道的缘故,每个控制器又有两个内存通道(channel),每个通道最多支持3根内存条(DIMM)。理论上最大单socket支持76.8GB/s的内存带宽,而两个QPI link,每个QPI link有9.6GT/s的速率(~57.6GB/s)事实上QPI link已经出现瓶颈了。


核心数还是源源不断的增加,Skylake桌面版本的i7 EE已经有了18个core,下一代的Skylake Xeon更多个Core。为了塞进更多的core,原本核心之间类似环网的设计变成了复杂的路由。由于这种架构上的变化,导致内存的访问变得更加复杂。两个IMC也有了local/remote的区别,在保证兼容性的前提和性能导向的纠结中,系统允许用户进行更为灵活的内存访问架构划分。于是就有了“NUMA之上的NUMA”这种妖异的设定(SNC)。

回到Linux,内核mm/mmzone.c , include/linux/mmzone.h文件定义了NUMA的数据结构和操作方式。Linux Kernel中NUMA的调度位于kernel/sched/core.c函数int sysctl_numa_balancing。

对于x86架构的计算机,那时的内存控制器还没有整合进CPU,所有内存的访问都需要通过北桥芯片来完成,此时的内存访问如上图所示,被称为UMA(uniform memory access, 一致性内存访问 )。这样的访问对于软件层面来说非常容易实现:总线模型保证了所有的内存访问是一致的,不必考虑由不同内存地址之前的差异。

之后的x86平台经历了一场从“拼频率”到“拼核心数”的转变,越来越多的核心被尽可能地塞进了同一块芯片上,各个核心对于内存带宽的争抢访问成为了瓶颈;此时软件、OS方面对于SMP多核心CPU的支持也愈发成熟;再加上各种商业上的考量,x86平台也顺水推舟的搞了NUMA(Non-uniform memory access, 非一致性内存访问)。在这种架构之下,每个Socket都会有一个独立的内存控制器IMC(integrated memory controllers, 集成内存控制器),分属于不同的socket之内的IMC之间通过QPI link通讯。然后就是进一步的架构演进,由于每个socket上都会有多个core进行内存访问,这就会在每个core的内部出现一个类似最早SMP架构相似的内存访问总线,这个总线被称为IMC bus。

很明显在这种架构之下,两个socket各自管理1/2的内存插槽,如果要访问不属于本socket的内存则必须通过QPI link。也就是说内存的访问出现了本地/远程(local/remote)的概念,内存的延时是会有显著的区别的。

在一个启用了NUMA支持的Linux中,Kernel不会将任务内存从一个NUMA node搬迁到另一个NUMA node。一个进程一旦被启用,它所在的NUMA node就不会被迁移,为了尽可能的优化性能,在正常的调度之中,CPU的core也会尽可能的使用可以local访问的本地core,在进程的整个生命周期之中,NUMA node保持不变。一旦当某个NUMA node的负载超出了另一个node一个阈值(默认25%),则认为需要在此node上减少负载,不同的NUMA结构和不同的负载状况,系统见给予一个延时任务的迁移——类似于漏杯算法。在这种情况下将会产生内存的remote访问。

NUMA node之间有不同的拓扑结构,各个 node 之间的访问会有一个距离(node distances)的概念,如numactl -H命令的结果有这样的描述:
node distances:
node 0 1 2 3
0: 10 11 21 21
1: 11 10 21 21
2: 21 21 10 11
3: 21 21 11 10

可以看出:0 node 到0 node之间距离为10,这肯定的最近的距离,0-1之间的距离远小于2或3的距离,这种距离方便系统在较复杂的情况下选择最合适的NUMA设定。


上图记录了某个Benchmark工具,在开启/关闭NUMA功能时QPI带宽消耗的情况。很明显的是,在开启了NUMA支持以后,QPI的带宽消耗有了两个数量级以上的下降,性能也有了显著的提升!

通常情况下,用户可以通过numactl来进行NUMA访问策略的手工配置,cgroup中cpuset.mems也可以达到指定NUMA node的作用。以numactl命令为例,它有如下策略:
–interleave=nodes //允许进程在多个node之间交替访问
–membind=nodes //将内存固定在某个node上,CPU则选择对应的core。
–cpunodebind=nodes //与membind相反,将CPU固定在某(几)个core上,内存则限制在对应的NUMA node之上。
–physcpubind=cpus //与cpunodebind类似,不同的是物理core。
–localalloc //本地配置
–preferred=node //按照推荐配置

对于某些大内存访问的应用,比如Mongodb,将NUMA的访问策略制定为interleave=all则意味着整个进程的内存是均匀分布在所有的node之上,进程可以以最快的方式访问本地内存。

讲到这里似乎已经差不多了,但事实上还没完。如果你一直按照这个“北桥去哪儿了?”的思路理下来的话,就有了另一个问题,那就是之前的北桥还有一个功能就是PCI/PCIe控制器,当然原来的南桥,现在的PCH一样也整合了PCIe控制器。事实上,在PCIe channel上也是有NUMA亲和性的。

比如:查看网卡eth5的NUMA,用到了netdev:<设备名称>这种方式。

[root@freeoa ~]# numactl --prefer netdev:eth5 --show
policy: preferred
preferred node: 0
physcpubind: 0
cpubind: 0
nodebind: 0
membind: 0 1 2 3

或者一个PCI address 为00:17的SATA控制器,用到了pci:<PCI address>

[root@freeoa~]# numactl --prefer pci:00:17 --show
policy: preferred
preferred node: 0
physcpubind: 0
cpubind: 0
nodebind: 0
membind: 0 1 2 3

还有block/ip/file的方式查看NUMA affinity,这里也就不再累述了。

现在的服务器,特别是多socket的服务器架构更像是通过NUMA共享了内存,进而通过共享内存从而共享外设的多台主机,一个业务就能跑满整台server的时代一去不返了。

Linux提供了一个一个手工调优的命令numactl(默认不安装),首先你可以通过它查看系统的numa状态。

root@freeoa:/usr/bin# numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23
node 0 size: 131037 MB
node 0 free: 3019 MB
node 1 cpus: 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31
node 1 size: 131071 MB
node 1 free: 9799 MB
node distances:
node 0 1
 0: 10 20
 1: 20 10

此系统共有2个node,各领取16个CPU和128G内存。

这里假设我要执行一个java param命令,此命令需要120G内存,一个perl param命令,需要16G内存。最好的优化方案时perl在node0中执行,而java在node1中执行,那命令是:
#numactl --cpubind=0 --membind=0 perl param
#numactl --cpubind=1 --membind=1 java param

当然也可以自找没趣:
#numactl --cpubind=0 --membind=0,1 java param

对于用掉内存大半的MongoDB,可配置为:
numactl --interleave=all mongod -f /etc/mongod.conf

即分配所有的node供其使用,这也是官方推荐的用法。

通过numastat命令可以查看numa状态
root@freeoa:/root# numastat
node0 node1
numa_hit 1775216830 6808979012
numa_miss 4091495 494235148
numa_foreign 494235148 4091495
interleave_hit 52909 53004
local_node 1775205816 6808927908
other_node 4102509 494286252

other_node过高意味着需要重新规划numa。

NUMA对性能的影响一例

通过docker封装好的SPECjbb,Docker从本质上说底层就是一个cgroup。首先查看一下机器的NUMA拓扑:
# numactl -H
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
node 0 size: 130502 MB
node 0 free: 123224 MB
node 1 cpus: 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
node 1 size: 131072 MB
node 1 free: 126082 MB
node distances:
node 0 1
 0: 10 21
 1: 21 10

开启HT之后,系统显示64个core,其中core 0-15,32-47位于NUMA node0,Core16-31,48-63 位于 NUMA node1。

通过限制cpuset-cpus、cpuset-mems来做对比。

只允许SPECjbb运行在core 0-15,32-47,并只能访问NUMA node1:
docker run --cpuset-cpus=0-15,32-47 --cpuset-mems=0 specjbb

然后就是只允许访问node1
docker run --cpuset-cpus=0-15,32-47 --cpuset-mems=1 specjbb

最后就是不做任何限定
docker run --cpuset-cpus=0-15,32-47 specjbb

一开始觉得这3个场景的性能差异应该不会很大,但数据出来后发现差距还不小。
SPECjbb 2005 得分1     差距1     SPECjbb 2005 得分2     差距2
场景1     882912.91     0.4%     256094.32     8.1%
场景 3     886731.13     –     278767.92     –
场景2     628329.09     29.1%     188030.75     26.6%

可能的几个误区:只允许CPU访问自己本地的NUMA node并不能得到最高的性能,有的时候NUMA node也有性能瓶颈。错误的NUMA设置其实会带来相当大的性能差距。还有一个从上表的数据上看不出来:一旦设置了允许CPU访问任何一个node,性能会有些许提升,但带来的结果偏差会变得很大。

cgroup的cpuset问题

项目中需要将某个进程强制绑定到一个指定的CPU core中,最先的考虑就是这个可以直接通过cgroup的cpuset配置来实现。

Control Groups 即Cgroup,它旨在通过一系列文件接口的方式实现对于进程的资源隔离。另外由于它映射成了一个文件系统,因此拥有了文件系统的优势——嵌套管理的优势。

首先是建立一个cgroup的group

root@freeoa:/sys/fs/cgroup/cpuset# cgcreate -g cpuset:test_1
root@freeoa:/sys/fs/cgroup/cpuset# ls /sys/fs/cgroup/cpuset/ | grep test
test_1

cpuset下面,有一个名叫test_1的目录已经建好。现在就修改cpuset.cpus的设定,将属于该策略的所有进程都绑定到cpu的第23个core去执行。

root@freeoa:/sys/fs/cgroup/cpuset# cd /sys/fs/cgroup/cpuset/test_1/
root@freeoa:/sys/fs/cgroup/cpuset/test_1# echo 23 > cpuset.cpus
root@freeoa:/sys/fs/cgroup/cpuset/test_1# cat cpuset.cpus
23

现在问题来了。当我尝试执行cgexe用test_1策略去起一个进程的时候,出现了如下的问题。

root@freeoa:/sys/fs/cgroup/cpuset/test_1# cgexec -g cpuset:test_1 sleep 1
cgroup change of group failed

'cgroup change of group failed':即进程无法切换到指定的cgroup。开始以为打开的方法不对,google了一下发现redhat,suse,arch,debian的社区都有类似的bug汇报。最终找到了Redhat官方的cgroup解释:
Some subsystems have mandatory parameters that must be set before you can move a task into a cgroup which uses any of those subsystems. For example, before you move a task into a cgroup which uses the cpuset subsystem, the cpuset.cpus and cpuset.mems parameters must be defined for that cgroup.

作为CPUset策略生效的必要条件,cpus和Mems必须强制指定。cpus是指进程被绑定的内核,而Mems则表示cpu的NUMA内存节点。

问题找到了,解决就很简单了:
root@freeoa:/sys/fs/cgroup/cpuset/test_1# echo 0-1 > cpuset.mems
root@freeoa:/sys/fs/cgroup/cpuset/test_1# cgexec -g cpuset:test_1 ls /
bin boot dev etc home initrd.img lib lib64 media mnt opt proc root run sbin srv sys tmp usr var vmlinuz

上文摘录自开源小站,感谢原作者。


Linux内存管理之非一致内存访问架构(NUMA)

一、背景

所谓物理内存,就是安装在机器上的,实打实的内存设备(不包括硬件Cache),被CPU通过总线访问。在多核系统中,如果物理内存对所有CPU来说没有区别,每个CPU访问内存的方式也一样,则这种体系结构被称为Uniform Memory Access(UMA)。

如果物理内存是分布式的,由多个cell组成(比如每个核有自己的本地内存),那么CPU在访问靠近它的本地内存的时候就比较快,访问其他CPU的内存或者全局内存的时候就比较慢,这种体系结构被称为Non-Uniform Memory Access(NUMA)。


以上是硬件层面上的NUMA,而作为软件层面的linux,则对NUMA的概念进行了抽象。即便硬件上是一整块连续内存的UMA,Linux也可将其划分为若干的node。同样,即便硬件上是物理内存不连续的NUMA,Linux也可将其视作UMA。所以在Linux系统中,你可以基于一个UMA的平台测试NUMA上的应用特性。从另一个角度,UMA就是只有一个node的特殊NUMA,所以两者可以统一用NUMA模型表示。


传统的SMP(对称多处理器)中,所有处理器都共享系统总线,因此当处理器的数目增大时,系统总线的竞争冲突加大,系统总线将成为瓶颈,所以目前SMP系统的CPU数目一般只有数十个,可扩展能力受到极大限制。NUMA技术有效结合了SMP系统易编程性和MPP(大规模并行)系统易扩展性的特点,较好解决了SMP系统的可扩展性问题,已成为当今高性能服务器的主流体系结构之一。

在NUMA系统中,当Linux内核收到内存分配的请求时,它会优先从发出请求的CPU本地或邻近的内存node中寻找空闲内存,这种方式被称作local allocation,local allocation能让接下来的内存访问相对底层的物理资源是local的。

每个node由一个或多个zone组成(我们可能经常在各种对虚拟内存和物理内存的描述中迷失,但以后你见到zone,就知道指的是物理内存),每个zone又由若干page frames组成(一般page frame都是指物理页面)。


基于NUMA架构的高性能服务器有HP的Superdome、SGI的Altix 3000、IBM的 x440、NEC的TX7、AMD的Opteron等。

概念

NUMA具有多个节点(Node),每个节点可以拥有多个CPU(每个CPU可以具有多个核或线程),节点内使用共有的内存控制器,因此节点的所有内存对于本节点的所有CPU都是等同的,而对于其它节点中的所有CPU都是不同的。节点可分为本地节点(Local Node)、邻居节点(Neighbour Node)和远端节点(Remote Node)三种类型。

本地节点:对于某个节点中的所有CPU,此节点称为本地节点;

邻居节点:与本地节点相邻的节点称为邻居节点;

远端节点:非本地节点或邻居节点的节点,称为远端节点。

邻居节点和远端节点,称作非本地节点(Off Node)。

CPU访问不同类型节点内存的速度是不相同的:本地节点>邻居节点>远端节点。访问本地节点的速度最快,访问远端节点的速度最慢,即访问速度与节点的距离有关,距离越远访问速度越慢,此距离称作Node Distance。

常用的NUMA系统中:硬件设计已保证系统中所有的Cache是一致的(Cache Coherent, ccNUMA);不同类型节点间的cache同步时间不一样,会导致资源竞争不公平,对于某些特殊的应用,可以考虑使用FIFO Spinlock保证公平性。

二、NUMA存储管理

NUMA系统是由多个结点通过高速互连网络连接而成的,如下图是SGI Altix 3000 ccNUMA系统中的两个结点。


NUMA系统的结点通常是由一组CPU(如,SGI Altix 3000是2个Itanium2 CPU)和本地内存组成,有的结点可能还有I/O子系统。由于每个结点都有自己的本地内存,因此全系统的内存在物理上是分布的,每个结点访问本地内存和访问其它结点的远地内存的延迟是不同的,为了减少非一致性访存对系统的影响,在硬件设计时应尽量降低远地内存访存延迟(如通过Cache一致性设计等),而操作系统也必须能感知硬件的拓扑结构,优化系统的访存。

目前IA64 Linux所支持的NUMA架构服务器的物理拓扑描述是通过ACPI(Advanced Configuration and Power Interface)实现的。ACPI是由Compaq、Intel、Microsoft、Phoenix和Toshiba联合制定的BIOS规范,它定义了一个非常广泛的配置和电源管理,目前该规范的版本已发展到2.0,3.0o版本正在制定中,具体信息可以从 http://www.acpi.info网站上获得。ACPI规范也已广泛应用于IA-32架构的至强服务器系统中。

Linux对NUMA系统的物理内存分布信息是从系统firmware的ACPI表中获得的,最重要的是SRAT(System Resource Affinity Table)和SLIT(System Locality Information Table)表,其中SRAT包含两个结构:
Processor Local APIC/SAPIC Affinity structure:记录某个CPU的信息;
Memory Affinity Structure:记录内存的信息;

SLIT表则记录了各个结点之间的距离,在系统中由数组node_distance[ ]记录。

Linux采用Node、Zone和页三级结构来描述物理内存的,如下图所示:


Linux中Node、Zone和页的关系

2.1 结点

Linux用一个struct pg_data_t结构来描述系统的内存,系统中每个结点都挂接在一个pgdat_list列表中,对UMA体系结构,则只有一个静态的pg_data_t结构contig_page_data。对NUMA系统来说则非常容易扩充,NUMA系统中一个结点可以对应Linux存储描述中的一个结点,具体描述见linux/mmzone.h。

typedef struct pglist_data {
zone_t node_zones[MAX_NR_ZONES];
zonelist_t node_zonelists[GFP_ZONEMASK+1];
int nr_zones;
struct page *node_mem_map;
unsigned long *valid_addr_bitmap;
struct bootmem_data *bdata;
unsigned long node_start_paddr;
unsigned long node_start_mapnr;
unsigned long node_size;
int node_id;struct pglist_data *node_next;
} pg_data_t;

下面就该结构中的主要域进行说明,

Node_zones
该结点的zone类型,一般包括ZONE_HIGHMEM、ZONE_NORMAL和ZONE_DMA三类

Node_zonelists
分配时内存时zone的排序。它是由free_area_init_core()通过page_alloc.c中的build_zonelists()设置zone的顺序

nr_zones
该结点的 zone 个数,可以从 1 到 3,但并不是所有的结点都需要有 3 个 zone

node_mem_map
它是 struct page 数组的第一页,该数组表示结点中的每个物理页框。根据该结点在系统中的顺序,它可在全局 mem_map 数组中的某个位置

Valid_addr_bitmap
用于描述结点内存空洞的位图

node_start_paddr
该结点的起始物理地址

node_start_mapnr
给出在全局 mem_map 中的页偏移,在free_area_init_core() 计算在 mem_map 和 lmem_map 之间的该结点的页框数目

node_size
该 zone 内的页框总数

node_id
该结点的 ID,全系统结点 ID 从 0 开始

系统中所有结点都维护在 pgdat_list 列表中,在 init_bootmem_core 函数中完成该列表初始化工作。

影响zonelist方式

采用Node方式组织的zonelist为:


即各节点按照与本节点的Node Distance距离大小来排序,以达到更优的内存分配。

zonelist[2]

配置NUMA后,每个节点将关联2个zonelist:
1) zonelist[0]中存放以Node方式或Zone方式组织的zonelist,包括所有节点的zone;
2) zonelist[1]中只存放本节点的zone即Legacy方式;

zonelist[1]用来实现仅从节点自身zone中的内存分配(参考__GFP_THISNODE标志)。

Page Frame

虽然内存访问的最小单位是byte或者word,但MMU是以page为单位来查找页表的,page也就成了Linux中内存管理的重要单位。包括换出(swap out)、回收(relcaim)、映射等操作,都是以page为粒度的。

因此,描述page frame的struct page自然成为了内核中一个使用频率极高,非常重要的结构体,来看下它是怎样构成的(为了讲解需要并非最新内核代码):

struct page {unsigned long flags;atomic_t count;atomic_t _mapcount;struct list_head lru};
struct address_space *mapping;unsigned long index;

flags表示page frame的状态或者属性,包括和内存回收相关的PG_active, PG_dirty, PG_writeback, PG_reserved, PG_locked, PG_highmem等。其实flags是身兼多职的,它还有其他用途,这将在下文中介绍到。
count表示引用计数。当count值为0时,该page frame可被free掉;如果不为0,说明该page正在被某个进程或者内核使用,调用page_count()可获得count值。
_mapcount表示该page frame被映射的个数,也就是多少个page table entry中含有这个page frame的PFN。
lru是"least recently used"的缩写,根据page frame的活跃程度(使用频率),一个可回收的page frame要么挂在active_list双向链表上,要么挂在inactive_list双向链表上,以作为页面回收的选择依据,lru中包含的就是指向所在链表中前后节点的指针。

如果一个page是属于某个文件的(也就是在page cache中),则mapping指向文件inode对应的address_space(这个结构体虽然叫address_space,但并不是进程地址空间里的那个address space),index表示该page在文件内的offset(以page size为单位)。

有了文件的inode和index,当这个page的内容需要和外部disk/flash上对应的部分同步时,才可以找到具体的文件位置。如果一个page是anonymous的,则mapping指向表示swap cache的swapper_space,此时index就是swapper_space内的offset。

事实上,现在最新Linux版本的struct page实现中大量用到了union,也就是同一个元素在不同的场景下有不同的意义。这是因为每个page frame都需要一个struct page来描述,一个page frame占4KB,一个struct page占32字节,那所有的struct page需要消耗的内存占了整个系统内存的32/4096,不到1%的样子,说小也小,但一个拥有4GB物理内存的系统,光这一项的开销最大就可达30多MB。

如果能在struct page里省下4个字节,那就能省下4多MB的内存空间,所以这个结构体的设计必须非常考究,不能因为多一种场景的需要就在struct page中增加一个元素,而是应该尽量采取复用的方式。

需要注意的是,struct page描述和管理的是这4KB的物理内存,它并不关注这段内存中的数据变化。

2.2 Zone

每个结点的内存被分为多个块,称为zones,它表示内存中一段区域。一个zone用struct_zone_t结构描述,zone的类型主要有ZONE_DMA、ZONE_NORMAL和ZONE_HIGHMEM。ZONE_DMA位于低端的内存空间,用于某些旧的ISA设备。

ZONE_NORMAL的内存直接映射到Linux内核线性地址空间的高端部分,许多内核操作只能在ZONE_NORMAL中进行。

因为硬件的限制,内核不能对所有的page frames采用同样的处理方法,因此它将属性相同的page frames归到一个zone中。对zone的划分与硬件相关,对不同的处理器架构是可能不一样的。

​比如在i386中,一些使用DMA的设备只能访问0~16MB的物理空间,因此将0~16MB划分为了ZONE_DMA。ZONE_HIGHMEM则是适用于要访问的物理地址空间大于虚拟地址空间,不能建立直接映射的场景。除开这两个特殊的zone,物理内存中剩余的部分就是ZONE_NORMAL了。


例如,在X86中,zone的物理地址如下:
类型:地址范围
ZONE_DMA:前16MB内存
ZONE_NORMAL:16MB - 896MB
ZONE_HIGHMEM:896 MB以上

Zone是用struct zone_t描述的,它跟踪页框使用、空闲区域和锁等信息,具体描述如下:
typedef struct zone_struct {
spinlock_t lock;
unsigned long free_pages;
unsigned long pages_min, pages_low, pages_high;
int need_balance;
free_area_t free_area[MAX_ORDER];
wait_queue_head_t * wait_table;unsigned long wait_table_size;
unsigned long wait_table_shift;struct pglist_data *zone_pgdat;
struct page *zone_mem_map;unsigned long zone_start_paddr;
unsigned long zone_start_mapnr;char *name;unsigned long size;
} zone_t;

在其他一些处理器架构中,ZONE_DMA可能是不需要的,ZONE_HIGHMEM也可能没有。比如在64位的x64中,因为内核虚拟地址空间足够大,不再需要ZONE_HIGH映射,但为了区分使用32位地址的DMA应用和使用64位地址的DMA应用,64位系统中设置了ZONE_DMA32和ZONE_DMA。

所以,同样的ZONE_DMA,对于32位系统和64位系统表达的意义是不同的,ZONE_DMA32则只对64位系统有意义,对32位系统就等同于ZONE_DMA,没有单独存在的意义。

此外,还有防止内存碎片化的ZONE_MOVABLE和支持设备热插拔的ZONE_DEVICE。可通过“cat /proc/zoneinfo |grep Node”命令查看系统中包含的zones的种类。

$ cat /proc/zoneinfo |grep Node
Node 0, zone DMA
Node 0, zone DMA32

下面就该结构中的主要域进行说明:


​当系统中可用的内存比较少时,kswapd将被唤醒,并进行页交换。如果需要内存的压力非常大,进程将同步释放内存。如前面所述,每个zone有三个阈值,称为pages_low,pages_min和pages_high,用于跟踪该zone的内存压力。pages_min的页框数是由内存初始化free_area_init_core函数,根据该zone内页框的比例计算的,最小值为20页,最大值一般为255页。当到达pages_min时,分配器将采用同步方式进行kswapd的工作;当空闲页的数目达到pages_low时,kswapd被buddy分配器唤醒,开始释放页;当达到pages_high时,kswapd将被唤醒,此时kswapd不会考虑如何平衡该zone,直到有pages_high空闲页为止。一般情况下,pages_high缺省值是pages_min的3倍。

Linux存储管理的这种层次式结构可以将ACPI的SRAT和SLIT信息与Node、Zone实现有效的映射,从而克服了传统Linux中平坦式结构无法反映NUMA架构的缺点。当一个任务请求分配内存时,Linux采用局部结点分配策略,首先在自己的结点内寻找空闲页;如果没有,则到相邻的结点中寻找空闲页;如果还没有,则到远程结点中寻找空闲页,从而在操作系统级优化了访存性能。

Zone虽然是用于管理物理内存的,但zone与zone之间并没有任何的物理分割,它只是Linux为了便于管理进行的一种逻辑意义上的划分。Zone在Linux中用struct zone表示(以下为了讲解需要,调整了结构体中元素的顺序):

struct zone {
spinlock_t lock;
unsigned long spanned_pages;
unsigned long present_pages;
unsigned long nr_reserved_highatomic;
atomic_long_t managed_pages;
struct free_area free_area[MAX_ORDER];
unsigned long _watermark[NR_WMARK];
long lowmem_reserve[MAX_NR_ZONES];
atomic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS];
unsigned long zone_start_pfn;
}
struct pglist_data *zone_pgdat;
struct page *zone_mem_map;


lock是用来防止并行访问struct zone的spin lock,它只能保护struct zone这个结构体哈,可不能保护整个zone里的所有pages。

spanned_pages是这个zone含有的总的page frames数目。在某些体系结构(比如Sparc)中,zone中可能存在没有物理页面的"holes",spanned_pages减去这些holes里的absent pages就是present_pages。

nr_reserved_highatomic是为某些场景预留的内存,managed_pages是由buddy内存分配系统管理的page frames数目,其实也就是present_pages减去reserved pages。

free_area由free list空闲链表构成,表示zone中还有多少空余可供分配的page frames。_watermark有min(mininum), low, high三种,可作为启动内存回收的判断标准

lowmem_reserve是给更高位的zones预留的内存。vm_stat作为zone的内存使用情况的统计信息,是“/proc/zoneinfo”的数据来源。

zone_start_pfn是zone的起始物理页面号,zone_start_pfn+spanned_pages就是该zone的结束物理页面号。zone_pgdat是指向这个zone所属的node的。zone_mem_map指向由struct page构成的mem_map数组。

因为内核对zone的访问是很频繁的,为了更好的利用硬件cache来提高访问速度,struct zone中还有一些填充位,用于帮助结构体元素的cache line对齐。这和struct page对内存精打细算的使用形成了鲜明的对比,因为zone的种类很有限,一个系统中一共也不会有多少个zones,struct zone这个结构体的体积大点也没有什么关系。

Node Distance

上节中的例子是以2个节点为例,如果有>2个节点存在,就需要考虑不同节点间的距离来安排节点,例如以4个节点2个ZONE为例,各节点的布局(如4个XLP832物理CPU级联)值如下:


​上图中,Node0和Node2的Node Distance为25,Node1和Node3的Node Distance为25,其它的Node Distance为15。

三、NUMA调度器

NUMA系统中,由于局部内存的访存延迟低于远地内存访存延迟,因此将进程分配到局部内存附近的处理器上可极大优化应用程序的性能。Linux 2.4内核中的调度器由于只设计了一个运行队列,可扩展性较差,在SMP平台表现一直不理想。当运行的任务数较多时,多个CPU增加了系统资源的竞争,限制了负载的吞吐率。在2.5内核开发时,Ingo Molnar写了一个多队列调度器,称为O(1),从2.5.2开始O(1)调度器已集成到2.5内核版本中。O(1)是多队列调度器,每个处理器都有一条自己的运行队列,但由于O(1)调度器不能较好地感知NUMA系统中结点这层结构,从而不能保证在调度后该进程仍运行在同一个结点上,为此,Eirch Focht开发了结点亲和的NUMA调度器,它是建立在Ingo Molnar的O(1)调度器基础上的,Eirch将该调度器向后移植到2.4.X内核中,该调度器最初是为基于IA64的NUMA机器的2.4内核开发的,后来Matt Dobson将它移植到基于X86的NUMA-Q硬件上。

3.1 初始负载平衡

在每个任务创建时都会赋予一个HOME结点(所谓HOME结点,就是该任务获得最初内存分配的结点),它是当时创建该任务时全系统负载最轻的结点,由于目前Linux中不支持任务的内存从一个结点迁移到另一个结点,因此在该任务的生命期内HOME结点保持不变。一个任务最初的负载平衡工作(也就是选该任务的HOME结点)缺省情况下是由exec()系统调用完成的,也可以由fork()系统调用完成。在任务结构中的node_policy域决定了最初的负载平衡选择方式。

Node_policy:平衡方式:注释
0(缺省值):do_execve():任务由fork()创建,但不在同一个结点上运行exec()
1:do_fork():如果子进程有新的mm结构,选择新的HOME结点
2:do_fork():选择新的HOME结点

3.2 动态负载平衡

在结点内,该NUMA调度器如同O(1)调度器一样。在一个空闲处理器上的动态负载平衡是由每隔1ms的时钟中断触发的,它试图寻找一个高负载的处理器,并将该处理器上的任务迁移到空闲处理器上。在一个负载较重的结点,则每隔200ms触发一次。调度器只搜索本结点内的处理器,只有还没有运行的任务可以从Cache池中移动到其它空闲的处理器。

如果本结点的负载均衡已经非常好,则计算其它结点的负载情况。如果某个结点的负载超过本结点的25%,则选择该结点进行负载均衡。如果本地结点具有平均的负载,则延迟该结点的任务迁移;如果负载非常差,则延迟的时间非常短,延迟时间长短依赖于系统的拓扑结构。

四、CpuMemSets

SGI的Origin 3000 ccNUMA系统在许多领域得到了广泛应用,是个非常成功的系统,为了优化Origin 3000的性能,SGI的IRIX操作系统在其上实现了CpuMemSets,通过将应用与CPU和内存的绑定,充分发挥NUMA系统本地访存的优势。Linux在NUMA项目中也实现了CpuMemSets,并且在SGI的Altix 3000的服务器中得到实际应用。

CpuMemSets为Linux提供了系统服务和应用在指定CPU上调度和在指定结点上分配内存的机制。CpuMemSets是在已有的Linux调度和资源分配代码基础上增加了cpumemmap和cpumemset两层结构,底层的cpumemmap层提供一个简单的映射对,主要功能是:将系统的CPU号映射到应用的CPU号、将系统的内存块号映射到应用的内存块号;上层的cpumemset层主要功能是:指定一个进程在哪些应用CPU上调度任务、指定内核或虚拟存储区可分配哪些应用内存块。

4.1 cpumemmap

内核任务调度和内存分配代码使用系统号,系统中的CPU和内存块都有对应的系统号。应用程序使用的CPU号和内存块号是应用号,它用于指定在cpumemmap中CPU和内存的亲和关系。每个进程、每个虚拟内存区和Linux内核都有cpumemmap,这些映射是在fork()、exec()调用或创建虚拟内存区时继承下来的,具有root权限的进程可以扩展cpumemmap,包括增加系统CPU和内存块。映射的修改将导致内核调度代码开始运用新的系统CPU,存储分配代码使用新的内存块分配内存页,而已在旧块上分配的内存则不能迁移。Cpumemmap中不允许有空洞,例如,假设cpumemmap的大小为n,则映射的应用号必须从0到n-1。

Cpumemmap中系统号和应用号并不是一对一的映射,多个应用号可以映射到同一个系统号。

4.2 cpumemset

系统启动时,Linux内核创建一个缺省的cpumemmap和cpumemset,在初始的cpumemmap映射和cpumemset中包含系统目前所有的CPU和内存块信息。

Linux内核只在该任务cpumemset的CPU上调度该任务,并只从该区域的内存列表中选择内存区分配给用户虚拟内存区,内核则只从附加到正在执行分配请求CPU的cpumemset内存列表中分配内存。

一个新创建的虚拟内存区是从任务创建的当前cpumemset获得的,如果附加到一个已存在的虚拟内存区时,情况会复杂些,如内存映射对象和Unix System V的共享内存区可附加到多个进程,也可以多次附加到同一个进程的不同地方。如果被附加到一个已存在的内存区,缺省情况下新的虚拟内存区继承当前附加进程的cpumemset,如果此时标志位为CMS_SHARE,则新的虚拟内存区链接到同一个cpumemset。

当分配页时,如果该任务运行的CPU在cpumemset中有对应的存储区,则内核从该CPU的内存列表中选择,否则从缺省的CPU对应的cpumemset选择内存列表。

4.3 硬分区和CpuMemSets

在一个大的NUMA系统中,用户往往希望控制一部分CPU和内存给某些特殊的应用。目前主要有两种技术途径:硬分区和软分区技术,CpuMemSets是属于软分区技术。将一个大NUMA系统的硬分区技术与大NUMA系统具有的单系统映像优势是矛盾的,而CpuMemSets允许用户更加灵活的控制,它可以重叠、划分系统的CPU和内存,允许多个进程将系统看成一个单系统映像,并且不需要重启系统,保障某些CPU和内存资源在不同的时间分配给指定的应用。

SGI的CpuMemSets软分区技术有效解决硬分区中的不足,一个单系统的SGI ProPack Linux服务器可以分成多个不同的系统,每个系统可以有自己的控制台、根文件系统和IP网络地址。每个软件定义的CPU组可以看成一个分区,每个分区可以重启、安装软件、关机和更新软件。分区间通过SGI NUMAlink连接进行通讯,分区间的全局共享内存由XPC和XPMEM内核模块支持,它允许一个分区的进程访问另一个分区的物理内存。

五、测试

为了有效验证Linux NUMA系统的性能和效率,在SGI公司上海办事处测试了NUMA架构对SGI Altix 350性能。该系统的配置如下:CPU:8个,1.5 GHz Itanium2;内存:8GB 互连结构,如下图所示:


SGI Altix350 4个计算模块的Ring拓扑

​测试用例:

1、Presta MPI测试包(来自ASCI Purple的Benchmark)

从互连拓扑结构可以看出,计算模块内部的访存延迟不需要通过互连,延迟最逗,剩下的需要通过1步或2步互连到达计算模块,我们通过Presta MPI测试包,重点测试每步互连对系统的影响,具体结果如下:
最小延迟(us):一步延迟(us):两步延迟(us)
1.6:1.8:2.0

2、NASA的NPB测试

​上述测试表明,SGI Altix 350系统具有较高的访存和计算性能,Linux NUMA技术已进入实用阶段。