Contents

【自作PC・Linuxユーザー向け】 NVMe SSD 3枚のbtrfs RAID0を理論値と実測で検証した話 【ストレージ】

手持ちの PC パーツを組み替えて開発用の Linux サーバーにしたので、NVMe SSD 3枚を btrfs の RAID0 にまとめて、I/O 性能がどう変わるかを検証しました。 主な目的は、ローカル LLM の大きなモデルファイル(50〜80GB)の読み込みを速くすることです。

3枚のうち1枚は、USB4 とレーンを分け合うソケットに挿していて x2 で接続されるので、RAID0 にしてもフル性能は出ないことは理屈の上で予想できていました。 それでも、どこまで理論どおりになるのか、x2 の1枚がどれだけ全体を引っ張るのか を数字で確かめたかったので、あえてその構成で組んで測り、後から x4 に切り替えて比べています。

進め方は、構成から理論値を見積もる → fio で実測する → 予想から外れたところの原因を掘る、の繰り返しです。 読み込みはほぼ理論どおりでしたが、書き込みと LLM のロードは理論値から大きく外れ、どちらも原因はストレージではなくソフトウェア側(btrfs と推論エンジンの実装)にありました。最後は推論エンジンに改善のプルリクエストを出しています。 (データは RAID0 なので、1枚壊れると多くのファイルが欠けます。メタデータは冗長化しているのでファイルの一覧は残り、欠けていない部分は救出できる可能性がありますが、全部失う覚悟のうえで、大事なデータはバックアップしつつ使う開発用の構成です)

結論

項目予想実測原因・補足
RAID0 のシーケンシャル読み(x2 が1枚)3.55 × 3 ≈ 10.6GB/s10.6GB/sbtrfs RAID0 は均等割りなので、一番遅い1枚に揃う
RAID0 のシーケンシャル読み(全部 x4)6.2 × 3 ≈ 18.7GB/s18.8GB/s理論どおり(単体の3.0倍)
チップセット経由の影響同時に使うと落ちるかも落ちない3枚同時でも各台が単体と同じ速度
RAID0 のシーケンシャル書き3倍前後ほぼ変わらず(2〜2.7GB/s)btrfs がチェックサム付きファイルへの O_DIRECT をバッファ書き込みに切り替えていた。普段の書き込みへの影響は小さい
LLM のモデルのロード(1スレッドの読み込み)大幅に短縮1割強ページキャッシュ経由の読み込み処理(CPU 側)が約 6GB/s で頭打ち。推論エンジン(Strata)を直して起動が約4秒短縮

作業環境

  • マザーボード: ASRock X870 Taichi Creator(BIOS 4.60)
  • CPU / メモリ: AMD Ryzen 9 9950X3D / 96GB
  • SSD: Crucial T500 2TB(PCIe 4.0 x4)× 3枚
    • 3枚とも以前の PC から流用したもの(書き込み 28〜34TB、寿命消費 3%)。SSD 側が Gen4 までなので、Gen5 のソケットに挿しても Gen4 で動きます
  • OS: Ubuntu 26.04.1(カーネル 7.0.0-38)。ファイルシステムは btrfs(ssd,discard=async,space_cache=v2、圧縮なし)
  • 計測ツール: fio 3.41(io_uring、O_DIRECT)
  • 計測スクリプトと生ログはすべて GitHub に置いています: https://github.com/akivajp/nvme-raid-bench

構成と理論値の見積もり

3枚の SSD は、それぞれ経路の違うソケットに挿さっています。

ソケット経路リンク見込み
M2_1CPU 直結Gen4 x4(ソケットは Gen5)約 6.6GB/s
M2_2CPU 直結Gen4 x2(USB4 とレーンを分け合う)約 3.5GB/s
チップセット側チップセット経由Gen4 x4(チップセットの上流 x4 を NIC・USB・SATA と共有)単体なら約 6.6GB/s。同時に使うと上流で詰まるかも
  • 実際の接続は、sysfs で確認できます

    $ for d in /sys/class/nvme/nvme*; do
        echo "$(basename $d): x$(cat $d/device/current_link_width) (max x$(cat $d/device/max_link_width))"
      done
    nvme0: x4 (max x4)   # チップセット側
    nvme1: x4 (max x4)   # M2_1
    nvme2: x2 (max x4)   # M2_2: SSD 側は x4 に対応しているが x2 で接続
    
    • PCIe の上流のブリッジ(CPU のルートポート)まで辿ると、ルートポート自体の最大が x2 でした。残りの x2 は、隣の USB4 コントローラ(ASM4242)のルートポートに割り当てられています

      $ cat /sys/bus/pci/devices/0000:00:03.1/max_link_width   # nvme2 の上流のルートポート
      2
      
    • ちなみに、この BIOS 設定では USB4 を実際に使っていなくても x2 でした。レーンの分割は BIOS の設定で決まるので、OS からは変えられません。lspci で型番を眺めるだけでは気づきにくいので、上流まで max_link_width を見るのがおすすめです

  • RAID0 の読み込み速度の見込み

    • btrfs の RAID0 は、データを一定の大きさに区切って、各ドライブに 均等に 割り振ります
    • なので、全体の速度は「一番遅いドライブ × 台数」で決まるはずです。x2 の1枚がいる間は 3.55 × 3 ≈ 10.6GB/s が上限、全部 x4 なら 6.2 × 3 ≈ 18.7GB/s の見込みです
    • チップセット経由の1枚が上流で詰まるようなら、さらに下がります

測り方

  • Linux の定番ベンチマークツール fio を使い、条件は Windows でおなじみの CrystalDiskMark に寄せました
    • シーケンシャル 1MiB(キュー深さ 8 / 1、並列 4×32)、ランダム 4KiB(キュー深さ 32 / 1、並列 8×32)の読み書き
    • 各条件 3回の中央値。読み込みは 20秒、書き込みは 10秒
  • 実際の用途に近いものとして、キャッシュを捨てた状態から 54.8GB の GGUF(LLM のモデルファイル)を最後まで読む時間 と、LLM サーバーの起動時間 も測りました
  • 気をつけた点
    • 未書き込みの領域を読むと、異常に速い値が出ます。SSD は、一度も書いていない(TRIM された)領域を読まれると、NAND を読まずにゼロを返すためです。生デバイスは、計測範囲を先に書き埋めてから測りました
    • btrfs では、読み込みの計測を先にやります。コピーオンライト(CoW)なので、ランダム書き込みをするとファイルが細切れになり、その後の読み込みの結果が変わってしまいます
    • スロットリングの確認。M2_2 〜 M2_4 は大きめのヒートシンク付きのカバーがあるものの、真上に 3.5 スロット厚の RTX 4090 がいます。SMART の温度スロットリングのカウンタ(thm_temp1/2_trans_count)を計測ごとに前後で記録しました(結果: 全計測で 0回)

検証1: 単体性能と、チップセット経由の影響

まず RAID0 にする前に、1枚ずつ測りました(MB/s)。

M2_1(CPU 直結 x4)※チップセット側(x4)M2_2(CPU 直結 x2)
SEQ1M Q8T1 読み6,7566,6183,552
SEQ1M Q8T1 書き2,0126,2393,360
RND4K Q32T1 読み1,4752,1572,191

※ M2_1 はシステムディスクなので btrfs 上のファイルで計測。他の2枚は生デバイス

  • x2 の1枚は、見込みどおりシーケンシャルが約 3.5GB/s で頭打ちです。4K ランダムは他と同じで、x2 の影響はありません
  • チップセット経由の1枚も、単体なら CPU 直結とほぼ同じ速度でした
  • 次に、3枚を同時に読んでみると、合計 15.7GB/s(6.0 + 6.2 + 3.5GB/s)。3枚とも単体と同じ速度が出ているので、チップセットや CPU 側で帯域を取り合っている様子はありません(ほかのデバイスが暇なら、ですが)
  • M2_1 の書き込みだけが妙に遅い(2.0GB/s)のは、ドライブではなく btrfs 側の事情でした。検証3で扱います

検証2: RAID0 の読み込みは「一番遅い1枚 × 台数」

既存のシステムディスクに2枚を足して、データを RAID0 に変換します(メタデータは RAID1。後で RAID1C3 に変更しました。末尾の追記を参照)。

$ sudo btrfs device add /dev/nvme0n1 /dev/nvme2n1 /
$ sudo btrfs balance start -dconvert=raid0 -mconvert=raid1 /
単体RAID0(x2 が1枚)RAID0(全部 x4)
SEQ1M Q32T4 読み6,23910,630(×1.70)18,771(×3.01)
SEQ1M Q1T1 読み4,0026,5819,863(×2.46)
RND4K Q32T1 読み1,4751,2931,466(×0.99)
SEQ1M Q8T1 書き2,0121,9072,667(×1.33)

(MB/s。全部 x4 の列は、BIOS で USB4 を無効にして M2_2 を x4 に切り替えた後の値)

  • x2 が1枚いる間は 10.6GB/s。3枚同時なら 15.7GB/s 出るのに、見積もりの 3.55 × 3 ≈ 10.6GB/s とぴったり同じ値で止まりました。速い2枚は、余力を残したまま x2 の1枚を待っていることになります
  • USB4 は今後もあまり使わない見込み(USB 3.2 Gen2 の Type-C が別にある)なので、BIOS で USB4 を無効にして M2_2 を x4 にすると、18.8GB/s。こちらも 6.2 × 3 ≈ 18.7GB/s の見積もりどおりです
  • ランダム 4K はほとんど変わりません。RAID0 が効くのは、まとまった量を読むときだけです
  • x4 にした M2_2 の最高温度は 66℃ → 74℃ に上がりましたが、スロットリングは起きていません
  • 読み込みはここまで理論どおり。一方で、書き込みは RAID0 にしてもほとんど伸びていません。生デバイス1枚でも 6.2GB/s 出るので、3枚なら 18GB/s 前後になるはずです

検証3: 書き込みが伸びない → btrfs が O_DIRECT をやめていた

  • btrfs 上の書き込みは、単体でも RAID0 でも 2〜2.7GB/s。台数を増やしても伸びないので、ストレージ以外のどこかで詰まっています

  • 調べると、2025年に入った btrfs の変更が原因でした

    • upstream の commit 968f19c5b1b7「btrfs: always fallback to buffered write if the inode requires checksum」(2025年3月。2026年2月には 6.6 / 6.12 の安定版にもバックポート)
    • btrfs はデータにチェックサムを付けます。ところが、O_DIRECT で書いている最中にアプリ側がバッファを書き換えると、ディスクに書かれた中身とチェックサムが食い違ってしまいます(VM のディスクイメージで実際に起きていたらしい)
    • そこで、チェックサム付きのファイルへの O_DIRECT 書き込みは、常にページキャッシュ経由の普通の書き込みに切り替える ことにした、という変更です。性能が落ちることは、コミットメッセージにも書かれています
    • fio の書き込みは O_DIRECT なので、まさにこれに当たっていたわけです
  • 本当に切り替わっているかは、/proc/vmstat の nr_dirtied(ページを Dirty にした回数の累計)で確かめられます。本物の O_DIRECT なら、ページキャッシュを通らないのでほぼ増えません

    $ d() { awk '$1=="nr_dirtied"{print $2}' /proc/vmstat; }
    $ a=$(d); fio --name=c --filename=testfile --rw=write --bs=1m --direct=1 --size=8g > /dev/null; b=$(d)
    $ echo "$(( (b-a)*4/1024 )) MiB"
    8200 MiB    # 8GiB 書いて、全部ページキャッシュを通っている
    
    • チェックサムを付けない設定(chattr +C)のファイルで同じことをすると 0 MiB でした
    • ちなみに、free で見える Cached の増え方では判別できません。切り替わった場合も、O_DIRECT の約束を守るために、書き終わったページをカーネルがすぐに捨ててしまうためです
  • では、チェックサムを外すとどれくらい変わるのか。RAID0(全部 x4)の上で比べました(MB/s)

    通常のファイルchattr +C のファイル
    O_DIRECT 書き込み(Q8T1)2,63212,584(×4.8)
    普通の書き込み(32GiB + fsync、1ファイル)3,8674,071(×1.05)
    普通の書き込み(4ファイル並列)7,4288,780(×1.18)
    • O_DIRECT なら約5倍。でも、普通の書き込みではほとんど差がありません
    • コピーやダウンロードのような普段の書き込みは、もともとページキャッシュ経由です。なので「btrfs は書き込みが遅い」のではなく、ベンチマーク(O_DIRECT)の数字が低く出ているだけ と言えます
    • 1ファイルの書き込みは、書き手1本の CPU が 100% で頭打ちでした。複数のファイルに分けて並列に書けば伸びます
  • 対処

    • 基本は何もしない。chattr +C にすると、チェックサムによる破損検出・圧縮・reflink が使えなくなるので、全体に付けるのは割に合いません
    • DB や VM イメージのように O_DIRECT で書くものの置き場所だけ、空のディレクトリに chattr +C を付ける(既にデータがあるファイルには後から付けられません)
    • ちなみに、チェックサムを保ったまま O_DIRECT を速くする修正(IOMAP_DIO_BOUNCE。本来の速度の約95%とのこと)も提案されていて、2026年6月の時点では btrfs 側が未マージでした。新しいカーネルでは解決しているかもしれません

検証4: LLM のモデルのロードは、ページキャッシュの読み込みで頭打ち

本命の用途です。キャッシュを捨てた状態から、54.8GB の GGUF を最後まで読む時間を測りました。

読み方単体RAID0(全部 x4)
O_DIRECT で並列に読む12.5 秒4.3 秒(×2.9)
read() で1スレッドで読む10.6 秒8.8 秒(×1.2)
mmap で1スレッドで読む11.1 秒9.9 秒(×1.1)
  • O_DIRECT で並列に読めば約3倍。一方、普通に1スレッドで読むと 1割ちょっとしか縮みません

  • 1スレッドの読み込みは 6GB/s 前後で頭打ちで、CPU 使用率は 80〜90%。ストレージではなく、ページキャッシュからユーザー空間へデータを写す処理(CPU 側) が律速です

  • 実際の LLM サーバーでも、起動してから最初の応答が返るまでを測りました(Qwen3.8-Flash-Next IQ3_S、GGUF 83.6GB)

    キャッシュなしキャッシュあり
    Strata(MoE 向けの推論エンジン。普段使い)15.5〜16.0 秒16.5 秒
    llama.cpp(mmap、既定)17.9 秒10.1 秒
    llama.cpp(--load-mode dio)15.0 秒14.4 秒
    • llama.cpp は、キャッシュがない状態なら --load-mode dio(O_DIRECT)が一番速くなりました。モデルがページキャッシュに残っているなら、既定の mmap の方が速いです
    • Strata は、ロード中の読み込みが約 6GB/s で一定。キャッシュの有無でも、読み込みのスレッド数を増やしても変わりません(Strata はエキスパートを RAM に固定するので、96GB だとモデルのキャッシュがそもそも残らない、という事情もあります)

Strata のローダーを読んで直す

  • Strata はオープンソースなので、ソースを読んでみました

    • Linux では、GGUF からのエキスパートの読み込みが fread で一時バッファに読み、memcpy で写す 方式でした。つまりページキャッシュ経由です
    • Windows には O_DIRECT 相当(FILE_FLAG_NO_BUFFERING)で読む経路が既にあって、Linux 側だけ (void) unbuffered; と未実装でした
  • 読み込み部分だけを C で再現して、読み方を比べました(32GiB、6スレッド、キャッシュなし)

    読み方GiB/s
    fread + memcpy(Strata の現行方式)5.88
    O_DIRECT + memcpy(Windows の経路を Linux に移した形)11.80
    O_DIRECT で読み先へ直接14.42
    • 現行方式の 5.88GiB/s は、Strata 本体で観測した値とぴったり一致します。スレッドを 16本にしても 5.90GiB/s で変わりません
  • そこで、Windows の経路を Linux に移植するパッチを書いて、Strata 本体で試しました

    • 変更は src/core/expert_source.cpp への 104 行の追加だけです。既存の環境変数 STRATA_UNBUFFERED_LOAD=1 を付けたときだけ O_DIRECT で読みます(これまで Linux では、この変数を付けても読み込みのスレッドが増えるだけでした)
    • O_DIRECT で開けないファイルシステムでは、従来の読み方に戻ります
    Strata 0.2.2(同じイメージで交互に3回ずつ)エキスパートの読み込み起動から応答可能まで
    既定5.65〜5.72 GiB/s17.9〜19.4 秒
    パッチ + STRATA_UNBUFFERED_LOAD=111.97〜12.04 GiB/s13.9〜14.0 秒
    • 読み込み部分は約2倍、起動全体では約4秒(2割強)短くなりました(普段使いの 0.1.45 でも同じパッチで 16.0 → 11.5 秒)。残りの約10秒は、CUDA の初期化や GPU への転送など、ストレージと関係のない部分です
    • 読み込んだデータのチェックサム(Strata の STRATA_VERIFY_ARENA=1)が、既定の読み方と完全に一致することも確認しました
  • このパッチは、プルリクエストとして Strata に送っています(AMD 向けのビルドを確認できていないので、ドラフトの状態です): https://github.com/Niko1221/Strata/pull/3624

補足: fio の mmap は並列にすると実際より速く出る

  • llama.cpp の mmap を真似て、fio の --ioengine=mmap --numjobs=8 で GGUF を読んだら 28GB/s と出ました

    • 3枚同時に読んでも 18.8GB/s が上限なので、物理的にありえない数字です
  • /proc/diskstats で、実際にディスクから読んだ量と突き合わせると

    fio の報告実際にディスクから読んだ量
    mmap × 8並列54.8 GB6.9 GB
    mmap × 154.8 GB54.9 GB
    psync × 8並列54.8 GB55.0 GB
    • mmap エンジンで並列にすると、各ジョブの担当範囲(offset_increment)がずれず、全員が同じ範囲を読んでいた ようです(2人目以降はページキャッシュから読むので速い)
    • エラーも警告も出ないので、物理的な上限と照らし合わせないと気づけません。mmap は1ジョブで測り、並列の読み込みは psync で測るようにしました

まとめ

  • 理論どおりだったこと
    • btrfs RAID0 の読み込みは「一番遅いドライブ × 台数」。x2 の1枚がいると 10.6GB/s、全部 x4 で 18.8GB/s(単体の3倍)
    • チップセット経由のソケットも、同時に使って帯域が詰まることはなかった
  • 理論から外れたこと(原因はソフトウェア側)
    • 書き込み: btrfs が、チェックサム付きのファイルへの O_DIRECT をバッファ書き込みに切り替えていた。ベンチマークの数字が低く出るだけで、普段の書き込みへの影響は小さい
    • LLM のロード: 1スレッドでページキャッシュ経由で読む処理が約 6GB/s で頭打ち。ローダーが O_DIRECT で読めば RAID0 が活きる(Strata にパッチを送付)
  • ストレージを速くしても、その手前のソフトウェアが同じペースで受け取れないと効果が出ない、というのが一番の学びでした。とはいえ容量は 5.5TB になり、モデルを置き放題になったのは素直に嬉しいです

お疲れ様でした!

追記(2026/10/22): メタデータを RAID1C3 にした

  • 記事の中では、メタデータを RAID1(2枚に同じものを書く)にしていました。ディスクが3枚あるので、3枚すべてに書く RAID1C3 に変更しました

    • データは RAID0 のままなので、1枚壊れれば多くのファイルは欠けます。一方で、メタデータ(ファイルの一覧や配置の情報)は、RAID1 なら残りの1つ、RAID1C3 なら残りの2つから読めます。救出作業の途中で残りのディスクに読み取りエラーが出ても、もう1つの複製から直せるぶん、RAID1C3 のほうが安心です

      $ sudo btrfs balance start -mconvert=raid1c3 /   # -m を指定すると、System も同じ構成に変換される
      $ btrfs filesystem df /
      Data, RAID0: total=708.00GiB, used=681.75GiB
      System, RAID1C3: total=32.00MiB, used=80.00KiB
      Metadata, RAID1C3: total=8.00GiB, used=5.56GiB
      GlobalReserve, single: total=512.00MiB, used=0.00B
      
      • 変換するのはメタデータ(約 5.5GiB)だけなので、すぐに終わりました
      • RAID1C3 は Linux 5.5 で入った機能で、変換後は 5.5 より古いカーネルではマウントできなくなります(古いレスキュー用 USB などで起動するときは注意)
  • メタデータを3か所に書くようになるので、書き込みが遅くならないかを変換の前後で測りました(3回の中央値)

    RAID1RAID1C3
    空ファイル 20万個の作成(sync まで)19.05 秒17.51 秒
    RND4K Q32T1 書き込み189 MB/s199 MB/s
    RND4K Q32T8 書き込み500 MB/s542 MB/s
    SEQ1M Q8T1 書き込み2,289 MB/s2,598 MB/s
    SEQ1M Q32T4 書き込み3,436 MB/s3,329 MB/s
    • どれも回ごとのばらつき(btrfs のランダム書き込みは ±2割ほど揺れます)の範囲で、遅くなった様子はありません。容量はメタデータの1つ分(約 5.6GiB)余計に使いますが、3枚で 5.5TB あるので誤差ですね
この記事は qiita.com にも掲載しています。