RustではじめるCPUとGPU

OSの層のコスト

この章でわかること:

  • システムコールの実コスト(実測: 通常の関数呼び出しの約120倍)と、その理由
  • スレッド間で互いを起床させる処理(コンテキストスイッチを含む)の実測(1往復約18マイクロ秒)
  • ファイルI/Oの実像: ページキャッシュと、readmmap(実測3.9倍)
  • システムコールを減らすという現代のI/O設計の方向(io_uring)
  • なぜ体系に必須か: ここまでの26章分はすべて1つのプロセスの中の 話でした。プログラムはOSの上で動きます。OSとの境界のコストモデルが ないと、I/Oとスレッドの設計判断に根拠を持てません

ユーザー空間とカーネル空間

OSは、アプリケーションが動くユーザー空間と、カーネルが動く カーネル空間を、CPUの特権レベル機構で分離しています。 ファイルの読み取り、スレッドの生成、時刻の取得など、ハードウェアや 他のプロセスに関わる操作はすべて、システムコール(system call)という 保護された関数呼び出しの形でカーネルに依頼します。

この保護にはコストがかかります。ほぼ何もしない システムコール(getpid)と、通常の関数呼び出しの実行時間を比べます。

関数呼び出し vs システムコール
use std::hint::black_box;
use std::time::Instant;

unsafe extern "C" {
    fn getpid() -> i32;
}

#[inline(never)]
fn plain_function(x: i32) -> i32 {
    black_box(x + 1)
}

fn main() {
    let n = 5_000_000;

    let start = Instant::now();
    let mut acc = 0i64;
    for i in 0..n {
        acc += plain_function(i) as i64;
    }
    let t = start.elapsed();
    println!(
        "通常の関数呼び出し: {t:>9.3?} ({:5.1}ns/回, acc={acc})",
        t.as_nanos() as f64 / n as f64
    );

    let start = Instant::now();
    let mut acc = 0i64;
    for _ in 0..n {
        acc += unsafe { getpid() } as i64;
    }
    let t = start.elapsed();
    println!(
        "getpidシステムコール: {t:>7.3?} ({:5.1}ns/回, acc={acc})",
        t.as_nanos() as f64 / n as f64
    );
}

筆者の実測(Playground)では、関数呼び出しが1.9ナノ秒、 getpid222.6ナノ秒で、約120倍です。

差の内訳は、特権レベルの遷移(専用命令での往復)、レジスタの 退避と復元、そして近年はCPU脆弱性の緩和策(カーネルとユーザーの ページテーブル分離など、14章の 機構に関わる対策)です。さらに直接には観測しにくいコストとして、 カーネルコードの実行がキャッシュとTLBの内容を置き換える (2章14章) ため、ユーザーコードに戻った後もしばらく遅くなります。

この数字から、I/O設計の第一原則であるバッチ化が導かれます。 1バイトずつwriteすれば1バイトあたり222ナノ秒かかりますが、 64KBをまとめて書けば1バイトあたり0.003ナノ秒です。Rustの BufReader/BufWriterは、このバッチ化を自動で行う ラッパーです(「ファイルI/Oでは必ずBufReader/BufWriterを使う」 という定石の根拠がこの実験です)。

なお、Instant::now()のような高頻度の操作が数十ナノ秒で 済むのは、カーネルが時刻データをユーザー空間に共有メモリで 公開し、システムコールなしで読ませる仕組み(vDSO)があるためです。 頻繁に呼ばれる操作には境界を越えさせない、という同じ原則の別の適用です。

スレッドの切り替えのコスト: コンテキストスイッチ

スレッドの切り替え(コンテキストスイッチ、context switch)は システムコールのさらに上位のコストです。2本のスレッドが チャネルで値を交互に送り合う実験で測ります。1往復には、 相手のスレッドを起床させて自分は待ち状態に入る処理が最低2回含まれます。

スレッド間の1往復にかかる時間
use std::sync::mpsc;
use std::time::Instant;

fn main() {
    // 2スレッドがチャネルで打ち返し合う。1往復 = 2回のスレッド起床
    let rounds = 100_000;
    let (tx1, rx1) = mpsc::channel::<u64>();
    let (tx2, rx2) = mpsc::channel::<u64>();

    let handle = std::thread::spawn(move || {
        for _ in 0..rounds {
            let v = rx1.recv().unwrap();
            tx2.send(v + 1).unwrap();
        }
    });

    let start = Instant::now();
    let mut v = 0u64;
    for _ in 0..rounds {
        tx1.send(v).unwrap();
        v = rx2.recv().unwrap();
    }
    let t = start.elapsed();
    handle.join().unwrap();
    println!(
        "{rounds}往復: {t:?} (1往復あたり {:5.2}µs, v={v})",
        t.as_nanos() as f64 / rounds as f64 / 1000.0
    );
}

筆者の実測(Playground、2コア)では1往復あたり約18マイクロ秒でした。 正確には、これは切り替え1回あたりのコストではなく、チャネルの 送受信、カーネルによる起床(futexシステムコール)、スケジューラの 実行、(コアが他のスレッドで使用中であれば)レジスタとスタックの切り替え、 そしてキャッシュとTLBの再ウォームアップまでを含む、 スレッド間の1往復の合計時間です。実務で発生するのも この合計なので、設計判断にはこの数字を使えます。

この数字を19章の数字と比べてください。 asyncタスクの切り替え(ユーザー空間の関数呼び出し)は 1マイクロ秒未満、スレッドの切り替えは十数マイクロ秒です。 待ちの多重化をカーネルではなくユーザー空間で行う、という asyncランタイムの存在理由が、この差です。 同時に、5章のrayonがスレッドプールを 再利用する理由(スレッド生成76マイクロ秒+切り替えコストの節約)も ここに根拠があります。

ファイルI/Oとページキャッシュ

ファイルの読み書きは、実際にはディスクとの直接のやり取りでは ありません。OSは読み書きされたファイルの中身をメモリに キャッシュします(ページキャッシュ、page cache。 14章のページ機構の応用です)。 2回目以降のアクセスはディスクにアクセスせず、メモリの速度で完了します。

では、キャッシュに存在するファイルを読む最速の方法は何でしょうか。 64MBのファイルで、通常のfs::readと、 14章で紹介したmmap (ファイルをアドレス空間に対応づける)を比べます。mmapの呼び出しは 20章のFFIの実践でもあります。

fs::read vs mmap (どちらもキャッシュ済み)
use std::fs;
use std::time::Instant;

fn main() {
    // 64MBのファイルを用意する(プロセスIDつきの一時ファイル名にする)
    let path = std::env::temp_dir().join(format!("book_io_{}.bin", std::process::id()));
    let path = path.as_path();
    let size = 64 * 1024 * 1024usize;
    let data: Vec<u8> = (0..size).map(|i| (i % 251) as u8).collect();
    let start = Instant::now();
    fs::write(path, &data).unwrap();
    println!("書き込み(64MB)   : {:>9.3?}", start.elapsed());
    drop(data);

    // (1) fs::read で全体を読む(カーネル→ユーザー空間へのコピー)
    for round in 1..=2 {
        let start = Instant::now();
        let buf = fs::read(path).unwrap();
        let sum: u64 = buf.iter().map(|&b| b as u64).sum();
        println!(
            "fs::read {round}回目   : {:>9.3?} (sum={sum})",
            start.elapsed()
        );
    }

    // (2) mmap でアドレス空間に貼って読む(コピーなし、14章の実践)
    use std::os::fd::AsRawFd;
    let file = fs::File::open(path).unwrap();
    assert_eq!(file.metadata().unwrap().len() as usize, size);
    let start = Instant::now();
    let ptr = unsafe {
        libc::mmap(
            std::ptr::null_mut(),
            size,
            libc::PROT_READ,
            libc::MAP_PRIVATE,
            file.as_raw_fd(),
            0,
        )
    };
    assert!(ptr != libc::MAP_FAILED);
    // SAFETY: mmapが成功し、size バイトが読み取り可能
    let mapped: &[u8] = unsafe { std::slice::from_raw_parts(ptr as *const u8, size) };
    let sum: u64 = mapped.iter().map(|&b| b as u64).sum();
    println!("mmap + 走査      : {:>9.3?} (sum={sum})", start.elapsed());
    let rc = unsafe { libc::munmap(ptr, size) };
    assert_eq!(rc, 0);
    fs::remove_file(path).unwrap();
}

筆者の実測(Playground)です。

書き込み(64MB) : 92ms  (ページキャッシュへの受け付け時間。ディスクへの反映は非同期)
fs::read 1回目 : 59ms
fs::read 2回目 : 59ms
mmap + 走査    : 15ms   (3.9倍)

fs::readの59ミリ秒の内訳は、64MBのヒープ確保とその初回タッチ (18章+14章の ページフォールト)、そしてページキャッシュからユーザーのバッファへの コピーです。mmapはページキャッシュのページをそのまま アドレス空間に対応づけるため、バッファの確保もコピーも起きません (代わりにファイル由来のページフォールトはあります)。 3.9倍の差は、これらの合算の差です。

ただしmmapには制約があります。アクセスのたびに ページフォールト(14章)が発生する可能性があり、I/Oエラーが戻り値ではなく シグナル(SIGBUS)で通知され、小さいファイルでは設定コストのほうが 大きくなります。適するのは、巨大で読み取りが中心のファイルです。Rustでは memmap2クレートが 広く使われている、安全性を重視したラッパーです。

システムコールを減らす: 現代のI/O設計の方向

この章の実測はすべて、境界を越えるたびに発生するコストでした。 現代の高性能I/Oの設計は、この境界越えの回数自体を減らす方向に進んでいます。

代表がLinuxのio_uringです。アプリケーションとカーネルが 2本のリングバッファ(提出用と完了用)を共有メモリに持ち、 多数のI/O要求の投入と完了の回収を、少数のシステムコールに まとめて行います(専用スレッドにカーネル側で監視させる設定なら、 提出のシステムコール自体をほぼゼロにもできます)。24章で見た GPUのコマンドキューと同じ設計で、1件ずつ依頼して待つ方式から、 複数の要求をキューに入れておいてまとめて完了を回収する方式への 転換です。Rustからはtokio-uringやglommioといったランタイムが利用します。

さらに徹底した方式(カーネルバイパス、DPDKなど)もありますが、 方向は1つです。頻繁に越える境界は、設計で取り除きます。 関数呼び出し(インライン化、6章)から、 FFI(20章)、GPU(24章)、 そしてカーネルまで、本書で繰り返し現れた同じ原則です。

まとめ

  • システムコールは関数呼び出しの約120倍(実測222ナノ秒)です。 対策はバッチ化で、BufReader/BufWriterはその自動化です
  • スレッドの切り替えは1往復約18マイクロ秒です。asyncタスク(1マイクロ秒未満) との差が、ユーザー空間での並行処理の存在理由です
  • ファイルはページキャッシュを介して読まれます。mmapはコピーを 省き、3.9倍速くなりました(適するのは巨大な読み取り中心のファイルです)
  • io_uringは、要求をまとめて投入し、完了をまとめて回収することで システムコール自体を減らします。頻繁に越える境界は設計で取り除く、 という本書で繰り返し現れた原則の最後の適用です

最終章では、ここまでの全知識を実務で運用する方法を扱います。 レイテンシの統計、性能の回帰検出、そして本書全体の知識の全体像です。