谈CPU共享场景下的性能问题

背景

容器化场景下,K8S (Kubernetes)调度器主导了单机上的资源布局,随之引入的是越来越多的多应用共享CPU场景,本文借着分析CPU共享的一些特征,引出其中存在的性能问题,并分析一些现有解法的思路和不足,做出展望。

CPU的独享与共享

OS提供Cgroup子系统以供用户进行资源管控,其中和CPU配置相关的有CPU和CPUSET两个子系统。

 

通常情况下一个容器内的应用会同时隶属于这两个子系统,并分别拥有一个对应的Cgroup子组。子组内提供了各个用于控制CPU使用的配置接口,比较关键的有:

  • cpuset.cpus

该组的CPU使用范围

  • cpu.cfs_quota_us & cpu.cfs_period_us

该组的CPU使用时间在每cfs_period_us内不会超过cfs_quota_us

  • cpu.shares

该组的share权重,和该组同层所的share权重之和的比值,即是最坏情况下在该层所占有的CPU时间比

 

所谓的CPU独享,就是通过配置cpuset.cpus来配置独占的CPU范围,容器之间没有CPU重叠。而CPU共享则是复数个容器配置相同的cpuset.cpus,并通过配置cpu.shares来控制各个容器使用这些CPU的比例。

需要注意的是,由于该比例仅在发生CPU竞争的时候生效,为了控制在没有竞争情况下容器的CPU使用量,还需要通过配置cpu.cfs_quota_us & cpu.cfs_period_us来进行限制。

共享CPU的特征

由于独享CPU独享的先入为主,谈共享CPU性能问题的时候,往往是对比于独享CPU而言的。要了解这些问题,必须对共享CPU后负载的行为特征有所了解。

首先,多个应用分享自己的CPU给彼此,会形成更大的CPU活动范围,原先独享方式下跑在小范围CPU内的任务,现在会在更大的活动范围里运行,这引入了第一个问题,即运行范围NUMA跨节点导致的性能问题。

其次,由于活动范围的增加,一个应用内的进程和线程可以被并发处理的能力提升了,但由于有quota的限制,并发度越高,消耗quota就越快,任务就会更快的在period内进入限制运行状态,这就引入了第二个问题,即并发度变化带来的性能问题。

最后,由于共享CPU,应用之间的竞争就会出现,时间片机制只能确保竞争的合理,无法消除竞争。竞争会带来两个问题,一是上下文切换,二是缓存刷新,由于整机负载规模没有发生变化,一定比例的上下文切换从应用自身任务的相互切换,变成了不同应用任务之间的切换,由于不同应用之间共享内存的概率较低,这就引入了第三个问题,即Cache亲和性下降带来的性能问题。

即然共享CPU存在那么多问题,为什么我们还越来越多的使用这种部署方式呢,原因是在于共享方式可以提升CPU资源的效能。当一个应用没有用满独享CPU的时候,这些资源是被浪费的,而共享方式就可以把这部分资源用在别的需要的应用上,不至于让CPU白白空闲。

问题分析

上面通过分析共享CPU的特征,引出了三个性能问题,接下来具体分析下这些问题。

问题I —— NUMA跨节点

在NUMA场景下,当任务的CPU使用范围超过一个NUMA节点,就会引入跨节点问题,即当任务运行在NUMA节点X的CPU上,其操作的内存却存放在NUMA节点Y上时,会触发远端内存访问,其写效率取决于X和Y的距离,距离越远、内存操作越重,性能下降的越明显。

通过perf工具可以抓取远端内存的访问次数,比较独享和共享两种配置下,单位时间内的访问次数差别,就可以确认跨节点问题的出现,通过观察numactl工具提供的节点距离,就可以估算出性能下降的程度。可以发现共享时远端访问次数大幅提升,写内存的性能下降非常明显。

问题II —— 并发度变化

并发度提升对应用的性能影响并不统一,有些应用倾向于让任务聚集在少量CPU上以更好的利用热缓存,另一些则倾向于分散任务到不同的CPU来降低延迟,而在共享CPU的场景下,由于存在应用之间的竞争,并发度本身也会时刻变化,应用性能就会随着并发度的变化而出现抖动,也就是俗称的长尾延迟。

问题III —— Cache亲和性

当同一CPU上运行的多个任务,如果他们都操作相同的内存,那么他们就具有Cache亲和性,可以利用热缓存来减少Cache和TLB Miss率。而应用之间共享CPU,单个CPU上就会运行不同应用的任务,他们往往是不具备Cache亲和性的。

通过perf工具可以抓取Cache/TLB的miss/ref次数,比较独享和共享两种配置下,单位时间内的次数差别,就可以确认Cache亲和性的变化。可以发现共享时Cache miss率小幅上升,Cache miss/ref次数大幅上升,同时iTLB miss率亦大幅上升,每次miss都意味着需要内存读取和缓存刷新,其性能影响不言而喻。

解法&不足

K8S

K8S提供了一种解决方案 —— CPU Manager,在部署容器时可以根据CPU拓扑信息,从单个NUMA节点上选取一组未被使用的CPU,通过限定容器使用这些独享的单节点CPU,不仅避免了来自其他容器对于Cache的干扰,又避免了远端内存访问导致的性能下降。事实上,CPU Manager只是把Guaranteed 容器从共享CPU模式回退到了独享模式,这样做仍然存在不足。

首先,虽然解决了跨NUMA的问题,但由于用了独享方式,仍然会存在资源碎片问题,而容器的CPU Request很难保障刚好填满一个NUMA节点,这样就会在各个节点上留下一些无法被分配的零散CPU,进一步扩大了资源浪费问题。

 

其次,CPU Manager在分配CPU后,无法根据各个NUMA节点的负载情况做后续调整,这会导致节点间的负载不均衡。通常各个NUMA节点是拥有各自独立的内存带宽和通道的,如果不能均衡利用这些硬件资源,就会造成部分节点内存带宽吃紧,部分节点浪费的情况,导致容器性能出现抖动。

OS

主流架构都提供了Cache的管控手段,例如Intel RDT,ARM MPAM以及AMD QoS等,然而在容器场景下这些管控手段仍存在问题:

  • 管控对象有数量限制,通常都少于容器数量,只能按容器类别管理
  • 管控方式是限制使用量,容易造成Cache浪费

未来新硬件会提供动态调整限制量的能力,如果能够拓展控制对象的数量匹配容器数量,配合足够灵敏的动态限制就可以更好地解决这一问题,但短期内还无法商用。即便可以,也仅仅是解决了Cache亲和性问题。

如果有一种能够在CPU共享场景下,按应用聚集任务到部分CPU上的功能,就可以在保留碎片利用能力的同时,解决掉这三个问题了。

但目前的主线内核并没有提供这样的机制,需要我们去弥补。Group Balancer (GB)就是其中一种解决方案,这是一种新的负载平衡方式,相对于传统的在各个CPU之间通过迁移任务来平衡,GB则是在各个CPU组之间通过迁移一组任务来平衡,以达到动态的、类CPU绑定的效果。

由于负载均衡本身的弹性特征,GB的动态绑定并不会限制任务的运行范围,而是仅仅增加相关任务的聚集概率,不会存在资源碎片。目前阿里云操作系统团队已经向开源社区推送了RFC补丁,后续版本仍在制作当中,详见附录I。

总结&展望

本文总结了CPU共享相对于独享的一个优势是能够通过利用CPU碎片提升资源使用效能。通过分析共享CPU的特征,我们总结了存在的三个问题:

  • CPU活动范围扩大导致的NUMA跨节点问题
  • CPU活动范围扩大引起并发度变化导致的性能抖动问题
  • 不同应用CPU竞争导致的Cache亲和性下降问题

随后分析并总结了现有的一些解决方案和其缺点:

  • K8S的CPU Manager特性可以NUMA跨节点问题并缓解另两个问题,但会恶化CPU资源使用效能
  • 硬件管控手段可以解决Cache亲和性下降问题,但前提是容器数量比较少,且会造成Cache浪费
  • Group Balancer可以解决这三个问题,还在推送社区的路上

随着CPU共享被越来越多的应用,未来OS必然会进一步增强共享CPU方式下的资源隔离性,Group Balancer可能只是一个开始,我们期待各路大神们各显其能,定型出完整的、通用的解决方案。

附录I -- Group Balancer RFC补丁及测试结果

讨论链接: https://www.spinics.net/lists/kernel/msg4195265.html

Modern platform are growing fast on CPU numbers, multiple
apps sharing one box are very common, they used to have
exclusive cpu setting but nowadays things are changing.

To achieve better utility of CPU resource, multiple apps
are starting to sharing the CPUs. The CPU resources usually
overcommitted since app's workload are undulated.

This introduced problems on performance when share mode vs
exclusive mode, for eg with cgroup A,B and C deployed in
exclusive mode, it will be:

  CPU_X (100%)     CPU_Y (100%)     CPU_Z (50%)
  T_1_CG_A         T_1_CG_B         T_1_CG_C
  T_2_CG_A         T_2_CG_B         T_2_CG_C
  T_3_CG_A         T_3_CG_B
  T_4_CG_A         T_4_CG_B

while the share mode will be:

  CPU_X (100%)     CPU_Y (75%)     CPU_Z (75%)
  T_1_CG_A         T_2_CG_A         T_1_CG_B
  T_2_CG_B         T_3_CG_B         T_2_CG_C
  T_4_CG_B         T_4_CG_A         T_3_CG_A
  T_1_CG_C

As we can see, the confliction between groups on CPU
resources are now happening all over the CPUs.

The testing on sysbench-memory show 30+% drop on share
mode, and redis-benchmark show 10+% drop too, compared
to the exclusive mode.

However, despite of the performance drop, in real world
we still prefer share mode. The undulated workload can
make the exclusive mode so unefficient on CPU utilization,
for eg the next period, when CG_A become 'idle', exclusive
mode will like:

  CPU_X (0%)     CPU_Y (100%)     CPU_Z (50%)
                 T_1_CG_B         T_1_CG_C
                 T_2_CG_B         T_2_CG_C
                 T_3_CG_B
                 T_4_CG_B

while share mode like:

  CPU_X (50%)     CPU_Y (50%)     CPU_Z (50%)
  T_2_CG_B         T_1_CG_C        T_3_CG_B
  T_4_CG_B         T_1_CG_B        T_2_CG_C

The CPU_X is totally wasted in exclusive mode, the resource
efficiency are really poor.

Thus what we need, is a way to ease confliction in share mode,
make groups as exclusive as possible, to gain both performance
and resource efficiency.

The main idea of group balancer is to fulfill this requirement
by balancing groups of tasks among groups of CPUs, consider this
as a dynamic demi-exclusive mode.

Just like balance the task among CPUs, now with GB a user can
put CPU X,Y,Z into three partitions, and balance group A,B,C
into these partition, to make them as exclusive as possible.

The design is very likely to the numa balancing, task trigger
work to settle it's group into a proper partition (minimum
predicted load), then try migrate itself into it. To gradually
settle groups into the most exclusively partition.

How To Use:

To create partition, for example run:
  echo disable > /proc/gb_ctrl
  echo "0-15;16-31;32-47;48-63;" > /proc/gb_ctrl
  echo enable > /proc/gb_ctrl

this will create 4 partitions contain CPUs 0-15,16-31,32-47 and
48-63 separately.

Then enable GB for your cgroup, run
  $CPU_CGROUP_PATH/cpu.gb_period_ms

And you can check:
  $CPU_CGROUP_PATH/cpu.gb_stat

which give output as:
  PART-0 0-15 1008 1086  *
  PART-1 16-31 0 2
  PART-2 32-47 0 0
  PART-3 48-63 0 1024

The partition ID followed by it's CPUs range, load of group, load
of partition and a star mark as preferred.

Testing Results:
  In order to enlarge the differences, we do testing on ARM platform
  with 128 CPUs, create 8 partition according to cluster info.

  Since we pick benchmark which can gain benefit from exclusive mode,
  this is more like a functional testing rather than performance, to
  show that GB help winback the performance.

  Create 8 cgroup each running 'sysbench memory --threads=16 run',
  the output of share mode is:
    events/s (eps):                      4181233.4646
    events/s (eps):                      3548328.2346
    events/s (eps):                      4578816.2412
    events/s (eps):                      4761797.3932
    events/s (eps):                      3486703.0455
    events/s (eps):                      3474920.9803
    events/s (eps):                      3604632.7799
    events/s (eps):                      3149506.7001
  the output of gb mode is:
    events/s (eps):                      5472334.9313
    events/s (eps):                      4085399.1606
    events/s (eps):                      4398122.2170
    events/s (eps):                      6180233.6766
    events/s (eps):                      4299784.2742
    events/s (eps):                      4914813.6847
    events/s (eps):                      3675395.1191
    events/s (eps):                      6767666.6229

  Create 4 cgroup each running redis-server with 16 io threads,
  4 redis-benchmark per each server show average rps as:

                    share mode       gb mode

  PING_INLINE     : 41154.84         42229.27               2.61%
  PING_MBULK      : 43042.07         44907.10               4.33%
  SET             : 34502.00         37374.58               8.33%
  GET             : 41713.47         45257.68               8.50%
  INCR            : 41533.26         44259.31               6.56%
  LPUSH           : 36541.23         39417.84               7.87%
  RPUSH           : 39059.26         42075.32               7.72%
  LPOP            : 36978.73         39903.15               7.91%
  RPOP            : 39553.32         42071.53               6.37%
  SADD            : 40614.30         44693.33              10.04%
  HSET            : 39101.93         42401.16               8.44%
  SPOP            : 42838.90         46560.46               8.69%
  ZADD            : 38346.80         41685.46               8.71%
  ZPOPMIN         : 41952.26         46138.14               9.98%
  LRANGE_100      : 19364.66         20251.56               4.58%
  LRANGE_300      : 9699.57          9935.86                2.44%
  LRANGE_500      : 6291.76          6512.48                3.51%
  LRANGE_600      : 5619.13          5658.31                0.70%
  MSET            : 24432.78         26517.63               8.53% 

上一篇:Elasticsearch Top5典型应用场景


下一篇:Apk升级过程中排查问题所引发的思考