TenForward

技術ブログ。はてなダイアリーから移転しました

cgroup の CPU コントローラーから設定する帯域幅制限のコードをちょっとだけ追ってみた

この記事は Linux Advent Calendar 2023 5 日目の記事です。前日は @mnishiguchi さんの「Linux US キーボードの CapsLock を Ctrl に変更する方法」ですね。私は setxkbmap でやってます。

さて、このブログ久々の技術的な内容です。Linux カーネルが持つ cgroup という機能のうち、タスクやタスクのグループに対して CPU の帯域制限(単位時間内にどれだけ CPU が使えるか)を設定できる機能のコードを少し追ってみたお話です。あとで紹介する gihyo.jp さんに掲載している記事とあわせて読んでいただけるとよりうれしいです。

もう 4 年くらいまえのブログなんですが、Indeed さんのエンジニアリングブログのこの 2 つの記事がすばらしいんです。わかりやすい。もう cgroup の CPU コントローラーから使う帯域幅制限は、これを読めば完全に理解できる!ってくらいすばらしいです。

理解できたのでうれしくて思わず記事にしちゃいました。このブログ記事と、カーネル付属文書 "CFS Bandwidth Control" とあわせて読むと完璧です。

記事の方はある程度確信があることだけ書いたのですが、このブログは、記事として書くには自信がない「たぶんそうちゃう?しらんけど」ということを、「知らんけど」というスタンスで書いてますw まあ、記事中にコードを追っかけた記録みたいなもんです。

もうひとつ、以前、カーネルのコードを読むには?みたいなテーマの登壇がありましたが、全部読まずに必要なところだけつまみ食いするにはこんな感じですよ、ってのもあわせて感じていただければと思います。

さて、元のブログはカーネルのバグ修正の結果、期待通りに動くようになったものの、CPU が無駄にスロットルされてしまって、特に(CPUコアが大量にあるような)大規模環境では問題になったのを修正した、というお話です。

こちらは上記のブログ記事と私の記事の(2)をご覧いただくと良く分かると思いますので、ここでは説明しませんが、ひとつだけ気になったところがあって、ブログ中ではサラッと書かれていただけなので、久々にカーネルのコードを追ってみました。

6.1 カーネルをベースに追ってますが、下書き時点で色々なバージョンが混じっててあとで合わせたので、何行目かの説明が間違ってたらごめんなさい。

気になったのは、Indeedの 2 つ目のブログで、2 つ目の説明のための表中の、

余っているクォータをグローバルなバケットにもどすようにタイマーが設定されます。ワーカー1が実行を停止した後、このタイマーは7ms に設定されます
スロットリングの解除: 有効な修正が不具合の原因になってしまった理由

というところです(30msのところ)。

この "7ms" ってのは固定値として 7ms なのか、それとも可変値なのか? ってのを、記事を書く上でも知りたくてちょっとだけ追ってみました。ちゃんと追ってないので、この後は間違っている可能性が高いです。カーネル賢者からの優しいツッコミをお待ちしています

CPU の帯域制御を行う大体の仕組みは、私の記事をご覧いただくとして、CPU に割り当てた実行時間(スライス)が使われないとわかった場合、その割り当てをグローバルプールに返却する処理があります。この際、割り当てたスライスの残りから 1ms だけを引いた分をグローバルプールに返却します。

この 1ms というのは、カーネルのコード内で定義されており、変更するにはソースコードを変更する必要があります。

/* a cfs_rq won't donate quota below this amount */
static const u64 min_cfs_rq_runtime = 1 * NSEC_PER_MSEC;
/* minimum remaining period time to redistribute slack quota */
static const u64 min_bandwidth_expiration = 2 * NSEC_PER_MSEC;
/* how long we wait to gather additional slack before distributing */
static const u64 cfs_bandwidth_slack_period = 5 * NSEC_PER_MSEC;

kernel/sched/fair.c 5425行目

この 1ms が定義された行に続く 2 行もあとで説明に使うので引用しています。

さて、実際に 1ms 引いて返しているところはというと、 __return_cfs_rq_runtime関数です。ここで残っている実行時間から1msを引いて、実行時間(クォータプール)に足しています(つまり戻している)。

static void __return_cfs_rq_runtime(struct cfs_rq *cfs_rq)
  :(snip)
    s64 slack_runtime = cfs_rq->runtime_remaining - min_cfs_rq_runtime;

kernel/sched/fair.c 5474行目

最初に返却する実行時間を計算していますが、ここで残っている実行時間からさきほどの min_cfs_rq_runtime を引いています。

static void __return_cfs_rq_runtime(struct cfs_rq *cfs_rq)
  :(snip)
    if (cfs_b->quota != RUNTIME_INF) {
        cfs_b->runtime += slack_runtime;

        /* we are under rq->lock, defer unthrottling using a timer */
        if (cfs_b->runtime > sched_cfs_bandwidth_slice() &&
            !list_empty(&cfs_b->throttled_cfs_rq))
            start_cfs_slack_bandwidth(cfs_b);
    }

kernel/sched/fair.c 5483行目

実際に返す処理は cfs_b->runtime += slack_runtime の部分で、1ms 引いて計算した値を構造体の実行時間(クォータプールですな)に足しています(たぶん)。つまり戻しているということです。

そして、その後の実際の返却処理であるstart_cfs_slack_bandwidth関数に飛ぶ前にはcfs_b->runtime > sched_cfs_bandwidth_slice()という条件判断があります。これは返却した後のクォータプールの残量がスライスより大きいかを判断しています。クォータはスライス単位でCPUに割り当てますので、当然返却してもクォータプールにスライス以下しか残量がなければCPUへの割り当てが行えません。

list_emptyで調べているのは、CPUで実行したいためにキューに入ってるタスクがあるかどうかを見てるのでしょうか(しらんけど)。

そして、実際に返す処理は呼び出している start_cfs_slack_bandwidth 関数です。

static void start_cfs_slack_bandwidth(struct cfs_bandwidth *cfs_b)
{
    u64 min_left = cfs_bandwidth_slack_period + min_bandwidth_expiration;

    /* if there's a quota refresh soon don't bother with slack */
    if (runtime_refresh_within(cfs_b, min_left))
        return;

    /* don't push forwards an existing deferred unthrottle */
    if (cfs_b->slack_started)
        return;
    cfs_b->slack_started = true;

    hrtimer_start(&cfs_b->slack_timer,
            ns_to_ktime(cfs_bandwidth_slack_period),
            HRTIMER_MODE_REL);
}

kernel/sched/fair.c 5455行目

5460 行目で、この時点で期間の残り時間が min_left 以内かどうかを調べています。min_left 以内であれば、返却したところで、再割り当てしても実行時間が残っていません。 この min_left は 5457 行目で計算していますが、この値は cfs_bandwidth_slack_period + min_bandwidth_expiration で 5+2 の 7ms です。

この 5ms は、cfs_bandwidth_slack_period で定義されており、割り当てられた CPU でクォータが使われない間待つ時間です。5ms CPU で何も実行されない場合返却しよう、ということです。

ここに 2ms 足しているのは、返却してから期間終了までが 2ms 以内しかない場合は実行時間がない(返しても効果がない)ということではないかと思われます(たぶん)。2ms は min_bandwidth_expiration で定義されています。コメントにも minimum remaining period time to redistribute slack quota なんで返却を行うのは、これ以上期間が残っているときだけにしようということだと思います。

ここで、先にIndeed ブログの引用として「タイマーは7msに設定」と書いてあったのは、7ms ではなく、実際は 5ms ではないかなと思います。実際に hrtimer_start でタイマーを設定しています(たぶん)が、ここに設定されているのは cfs_bandwidth_slack_period つまり 5ms です。