[PERFORMANCE]ADVANCED

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行)

ARC本体(メモリ側) 5,377行L2ARC(キャッシュデバイス側) 2,917行その他 945行
図は横にスクロールできます
ヘッダ/バッファと変換: 1,587行 / 58関数ヘッダ/バッファと変換1,587行 / 58関数L2ARC 書き込み(feed): 1,398行 / 22関数L2ARC 書き込み(feed)1,398行 / 22関数退避・縮小・メモリ確保: 1,384行 / 42関数退避・縮小・メモリ確保1,384行 / 42関数アクセス・読み込み: 1,075行 / 7関数アクセス・読み込み1,075行 / 7関数kstat・初期化・チューニング: 945行 / 13関数kstat・初期化・チューニング945行 / 13関数L2ARC 永続化・リビルド: 924行 / 18関数L2ARC 永続化・リビルド924行 / 18関数解放・書き込み: 679行 / 10関数解放・書き込み679行 / 10関数状態遷移・サイズ会計: 427行 / 8関数状態遷移・サイズ会計427行 / 8関数L2ARC デバイス管理: 372行 / 10関数L2ARC デバイス管理372行 / 10関数ハッシュ表・kmemキャッシュ: 225行 / 13関数ハッシュ表・kmemキャッシュ225行 / 13関数L2ARC 読み込み: 223行 / 2関数L2ARC 読み込み223行 / 2関数

ファイル全体は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本体L2ARCkstat
図は横にスクロールできます
arc_read(): 592行arc_read592行arc_kstat_update(): 260行arc_kstat_update260行l2arc_write_sublist(): 245行(master で追加)l2arc_write_sublist245行 master で追加l2arc_evict(): 224行l2arc_evict224行arc_read_done(): 223行(2.4.4 では208行)arc_read_done223行 2.4.4 では208行l2arc_write_buffers(): 223行(2.4.4 では269行)l2arc_write_buffers223行 2.4.4 では269行l2arc_rebuild(): 209行l2arc_rebuild209行arc_evict_state(): 196行arc_evict_state196行arc_release(): 195行(2.4.4 では173行)arc_release195行 2.4.4 では173行arc_change_state(): 193行arc_change_state193行

arc_read()だけが592行と突出しています。上位10のうち4つがL2ARCの関数で、そのうちl2arc_write_sublist()はmasterで新しく切り出された関数です。

ヘッダは7つの状態を渡り歩く

ARCがキャッシュしている各ブロックにはarc_buf_hdr_tというヘッダがあり、常に次のどれか1つの状態に属しています。

ARCヘッダの7つの状態と遷移

図は横にスクロールできます
初回アクセス62ms超の再ヒット退避prefetchでゴーストヒットゴーストヒット退避ゴーストヒットUNCACHED指定参照0で即破棄L2ヘッダありL2ヘッダあり(なければ破棄)再アクセスarc_anonどのリストにもないarc_mru最近1回参照されたarc_mfu複数回参照されたarc_uncachedキャッシュしないarc_mru_ghostヘッダのみarc_mfu_ghostヘッダのみarc_l2c_onlyデータはL2にだけある破棄

実線枠はデータを保持している状態、破線枠はヘッダだけが残るゴースト、青枠はデータが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行を抜き出したもの)

図は横にスクロールできます
ヒットミス読める読めない検証失敗arc_read()buf_hash_find()(spa, DVA, birth) で検索L1にデータあり?arc_access(hit)I/O中なら完了を待つarc_buf_alloc_impl()arc_buf_fill で伸長・復号ARCヒットとして返すL2から読める条件・ヘッダにL2ヘッダがある・キャッシュデバイスが生きている・l2arc_norw かつ書き込み中、ではないarc_hdr_alloc()ゴーストなら arc_hdr_reallocarc_access(miss)状態遷移を判定arc_hdr_alloc_abd()溢れていれば退避を待つL2から読める?zio_read_phys()完了 → l2arc_read_done()zio_read()プールのvdevから読むarc_read_done()検証・buf作成・コールバック・noprefetch

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者の分担

図は横にスクロールできます
割り当てる側(I/Oスレッド)退避 zthr回収 zthrarc_get_data_impl()データ領域を確保arc_adapt()余裕があれば arc_c を成長arc_is_overflowing()NONE / SOME / SEVEREarc_wait_for_eviction()溢れていれば待たされる確保量の zfs_arc_eviction_pct(200%)分の退避が進むまで待つarc_evict_cb_check()arc_evict_cb()arc_evict()arc_evict_impl()arc_evict_state()arc_evict_task()arc_evict_state_impl()arc_evict_hdr()taskq × Narc_reap_cb_check()空きメモリを判定arc_reap_cb()arc_reduce_target_size()arc_c を引き下げるarc_kmem_reap_soon()kmemキャッシュを回収arc_evict() から依頼arc_prune_async()dentry / inode の解放依頼起こす依頼

破線は直接の関数呼び出しではなく、スレッドを起こす・タスクを投入するといった間接的なつながり。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本)

図は横にスクロールできます
1間隔を待って起床前回決めた時刻まで cv_timedwait2l2arc_hdr_limit_reached()書かない条件を確認リビルド待ち・全体TRIM中・メモリ逼迫L2ヘッダ量 > ARCの33% なら見送り3l2arc_write_size()書く量と次の間隔を決めるmin(l2arc_write_max, DWPD予算)上限はデバイス容量の1/44l2arc_evict()書き込み位置の先を空けるhand の先の古いL2ヘッダを外す5l2arc_write_buffers()4パスでリストを走査走査深さ = 書込量 × l2arc_headroom(8)6l2arc_write_sublist()1ブロックずつ書き出すeligible なヘッダを zio_write_phys7実績で間隔を補正書けなければ l2arc_feed_secs(1秒)待つ手順5の走査順(l2arc_write_buffers)0 MFU メタ1 MRU メタ2 MFU データ3 MRU データメタが連続 l2arc_meta_cycles(2)回予算を使い切ると、次の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への書き出し: ゼロコピーか、コピーか

図は横にスクロールできます
はいいいえはいいいえeligible なヘッダb_rabd があり、サイズ一致?b_pabd をそのまま使える?非暗号・共有なし・psize == asize・圧縮ARCの状態が一致ゼロコピーARCの領域を直接書くl2arc_apply_transforms()新しいABDを確保abd_alloc_for_io()コピー / 再圧縮 / 暗号化free_on_write リストへ

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.xmaster(81b19c6)
feedスレッド1本がデバイスを順に巡回デバイスごとに1本
l2arc_write_maxの既定値32MiB64MiB
l2arc_write_boostwarmup中に上乗せ(既定32MiB)削除。初回充填が終わるまではDWPD制限を掛けない形に
l2arc_dwpd_limitなし新設(既定100 = 1.0 DWPD、未使用分は翌日へ繰り越し)
l2arc_meta_cyclesなし新設(既定2)
l2arc_ext_headroom_pctなし新設(既定25)
l2arc_feed_min_ms / l2arc_feed_againfeed間隔の制御に使われるパラメータは残るが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_min0(自動)ARCの最大/最小サイズ
zfs_arc_meta_balance500ゴーストヒット時にメタデータとデータのどちらを残すかの重み
zfs_arc_no_grow_shift5空きメモリがarc_c >> nを下回ると成長を止める
zfs_arc_eviction_pct200溢れているとき、確保量の何%ぶんの退避を待つか
l2arc_meta_percent33L2ヘッダがARCのこの割合を超えたらfeedを止める
l2arc_noprefetch1先読みで入ったバッファをL2に書かない
l2arc_mfuonly01でMFUのみ、2でMFUとMRUのメタデータのみをL2に書く
l2arc_exclude_special0special vdev上のブロックをL2の対象外にする
l2arc_rebuild_enabled1インポート時にL2の内容を復元する(永続L2ARC)

Linuxでは/sys/module/zfs/parameters/以下に並んでいます。

読み終えて

「L2ARCのヘッダがRAMを食う」という一文は、コードの上ではarc_l2c_onlyという状態、arc_hdr_realloc()による小さいヘッダへの付け替え、そしてl2arc_meta_percentというブレーキの3点に分かれて存在していました。人の説明を受け売りしていた頃より、自分の失敗をずっと具体的に説明できるようになった気がします。

関数の境界や呼び出し関係は、ソースを機械的に解析して起こしたものです。マクロ経由の呼び出しや関数ポインタ越しの呼び出しは一部しか拾えていないので、細部はGitHubの該当行で確認してください。行番号も81b19c6時点のものなので、masterが進めばずれていきます。