手持ちの 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/s | 10.6GB/s | btrfs RAID0 は均等割りなので、一番遅い1枚に揃う |
| RAID0 のシーケンシャル読み(全部 x4) | 6.2 × 3 ≈ 18.7GB/s | 18.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_1 | CPU 直結 | Gen4 x4(ソケットは Gen5) | 約 6.6GB/s |
| M2_2 | CPU 直結 | 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,756 | 6,618 | 3,552 |
| SEQ1M Q8T1 書き | 2,012 | 6,239 | 3,360 |
| RND4K Q32T1 読み | 1,475 | 2,157 | 2,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,239 | 10,630(×1.70) | 18,771(×3.01) |
| SEQ1M Q1T1 読み | 4,002 | 6,581 | 9,863(×2.46) |
| RND4K Q32T1 読み | 1,475 | 1,293 | 1,466(×0.99) |
| SEQ1M Q8T1 書き | 2,012 | 1,907 | 2,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 なので、まさにこれに当たっていたわけです
- upstream の commit
-
本当に切り替わっているかは、
/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,632 12,584(×4.8) 普通の書き込み(32GiB + fsync、1ファイル) 3,867 4,071(×1.05) 普通の書き込み(4ファイル並列) 7,428 8,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 だとモデルのキャッシュがそもそも残らない、という事情もあります)
- llama.cpp は、キャッシュがない状態なら
Strata のローダーを読んで直す
-
Strata はオープンソースなので、ソースを読んでみました
- Linux では、GGUF からのエキスパートの読み込みが
freadで一時バッファに読み、memcpyで写す 方式でした。つまりページキャッシュ経由です -
Windows には O_DIRECT 相当(
FILE_FLAG_NO_BUFFERING)で読む経路が既にあって、Linux 側だけ(void) unbuffered;と未実装でした
- Linux では、GGUF からのエキスパートの読み込みが
-
読み込み部分だけを 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/s 17.9〜19.4 秒 パッチ + STRATA_UNBUFFERED_LOAD=111.97〜12.04 GiB/s 13.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 GB 6.9 GB mmap × 1 54.8 GB 54.9 GB psync × 8並列 54.8 GB 55.0 GB - mmap エンジンで並列にすると、各ジョブの担当範囲(
offset_increment)がずれず、全員が同じ範囲を読んでいた ようです(2人目以降はページキャッシュから読むので速い) - エラーも警告も出ないので、物理的な上限と照らし合わせないと気づけません。mmap は1ジョブで測り、並列の読み込みは psync で測るようにしました
- mmap エンジンで並列にすると、各ジョブの担当範囲(
まとめ
-
理論どおりだったこと
- 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回の中央値)
RAID1 RAID1C3 空ファイル 20万個の作成(sync まで) 19.05 秒 17.51 秒 RND4K Q32T1 書き込み 189 MB/s 199 MB/s RND4K Q32T8 書き込み 500 MB/s 542 MB/s SEQ1M Q8T1 書き込み 2,289 MB/s 2,598 MB/s SEQ1M Q32T4 書き込み 3,436 MB/s 3,329 MB/s - どれも回ごとのばらつき(btrfs のランダム書き込みは ±2割ほど揺れます)の範囲で、遅くなった様子はありません。容量はメタデータの1つ分(約 5.6GiB)余計に使いますが、3枚で 5.5TB あるので誤差ですね
この記事は個人ブログ(KANAF Works)にも掲載しています。