Android 里面的 F2FS 特性¶
标题:[저자직강] F2FS Features in Android (구글 김재극박사)
日期:2022/07/22
作者:김재극
链接:https://www.youtube.com/watch?v=y0cD_vCotqE
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注:Jaegeuk Kim 亲自演讲的 F2FS 特性发展历史,长达一个半小时!先收藏了。
我这就开始。因为我是临时想到的,或者说我匆忙整理资料时想到的,所以会以我们当前使用的 Android 最新内核版本中的挂载选项或某些特性为主进行讲解,并且会介绍我们是如何针对 Android 进行优化的。另外,考虑到在座各位大多是研究生,可能对问题本身比对解决方案更感兴趣,所以我选取了大约十多个围绕具体问题或主题的内容。我会逐一讲解,并分享其中有趣的部分,以及我们在开发产品(我现在正在做 Pixel 手机)时遇到并需要解决的问题。因此,我将按主题分类进行列举。
另外,可以按年代顺序来理解这些主题:我先介绍的主题是早期、按时间顺序演进的。所以,早期的关注点主要是性能方面的重要特性,但从某个时间点之后,趋势转向了如何从稳定性角度强化 QA 等方面,再之后又回到性能,如此反复。因此,主题也大致按性能->提升稳定性->再回到性能这样的顺序排列,供大家参考。
在介绍每个特性之前,我觉得有必要先画图说明一下 Android 设备是如何运作的,这需要一点背景知识。所以,我想最好先在这里解释一下这个背景,为此我做了些准备。
那么,设备第一次启动时会发生什么?有人知道 Android 设备首次启动时的流程吗?最开始做什么?软件… 软件启动后,一切就开始了。当然,在此之前还需要了解架构。智能手机现在有高端、中端、入门级,甚至还有物联网设备,所有这些都正在整合到 Android 中。但首先需要了解设备的硬件架构是怎样的,因为谈论性能不能脱离它。不能笼统地说性能必须好,而是要根据入门级的水平找到合适的性能权衡点(trade-off),这是首要的。因此,我先简要介绍一下硬件架构,大约花 10 到 20 分钟。
我们现在看的是高端设备,不是低端。高端是什么呢?就是把我们能组装到一部手机里的最快组件都用上。逐一举例:首先当然是 CPU。我们制造 SoC,在给定的芯片面积内使用多少个核心。现在用的是大小核(big.LITTLE)架构:4 个小核、2 个中核、2 个大核,总共 8 个核心。这是第一点。
接下来是 DRAM 大小。DRAM 大小方面,有 12GB、8GB。像苹果会用更小的容量,但也会用。DRAM 大小的重要性在于:从性能角度看,大 DRAM 的关键因素不是减少交换(swap),而是最小化运行时开销。DRAM 小的话,就需要做更多工作来最小化交换或内存管理带来的额外开销。我们目前专注于高端设备,高端设备的 DRAM 也大。
再看存储。存储容量方面,以前可能只有 32GB、64GB,但现在支持三种大容量:128GB、256GB、512GB。所以存储趋势是变大了。但当容量增大到一定程度时,性价比就不同了。性能方面,32GB、64GB 时容量小,芯片成本低,卖得多但性能差。最终,研究存储的人知道,基于闪存的存储内部要考虑并行度(parallelism)、寿命(age)间隔大小(intervals)等因素。实际上,如果固守小容量,性能不仅无法持续提升,反而会下降。相反,当前趋势是通过增大容量来提高性能,并且为了在高端设备上保证一定性能水平,容量提升到了 128GB、256GB、512GB。
简单来说,跑基准测试时,256GB 当然比 128GB 快,但 256GB 和 512GB 性能几乎一样。按当前趋势来看,实际购买的客户… 因为我们做产品,从定制角度,256GB 卖到 512GB 价格翻倍,但性能差不多,而我们开发所需的资源投入更少。所以像苹果这样的情况,他们综合考虑价格因素以获得最大利润的结构正在发生。实际上,聪明地想,小容量也可以用,比如 256GB 对不怎么拍照或录像的用户场景来说足够了。
研究存储的人绝不会把存储填满再用。像我,只用了 10%-20%,其余都上传到云端,本地存储尽量小以求最快速度,我是这种思维方式。但最终,用户… 看看实际大数据,用户几乎 90% 以上都是把存储填满在用。他们不加思考地不断安装应用,不卸载,也不怎么用,就这么装着装着,容量就变大了,我们靠这个赚钱(指卖大容量),但从存储研究角度看,这不是好方向。应该定期适当卸载应用,或者做碎片整理,保持系统清洁,这样反而能维持性能并长久使用。我们也在观察用户使用模式,以此为基础来打造设备。
回到最初的问题:设备开机时如何运作以及运行时软件如何管理给定的硬件(CPU、DRAM、存储),从而最小化或最大化用户的 I/O 延迟、带宽或基准测试(虽然不重要)等指标,这是必要的,也是我的主题所在。
最后想简单说明一点的是:系统是如何构建的。Android 首次启动时,最终是如何配置这个设备并使 Android 这个操作系统良好运行的?这是第一个问题。结论是:启动时有两种模式。一种是快速模式(fast mode),bootloader 首先启动。bootloader 是一个非常小的代码,称为 boot ROM,这个二进制文件几乎内置于 SoC 中。SoC 内的 boot ROM 执行第一条指令。这有好几个阶段:boot ROM 代码开始运行,然后调用 boot loader。boot loader 只是运行一个简单的循环,是一个简单的单线程二进制文件。它通过调用启动 bootloader。
boot loader 首先做的工作是设置设备,以及在存储上如何构建 Android 镜像。例如,创建什么分区、创建什么 GPT 表、大小如何、设备上的逻辑单元或命名空间如何配置,以便 Android 之后使用。这些复杂的协商是在 Android 和设备制造商之间共享进行的,以构建系统。所以第一阶段是 bootloader。
bootloader 提供了所有刷写(flash)的基本命令。在那里构建 Android 系统时,例如,bootloader 这样首次加载后,接下来就是配置存储。它进行分区操作。最基础的有两种分区:一种是只读分区,另一种是用户可读写分区。只读分区有多个,剩下的是可读写分区。这个可读写分区通常就是我们说的数据分区(data partition)。所有新写入的数据,包括应用、图片等等,都存储在这里。所以用户数据存储在这里。
其余的只读分区通常是系统分区(system partition)。这些包含了系统库、Android 启动所需的所有脚本、二进制文件和库等。最初,bootloader 的工作就是格式化这个系统分区并刷写镜像。所以从软件角度看,我们构建 Android,从源代码构建 Android 源代码时,会生成多个 .image 文件。其中 system.img 就是用来刷写的文件。在 bootloader 中顺序写入。
启动时,bootloader 完成它的工作后,接下来就是加载内核(kernel)。所以它将包含在内的内核镜像加载到 DRAM 中,解压缩,然后启动该内核。在这个阶段,首次加载模块(modules)等所有事情都发生了,磁盘也被初始化了。接下来,挂载那些只读分区。然后 Android 主屏幕出现,GUI 开始运行,Android 就启动了。同时,数据分区也需要格式化,格式化成新的以便写入新数据。我们称这个格式化过程为“擦除数据”(wipe to data)。数据分区如果完全擦除,就变成空状态。在首次启动时,会将其格式化为特定的文件系统。格式化之后,挂载数据分区,它就进入“就绪可用”(ready to use)状态了。这时用户开始输入密码或进行首次设置。
我解释这两个分区的原因在于:在这两种分区(只读和可读写)上使用什么文件系统,这是首要目标。例如,过去直到现在,只读分区和可读写分区使用的是 ext4。使用 ext4 时有一些问题点:首先,稳定性肯定有保障,非常稳定。但它是一个相当古老的文件系统,要加入新特性非常保守,添加起来非常困难。而且它状态复杂,我们想在 Android 中精确优化 Android 特性与文件系统特性(通常称为垂直优化 vertical optimization),但 ext4 成了巨大的瓶颈,需要大量资源。所以我们就开始考虑:换个文件系统吧。于是 F2FS 诞生了。
所以最初的 F2FS 版本非常简单。正如教授所说,只做顺序写入,没想太多。但随着时间推移,为了解决实际问题,特性一个个加进来,现在变得有点复杂了。但最终,所有这些过程都是为了对 Android 工作负载进行最优化的。这是与 ext4 的最大区别。ext4 在数据中心被大量使用,但实际上数据中心使用的特性并不多。例如,我和 ext4 维护者聊过,也聊过在云中怎么用。实际上云里不用 ext4,而是用类似 ext2 的东西。所有东西都经过缓冲,不用新东西,目标只是保持稳定。那些额外的特性是通过在 ext4 之上使用分布式文件系统来实现的。最终导致 ext4 本身不再发展。
回到 Android,我们拿着 F2FS 的初期版本,针对 Android 进行优化,并且最初是作为为闪存创建最友好布局的工具开始的,到现在添加了更多的特性。
这里想说的是:对于只读分区,现在也有各种话题,比如是用 ext4、F2FS,还是最近出的另一个文件系统 erofs?有这样的讨论。但对于可读写分区,F2FS 现在已经是默认了。例如,三星等主要 OEM 厂商现在都启用了 F2FS 并出货(shipping)。这是当前状态。整体趋势对于 Android 设备来说大致如此。
那么,我们主要关注的是可读写分区(大部分是)。所以重点是如何快速写入用户数据。从性能角度、稳定性角度,还有生命周期角度(因为闪存寿命很重要)。我们从这三个角度出发,用 F2FS 开始了工作。下面介绍第一个特性。
第一个特性是原子写入支持(Atomic Write Support)。这是大约 4 年前在 Pixel 上首次支持的。就是说,SQLite 是 Android 中最大、最重要的数据库引擎。使用它时,需要满足一个关键属性:原子性。当你在一个数据库文件中的多个位置(多个块)更新数据时,这些更新内容要么全部应用,要么都不应用。比如突然断电时,要么看到所有更新后的内容,要么都回滚到之前版本。这就是“全有或全无”(all or nothing)。为了保证这个属性,SQLite 默认的做法是创建两个文件:一个是原始数据库文件,另一个是日志文件(journal file)。它先将新更新写入日志文件,等数据完全保证写入后,再更新原始数据库文件,然后删除日志文件。这样就能保证要么显示所有更新,要么回滚到原始状态(因为有原始文件)。
这样做的问题很明显:需要写两次(write twice)。而且每次写,为了在断电后能恢复,都必须调用 fsync
。在 Linux 中,要 100% 保证数据确实落盘,只有 fsync
或 sync
这两种方法。这个案例中,因为需要为两个文件保证这种属性,所以需要发两次 fsync
:写日志文件后 fsync
,然后更新原始文件后再 fsync
。如果在两个 fsync
之间断电了,要么全丢旧的,要么如果新的写成功了,就重新应用新数据… 这样管理。但这种开销非常大。因为从数据并行(parallelism)角度看,连续写一大段数据,和写一点、等一等(等待 fsync
完成)、再写一点,两者之间的差异是巨大的。因为存储(闪存)端的延迟已经是毫秒级别了。主机端用相同的 IO 模式、相同的顺序写,如果分成多次发送并且中间有延迟,与一次性发送一个大 IO 到磁盘相比,在数据传输(DMA 传输)过程中会被打断。一旦被打断,性能差异就会非常大。
考虑到所有这些,性能优化的最佳方案是减少 IO 写入次数。所以我们首先考虑的是:修改 SQLite。为什么要 SQLite 做这些?文件系统能不能做?文件系统掌握着所有恢复的钥匙。文件系统控制并管理一切。因此,文件系统可以充分支持它。具体来说,就是文件系统能否支持基于文件的原子写入(file-based atomic write)。意思是:用户创建了一个文件,进行了更新,在文件中随机偏移更新了数据,文件系统能否保证这些数据要么全在,要么全不在?如果文件系统支持这个,那么 SQLite 之前那些创建两个文件、这里写一次等 fsync
、那里写一次等 fsync
的操作,就可以减少到一个操作,性能几乎能提升一倍。
基于这个想法,我们实际实现并支持了。解决方案很简单:将这些待更新的数据在页面缓存(page cache)中累积一段时间,不立即应用到磁盘。当 SQLite 说事务完成时,再一次性刷写它们,同时做标记以便恢复。这就是第一个特性。我们实现了这个,并与 SQLite 维护者合作,正式地让 SQLite 在启用 F2FS 的这个特性时,以这种方式工作。这是第一个应用的特性。
但是,解释这个的原因在于,如刚才所说,第一个版本是把所有东西都积压在 DRAM 里。这会导致最坏情况。例如,用户开始一个事务(transaction),打开一个文件,持续写入而不提交事务(commit)。所有数据都会堆积在页面缓存里,既不能回收也不能失效。这样下去,内存压力增大,设备可能崩溃。这相当于一个安全漏洞。实际中这种情况不会发生(除非是恶意程序)。但正因为考虑到这种极端情况,我们一直在思考,并在今年做了新版本。
在原有方案中,我们增加了可以刷写这些数据的能力。关键的不同想法是利用“临时 inode”(temp inode)。Linux 文件系统端有一个通用特性,VFS 特性,叫做临时 inode。临时 inode 的属性是:它不会出现在目录项(directory entry)中。这个 inode 是虚拟的,关闭(close)后,这个 inode 就无处访问,直接消失了。可以认为它是一个临时存在的 inode(volatile inode)。我们可以临时创建这种 inode。利用这个 inode 结构,我们可以临时持有那些数据,而不仅仅是占用内存。当内存不足(memory flash)时,必要时可以将这些数据写入磁盘,同时保证原子性。这样就能实现相同的功能。所以这个功能现在新加了进去,解决了之前提到的内存压力问题。这就是第一个特性:原子写入(Atomic Write)。
第二个特性几乎是强制性的或推荐性的:加密(Encryption)。Android 的安全团队非常非常强大。他们会提出很多奇怪的要求,但通常会导致性能下降。安全总是以性能为代价的。我们只能硬着头皮做。如果说“做不到,性能会下降”,那没得谈,他们只会说“必须做”(just do it)。其中第一个就是加密。文件级加密(File-Based Encryption)简单来说就是按文件进行加密。你打开一个文件,给它分配一个加密密钥,然后所有对该文件的读写都需要加密/解密。没有密钥,就无法读取数据(因为看到的是加密数据)。现在大部分手机都用这个,因为高通、三星等都这样做了。这个特性在 3-4 年前就成了默认。
但为什么要按文件加密呢?问题在于这个密钥是为谁准备的?你们第一次启动手机时输入密码吧?那就是密钥。一个密钥对应一个密码,所以每个用户会生成一个密钥。但无法用这个密钥加密整个设备。因为像系统库,Android 本身需要读取。我们只想加密用户内容(user content),不能加密全部。所以只希望对包含用户内容的文件进行加密。这就是文件级加密出现的原因。
早期版本的文件级加密完全是软件实现的。给你密钥,开始操作时,比如加密 4K 数据,解密 4K 数据,都是由 CPU(软件)完成的。这意味着消耗 CPU 周期,导致功耗增加、延迟上升。最后,当 IO 完成时,还需要解密。这会导致上下文切换发生。在 IRQ 处理路径中,如果需要进行解密,在那个上下文中可能无法完成,因为需要用到互斥锁或可能导致休眠函数。必须使用工作队列(workqueue),切换到另一个上下文,在那里解密,然后再切换回原来调用文件系统的上下文,返回 IO 完成状态。这些问题最近才充分暴露出来。
最终,在工作队列中进行这种上下文切换的延迟,如果超过 100 微秒,或者因为其他实时线程或线程调度原因导致延迟增加,最终 IO 完成本身就会大幅延迟。这是一个问题。第二个问题是,从服务质量(QoS)角度看,这种延迟的增加是不确定性的,这成了另一个问题。最近 Android 性能团队要求:必须消除这种延迟。性能好固然好,但这种延迟绝不能是非确定性的。所以性能团队要求:无论如何要避免使用软件工作队列。最简单的办法是让硬件来做。于是硬件开始支持。
fscrypt 是文件级加密,但最初是用软件实现的。当硬件支持时,我们就添加了“内联加密”(Inline Encryption)特性。内联加密特性启用后,如果硬件支持,就不再需要使用工作队列。读取的数据在返回时就已经是解密好的了。工作原理相对简单:密钥等东西已在内核中。硬件需要做的:加密引擎位于 UFS(存储控制器)内部,更准确地说是在 SoC 内部的 IP 里。有硬件加密引擎,并且旁边集成了可以存储密钥的寄存器。
当你想加密某些数据时,在最终通过控制器向存储发送 IO 时,会附带发送信息:“用第 X 号槽(slot)里的密钥加密这个”。控制器在传输数据的过程中进行加密,UFS 存储最终写入的就是加密数据。读取也一样:读取时也说“用第 X 号槽的密钥解密这个”。因为是硬件处理,所以当软件收到 IO 完成(bio completion)中断时,数据已经是解密好的状态了。这样就减少了这种开销。现在几乎所有 Android 设备都以这种方式工作。
另一个新东西是验证(Verity)。我们做了加密/解密。那么另一个用户场景是什么?假设黑客攻击。还记得最初提到的只读分区吗?也就是存放系统库的分区。它们没办法(加密),刚才说的加密解密是针对可读写分区的。有两个分区:只读和可读写。可读写分区用密钥进行加密/解密操作。但系统只读分区那边没办法,目前不需要加密/解密,因为不写入。那这边怎么处理?有什么问题?
问题是完整性检查。我们最初刷写的是我们想要的镜像。但如果有黑客把其中某个特定块替换掉,把它改成总是跳转到某个 PC 地址的代码(具体怎么做的我不知道),黑客这么做了,然后启动设备。例如,我拿走教授的的手机,为了替换几个块而重新刷写,然后再启动。启动时输入了密码,那一刻,只读分区系统库中的代码(被我替换成了病毒或恶意代码)就可能读取密码。为了防止这种情况发生,只读分区有“验证”(Verity)功能。Verity 是关于完整性检查的。检查上次写入的数据,再次读取时是否真的相同。
这个特性是作为设备映射器(device mapper)的一个目标实现的。所有 Android 设备都在用。结构是:原始数据写好后制作镜像,然后在镜像末尾附加一些信息。什么信息?是一个哈希值树(hash tree),称为默克尔树(Merkle tree)。一个哈希值对应从 0 开始的每 4K 数据。所以有一串哈希值列表,直到结束。把这个列表做成树状结构,写在镜像末尾。刷写时,是把原始镜像加上这个哈希树一起整体刷写进去。
启动时,设备映射器做的第一件事是读取末尾的哈希表,然后 Android 启动过程中开始读取分区数据。每次读取时,它重新计算新读取数据的哈希值,与树中原有的哈希值比较。如果不匹配,就认为数据被篡改了,设备会在启动过程中立即停止(panic)。用户根本看不到启动界面。偶尔会看到设备提示“Your device is corrupted”(你的设备已损坏),无法启动,大部分情况都是只读分区这边哈希不匹配造成的。简单的情况是刷写中途中断导致刷写不完整,或者确实被黑客篡改了。这是为了阻止这些情况。
但这是设备映射器针对整个磁盘的。现在有一个类似的特性:基于文件的验证(File-Based Verity)。意思是,对单个文件,检查这个文件是否真的没被改动过?这通常是针对包文件(package file)的。例如,你们安装新应用时,首先是从 Google 服务服务器下载 APK 包文件。下载后,里面根据某种格式包含了库文件、应用更新文件等所有东西。最初是好的,但如果有人中途篡改了数据,做了和 DM-Verity 类似的事情。安装那个应用时,它就可能做坏事。为了防止这种情况,我们为 APK 文件本身添加了基于文件的验证功能。
启用文件验证后,原始文件末尾同样会存储一个哈希树。每次读取该文件的任何数据时,都会计算哈希并与存储的哈希比较,进行完整性检查。如果不匹配,就返回 I/O 错误。这样,如果被篡改,应用就会被拒绝安装(弹开)。这是与 Android 包管理团队合作进行优化的基础之一。
下一个问题是 OTA(空中下载更新)问题。OTA 是什么?就是更新。新镜像来了,下载一个这么大的包,下载后如何将现有 Android 设备更新到新镜像?这时出现的问题。简单的方法是像之前说的,有只读分区和可读写分区。只读分区如果在写入中途失败,就无法恢复,因为写入失败就全丢了。但现在做 OTA,例如…(此处重复“패키지에 어떤 패키지에”可能是口误或强调过程复杂),这有点复杂。但如果在 OTA 过程中,用户… 比如在写入过程中突然断电,那就完全无法恢复。用户只能找客服中心要求退货。通常简单处理,Google 总是很爽快地给换新机(happy to give new one)。这是小贴士:Google 退款很容易。有问题就寄回再拿新的。总之,为了最小化这种情况,方法很简单,还是那个“全有或全无”的问题。
有旧版本的只读分区和当前运行的新版本。如何保证要么完全切换到新版本,要么完全回退到旧版本?为此,简单的方法又是划分区域。划分出两个相同的分区集合(two sets)。在一边写入当前使用的状态时,在另一块相同大小的空间里写入新下载的镜像,然后进行切换。这样,在某个时刻,总有一个是有效的。这样来回切换。这在 Android 中称为 AB 更新(A/B update),通常使用这种方案。但代价是容量翻倍。这总是个问题。但无论如何,对于只读分区目前没有其他办法,一直用 AB 更新。
那么数据分区(可读写)怎么办?例如,系统分区从 A 成功更新到 B。假设用户从 Android P 升级到 Q。更新完成了,系统都更新到 Q 了。首次启动后,应用也开始更新… 运行中设备突然崩溃了。设备很自然地回滚到了旧版本 P。但数据分区已经更新了一半,应用无法识别旧版本的数据,开始崩溃。这曾是个大问题,尤其像从 P 到 Q 这样有大变更的版本跳跃时。系统能运行,但数据残留异常导致一切崩溃。
为了解决这个问题,我们创建了 检查点禁用(checkpoint=disable) 特性。这是一个挂载选项(mount option)。意思是,这时(OTA更新时)是 F2FS。文件系统像控制恢复一样,在这个时间点对整个文件系统的状态进行冻结。以这个点为基准做标记。更新继续进行,如果没有收到完成信号就重启了,就忽略所有更新,回到这个点。控制这个时间点的就是挂载选项 checkpoint=disable
,它标记这个点。如果更新完全成功,Android 会通知“重新启用检查点”(checkpoint enable again),即使通过重新挂载(remount)给出这个选项,这个空间也会完全清空。从那时起开始使用新版本。在这期间如果发生什么(比如重启或卸载),期间写入的所有内容都会被丢弃(discard),也就是回滚(rollback)。我们加入了这个特性,解决了 AB 更新 OTA 的问题。
下一个是大主题:我们加入了压缩(Compression)。这里可以看到支持大约三四种压缩算法。设计是基于簇(cluster)的。例如,你想压缩某个文件,就给它设置一个标志“压缩它”。如果它被设为候选,并且写入是从 0 开始的顺序写入——这是最好的情况,所有数据都在页面缓存中。假设设置了簇大小(默认是 16K?4个块?)连续 4 个块幸运地在页面缓存中,并且写了这 4 个块,如果压缩后变成 2 个或 3 个块,就进行压缩。把 4 个块压缩成 3 个块,然后只写 3 个块到磁盘。接着检查下一个 4 块簇,如果能压缩成 3 个块就写 3 个,如果压缩效果不好,就用 4 个块原样(plain text)写入。这样进行压缩。
设计说起来容易,实现起来困难很多。例如,必须在页面缓存中锁定 4 个页。这实际上是最大的顾虑。因为如果没压缩,可能只需要写第二个页。或者,如果同时发生读取请求,而这个锁被持有,读取服务就会被阻塞。这会导致并发随机读取的性能变差。压缩本身就有明显的优缺点:压缩可以减少空间占用,但会降低性能。对于随机读取,尤其不利,因为可能需要读取整个压缩块(4个块)才能解压出所需的那一个 4K 数据。
所以,最需要压缩的文件是哪些?最终最大的问题点,以及我们设为候选的文件是这些(指幻灯片):APK 包文件。关键点是“写一次,读多次”(write once, read most),且几乎没有随机读取,主要是顺序读取的情况。为什么必须这样?有一个问题:我们压缩是为了节省空间,但实际上不能释放空间(cannot actually release space)。例如,写了一个 1GB 的文件,压缩得很好,省了 500MB,但不能把这 500MB 还给用户,因为不知道什么时候会更新。文件被写入后,总要更新吧?如果更新时需要解压缩导致膨胀(需要更多空间),而空间不够,就会出现“空间不足”(no space)错误。这问题无法回避。
问题在于无法释放空间。那怎么才能释放?我们绞尽脑汁的结果是:必须找到那些不会更新的情况(scenarios where updates don’t happen)。压缩效果好,写入是顺序的,压缩也好,但之后不再更新,这种情况最理想。顺序读取也主要发生。符合这些条件的就是这些文件:APK。从服务器下载安装后,压缩率(compression ratio)非常高,接近 30%。写 1GB 能压缩到 700MB,压缩率很大。还有 .so 库文件(library files),只写一次,然后只读的情况。Apex 文件也是一种,是一种库的包文件,详细解释起来有点困难(可能需要查 Android 文档),从模式看也是只写后读。ODEX 和 VDEX 文件是知道的,它们用于 Java 编译(dexopt)等过程,写入后通常会被删除或类似处理。
这里的关键点是:需要用户(Android)告诉我们。Android 需要说“我不会更新这个了”。所以,我们加入了这个特性(signal)。然后 Android 的包管理器管理这些应用,我们在那里添加代码:如果文件被压缩了,就回收空间。“我不更新了,之后只删除”。这样双方协作进行了更新。现在,在下一个 Android 版本中,这将成为这些类型文件的默认设置。所以正式支持这些文件。
除了这些,当然可以压缩更多候选文件。但问题是,那样做的话,中间更新的稳定性问题、如何测试等问题。所以我们暂时只压缩了那些最确定的文件。有趣的问题是:这样做了之后,又出现了前面提到的性能问题。读取时,发出 IO 请求,IRQ 触发,中断上来… 发现这是压缩数据,需要解压缩。这又需要工作队列!如最初所说,解决这个问题的唯一方法是避免使用工作队列,确保路径上没有任何会导致休眠的函数。如果在 IRQ 上下文中能无休眠地(sleep-free)一路执行完,就能解决。
第二个方法是使用硬件。解压缩目前不能用硬件做。内联加密用硬件解决了。验证也还没解决。对于解压缩路径,我们当时能做的就是移除所有可能导致上下文切换的点。例如,页面分配可能导致上下文切换。我们在该路径上去除了所有可能导致休眠的位置。然后,当收到 IO 时,直接调用解压缩库函数,在同一个上下文中连续执行。我们现在已经打上补丁,正在测试。
另外,对于解压缩,还有一个选项:压缩缓存(Compressed Cache)。我们又加了一层缓存。文件系统有基于文件的页面缓存,现在在磁盘缓存之上(类似旧的缓冲区缓存 buffer cache 概念,我们现在不用 buffer cache),只为压缩相关的特定数据添加了一个缓存。这样,当需要读取磁盘上压缩数据时,如果这些数据已经在内存缓存中命中(hit),就可以直接用缓存数据进行解压缩,避免 IO。这样,如果缓存命中率高,不用做 IO,CPU 直接解压缩,随机读取性能就会变好。是的,这目标就是改进随机读取性能。
哦,这个(指幻灯片)是之前提到的支持只读分区所做的。所以现在,对于写入,有几个数据结构:SSA(Segment Summary Area)啦,或者用于 GC 的,或者 SIT(Segment Information Table)啦。对于纯只读分区,这些其实是不需要的。所以我们一直在减少这些分区所需的最小尺寸(minimum requirement)。但对于系统分区没问题,它有几 GB 大小。但这里(指幻灯片上的 Apex 等)支持只读涉及到 Apex 文件。Apex 是在用户数据分区更新的小文件包文件,大小约 10MB、5MB。目前这些文件是 ext4 或 erofs 支持的,F2FS 目前还无法很好地处理(存在限制)。所以介绍一下。
这样大概还剩三四个主题?是的。F2FS 在去年左右开始关注垃圾回收(Garbage Collection, GC)。GC 会产生碎片,清理多少碎片才好?我们一直在处理这个问题。在此期间,Chaoyu 加入并带来了一个新特性:年龄感知垃圾回收(Age-threshold based GC)。我们长期使用两种 GC 算法:贪心算法和基于成本效益的算法。相关论文你们可能知道,内容简单。他在其中加入了“老化”(aging)概念。因为单靠基于成本效益算法很难精确找到 GC 决策点(GC victim)等等。他通过改变公式添加了这个特性。我对算法细节研究不深,只做介绍。感兴趣可以看补丁和代码。
接下来是各种挂载选项(Mount Options)。当然都是有原因才加的。第一个是 mode=adaptive
这样的挂载选项。如教授所说,这是关于块分配策略的。最初是纯日志结构文件系统(Log-structured File System, LFS)方式,顺序写入。但某个时刻开始质疑:只坚持这个能行吗?性能上看,因为在脏数据非常多的情况下,坚持 LFS 方式,分配一个新段需要迁移海量数据,设备能否承受?设备当然… 设备当然… 以前设备慢,问题可能更严重,但现在可能减轻了。但总之我们开始考虑。
第二个想法是:利用空洞回收未使用空间可能效果不错。因为与其只做 GC,不如在空洞中再利用,最终它们也能体现 GC 的效果。如果碎片被整理,就产生一个完全有效的块。碎片这样被填满,其他地方的空白空间就会相对增多,平均有效块计数就会减少。所以我们添加了 SSR(空间碎片回收,Space Shrink Reclamation)模式,然后加入了自适应模式(adaptive mode)。
在此之上,我们还开始加入就地更新(In-place Update)。为什么?因为在 SQLite 这类情况下,会有更新数据并调用 fsync
的场景。在这种情况下,如果把数据写到别处,就需要更新指向它的 inode 元数据信息。最坏情况是:更新 4 个数据块,需要更新 4 个节点块,总共写 8 个块(两倍于数据量),性能会很差。相反,如果能在原位置覆盖写入(overwrite),像旧方式那样,就不需要更新元数据。只更新原始数据块即可,写量减半(写量最少)。但代价是发生随机 I/O。
这里存在权衡:写 4 个块,一种情况是发生 1 次随机写入(量=1),另一种情况是发生 2 次顺序写入(量=2)。哪个更好?这非常令人纠结。最终,这个问题取决于存储设备上顺序写入和随机写入的性能差异有多大。如果顺序写入快很多(如图中差距大),顺序写两次更好。如果差距小到几乎一样(如图中接近),写两倍的量可能开销更大,而快速完成一次随机写入可能更好。这种现象在 SSD 或高端设备上出现,因为 SSD 会缓存,实际写入延迟被掩盖了。所以随机写入性能变好了。于是我们想“这个对吗?那个对吗?”,现在我们开发的高端设备正朝着这个方向(类似 SSD)。顺序写入能达到 1GB/s 以上,启用 SLC 模式甚至更快,随机写入也因为大量缓存而快多了。
所以默认模式允许这种就地更新。但与过去不同的是,不是所有情况都做,只在需要时才做。例如,在调用 fsync
的情况下,或者某些特定场景触发时才做。或者在进行 DIO(Direct IO)时,因为 DIO 就是想直接操作,没必要像缓冲写那样转换成 LFS 方式,所以 DIO 时就允许它就地更新。这样,性能因素随着时间推移发生了变化,这些模式又被添加进来。这个模式也是块分配策略的一部分。
后来我们又加了两个选项:fragment=segment
和 fragment=block
。这是干什么的?我们测试时总被问:碎片化时的性能究竟如何?要回答这个,问题又变成了:用什么模型让设备碎片化?用什么场景测试?跑什么基准测试?不同人随机写,性能差异很大,顺序写还是随机写情况也不同。所以我们添加了这个选项:强制制造碎片化(Force Fragmentation)。可以选择按段单位随机分配,或者按 4K 块单位制造完全随机碎片。设置这个选项后跑测试,无论之前是顺序写还是什么,块分配会变得完全随机。删除后会产生大量脏段。用于测试目的。
另一个是分配模式(alloc_mode)。这决定了分配大单元(段)的顺序。当前默认是尽量接着上次分配的段后面分配(保持局部性 locality)。这样局部性好。但我们在考虑一个特性:存储(闪存)的速度在不同的区域是不同的。例如,有些空间是 SLC 模式,主机可以自由访问 SLC 空间的 API 正在增加。UFS 规范中已经有“寿命加速器”(Life Booster)功能,你可以告诉它“我想把这个数据写到 SLC”,它就会进入 SLC。这样,主机开始感知数据的存放位置,存储就分层了:快速的 SLC,慢速的 TLC,非常慢的 QLC。以后甚至可能有网络盘或速度不同的多个块设备。如果 F2FS 把它们捆绑成一个,就需要考虑数据放哪里。
为此添加的最简单方案是 alloc_mode=reuse
选项。意思是,新分配空闲段时,总是从 0 号开始分配。其他含义是,如果你想只在前部区域用 SLC,就可以设置这个策略。用 alloc_mode=reuse
,新分配总是从 0 开始,这样就能尽量用 SLC。当 GC 进行时,自然会把前部的块迁移到后部(需要改变 GC 算法),这样前部区域总能保持空闲,通过 alloc_mode=reuse
分配就能一直用快速的 SLC 存储。预期有这个效果。
另一个用户场景是低端设备(low-end devices)。例如 4GB 这样的小设备,Discard 命令对它们至关重要。所以总是尽量从前面开始写,这样写到后面时,有些块就完全没被触碰过。从设备角度看,这些是全新未用过的空间。GC 时效率会高很多。所以低端设备也能用这个选项。
第三个是内存相关的。这部分是我们之前提到的在解压缩路径中消除工作队列时遇到的问题。一个问题是:如果在软中断上下文进行解压缩,为了避免内存分配,我们预先在发出 IO 前分配好内存。IO 结束和解压缩完成后,必须释放该内存。这导致内存被持有的时间变长,内存占用上升。对于高端设备没问题,但低端设备可能出现内存压力问题。所以我们添加了这个模式:对于低内存设备,启用这个挂载选项。因为内存压力问题更严重,这时要优先解决内存问题,恢复使用工作队列。当然也可以选择不用压缩。但如所说,低端设备喜欢压缩,不过如果内存压力问题更重要,就必须使用工作队列,接受性能损失。这就是 compress_mode=fs
选项。
最后是 fsync_mode
。fsync
时有很多考量。因为 fsync
需要等待 IO 完成,这通常用于非常重要的操作。Android 只在数据确实重要、断电后必须存活、必须保证落盘时才使用。常见用例是原子文件这个 Java API。Android 提供了这个 API,上层调用时认为它是原子的,但底层实现可能在做日志等各种事情。我们计划用 F2FS 的原子写入替换它。或者 SQLite 的 WAL(Write-Ahead Logging)模式也会用到。
在这些用例中,fsync
非常频繁,延迟极其重要。所以我们在 fsync_mode
中加入了几个选项。posix
模式是目前只保证 POSIX 要求的最小语义的实现。strict
模式则提供更多保证。例如,目录项中文件被删除的情况。如果 fsync
了目录,POSIX 不要求恢复那个删除项,但某些特定情况(如重命名)下,ext4 过去会恢复它。于是有人问“为什么你们不恢复?” 所以添加了 strict
模式,提供更多恢复的可能性。
最后是 nobarrier
。这也是关于 POSIX 移除 sync_cache
的。sync_cache
是什么?文件系统写入后,即使 IO 完成了,也不能保证所有问题都解决了,因为数据可能在磁盘内部的缓存里,比如 SRAM。文件系统说“IO 完成了”,告诉用户“这肯定持久化了”(persisted),但磁盘 SRAM 里的数据如果断电就会丢失。所以文件系统在写完后,在 IO 完成前,必须再发一个命令来刷新磁盘缓存。这个命令就是 sync_cache
。在 Linux 块层这里是 barrier
。
问题在于,在设备端(尤其是 FTL),sync_cache
开销巨大。非常大。发出 sync_cache
不仅会清空 SRAM,还必须同时写入 FTL 等各种映射表。每次写都要多写一倍甚至更多的元数据。如果频繁发生 sync_cache
,写元数据的开销就太大了。写量即使不大,如果太频繁,也会严重磨损寿命,甚至在某些固件设计失误的特定厂商设备上,出现过因写元数据导致设备完全死机的情况。
所以我们当时的提议是:sync_cache
真的那么关键吗?从性能角度,因为高端 SSD 这类设备缓存很大。但现在设备端,SRAM 也就几百 KB。再想想 fsync
时断电的情况:用户调用 fsync
返回后,在用户能做任何事(比如认为“好了,现在安全了”)之前,电源就断了。那短短几毫秒内断电的话,fsync
失败和电源失效是等效的。所以从电源失效时刻看,fsync
失败了。
所以,为什么必须刷新?对于 fsync
这种特定情况,我们建议:先别做 sync_cache
试试看。系统崩溃时 sync_cache
是必须的,但针对 fsync
这个特定情况,先别做。于是我们设置了 nobarrier
选项,不发送 sync_cache
命令,只等待 IO 完成就结束。用户当然等待了时间,但电源失效如果真的发生在几毫秒内,也可以认为 fsync
失败了。基于这个逻辑,我们在实际设备上测试了,至今没出问题。所以默认就用了 nobarrier
的 fsync
。
接下来是 GC 性能。GC 收集性能有多大影响?把 GC 做好,回收大量空闲空间当然好。碎片整理就像旧磁盘整理碎片。问题在于什么时候做?更进一步,用户有设备空闲的时候吧?那时做不行吗?于是开始了。
幸运的是,Android 有一个服务,叫做空闲维护(Idle Maintenance)服务。这个服务在凌晨 3 点左右启动(大家睡觉时)。它会唤醒设备检查状态:是否在充电?电量多少?用户真没在用吗?等等。这些 API 都有。那个服务启动时,如果是空闲状态,就该做些事。最初版本是:它在凌晨 3 点启动,运行约 1 小时。代码上看到“现在空闲了”,就该做点什么。以前 ext4 时代,那时做的是发送 Discard 命令(FS Trim)。它会扫描整个磁盘,向所有有空洞的块发送 Discard 命令。大约花 2 分钟或几分钟。为什么做这个?因为 ext4 有个先天问题:Discard 会影响用户 IO。如果 Discard 慢,比如删了一个文件,ext4 会立刻开始 Discard 那些空间,这会阻塞其他 IO 操作。有时删除操作会卡几秒,因此收到很多投诉。根本问题是解决这个,ext4 后来关闭了运行时 Discard。那么 Discard 就不发送了?然后在新服务(凌晨 3 点)扫描发送 Discard。这是 ext4 维护者提议的方式。
但这又带来问题:运行时如果完全不 Discard,脏数据持续累积,设备端 GC 会持续发生,这对设备合适吗?所以 F2FS 最初要解决的第一个问题就是:在运行时发送 Discard 时,不能成为其他 IO 的瓶颈。所以 F2FS 做的第一件事就是创建 Discard 线程。这是 F2FS 早期战胜 ext4 的第一招!那个问题就解决了。
现在凌晨 3 点为什么 F2FS 需要运行服务?背景就是这样。有了它,F2FS 不太需要做 Trim,因为我们在运行时做 Discard。但除此之外,在那个宝贵的时间里只做 Trim 吗?不如也做 GC 吧?F2FS 的 GC 怎么做?当前 F2FS 的 GC 有两种方式:前台 GC(foreground GC,实在没办法时才做)和后台 GC(background GC,有一个线程在等待运行)。后台 GC 最大的问题是:系统挂起时它无法运行。系统挂起,线程也挂起(冻结 frozen)。真正空闲的绝大部分时间,设备屏幕是关的,这意味着很可能进入挂起状态。屏幕开着时用户可能在用,后台 GC 也难运行。所以人们怀疑它到底有没有在运行。
于是我们开始研究那个凌晨 3 点的服务,并在其中添加了 GC 紧急(GC Urgent) 系统调用。向它写入 1,它就全速运行 GC,不管其他,100% 占用线程疯狂做 GC。有趣的是,它非常快。即使在碎片化严重的情况下,运行几小时(别说一天从 3 点开始跑 1 小时)也能清理掉巨量碎片。我们最初就这样开始了。当然会出问题:凌晨 3 点就算运行,人可能醒着,或者放着睡觉了,第二天醒来发现电量从 40% 掉光了。于是抱怨就来了:“我电池去哪了?放着睡觉而已”。一查发现是这个服务疯狂做 GC 耗尽了电池。接下来能做的就是加条件。如我前面所说,该服务知道设备状态(电池等)。第一个条件是必须在充电。第二个条件是电量必须高于 80%。第三个条件是用户确实没在用。加了这三个条件后,电池问题解决了。但反过来意味着 GC 没做!所以问题又绕回来了:GC 到底该什么时候做?今年我们终于做了新尝试:彻底重构了那个服务。凌晨 3 点做 1 小时真的合理吗?
于是换成了 Android 的 Mendel 框架。Mendel 是什么?它只搭建好服务的壳(shell),放进设备里。具体怎么做(策略)由服务器下发。这样只需做好设备端运行的部分,因为无需更新设备,服务器可以实时更改。这是关键。那么这个服务需要改成什么样?当然要考虑充电等因素。
当前设计是:服务每隔 1 小时唤醒一次(不再是只在凌晨 3 点)。唤醒后,它查看过去一段时间(比如 20 小时或一天)用户平均写了多少数据?写了 1GB 还是 2GB?然后预测接下来 1 小时的需求。“平均写 2GB 啊,那预留 2GB 的空间吧”。基本思路是这样。但这需要服务器控制,所以还需要其他参数。1 小时间隔没问题。但当时只看过去 24 小时的写入量吗?不是的。同样要看电池、是否在充电。现在还要看存储寿命。快死的设备不能再使劲 GC。还要看存储的脏数据情况。
为此我们创建了一个指标:脏数据率(Dirty Ratio)。脏段的数量除以(脏段数量 + 空闲段数量),得到一个百分比。100% 意味着全是脏段(所有剩余空间逻辑上都是脏数据)。90% 意味着 10 个段中有 9 个是脏的。0% 意味着完全干净。把这个指标放进去,阈值由服务器管理。例如,服务器说“保持脏数据率在 80% 以下”。如果设备报告的脏数据率高于阈值,即使不需要 GC 也得做。基准线是按 1 小时间隔检查,再加上一些基于 AI 的操作。现在已部署。
最关键的问题是:用户真的会产生高脏数据率吗?如果不会,这些工作都白费。脏数据率是否真的影响性能?跑再多基准测试,答案当然是性能下降。但用户对此有多敏感、抱怨多少?这两个问题很难回答,没有现场数据几乎不可能。所以我们一直在做的是:将设备的各种信息(如脏数据情况、存储碎片情况)持续上传到服务器。每月生成图表,显示脏数据率:报告 100% 的设备有多少,90% 的有多少等。有趣的是,有设备报告 90% 以上!这意味着我们需要调优得更激进才能消除它。现在正根据数据持续调优。
除了脏数据率,从性能角度看,还有两个关键指标:启动时间和应用启动时间。应用启动时间也有数据。基于此,我们尝试(很幸运地)寻找相关性。简单说就是:对比大量做 GC 的设备(实际用户数据难获取,通常我们用几百台设备分发给谷歌员工测试最新版本,持续更新,他们发现问题就报告)的应用启动时间。这样接收数据,观察应用启动时间变化,同时看服务器端控制 GC 的条件。现在正在进行中。
这个是次要的。我们有一个叫做“检查点”(checkpoint)的东西,它在 F2FS 中标记整个文件系统的一个一致性状态点。那时会刷写所有文件系统元数据,并创建一个像 AB 更新那样的状态点,可以随时回滚。过去这里存在优先级反转问题。最近 Android 性能团队一直关注 I/O 优先级在哪里?线程优先级、上下文切换如何发生?
例如,相机应用是我们的最高优先级。启动时一卡顿,Clank(卡顿检测工具)就报警。那时几个实时线程开始运行。最初跑起来,内存 Clank 很严重,预留一大块内存,做 CMA(连续内存分配)。如果 CMA 分配失败,相机应用就崩溃,那就彻底乱套了。所以 CMA 分配必须 100% 保证,后来加入了 GCMA(可能以后会加入)。
同时,实时线程:我们有 8 个核心,对吧?两个大核必须给实时线程。剩下的给 CPU 0(小核中最慢的核)。前台应用则给中核或大核。实时线程在大核。
现在这里的问题是:存储 I/O 完成的软中断发生在 CPU 0。一触发就不知道什么时候会触发。CPU 0 上争用非常严重。我们想提高它的优先级,但现在还不行。因为相机需要它的 IT 线程必须运行,如果在那瞬间被延迟或其他进程延迟了它,就会出大乱子。
Android 性能方面有前台应用和后台应用两种优先级。前台应用应该获得更高的 CPU 核心(比如大核)。这基本已实现,使用 cpuset 或 cgroups。我们用 cgroups(块设备 cgroup block CG)。使用块 cgroup 时,需要设置 I/O 优先级等。我们做了工作,给前台高 I/O 优先级,后台低优先级,并让 I/O 调度器感知。
但发生了优先级反转问题:前台 I/O 优先级高,但有时后台进程触发了检查点。前台在等待这个检查点完成。于是,后台进程的检查点阻塞了前台的重要 I/O 操作。为此,我们把检查点进程移到了单独的线程,并设置该线程为高优先级,避免它在后台运行,强制其快速完成。在切换成线程后,有时会出现延迟,检查点变慢。或者同时收到多个检查点请求,中间没操作,这种可以合并。这些情况在单线程时看不到,现在线程化了就能看到,因此诞生了这个特性(合并检查点)。所以后台性能也关注这些优先级问题。
接下来的特性有点测试相关。一个是故障注入(Fault Injection),另一个是关机(Shutdown)特性。用于更好地测试。简单说:故障注入有一个列表,可以指定在哪些操作中人为注入故障。例如模拟内存分配失败,或者模拟分配错误导致的失败。关机特性是通过发送一个 ioctl
命令,让文件系统停止所有操作,冻结在那个点。之后不能再发 ioctl
,只能卸载文件系统再重新挂载。用这两个功能可以做什么?我常用的测试是 fsstress
(文件系统压力测试工具)。它随机生成文件系统操作(创建、删除、更新等)。运行 fsstress
,同时打开故障注入(随机决定哪个函数失败)。在运行时随机注入错误,但不能崩溃。中途突然扔一个关机命令(ffs shutdown ioctl
),冻结在那个点。然后可以检查:比如在检查点中途冻结,能否回滚到前一个检查点?或者 fsync
中途关机,重启后 fsync
的数据是否存活?用这两个操作可以进行运行时错误处理是否健壮,以及断电后恢复是否良好的测试。我们一直在运行这些测试。
最后两个。首先是 UFS(你们手机最常用的存储,高端和中端以上 Android 设备用,苹果有自己的定制 NVM)。UFS 有一个功能是“寿命加速器”(Life Booster),它允许主机指定“我想把这个数据写到 SLC”。所以,如果你想做研究或测试性能,可以利用这个特性。这可以用来验证性能提升或下降。所以了解这个特性并善用是好的。如果转向 NVMe,通过命名空间也能控制,比如 SLC 命名空间(SLC namespace)。所以这算一个。
第二个是支持区域设备(Zoned Device Support)。最近社区讨论很多,Linux 社区也是,NVMe SSD 也支持区域设备了。数据中心/云那边,F2FS 其实不用。他们自己构建所有软件,包括分布式系统。对于他们,区域 NVMe 设备只是一个新玩具。但考虑到它的优势,比如在移动端为什么不用?F2FS 默认就支持区域设备。在真实区域设备上运行 F2FS 也没问题。所以从这个意义上,我们现在推动所有 Android 设备驱动支持区域设备。例如,垃圾回收在设备端做,放到主机端做,最大的好处是主机能保证服务质量。第二个好处是考虑随机读取:在 Android 设备上,有趣的现象是,最重要的 I/O 是什么?过去通常厂商认为:顺序写、顺序读、随机读、随机写都快就行。但现在我们总说:用户真正看重的负载是读操作,而且是小块读取。最重要的优先级是随机读取的 IOPS,而不是带宽。延迟是最重要的。而区域设备能最大化降低延迟。因为使用区域设备,映射粒度变大。以前块映射,所有映射都在 SRAM 里。当前传统方式是页映射,导致按需读取(on-demand reading)。如果使用区域设备,映射可以更大粒度地加载到 SRAM,随机读取的延迟可以大大降低。这是最大优势。
我们总说,Android 设备的工作负载中这最重要。为什么?因为启动时间也是读操作。应用启动时间也是读操作。块大小很小。哒哒哒地读。这种情况下越快,性能提升越明显。在这种环境下,区域设备很重要。
代价是:区域设备上,主机端垃圾回收可能导致写入延迟变长。那么写入性能比读差。如果主机能控制最大写入延迟,为什么不行?因为我们并不要求极高的写入带宽。所有东西都是缓冲写入,在后台刷新。如果写入速度从 800MB/s 降到 700MB/s 也没关系(现在都很快)。只要不出现长延迟就行。控制长延迟,主机端 GC 可以做到。因为主机内存大(8GB、10GB DRAM),可以在内存中暂存数据,延迟或批量处理很自由。而且冷热数据分离由文件系统做肯定比设备好(设备没上下文)。所以主机 GC 更好,代价是主机需要更频繁地发送 Discard 命令(因为所有信息在文件系统)。
最后一点:主机选定受害者(victim)后迁移的速度,主机比设备慢吗?当然。因为 F2FS 做 GC 时,需要从磁盘读取数据到 DRAM,标记为脏,再写回去。这些步骤带来延迟。而如果主机说“把这里的数据移到那里”,设备内部直接迁移(不需要经过主机 DRAM)。这种数据迁移在设备内部完成,在 Linux 社区被称为“复制卸载”(Copy Offload),正在讨论中。所以对于区域设备,最佳方案可能是:文件系统决定 GC,但数据迁移由设备直接执行。我们正在积极研究这个。