arc.cを1万1千行読んで、あのL2ARCの失敗をやっと説明できた
RAM 32GBの`homenas`に1TBのL2ARCを足して逆に遅くした件は、「L2ARCのヘッダがRAMを食うから」と説明してきました。ただ、それがコードのどこで起きているのかは見たことがありませんでした。OpenZFS masterの`module/zfs/arc.c`(11,841行・203関数)を通読し、ヘッダの7つの状態、`arc_read()`の骨格、退避と縮小の3者、2026年2月に書き直されたL2ARCのfeedまでを図にまとめます。
以前、RAMが32GBしかないhomenasに1TBのL2ARCを足して、足す前より遅くした話を書きました(L2ARCを足したら逆に遅くなった)。あの記事では原因を「L2ARCの各エントリのヘッダがRAM側に常駐するから」と説明しましたが、正直に言うと、それがコードのどこで起きているのかは見たことがありませんでした。人から聞いた説明をそのまま書いていたわけです。
ずっと引っかかっていたので、OpenZFSのmodule/zfs/arc.cを頭から通読しました。対象はmasterの81b19c6(2026年9月28日)です。11,841行、関数は203個。読みながら関数ごとに機能を分類し、呼び出し関係を追って図に起こしたものをここに置いておきます。
先に断っておくと、この記事はソースを読んだ結果であって、homenasで計測した数字ではありません。また、後半で触れるL2ARCまわりの変更はmasterにしか入っておらず、執筆時点の最新リリースであるzfs-2.4.4とは挙動が違う部分があります。その違いは表にまとめています。
まず地図: 11,841行の内訳
いきなりarc_read()から読み始めて一度挫折したので、2回目は全関数を機能別に仕分けるところから始めました。
arc.c の関数本体を機能別に集計した行数(203関数・計9,239行)
ファイル全体は11,841行。残りは冒頭1,042行の設計コメントと定義、L2ARCの設計コメント242行、末尾のモジュールパラメータ定義129行、関数間のコメントなど。棒にカーソルを合わせると関数数も表示されます。
眺めて意外だったのは2点です。
1つめは、L2ARC側のコードの多さです。関数本体だけで数えると、ARC本体(メモリ側)が5,377行に対してL2ARCが2,917行。L2ARCは「おまけの二次キャッシュ」くらいに思っていましたが、コード量ではARC本体の半分を超えています。デバイスの抜き差し、永続化(再起動後の復元)、書き込み予算の管理と、メモリ上のキャッシュにはない仕事が多いからです。
2つめは、最も多くの関数から呼ばれているのが、アルゴリズムの中核ではなく会計まわりの小さな関数だったことです。呼び出し元の数はarc_hdr_size()が22、arc_buf_size()が21、arc_hdr_set_flags()が18。arc.cは「どのブロックを残すか」を賢く決めるコードである以前に、ヘッダ1つ1つのサイズとフラグを正確に数え続けるコードなのだと感じました。
冒頭の1,042行はまるごと設計コメントとグローバル変数・チューナブルの定義です。これから読む人は、関数に入る前にここを読むのをおすすめします。私は飛ばして挫折しました。
行数の多い関数トップ10(arc.c、master 81b19c6)
arc_read()だけが592行と突出しています。上位10のうち4つがL2ARCの関数で、そのうちl2arc_write_sublist()はmasterで新しく切り出された関数です。
ヘッダは7つの状態を渡り歩く
ARCがキャッシュしている各ブロックにはarc_buf_hdr_tというヘッダがあり、常に次のどれか1つの状態に属しています。
ARCヘッダの7つの状態と遷移
図は横にスクロールできます実線枠はデータを保持している状態、破線枠はヘッダだけが残るゴースト、青枠はデータがL2ARCにしかない状態。書き込みのための arc_release() は、どの状態にいるヘッダも arc_anon に戻す(図では省略)。
遷移の判定はarc_access()、退避側はarc_evict_hdr()、実際にリストを付け替えてサイズを会計し直すのはarc_change_state()が担当しています。読んでいて「なるほど」と思った点を3つ挙げます。
MRUからMFUへの昇格には時間の間引きがある。 MRU上で再びヒットしても、前回のアクセスからARC_MINTIME(hz>>4、約62ms)以内なら昇格しません。1回のI/Oの中で同じブロックを続けて参照したのを「2回目の利用」と数えないためです。直前のアクセスがprefetchだった場合も昇格は見送られます。
ゴーストのヒットが配分を動かす。 データを捨ててヘッダだけ残したゴースト状態でヒットすると、それがarc_evict()がMRUとMFU、メタデータとデータの目標配分を調整する材料になります。メタデータとデータの重みを決めるのがzfs_arc_meta_balance(既定500)です。
arc_l2c_onlyこそが「RAMを食うL2ヘッダ」の正体。 ゴーストがさらに退避されるとき、そのブロックがL2ARCに書かれていれば、arc_hdr_realloc()でL1部分を落とした小さいヘッダに付け替えてarc_l2c_onlyへ移ります。データはL2ARCにしかないのに、ヘッダはRAM上に残り続ける。1TBのL2ARCを埋めれば、そのぶんのarc_l2c_onlyヘッダがRAMに並ぶわけです。あの記事で書いた説明が、ようやくコード上の具体的な状態として見えました。
arc_read() の骨格
592行あるarc_read()は、ハッシュ検索からヒット/ミスの判定、L2ARCから読むか通常のzioで読むかの選択まで全部を1つの関数で持っています。条件分岐をすべて追うと迷子になるので、骨格だけ抜き出しました。
arc_read() の骨格(592行を抜き出したもの)
図は横にスクロールできますl2arc_noprefetch の判定は読み込み完了側の arc_read_done() にあり、先読みで入ったヘッダはここで L2CACHE フラグを外される。L2から読んだデータがチェックサム検証に失敗した場合は、通常の zio_read() でやり直す。
ミス側のarc_hdr_alloc_abd()でデータ領域を確保するとき、ARCが目標サイズを超えていると、読もうとしたスレッド自身が退避の進捗を待たされる経路があります(次の節のarc_wait_for_eviction())。以前の動画編集の記事で「キャッシュ整理の直後にスクラブが引っかかる」と書きましたが、あのときasync destroy以外に疑うべきだった場所の1つがここでした。
もう1つ、l2arc_noprefetch(既定1)の判定がarc_read()ではなく完了側のarc_read_done()にあることも初めて知りました。先読みで入ったヘッダは、ここでL2CACHEフラグを外されます。動画素材のような大きなシーケンシャル読み込みは先読みで入ってくる割合が高いので、既定のままならL2ARCを押し流しにくい、ということになります。
退避と縮小は3者で回っている
ARCを小さく保つ仕組みは、1つのスレッドが全部やっているわけではありませんでした。
退避と縮小: 3者の分担
図は横にスクロールできます破線は直接の関数呼び出しではなく、スレッドを起こす・タスクを投入するといった間接的なつながり。arc_evict_task() は taskq に並列投入される。
- 割り当てる側(I/Oを発行したスレッド):
arc_adapt()で余裕があれば目標サイズarc_cを育て、arc_is_overflowing()で溢れ具合をNONE/SOME/SEVEREの3段階で判定します。溢れていればarc_wait_for_eviction()で、確保しようとした量のzfs_arc_eviction_pct(既定200%)ぶんの退避が進むまで待たされます - 退避zthr:
arc_evict()が「どの状態から何バイト退避するか」を決める中核です。状態全体の退避を受け持つarc_evict_state()は、必要に応じてarc_evict_task()をtaskqに並列投入します。並列度はzfs_arc_evict_threadsで決まります - 回収zthr: 空きメモリを見て、足りなければ
arc_reduce_target_size()でarc_cそのものを引き下げ、arc_kmem_reap_soon()でkmemキャッシュを回収します。空きがarc_c >> zfs_arc_no_grow_shift(既定5なのでarc_cの1/32)を下回ると、ARCの成長も止めます
arc_evict()はarc_prune_async()を通じて、Linuxのdentry/inodeキャッシュにも解放を頼みます。ZFSのメモリ事情がカーネルのファイル名キャッシュにまで波及する、というのはここを読むまで想像していませんでした。
L2ARCのfeedは2026年2月に書き直されていた
今回いちばん収穫があったのがL2ARCの書き込み側です。masterでは2026年2月にfeedまわりが大きく書き直され、キャッシュデバイスごとにl2arc_feed_thread()が1本ずつ動く形になっていました。
l2arc_feed_thread() の1周(キャッシュデバイスごとに1本)
図は横にスクロールできますDWPD予算(l2arc_dwpd_limit)は初回充填が終わり、かつL2の総容量が 2 × arc_c_max 以上の構成でだけ適用される。1秒あたり64MiBを超える場合は1秒を複数回のfeedに分割する。
手順2のl2arc_hdr_limit_reached()が、まさに私の失敗に対応するブレーキです。メモリが逼迫しているか、L2ヘッダの量がARCのl2arc_meta_percent(既定33%)を超えていると、その回のfeedを見送ります。見送った回数はarcstatsのl2_abort_lowmemに数えられます。L2ヘッダの数はL2ARCに載っているブロックの数に比例するので、L2ARCが大きいほど、そしてブロックサイズが小さいほどこのブレーキに近づきます。今のコードなら、ヘッダがARCの3分の1を超えた時点でfeedが止まり、l2_abort_lowmemが増えていくはずです。「L2ARCがRAMに対して大きすぎる」ことが、カウンタで見える形になっています。
手順3で書く量を決めるl2arc_write_size()は、l2arc_get_write_rate()から秒あたりの量をもらいます。これはl2arc_write_maxとDWPD予算の小さい方で、新設のl2arc_dwpd_limit(既定100 = 1.0 DWPD)がSSDの寿命を守るための上限になります。使い切らなかった予算は翌日に繰り越されます。ただしDWPDの制限が掛かるのは、初回の充填が終わった後、かつL2の総容量が2 × arc_c_max以上の構成だけです。
手順5の走査は、MFUのメタデータ → MRUのメタデータ → MFUのデータ → MRUのデータの順です。メタデータを優先するので、homenasのような小ファイル混じりの環境ではディレクトリ探索が先に速くなるはずです。ただしメタデータが連続して予算を使い切ると、l2arc_meta_cycles(既定2)回ごとに1回はデータに順番を譲ります。
ゼロコピーで書ける場合、書けない場合
手順6でヘッダを1つずつ書き出すとき、ARCのメモリをそのままデバイスに書けるかどうかで分岐します。
L2ARCへの書き出し: ゼロコピーか、コピーか
図は横にスクロールできますL2ARCへの書き込みでデータ本体のために新しくメモリを確保するのは右の枝だけ。確保したABDは free_on_write リストに積まれ、書き込み完了後に l2arc_do_free_on_write() で解放される。
右の枝に落ちると、abd_alloc_for_io()でデータ本体のための新しいメモリを確保してから書きます。Linuxの場合、この先のabd_os.cのabd_alloc_chunks()は、まず大きな連続ページ(高次ページ)をreclaimなしで取りに行き、失敗するたびに次数を下げます。最小単位のページだけはreclaimありで確保し、それでも取れなければ1 tick待って再試行します。
動画編集の記事では「メモリの断片化」という言葉を曖昧に使っていましたが、断片化が表に出る場所の具体例がここでした。どの次数で取れたかはabdstatsのscatter_order_N、最小ページの再試行回数はscatter_page_alloc_retryで観測できます。
2.4.x と master の違い
L2ARCまわりの差分を表にしておきます。以下のmaster側の変更は、zfs-2.4.4には入っていません。 2.4.x系でl2arc_dwpd_limitを設定しようとしても、そもそもそのパラメータは存在しません。
| 項目 | zfs-2.4.x | master(81b19c6) |
|---|---|---|
| feedスレッド | 1本がデバイスを順に巡回 | デバイスごとに1本 |
l2arc_write_maxの既定値 | 32MiB | 64MiB |
l2arc_write_boost | warmup中に上乗せ(既定32MiB) | 削除。初回充填が終わるまではDWPD制限を掛けない形に |
l2arc_dwpd_limit | なし | 新設(既定100 = 1.0 DWPD、未使用分は翌日へ繰り越し) |
l2arc_meta_cycles | なし | 新設(既定2) |
l2arc_ext_headroom_pct | なし | 新設(既定25) |
l2arc_feed_min_ms / l2arc_feed_again | feed間隔の制御に使われる | パラメータは残るがarc.cからは参照されない |
見ておくカウンタとチューナブル
読んだ結果を踏まえて、homenasで定期的に見るようにしたのは次の値です。
# ARCとL2ARCのサイズ、L2ヘッダの量、ヘッダ過多でfeedを見送った回数
grep -E '^(size|c|c_max|l2_size|l2_asize|l2_hdr_size|l2_abort_lowmem) ' /proc/spl/kstat/zfs/arcstats
# ABD確保がどの次数で成功しているか、最小ページの再試行回数
grep -E 'scatter_order|scatter_page_alloc_retry' /proc/spl/kstat/zfs/abdstats
l2_hdr_sizeがsizeのどれくらいを占めているかは、あの失敗を二度としないための一番手軽な指標です。arc.cの末尾で公開されているチューナブルは38個ありますが、自分が意味を理解して触る可能性があるのは次のあたりだけでした。値は81b19c6での既定値です。
| パラメータ | 既定値 | 意味 |
|---|---|---|
zfs_arc_max / zfs_arc_min | 0(自動) | ARCの最大/最小サイズ |
zfs_arc_meta_balance | 500 | ゴーストヒット時にメタデータとデータのどちらを残すかの重み |
zfs_arc_no_grow_shift | 5 | 空きメモリがarc_c >> nを下回ると成長を止める |
zfs_arc_eviction_pct | 200 | 溢れているとき、確保量の何%ぶんの退避を待つか |
l2arc_meta_percent | 33 | L2ヘッダがARCのこの割合を超えたらfeedを止める |
l2arc_noprefetch | 1 | 先読みで入ったバッファをL2に書かない |
l2arc_mfuonly | 0 | 1でMFUのみ、2でMFUとMRUのメタデータのみをL2に書く |
l2arc_exclude_special | 0 | special vdev上のブロックをL2の対象外にする |
l2arc_rebuild_enabled | 1 | インポート時にL2の内容を復元する(永続L2ARC) |
Linuxでは/sys/module/zfs/parameters/以下に並んでいます。
読み終えて
「L2ARCのヘッダがRAMを食う」という一文は、コードの上ではarc_l2c_onlyという状態、arc_hdr_realloc()による小さいヘッダへの付け替え、そしてl2arc_meta_percentというブレーキの3点に分かれて存在していました。人の説明を受け売りしていた頃より、自分の失敗をずっと具体的に説明できるようになった気がします。
関数の境界や呼び出し関係は、ソースを機械的に解析して起こしたものです。マクロ経由の呼び出しや関数ポインタ越しの呼び出しは一部しか拾えていないので、細部はGitHubの該当行で確認してください。行番号も81b19c6時点のものなので、masterが進めばずれていきます。