【解説】コード生成の最適化によるLinuxコンテキストスイッチの改善パッチ

はじめに

今回は2025年11月にXie Yuanbin氏によって提案された一連のパッチシリーズ「Optimize code generation during context switching」について解説します。

これはLinuxのスケジューラ/コンテキストスイッチのパフォーマンスを改善するものですが、従来のようなアルゴリズムの変更ではなく「コンパイラによるコード生成」という、より低レイヤーかつ物理的な側面に焦点をあてたものです。

本記事では、このパッチシリーズが具体的に何を行ったか、Linuxカーネルのソースコードレベルで解説します。

パッチのサマリー

  • 標準的なIntel CPU環境において、コンテキストスイッチの所要時間を 約8~11%短縮
  • Spectre V2などの投機的実行脆弱性に対する緩和策が適用された環境下では 最大35~44%短縮

サーバーサイドのワークロードにおいて、Linuxのコンテキストスイッチは毎秒数万~数十万回発生するため、影響は小さくなさそうです。

前提知識

コンテキストスイッチ

まずLinuxカーネルが実行中のタスク(プロセスやスレッド)を切り替える仕組み「コンテキストスイッチ」について解説します。
コンテキストスイッチによりOSはハードウェアのレジスタ状態、メモリ空間、そしてカーネル内部の管理構造体を一斉に切替させます。

__schedule() 関数

今回は kernel/sched/core.c に定義された __schedule() 関数から見ていきます。
呼び出す関数の話もするので、以下からコードを同時に見るとわかりやすいです。
https://elixir.bootlin.com/linux/v6.17/source/kernel/sched/core.c#L6816

この関数はスケジュールのメインとなる関数で、現在CPUを占有しているタスク(prev)を退避させ、次に実行すべきタスク(next)を選出する役割を担います。

__schedule() の処理フローは、以下の主要なフェーズに分解できます。
なお、これはコンテキストスイッチの前振りの話なので、今回の記事では軽く流します。

1. スケジューリングの無効化とロック取得
最初に rcu_note_context_switch() を呼び出しRCU(Read-Copy-Update)の静止状態を記録します。
その後、当該CPUのランキュー(struct rq)を保護するために rq_lock() を取得します。この時点で割り込みは禁止され、アトミックな操作が保証されます。

2. 次に実行するタスクの選定
pick_next_task() 関数が呼び出され、スケジューリングクラス(CFS, RT, Deadline, Idleなど)の優先順位に従って、次にCPU時間を割り当てるべきタスク(next) が決定されます。
もし今のタスクと同じ(prev == next)であれば、コンテキストスイッチは不要となり、ロックを解放して即座にリターンします。

3. コンテキストスイッチの実行
prev != next の場合、context_switch() 関数が呼び出されます。
ここが今回のパッチの対象となる部分です。

context_switch() 関数

大きく分けて「メモリ空間(mm)の切り替え」と「レジスタ状態(実行コンテキスト)の切り替え」の2つのフェーズがあります。
https://elixir.bootlin.com/linux/v6.17/source/kernel/sched/core.c#L5301

省略すると以下のようになります。

/* kernel/sched/core.c */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
               struct task_struct *next, struct rq_flags *rf)
{
    prepare_task_switch(rq, prev, next);

    /* 1. メモリ空間の切り替え */
    arch_start_context_switch(prev);
    if (!next->mm) {
        /* カーネルスレッド(mmを持たない)の場合 */
        enter_lazy_tlb(prev->active_mm, next);
    } else {
        /* ユーザープロセスの場合 */
        switch_mm_irqs_off(prev->active_mm, next->mm, next);
    }

    /* 2. レジスタ状態とスタックの切り替え */
    switch_to(prev, next, prev);
    
    /* 3. 切り替え後の後処理 */
    barrier();
    return finish_task_switch(prev);
}

switch_mm_irqs_off() はx86アーキテクチャにおいてCR31 の書き換えを行っており、TLB2 のフラッシュを伴うコストの高い関数です。
https://elixir.bootlin.com/linux/v6.17/source/arch/x86/mm/tlb.c#L782

また、switch_to() マクロはアーキテクチャ依存のアセンブリコードに展開され、スタックポインタや命令ポインタ、その他のcallee-savedレジスタを退避/復元します。
switch_to()が完了した瞬間、CPUは prev から next へと物理的に遷移しています。
https://elixir.bootlin.com/linux/v6.17/source/arch/x86/include/asm/switch_to.h#L49
https://elixir.bootlin.com/linux/v6.17/source/arch/x86/entry/entry_64.S#L177

finish_task_switch() 関数

switch_to() から処理が戻ってきたとき、コード上の位置は context_switch() の続きですが、実行しているコンテキストは「新しくスイッチインされたタスク(next)」になります(厳密には、そのタスクが以前にスイッチアウトされた際の続きです)。ここで呼び出されるのが finish_task_switch() です。

finish_task_switch() は、コンテキストスイッチを完了させるための関数です。
ロックの解放、事後処理、メモリ管理構造体のドロップなどを行います。

この関数は、すべてのコンテキストスイッチの最後尾に必ず位置しており、その実行頻度はシステム全体のコンテキストスイッチ回数と完全に一致します。

セキュリティ緩和策との関連

SpectreやMeltdownといった投機的実行の脆弱性が発見されて以降、コンテキストスイッチは単なるレジスタの書き換え以上の作業が行われています。

パイプラインの破壊

CPUは性能を稼ぐために、条件分岐の結果が確定する前に先の命令を実行する「投機的実行」を行います。Spectre脆弱性は、この投機的実行の痕跡(キャッシュの状態変化など)をサイドチャネルとして利用し、本来アクセスできないメモリ内容を読み取る攻撃です。

これを防ぐため、OSカーネルには様々なマイグレーションが導入されました。
特に重要なのが IBPB3 と L1 Cache Flush4 です。
switch_mm() 内で異なるユーザープロセスへ切り替える際、カーネルはセキュリティ上の理由から、前のプロセスの情報を消し去るためにIBPBを発行し、場合によってはL1キャッシュをフラッシュします。

context_switch() の後半、つまり switch_to() を抜けて finish_task_switch() に到達した瞬間、CPUは以下のような状態に陥っています。

  • 命令キャッシュ(L1i):コンテキストが変わったため、キャッシュミスが多発
  • データキャッシュ(L1d):フラッシュされているか、全く異なる空間のデータを指している
  • 分岐予測(Branch Predictor):IBPBによって履歴が消去されており、分岐先を予測できない
  • TLB:フラッシュされており、アドレス変換にページウォークが必要

この状態で、finish_task_switch() を通常の関数呼び出し(CALL命令)として実行しようとすると以下の問題が生じます。

  • CALL命令のコスト:アドレス解決が必要
  • ターゲットへのジャンプ:新しいアドレスの命令をフェッチする必要があるが、L1iキャッシュには存在しない
  • パイプラインストール:分岐予測が効かないため、CPUは命令フェッチが完了するまでパイプラインが停止する

今回この記事で紹介するパッチがターゲットにしたのは、このタイミングにおける関数呼び出しを改善するものです。

問題点

Linuxカーネルは通常、GCCやClangの -O2 オプションでビルドされます。
-O2 はコードサイズと実行速度のバランスを取る標準的な最適化レベルであり、多くの小規模な関数は自動的にインライン展開されます。

しかし、finish_task_switch() は -O2 でもインライン化されていませんでした。
これにはいくつかの複合的な要因があります。

1. コンパイラのヒューリスティック
コンパイラが関数をインライン化するかどうかは、関数のサイズや呼び出し回数に基づいた複雑なヒューリスティックによって決定されます。
finish_task_switch() は内部でロック操作や条件分岐、デバッグ用のフックを含んでおり、コンパイラが「インライン化するとコードサイズが大きくなりすぎる」と判断するには十分な複雑さを持っていました。
https://elixir.bootlin.com/linux/v6.17/source/kernel/sched/core.c#L5182

2. セクション配置の乖離
呼び出し元である __schedule() 関数には、__sched という属性が付与されています。

/* kernel/sched/core.c */
static void __sched notrace __schedule(bool preempt)
{
...
}

このマクロは、この関数をバイナリ内の .sched.text という特別なセクションに配置するよう指示します。これは、スケジューリング関連のコードを一箇所に集め、キャッシュ効率を高めるための伝統的な最適化です。
一方 finish_task_switch() には特別なセクション属性がなかったため、通常の .text セクションに配置されます。

リンク時において .sched.text セクションと .text セクションは、メモリ上で離れた場所に配置される可能性があります。 switch_to() から戻ってきた直後の命令ポインタは .sched.text 内にあります。そこから finish_task_switch() を呼び出すには、.text セクションへの遠距離ジャンプ(Far Jump)が必要になります。

この物理的な距離は、命令キャッシュの局所性を著しく低下させます。近場のジャンプであれば、プリフェッチャが次のキャッシュラインを先読みしている可能性がありますが、セクションを跨ぐようなジャンプでは、ほぼ確実に命令キャッシュミスが発生します。
前述の通り、投機的実行の緩和策でパイプラインが脆弱になっている状態で、このキャッシュミスは大きなレイテンシ増大に繋がります。

改善点

この問題を解決するために、主要な関数の定義を __always_inline によって強制的にインライン化しました。ここでは3つの主要な変更点について、ソースコードの差分を追いながら解説します。

enter_lazy_tlb()のインライン化

[PATCH v4 1/3] x86/mm/tlb: Make enter_lazy_tlb() always inline on x86

まずはカーネルスレッドへのスイッチ時に使用される enter_lazy_tlb 関数です。
従来、x86アーキテクチャにおけるこの関数は、Cソースファイル内で定義された通常の関数でした。

/* arch/x86/mm/tlb.c */
void enter_lazy_tlb(struct mm_struct *mm, struct task_struct *tsk)
{
    if (this_cpu_read(cpu_tlbstate.loaded_mm) == &init_mm)
        return;

    this_cpu_write(cpu_tlbstate_shared.is_lazy, true);
}

この関数は、カーネルスレッドが前のタスクのページテーブルを借用する「Lazy TLB」モードに入るためのものです。処理自体はCPUごとの変数をチェックし、フラグを立てるだけの極めて軽量なものです。しかし、外部関数として定義されているため、呼び出しコストが処理コストを上回る状態でした。

パッチでは、この関数の実体をヘッダーファイルに移動し、static __always_inline を付与しました。

/* arch/x86/include/asm/mmu_context.h */
/*... */
#define enter_lazy_tlb enter_lazy_tlb
static __always_inline void enter_lazy_tlb(struct mm_struct *mm, struct task_struct *tsk)
{
    if (this_cpu_read(cpu_tlbstate.loaded_mm) == &init_mm)
        return;

    this_cpu_write(cpu_tlbstate_shared.is_lazy, true);
}

この変更により、context_switch 内で if (!next->mm) が真となった場合、関数呼び出し(CALL)ではなく、数命令のロード/ストア命令が直接展開されるようになります。これはARMやRISC-Vなどの他のアーキテクチャでは既に行われていた最適化であり、x86の実装をモダンな基準に合わせた形となります。

raw_spin_rq_unlock() のインライン化

[PATCH v4 2/3] sched: Make raw_spin_rq_unlock() inline

次に、スケジューラのロック解放処理の関数もインライン化します。

/* 修正前: kernel/sched/core.c */
void raw_spin_rq_unlock(struct rq *rq)
{
    raw_spin_unlock(rq_lockp(rq));
}

/* 修正後: kernel/sched/sched.h */
static inline void raw_spin_rq_unlock(struct rq *rq)
{
    raw_spin_unlock(rq_lockp(rq));
}

raw_spin_rq_unlock() は、実質的にスピンロックを解放するアトミック命令(x86なら MOV や LOCK プレフィックス付き命令)のラッパーに過ぎません。これを非インライン関数として残しておく必要はないので、インライン化することで余計なジャンプを排除することができます。

finish_task_switch() のインライン化

[PATCH v4 3/3] sched/core: Make finish_task_switch() and its subfunctions always inline

これが本パッチシリーズの本体であり、最も効果の大きい変更です。
finish_task_switch() と、その関数の中で呼び出している別の関数を全て強制インライン化します。

この変更に伴い、mmdrop の遅延処理など finish_task_switch() が使用する内部関数の一部も見直される必要がありますが、呼び出し元である context_switch()、ひいては __schedule() の中にコードを融合できる点が大事です。これにより以下のメリットが発生します。

1. セクションの統合
インライン化されることで、finish_task_switch() のコード本体は __schedule() の一部となります。つまり、自動的に .sched.text セクションに配置されることになります。これにより、コードのセクションまたぎの問題が解消されます。

2. 命令キャッシュの局所性
switch_to() から戻ってきた直後に実行すべき命令群が、メモリ上で連続して配置されることになります。現代のCPUはプリフェッチャによってメモリ上の連続したデータを先読みするため、これは理想的な状態です。
たとえ投機的実行緩和策で分岐予測がリセットされていたとしても、シーケンシャルな命令実行であればパイプラインのストールは最小限に抑えられます。

3. レジスタ退避の削減
関数呼び出し規約では、呼び出される関数は特定のレジスタ(RBP, RBX, R12-R15など)を保存・復元する義務があります。インライン化により新たなスタックフレームを作成する必要がなくなり、これらのレジスタ退避/復元のオーバーヘッドが消滅します。

アセンブリレベルでの解説

これらの変更が実際にCPUレベルでどのような違いを生むのか、アセンブリコードの概念モデルを用いて解説します。

; __schedule (.sched.text)
   ...
    call    switch_to               ; コンテキストスイッチ実行
    ; --- nextタスクとしてここに戻る ---
    
    ; 1. 引数のセットアップ
    mov     rdi, r12                ; prevタスクへのポインタ
    
    ; 2. 遠距離ジャンプ (Cache Missが発生しやすい)
    call    finish_task_switch      ;.textセクションへジャンプ
    
    ;...
    
; finish_task_switch (.text)
finish_task_switch:
    push    rbp                     ; プロローグ
    mov     rbp, rsp
    push    r12                     ; レジスタ退避
    
    ; 中身の処理...
    
    ; 3. さらに内部で関数呼び出し
    call    raw_spin_rq_unlock      ; 再度CALL
    
    pop     r12                     ; エピローグ
    pop     rbp
    ret                             ; リターン (分岐予測が必要)

このフローでは、switch_to() 直後の全てリセットされた状態で CALL 命令が実行され、さらに finish_task_switch() 内ではスタック操作と別の CALL が発生しています。これらはすべて、パイプラインに重い負荷をかけます。

; __schedule (.sched.text)
   ...
    call    switch_to               ; コンテキストスイッチ実行
    ; --- nextタスクとしてここに戻る ---
    
    ; finish_task_switch の中身がここに展開される
    
    ; 1. RQの取得 (インライン展開)
    mov     rax, qword ptr gs:[this_cpu_off]
    
    ; 2. メモリドロップのチェック (インライン展開)
    mov     rdx, [rax + prev_mm_offset]
    test    rdx, rdx
    jz     .no_mm_drop
    ;... mmdrop の処理...
.no_mm_drop:

    ; 3. ロック解放 (インライン展開)
    ; raw_spin_rq_unlock の中身がアトミック命令として直接配置
    lock bts dword ptr [rax + lock_offset], 0 
    
    ; 4. 次の処理へシームレスに移行
   ...

CALL と RET が消滅し、PUSH / POP によるスタック操作も不要になりました。
命令が連続しているため、IBPBによってBTBがフラッシュされていても、CPUは素直に次のアドレスの命令をフェッチするだけで済みます。

まとめ

今回は コード生成の最適化によるLinuxコンテキストスイッチの改善パッチ について解説しました。

結論だけ見たら「一部の関数をインライン化しただけかよ!」ってなるかもしれませんが、コロンブスの卵みたいなもので、何十年も誰も気付かなかったわけですから面白いです。結構Linuxカーネルだとこういう改善はあるイメージです。

やってることは関数のインライン化ですが、その理由としてはコンテキストスイッチを行った後は色んなキャッシュがリセットされている(Spectre V2などの投機的実行脆弱性に対する緩和策が適用された環境下では特に)のを踏まえて、余分なfar jampやcallを減らして連続したコードを実行させるという素直な実装でした。

ただコンテキストスイッチ自体が一秒間に何千~何万回も発生するので、ちょっとした改善でも全体のパフォーマンス改善に貢献できそうですね。

Linux 6.18のマージウィンドウは閉じられてしまったので、マージされるとしても最速でLinux 6.19以降になると思います。楽しみですね。

  1. ページディレクトリのベースとなるレジスタ ↩︎
  2. 仮想アドレスに対応した物理アドレスをバッファするテーブル ↩︎
  3. Indirect Branch Prediction Barrier, 分岐予測バッファ(BTB)をフラッシュする ↩︎
  4. VM入出力時などにL1データキャッシュをフラッシュする ↩︎

シェアする

  • このエントリーをはてなブックマークに追加

フォローする