[PERFORMANCE]ADVANCED

日曜夜のscrubは、本当は遠慮していた — vdev_queue.cを読む

日曜21時のscrubが家族の動画配信とぶつかってカクついた件は、時間をずらして片付けたまま、なぜぶつかったのかは分かっていませんでした。OpenZFSのI/Oスケジューラ`module/zfs/vdev_queue.c`(1,195行、公開パラメータ27個)を読むと、scrubはかなり慎重に通常のI/Oへ道を譲るように作られていました。9本のI/Oクラス、次のI/Oの選び方、非同期書き込みの上限の動き方、scrubの遠慮の仕組み、そしてmasterで入った「SSDはキューを通らない」変更までを図にまとめます。

homenasのscrubを日曜21時に回していて、家族から「日曜の夜だけ動画がカクつく」と言われた話を以前書きました(日曜夜のscrubで家族に不満を言われた話)。あのときは平日の未明に時間をずらして片付けましたが、正直なところ、scrubと動画の読み込みがディスクの前で何を取り合っていたのかは分かっていませんでした。

arc.cを読んだ回で味をしめたので、今回はI/Oスケジューラ本体のmodule/zfs/vdev_queue.cを読みました。対象はOpenZFS masterのf239ea9(2026年9月30日)です。

このファイルは1,195行しかありません。それなのに、モジュールパラメータとして外から変えられる値が27個もあります。関数の数もほぼ同じ27個です。arc.cが11,841行に38個だったのと比べると、行数あたりの「調整つまみ」の密度が桁違いです。スケジューラという仕事が、アルゴリズムよりも「どこまで出すか」という塩梅の集まりであることが、ファイルの形からも見えます。

今回もソースを読んだ結果であって、homenasで計測した数字ではありません。また、最後に触れるschedulerプロパティはmasterにしかなく、執筆時点の最新リリースzfs-2.4.4にはありません。

1台のディスクの前に並ぶ9本の列

ZFSは、プールを構成する1台1台のディスク(leaf vdev)ごとにキューを持っています。I/Oはまず種類ごとの列(I/Oクラス)に並び、そこからデバイスへ送られます。ファイル冒頭のコメントには「5つのI/Oクラス」と書いてありますが、今のzio_priority_tには、キューに並ぶクラスが9つあります。コメントが機能の追加に追いついていない、というのもソースを読むと出会う景色です。

I/Oクラスごとの同時発行数(1台のディスクあたり、min_active〜max_active の既定値)

対話的なI/O非対話的なI/O(対話的なI/Oがある間は絞られる)
図は横にスクロールできます
0246810同期読み込み: zfs_vdev_sync_read_min_active=10 / max_active=10同期読み込み10同期書き込み: zfs_vdev_sync_write_min_active=10 / max_active=10同期書き込み10非同期読み込み: zfs_vdev_async_read_min_active=1 / max_active=3非同期読み込み1〜3非同期書き込み: zfs_vdev_async_write_min_active=2 / max_active=10(dirtyデータ量で変動)非同期書き込み2〜10 dirty量で変動scrub / resilver: zfs_vdev_scrub_min_active=1 / max_active=3scrub / resilver1〜3デバイス削除: zfs_vdev_removal_min_active=1 / max_active=2デバイス削除1〜2初期化: zfs_vdev_initializing_min_active=1 / max_active=1初期化1TRIM: zfs_vdev_trim_min_active=1 / max_active=2TRIM1〜2順次resilver: zfs_vdev_rebuild_min_active=1 / max_active=3順次resilver1〜3

上から zio_priority_t の並び順。全クラス合計の上限は zfs_vdev_max_active(既定1000)。TRIM はコード上「対話的」に分類されている。点にカーソルを合わせるとパラメータ名が表示されます。

各クラスには「最低これだけは同時に発行してよい」(min_active)と「これ以上は同時に発行しない」(max_active)の2つの値があります。同期読み書きは10で固定、非同期読み込み(先読み)は1〜3、scrubとresilverは1〜3です。非同期書き込みだけは上限が固定ではなく、後で見るようにdirtyデータの量で動きます。全クラスの合計にもzfs_vdev_max_active(既定1000)という上限がありますが、各クラスの上限を全部足しても50に届かないので、普段はこちらに当たることはなさそうです。

ここでの数字はすべてディスク1台あたりです。homenasのRAIDZ2を構成するExos 8台は、それぞれが自分の9本の列を持っています。

次のI/Oはどう選ばれるか

次に発行するI/Oの選び方(vdev_queue_io_to_issue)

図は横にスクロールできます
達しているまだ余裕あり見つからない見つかった見つからない見つかったvdev_queue_io_to_issue()合計 1000 件に達した?発行しない1. min_active 未満のクラス前回の次のクラスから一周探す(ラウンドロビンで飢餓を防ぐ)2. max_active 未満のクラス優先順(同期読み→…→順次resilver)の先頭から探すどちらもなし → 発行しないクラスから1つ取り出す同期読み書き・TRIMは到着順 / 他は位置順vdev_queue_aggregate()隣接するI/Oを1つにまとめるvdev_queue_pending_add()発行中として数え、デバイスへ

この手順は、I/Oがキューに1つ積まれるたびと、1つ完了するたび(vdev_queue_io_done)に繰り返される。完了側では発行できるものがなくなるまでループする。

選び方は2段階でした。まず、前回発行したクラスの次から一周して、min_activeに満たないクラスを探します。ここをラウンドロビンにしているのは、特定のクラスが永遠に後回しにされる(飢餓)のを防ぐためです。全クラスが最低値を満たしていたら、今度は優先順の先頭(同期読み込み)から、max_activeに満たないクラスを探します。

意外だったのは、スケジューラ専用のスレッドがないことです。この選択は、I/Oがキューに積まれた瞬間(vdev_queue_io())と、I/Oが1つ完了した瞬間(vdev_queue_io_done())に、その場で行われます。完了側では、発行できるものがなくなるまでループします。つまりディスクに空きができるたびに、そのときの列の状況を見て次を詰めていく作りです。

位置順に取り出して、隣り合うものをまとめる

位置順に取り出し、隣接するI/Oを1つにまとめる

図は横にスクロールできます
① 前回の続きの位置から取り出す(エレベーター方式)ディスク上の位置(オフセット)→前回発行したI/Oの終端(vq_last_offset)次はこれ対象は到着がいちばん古い約0.54秒の区切りの中だけ。その中で先にI/Oがなければ、区切りの先頭(最も手前の位置)に戻る。② 隣り合うI/Oを1つにまとめる(vdev_queue_aggregate)ABCD隙間 ≤ 32KiB離れすぎ → まとめないA+B+隙間+C を1回で読む上限: HDD 1MiB(aggregation_limit)/ SSD 128KiB書き込みは原則ぴったり隣接するものだけ読み込みの隙間ぶんは使い捨てのバッファに読み込む。書き込みは、RAIDZのパディングのような省略可能なI/Oを挟んで4KiBまでの隙間をつなぐことがある。

非同期読み書き・scrub・初期化などのクラスは、到着時刻を約0.54秒(2^29ナノ秒)単位で区切ったうえで位置順に並べたAVL木に積まれる。同期読み書きとTRIMは到着順のリストで、この並べ替えはしない。

同期読み書きとTRIMは到着順の単純なリストですが、非同期読み書きやscrubは、ディスク上の位置(オフセット)順に並べた木に積まれます。取り出すときは、前回発行したI/Oの終端位置のすぐ先にあるものを選びます。ディスクのヘッドが一方向に進み続けるように拾っていく、いわゆるエレベーター方式です。

ただし、到着時刻を約0.54秒(2^29ナノ秒)単位で区切り、いちばん古い区切りの中だけで位置順に進めます。新しく来たI/Oが、古いI/Oを位置の都合で追い越し続けることはありません。

取り出したI/Oの前後に隣り合うI/Oがあれば、vdev_queue_aggregate()が1つの大きなI/Oにまとめます。上限はHDDで1MiB、SSDなど回転しないデバイスでは128KiBです。読み込みは32KiBまでの隙間を挟んでもまとめます(隙間の分は読み捨てます)。scrubのように位置順に連続して読むI/Oは、ここで1MiB近くまで膨らみやすい、ということになります。この点は後で効いてきます。

非同期書き込みだけは上限が動く

非同期書き込みの同時発行数の上限は、dirtyデータの量で増える

図は横にスクロールできます
0246810同時発行数の上限(1台あたり)0%0.0GiB20%0.8GiB30%1.2GiB60%2.4GiB100%4.0GiBdirtyデータ量(zfs_dirty_data_max に対する割合 / 下段はRAM 128GBの場合)txg の sync を促すzfs_dirty_data_sync_percent書き込みの遅延(dmu_tx_delay)が始まるzfs_delay_min_dirty_percentdirty 30%(約1.2GiB)で上限 2dirty 60%(約2.4GiB)で上限 10min_active = 2max_active = 10

zfs_dirty_data_max の既定は物理メモリの10%で、上限は 4GiB と物理メモリの25% の小さい方。pool に保留中の sync タスク(zfs destroy など)があるときは、dirty 量に関係なく 10 になる。

非同期書き込みは、txgのsync(数秒ごとに、メモリ上にたまった変更をまとめてディスクに書き出す処理)で一気に発生します。これを全力で吐き出すと、その間は同期読み書きのレイテンシが荒れます。そこでスケジューラは、プールにたまっているdirtyデータの量に応じて、同時発行数の上限を2から10まで直線的に動かします。dirtyデータが少ないうちは控えめに、たまってきたら多めに、という調整です。

横軸の基準になるzfs_dirty_data_maxは、既定で物理メモリの10%ですが、4GiBと物理メモリの25%の小さい方で頭打ちになります。RAM 128GBのhomenasなら12.8GBではなく4GiBで、30%は約1.2GiB、60%は約2.4GiBです。

この60%という点は、dsl_pool.cで書き込み側に遅延を入れ始めるzfs_delay_min_dirty_percent(既定60)とちょうど同じ位置にあります。ディスクへの発行数が上限に張り付いてもdirtyデータが減らないなら、今度はアプリケーションの書き込みのほうを遅らせる、という二段構えです。

もう1つ、プールに保留中のsyncタスクがあるときは、dirtyデータの量に関係なく上限は10になります。コードのコメントには「syncタスクはユーザーの操作に対応するので、できるだけ速く押し出す」とあります。zfs destroyによるスナップショットの削除もsyncタスクとして処理されるので、以前async destroyの記事で書いた「スナップショットの一括削除の直後」は、非同期書き込みがいちばん開いている時間帯でもあったことになります。

scrubは、ちゃんと遠慮していた

ここからが本題です。

scrub が通常のI/Oに道を譲る仕組み(nia credit)

図は横にスクロールできます
対話的なI/Oが発行中scrub の同時発行数の上限1対話I/Oが1件完了するたびに送れるのは最大5件まで対話的なI/Oが途切れた直後scrub の同時発行数の上限1scrub のI/Oが完了するたびにカウントを1つ進めるアイドルとみなすscrub の同時発行数の上限3scrub は max_active まで同時に発行できる最後の1件が完了5件完了対話的なI/Oが1件でも発行されたら戻る

数字は既定値(zfs_vdev_scrub_min_active=1、scrub_max_active=3、zfs_vdev_nia_credit=5、zfs_vdev_nia_delay=5)。デバイス削除・初期化・順次resilverも同じ仕組みで絞られる。

コード上、scrub、デバイス削除、初期化、順次resilverの4つは「非対話的なI/O」に分類され、それ以外(同期・非同期の読み書きとTRIM)は「対話的なI/O」です。非対話的なI/Oは、対話的なI/Oが1件でも発行中なら、同時発行数が1に絞られます。さらにvq_nia_creditというカウンタで、対話的なI/Oが1件完了するごとに最大5件(zfs_vdev_nia_credit)までしか送れないように制限されます。

この制限が入っている理由は、コメントにはっきり書いてありました。要約すると「HDDの中には、シーケンシャルなI/Oを極端に優先して、同時に来たランダムI/Oのレイテンシが数秒に達するものがある。max_activeを1にしてもそうなる機種がある。だから、対話的なI/Oが終わっていないうちは、非対話的なI/Oを一定数しか送らない」。まさに、scrubの連続読み込みが動画の読み込みを押しのける状況への対策です。

一方で、対話的なI/Oがすべて終わり、そこから非対話的なI/Oが5件(zfs_vdev_nia_delay)完了すると、ディスクは「アイドル」とみなされ、scrubはmax_activeの3まで同時に発行できるようになります。

では、なぜ日曜の夜にカクついたのか

読み終えた時点での私の仮説を書いておきます。検証はしていません。

動画の再生は、プレーヤーが数秒先までまとめて読んでおき、しばらく何も読まない、という波のある読み方をします。読み込みが途切れている間に、scrubのI/Oが5件完了すれば、ディスクはアイドルと判定されます。するとscrubは最大3件まで同時に、しかもエレベーター方式とアグリゲーションで1MiB近くまで膨らんだ読み込みを、ディスクに送り込みます。

次に動画の読み込みが来ても、スケジューラが並べ替えられるのはまだキューにあるI/Oだけです。すでにディスクへ送ってしまったscrubのI/Oは取り消せません(vdev_queue_change_io_priority()のコメントにも、発行中のI/Oの優先度は変えられないとあります)。動画の読み込みは、ディスクの中に入っている大きなscrubの読み込みの後ろで待つことになります。HDDの中での並べ替えがシーケンシャル優先なら、待ち時間はさらに延びます。

もしこの仮説が当たっているなら、試す価値がありそうなのは次のあたりです。どれもLinuxでは/sys/module/zfs/parameters/から実行中に変えられます。

パラメータ既定値試す方向
zfs_vdev_scrub_max_active31にすると、アイドル判定後もscrubは1件ずつしか出ない
zfs_vdev_nia_delay5大きくすると、アイドルとみなすまでの猶予が延びる
zfs_vdev_nia_credit5小さくすると、対話的I/Oの合間に挟めるscrubの件数が減る
zfs_vdev_aggregation_limit1MiB小さくすると、1回のI/Oが膨らみにくくなる(scrub以外にも効く)

もっとも、いちばん確実なのは今やっている「scrubを家族が動画を見ない時間に回す」ことで、それは変わりません。パラメータを触るのは、時間をずらせない本番環境のほうだと思います。

masterのschedulerプロパティ: SSDはキューを通らない

最後に、zfs-2.4.4との比較で見つけたmasterの変更です。

master で入った scheduler プロパティ: SSD はキューを通らない

図は横にスクロールできます
onoffauto(既定)いいえ(HDD・ファイル)はい(SSD・NVMe)vdev_queue_io(zio)leaf vdev の scheduler は?非回転かつブロックデバイス?キューに積むこの記事のスケジューラを通るキューを通らないそのままデバイスへ送る

zfs-2.4.4 にはこのプロパティも分岐もなく、すべてのI/Oがキューを通る。データを持たない省略可能なI/O(NODATA)は、設定にかかわらず常にキューに積まれる。

masterには、leaf vdevごとに設定できるschedulerプロパティ(auto | on | off)が追加されています。既定のautoでは、回転するディスク(HDD)やファイルを使ったvdevはこれまで通りキューを通りますが、回転しないブロックデバイス(SSDやNVMe)は、この記事で見てきたキューを丸ごと通らず、そのままデバイスに送られます。キューの外に出たI/Oには、クラスごとの上限もscrubの遠慮も、エレベーターもアグリゲーションも効きません。速いデバイスにとっては、キューで並べ替えるより直接投げたほうが得、という判断だと読みました。

# leaf vdev ごとの設定を確認する(masterのみ)
zpool get scheduler homenas <leaf vdevの名前>

homenasに当てはめると、データを持つExosのHDDは今まで通りキューを通り、special vdevとSLOGに使っているOptaneは、masterに上げればキューを通らなくなります。マニュアルには、HDDでoffにするとI/Oが飢餓状態になり得るので推奨しない、という注意と、この設定はvdevを交換すると引き継がれない、という注意が書かれています。

項目zfs-2.4.4master(f239ea9)
schedulerプロパティなしあり(auto / on / off、既定auto)
SSD/NVMeへのI/O常にキューを通るautoではキューを通らない
HDD・ファイルへのI/Oキューを通るキューを通る(変わらず)
各クラスのmin_active / max_activeの既定値本記事の値同じ

見ておく値

キューの中で実際に何件待っていて何件発行中なのかは、zpool iostatで見られます。

# クラスごとのキューの待ち件数(pend)と発行中の件数(activ)を1秒ごとに
zpool iostat -qv homenas 1

# 現在のスケジューラ関連のパラメータ
grep . /sys/module/zfs/parameters/zfs_vdev_*_active /sys/module/zfs/parameters/zfs_vdev_nia_*

scrub中に動画を再生しながら-qの出力を眺めて、scrubのactivが1と3の間を行き来するのが見えれば、上の仮説の裏付けになるはずです。次の日曜にやってみるつもりです。

読み終えて

「scrubが動画を邪魔した」と思い込んでいましたが、コードを読む限り、scrubはかなり礼儀正しく作られていました。対話的なI/Oがあれば1件ずつに絞り、合間に挟む件数まで数えています。それでもぶつかるのは、スケジューラが手を出せるのがキューの中までで、ディスクに送ったあとのI/Oは取り返せないからだろう、というのが今の理解です。

行番号と既定値はf239ea9時点のものです。関数の境界や呼び出し関係は自分で読んで起こしたもので、細部はGitHubの該当行で確認してください。