6.4 实测 NUMA 陷阱

我们公司的基础架构部有个云Redis平台,其中Redis实例在申请的时候可以自由选择需要的内存的大小。然后就引发了我的一个思考,Redis单实例内存最大申请到多大比较合适?假设母机是64GB内存的物理机,如果不考虑CPU资源的的浪费,我是否可以开一个50G的Redis实例?
于是我在Google上各种搜索,讨论这个问题的人似乎不多。找到唯一感觉靠谱点的答案,那就是单进程分配的内存最好不要超过一个node里的内存总量,否则linux当该node里的内存分配光了的时候,会在自己node里动用硬盘swap,而不是其它node里申请。这即使所谓的numa陷阱,当Redis进入这种状态后会导致性能急剧下降(不只是redis,所有的内存密集型应用如mysql,mongo等都会有类似问题)。
看起来这个解释非常有说服力。于是乎,我就想亲手捕捉一次NUMA陷阱,看看这个家伙究竟什么样。

先聊聊QPI与NUMA

最早在CPU都是单核的时候,用的总线都是FSB总线。经典结构如下图: (此处原文档有图,描述FSB总线结构:CPU通过FSB连接北桥,北桥连接内存)

到来后来CPU的开发者们发现CPU的频率已经接近物理极限了,没法再有更大程度的提高了。在2003年的时候,CPU的频率就已经达到2个多GB,甚至3个G了。现在你再来看今天的CPU,基本也还是这个频率,没进步多少。摩尔定律失效了,或者说是向另外一个方向发展了。那就是多核化、多CPU化。 刚开始核不多的时候,FSB总线勉强还可以支撑。但是随着CPU越来越多,所有的数据IO都通过这一条总线和内存叫唤数据,这条FSB就成为了整个计算机系统的瓶颈。举个北京的例子,这就好比进回龙观的京藏高速,刚开始回龙观人口不多的时候,这条高速承载没问题。但是现在回龙观聚集了几十万人口了,“总线”仅有这一条,未免效率太低。 CPU的设计者们很快改变了自己的设计,引入了QPI总线,相应的CPU的结构就叫NMUA架构。下图直观理解
(此处原文档有图,描述NUMA架构:两个CPU各自直连一部分内存,通过QPI互联)

话说NUMA陷阱

NUMA陷阱指的是引入QPI总线后,在计算机系统里可能会存在的一个坑。大致的意思就是如果你的机器打开了numa,那么你的内存即使在充足的情况下,也会使用磁盘上的swap,导致性能低下。原因就是NUMA为了高效,会仅仅只从你的当前node里分配内存,只要当前node里用光了(即使其它node还有),也仍然会启用硬盘swap。 当我第一次听说到这个概念的时候,不禁感叹我运气好,我的Redis实例貌似从来没有掉进这个陷阱里过。那为了以后也别栽坑,赶紧去了解了下我的机器的numa状态:

# numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 12 13 14 15 16 17
node 0 size: 32756 MB
node 0 free: 19642 MB
node 1 cpus: 6 7 8 9 10 11 18 19 20 21 22 23
node 1 size: 32768 MB
node 1 free: 18652 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

上面结果说明我们有两个node,node0和node1,分别有12个核心,各有32GB的内存。 再看zone_reclaim_mode,它用来管理当一个内存区域(zone)内部的内存耗尽时,是从其内部进行内存回收还是可以从其他zone进行回收的选项:

  • 0 关闭zone_reclaim模式,可以从其他zone或NUMA节点回收内存
  • 1 打开zone_reclaim模式,这样内存回收只会发生在本地节点内
  • 2 在本地回收内存时,可以将cache中的脏数据写回硬盘,以回收内存
  • 4 在本地回收内存时,表示可以用Swap 方式回收内存

额,好吧。我的这台机器上的zone_reclaim_mode还真是1,只会在本地节点回收内存。

# cat /proc/sys/vm/zone_reclaim_mode
1

实践捕捉numa陷阱未遂

那我的好奇心就来了,既然我的单个node节点只有32G,那我部署一个50G的Redis,给它填满数据试试到底会不会发生swap。 实验开始,我先查看了本地总内存,以及各个node的内存剩余状况。

# top
......
Mem:  65961428k total, 26748124k used, 39213304k free,   632832k buffers
Swap:  8388600k total,        0k used,  8388600k free,  1408376k cached
# cat /proc/zoneinfo"
  ......
Node 0, zone   Normal
  pages free     4651908
Node 1, zone   Normal
  pages free     4773314

总内存不用解释,/proc/zoneinfo 里包含了node可供应应用程序申请的free pages。node1有4651908个页面,4651908*4K=18G的可用内存。 接下来让我们启动redis实例,把其内存上限设置到超过单个node里的内存大小。我这里单node内存大小是32G,我把redis设置成了50G。开始灌入数据。最终数据全部灌完之后,

# top
......
Mem:  65961428k total, 53140400k used, 12821028k free,   637112k buffers
Swap:  8388600k total,        0k used,  8388600k free,  1072524k cached
  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND
 8356 root      20   0 62.8g  46g 1292 S  0.0 74.5   3:45.34 redis-server
 
# cat /proc/zoneinfo | grep "pages free"
  pages free     3935
  pages free     347180
  pages free     1402744
  pages free     1501670
# grep ctxt /proc/8356/status
voluntary_ctxt_switches:          5259503
nonvoluntary_ctxt_switches:       1449

实验证明,在zone_reclaim_mode为1的情况下,Redis是平均在两个node里申请节点的,并没有固定在某一个cpu里。 莫非是大佬们的忠告错了吗?其实不是,如果不绑定亲和性的话,分配内存是当进程在哪个node上的CPU发起内存申请,就优先在哪个node里分配内存。之所以是平均分配在两个node里,是因为redis-server进程实验中经常会进入主动睡眠状态,醒来后可能CPU就换了。所以基本上,最后看起来内存是平均分配的。如下图,CPU进行了500万次的上下文切换,用top命令看到cpu也是在node0和node1跳来跳去。

改进方法,成功抓获numa陷阱

杀死进程,内存归位 绑定CPU和内存的亲和性,然后再启动。

numactl --cpunodebind=0 --membind=0 /search/odin/daemon/redis/bin/redis-server 
/search/odin/daemon/redis/conf/redis.conf 
# cat /proc/zoneinfo 
Node 0, zone   Normal
  pages free     10697
Node 1, zone   Normal
  pages free     7686732

top命令观察到CPU确实一直在node0的节点里。node里的内存也在快速消耗。 看,内存很快就消耗光了。我们再看top命令观察到的swap,很激动地发现,我终于陷入到传说中的numa陷阱了。

Tasks: 603 total,   2 running, 601 sleeping,   0 stopped,   0 zombie
Cpu(s):  0.7%us,  5.4%sy,  0.0%ni, 85.6%id,  8.2%wa,  0.0%hi,  0.1%si,  0.0%st
Mem:  65961428k total, 34530000k used, 31431428k free,   319156k buffers
Swap:  8388600k total,  6000792k used,  2387808k free,   777584k cached
  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND
  258 root      20   0     0    0    0 R 72.3  0.0   0:17.18 kswapd0
25934 root      20   0 37.5g  30g 1224 D 71.6 48.7   1:06.09 redis-server

这时候,Redis实际使用的物理内存RES定格到了30g不再上涨,而是开始消耗Swap。 又过了一会儿,Redis被oom给kill了。

结论

通过今天的实验,我们可以发现确实有NUMA陷阱这种东西存在。不过那是我手工通过numactl 指令绑定cpu和mem的亲和性后才遭遇的。相信国内绝大部门的线上Redis没有进行这个绑定,所以理论上来单Redis单实例可以使用到整个机器的物理内存。(实践中最好不要这么干,你的大部分内存都绑定到一个redis进程里的话,那其它CPU核就没啥事干了,浪费了CPU的多核计算能力) 另外扩展一下,当通过numactl 绑定CPU和mem都在一个node里的时候,内存IO不需要经过总线,性能会比较高,你Redis的QPS能力也会上涨。和跨node的内存IO性能对比,可以下面的实例,就是10:21的区别。

# numactl --hardware
......
node distances:
node   0   1
  0:  10  21
  1:  21  10

你要是对性能有极致的追求,可以试着绑定numa的亲和性玩玩。不过,no作no die,掉到numa陷阱里可别赖我,嘎嘎!


第七章 内存性能优化实例

7.1 一次工程中内存性能优化实践

现代的开发语言除了C++以外,大部分都对内存管理做好了封装,一般的开发者根本都接触不到内存的底层操作。更何况现在各种优秀的开源组件应用越来越多,例如mysql、redis等,这些甚至都不需要大家动手开发,直接拿来用就好了。所以有些同学也会觉得作为应用层开发的同学没有学习的必要去学习底层。
但我想通过本文的实际案例告诉大家,哪怕不直接接触内存底层操作,就只是用一些开源的工具,如果你能理解底层的工作原理,你也能够用到极致。

用户访问历史读写需求

假如现在有这样一个人业务需求,用户每次刷新都需要获得要消费的新数据,但是不能和之前访问过的历史重复。你可以把它和你经常在用的今日头条之类的信息流app联系起来。每次都要看到新的新闻,但是你肯定不想看到过去已经看过的文章。 这样在功能实现的时候,就必要保存用户的访问历史。当用户再来刷新的时候,首先得获取用户的历史记录,要保证推给用户的数据和之前的不重复。当推荐完成的时候,也需要把这次新推荐过的数据id记录到历史里。 为了适当降低实现复杂度,我们可以规定每个用户只要不和过去的一万条记录重复就可以了。这样每个用户最多只需要保存一万条历史id,如果存满了就把最早的历史记录挤掉。我们进一步具体化一下这个需求的几个关键点:

  • 每个数据id是一个int整数来表示
  • 每个用户要保存1万条id
  • 每次用户刷新开始的时候需要将这1万条历史全部读取出来过滤一遍
  • 每次用户刷新结束的时候需要将新访问过的10条写入一遍,如果超过1万需将最早的记录挤掉

可见,每次用户访问的时候,会涉及到一个1万规模的数据集上的一次读取和一次写入操作。 好了,需求描述完了,我们怎么样进行我们的技术方案的设计呢?相信你也能想到很多实现方案,我们今天来对比两个基于Redis下的存储方案在性能方面的优劣。

方案一:用Redis的list来存储

首先能想到的第一个办法就是用Redis的List来保存。因为这个数据结构设计的太适合上面的场景了。

  • List下的lrange命令可以实现一次性读取用户的所有数据id的需求。
  • lpush命令可以实现新的数据id的写入,ltrim可以保证将用户的记录数量不超过1万条。 我们准备一个用户,提前存好一万条id。写入的时候每次只写入10条新的id,读取的时候通过lrange一次全部读取出来。进行一下性能耗时测试,结果如下。

方案二:用Redis的string来存储

我能想到的另外一个技术方案就是直接用String来存。我们可以把1万个int表示的数据id拼接成一个字符串,用一个特殊的字符把他们分割开。例如:“100000_100001_10002”这种。 存储的时候,拼接一下,然后把这个大字符串写到Redis里。读取的时候,把大字符串整体读取出来,然后再用字符切割成数组来使用。 由于用string存储的时候,保存前多了一个拼接字符串的操作,读取后多了一步将字符串分割成数组的操作。在测试string方案的时候,为了公平起见,我们把需要把这两步的开销也考虑进来。 核心代码如下:

$userItems = array(......);
//写入
for($i=0; $i<$repeats; $i++){
  $redis->set('TEST_KEY', implode('_', $userItems));
}
//读取
for($i=0; $i<10000; $i++){
  $items = explode("_", $redis->get('TEST_KEY'));
}

耗时测试结果如下:

Write repeats:10000     time consume:6.4061808586121    each 0.00064061808586121
Read repeats:10000      time consume:4.9698271751404    each 0.00049698271751404

结论

我们再直观对比下两个技术方案的性能数据。

方案写入耗时读取耗时总耗时
list0.066ms4.238ms4.304ms
string0.640ms0.496ms1.136ms

基于list的方案里,写入速度非常快,只需要0.066ms,因为仅仅只需要写入新添加的10条记录就可以了,再加一次链表的截断操作,但是读取性能可就要慢很多了,超过了4ms。原因之一是因为读取需要整体遍历,但其实还有第二个原因。我们本案例中的数据量过大,所以Redis在内部实际上是用双端链表来实现的。 (此处原文档有图,描述链表结构:节点通过指针串联,内存中随机分布) 通过上图你可能看出来,链表是通过指针串起来的。大量的node之间极大概率是随机地分布在内存的各个位置上,这样你遍历整个链表的时候,实际上大概率会导致内存的随机模式下工作。 基于string方案在写入的时候耗时比list要高,因为每次都得需要将1万条全部写入一遍。但是读取性能却比list高了10倍,总体上耗时加起来大约只有方案一的1/4左右。为什么?我们再来看下redis string数据结构的内存布局 (此处原文档有图,描述string在内存中连续存储) 可见,如果用string来存储的话,不管用户的数据id有多少,访问将全部都是顺序IO。顺序IO的好处有两点:

  1. 内存的顺序IO的耗时大约只是随机IO的1/3-1/4左右,
  2. 对于读取来说,顺序访问将极大地提升CPU的L1、L2、L3的cache命中率 所以如果你深入了内存的工作原理,哪怕你不能直接去操作内存,即使只是用一些开源的软件,你也能够将它的性能发挥到极致~

7.2 PHP7内存性能优化思想精髓

前面我们讨论了内存的工作原理,也进行了一些性能相关的测试。那么今天开始我们来看几个在实践中的应用。首先我们先从PHP开始。
2015年,PHP7的发布可以说是在技术圈里引起了不小的轰动,因为它的执行效率比PHP5直接翻了一倍。PHP7在内存方面,你是否知道作者都进行了哪些优化?你是否能够深层次理解到作者优化思路的精髓?
让我们从几个核心的数据结构改进开始看起。

PHP7 zval变化

  1. PHP5.3中的zval:
    我们这里只讨论64位操作系统下的情况。该zval_struct结构体中的由四个成员构成,其中zvalue_value稍微复杂一些,是一个联合体。联合体中最长的成员是一个指针加一个int,8+4=12字节。但是默认情况下,会进行内存对齐,故zval_struct会占用16字节。 那么
typedef unsigned int zend_object_handle;
typedef struct _zend_object_value {
  zend_object_handle handle;
  zend_object_handlers *handlers;
} zend_object_value;
 
typedef union _zvalue_value {
  long lval;          /* long value */
  double dval;        /* double value */
  struct {
    char *val;
    int len;
  } str;
  HashTable *ht;        /* hash table value */
  zend_object_value obj;
} zvalue_value;
 
struct _zval_struct {
  /* Variable information */
  zvalue_value value;   /* value */
  zend_uint refcount__gc;
  zend_uchar type;  /* active type */
  zend_uchar is_ref__gc;
};

_zval_struct总的字节 = value(16)+ refcount__gc(4)+ type(1)+ is_ref__gc(1)= 占用22字节。
最后再考虑下内存对齐,实际占用24字节。

  1. PHP7.2中的zval
    7.2中的zval_struct结构体里由3个成员构成,其中zend_value看起来比较复杂,实际上只是一个8字节的联合体。
    u1也是一个联合体,占用是4个字节。u2也一样。这样zval_struct就实际占用16字节。
typedef struct _zval_struct     zval;
typedef union _zend_value {
  zend_long         lval;       /* long value */
  double            dval;       /* double value */
  zend_refcounted  *counted;
  zend_string      *str;
  zend_array       *arr;
  zend_object      *obj;
  zend_resource    *res;
  zend_reference   *ref;
  zend_ast_ref     *ast;
  zval             *zv;
  void             *ptr;
  zend_class_entry *ce;
  zend_function    *func;
  struct {
    uint32_t w1;
    uint32_t w2;
  } ww;
} zend_value;
 
struct _zval_struct {
  zend_value        value;      /* value */
  union {  
    struct {
      ZEND_ENDIAN_LOHI_4(
        zend_uchar    type,     
        zend_uchar    type_flags,
        zend_uchar    const_flags,
        zend_uchar    reserved)      
    } v;
    int type_info;
  } u1;
  union {  ...... } u2;
};

PHP7 HashTable变化

  1. PHP5.3里的HashTable:
typedef struct _hashtable {
        uint nTableSize;
        uint nTableMask;
        uint nNumOfElements;
        ulong nNextFreeElement;   //注意这里:浪费ing
        Bucket *pInternalPointer;       /* Used for element traversal */
        Bucket *pListHead;
        Bucket *pListTail;
        Bucket **arBuckets;
        dtor_func_t pDestructor;
        zend_bool persistent;
        unsigned char nApplyCount;
        zend_bool bApplyProtection;
} HashTable;

在5.3里HashTable就是一个大struct, 有点小复杂,我们拆开了细说:

  • uint nTableSize 4字节
  • uint nTableMask 4字节
  • uint nNumOfElements 4字节,
  • ulong nNextFreeElement 8字节 注意这前面的4个字节会被浪费掉,因为nNextFreeElement的开始地址需要对齐
  • Bucket *pInternalPointer 8字节
  • Bucket *pListHead 8字节
  • Bucket *pListTail 8字节
  • Bucket **arBuckets 8字节
  • dtor_func_t pDestructor 8字节
  • zend_bool persistent 1字节
  • unsigned char nApplyCount 1字节
  • zend_bool bApplyProtection 1字节

最终 总字节数 = 4+4+4+4(nNextFreeElement前面这四个字节会留空)+8+8+8+8+8+8+1+1+1 = 67字节。
再加上结构体本身要对齐到8的整数倍,所以实际占用72字节。

  1. PHP7.2里的HashTable:
typedef struct _zend_array HashTable;
struct _zend_array {
  zend_refcounted_h gc;
  union {
    struct {
      ZEND_ENDIAN_LOHI_4(
        zend_uchar    flags,
        zend_uchar    nApplyCount,
        zend_uchar    nIteratorsCount,
        zend_uchar    consistency)
    } v;
    uint32_t flags;
  } u;
  uint32_t          nTableMask;
  Bucket           *arData;
  uint32_t          nNumUsed;
  uint32_t          nNumOfElements;
  uint32_t          nTableSize;
  uint32_t          nInternalPointer;
  zend_long         nNextFreeElement;
  dtor_func_t       pDestructor;
};

在7.2里HashTable:

  • zend_refcounted_h gc 看起来唬人,实际就是个long,占用8字节
  • union… u 占用4字节
  • uint32_t 占用4字节
  • Bucket* 指针占用8字节
  • uint32_t nNumUsed 占用4字节
  • uint32_t nNumOfElements 占用4字节
  • uint32_t nTableSize 占用4字节
  • uint32_t nInternalPointer 占用4字节
  • zend_long nNextFreeElement 占用8字节
  • dtor_func_t pDestructor 占用8字节

总字节数 = 8+4+4+8+4+4+4+4+8+8 = 56字节,并且正好达到了内存对齐的状态,没有额外的浪费。

另外还有PHP源代码里经常出镜的Buckets也从72下降到了32字节,这里我就不翻源代码了。

优化思路精髓

我们看了两个核心数据结构的结构体变化,这上面的优化都是什么含义呢? 拿HashTable举例,貌似从72字节优化到了56字节,这内存节约的也不是特别多嘛,才20%多而已!
但这里面其实隐藏了两个较深层次优化思路

第一、 CPU在向内存要数据的时候是以Cache Line为单位进行的,而我们说过Cache Line的大小就是64字节。回过头来看HashTable,在7.2里的56字节,只需要CPU向内存进行一次Cache Line大小的burst IO,就够了。而在5.3里的72字节,虽然只比Cache Line大了那么一丢丢,但是对不起,必须得进行两次burst IO才可以。 所以,在计算机里,72字节相对56字节实际上是翻倍的性能提升!!

第二、 CPU的L1L2L3的容量是固定的几十K或者几十M。假设Cache的都是HashTable,那么Cache容量不变的条件下,PHP7里能Cache住的HashTable数量将会翻倍,缓存命中率提升一大截。要知道L1命中后只需要1ns多一点的耗时,而如果穿透到内存的话可能就需要40多纳秒的延时了,整整差了几十倍。

所以PHP内核的作者大牛深谙CPU与内存的工作原理,表面上看起来只是几个字节的节约,但是实际上爆发出了巨大的性能提升!

## 6.4 实测 NUMA 陷阱
 
我们公司的基础架构部有个云 Redis 平台,其中 Redis 实例在申请的时候可以自由选择需要的内存的大小.然后就引发了我的一个思考,Redis 单实例内存最大申请到多大比较合适?假设母机是 64GB 内存的物理机,如果不考虑 CPU 资源的浪费,我是否可以开一个 50G 的 Redis 实例?
 
于是我在 Google 上各种搜索,讨论这个问题的人似乎不多.找到唯一感觉靠谱点的答案,那就是单进程分配的内存最好不要超过一个 [[NUMA]] 节点里的内存总量,否则 Linux 当该节点里的内存分配光了的时候,会在自己节点里动用硬盘 swap,而不是其它节点里申请.这即使所谓的 **NUMA 陷阱**,当 Redis 进入这种状态后会导致性能急剧下降(不只是 redis,所有的内存密集型应用如 mysql,mongo 等都会有类似问题).
 
看起来这个解释非常有说服力.于是乎,我就想亲手捕捉一次 NUMA 陷阱,看看这个家伙究竟什么样.
 
### 先聊聊 QPI 与 NUMA
 
最早在 CPU 都是单核的时候,用的总线都是 FSB 总线.经典结构如下图:
 
(此处应有一张 FSB 总线图,请参照原文档)
 
到来后来 CPU 的开发者们发现 CPU 的频率已经接近物理极限了,没法再有更大程度的提高了.在 2003 年的时候,CPU 的频率就已经达到 2 个多 GB,甚至 3 个 G 了.现在你再来看今天的 CPU,基本也还是这个频率,没进步多少.摩尔定律失效了,或者说是向另外一个方向发展了.那就是多核化、多 CPU 化.
 
刚开始核不多的时候,FSB 总线勉强还可以支撑.但是随着 CPU 越来越多,所有的数据 IO 都通过这一条总线和内存交换数据,这条 FSB 就成为了整个计算机系统的瓶颈.举个北京的例子,这就好比进回龙观的京藏高速,刚开始回龙观人口不多的时候,这条高速承载没问题.但是现在回龙观聚集了几十万人了,“总线”仅有这一条,未免效率太低.
 
CPU 的设计者们很快改变了自己的设计,引入了 QPI 总线,相应的 CPU 的结构就叫 NUMA 架构.下图直观理解:
 
(此处应有一张 NUMA 架构图,请参照原文档)
 
### 话说 NUMA 陷阱
 
NUMA 陷阱指的是引入 QPI 总线后,在计算机系统里可能会存在的一个坑.大致的意思就是如果你的机器打开了 numa,那么你的内存即使在充足的情况下,也会使用磁盘上的 swap,导致性能低下.原因就是 NUMA 为了高效,会仅仅只从你的当前节点里分配内存,只要当前节点里用光了(即使其它 node 还有),也仍然会启用硬盘 swap.
 
当我第一次听说到这个概念的时候,不禁感叹我运气好,我的 Redis 实例貌似从来没有掉进这个陷阱里过.那为了以后也别栽坑,赶紧去了解了下我的机器的 numa 状态:
 
```shell
# numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 12 13 14 15 16 17
node 0 size: 32756 MB
node 0 free: 19642 MB
node 1 cpus: 6 7 8 9 10 11 18 19 20 21 22 23
node 1 size: 32768 MB
node 1 free: 18652 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

上面结果说明我们有两个 node,node0 和 node1,分别有 12 个核心,各有 32GB 的内存.

再看 zone_reclaim_mode,它用来管理当一个内存区域(zone)内部的内存耗尽时,是从其内部进行内存回收还是可以从其他 zone 进行回收的选项:

  • 0 关闭 zone_reclaim 模式,可以从其他 zone 或 NUMA 节点回收内存
  • 1 打开 zone_reclaim 模式,这样内存回收只会发生在本地节点内
  • 2 在本地回收内存时,可以将 cache 中的脏数据写回硬盘,以回收内存
  • 4 在本地回收内存时,表示可以用 Swap 方式回收内存

额,好吧.我的这台机器上的 zone_reclaim_mode 还真是 1,只会在本地节点回收内存.

# cat /proc/sys/vm/zone_reclaim_mode
1

实践捕捉 NUMA 陷阱未遂

那我的好奇心就来了,既然我的单个 node 节点只有 32G,那我部署一个 50G 的 Redis,给它填满数据试试到底会不会发生 swap.

实验开始,我先查看了本地总内存,以及各个 node 的内存剩余状况.

# top
......
Mem:  65961428k total, 26748124k used, 39213304k free,   632832k buffers
Swap:  8388600k total,        0k used,  8388600k free,  1408376k cached
 
# cat /proc/zoneinfo"
  ......
Node 0, zone   Normal
  pages free     4651908
Node 1, zone   Normal
  pages free     4773314

总内存不用解释,/proc/zoneinfo 里包含了 node 可供应应用程序申请的 free pages.node1 有 4651908 个页面,4651908*4K=18G 的可用内存.

接下来让我们启动 redis 实例,把其内存上限设置到超过单个 node 里的内存大小.我这里单 node 内存大小是 32G,我把 redis 设置成了 50G.开始灌入数据.最终数据全部灌完之后,

# top
......
Mem:  65961428k total, 53140400k used, 12821028k free,   637112k buffers
Swap:  8388600k total,        0k used,  8388600k free,  1072524k cached
  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND
 8356 root      20   0 62.8g  46g 1292 S  0.0 74.5   3:45.34 redis-server
 
# cat /proc/zoneinfo | grep "pages free"
  pages free     3935
  pages free     347180
  pages free     1402744
  pages free     1501670
 
# grep ctxt /proc/8356/status
voluntary_ctxt_switches:        5259503
nonvoluntary_ctxt_switches:     1449

实验证明,在 zone_reclaim_mode1 的情况下,Redis 是平均在两个 node 里申请节点的,并没有固定在某一个 cpu 里.

莫非是大佬们的忠告错了吗?其实不是,如果不绑定亲和性的话,分配内存是当进程在哪个 node 上的 CPU 发起内存申请,就优先在哪个 node 里分配内存.之所以是平均分配在两个 node 里,是因为 redis-server 进程实验中经常会进入主动睡眠状态,醒来后可能 CPU 就换了.所以基本上,最后看起来内存是平均分配的.如下图,CPU 进行了 500 万次的上下文切换,用 top 命令看到 cpu 也是在 node0 和 node1 跳来跳去.

改进方法,成功抓获 NUMA 陷阱

杀死进程,内存归位

# cat /proc/zoneinfo 
Node 0, zone   Normal
  pages free     7597369
Node 1, zone   Normal
  pages free     7686732

绑定 CPU 和内存的亲和性,然后再启动.

numactl --cpunodebind=0 --membind=0 /search/odin/daemon/redis/bin/redis-server 
/search/odin/daemon/redis/conf/redis.conf 

top 命令观察到 CPU 确实一直在 node0 的节点里.node 里的内存也在快速消耗.

# cat /proc/zoneinfo 
Node 0, zone   Normal
  pages free     10697
Node 1, zone   Normal
  pages free     7686732

看,内存很快就消耗光了.我们再看 top 命令观察到的 swap,很激动地发现,我终于陷入到传说中的 NUMA 陷阱了.

Tasks: 603 total,   2 running, 601 sleeping,   0 stopped,   0 zombie
Cpu(s):  0.7%us,  5.4%sy,  0.0%ni, 85.6%id,  8.2%wa,  0.0%hi,  0.1%si,  0.0%st
Mem:  65961428k total, 34530000k used, 31431428k free,   319156k buffers
Swap:  8388600k total,  6000792k used,  2387808k free,   777584k cached
  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND
  258 root      20   0     0    0    0 R 72.3  0.0   0:17.18 kswapd0
25934 root      20   0 37.5g  30g 1224 D 71.6 48.7   1:06.09 redis-server

这时候,Redis 实际使用的物理内存 RES 定格到了 30g 不再上涨,而是开始消耗 Swap.

又过了一会儿,Redis 被 oom 给 kill 了.

结论

通过今天的实验,我们可以发现确实有 NUMA 陷阱这种东西存在.不过那是我手工通过 numactl 指令绑定 cpu 和 mem 的亲和性后才遭遇的.相信国内绝大部分的线上 Redis 没有进行这个绑定,所以理论上来单 Redis 单实例可以使用到整个机器的物理内存.(实践中最好不要这么干,你的大部分内存都绑定到一个 redis 进程里的话,那其它 CPU 核就没啥事干了,浪费了 CPU 的多核计算能力)

另外扩展一下,当通过 numactl 绑定 CPU 和 mem 都在一个 node 里的时候,内存 IO 不需要经过总线,性能会比较高,你 Redis 的 QPS 能力也会上涨.和跨 node 的内存 IO 性能对比,可以下面的实例,就是 10:21 的区别.

# numactl --hardware
......
node distances:
node   0   1
  0:  10  21
  1:  21  10

你要是对性能有极致的追求,可以试着绑定 numa 的亲和性玩玩.不过,no 作 no die,掉到 numa 陷阱里可别赖我,嘎嘎!


7.1 一次工程中内存性能优化实践

现代的开发语言除了 C++ 以外,大部分都对内存管理做好了封装,一般的开发者根本都接触不到内存的底层操作.更何况现在各种优秀的开源组件应用越来越多,例如 mysql、redis 等,这些甚至都不需要大家动手开发,直接拿来用就好了.所以有些同学也会觉得作为应用层开发的同学没有学习的必要去学习底层.但我想通过本文的实际案例告诉大家,哪怕不直接接触内存底层操作,就只是用一些开源的工具,如果你能理解底层的工作原理,你也能够用到极致.

用户访问历史读写需求

假如现在有这样一个业务需求,用户每次刷新都需要获得要消费的新数据,但是不能和之前访问过的历史重复.你可以把它和你经常在用的今日头条之类的信息流 app 联系起来.每次都要看到新的新闻,但是你肯定不想看到过去已经看过的文章.这样在功能实现的时候,就必要保存用户的访问历史.当用户再来刷新的时候,首先得获取用户的历史记录,要保证推给用户的数据和之前的不重复.当推荐完成的时候,也需要把这次新推荐过的数据 id 记录到历史里.

为了适当降低实现复杂度,我们可以规定每个用户只要不和过去的一万条记录重复就可以了.这样每个用户最多只需要保存一万条历史 id,如果存满了就把最早的历史记录挤掉.我们进一步具体化一下这个需求的几个关键点:

  • 每个数据 id 是一个 int 整数来表示
  • 每个用户要保存 1 万条 id
  • 每次用户刷新开始的时候需要将这 1 万条历史全部读取出来过滤一遍
  • 每次用户刷新结束的时候需要将新访问过的 10 条写入一遍,如果超过 1 万需将最早的记录挤掉

可见,每次用户访问的时候,会涉及到一个 1 万规模的数据集上的一次读取和一次写入操作.

好了,需求描述完了,我们怎么样进行我们的技术方案的设计呢?相信你也能想到很多实现方案,我们今天来对比两个基于 Redis 下的存储方案在性能方面的优劣.

方案一:用 Redis 的 List 来存储

首先能想到的第一个办法就是用 Redis 的 List 来保存.因为这个数据结构设计的太适合上面的场景了.

  • List 下的 lrange 命令可以实现一次性读取用户的所有数据 id 的需求.
  • lpush 命令可以实现新的数据 id 的写入,ltrim 可以保证将用户的记录数量不超过 1 万条.

我们准备一个用户,提前存好一万条 id.写入的时候每次只写入 10 条新的 id,读取的时候通过 lrange 一次全部读取出来.进行一下性能耗时测试,结果如下.

$redis->lrange('TEST_KEY', 0,9999);
$redis->lpush('TEST_KEY', 1,2,3,4,5,6,7,8,9,10);
$redis->ltrim('TEST_KEY', 0,9999);

方案二:用 Redis 的 String 来存储

我能想到的另外一个技术方案就是直接用 String 来存.我们可以把 1 万个 int 表示的数据 id 拼接成一个字符串,用一个特殊的字符把他们分割开.例如:"100000_100001_10002" 这种.存储的时候,拼接一下,然后把这个大字符串写到 Redis 里.读取的时候,把大字符串整体读取出来,然后再用字符切割成数组来使用.

由于用 string 存储的时候,保存前多了一个拼接字符串的操作,读取后多了一步将字符串分割成数组的操作.在测试 string 方案的时候,为了公平起见,我们把需要把这两步的开销也考虑进来.

核心代码如下:

$userItems = array(......);
//写入
for($i=0; $i<$repeats; $i++){
  $redis->set('TEST_KEY', implode('_', $userItems));
}
//读取
for($i=0; $i<10000; $i++){
  $items = explode("_", $redis->get('TEST_KEY'));
}

耗时测试结果如下:

Write repeats:10000     time consume:6.4061808586121    each 0.```markdown
Write repeats:10000     time consume:6.4061808586121    each 0.00064061808586121
Read repeats:10000      time consume:4.9698271751404    each 0.00049698271751404
写入耗时读取耗时总耗时
List0.066ms4.238ms4.304ms
String0.640ms0.496ms1.136ms

我们再直观对比下两个技术方案的性能数据。

基于 List 的方案里,写入速度非常快,只需要 0.066ms,因为仅仅只需要写入新添加的 10 条记录就可以了,再加一次链表的截断操作,但是读取性能可就要慢很多了,超过了 4ms。原因之一是因为读取需要整体遍历,但其实还有第二个原因。我们本案例中的数据量过大,所以 Redis 在内部实际上是用双端链表来实现的。

(此处应有链表内存布局图,请参照原文档)

通过上图你可能看出来,链表是通过指针串起来的。大量的 node 之间极大概率是随机地分布在内存的各个位置上,这样你遍历整个链表的时候,实际上大概率会导致内存的随机模式下工作。

基于 String 方案在写入的时候耗时比 List 要高,因为每次都得需要将 1 万条全部写入一遍。但是读取性能却比 List 高了 10 倍,总体上耗时加起来大约只有方案一的 1/4 左右。为什么?我们再来看下 Redis String 数据结构的内存布局:

(此处应有 String 内存布局图,请参照原文档)

可见,如果用 string 来存储的话,不管用户的数据 id 有多少,访问将全部都是顺序 IO。顺序 IO 的好处有两点:

  1. 内存的顺序 IO 的耗时大约只是随机 IO 的 1/3-1/4 左右。
  2. 对于读取来说,顺序访问将极大地提升 CPU 的 L1、L2、L3 的 cache 命中率。

所以如果你深入了内存的工作原理,哪怕你不能直接去操作内存,即使只是用一些开源软件,你也能够将它的性能发挥到极致。


7.2 PHP7 内存性能优化思想精髓

前面我们讨论了内存的工作原理,也进行了一些性能相关的测试。那么今天开始我们来看几个在实践中的应用。首先我们先从 PHP 开始。

2015 年,PHP7 的发布可以说是在技术圈里引起了不小的轰动,因为它的执行效率比 PHP5 直接翻了一倍。PHP7 在内存方面,你是否知道作者都进行了哪些优化?你是否能够深层次理解到作者优化思路的精髓?

让我们从几个核心的数据结构改进开始看起。

PHP7 zval 变化

1. PHP5.3 中的 zval

我们这里只讨论 64 位操作系统下的情况。该 zval_struct 结构体中的由四个成员构成,其中 zvalue_value 稍微复杂一些,是一个联合体。联合体中最长的成员是一个指针加一个 int,8+4=12 字节。但是默认情况下,会进行内存对齐,故 zval_struct 会占用 16 字节。

typedef unsigned int zend_object_handle;
typedef struct _zend_object_value {
  zend_object_handle handle;
  zend_object_handlers *handlers;
} zend_object_value;
 
typedef union _zvalue_value {
  long lval;          /* long value */
  double dval;        /* double value */
  struct {
    char *val;
    int len;
  } str;
  HashTable *ht;        /* hash table value */
  zend_object_value obj;
} zvalue_value;
 
struct _zval_struct {
  /* Variable information */
  zvalue_value value;   /* value */
  zend_uint refcount__gc;
  zend_uchar type;  /* active type */
  zend_uchar is_ref__gc;
};

_zval_struct 总的字节 = value(16)+ refcount__gc(4)+ type(1)+ is_ref__gc(1)= 占用 22 字节。 最后再考虑下内存对齐,实际占用 24 字节

2. PHP7.2 中的 zval

7.2 中的 zval_struct 结构体里由 3 个成员构成,其中 zend_value 看起来比较复杂,实际上只是一个 8 字节的联合体。u1 也是一个联合体,占用是 4 个字节。u2 也一样。这样 zval_struct 就实际占用 16 字节

typedef struct _zval_struct     zval;
typedef union _zend_value {
  zend_long         lval;       /* long value */
  double            dval;       /* double value */
  zend_refcounted  *counted;
  zend_string      *str;
  zend_array       *arr;
  zend_object      *obj;
  zend_resource    *res;
  zend_reference   *ref;
  zend_ast_ref     *ast;
  zval             *zv;
  void             *ptr;
  zend_class_entry *ce;
  zend_function    *func;
  struct {
    uint32_t w1;
    uint32_t w2;
  } ww;
} zend_value;
 
struct _zval_struct {
  zend_value        value;      /* value */
  union {  
    struct {
      ZEND_ENDIAN_LOHI_4(
        zend_uchar    type,     
        zend_uchar    type_flags,
        zend_uchar    const_flags,
        zend_uchar    reserved)      
    } v;
    int type_info;
  } u1;
  union {  ...... } u2;
};

PHP7 HashTable 变化

1. PHP5.3 里的 HashTable

typedef struct _hashtable {
        uint nTableSize;
        uint nTableMask;
        uint nNumOfElements;
        ulong nNextFreeElement;
        Bucket *pInternalPointer;       /* Used for element traversal */
        Bucket *pListHead;
        Bucket *pListTail;
        Bucket **arBuckets;
        dtor_func_t pDestructor;
        zend_bool persistent;
        unsigned char nApplyCount;
        zend_bool bApplyProtection;
} HashTable;

再 5.3 里 HashTable 就是一个大 struct,有点小复杂,我们拆开了细说:

  • uint nTableSize 4 字节
  • uint nTableMask 4 字节
  • uint nNumOfElements 4 字节
  • ulong nNextFreeElement 8 字节(注意这前面的 4 个字节会被浪费掉,因为 nNextFreeElement 的开始地址需要对齐)
  • Bucket *pInternalPointer 8 字节
  • Bucket *pListHead 8 字节
  • Bucket *pListTail 8 字节
  • Bucket **arBuckets 8 字节
  • dtor_func_t pDestructor 8 字节
  • zend_bool persistent 1 字节
  • unsigned char nApplyCount 1 字节
  • zend_bool bApplyProtection 1 字节

总字节数 = 4+4+4+4(nNextFreeElement 前面这四个字节会留空)+8+8+8+8+8+8+1+1+1 = 67 字节。再加上结构体本身要对齐到 8 的整数倍,所以实际占用 72 字节

2. PHP7.2 里的 HashTable

typedef struct _zend_array HashTable;
struct _zend_array {
  zend_refcounted_h gc;
  union {
    struct {
      ZEND_ENDIAN_LOHI_4(
        zend_uchar    flags,
        zend_uchar    nApplyCount,
        zend_uchar    nIteratorsCount,
        zend_uchar    consistency)
    } v;
    uint32_t flags;
  } u;
  uint32_t          nTableMask;
  Bucket           *arData;
  uint32_t          nNumUsed;
  uint32_t          nNumOfElements;
  uint32_t          nTableSize;
  uint32_t          nInternalPointer;
  zend_long         nNextFreeElement;
  dtor_func_t       pDestructor;
};

在 7.2 里 HashTable:

  • zend_refcounted_h gc 看起来唬人,实际就是个 long,占用 8 字节
  • union... u 占用 4 字节
  • uint32_t 占用 4 字节
  • Bucket* 指针占用 8 字节
  • uint32_t nNumUsed 占用 4 字节
  • uint32_t nNumOfElements 占用 4 字节
  • uint32_t nTableSize 占用 4 字节
  • uint32_t nInternalPointer 占用 4 字节
  • zend_long nNextFreeElement 占用 8 字节
  • dtor_func_t pDestructor 占用 8 字节

总字节数 = 8+4+4+8+4+4+4+4+8+8 = 56 字节,并且正好达到了内存对齐的状态,没有额外的浪费。

另外还有 PHP 源代码里经常出镜的 Buckets 也从 72 字节下降到了 32 字节,这里我就不翻源代码了。

优化思路精髓

我们看了两个核心数据结构的结构体变化,这上面的优化都是什么含义呢?拿 HashTable 举例,貌似从 72 字节优化到了 56 字节,这内存节约的也不是特别多嘛,才 20% 多而已!

但这中间其实隐藏了两个较深层次优化思路:

第一、 CPU 在向内存要数据的时候是以 Cache Line 为单位进行的,而我们说过 Cache Line 的大小就是 64 字节。回过头来看 HashTable,在 7.2 里的 56 字节,只需要 CPU 向内存进行一次 Cache Line 大小的 burst IO,就够了。而在 5.3 里的 72 字节,虽然只比 Cache Line 大了那么一丢丢,但是对不起,必须得进行两次 burst IO 才可以。所以,在计算机里,72 字节相对 56 字节实际上是翻倍的性能提升!!

第二、 CPU 的 L1、L2、L3 的容量是固定的几十 K 或者几十 M。假设 Cache 的都是 HashTable,那么 Cache 容量不变的条件下,PHP7 里能 Cache 住的 HashTable 数量将会翻倍,缓存命中率提升一大截。要知道 L1 命中后只需要 1ns 多一点点的耗时,而如果穿透到内存的话可能就需要 40 多纳秒的延时了,整整差了几十倍。

所以 PHP 内核的作者大牛深谙 CPU 与内存的工作原理,表面上看起来只是几个字节的节约,但是实际上爆发出了巨大的性能提升!!