內核優化之PSI篇:快速發現性能瓶頸,提高資源利用率
歡迎關注【字節跳動 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