ATC'19: EROFS

之前看到培神在群里转华为贡献的 EROFS 可能进安卓主线并作为默认只读文件系统,不仅对这一贡献比较感兴趣,遂把论文拉出来读了一遍。文章作者来自华为和 SJTU iPads,内容还是比较清晰的,描述了它们怎么优化只读文件系统的,它们的贡献大概有:

  1. 利用 LZ4 来把输入块压缩成固定大小的块输出 BLOCK 存储在块存储设备上
  2. 利用巧妙的技巧,来优化解压缩的开销。作者认为这块的开销有两个方面:文件大小的放大、解压缩需要的的内存开销(见后文图)
    1. 文章把 compressed block 分成了两种模式:cached io 和 in-place io。
    2. 用多种方法来优化解压缩,包括直接把用户内存映射到解压缩对应的 Page、跳过不需要解压缩的多余数据

本文只代表 19 ATC 发表的状况,听说 2023 年 Linux 主线代码和这个有点变化,而且作者目前还在维护这套系统。感兴趣的可以翻翻 maillist 来得知最新的情况。

背景和相关系统介绍

EROFS 是一个只读的压缩文件系统。而安卓本身代码也使用了 Linux Kernel 部分的一些代码[3]. 本身 Linux 上很多地方就是放只读文件的,比如系统的 /system 之类的 read-only space. 那么根据论文的作者所说,实际上安卓的系统文件大小本身是挺大的,在 android 9 上面增长到了 1.9 GB。如下图

(Sparse Image 和 Raw Image 是镜像的不同格式,见 [4] )

img

  1. Btrfs 这种 fs 对压缩并没有那么友好,它会把数据切分成多个 128KB 的 Chunk,然后独立压缩,把这些数据存放在不同的 Extent 中,这些 Extent 的位置会存放在一个 Btree 索引上。需要读取的时候这里需要 decompress 整个 column chunk. 然后去处理. Btrfs 是可以更新的,但是更新和重写整个 chunk 差不多了,还要额外更新一下 index。
  2. 而 SquashFs 是一个已存在的压缩只读文件系统,但是这个系统会带来不少 overhead,有的是设计层面的,有的是实现层面的,见下图:

img

Squashfs 支持配置输入 block-size,然后把这些压缩到输出然后分配到磁盘的 Block 上。也支持配置一些算法。在 Squashfs 上,也需要一些元数据(比如压缩前后的映射)。压缩后的大小存储在 inode 中的列表上。

从文章的实验角度看,这些设备的压缩率其实是很高的。这导致:

  1. 每次会多读一些磁盘 IO 的数据(这个是设计角度导致的,比如压缩后的块和磁盘 size 不对齐)
  2. 解压缩需要申请 uncompressed 的块大小的临时内存。本来盘大小就小于内存了,你这资源开销更大了…这些内存在现在还没多大内存的机器上操作还会加剧 swapping。

作者认为,Squashfs 设计有下列核心问题:

  1. Fixed-sized input compression: 这种方式对小 io 带来了比较大的读放大和内存放大,如果全要读倒还好,但是对于小 io,比方说要读取第一个 block,然后磁盘上可能没对齐的 BLOCK 都给读了,然后又要准备解压缩空间,因为你也不知道这些东西每个用户层面的读要的数据在哪块. 当配置成 4KB 的输入块的时候,上述问题会被改善,但是压缩的 batch-size 又太小了,导致白压缩了。
  2. 数据拷贝的开销。这部分上面的图写的非常清晰,这里读的 buffer->解压块 -> 用户内存。每部分都引入了一定的开销。这部分我感觉一定程度上也是实现问题了。不知道 block-size 和设计感觉和这个的影响没有那么直接。主要是工程化的时候怎么在内核上支持这些需求。在实现上的时候,squashfs 实现如下
    1. 拿到 compressed block 的数量
    2. 给每个 compressed block 申请内存。然后 issue io 把它们读起来。这里可能会有个 io page 池,这个池是预先申请的,给 io 对应的需求使用
    3. 拷贝到 temp input buffer,这里需要一个连续的空间,然后解压到 temp output buffer. 作者认为,这里的主要瓶颈还是 memory access 而不是 CPU
    4. 给用户的 page cache 拷贝

作者认为,优化空间在于:

  1. 减少 io
  2. 减少内存申请

EROFS

Fixed-size output compression

作者认为,一部分问题来源于 Fixed-size input 的不够 flexible,所以他们使用的策略是 fixed-sized output (这块其实我也没有百分百被说服,因为感觉是有 trade-off 的,在同样压缩率的情况下,这里保证了对下层 block 的 io 是对齐的,但是本身 decompress 中的各种放大还是存在的。)

img

具体方法上,EROFS 采用了 LZ4 的方式,在 {输入大小到达 1MB, 输出大小到达4k} 两个条件任意满足一个就会切下面的 BLOCK。如果是输入大小为1MB可能也会准备一个 4K 的块。这个增加了压缩率的上限(毕竟原来128KB一压)。2(c) 就是压缩的一个图例。作者认为这样有如下有点:

  1. 最大压缩率更好(我认为这个取决于输入吧 orz)
  2. 在解压缩的时候,io 控制的比较好。作者也通过一些 hack 的方式让解压缩尽量解压少的空间
    1. 作者认为只有对应的压缩数据会被解压,我觉得没差把。我感觉一个隐含条件就是这堆东西压缩率很高,压缩率低的条件下文章提到的很多东西都比较扯。。。这样对于小碎读有一定优化,比如小碎读最多引入两个 Physical Page IO. (不过看文章领悟了一下,应该这里 fs 层也是 Page-Level IO,原则上 fs 上层读一个 Page 下头确实最多读两个物理 Page)。
  3. 使得后文提到的 in-place compression 成为可能。

Input Buffer Handling: Cached I/O and io-place I/O

img

  1. Cached I/O: 如果要 partial read 某个 block
    1. 使用一块 inode 区域来存储状态,数据被 fetch 到一个额外的 page cache 中。
    2. 作者认为这种小块读可能是连续的,所以可能会 cache 一会儿,后面读到了后面的区段可以避免反复解压(这段有点抽象,可以看后面的 decompression 一节)。
  2. In-place I/O: 比较 hack,包含一些核心优化,给一些需要 decompressed 的 Page 准备的。这里认为大块读不会被反复请求,所以把 page 放到连续的解压缩 Buffer 的尾部

(这段有点抽象,可以看后面的 decompression 一节,配合理解)

Decompression

img

如上图,假设现在用户需要 D4/D6 ,这里需要看看 Cache-IO 怎么处理

img

如上 (b),这里 EROFS 只需要 D3 D4,所以它:

  1. 发起两个 Page 的物理 io
  2. 准备一些 temporary page 给 D0-D2,因为 和 需要 mapping 的 Page 不一样,这些暂时不需要,然后直接解压,然后 D3 D4 解压的用 vmap 映射好丢给用户层

如果是 in-place I/O,那么如同 (d),这里 C0 会被放到尾部,然后直接解压完。

这里还有几个工程优化:

  1. 小页面用 Per-CPU buffer decompression。因为很多小页面解压,EROFS 禁止了切换(在第五节介绍的)然后切成 per-cpu 的小页面解压,这里再解压缩页面小于 4个 page 的时候才会使用,例如 3(c). 当然这里也需要拷贝出去了
  2. Rolling decompression: 为了避免不需要的内存申请太多,这里会每个 CPU 准备 16 个 Physical Pages,然后滚动 decompress

img

此外,如果当 figure5 这种场景出现的时候,也会使用临时的 Per-cpu页面。

Reference

  1. https://www.usenix.org/system/files/atc19-gao.pdf
  2. https://www.usenix.org/conference/atc19/presentation/gao (Presentation)
  3. https://www.androidauthority.com/android-linux-784964/
  4. https://wiki.archlinux.org/title/sparse_file