3行でまとめると
- /proc/pressure/cpu の "some" でCPU負荷を監視できる
- 単位はパーセント(0から100)
- でも正直使いにくい気がする
はじめに
Linux-4.20からPSI(Pressure Stall Information)が入り、CPUとIOとMemoryを監視できるようになったが、値のしきい値やら何を監視しているのかやらの説明が乏しいので、特にCPUについて調査してみた。なお、Linux-6.8くらい(Ubuntu-24.04)で実験しつつ、Linux-6.12のコードを見て確認した。
IOやMemoryについては、hibomaさんの日記に詳しい(IO編)(Memory編)ので、そちらに任せることにし、この記事ではCPUに絞ることにした。
documentation
PSI - Pressure Stall Information — The Linux Kernel documentation にある。が、概要的な話ばかりだったり、ユーザランドからPSIのfdを使って監視する方法だったりで、あまり詳しい話は書かれていない。
概要
CPUの負荷を監視する場合、従来は下記の2つが主流だったかと思う。
Load Average
1つがLoad Average。/proc/loadavg などで取得できる。伝統的にUNIX系での負荷の指標となっているが、Linuxでは実行中と実行待ちだったタスク(UNINTERRUPTABLEとRUNNING)の個数の平均値となっていて、約5秒ごと(5*hz + 1)にサンプリングし、1分/5分/15分の平均を出力する。
サンプリングの周期の影響で、ときに思わぬ勘違いをしてしまうことがあるので、この記事を見て事例を覚えておく必要がある。また記事にある通り、単純にサンプリングすると過去N個の履歴をすべて保持しないといけないので、そういうことはやっておらず、指数移動平均値で計算した値としている。
メモリ不足のときもIO待ち多発のときも Load Average が高くなるので、負荷監視作業で何がネックなのかを勘違いする例が時々見られる。昔の話だが、私も、RTL8139でEthernet帯域を使い果たすとドライバがポーリング待ちしてCPU負荷が上がる現象が起こり、Ethernet帯域のせいだと気づくのに時間を取られた。
CPU使用率
もう1つが、いわゆるCPU使用率。top などで監視することが多い。Linuxでは、hz(CONFIG_HZ)の単位でどのタスクがCPUを使用していたかをサンプリングしている。topは、インターバル周期(デフォルト3秒)ごとに、Linuxのサンプリングを取得できる/proc/statを読んで差分を計算し、そこから割合を表示している。
CPUごとに集計するため、今どきのマルチコアCPU当たり前な環境ではCPU(SMPだとスレッド?)の個数も意識する必要があったり、dockerなどcgroupsでCPU個数を制限しているのかだったり、VM環境だとホスト側の事情やらVMの間でのCPU奪い合いやらがあったり、と top はわかりやすく表示してくれる反面、その解釈が実は難しかったりする。
CPU使用率もLoad Averageと同様にサンプリング周期に左右されるため、周期ピッタリでちょっとだけCPUを使うプログラムが常駐していると「ずっとCPUを占有している」かのように観測されることがある。また、topのインターバルはkernelのサンプリング周期とは別であるため、topの監視インターバルを短くしても返ってCPU使用率の精度が下がるだけで意味がない。topは「立ち上がって実行してすぐに終了する」タスクの監視には不向きであり、そういうのはpsのSオプション(cutimeやcstime(dead childを親に集計))のほうが適している。
ちなみに、最近はCONFIG_NO_HZなので、必ずしも 1/hz 秒ごとに割り込み入れて起き上がって集計して...みたいなことはやっていない。また、Ubuntu-24.04環境だと CONFIG_HZ=1000 となっていて、CONFIG_HZ=100 が当たり前だと聞かされて育った身としては時代の流れを感じる。
PSI CPU
これら2つがサンプリング周期に左右されて値が不安定なわりに広く多くのユーザに参照されるから困った、からなのかどうかまではわかってないけど、新たな指標としてPSIのCPUが作られた。/proc/pressure/cpu で参照できる。Load Averageに似ていて、タスク切り替わり時に実行待ちタスクがいたかどうかを確認しつつ、Load Averageのようなタスク数ではなくて、待ち時間で計上する。
"some"の行は、少なくとも1つ以上のCPUでstallした場合を計上し、待ちになった時間の割合をパーセントで出す。ただしCPUの個数で割る。詳細は「実験」の章を見て理解してほしい。
"full"の行は、すべてのCPUがstallしていた場合を計上する点が異なる。が、Linux-6.12までの時点ではゼロしか出力しない。
"avg10" "avg30" "avg300"はそれぞれ10秒/30秒/300秒平均を出す一方で、"total"だけは単位が異なり、usec単位での累積時間を表す。
/proc/pressure/cpu を開いたfdにwriteすることでしきい値を超えたときに通知をもらうことができる。このあたりは今回の記事では省略する。
実験
手順
値の傾向をつかみにくいと感じたのでテストをして値の変化を確認してみた。
- オンラインCPUのコア数をNとする(/sys/devices/system/cpu/cpuX/onlineで調整)
- ビジーループするタスク(スレッド)の個数をTとする
- 十分にidleを保った状態からビジーループ開始し、0秒後から2秒間隔で20回だけ /proc/pressure/cpu の値を取得
- someのavg10とavg60の値をグラフ化
N=1 T=1の場合
N=1 T=2の場合
N=1 T=3の場合
100%に収束するような曲線になってる、がT=2と比べて急には見えない
N=2 T=2の場合
N=2 T=3の場合
N=2 T=4の場合
100%に収束するはず?だけどなにかデータがおかしいかもしれない。
N=4 T=4の場合
N=4 T=5の場合
N=4 T=6の場合
傾向について考察
- N >= T ならば、ほぼゼロのまま
- 2*N > T ならば、(T-N)/N の割合に収束する
- 2*N <= T ならば、100%に収束する
- 「avg10」とはいえ、10秒では収束せず、もう少し長いスパンの加重平均になる
ソースコード
値が増加するトリガ
値の計上などを行うのはrecord_times()関数で、これを呼ぶのはpsi_group_change()となっている。psi_group_change()を呼ぶ箇所をトレースするとこうなる。cgroupsのケアやら、cpuではなくてmemoryを計上したい系やらが登場するものの、おおむねタスク切り替わりのタイミングでよいかと思う。
- __schedule()->psi_sched_switch()->psi_task_switch()->psi_group_change()
- enqueue_task()->psi_enqueue()->psi_task_change()->psi_group_change()
- dequeue_task()->psi_dequeue()->psi_task_change()->psi_group_change()
- try_to_wake_up()->psi_ttwu_dequeue()->psi_task_change()->psi_group_change()
- css_set_move_task()->cgroup_move_task()->psi_task_change()->psi_group_change()
- cgroup_pressure_write()->psi_cgroup_restart()->psi_group_change()
- psi_memstall_enter()->psi_task_change()->psi_group_change()
- psi_memstall_leave()->psi_task_change()->psi_group_change()
値の増加の条件
record_times()関数はフラグが立っているかどうかで計上するかどうかを決めている。そのフラグを立てているのはtest_states()関数で、
static u32 test_states(unsigned int *tasks, u32 state_mask)
{
const bool oncpu = state_mask & PSI_ONCPU;
if (tasks[NR_IOWAIT]) {
state_mask |= BIT(PSI_IO_SOME);
if (!tasks[NR_RUNNING])
state_mask |= BIT(PSI_IO_FULL);
}
if (tasks[NR_MEMSTALL]) {
state_mask |= BIT(PSI_MEM_SOME);
if (tasks[NR_RUNNING] == tasks[NR_MEMSTALL_RUNNING])
state_mask |= BIT(PSI_MEM_FULL);
}
if (tasks[NR_RUNNING] > oncpu)
state_mask |= BIT(PSI_CPU_SOME);
if (tasks[NR_RUNNING] && !oncpu)
state_mask |= BIT(PSI_CPU_FULL);
if (tasks[NR_IOWAIT] || tasks[NR_MEMSTALL] || tasks[NR_RUNNING])
state_mask |= BIT(PSI_NONIDLE);
return state_mask;
}
PSI_CPU_SOMEを立てる条件が少々ややこしい。"oncpu"は、boolであり、
- oncpuがfalseのケースは、CPUストール状態が解消されたのにもかかわらず自CPUのrunqにまだRUNNINGの人が1人以上いた場合
- oncpuがtrueのケースは、CPUストール状態のときに自CPUのrunqにRUNNINGの人が2人以上いた場合。つまり自タスク以外にもう1人以上いた場合、ということでいい?
ということかと思う。ただし、migrateする前なのか後なのかによって条件がややこしいので、厳密なところが把握しづらい。
下の方で見ていくが、PSI_CPU_FULL は現時点では意味をなしていないため、ここでは無視して良い。
平均値の更新周期
kernel/sched/psi.cのgroup_init()関数(起動のドライバ初期化時に呼ばれる)で定期的な値更新をするためのkworkerを用意している。
INIT_DELAYED_WORK(&group->avgs_work, psi_avgs_work);
このkworkerを、タスク切り替わり時や、周期的なタイミングで実行することで、/proc/pressure/ 以下のノードに見える値を更新している。周期的な方は、psi_avgs_work()関数の中、
if (changed_states & PSI_STATE_RESCHEDULE) {
schedule_delayed_work(dwork, nsecs_to_jiffies(
group->avg_next_update - now) + 1);
}
コンテキストスイッチしたときは、psi_group_change()関数の中、
if (wake_clock && !delayed_work_pending(&group->avgs_work))
schedule_delayed_work(&group->avgs_work, PSI_FREQ);
また、このときの起こす計算に用いているPSI_FREQは、
/* Running averages - we need to be higher-res than loadavg */
#define PSI_FREQ (2*HZ+1) /* 2 sec intervals */
となっている。ちなみに、loadavgも似たような仕組みで5秒周期で更新している(LOAD_FREQマクロ、kernel/sched/loadavg.c や include/linux/sched/loadavg.h 付近)が、CONFIG_NO_HZ が入ってきた影響で、昔ながらのticksありきなやり方から比べるとかなり複雑化している模様。
平均値の更新方法
値の更新はcalc_avgs()関数で行っている。
static void calc_avgs(unsigned long avg[3], int missed_periods,
u64 time, u64 period)
{
unsigned long pct;
/* Fill in zeroes for periods of no activity */
if (missed_periods) {
avg[0] = calc_load_n(avg[0], EXP_10s, 0, missed_periods);
avg[1] = calc_load_n(avg[1], EXP_60s, 0, missed_periods);
avg[2] = calc_load_n(avg[2], EXP_300s, 0, missed_periods);
}
/* Sample the most recent active period */
pct = div_u64(time * 100, period);
pct *= FIXED_1;
avg[0] = calc_load(avg[0], EXP_10s, pct);
avg[1] = calc_load(avg[1], EXP_60s, pct);
avg[2] = calc_load(avg[2], EXP_300s, pct);
}
計算式を正確に追うとややこしいものの、expな加重平均を固定小数点で計算している。ということで、これは EMA(Exponential Moving Average)の指数移動平均値ということでいいのかな?これが、ドキュメントで「平均」と述べつつも、必ずしも10秒や60秒で収束するわけではない理由かと思われる。なお、先のloadavgの記事に書かれているのと同じやり方なので、psiがloadavgをマネて実装したのではないかと思われる。
CPU FULL
cpuのfullは未定義となっている。上記までの通り PSI_CPU_FULL は一応は計算しているものの、psi_cgroup_restart()関数にて下記のようになっており、常に0が取得される。
for (full = 0; full < 2 - only_full; full++) {
unsigned long avg[3] = { 0, };
u64 total = 0;
int w;
/* CPU FULL is undefined at the system level */
if (!(group == &psi_system && res == PSI_CPU && full)) {
for (w = 0; w < 3; w++)
avg[w] = group->avg[res * 2 + full][w];
total = div_u64(group->total[PSI_AVGS][res * 2 + full],
NSEC_PER_USEC);
}
seq_printf(m, "%s avg10=%lu.%02lu avg60=%lu.%02lu avg300=%lu.%02lu total=%llu\n",
full || only_full ? "full" : "some",
LOAD_INT(avg[0]), LOAD_FRAC(avg[0]),
LOAD_INT(avg[1]), LOAD_FRAC(avg[1]),
LOAD_INT(avg[2]), LOAD_FRAC(avg[2]),
total);
}
その他
CONFIG_IRQ_TIME_ACCOUNTING が有効な場合は /proc/pressure/irq も生えてくる模様。
あとがき
loadavgはよく下記のような問題点があげられる。
- 一番短いのでも60秒で短期スパンの監視ができない
- 単位がタスクの個数になっている
- IOWAITも混ぜて合計している
それを踏まえて、待ち時間から割合を計算するように改善を入れ、cpu/memory/ioと独立させて作ったのがPSIなんじゃないかと思う。しかしそんなPSIも、cpuやioはstallする前後で値がピーキーになりがち、memoryは上下が激しくていまいち使い物にならない、みたいなのが実態なのかなと想像する。結局のところ、memoryを使ってゆるくスラッシング状態を監視する、cpuを使って 100/N パーセントを上回らないように新規タスク起こしを調整する、くらいしか使いどころがないんじゃないかと想像する。それってloadagをN+1しきい値やら急な増加やらで監視するのとあまり変わらんような。
ただ、私がPSIを有効に利用する例を知らないだけかもしれないので、もしご存じの方いればそういう事例を教えてもらえれば。ちなみに、bitbake に BB_PRESSURE_MAX_CPU があるけど、これも今のところほとんど使われてなさそう。
参考サイト
PSI関連
- PSI - Pressure Stall Information — The Linux Kernel documentation
- Tracking pressure-stall information [LWN.net]
- PSIをLXCのコンテナで試す: その(2) そもそもの値の定義・CPU Pressureをもう少し測る - ローファイ日記
- Linux Kernel: PSI - Pressure Stall Information /proc/pressure/io で IO 待ちを観察する - hibomaの日記
- Linux Kernel: PSI - Pressure Stall Information /proc/pressure/memory で メモリのストールを観察する - hibomaの日