翻译自 http://developer.amd.com/wordpress/media/2012/10/LibNUMA-WP-fv1.pdf
在传统的对称多处理器(SMP, Symmetric Multiprocessing)系统中,整个计算机中的所有cpu共享一个单独的内存控制器。当所有的cpu同时访问内存时,这个内存控制器常常成为性能瓶颈。同时,这种架构也不能适应使用大量的cpu的场景。于是,为了解决这些问题,越来越多的现代计算机系统采用了CC/NUMA(缓存一致性/非对称访存)架构。例如AMD* Opteron*, IBM* Power5*, HP* Superdome, and SGI* Altix*.
在SMP系统上,所有的cpu对共享内存控制器拥有相同的访问权限。cpu之间的访问这个共享资源的请求会导致拥塞。这个单独的内存控制器能够管理的内存大小也是有限的,很可能限制了整个系统的内存大小。另外,通过这个单独的通信中心也会导致时延高居不下。
于是,NUMA架构就被设计出来,解决SMP架构在扩展性方面的诸多限制。与SMP架构中整个系统只有一个内存控制器不同,NUMA系统中被分成多个node。每个node都有处理器和它自己控制的内存。这些处理器可以非常快速的访问node内部的本地内存。系统上所有的node使用一个快速的互联总线连接起来。每个新node加入,都会给系统提供更多的内存总带宽和内存访问性能,所以具备很高的扩展能力。
同一个node内部的处理器(一个numa node可能拥有多个cpu core)对于node内部的内存具有相同的读写能力。在一个numa系统中,cpu集成了内存控制器,例如AMD Opteron,一个numa node由一个单独的cpu组成,整个cpu具备了多个core或者多个处理线程。在其他更传统的NUMA系统中,例如SGI Altix或HP Superdome,大量的numa node(node内部类似一个SMP系统)由2个或4个cpu共享内存。
在一个NUMA系统中,每个cpu可以访问本地或者远端的内存,本地的内存位于同一个numa node中,cpu访问本地内存的时延开销很小。远端内存位于不同的numa node中,必须通过内部互联(例如QPI)才能访问。从软件的角度看来,访问远端内存与访问本地内存的方法相同;具有完全的缓存一致性。相对访问本地内存,访问远端内存要花费更长的时间,这是因为数据通过内部互联总线读取,导致了更多的时延。
理论上,软件可以按照相同的方式对待NUMA系统与SMP系统,忽略掉访问本地、远端内存的区别。实际上,也是经常这么做的。但是为了获得最佳的性能,应该考虑到NUMA、SMP架构差异引入的问题。
NUMA架构的一大优势就是:即使在一个拥有很多cpu的大系统中,cpu仍然能够以很低的时延访问本地内存。这是因为现代cpu的速度比内存芯片的速度快了很多。CPU经常要花费不小的时间来等待内存数据。最小化内存访问时延,可以显著的优化软件的性能。
NUMA的优化方式是:将内存通过特定node分配,并且让程序尽量快的读取这段内存。实现这种优化的首要方法是,线程通过node分配本地内存,并且保证这个线程一直工作在这个node上(通过node affinity来确定)。这将会获得最低的时延,最小的内部互联开销。
在SMP系统中,通常的优化方式被称为cache affinity。cache affinity尝试将数据维持在cpu的缓存中,而不是频繁的在cpu之间切换。这项工作通常有操作系统的调度器来完成。然后,这与node affinity有很大的不同点:在SMP系统中,当一个线程在cpu之间切换,它的缓存内容也跟着一同移动。一但一个内存区域被提交给一个特定的NUMA node,它将不会被一同调度到其他的numa node上。一个线程在不同node上切换,会增加访问内部互联总线的开销,导致访问时延的上升。这是为何NUMA系统比SMP系统更加需要实现node affinity。所以,cache affinity也是需要在NUMA系统上采用的优化手段。但是这些方法对于最佳的使用性能,并不足够。
NUMA API可以描述线程与cpu、内存的布置情况。主要是用来关联内存的所在位置。另外,应用程序可以用它来单独设置cpu affinity。NUMA API当前(2005年)在SUSE LINUX Enterprise Server 9版本基于AMD64和Intel安腾系列处理器。
很多程序会使用NUMA API对内存访问的时延和带宽进行优化。大部分程序看起来更喜欢对时延进行优化,但是仍然有一小部分例外,需要对带宽进行优化。
使用numa node的本地内存可以获得最低的时延。如果要获得更大的带宽,多个结点的内存控制器可以被并行的使用。这很类似RAID通过将I/O操作并分到多个硬盘上来达到提高I/O性能的目的。NUMA API可以使用CPU中的MMU(内存管理单元)将来自不同内存控制器的内存块交织在一起。这意味着,在这个映射中的每个联系的页来自不同的numa node。
当一个程序对这个交错的内存区域,做一次大流量的内存访问,多个numa node的内存控制器的带宽复合在一起。这么做能有多大的效果要依赖于NUMA架构,特别是内部互联总线、本地与远端内存的访问时延。在一些系统中,只有相邻的numa node上运行才能有明显的效果。
对于一些NUMA系统,例如AMD Opteron,可以通过设置firmware实现在所有的numa node上交错内存访问。这被称为结点交错。结点交错类似NUMA API提供的交错模式,但是它们有显著的不同点。结点交错用在所有的内存访问上。NUMA API则对处理器、线程进行单独配置。如果firmware开启了结点交错,NUMA策略就被禁用了。要使用NUMA策略,就要在bios设置中,禁用掉结点交错功能。
通过NUMA API,应用程序可以单独的调整内存访问策略,选择对时延优化还是对带宽优化。
内核管理处理器的内存策略和特定内存的映射。内核可以通过三个新的系统调用来控制,这通常由应用程序调用用户态的动态库libnuma来实现。libnuma是一套NUMA策略推荐的API。它比直接使用系统调用更加方便。这篇文章将会讲解这些更高层次的接口。如果程序代码不能被修改,管理员可以使用numactl命令行工具配置一些策略。相对来说,这比在程序中直接实现控制策略效果要差一些。
用户态的库、工具程序包含在numactl的RPM包中,与SUSE Linux Enterprise Server 9一同发布。另外,在这个软件包中,还有几个实用的工具,比如numastat可以收集内存分配的统计信息,numademo用了演示系统中不同numa策略的效果。软件包中也包含了所有函数、工具程序的帮助文档。
NUMA API的主要任务是管理各种策略。这些策略可以用于处理器访问内存区域。NUMA API当前支持以下策略:
default 在本地numa node上分配内存(当前的线程已经开始运行)
bind 在一系列特定的numa node上分配内存
interleave 在一系列numa node上分配交错内存
preferred 优先在某个numa node上分配内存
bind与prefered区别是,在特定numa node上分配内存失败时,bind策略会直接报错返回失败信息,而preferred策略会回滚,再到其他的numa node上分配内存。使用bind策略会由于swapping,导致早期内存不足、时延上升。在libnuma中,preferred和bind是可以复合的,也可以在不同的线程中使用numa_set_strict函数进行单独配置。默认的是更有弹性的preferred内存分配方式。
每个进程(进程策略)或者每个内存区域都可以配置策略。子进程可以通过fork继承父进程的进程策略。在进程的上下文环境中,进程策略可以应用在所有的内存分配。这包括了由系统调用产生的内核的内存分配、文件缓存。中断通常在当前节点上分配内存。进程策略也通常应用在内核分配内存页的过程中。
每个内存区域策略也被称为VMA策略,它允许进程为地址空间内的内存块设置策略。内存区域策略相比进程策略优先级更高。内存区域策略最大的优势就是可以在分配内存之前生效。当前,只支持匿名进程内存、SYSV共享内存、shmem和tmpfs映射、巨页文件系统(hugetlbfs)。针对共享内存配置的策略会持续到共享内存区域或文件被删除。
numactl是一个命令行工具,能够以特定的NUMA策略启动进程。可以在不修改或重新编译程序的前提下,设置NUMA策略。
以下是几个numactl的例子:
numactl --cpubind=0 --membind=0,1 program
使用node 0的cpu启动进程program,并且使用node 0或1为进程分配内存。cpubind后的参数是node序号,并不是cpu的序号。在多cpu组成一个numa node的系统中,cpu序号与numa node序号是不同的。
numactl --preferred=1 numactl --show
分配内存优选numa node 1上的cpu结果状态。
numactl --interleave=all numbercruncher
以交错内存的策略,运行numbercruncher程序
numactl --offset=1G --length=1G --membind=1 --file /dev/shm/A --touch
将临时文件系统上的/dev/shm/A文件的第二GB数据绑定到numa node 1
numactl --localalloc /dev/shm/file
为共享内存文件/dev/shm/file重置策略
numactl --hardware
显示numa node的硬件信息
以下是有关numactl的命令行选项的简介。这些命令行选项中的大部分需要带参数。系统中的每个numa node都有唯一的编号。一个结点掩码可以是以逗号分隔的node数列表、numa node范围(node1-node2)或者all(所有的node)。可以通过运行numactl --hardware来显示系统中所有可用的numa node。
numactl最常用到的就是给一个进程设置内存numa策略。策略由第一个参数给出,其后是要运行的程序名及其命令行参数。可用的命令行参数如下所示:
--membind=nodemask:
只在nodemask注明的node上分配内存
--interleave=nodemask:
将nodemask上所有的内存交错分配
--cpubind=nodemask:
只在nodemask上标名的cpu上运行程序,对内存分配策略不产生影响,只对进程调度有效果
--preferred=node:
从参数给出的node中优先分配内存
另外两个有用的选项:
--show:
打印当前进程从命令行启动shell命令中继承的NUMA策略
--hardware:
显示系统中所有可用的NUMA资源
其他关于numactl命令的细节,可以参考man页
numactl可以改变共享内存段的策略。这可以用来改变一个应用程序的策略。
一个程序由多个进程组成,这些进程使用了公同的共享内存。这在数据库服务器中很常见。对于单独的进程,最好是在当前的numa node上使用默认的内存分配策略。这样,就可以在读取本地数据结构时获得最低的内存访问时延。然而,共享内存段被运行在不同node节点上的多个进程共享。为了避免分配出内存的这个节点成为热点,将共享内存段设置为交错读取是更有益处。同时,对这块内存的访问也被分载到了所有的numa node上了。
使用更多复杂的numa策略也成为可能。当共享内存中的一部分被特定的处理器频繁的读取, 却很少被其他处理器访问,可以将这块内存与相应的NUMA node或者在将与之相邻的numa node绑定。
这里的共享内存是指SYSV共享内存(由shmat系统调用获得)、tmpfs或shmfs(通常在/dev/shm下)文件系统中mmap映射的文件、巨页文件。共享内存策略可以在启动应用程序之前进行配置。共享内存的策略一直生效,直到这块共享内存被删除。
这个策略只能应用在新分配出的内存页。共享内存中已经存在的页面不能遵循这个策略。
将1GB的tmpfs文件对应的内存设置成基于所有numa node分配:
numactl --length=1G --file=/dev/shm/interleaved --interleave=all
巨页文件可以以相同的方式设置,但是文件的总长必须是巨页(huge page)大小的整数倍。
通过--offset=number参数可以指定共享内存或文件的便宜位置。所有的数字参数可以带单位:G、M、K。新文件的类型可以通过--mode=mode来配置。
可以使用--strict参数。当使用--strict参数时,如果已经分配出的内存页没有遵循新的策略,numactl将会报错。
numactl还有其他更多的选项,可以控制共享内存的类型。可以参考numactl的man页。
文章写到这来,一直在描述numactl,它能够控制整个进程的内存分配策略。但是这种方法的缺陷是:只能控制整个进程的内存分配,不能区分不同的内存区域、线程的区别。对于很多程序,还需要更细粒度的内存分配策略。
这可以通过libnuma来实现。libnuma是一个共享库,应用程序可以调用,并且libnuma提供了配置NUMA策略的API。相比NUMA系统调用,它提供了更高级的接口,更适合在程序中使用。libnuma可以从numactl RPM包中获得。
程序连接libnuma库可以使用以下命令:
cc ... -lnuma
NUMA API函数、宏都定义在numa.h头文件中:
#include <numa.h> ... if (numa_available() < 0) { printf("Your system does not support NUMA API\n"); ... } ...
在任何一个NUMA API函数被调用之前,程序必须调用numa_available()函数。当这个函数返回负值,说明当前系统不支持任何一个NUMA策略。如果这样,所有其他NUMA API函数都是未定义的,并且不能被调用。
下一步通常调用numa_max_node()函数。这个函数能够上报当前系统中numa node的个数。numa node个数在程序内存策略配置的过程中需要用到。所有的程序应该使用动态的发现numa node的个数,而不是使用硬编码来设置numa node个数。
所有的libnuma状态保存在每个线程上。修改一个线程的numa策略,不会影响到同一个进程上的其他线程。
以下的章节将会通过范例介绍libnuma中函数的大概用法。一些不常用到的函数没有被提及。要想获得更多细节,请参考numa的man页。
libnuma通过numa.h中定义的抽象数据结构nodemask_t管理结点的集合。nodemast_t提供了一个描述结点数量的固定位的集合。系统中的每个结点都有对应的唯一数字。最大的数字通过numa_max_node()获得。最大的结点数是由NUM_NUM_NODES宏设置。很多NUMA API函数都会涉及nodemask。
通过nodemask_zero()函数,将nodemask初始化为空
nodemask_t mask; nodemask_zero(&mask);一个单独的node可以由nodemask_set函数设置,有nodemask_clr清空。nodemask_equal用来比较两个nodemask。nodemask_isset比较nodemask中对应的位是否被置位。
nodemask_set(&mask, maxnode); /* set node highest */ if (nodemask_isset(&mask, 1)) { /* is node 1 set? */ ... } nodemask_clr(&mask, maxnode); /* clear highest node again */这里有两个预设的nodemask:numa_all_nodes代表系统中所有的结点,numa_no_node则是一个空集合。
numa_alloc_interleaved allocates memory interleaved on all nodes in the system.
libnuma提供了以特定策略分配内存的函数。这些函数将所有分配页的整数倍(在AMD系统上,页大小为4K)大小的内存,而且相对较慢。只适用于分配超过cpu缓存大小的较大的内存对象,NUMA策略很有可能对这些内存起作用。如果没有内存空间可以被分配,将会返回NULL。所有numa_alloc族函数的分配出的内存,应该由numa_free函数释放。
numa_alloc_onnode用来在一个给定的numa node上分配内存:
void *mem = numa_alloc_onnode(MEMSIZE\_IN\_BYTES, 1); if (mem == NULL) /* report out of memory error */ ... pass mem to a thread bound to node 1 ...memsize应该小于node上的内存大小。切记,其他的程序也会在这个node上分配内存,如果这个node上所有的内存都被分配,将会导致内存被换出到硬盘。numa_node_size()函数可以被用来发现当前系统上的numa node大小限制。
线程最终需要释放内存,通过numa_free函数:
numa_free(mem, memsize);默认情况下,numa_alloc_onnode首先尝试在特定的node上分配内存,如果失败,将会在其他有剩余内存空间的结点上分配内存。当numa_set_strict(1)先执行,numa_alloc_onnode函数在预设结点没有内存空间的情况下,将不会回滚错误,直接返回分配内存失败。在分配内存之前,内核会尝试将node上的内存换出到硬盘,并清除其他缓存,这将会导致时延。
numa_allocinterleaved在系统上的所有结点分配交错(interleaverd)内存
void *mem = numa_alloc_interleaved(MEMSIZE\_IN\_BYTES); if (mem == NULL) /* report out of memory error */ ... run memory bandwidth intensive algorithm on mem ... numa\_free(mem, MEMSIZE_IN_BYTES);
将内存交错在所有的结点上,并不一定能够达到最佳的性能。根据机器的NUMA架构,有时候只有相邻结点的交错内存才能获得更好的带宽。 numa_alloc_interleaved_subset函数可以只在几个给定的node上分配交错内存。
另外一个函数numa_alloc_local可以在本结点上分配内存。通常是默认用来进行内存分配,但是如果需要使用与进程的内存分配策略不同的策略时,需要首先指定策略。numa_alloc函数使用当前进程的策略分配内存。
每个线程都有一个默认的内存策略,继承自它的父线程。除非通过numactl来修改,这个策略通常是优先从本结点上优先分配内存。如果一个程序中现存的内存分配代码不能修改成numa_alloc函数,有时可以采用修改进程策略的方法达到目的。采用这种方法,给定的子函数可以在不修改代码的前提下以非默认策略的方式运行。
numa_set_interleave_mask函数可以让当前线程以交错(interleaving)方式分配内存。未来所有的内存,将会从掩码给定的结点上轮询(round robing)分配。numa_all_nodes将内存分配交错(interleaving)在所有的node上。numa_no_nodes将会关闭交错分配内存。numa_get_interleave_mask函数返回当前的交错掩码。这可以将当前的内存分配策略保存到文件中,在策略修改后,再次恢复。
numamask_t oldmask = numa_get_interleave_mask(); numa_set_interleave_mask(&numa_all_nodes); /* run memory bandwidth intensive legacy library that allocates memory */ numa_set_interleave_mask(&oldmask);numa_set_preferred设置当前线程优先分配内的结点。内存分配器先尝试从这个结点上分配内存。如果这个结点没有足够的空间,它会尝试其他结点。
numa_set_membind设置了严格的内存绑定掩码。严格意味着内存必须从指定的结点上分配。如果在内存交换以后,结点上仍然没有足够的内存,分配操作就会失败。
numa_set_membind返回当前的内存绑定掩码。
numa_set_localalloc将进程的策略设置为标准的本地分配策略。
......