内核优化之PSI篇:快速发现性能瓶颈,提高资源利用率

语言: CN / TW / HK

欢迎关注【字节跳动 SYS Tech】公众号。字节跳动 SYS Tech 聚焦系统技术领域,与大家分享前沿技术动态、技术创新与实践、行业技术热点分析等内容。

背景介绍

了解操作系统原理的同学应该知道,业务进程的运行性能取决于多种系统资源的分配。比如进程需要等待某些 IO 的返回,需要从伙伴系统分配内存,可能会由于 memory cgroup 的限制或者系统内存的水线配置而进行内存回收,进程运行需要调度器分配 CPU 时间,可能由于 CPU cgroup 的 quota 配置或者系统负载较大而等待调度器的调度。

所以一个进程的运行过程实际上是一个不断等待不断执行的过程,过多的等待会对进程的吞吐和延时造成负面影响,是否有一种内核机制能够量化这些等待时间呢?

PSI 通过 hook CPU 调度器和 hook 一些 IO 和 memory 的关键点,来得到一个线程开始等待某种资源和结束等待某种资源的时间点以及程序运行的时间,然后用移动平均算法算出一个表示 pressure stall information 的百分比来量化。

用途

PSI 监控机制给我们提供了一种实时量化指标,反映业务进程的吞吐和延时,是否有某种系统资源上的瓶颈。这可以帮助我们了解特定业务进程的资源需求,协助业务的部署密度。并且可以根据 PSI 指标,动态调节业务的部署和资源的分配,来保证特定业务的性能要求和系统的健康程度。

使用接口

监控接口

PSI 通过 /proc 文件系统导出了三个接口文件,用于反映实时的系统级别的 CPU,memory 和 IO 压力。

# cat /proc/pressure/cpu

some avg10=0.00 avg60=0.00 avg300=0.00 total=0

full avg10=0.00 avg60=0.00 avg300=0.00 total=0



# cat /proc/pressure/memory

some avg10=0.00 avg60=0.00 avg300=0.00 total=0

full avg10=0.00 avg60=0.00 avg300=0.00 total=0



# cat /proc/pressure/io

some avg10=0.00 avg60=0.00 avg300=0.00 total=0

full avg10=0.00 avg60=0.00 avg300=0.00 total=0

其中的 some 表示至少有一个线程有资源瓶颈的时间比例,full 表示所有线程都有资源瓶颈的时间比例。

full 状态相当于整个系统由于资源瓶颈没有执行任何 productive 的代码,白白浪费了 CPU 资源。而 some 则是某些线程有资源瓶颈,另外的线程还是在利用 CPU 资源执行 productive 的代码。

avg10,avg60,avg300 则是在 10s,60s,300s 的时间窗口计算的移动平均百分比,表示资源瓶颈状态的时间比例,可以给我们短期、中期和长期的量化了解。total 则是资源瓶颈状态的绝对时间积累,我们也可以对 total 的变化进行监控来发现延时抖动的情况。

trigger 接口

除了读取这些接口文件获取实时指标外,我们还可以写入这些接口文件向内核注册 trigger,通过select()poll()epoll() 等待 trigger 事件的发生。

# 150ms threshold for partial memory stall in 1sec time window

echo "some 150000 1000000" > /proc/pressure/memory



# 50ms threshold for full io stall measured within 1sec time window

echo "full 50000 1000000" > /proc/pressure/io

trigger 示例代码

#include <errno.h>

#include <fcntl.h>

#include <stdio.h>

#include <poll.h>

#include <string.h>

#include <unistd.h>



/*

 * Monitor memory partial stall with 1s tracking window size

 * and 150ms threshold.

 */

int main() {

      const char trig[] = "some 150000 1000000";

      struct pollfd fds;

      int n;



      fds.fd = open("/proc/pressure/memory", O_RDWR | O_NONBLOCK);

      if (fds.fd < 0) {

              printf("/proc/pressure/memory open error: %s\n",

                      strerror(errno));

              return 1;

      }

      fds.events = POLLPRI;



      if (write(fds.fd, trig, strlen(trig) + 1) < 0) {

              printf("/proc/pressure/memory write error: %s\n",

                      strerror(errno));

              return 1;

      }



      printf("waiting for events...\n");

      while (1) {

              n = poll(&fds, 1, -1);

              if (n < 0) {

                      printf("poll error: %s\n", strerror(errno));

                      return 1;

              }

              if (fds.revents & POLLERR) {

                      printf("got POLLERR, event source is gone\n");

                      return 0;

              }

              if (fds.revents & POLLPRI) {

                      printf("event triggered!\n");

              } else {

                      printf("unknown event received: 0x%x\n", fds.revents);

                      return 1;

              }

      }



      return 0;

}

cgroup接口

使用cgroup-v2 时 PSI 还提供了 per-cgroup 的 pressure stall information 接口,用于监控和跟踪特定 cgroup 的资源瓶颈状态。

cgroupfs 的 mount 点的每个子目录都有三个文件接口:cpu.pressure,memory.pressure,io.pressure。读取文件内容的形式和 /proc/pressure 一样,同样也可以进行写入注册 trigger 事件。

性能优化

PSI 机制为我们提供了一种实时量化系统级别或 cgroup 级别是否存在资源瓶颈的指标,对于调度部署业务负载和资源分配有很好的指导作用,而且可以明确业务进程的吞吐和延时方面存在的资源瓶颈。

但是 PSI 机制不是没有开销的,它 hook 了调度器和 IO,memory 等热点路径,统计等待资源的时间并计算比例,这些都是有开销的。为了在生产环境常态化开启 PSI 特性,我们需要解决 PSI 的性能开销问题。

问题场景

我们在线上场景遇到的一起 PSI 性能问题的 perf top 热点,其中 psi_task_change() 热点比较高,对当时业务进程的吞吐和延时都有较大的负面影响。

代码分析

psi_task_change() 的热点开销问题可能有两个方面的原因:一个是它本身执行比较耗时,另一个是它被调用的频率太高。

psi_task_change()函数的作用通过名字可知,在 PSI 的所有 hook 点都会发生 task 状态的变化,比如开始等待 memory,结束等待 memory,开始等待 CPU,结束等待 CPU 等,因此该函数调用频率较高。

另外psi_task_change()内部实现不仅需要改变该 task 的状态,还要改变 task 所在每个 cgroup 的状态,改变前需要统计上个状态的时间。

详情参见以下代码片段:

 void psi_task_change(struct task_struct *task, int clear, int set)

 {

         int cpu = task_cpu(task);

         struct psi_group *group;

         bool wake_clock = true;

         void *iter = NULL;



         if (!task->pid)

                 return;



         psi_flags_change(task, clear, set);



         /*

          * Periodic aggregation shuts off if there is a period of no

          * task changes, so we wake it back up if necessary. However,

          * don't do this if the task change is the aggregation worker

          * itself going to sleep, or we'll ping-pong forever.

          */

         if (unlikely((clear & TSK_RUNNING) &&

                      (task->flags & PF_WQ_WORKER) &&

                      wq_worker_last_func(task) == psi_avgs_work))

                 wake_clock = false;



         while ((group = iterate_groups(task, &iter)))

                 psi_group_change(group, cpu, clear, set, wake_clock);

 }

通过以上代码分析,我们发现有两个可以优化的方向:尽量减少psi_task_change()的调用,以及尽量减少psi_group_change()的调用。

代码优化

1. 利用共同的cgroup

我们需要知道的一个捷径是当task A切换到task B时,如果A和B存在共同的cgroup时,其实cgroup的状态是没有变化的,只是task A和task B的状态变化了。

所以根据这个事实我们可以优化发生频率比较高的task_switch hook,不再遍历改变task A的所有cgroup,然后再次遍历task B的所有cgroup,而是进行整合:只改变task A和task B的不同cgroup分支,直到相同的cgroup停止,减少psi_task_change()的调用开销。

2. 减少sleep导致的状态切换

sleep before:

  psi_dequeue()

    while ((group = iterate_groups(prev)))  # all ancestors

      psi_group_change(prev, .clear=TSK_RUNNING|TSK_ONCPU)

  psi_task_switch()

    while ((group = iterate_groups(next)))  # all ancestors

      psi_group_change(next, .set=TSK_ONCPU)



sleep after:

  psi_dequeue()

    nop

  psi_task_switch()

    while ((group = iterate_groups(next)))  # until (prev & next)

      psi_group_change(next, .set=TSK_ONCPU)

    while ((group = iterate_groups(prev)))  # all ancestors

      psi_group_change(prev, .clear=common?TSK_RUNNING:TSK_RUNNING|TSK_ONCPU)

通过以上对比可知:优化掉了 psi_dequeue 触发的 psi_group_change() 调用,将状态的改变整合放到了psi_task_switch(),减少了 cgroup 的状态切换开销。

总结

PSI 的优化成果已经合入上游的主线内核,也合入了 veLinux 开源的 5.4 内核版本,现在部署的 5.4 内核都已经常态化开启了 PSI 机制,配合 cgroup-v2 的使用会更好地帮助我们发现业务性能的资源瓶颈,以及动态的自适应运维和资源分配。

社区也已经存在基于 PSI 机制的 OOMD(out of memory daemon) service,可以更好地应对系统 OOM 的场景,同时在字节内部也积极开发和应用基于 PSI 机制的动态资源管控程序,以在保证业务性能的同时,提高资源利用率。

veLinux 内核 GitHub 地址:http://github.com/bytedance/kernel