はじめに
ext4にfast commitの機能が入ったことを完全に見落としてしまっていたので、やる気が消える前に調査してまとめておこうと思った。
概要
ドキュメント
正直なところ、まずはドキュメントやLWNの記事をちゃんと読むのが良いと思う。Filesystemのデフォルトでは有効になっていない機能を中身も理解せずに使うのは危険かと思う。
概要
ext4(含むext3)は、powerlossなどのときにFilesystemが壊れるのを防ぐため、jbd2を用いたjournalの仕組みを取り入れている。jbd2は、特定のFilesystemに依存しないようにするためか、実装がblock指向のjournalとなっている。このため、どんなささいな変更であったとしても、必ずblock単位での処理となる。ext4の場合blockはたいてい4KiBとなる。
powerlossなどに対する備えであるため、どうしても書き込みのチェックポイントとしての同期が必要になる。単純にjournalが4KiB単位の処理のため書き込み量が増えがちである以上に、fsync()などの場合にストレージデバイス側のキャッシュをはかせるためのFUAも必要となり、待つべきポイントが増える。さらに、ext4の場合はinode同士の依存が強く、fsync()したときにそのinodeのみの書き出しとならず、他のinodeもまとめて書き出されてしまうという実装になってしまっているようだ。これに対するカウンターとしてfast commit機能が導入された。
fast commitは、fsync()が多用された場合や同期書き込み(mount or open)のときに効果を発揮し、block単位でのjournalではなくて、inodeのmetadataレベルでの操作の単位でjournalを書き込む。こうすることで、fsync()の対象のinodeに関連したもののみに絞ってjournalに書き込めるようにし、書き込むべきjournalの量を減らす。powerlossなどの後のreplay時は、ext4のinodeのmetadata操作である前提で論理的にroll forward処理を行う。
こんな設計の志向のため、fast commitはその適用範囲が必ずしも広くない。fast commitだけで対処できない場合は従来のjournal書き込みへfallbackするし、そもそも定期的な書き込み(jbd2 commit timer、デフォルトは5秒)のときもfast commitではなくて従来のjournal書き込みにしている。もっとアグレッシブにすることもできたようだが、既存のテストが失敗するとかの理由で、適用範囲を広げるのは見合わせたようだ、と先のLWN.netの記事に書かれていた。
バージョン
ext4 fast commit機能はLinux-v5.10で入った。e2fsprogs-v1.46.0で入った。
この記事では、Linux-v6.8くらい(Ubuntu-24.04)で実験しつつ、Linux-v6.12とe2fsprogs-v1.47.2のコードを見ている。
有効にする方法
mkfs.ext4
-Oオプションで直接feature名を指定する。
[rarul@kana fctest]$ dd if=/dev/zero of=test.dat bs=1M count=64
[rarul@kana fctest]$ mkfs.ext4 -O fast_commit test.dat
[rarul@kana fctest]$ dumpe2fs -h test.dat |grep -i fast
dumpe2fs 1.47.0 (5-Feb-2023)
Filesystem features: has_journal ext_attr resize_inode dir_index fast_commit filetype extent 64bit flex_bg sparse_super large_file huge_file dir_nlink extra_isize metadata_csum
Fast commit length: 16
tune2fs
-Oオプションで直接feature名を指定する。
[rarul@kana fctest]$ tune2fs -O fast_commit test.dat
制約
data=journal のときは fast commit は無効になる。
fast commitが有効なFilesystemをLinux-5.10より前のkernelではmountできない。(JBD2_FEATURE_INCOMPAT_FAST_COMMIT)
fast commitがjournalに記録されたままかもしれないので、tune2fsでfast commitの有効無効をむやみに切り替えない方が良い。まぁ、ふつうは正しくumountしてるだろうし、kernelもfast commit無効のときにもreplayだけはするように実装されてるようだけど、とはいえあまり他の人がやらないようなことは避けたほうが無難かと。
実験
fast commitが行われている様子を実際に動かして確認する。こんな感じのスクリプトを動かして、fsyncして1秒後のjournalをdebugfsコマンドを使ってjournal.txtファイルへとダンプして抜き出し、
#!/bin/bash -xe
sudo echo "hold sudo session"
mkdir -p mnt
dd if=/dev/zero of=test.ext4 bs=1M seek=64 count=0
mkfs.ext4 -O fast_commit test.ext4
sync
sleep 1
sudo mount -t ext4 test.ext4 mnt
sudo bash -c "echo This is aaa > mnt/aaa.txt"
sudo bash -c "echo This is bbb > mnt/bbb.txt"
sudo bash -c "echo This is ccc > mnt/ccc.txt"
sleep 1
./fsync mnt/aaa.txt mnt/bbb.txt mnt/ccc.txt
echo "dump <8> journal.txt"| ../../src/e2fsprogs_build/debugfs/debugfs test.ext4
sleep 1
sudo umount mnt
抜き出したjournal.txtをdebugfsコマンドのlogdumpで表示するとこうなる。
$ debugfs test.ext4
debugfs 1.47.2 (1-Jan-2025)
debugfs: logdump -f journal.txt
Journal starts at block 1, transaction 2
Found expected sequence 2, type 1 (descriptor block) at block 1
Found expected sequence 2, type 2 (commit block) at block 8
No magic number at block 9: end of journal.
*** Fast Commit Area ***
tag HEAD, features 0x0, tid 3
tag ADD_RANGE, inode 13, lblk 0, pblk 2082, len 1
tag INODE, inode 13
tag TAIL, tid 3
tag ADD_RANGE, inode 14, lblk 0, pblk 2083, len 1
tag INODE, inode 14
tag TAIL, tid 3
なお ./fsyncは、スクリプトのワンライナーがうまくいかなくて、結局下記のようにC言語で書いた。
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
static void do_fsync(const char* filename) {
int fd = open(filename, O_RDWR);
if (fd < 0) {
fd = open(filename, O_RDONLY);
}
if (fd < 0) {
perror(filename);
return;
}
fsync(fd);
close(fd);
}
int main(int argc, char* argv[]) {
for(int i=1; i< argc; i++) {
do_fsync(argv[i]);
}
}
データ構造
fast commit の on diskデータ構造
fast commitの書かれる領域は、struct ext4_fc_tlで定義されるヘッダと、ヘッダの中の fc_tag で指定される種類ごとに規定される可変長のデータとが繰り返し登場する構造となる。struct ext4_fc_tlのヘッダには fc_tag とは別に fc_len があり、fc_len だけ位置を進めることで次のstruct ext4_fc_tlのヘッダの場所がわかる。
ここでstruct ext4_fc_tlを紹介...と思ったけど、tagごとの構造体も同じ箇所で定義されてるので、ヘッダだけでなくタグごとのものも一緒に紹介する。
/* Fast commit on disk tag length structure */
struct ext4_fc_tl {
__le16 fc_tag;
__le16 fc_len;
};
/* Value structure for tag EXT4_FC_TAG_HEAD. */
struct ext4_fc_head {
__le32 fc_features;
__le32 fc_tid;
};
/* Value structure for EXT4_FC_TAG_ADD_RANGE. */
struct ext4_fc_add_range {
__le32 fc_ino;
__u8 fc_ex[12];
};
/* Value structure for tag EXT4_FC_TAG_DEL_RANGE. */
struct ext4_fc_del_range {
__le32 fc_ino;
__le32 fc_lblk;
__le32 fc_len;
};
/*
* This is the value structure for tags EXT4_FC_TAG_CREAT, EXT4_FC_TAG_LINK
* and EXT4_FC_TAG_UNLINK.
*/
struct ext4_fc_dentry_info {
__le32 fc_parent_ino;
__le32 fc_ino;
__u8 fc_dname[];
};
/* Value structure for EXT4_FC_TAG_INODE. */
struct ext4_fc_inode {
__le32 fc_ino;
__u8 fc_raw_inode[];
};
/* Value structure for tag EXT4_FC_TAG_TAIL. */
struct ext4_fc_tail {
__le32 fc_tid;
__le32 fc_crc;
};
fast commit の tag の種類
- EXT4_FC_TAG_HEAD (0x0009) --- tid(transaction id)で規定するfast commitのトランザクションのチェーンの開始を示す。struct ext4_fc_headが続く。
- EXT4_FC_TAG_ADD_RANGE (0x0001) --- inodeに対するextentが追加されたことを示す。ファイルサイズが大きくなった場合に相当する。struct ext4_fc_add_rangeが続く。
- EXT4_FC_TAG_DEL_RANGE (0x0002) --- inodeに対するextentが削除されたことを示す。ファイルサイズが小さくなった、または消された場合に相当する。struct ext4_fc_del_rangeが続く。
- EXT4_FC_TAG_CREAT (0x0003) --- inodeが作られたことを示す。ファイルが作られたことに相当する。struct ext4_fc_dentry_infoが続く。
- EXT4_FC_TAG_LINK (0x0004) --- ディレクトリエントリが作られたことを示す。ファイルが作られた、もしくはハードリンクが作られたことに相当する。struct ext4_fc_dentry_infoが続く。
- EXT4_FC_TAG_UNLINK (0x0005) --- ディレクトリエントリが消えたことを示す。ファイルが消されたことに相当する。struct ext4_fc_dentry_infoが続く。
- EXT4_FC_TAG_INODE (0x0006) --- inodeのメタデータが更新されたことを示す。ファイルの属性変更に相当する。struct ext4_fc_inodeが続く。
- EXT4_FC_TAG_PAD (0x0007) --- fast commit領域の空領域であることを示す。blockの中がいっぱいのとき、block境界をまたいで配置することはせず、padのデータで埋めて、次のfast commitは次のblockに配置する。とくに追加データはない。
- EXT4_FC_TAG_TAIL (0x0008) --- tidで規定するfast commitのトランザクションのチェーンの終了を示す。開始から終了までの間のデータのcrc32(crc32c)も記録していて、replay時にはこれが一致しているかの確認も行う。cstruct ext4_fc_tailが続く。
journalデータへの配置
fast commitは、デフォルトでは、従来通りのjournalとして確保された領域サイズの64分の1(EXT2_JOURNAL_TO_FC_BLKS_RATIO)、または256ブロック(JBD2_DEFAULT_FAST_COMMIT_BLOCKS)、のうちの大きい方を従来のjournalのサイズに追加する形で領域を確保する。もしくは mkfs.ext4 -J fast_commit_size= でサイズ指定できる。このサイズは、jbd2 superblockのs_num_fc_blksに記録される。kernelが起動時にこれを用いて、確保した領域(通常はino==8(EXT4_JOURNAL_INO)、正確には外部デバイスではない場合のs_journal_inum)のうちの後ろの方をfast commitの領域として使用する。(j_fc_last j_fc_first)
ちなみに、JBD2_FC_BLOCKというjbd2のブロックが定義されているが、これは使われていない。上記の通り、journalとして確保されるデータの中を、従来の形式のjournalを書く領域と、fast commit形式のjournalを書く領域とに分けて管理される。
実装
fast commitの有効無効に寄与する箇所
fs/ext4/super.cのext4_set_def_opts()は、ext4 superblockにfeatureフラグが立っていればmountオプションのフラグを立てる。
if (ext4_has_feature_fast_commit(sb))
set_opt2(sb, JOURNAL_FAST_COMMIT);
fs/ext4/super.cのext4_check_journal_data_mode()は、journalモードが「data=journal」のときFAST_COMMITを無効化する。
static int ext4_check_journal_data_mode(struct super_block *sb)
{
if (test_opt(sb, DATA_FLAGS) == EXT4_MOUNT_JOURNAL_DATA) {
printk_once(KERN_WARNING "EXT4-fs: Warning: mounting with "
"data=journal disables delayed allocation, "
"dioread_nolock, O_DIRECT and fast_commit support!\n");
/* can't mount with both data=journal and dioread_nolock. */
clear_opt(sb, DIOREAD_NOLOCK);
clear_opt2(sb, JOURNAL_FAST_COMMIT);
fs/jbd2/journal.cのjournal_check_superblock()は、fast commit有効時にjbd2 superblockのパラメータが適切かどうかをチェックしている。
num_fc_blks = jbd2_has_feature_fast_commit(journal) ?
jbd2_journal_get_num_fc_blks(sb) : 0;
if (be32_to_cpu(sb->s_maxlen) < JBD2_MIN_JOURNAL_BLOCKS ||
be32_to_cpu(sb->s_maxlen) - JBD2_MIN_JOURNAL_BLOCKS < num_fc_blks) {
printk(KERN_ERR "JBD2: journal file too short %u,%d\n",
be32_to_cpu(sb->s_maxlen), num_fc_blks);
return err;
}
fs/jbd2/journal.cのjournal_load_superblock()は、fast commit有効無効に応じてon memoryパラメータを整えている。
if (jbd2_has_feature_fast_commit(journal)) {
journal->j_fc_last = be32_to_cpu(sb->s_maxlen);
journal->j_last = journal->j_fc_last -
jbd2_journal_get_num_fc_blks(sb);
journal->j_fc_first = journal->j_last + 1;
journal->j_fc_off = 0;
}
fs/jbd2/journal.cのjournal_reset()は、mount時のreplay類が終わったらjbd2 superblockのfast commitのflagをおろしている。この後にもclearする箇所が出てくるけどちょっと意図がわからない。
/*
* Now that journal recovery is done, turn fast commits off here. This
* way, if fast commit was enabled before the crash but if now FS has
* disabled it, we don't enable fast commits.
*/
jbd2_clear_feature_fast_commit(journal);
fs/jbd2/journal.cのjbd2_mark_journal_empty()は、journalに空っぽを書くときはjbd2 superblockのfast commitフラグをクリアした状態で書くようにしている。
if (jbd2_has_feature_fast_commit(journal)) {
/*
* When journal is clean, no need to commit fast commit flag and
* make file system incompatible with older kernels.
*/
jbd2_clear_feature_fast_commit(journal);
had_fast_commit = true;
}
fs/jbd2/recovery.cのdo_one_pass()は、
if (jbd2_has_feature_fast_commit(journal) && pass != PASS_REVOKE) {
err = fc_do_one_pass(journal, info, pass);
if (err)
success = err;
}
他にも「FAST_COMMIT」でgrepするとflagを見て処理をするかしないか切り分ける箇所がいくつか見つかるけど、ほぼすべてがシンプルな例なので、ここでは割愛する。
fast commitのデータを構築する箇所
下記の関数がfast commitのデータを構築する箇所になっている。
- ext4_fc_track_unlink()
- __ext4_fc_track_unlink()
- ext4_fc_track_link()
- __ext4_fc_track_link()
- ext4_fc_track_create()
- __ext4_fc_track_create()
- ext4_fc_track_inode()
- ext4_fc_track_range()
後述するが、これら以外にも書き込みを行うタイミングで追加でfast commitのデータを構築する。
構築されたデータは、struct ext4_sb_infoの中にあるstruct list_head s_fc_q[2](ディレクトリエントリ以外) と s_fc_dentry_q[2](ディレクトリエントリ用)に追加する。[2]となっているがこれは、FC_Q_STAGING と FC_Q_MAIN になる。通常は FC_Q_MAIN のリストに追加する。もしfast commitを別スレッドが書き込み中などだった場合、FC_Q_MAIN ではなくて FC_Q_STAGING のリストにつなぐ。fast commitの書き込み処理が完了したタイミングで、FC_Q_STAGING のリストにあったものが FC_Q_MAIN のリストにつなぎ直される。繋ぎ変えはfs/ext4/fast_commit.cのfs/ext4/fast_commit.c()で行っている。
list_splice_init(&sbi->s_fc_dentry_q[FC_Q_STAGING],
&sbi->s_fc_dentry_q[FC_Q_MAIN]);
list_splice_init(&sbi->s_fc_q[FC_Q_STAGING],
&sbi->s_fc_q[FC_Q_MAIN]);
詳細は省くが、それぞれの関数を呼ぶ箇所は下記のようになる。
- ext4_fc_track_unlink()は、ext4_rmdir()と__ext4_unlink()とext4_rename()から呼ぶ
- ext4_fc_track_link()は、__ext4_link()とext4_rename()から呼ぶ
- ext4_fc_track_create()は、ext4_create()とext4_mknod()とext4_mkdir()とext4_rename()から呼ぶ
- ext4_fc_track_inode()は、ext4_mark_iloc_dirty()から呼ぶ
- ext4_fc_track_range()は、ext4_map_blocks()とext4_punch_hole()とext4_setattr()から呼ぶ
fast commitを書く処理の流れ
ext4_fc_commit()関数がfast commitを実際にon diskに書くところとなる。ext4_fc_commit()からの主要関数のcall traceをインデントで表すとこんな感じになる。
ext4_fc_commit()
jbd2_fc_begin_commit()
ext4_fc_perform_commit()
ext4_fc_submit_inode_data_all()
jbd2_submit_inode_data()
ext4_fc_wait_inode_data_all()
jbd2_wait_inode_data()
ext4_fc_commit_dentry_updates()
ext4_fc_write_inode()
ext4_fc_reserve_space()
ext4_fc_write_inode_data()
ext4_fc_reserve_space()
ext4_fc_add_dentry_tlv()
ext4_fc_reserve_space()
ext4_fc_write_inode_data()
ext4_fc_reserve_space()
ext4_fc_write_inode()
ext4_fc_reserve_space()
ext4_fc_write_tail()
ext4_fc_reserve_space()
jbd2_fc_wait_bufs()
jbd2_fc_end_commit()
基本は、1つ前の章で述べた s_fc_qやs_fc_dentry_qのリストを下に書き込みを行うが(ext4_fc_submit_inode_data_all()やext4_fc_commit_dentry_updates()あたり)、ブロックの開始位置だった場合(必然的にfast commitチェインの先頭)は EXT4_FC_TAG_HEAD を、ブロックの終了位置に収まらなかった場合は EXT4_FC_TAG_PAD を、fast commitチェインの最後は EXT4_FC_TAG_TAIL を、それぞれ作って書き込む。これらの処理のときにブロックの位置やオフセットやバッファ管理をext4_fc_reserve_space()で行っている。
fast commitを書く処理を実行する箇所
ext4_fc_commit()を呼び出す箇所は下記となる。
ext4/fsync.cのext4_fsync_journal()関数からの系は、ext4_sync_file()->ext4_fsync_journal()->ext4_fc_commit()の呼び出しであるため、fsync()/fdatasync()/msync()の系となる。
return ext4_fc_commit(journal, commit_tid);
ext4/extents.cのext4_fallocate()からの系は、syncモードのときにのみ呼び出しをする。
if (file->f_flags & O_SYNC && EXT4_SB(inode->i_sb)->s_journal) {
ret = ext4_fc_commit(EXT4_SB(inode->i_sb)->s_journal,
EXT4_I(inode)->i_sync_tid);
}
ext4/inode.cのext4_do_writepagesからの系は、data=journalモードの場合はfast commitは無効になっているはずだから、このコードは通ることがない?
/*
* data=journal mode does not do delalloc so we just need to writeout /
* journal already mapped buffers. On the other hand we need to commit
* transaction to make data stable. We expect all the data to be
* already in the journal (the only exception are DMA pinned pages
* dirtied behind our back) so we commit transaction here and run the
* writeback loop to checkpoint them. The checkpointing is not actually
* necessary to make data persistent *but* quite a few places (extent
* shifting operations, fsverity, ...) depend on being able to drop
* pagecache pages after calling filemap_write_and_wait() and for that
* checkpointing needs to happen.
*/
if (ext4_should_journal_data(inode)) {
mpd->can_map = 0;
if (wbc->sync_mode == WB_SYNC_ALL)
ext4_fc_commit(sbi->s_journal,
EXT4_I(inode)->i_datasync_tid);
}
fs/ext4/inode.cのext4_write_inode()からの系は、inodeメタデータ系の書き込みでsyncモードのときに呼び出す。
/*
* No need to force transaction in WB_SYNC_NONE mode. Also
* ext4_sync_fs() will force the commit after everything is
* written.
*/
if (wbc->sync_mode != WB_SYNC_ALL || wbc->for_sync)
return 0;
err = ext4_fc_commit(EXT4_SB(inode->i_sb)->s_journal,
EXT4_I(inode)->i_sync_tid);
ということで、ざっくり、syncモード(sync mountまたはO_SYNCでopen()した)の場合もしくはfsync()系が呼ばれたときにfast commitの書き出しが行われることが確認できる。逆に、ext4_sync_fs()やjbd2_journal_commit_transaction()とは関わらないので、journal commit timerやsyscall syncの場合は、fast commitではなくて従来のjournal書き込みが行われることがわかる。
fast commitをreplayする箇所
ext4_fc_replay()がreplayをしている箇所となる。ext4_fc_replay()からのcall traceをインデント化して表すとこうなり、tagの種類に応じた関数へ実処理を割り振っている様子がわかる。ext4_fc_replay_scan()は、2パスreplayするときの1パス目にあたる「PASS_SCAN」のときに呼ばれ、主にfast commitのchainのデータの中身に不整合がないかをチェックしている。
ext4_fc_replay()
ext4_fc_replay_scan()
ext4_fc_replay_link()
ext4_fc_replay_link_internal()
ext4_fc_replay_unlink()
ext4_fc_replay_add_range()
ext4_fc_record_modified_inode()
ext4_fc_replay_create()
ext4_fc_replay_link_internal()
ext4_fc_replay_del_range()
ext4_fc_record_modified_inode()
ext4_fc_replay_inode()
ext4_fc_record_modified_inode()
ext4_fc_replay()関数は、fs/ext4/fast_commit.cのext4_fc_init()関数でcallback登録している。
void ext4_fc_init(struct super_block *sb, journal_t *journal)
{
/*
* We set replay callback even if fast commit disabled because we may
* could still have fast commit blocks that need to be replayed even if
* fast commit has now been turned off.
*/
journal->j_fc_replay_callback = ext4_fc_replay;
if (!test_opt2(sb, JOURNAL_FAST_COMMIT))
return;
journal->j_fc_cleanup_callback = ext4_fc_cleanup;
}
callbackは、fs/jbd2/recovery.cのfc_do_one_pass()関数で使われる。
err = journal->j_fc_replay_callback(journal, bh, pass,
next_fc_block - journal->j_fc_first,
expected_commit_id);
これは、jbd2_journal_load()jbd2_journal_recover()->do_one_pass()->fc_do_one_pass()の系で実行されるため、mount時の従来のjournalのreplayの延長からfast commitのreplayも行われることがわかる。
終わりに
実装を追う場合でも、大雑把な流れの把握をやりやすくまた関数の名前も適切になっているので、それほど難しくはないのかなと思う。その一方で、Filesystemなので、タイミングやメモリ状態チェックや適切な排他やといった細かいところまで深く理解するのは一筋縄ではいかないように見える。
fast commitは、fsync()類のときにのみうまく機能するように実装されており、また明示的に有効にしない限り使われることがないので、よほどext4でfsync()が遅い現象で悩んでいない限りは、現時点ではfast commitを特に意識する必要はないと思う。
fast commitでjournalに書き込む粒度を小さくしたとはいえ、fsync()のときに使われるというユースケース上、どうしても最低1ブロックはfast commitの領域をFUAでwriteすることになる。fsync()を多用したケースで効果があるとはいえ、FUAなので、どうしてもレイテンシやシーケンシャルなIOPSの面では不利なままかと思われる。このへんまで解決しようと思うと、nobarrierやjournal_async_commitや外部journalやといったストレージ側にもpowerloss対策を入れる必要があるように思える。もしくは、そういうユースケースだとわかっている場合にlog structuredなFilesystemを選ぶほうが有利かもしれない。