f2fs:buffered overwrite 快路径跳过 inode folio 查询
💡 一句话总结
在「已有数据块的逻辑→物理映射已被 f2fs 内存中的读 extent 缓存记录」的覆盖写(buffered overwrite)场景下,f2fs 每次写都先取 inode folio(dnode 节点页)再查缓存,多付一次节点 folio 查询(页缓存 xarray 查找 + 锁 + inode 校验和 + sanity 检查)的固定开销;本补丁把「查读 extent 缓存」提前到取 inode folio 之前,缓存命中即直接返回块地址、整体跳过节点 folio 查询。作者在 QEMU/KASAN x86_64 VM 中测得 64 字节覆盖写中位耗时 1724.93 → 1560.24 ns/write(约 -9.6%)、256 字节覆盖写 1713.38 → 1577.85 ns/write(约 -7.9%),20k 次写中 f2fs_get_inode_folio() 调用从 20004 降到 4(作者自报,未独立验证)。
📋 补丁基本信息
| 项目 | 内容 |
|---|---|
| 补丁类型 | 优化(performance,热路径减开销) |
| 性能类别 | 热路径(fast path 减指令 / 减节点页查询开销) |
| 状态 | 状态(Merged)· 合入版本:Linux 7.2(git describe --contains 确认首个包含 commit 的 tag 为 v7.2-rc1) |
| 当前版本 | 主线单版本(commit 直接合入 f2fs 树)· 主线 commit 链接 |
| 版本演进 | 仅主线单版本,暂无多版本演进。commit message 无 Link:/Message-ID trailer,本环境 lore 查询超时,未检索到对应公开 lore 补丁系列(如实标注,见"方案演进") |
| 作者机构 | Wenjie Qi(小米 Xiaomi,Signed-off-by qiwenjie@xiaomi.com)· 合入人 Jaegeuk Kim(f2fs 维护者) |
| 提交日期 | 2026-05-29(2026-06-23 经 f2fs-for-7.2-rc1 合入主线) |
| 改动范围 | fs/f2fs/data.c · +5/-0 行 · 1 文件 |
| 核心函数 | prepare_write_begin() / f2fs_lookup_read_extent_cache_block() / f2fs_get_inode_folio() |
| 原始链接 | git.kernel.org commit · Kernel F2FS 文档 |
📊 速览卡片
特性等级依据:有实测数字(覆盖写中位耗时 -8%~-10%,节点 folio 查询调用近零)→ 幅度分高;纯内核 5 行改动、无配置/硬件门槛、on-disk 格式不变 → 落地与兼容分高;但收益仅作用于"块映射已被读 extent 缓存记录"的覆盖写场景(需文件曾读/写过),非全负载普适 → 综合 ★★★★(解读(AI 分析))。
🎯 解决什么问题
prepare_write_begin() 首先获取 inode folio 并构建 dnode,之后才检查读 extent 缓存。对一个非 inline、非压缩文件的普通覆盖写,extent 缓存命中时已经给出了数据块地址,后续路径不需要分配或更新任何节点状态。因此在该窄场景下,取 inode folio 是纯浪费——作者把读 extent 缓存的检查提前到取 inode folio 之前,避免覆盖写快路径上的一次节点 folio 查询(commit message 原文:"This avoids a node-folio lookup in the buffered overwrite fast path when the mapping is already cached")。
f2fs_write_begin() → prepare_write_begin()(决定数据块地址 blk_addr)。旧代码在函数内先调用 f2fs_get_inode_folio()(= __get_node_folio(),读取 inode 自身的 dnode 节点页)构建 dnode_of_data,再查读 extent 缓存。关键事实:当 extent 缓存命中时,旧代码用的也是缓存里的块地址(
*blk_addr = ei.blk + index - ei.fofs),dnode 里查出来的地址根本没被用到——也就是说,取 inode folio 的整套开销都是冗余的。这套开销包括:f2fs_grab_cache_folio() 在节点地址空间的 xarray 查找、read_node_folio() 的读页流程、folio_lock()、f2fs_inode_chksum_verify()(对 4KB inode 块算校验和,CPU 成本可观)、f2fs_sanity_check_node_footer(),最后还要 f2fs_put_dnode() 释放——每次写都付一次。为什么能这样优化:读 extent 缓存保存的是"逻辑块 → 物理块"的映射;命中即得物理块地址,且意味着该块已分配(空洞不会进缓存),不需要任何节点状态变更,因此可以直接返回。
f2fs_write_begin → prepare_write_begin 取块地址。为什么该场景遇到缺陷:f2fs 的读 extent 缓存由读路径(
F2FS_GET_BLOCK_PRECACHE → f2fs_update_read_extent_cache_range())和写路径(f2fs_update_data_blkaddr() → f2fs_update_read_extent_cache())共同填充。所以"文件块曾被读过或写过 → 映射已进内存缓存树 → 后续覆盖写命中缓存"是常态。旧代码对每一次覆盖写都付节点 folio 查询开销,即使块地址早已在缓存里。典型负载:数据库小记录原地更新(B+树节点/槽位重写)、日志与预写日志(WAL/journal)热区域重写、
fio 随机写、文件热区域反复修改、读后改写(read-modify-write)型工作负载。硬件配置放大:该开销是每写一次的固定 CPU 成本,写的频率越高、块越小,占比越明显(benchmark 中小块覆盖写收益更显著)。
🧩 核心机制
核心逻辑点只有一个:把"查读 extent 缓存"从 inode folio 查询之后提前到函数入口——对缓存命中的覆盖写,一次性取得物理块地址并立即返回,跳过整个节点 folio 查询路径。
prepare_write_begin() 函数顶部新增一行 4 条件早退守卫:!inline && !compressed && (pos & PAGE_MASK) < i_size && extent_cache_hit → return 0。三个排除条件分别保护什么:inline 数据文件(写可能触发
f2fs_convert_inline_folio() 内联转换)、压缩文件(需压缩准备 f2fs_prepare_compress_overwrite())、页起点 ≥ EOF 的写(需 f2fs_reserve_block() 预留新块)——这三类仍需走旧路径拿"特殊处理",故明确排除。为什么
(pos & PAGE_MASK) < i_size_read(inode) 是安全边界:f2fs 按 4KB 页分配数据块,含任何 < i_size 偏移的页,其数据块必已分配。因此对该页,extent 缓存命中返回的物理地址一定有效,不需要预留块。跨 EOF 的页起点 ≥ i_size 时被排除,避免在未分配区域走快路径。正确性要点:早退路径只设置
*blk_addr,不设置 *node_changed(即调用方的 need_balance)。因为该路径没有修改任何节点状态,保持调用方初始化的 false 正是正确行为(f2fs_write_begin() 入口初始化 bool need_balance = false,已核实)。语义上与旧代码"extent 缓存命中"分支完全等价(旧分支同样用缓存地址 dn.data_blkaddr),只是省掉了 f2fs_get_inode_folio() → 构建 dnode → f2fs_put_dnode() 的整套动作。
prepare_write_begin() 先 f2fs_get_inode_folio()(红框,冗余节点 folio 查询:xarray 查找 + 锁 + inode 校验和 + sanity)再查 extent 缓存;右边新路径:守卫条件满足后先查便宜的 extent 缓存,命中即得 blk_addr 并 return 0,f2fs_get_inode_folio() 整体跳过(灰框 ✗)。来源:基于真实 commit diff(fs/f2fs/data.c)绘制
| 步骤 | 操作 | 目的 |
|---|---|---|
| ① 提前查缓存 | 函数入口先判断 !inline && !compressed && (pos&PAGE_MASK)<i_size,再 f2fs_lookup_read_extent_cache_block(inode, index, blk_addr) | 命中即直接得物理块地址,跳过节点 folio 查询(前提) |
| ② 命中即返回 | 缓存命中时 return 0,need_balance 保持 false | 无节点状态变更 → 无需 balance,无需取 inode folio(主体) |
| ③ 保留旧路径 | inline / 压缩 / 跨 EOF 写仍走原有取 folio + 转换/预留流程 | 特殊路径行为不变,仅优化窄覆盖写场景(收尾) |
@@ -3719,6 +3719,11 @@ static int prepare_write_begin(struct f2fs_sb_info *sbi,
int flag = F2FS_GET_BLOCK_PRE_AIO;
int err = 0;
+ if (!f2fs_has_inline_data(inode) && !f2fs_compressed_file(inode) &&
+ (pos & PAGE_MASK) < i_size_read(inode) &&
+ f2fs_lookup_read_extent_cache_block(inode, index, blk_addr))
+ return 0;
+
/*
* If a whole page is being written and we already preallocated all the
* blocks, then there is no need to get a block address now.▲ 这段改动是机制成立的关键:4 个条件把快路径严格限定在"普通覆盖写 + 块映射已缓存"的窄场景。f2fs_lookup_read_extent_cache_block() 命中时把 *blk_addr 填成物理块地址(ei.blk + index - ei.fofs)并返回真,函数立即返回 0——调用方 f2fs_write_begin() 拿到有效 blkaddr 后走 f2fs_submit_page_read() 读旧数据进页(部分页写需要 read-modify-write),行为与旧代码完全一致,只是省掉了取 inode folio。
📈 性能影响
folio_lock()、inode 块校验和(CRC)计算、节点 sanity 检查、dnode 构建与释放。这是 Brendan Gregg 视角下的"减少计算量"(on-CPU),不涉及锁等待或 I/O 往返;改动也不增加任何 off-CPU 等待。次要角度为 内存层次(解读(AI 分析)):免掉对
NODE_MAPPING(sbi) 节点地址空间的一次访问,避免该缓存行/页表项访问,对 CPU cache 与 TLB 略有正面影响。
sync_file_range 类频繁小写负载收益最明显。文件曾读/写(映射已入 extent 缓存)是命中前提;文件块首次写(新分配)或缓存被逐出后,走旧路径,行为不变。
测试环境注意:作者用 QEMU/KASAN x86_64 VM 测量——KASAN 的内存检查会放大绝对耗时,但 before/after 是同一环境的相对比较,
f2fs_get_inode_folio() 调用数从 20004→4 直接印证"查询被跳过"这一机制,趋势可信(解读(AI 分析))。
| 场景/用例 | 运行环境 | 改进前 | 改进后 |
|---|---|---|---|
| 64-byte 覆盖写 | QEMU/KASAN x86_64 VM,已有 1MiB 文件 | 1724.93 ns/write | 1560.24 ns/write(约 -9.6%) |
| 256-byte 覆盖写 | QEMU/KASAN x86_64 VM,已有 1MiB 文件 | 1713.38 ns/write | 1577.85 ns/write(约 -7.9%) |
| 20k 次 64B 覆盖写 | 函数级 profiling | f2fs_get_inode_folio() 20004 次 | f2fs_get_inode_folio() 4 次 |
说明:以上数字均来自作者 commit message 自报("In a QEMU/KASAN x86_64 VM, using a small buffered overwrite workload on an existing 1MiB file, median time improved as follows..."),作者自报,未独立验证。百分比为按前值归一化计算((前-后)/前):64B ≈ 9.55%,256B ≈ 7.91%。KASAN 环境绝对耗时偏大,建议以相对提升与函数调用数变化为准。
来源:作者 commit message 自报数据(未独立验证),基于真实数字绘制
🔄 方案演进
本补丁为 f2fs 树直接合入的主线单版本,无可回溯的公开 lore 补丁系列(如实标注数据源局限):
Merge tag 'f2fs-for-7.2-rc1'(merge commit 09ca8dc7d634)进入主线。数据源说明:该 commit message 无
Link:/Message-ID trailer,无法直接定位到 lore 补丁系列;本环境 lore MCP 查询(lore_search/lore_find/lore_substr_subject/lore_count)多次超时,未检索到公开补丁系列与 review 讨论。因此版本演进与讨论细节不作编造,背景/动机以 commit message 原文为准。
qiwenjie@xiaomi.com,小米),Reviewed-by: Chao Yu <chao@kernel.org>(f2fs 维护者),Signed-off-by: Jaegeuk Kim <jaegeuk@kernel.org>(f2fs 维护者,合入人)——经 f2fs 维护者常规 review 流程合入主线。
⚠️ 风险与局限
max_extent_cnt/逐出策略),缓存未命中或已被逐出时走旧路径,行为不变(no-op 安全)。非 inline、非压缩、页起点 < i_size 是硬条件:inline/压缩/跨 EOF 写被明确排除在快路径之外,仍走旧流程,因此这三类场景零行为变化。
KASAN 环境放大绝对数字:benchmark 的绝对 ns/write 在真实硬件上会小很多,但相对收益趋势与"跳过查询"的机制相互印证。
正确性核心在
need_balance(*node_changed):早退路径不置位,依赖调用方 f2fs_write_begin() 初始化为 false——已核实该初始化存在,且该路径确实不改节点状态,故 f2fs_balance_fs() 不会被错误跳过(因为旧代码该场景下 dn.node_changed 也为 false)。建议在升级后对小写随机覆盖写负载做一次延迟/吞吐冒烟测试验证收益,并观察
f2fs_get_inode_folio() 的 profile 计数是否如预期下降(解读(AI 分析))。
blk_addr/need_balance 均为函数调用参数与局部变量,不影响任何导出接口或挂载选项。
潜在讨论点(推测):① 早退路径不显式写
*node_changed,依赖调用方初始化为 false——这是"隐式依赖",若未来调用方改变初始化或新增"需平衡"语义需特别注意;② 读 extent 缓存陈旧性——新代码与旧代码命中时使用同一缓存数据源(ei.blk + index - ei.fofs),不引入新的陈旧暴露;既有 truncate/fallocate/punch-hole 路径通过 f2fs_update_read_extent_cache_range() 更新/失效缓存,该机制未受影响。
🔗 交叉引用
fs/f2fs/data.c,+5 行)git.kernel.org merge 09ca8dc7d634 — Merge tag 'f2fs-for-7.2-rc1' — 本补丁进入主线的 merge commit(2026-06-23)
The Linux Kernel documentation — Flash-Friendly File System (F2FS) — f2fs 官方文档:节点页(node page)、extent cache 与 buffered I/O 机制背景
git.kernel.org 当前 fs/f2fs/data.c —
prepare_write_begin() / f2fs_write_begin() 所在源码文件(含本补丁后代码)
本站内容均由 AI 基于公开知识辅助生成,仅供学习参考,请勿直接引用作为依据。作者不对信息的准确性、完整性及适用性作保证,亦不对因使用本站内容产生的任何损失承担责任。