普通视图

发现新文章,点击刷新页面。
昨天以前ArthurChiao's Blog

存储进阶笔记(二):Linux 存储栈:从 Device Mapper、LVM 到文件系统(2024)

2024年12月15日 08:00

记录一些平时接触到的存储知识。由于是笔记而非教程,因此内容不求连贯,有基础的同学可作查漏补缺之用。

Fig. LVM concepts, and how userspace file operations traverse the Linux storage stack.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 Device Mapper:内核存储基础设施

1.1 内核框架:物理块设备 -> 虚拟块设备

Device mapper设备映射器) 是 Linux 内核提供的一个框架,用于将物理块设备(physical block devices) 映射到更上层的虚拟块设备(virtual block devices)。

  • 是逻辑卷管理器(LVM)、software RAID 和 dm-crypt 磁盘加密技术的基础
  • 还提供了诸如文件系统快照等功能,
  • 还可以在传递数据的同时进行修改,例如,在提供磁盘加密,或者模拟不可靠的硬件行为。

1.2 在内核存储栈中的位置

Fig. Device Mapper 在 Linux 存储栈中的位置(图中间部分)

1.3 使用场景及典型应用

  • dm-cache:组合使用 SSD 和 HDD 的混合卷(hybrid volume)

    A hybrid volume is any volume that intentionally and opaquely makes use of two separate physical volumes. For instance, a workload may consist of random seeks so an SSD may be used to permanently store frequently used or recently written data, while using higher-capacity rotational magnetic media for long-term storage of rarely needed data. On Linux, bcache or dm-cache may be used for this purpose.

  • Docker – 基于 device mapper 给容器创建 copy-on-write 存储;
  • LVM2 – 内核最常用的一种逻辑卷管理器(logical volume manager)

2 LVM:基于 Device Mapper 创建逻辑卷(设备)

2.1 功能

Logical Volume Manager (LVM,逻辑卷管理器)1998 年引入内核,是一个基于 device mapper 的框架, 为内核提供逻辑卷管理能力

LVM 可以认为是物理磁盘和分区之上的一个很薄的软件层, 能方便换盘、重新分区和备份等等管理工作。

2.2 LVM 中的概念/术语图解

Fig. LVM concepts, and how userspace file operations traverse the Linux storage stack.

2.3 使用场景

LVM 使用场景:

  • 将多个物理卷(physical volumes)或物理盘创建为一个逻辑卷(logical volume),有点类似于 RAID0,但更像 JBOD,好处是方便动态调整卷大小。
  • 热插拔,能在不停服的情况下添加或替换磁盘,管理非常方便。

2.4 使用教程

  1. What is LVM2 in Linux?, medium.com, 2023

3 文件系统:基于物理或逻辑卷(块设备),创建和管理文件层级

3.1 常规文件系统:不能跨 device

常规的文件系统,例如 XFS、EXT4 等等,都不能跨多个块设备(device)。 也就是说,创建一个文件系统时,只能指定一个特定的 device,比如 /dev/sda

要跨多个盘,只能通过 RAID、JBOD、LVM 等等技术将这些块设备合并成一个逻辑卷, 然后在这个逻辑卷上初始化文件系统。

3.2 Cross-device 文件系统

更高级一些的文件系统,是能够跨多个块设备的,包括,

  • ZFS
  • BTRFS

4 云计算:块存储是如何工作的

上一节已经介绍到,在块设备上初始化文件系统,就可以创建文件和目录了。 这里所说的块设备 —— 不管是物理设备,还是逻辑设备 —— 穿透之后终归是一个插在本机上硬件设备

有了虚拟化之后,情况就不一样了。 比如有一类特殊的 Linux 设备,它们对操作系统呈现的确实是一个块设备, 但其实底层对接的远端存储系统,而不是本机硬件设备。

在云计算中,这种存储类型称为“块存储”。

4.1 典型块存储产品

块存储(Block Storage),也称为 block-level storage,是公有云和私有云上都非常常见的一种存储。 各家的叫法或产品名字可能不同,例如,

  • AWS EBS(Elastic Block Store)
  • 阿里云的 SSD
  • Ceph RBD

4.2 工作层次:块级别

块存储工作在块级别(device-level),可以直接访问数据并实现高性能I/O。 因此它提供高性能、低延迟和快速数据传输

4.3 使用场景和使用方式

使用场景:

  • 虚拟机系统盘
  • 数据库磁盘

使用方式:

  1. 在块存储系统(例如 AWS EBS)中创建一个块设备,
  2. 将这个块挂载到想使用的机器上,这时呈现给这台机器的操作系统的是一个块设备(/dev/xxx),

    Storage Decision. Image Source

  3. 在这个块设备上初始化文件系统(例如初始化一个 ext4 文件系统),然后就可以像普通硬盘一样使用了。

4.4 基本设计

AWS 对文件存储、对象存储和块存储有一个不错的介绍文档。 其中提到的块存储的设计:

  • 块存储将数据划分为固定大小的 block进行存储。Block 的大小在初始化块设备时指定,可以是几 KB 到几 MB
  • 操作系统为每个 block 分配一个唯一的地址/序号,记录在一个表中。寻址使用这个序号,因此非常快;
  • 每个 Block 独立,可以直接访问或修改某个 block,不影响其他 blocks;
  • 存储元数据的设计非常紧凑,以保持高效
    • 非常基本的元数据结构,确保了在数据传输过程中的最小开销。
    • 搜索、查找和检索数据时,使用每个 block 的唯一标识符。
  • 块存储不依赖文件系统,也不需要独立的进程(例如,区别于 JuiceFS [4]),由操作系统直接管理

4.5 Ceph 块存储(RBD)的设计

4.5.1 概念

  • Pool:存储对象的逻辑分区(logical partitions used to store objects),有独立的 resilience/placement-groups/CRUSH-rules/snaphots 管理能力;
  • Image: 一个块,类似 LVM 中的一个 logical volume
  • PG (placement group): 存储 objects 的副本的基本单位,一个 PG 包含很多 objects,例如 3 副本的话就会有 3 个 PG,存放在三个 OSD 上;

创建一个 RBD 块设备的大致步骤:

$ ceph osd pool create {pool-name} [{pg-num} [{pgp-num}]] [replicated] \
         [crush-rule-name] [expected-num-objects]
$ rbd pool init {pool-name}
$ rbd create --size {size MB} {pool-name}/{image-name}

4.5.2 RBD 的后端存储:Ceph 对象存储

Ceph 的设计比较特殊,同时支持三种存储类型:

  1. 对象存储(object storage),类似 AWS S3;
  2. 文件存储(file storage),类似 JuiceFS [4];
  3. 块存储(block storage),类似 AWS EBS。

    背后,每个块存储中的 “block”(4.4 节中介绍的 block 概念), 实际上最后是一个 Ceph 对象存储中的 object。 也就是 Ceph 的块存储是基于 Ceph 的对象存储

4.5.3 读写流程

Fig. Ceph RBD IO. Each object is fix-sized, e.g. 4MB by default. Image Source

4.5.4 客户端代码实现

两种使用方式,二选一:

Fig. Ceph RBD workflow. Image Source

  1. 用户态库:librbd,这会直接通过 librados 去访问 Ceph 集群;
  2. 内核态库:将 RBD 挂载到主机之后,在系统中就可以看到一个 /dev/rbd{N} 的设备,
    • 可以像使用本地盘一样,在这个设备上初始化一个文件系统,然后就能在这个文件系统里面读写文件了;
    • RBD 驱动会将这些文件操作转换为对 Ceph 集群的操作,比如满 4MB 的文件作为一个 object 写到 Ceph 对象存储中;
    • 内核驱动源码:drivers/block/brd.c
    • 源码解读:[2,3]

参考资料

  1. What’s the Difference Between Block, Object, and File Storage?, aws.amazon.com, 2024
  2. Ceph-RBD 源码阅读, blog.shunzi.tech, 2019
  3. Deep Dive Into Ceph’s Kernel Client, engineering.salesforce.com, 2024
  4. JuiceFS 元数据引擎初探:高层架构、引擎选型、读写工作流(2024)

Written by Human, Not by AI Written by Human, Not by AI

存储进阶笔记(一):硬件基础:HDD/SDD、JBOD、RAID 等(2024)

2024年12月15日 08:00

记录一些平时接触到的存储知识。由于是笔记而非教程,因此内容不求连贯,有基础的同学可作查漏补缺之用。

Fig. 12 Left: HDDs as a JBOD, present to OS as 12 independent devices (sd*), running a Ceph OSD service on each device. Right: speedup performance with high-end RAID cards.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 磁盘的硬件组成和工作原理

1.1 HDD 和 SSD

1.2 直接使用 HDD/SDD 面临的问题

  1. 单个磁盘的容量、性能等不够
  2. 冗余/高可用需求

解决办法:RAID、JBOD、LVM 等等。

2 容量不够,JBOD (Just a Bunch Of Disks) 来凑

2.1 定义

JBOD 在 Wikipedia 中没有单独的词条, 而是归类在 Non-RAID drive architectures 中。

JBOD 是一种架构,

  • 往下管理的是多个磁盘,这里所说的“磁盘”可以是
    • 物理设备,
    • 逻辑卷(logical volume),又分为几种,
      • 多个物理设备组合成的一个逻辑卷,比如用 LVM 或者 mdadm 之类的工具(后面会介绍);
      • btrfs 之类的能跨设备的文件系统(device-spanning filesystem)
  • 往上呈现给操作系统的是一个或多个独立设备(devices,/dev/xxx)。

最简化的理解:使用 JBOD 模式,那机器上插了几个盘,操作系统中就能看到几个 /dev/sd* 设备。

比如下图是一台 12 盘的 Ceph 机器。Ceph 的设计中,每个盘由一个独立的进程来管理,也就是它的 OSD 进程, 所以就适合做 JBOD(但 RAID 也是可以的,右边所示 [2]),

Fig. 12 Left: HDDs as a JBOD, present to OS as 12 independent devices (sd*), running a Ceph OSD service on each device. Right: speedup performance with high-end RAID cards.

2.2 优缺点

  • 无冗余:每个盘(或逻辑 volume)都是独立的,可以独立访问,在其他盘上没有冗余,坏了里面的数据就没了;
  • 每个盘都是独立的,所以加减盘比较简单和方便(作为对比,RAID 加减盘就得考虑数据重新分布了);
  • 可扩展性和灵活性比较好。可以将不同大小的盘组合到一起;
  • 灵活控制数据存储和备份策略;
  • 性能上就是多个盘的叠加,没有额外性能提升(相比某些 RAID 之类的);
  • 便宜,不怎么花钱。

2.3 使用场景

  • 需要独立盘的场景,例如 Ceph OSD;
  • 动态扩容比较频繁的场景,例如云存储;
  • 需要精确控制备份策略的场景。

2.4 类似功能的软件:LVM

JBOD 是硬件特性,主板的存储控制器自带这个功能,一般的 RAID 卡也支持 JBOD 模式。

也有一些具有类似功能的软件,比如 LVM (Logical Volume Manager)。 下一篇再介绍。

3 花钱办事:硬件 RAID 卡数据冗余+提升性能

3.1 定义

RAID 是 Redundant Array of Independent Disks 的缩写,独立磁盘冗余阵列,可以提供多种级别的数据容易,防止因为单个磁盘故障导致数据丢失或不可用。 RAID 本身只是一种技术。实现上可以是硬件 RAID 卡,也可以是纯软件方案。

我们接下来讨论的主要是硬件 RAID 卡

3.2 分类

3.2.1 按 RAID 模式分类

可参考 [2],不错的介绍和软件 raid 教程。

3.2.2 按有无缓存(write back cache)分类

RAID 卡上有没有内存:

    • 低端卡,便宜
    • 数据直接写入磁盘(write-throught)。无加速能力,但能做硬件 RAID,性能比纯软件的 RAID 还是要好。
    • 高端卡,贵
    • 数据写到 RAID 卡内存后直接返回write-back),极大提高性能。

查看 WB cache 大小

$ ./storcli64 /c0 show all | grep "Current Size"
Current Size of FW Cache (MB) = 6675

3.3 实物图及使用方式

3.3.1 SATA/PCIe RAID

以下是 Broadcom MegaRAID 9560-16i 8GB RAID 卡,自带 8C 处理器,8GB 内存

Fig. Broadcom MegaRAID 9560-16i 8GB RAID Controller.

RAID 卡本身作为 PCIe 卡插到主板上,磁盘通过 SATA 接口插到右侧(也可以加转换线,将 PCIe 接口的 NVME SSD 插到右侧)。 一些产品参数 [3]:

  • PCIe 4.0 RAID 卡
  • 单个 RAID 卡最多能支持 240 SAS/SATA devices or 32 NVMe devices
  • 支持 RAID 0, 00, 1, 5, 6, 10, 50 and 60
  • JBOD mode with RAID 0, 1, 10 and JBOD for SDS environments

3.3.2 M.2 RAID

NVME SSD 有两种常见的接口格式:

  1. PCIe 格式:这种 SSD 数据线直接插在主板的 PCIe 插槽上就行了,速度已经很快,例如 PCIe Gen4 的实测写入带宽能打到 3GB/s 左右,Gen5 的写入带宽号称能到 8GB/s。
  2. M.2 格式:体积很小,插在主板上的 M.2 插槽上,速度也很快,但容量一般较小;

如果以上速度还不满足业务需求,可以考虑加上 RAID 卡,下面是 M.2 格式的多个 NVME SSD 做成 RAID 的样子:

Fig. Hardware RAID10 over NVME SSDs. Image Source

前面 Broadcom 那个卡也支持 NVME RAID,但支持的 PCIe 格式的 NVME,而且需要通过 PCIe 扩展线来连接。

3.4 RAID 卡上为什么要配备电池(或超级电容)?

3.4.1 突然掉电的问题

对于有 WB cache 的,如果数据写到了 cache,但还没写到磁盘,掉电了怎么办?会导致数据丢失。 所以引入了配套的电池(BBU, Battery Backup Unit),

  • 电池的作用不是在断电后将数据刷到磁盘 —— 因为这时候磁盘也没电了 —— 而是确保缓存中数据的安全,等重新上电后,再刷到磁盘
  • BBU 可以保持 RAID Cache 中的数据几天时间,具体看厂商及电池寿命;
  • 没有电池或电池失效,读缓存还可以用,写缓存会自动关闭(写性能急剧下降)。

3.4.2 BBU vs. supercapacitors

电池能解决掉电丢数据问题,但寿命和故障率是个问题。近几年新出来的另一种保持数据的方式是超级电容(supercapacitors)。

BBU or SuperCapacitor [4]:

  • A BBU has a docked battery that powers the volatile cache memory for up to 72 hours. Like all Li-ion batteries, they will age and need to be replaced in a maintenance slot after about three to five years.
  • A SuperCapacitor works differently, but also provides higher security: With the energy stored in the capacitor, the data is quickly shifted into a non-volatile memory and is thus ready for the next start.

3.4.3 查看 raid 卡超级电容信息

$ ./storcli64 /c0/cv show all J | jq

3.5 降本方案

再回到 RAID 卡本身。东西好是好,但贵,有没有降本的方案呢?

3.5.1 VROC (Virtual Raid On CPU)

Intel CPU 独有的技术,CPU 内置硬件模块,官方介绍

没用过。

参考资料

  1. Considerations for using a RAID controller with OSD hosts, redhat.com, 2024
  2. An Introduction to RAID in Linux, baeldung.com, 2024
  3. Broadcom MegaRAID 9560-16i 8GB RAID Controller, 2024
  4. Protecting RAID systems with BBU or SuperCapacitor, 2024

Written by Human, Not by AI Written by Human, Not by AI

[译] SSD 是如何工作的:固态硬盘内部结构与工作原理的动画展示(2020)

2024年12月1日 08:00

译者序

本文翻译自 2020 年 Branch Education 的一个科普视频 How do SSDs Work? How does your Smartphone store data? Insanely Complex Nanoscopic Structures!, 强烈推荐观看原视频。本文整理个图文版方便查阅与思考。

水平及维护精力所限,译文不免存在错误或过时之处,如有疑问,请查阅原视频。 传播知识,尊重劳动,年满十八周岁,转载请注明出处

以下是译文。



手机的存储、平板电脑的存储、SSD 硬盘,其实都类似,核心都是一个固态(Solid State) 存储芯片

称为“固态”是相对于旋转(rotational)磁盘(也就是普通 HDD 硬盘)那种“动态”而言的。

本文将深入到这个芯片内部,看看它是如何工作的。

1 存储材料 & 结构:Charge Trap

将 SSD 芯片放大到纳米级,就能看到它存储电荷的基本结构

  • 根据技术路线的不同,存储结构/材料的选择也不同,
  • 本文介绍的是比较新的一种,称为 Charge Trap(电荷捕获,或电荷陷阱), 它使用的是氮化矽(silicon nitride),这是一种绝缘体

下图中的“工”字结构就是 Charge Trap,它的基本原理是将电子吸附到氮化矽上, 吸附的电子数量不一样,电荷的高低就不一样,从而可以用于表示不同的数字,

图中黄色部分就是吸附的电子,

  • 较老的技术只能存储2 个不同的电荷级别,即电子很多或很少, 因此只能表示两种数值,也就是 1bit 01
  • 较新的 Charge Trap 可以存储 8 个或 16 个电荷级别, 也就是每个 Charge Trap 可以表示 3bit 或 4bit

被吸附的电荷可以保持几十年之久,这也是它被称为电荷陷阱的原因。

2 SSD 芯片硬件组成

下面从小到大,看看是如何基于 Charge Trap 这样一个最基本单元构建出一个最终的 SSD 芯片的。

2.1 Charge Trap -> 基本存储单元 Memory Cell

Charge Trap 是 SSD 的基本存储单元 —— memory cell —— 的核心。

在本文接下来的内容中,我们假设一个 charge trap 支持 8 个不同的电荷级别,也就是说可以表示 3bit, 比如吸附的电子很少对应 111,吸附的电子很多对应 000

下面简单介绍下读取和删除数据对应的底层操作。

2.1.1 读取数据

读取一个 memory cell 存储的数据,就是测量这个 Charge Trap 上的的电荷量

这需要先通过 control gate 锁定该 Charge Trap,然后信息就可以从中间的传输线送上去。 后面会详细介绍。

2.1.2 删除数据

删除一个 memory cell 存储的数据,就是清除这个 Charge Trap 上的的电荷量, 使其回到最低电平(111)。

2.2 纵向堆叠 Memory Cell -> String

有了能表示 3bit 的基本单元,接下来我们将 N 个 cell 垂直堆叠起来, 就得到一个称为 String(“串”)的结构。

下图是 10 个 memory cell 堆叠成的 string,

一个 String 内的所有 cell 共享顶部的 bit line(“bit 传输线”,读取或写入 cell 数据的线),

一个 String 有很多 cell,但它们共享同一根 bit line, 因此,在任一时间只能激活 String 中的一个 cell。为此,需要引入了 control gate

  • control gate 控制 String 上的哪个 cell 可以读写数据,此时称为“激活”状态; 如上图所示,读取第 10 层的 cell 信息时,就激活第 10 层的 control gate:
  • 但注意,control gate 只是用来激活 cell,而不是用来读取 cell 的信息: 比如在读数据场景,被激活的 cell 会将它保存的信息通过 String 中心的数据线(每个“工”字的中心线)发送给顶部的 bit line

2.3 横向堆叠 Memory Cell -> Page

将多个 String 水平连到一起,就得到一个二维 cell 空间。

横向的每一排 memory cell,称为一个 Page(“页”),如下图所示:

2.4 String+Page 组成 2D 存储矩阵 -> Row

String+Page 组成的 2D 存储矩阵,称为 Row(虽然在这里直觉上叫“Page”更合适,后面会看到这个名称的由来),

2.4.1 bit line 和 control gate

再来看下 bit-line/control-gate 和 String/Row 的关系,

  • 每个 String 有独立的 bit line;
  • 每个 Row 上的所有 cell 共享一个 control gate,

2.4.2 读写一个 Page:仅需一次 control gate 操作

由上图可知,向 Row 写入或读取数据时,横向的 cell 能同时被激活,它们能通过顶上的 bit lines 并行传输。

换句话说,一个 Page 内的数据仅需一次操作就能全部读出或写入

2.5 多个 Row(2D)堆叠成 3D 存储模块 -> Block

将 N 个 Row 并排连起来,就得到一个 block。下面是 6 个 Row 组成的 block,

下面是 12 个 Row 组成的 block,

2.5.1 渲染图(3D-NAND / V-NAND)

这种立体的 Block 有个专业名词叫 3D-NANDV-NAND(垂直堆叠 NAND), 以为以前的芯片都是二维的,

NAND 本身是 Not AND(“与非”门)的缩写,是一种逻辑门,后来泛指一类存储技术。

2.5.2 Block 能存储多少数据:~1.5KB

现在让我们来算一下,一个 block 能存储多少数据。

  • 3bit/cell
  • 10 cells/string
  • 32 cells/page
  • 6 rows/block
  • 2 block

最终是 3,840 个 memory cell, 总共能够存储 11,520 bit,约 1.4KB

2.6 小结

回顾下我们目前为止介绍的所有概念,

从小到大的结构是:cell -> String / Page -> Row -> Block。 这里还有 Column 和 Layer 的概念,这个图加上这俩概念,就不难理解为什么一个 2D cell 矩阵叫 Row 而不叫 Page 了。

3 真实 SSD 产品的参数

3.1 Block

3.1.1 高度(Cells per String):100~200 cells

图中画的是 96~136 层高,右边是一张纸,可以直观理解 100~200 层大概是什么概念。

3.1.2 宽度(Cells per Page): 30K~60K cells

一个 Page 的宽度约为 30,000~60,000 个 memory cell。

这意味着有 30,000~60,000 可并行读写的 bit lines

3.1.3 深度(Rows per Block):4~8 Rows

4~8 个 Row 组成一个 Block,

3.2 Blocks per Chip Unit: 4K~6K

一个最基础的芯片单元有大约 4000~6000 个 Block(后面还将重复这个基础单元很多次,最终封装成一个芯片)。

3.3 Row decoder, Page Buffer

  • 两侧的 control gate & bit line selector 组成了所谓的行解码器,通过这两组选择器就可以访问任意 Page
  • 一个 Page(约 45,000 个 memory cell)能同时使用上方并行的 bit line 来读取或写入信息;
  • 上万条 bit line 将 Page 中的数据送到 Page cache

下图是对应到实际芯片的结构,

图中的产品为了提高存储容量,将 3.2 介绍的模块复制了一倍。 这样一个模块的读写速度约为 500MB/s

3.4 多层 Chip Unit,封装到最终的一块 SSD 芯片

为了进一步提高存储容量,在一个芯片中放 8 个(层)上一节那样的子芯片, 然后通过外围接口芯片(下图最左侧)来协调这 8 个子芯片,

这样一个结构再加个外壳封装,才是我们拆开 SSD 时在电路板上看到的芯片


Written by Human, Not by AI Written by Human, Not by AI

[译] HDD 是如何工作的:旋转硬盘内部结构与工作原理的动画展示(2022)

2024年11月16日 08:00

译者序

本文翻译自 2022 年 Branch Education 的一个科普视频 How do Hard Disk Drives Work? (Youtube), 强烈推荐观看原视频(上不了油管的,B 站也有搬运)。本文整理个图文版方便查阅与思考,

水平及维护精力所限,译文不免存在错误或过时之处,如有疑问,请查阅原视频。 传播知识,尊重劳动,年满十八周岁,转载请注明出处

以下是译文。



原视频由 PCBWay 赞助,感谢赞助商。

1 硬盘拆解

1.1 盘片(platter)

盘片是存储数据的地方,

Disk/platter

  • 根据存储容量的不同,硬盘可能会有多个盘片堆叠,如上面右图所示;
  • 磁盘由铝镁合金(aluminum magnesium alloy)和其他合金的多个涂层组成,

    Disk/platter

  • 磁性功能层是 120nm 的钴铬钽合金薄层(cobalt chromium tantalum alloy), 它由磁性微块组成,磁极方向能变,

    Disk/platter

  • 盘片安装在主轴上,主轴使用中心的无刷直流电机(brushless DC motor)以 7200rpm 的等速度旋转。

1.2 机械臂装置

机械臂装置包括好几个组成部分,分别来看下。

1.2.1 机械臂(arm)

每个盘(platter)上下各有一个臂(arm),

1.2.2 滑橇(slider)和读写头(read/write head)

每个臂的末端有一个称为 slider(滑橇、滑块)的模块,它里面又包括了一个读/写头 (注意,读头和写头是分开的两个部件,后面会详细介绍),

磁盘高速旋转产生的气流能使这个滑块(和读写头)浮起来, 稳定运行在离磁盘表面 15nm(约 100 个原子)的地方,如下面的动图所示,

Fig. 高速旋转的盘片产生的气流使滑橇和读写头飘起来

1.2.3 读写头停靠装置

只有当盘片全速旋转时(有数据读写任务),机械臂才会转到磁盘表面上。 平时盘片不旋转时(没有读写任务),机械臂会停在磁盘边上的一个小塑料装置上。

1.2.4 尾部音圈电机(马达)

机械臂的尾部有一个 音圈电机(voice coil motor),或称音圈马达,它由线圈(coil of wire)和上下两个强钕磁铁(strong neodymium magnets)组成,

VCM(Voice Coil Motor)一种特殊形式的直接驱动电机,原理和扬声器类似,固得名。 通电线圈在磁场内就会产生力,力的大小与施加在线圈上的电流成比例,运动轨迹可以是直线也可以是弧线。 具有结构简单、体积小、速度快、响应快等特点。译注。

线圈通电之后会产生一个力,使机械臂在磁盘上移动(可以正向也可以反向),

这种马达的速度和精度:

  • 速度:读/写头能够在不同磁道上来回移动 ~20 次/秒
  • 精度:读/写头位置精度 ~30nm

1.3 机械臂-电路板之间的数据线

如下图所示,一条柔性电线(a flexible ribbon of wires)沿着机型臂的侧面布线,

  • 一边连接到读/写头
  • 一边连接到一个连接器(connector),该 connector 进一步连接到硬盘的主板,或称印刷电路板(PCB)。

1.4 PCB 和上面的芯片

PCB 上面的东西如下图所示,

这里主要介绍三个芯片:

  1. 主处理器芯片
  2. 内存芯片,作为主处理器的 cache;
  3. 控制音圈马达磁盘主轴电机的芯片。

1.5 数据线接口(e.g. SATA)和电源线接口

PCB 边缘还有两个硬件接口,

  • 数据接口:例如 SATA 接口,用于和电脑主板相连传输数据;
  • 电源接口:用于给 HDD 供电。

1.6 防尘装置

再看一下硬盘的两个防尘装置,

  1. 垫圈:将磁盘密封起来;
  2. 灰尘过滤器:用于捕获灰尘颗粒。

密封和过滤都是非常必要的,因为读写头距离盘片仅 15nm, 而灰尘颗粒的大小可达 10,000nm, 如果与 7200rpm 高速旋转磁盘碰撞,可能会造成严重损坏,

Fig. 读写头正常运行时,距离盘片仅 15nm。

2 盘片的微观组成

了解了粗粒度的硬件构成之后,现在让来深入到盘片的内部,看看它的微观组成。

2.1 磁盘(disk) -> 磁道(track)

首先,每个磁盘以同心圆的方式分割为多个磁道(concentric circles of tracks),

Fig. 磁盘分割为大量磁道。

每个磁盘的磁道数量能达到 500,000 个甚至更多。

2.2 磁道(track) -> 扇区(sector)

然后,沿着直径的方向,所有磁道又被分割为多个扇区,

Fig. 磁道进一步分割为扇区。

2.3 扇区内

现在看一下每个扇区内的结构,

Fig. 每个扇区的内部结构。

如上图所示,每个扇区中,依次包含五部分。

2.3.1 前导/同步区(preamble or synchronization zone)

记录这个旋转磁盘的确切速度每个比特位的长度(length of each bit of data)。

2.3.2 地址区

帮助读/写头确定当前位于哪个磁道和扇区

2.3.3 数据区

扇区大小

扇区的大小因盘而异,例如老一些的盘是 512 字节或 2KB,新一些的通常是 4KB。

查看磁盘扇区大小(译注)

有很多工具可以查看,lsblk 指定显示磁盘名字、物理扇区大小和逻辑扇区大小:

$ lsblk -o NAME,PHY-SeC,LOG-SeC
NAME                   PHY-SEC LOG-SEC
sda                       4096     512  # 这块是 SATA SSD
sdb                        512     512  # 这块是 SATA HDD

fdisk -l,这个命令好记:

$ fdisk -l
Disk /dev/sdb: 2.18 TiB, 2399276105728 bytes, 4686086144 sectors
Disk model: XXX                                                    # 硬盘型号
Units: sectors of 1 * 512 = 512 bytes                              # 当前扇区大小
Sector size (logical/physical): 512 bytes / 512 bytes              # 逻辑值 & 物理支持的最大值
I/O size (minimum/optimal): 512 bytes / 512 bytes

iostat 磁盘读写带宽(译注)

可以通过 cat /proc/diskstats 查看磁盘的读写情况,其中就包括了每个磁盘已经读写的 sectors 数量:

$ cat /proc/diskstats
#                            r_sectors               w_sectors
   8       0 sda 31663 10807 2928442     8471 203024 106672     6765800 ...

这个数量乘以 sector 大小,就是已经读写的字节数,iostat 等工具显示的磁盘读写带宽,就是根据这个来计算(估算)的。

一个扇区只会属于一个文件(译注)

根据 wikipedia Disk sector, 对于绝大部分文件系统来说,任何一个文件都是占用整数个扇区的 —— 也就是说一个扇区只会属于一个文件, 如果没用满,后面的就空着。所以在调整扇区大小时,这是一个需要考虑的因素。

扇区与 block 的关系(译注)

这里说的 block 是文件系统的概念,比如常见的一个 block 是 4KB,如果磁盘格式化的时候,扇区大小选择的 512B,那一个 block 就对应 8 个扇区。 对操作系统屏蔽了底层的硬件细节。

2.3.4 纠错码(ECC)区

Fig. 每个扇区的内部结构。

用于校验存储在块中的数据。

2.3.5 扇区之间的间隔区

给了读/写磁头一定的容错能力。

3 写数据

现在让我们进一步看看读/写磁头的内部机制,以及写头(write head)是是如何写数据的。

3.1 磁场微块和磁化

扇区是由一个个磁场微块组成的, 写头通过改变磁盘微块的磁化方向来实现数据写入,

每个磁盘微块大小约为 90nm x 100nm x 125nm

磁化之外,微块内原子的南北极是随机的; 磁化之,微块所有原子的北南极都指向同一方向,

每个微块对应的就是一个 bit 数据,

3.2 写入 1bit 的过程

下面具体看一下如何磁化一个微块(相当于写入 1bit 数据)。

电流施加到 write head 的线圈之后,就会在此处产生一个强磁场,

这个磁场沿着 write head 向下,聚焦到尖端的一个小点,改变它正下方的磁盘微块极性 (中间的缝隙就是前面提到过的读写头 15nm 悬浮高度),

磁化之后的微块变成永磁体,能保持这个状态很多年,也就是数据已经持久化, 以后可以重复用读头感应这个永久磁场,读出存储的数据。

3.3 覆盖写

原理跟上面一样,也是逐 bit 来。 如果新写入的 bit 跟已经存储的一样,磁极就不变,否则就改变一下方向。

4 读数据

再来看看如何从磁盘读数据。

4.1 如何表示 0 和 1

4.1.1 不是用南北极指向表示

前面我们假设了不同南北极的磁块分别表示 0 和 1,

这在概念上非常简单,但实际实现并非如此。

4.1.2 用南北极指向的变化表示

实际的 read head,检测的是相邻两个微块的磁极变化, 这是因为磁极变化的强度单个微块的磁场强度要大得多,所以这种方式的检测准确率非常高

所以,如上图所示,

  • 相邻微块磁场方向变化,表示 1;
  • 相邻微块磁场方向不变,表示 0。

4.2 读头(read head)内部结构

那么,检测这些磁场的读头内部结构是怎样的呢?

如上图所示,

  • 读头里面是多层导电材料,由铁磁材料和非磁性材料的交替组成。
  • 这种多层材料具有一种称为巨磁阻(giant magnetoresistance, GMR)的特性, 简单来说,穿过它的磁场强度发生变化时,它的电阻率就会变化

4.3 读取数据:GMR 和读头电阻率

基于 GMR 特性,根据读头的电阻率就能判断下面存储的 0 还是 1,

  • 电阻率较低时,表示读取头下方磁场变化强,对应存储的是 bit 1
  • 电阻率较高且无磁场时,对应存储的是 bit 0

4.4 连续 0 的问题

以上过程有一个问题:如果较长连续区域的磁极都一样,对应的就是一长串的 0,由于读头的精度,有可能会导致多读或少读几个 0,导致数据错乱。

解决方少:利用每个 sector 的前导区和纠错码区中的信息。

5 致谢

原作者 Branch Education 感谢所有个人赞助者和会员赞助商,让他们制作了如此精良的科普视频。

6 Linux 存储相关的子系统和软件栈(译注)

6.1 从进程 read/write 请求到 HDD 读写数据

来自 Linux Storage Stack Diagram, 涵盖了 3.x ~ 6.x 多个内核版本,这里先贴一个 3.x 的,因为简单, 方便看出从用户进程发出 read/write 请求到 HDD 读写数据的内核模块链路:

虚拟文件系统(VFS)里面分为几类:

  1. 常规文件系统(ext4, xfs, btrfs, …);
  2. 网络文件系统(NFS, CIFS, …);
  3. 伪文件系统(procfs, sysfs, …);
  4. 特殊文件系统(tmpfs, devtmpfs, …)。

再贴一个 kernel v6.9 的,

6.2 内核 block layer 深入解读

  1. A block layer introduction part 1: the bio layer, LWN.net, 2017
  2. A block layer introduction part 2: the request layer, LWN.net, 2017

6.3 其他优质文章

  1. How does a hard drive work, https://www.explainthatstuff.com/, 2024

    除了硬件拆解和介绍工作原理,还对比了 HDD 和 SDD,并且更重要的,介绍了 IBM 发明硬盘的历史

  2. How a Hard Drive Works, cs.stanford.edu, 2012

    斯坦福的一个老师实物教学,开盖展示读写数据时,硬盘的工作过程(然后这个盘就报废了)。

  3. HDD from Inside: Hard Drive Main Parts, https://hddscan.com/

    硬件拆解部分比本文更详细,想了解更多硬件细节的,可作为补充。


Written by Human, Not by AI Written by Human, Not by AI

直观解读 JuiceFS 的数据和元数据设计(三):看山还是山(2024)

2024年10月27日 08:00

本系列分为三篇文章,试图通过简单的实地环境来直观理解 JuiceFS 的数据(data)和元数据(metadata)设计。

Fig. JuiceFS object key naming and the objects in MinIO.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 如何从数据和元数据中恢复文件

1.2 理论步骤

对于一个给定的 JuiceFS 文件,我们在上一篇中已经看到两个正向的过程:

  1. 文件本身被切分成 Chunk、Slice、Block,然后写入对象存储;
  2. 文件的元数据以 inode、slice、block 等信息组织,写入元数据引擎。

有了对正向过程的理解,我们反过来就能从对象存储和元数据引擎中恢复文件: 对于一个给定的 JuiceFS 文件,

  1. 首先扫描元数据引擎,通过文件名、inode、slice 等等信息,拼凑出文件的大小、位置、权限等等信息;
  2. 然后根据 slice_id/block_id/block_size 拼凑出对象存储中的 object key;
  3. 依次去对象存储中根据这些 keys 读取数据拼到一起,得到的就是这个文件,然后写到本地、设置文件权限等等。

但这个恢复过程不是本文重点。本文主要看几个相关的问题,以加深对 JuiceFS 数据/元数据 设计的理解。 更多信息见官方文档 [2]。

1.2 juicefs info 查看文件 chunk/slice/block 信息

JuiceFS 已经提供了一个命令行选项,能直接查看文件的 chunk/slice/block 信息,例如:

$ ./juicefs info foo-dev/file2_5MB
foo-dev/file2_5MB :
  inode: 3
  files: 1
   dirs: 0
 length: 5.00 MiB (5242880 Bytes)
   size: 5.00 MiB (5242880 Bytes)
   path: /file2_5MB
 objects:
+------------+--------------------------------+---------+--------+---------+
| chunkIndex |           objectName           |   size  | offset |  length |
+------------+--------------------------------+---------+--------+---------+
|          0 | foo-dev/chunks/0/0/3_0_4194304 | 4194304 |      0 | 4194304 |
|          0 | foo-dev/chunks/0/0/3_1_1048576 | 1048576 |      0 | 1048576 |
+------------+--------------------------------+---------+--------+---------+

和我们在 MinIO 中看到的一致。

2 如何判断 {volume}/chunks/ 中的数据是否是合法

bucket 中的数据是 JuiceFS 写入的,还是其他应用写入的呢? 另外即使是 JuiceFS 写入的,也可能有一些数据是无效的,比如 size 为 0 的 block、超出所属 slice 范围的 block 等等。 我们来看看基于哪些规则,能对这些非法数据进行判断。

2.1 原理

准备工作:

  1. 从 JuiceFS 的元数据引擎中读取所有 slice size,这对应的是元数据信息
  2. 从 object storage 中读取所有 object key,这对应的数据信息

接下来,根据几条标准,判断 bucket 中 {volume}/chunks/ 内的数据是否是合法的 JuiceFS 数据:

  1. 如果 object 不符合命名规范 {volume}/chunks/{slice_id/1000/1000}/{slice_id/1000}/{slice_id}_{block_id}_{block_size}, 那么这个 object 就不是 JuiceFS 写入的;
  2. 如果符合以上命名规范,,那么这个 object 就是 JuiceFS 写入的,接下来,
    1. 如果 object 大小为零,那可以清理掉,因为这种 object 留着没意义;
    2. 如果 object 大小不为零,根据元数据内记录的 slice/block 信息计算这个 block 应该是多大,
      1. 如果大小跟 object 一致,那这个 object 就是一个合法的 JuiceFS 数据(Block);
      2. 否则,说明这个 object 有问题。

这个过程是没问题的,但需要对所有 object 和所有元数据进行遍历和比对,效率比较低。 有没有更快的方法呢?

2.2 改进:pending delete slices

回忆上一篇,在元数据引擎中其实已经记录了待删除的 slice/block 信息, 这里“待删除”的意思是 JuiceFS 中已经把文件删掉了(用户看不到了,volume usage 统计也不显示了), 但还没有从对象存储中删掉,

  • D 开头的记录:deleted inodes
  • 格式:D{8bit-inode}{8bit-length}

这种记录是 JuiceFS 在从 object storage 删除文件之前插入到元数据引擎中的, 所以扫描所有 D 开头的记录,可以找到所有待删除的 slice/block 信息。

2.3 工具:juicefs gc

结合 2.1 & 2.2,就可以快速判断 bucket 中的数据是否是 JuiceFS 合法数据,不是就删掉; 基于 juicefs 已有的代码库,就可以写一个工具 —— 但用不着自己写 —— JuiceFS 已经提供了。

2.3.1 核心代码

完整代码见 pkg/cmd/gc.go

从元数据引擎 list 所有 slice 信息

func (m *kvMeta) ListSlices(ctx Context, slices map[Ino][]Slice, delete bool, showProgress func()) syscall.Errno {
    if delete
        m.doCleanupSlices()

    // 格式:A{8digit-inode}C{4digit-blockID}   file chunks
    klen := 1 + 8 + 1 + 4
    result := m.scanValues(m.fmtKey("A"), -1, func(k, v []byte) bool { return len(k) == klen && k[1+8] == 'C' })

    for key, value := range result {
        inode := m.decodeInode([]byte(key)[1:9])
        ss := readSliceBuf(value) // slice list
        for _, s := range ss
            if s.id > 0
                slices[inode] = append(slices[inode], Slice{Id: s.id, Size: s.size})
    }

    if m.getFormat().TrashDays == 0
        return 0

    return errno(m.scanTrashSlices(ctx, func(ss []Slice, _ int64) (bool, error) {
        slices[1] = append(slices[1], ss...)
        if showProgress != nil
            for range ss
                showProgress()
        return false, nil
    }))
}

从对象存储 list 所有 objects 信息

    // Scan all objects to find leaked ones
    blob = object.WithPrefix(blob, "chunks/")
    objs := osync.ListAll(blob, "", "", "", true) // List {vol_name}/chunks/ 下面所有对象

遍历所有 objects,跟元数据引擎中的 slice 信息比对

    for obj := range objs {
        // key 格式:{slice_id/1000/1000}/{slice_id/1000}/{slice_id}_{index}_{size}
        parts := strings.Split(obj.Key(), "/")     // len(parts) == 3
        parts = strings.Split(parts[2], "_")       // len(parts) == 3

        sliceID, _ := strconv.Atoi(parts[0])       // slice id, JuiceFS globally unique
        blockID, _ := strconv.Atoi(parts[1])       // blockID in this slice
        blockSize, _ := strconv.Atoi(parts[2])     // block size, <= 4MB
        sliceSizeFromMetaEngine := sliceSizesFromMetaEngine[uint64(sliceID)]       // tikv 中记录的 slice size

        var isEmptySize bool
        if sliceSizeFromMetaEngine == 0 {
            sliceSizeFromMetaEngine = sliceSizesFromTrash[uint64(sliceID)]
            isEmptySize = true
        }
        if sliceSizeFromMetaEngine == 0 {
            foundLeaked(obj)
            continue
        }

        if blockSize == chunkConf.BlockSize { // exactly 4MB
            if (blockID+1)*blockSize > sliceSizeFromMetaEngine
                foundLeaked(obj)
        } else {                              // < 4MB
            if blockID*chunkConf.BlockSize+blockSize != sliceSizeFromMetaEngine 
                foundLeaked(obj)
        }
  1. slice size 为 0,说明这个 slice 在元数据引擎中被 compact 过了;
  2. slice size 非零,
    • block size == 4MB,可能是也可能不是最后一个 block;
    • block size != 4MB,说明这个 block 是最后一个 block;

2.3.2 使用方式

$ ./juicefs gc -h
NAME:
   juicefs gc - Garbage collector of objects in data storage

USAGE:
   juicefs gc [command options] META-URL

大致效果:

$ ./juicefs gc tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev
<INFO>: TiKV gc interval is set to 3h0m0s [tkv_tikv.go:138]
<INFO>: Data use minio://localhost:9000/juicefs-bucket/foo-dev/ [gc.go:101]

Pending deleted files: 0                             0.0/s
 Pending deleted data: 0.0 b     (0 Bytes)           0.0 b/s
Cleaned pending files: 0                             0.0/s
 Cleaned pending data: 0.0 b     (0 Bytes)           0.0 b/s
        Listed slices: 6                             327.3/s
         Trash slices: 0                             0.0/s
           Trash data: 0.0 b     (0 Bytes)           0.0 b/s
 Cleaned trash slices: 0                             0.0/s
   Cleaned trash data: 0.0 b     (0 Bytes)           0.0 b/s
      Scanned objects: 37/37 [=================================]  8775.9/s used: 4.268971ms
        Valid objects: 37                            11416.0/s
           Valid data: 134.0 MiB (140509216 Bytes)   41.0 GiB/s
    Compacted objects: 0                             0.0/s
       Compacted data: 0.0 b     (0 Bytes)           0.0 b/s
       Leaked objects: 0                             0.0/s
          Leaked data: 0.0 b     (0 Bytes)           0.0 b/s
      Skipped objects: 0                             0.0/s
         Skipped data: 0.0 b     (0 Bytes)           0.0 b/s

<INFO>: scanned 37 objects, 37 valid, 0 compacted (0 bytes), 0 leaked (0 bytes), 0 delslices (0 bytes), 0 delfiles (0 bytes), 0 skipped (0 bytes) [gc.go:379]

3 问题讨论

3.1 chunk id 和 slice id 的分配

  1. 每个文件都是从 chunk0 开始的;
  2. 实际上没有 chunk id 的概念,只是在查找文件的过程中动态使用,并没有存储到数据和元数据中;

代码里就是直接根据 64MB 计算下一个 chunk id,接下来的读写都是 slice 维度的, slice id 是全局唯一的,会存储到数据(object key)和元数据(tikv keys/values)中。

下一个可用的 sliceID 和 inodeID 记录在 global unique 变量中,初始化:

Register("tikv", newKVMeta)                  // pkg/meta/tkv_tikv.go
                 |-newBaseMeta(addr, conf)   // pkg/meta/tkv.go
                   |-newBaseMeta(addr, conf) // pkg/meta/base.go
                     |-.freeInodes // initialized as default value of type `freeID`
                     |-.freeSlices // initialized as default value of type `freeID`

然后,以写文件为例,调用栈:

Write(off uint64, data)
  |-if f.totalSlices() >= 1000 {
  |     wait a while
  | }
  |-chunkID := uint32(off / meta.ChunkSize) // chunk index, or chunk id
  |-pos := uint32(off % meta.ChunkSize)     // position inside the chunk for writing
  |-for len(data) > 0 {
  |   |-writeChunk
  |       |-c := f.findChunk(chunkID)
  |       |-s := c.findWritableSlice(off, uint32(len(data)))
  |       |-if no wriatable slice {
  |       |     s = &sliceWriter{chunk: c, off: off, }
  |       |     go s.prepareID(meta.Background, false) // pkg/vfs/writer.go
  |       |           |-NewSlice
  |       |               |-*id = m.freeSlices.next    // globally unique ID
  |       |
  |       |     c.slices = append(c.slices, s)
  |       |     if len(c.slices) == 1 {
  |       |         f.refs++
  |       |         go c.commitThread()
  |       |     }
  |       |-}
  |       |-return s.write(ctx, off-s.off, data)
  |         NewSlice // pkg/meta/base.go
  |-}

3.2 JuiceFS pending delete slices 和 background job

3.2.1 设计初衷

引入 pending delete slices 主要是大批量删除场景的性能优化

  1. 每个 JuiceFS 客户端只允许并发 100 的删除操作;
  2. 超过 100 时,自动放入后台队列,由 background job 异步删除;

3.2.2 代码

// pkg/meta/base.go

func (m *baseMeta) fileDeleted(opened, force bool, inode Ino, length uint64) {
    if opened
        m.removedFiles[inode] = true
    else
        m.tryDeleteFileData(inode, length, force)
}

func (m *baseMeta) tryDeleteFileData(inode Ino, length uint64, force bool) {
    if force {
        m.maxDeleting <- struct{}{}
    } else {
        select {
        case m.maxDeleting <- struct{}{}: // maxDeleting 没满,直接删
        default:                          // maxDeleting 满了之后走到这里,直接返回,靠后台任务删
            return // will be cleanup later
        }
    }

    go func() {
        m.en.doDeleteFileData(inode, length)
        <-m.maxDeleting
    }()
}

这个 maxDeleting 初始为一个 100 的 buffered channel,每次删除文件时,会尝试往里面放一个元素,

// pkg/meta/base.go

func newBaseMeta(addr string, conf *Config) *baseMeta {
    return &baseMeta{
        sid:          conf.Sid,
        removedFiles: make(map[Ino]bool),
        compacting:   make(map[uint64]bool),
        maxDeleting:  make(chan struct{}, 100), // 代码里写死了 100
        ...

3.2.3 潜在的问题

后台删除是 JuiceFS client 中的 background job 做的,这个 background job 的开关是可配置的,

$ ./juicefs mount --no-bgjob ... # 关闭 background job

这个开关的控制有点 tricky:

  1. 打开:如果一个 volume 的客户端太多,大家都会去做后台清理,都获取文件锁,对元数据引擎的压力非常大;
  2. 关闭:没有客户端去做后台清理,导致这些文件一直存在于对象存在中,也可以称为文件泄露,使用成本上升。

一种折中的做法:

  • 客户端不太多的 volumes:默认启用 bgjob;
  • 客户端太多的 volumes,默认关闭 bgjob,然后指定特定的 client 开启 bgjob,代表这个 volume 的所有客户端执行清理操作。

3.3 JuiceFS 支持的单个最大文件 128PiB 是怎么来的

从以上定义可以看到,理论上 JuiceFS 支持的单个文件大小是 maxSliceID (int64) * maxChunkSize, 以默认的 maxChunkSize=64MB(2^26 Byte)为例,

  • 理论上限:2^63 * 2^26 = 2^(63+26) Byte
  • 实际上限:2^31 * 2^26 = 2^(31+26) Byte = 128PiB,这个数字来自官方文档

实际上限是 128PiB 的原因也很简单,在代码里写死了

// pkg/vfs/vfs.go

const (
    maxFileSize = meta.ChunkSize << 31
)

3.4 为什么 JuiceFS 写入对象存储的文件,不能通过对象存储直接读取?

这里说的“不能读取”,是指不能直接读出原文件给到用户,而不是说不能读取 objects。

看过本文应该很清楚了,JuiceFS 写入对象存储的文件是按照 Chunk、Slice、Block 进行切分的, 只有数据内容,且保护重复数据,还没有文件信息元信息(文件名等)。

所以,以对象的存储的方式只能读这些 objects,是无法恢复出原文件给到用户的。

3.5 JuiceFS 不会对文件进行合并

Highlight:JuiceFS 不会文件进行合并写入对象存储, 这是为了避免读放大

4 总结

至此,我们对 JuiceFS 数据和元数据设计的探索学习就告一段落了。希望有了这些知识, 用户和工程师在日常的使用和维护 JuiceFS 过程中,看问题和解决问题能更加得心应手。

参考资料

  1. 官方文档:JuiceFS 如何存储文件, juicefs.com
  2. 官方文档:文件数据格式, juicefs.com

Written by Human, Not by AI Written by Human, Not by AI

直观解读 JuiceFS 的数据和元数据设计(二):看山不是山(2024)

2024年10月27日 08:00

本系列分为三篇文章,试图通过简单的实地环境来直观理解 JuiceFS 的数据(data)和元数据(metadata)设计。

Fig. JuiceFS object key naming and the objects in MinIO.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 引言

上一篇从功能的角度体验了下 JuiceFS,这一篇我们深入到背后,看看 JuiceFS 分别在数据和元数据上做了哪些设计,才给到用户和本地文件系统一样的体验的。

2 对象存储中 JuiceFS 写入的文件

本篇以 MinIO 为例,来看 JuiceFS 写入到对象存储中的文件是怎样组织的。 其他云厂商的对象存储(AWS S3、阿里云 OSS 等)也都是类似的。

2.1 Bucket 内:每个 volume 一个“目录”

可以用上一篇介绍的 juicefs format 命令再创建两个 volume,方便观察它们在 bucket 中的组织关系,

Fig. MinIO bucket browser: volume list.

如上图所示,bucket 内的顶层“目录”就是 JuiceFS 的 volumes

我们这里提到“目录”时加双引号,是因为对象存储是扁平的 key-value 存储,没有目录的概念, 前端展示时模拟出目录结构(key 前缀一样的,把这个前缀作为一个“目录”)是为了查看和理解方便。 简单起见,后文不再加双引号。

2.2 每个 volume 的目录: {chunks/, juicefs_uuid, meta/, ...}

每个 volume 目录内的结构如下:

{volume_name}/
  |-chunks/         # 数据目录,volume 中的所有用户数据都放在这里面
  |-juicefs_uuid    
  |-meta/           # `juicefs mount --backup-meta ...` 产生的元数据备份存放的目录

2.2.1 juicefs_uuid:JuiceFS volume 的唯一标识

可以把这个文件下载下来查看内容,会发现里面存放的就是 juicefs format 输出里看到的那个 uuid, 也就是这个 volume 的唯一标识。

删除 volume 时需要用到这个 uuid。

2.2.2 meta/:JuiceFS 元数据备份

如果在 juicefs mount 时指定了 --backup-meta,JuiceFS 就会定期把元数据(存在在 TiKV 中)备份到这个目录中, 用途:

  1. 元数据引擎故障时,可以从这里恢复;
  2. 在不同元数据引擎之间迁移元数据。

详见 JuiceFS 元数据引擎五探:元数据备份与恢复(2024)

2.2.3 chunks/

Fig. MinIO bucket browser: files in a bucket.

chunks/ 内的目录结构如下,

{volume_name}/
  |-chunks/
  |   |-0/                # <-- id1 = slice_id / 1000 / 1000
  |   |  |-0/             # <-- id2 = slice_id / 1000
  |   |     |-1_0_16      # <-- {slice_id}_{block_id}_{size_of_this_block}
  |   |     |-3_0_4194304 #
  |   |     |-3_1_1048576 #
  |   |     |-...
  |-juicefs_uuid    
  |-meta/

如上,所有的文件在 bucket 中都是用数字命名和存放的,分为三个层级:

  1. 第一层级:纯数字,是 sliceID 除以 100 万得到的;
  2. 第二层级:纯数字,是 sliceID 除以 1000 得到的;
  3. 第三层级:纯数字加下划线,{slice_id}_{block_id}_{size_of_this_block},表示的是这个 chunk 的这个 slice 内的 block_id 和 block 的大小。

不理解 chunk/slice/block 这几个概念没关系,我们马上将要介绍。

2.3 小结

通过以上 bucket 页面,我们非常直观地看到了一个 JuiceFS volume 的所有数据在对象存储中是如何组织的

接下来进入正题,了解一下 JuiceFS 的数据和元数据设计。

3 JuiceFS 数据的设计

3.1 顶层切分:一切文件先切 chunk

对于每个文件,JuiceFS 首先会按固定大小(64MB)切大块, 这些大块称为「Chunk」。

  • 这是为了读或修改文件内容时,方便查找和定位
  • 不管是一个只有几字节的文本文件,还是一个几十 GB 的视频文件, 在 JuiceFS 中都是切分成 chunk,只是 chunk 的数量不同而已。

3.1.1 示意图

Fig. JuiceFS: split each file into their respective chunks (with max chunk size 64MB).

3.1.2 对象存储:不存在 chunk 实体

结合上一节在对象存储中看到的目录结构,

{volume_name}/
  |-chunks/
  |   |-0/                # <-- id1 = slice_id / 1000 / 1000
  |   |  |-0/             # <-- id2 = slice_id / 1000
  |   |     |-1_0_16      # <-- {slice_id}_{block_id}_{size_of_this_block}
  |   |     |-3_0_4194304 #
  |   |     |-3_1_1048576 #
  |   |     |-...
  |-juicefs_uuid    
  |-meta/
  1. Chunk 在对象存储中 没有对应任何实际文件,也就是说在对象存储中没有一个个 64MB 的 chunks
  2. 用 JuiceFS 的话来说,Chunk 是一个逻辑概念。暂时不理解没关系,接着往下看。

3.2 Chunk 内的一次连续写入:Slice

chunk 只是一个“框”,在这个框里面对应文件读写的,是 JuiceFS 称为「Slice」 的东西。

  • chunk 内的一次连续写入,会创建一个 slice,对应这段连续写入的数据;
  • 由于 slice 是 chunk 内的概念,因此它不能跨 Chunk 边界,长度也不会超 max chunk size 64M。
  • slice ID 是全局唯一的;

3.2.1 Slice 的重叠问题

根据写入行为的不同,一个 Chunk 内可能会有多个 Slice,

  • 如果文件是由一次连贯的顺序写生成,那每个 Chunk 只包含一个 Slice
  • 如果文件是多次追加写,每次追加均调用 flush 触发写入上传,就会产生多个 Slice

Fig. JuiceFS: chunks are composed of slices, each slice corresponds to a continues write operation.

拿 chunk1 为例,

  1. 用户先写了一段 ~30MB 数据,产生 slice5
  2. 过了一会,从 ~20MB 的地方重新开始写 45MB(删掉了原文件的最后一小部分,然后开始追加写),
    • chunk1 内的部分产生 slice6
    • 超出 chunk1 的部分,因为 slice 不能跨 chunk 边界,因此产生 chunk2slice7
  3. 过了一会,从 chunk1 ~10MB 的地方开始修改(覆盖写),产生 slice8

由于 Slice 存在重叠,因此引入了几个字段标识它的有效数据范围,

// pkg/meta/slice.go

type slice struct {
    id    uint64
    size  uint32
    off   uint32
    len   uint32
    pos   uint32
    left  *slice // 这个字段不会存储到 TiKV 中
    right *slice // 这个字段不会存储到 TiKV 中
}

3.2.2 读 chunk 数据时的多 slice 处理:碎片化和碎片合并

Fig. JuiceFS: chunks are composed of slices, each slice corresponds to a continues write operation.

对 JuiceFS 用户来说,文件永远只有一个,但在 JuiceFS 内部,这个文件对应的 Chunk 可能会有多个重叠的 Slice,

  • 有重叠的部分,以最后一次写入的为准。
  • 直观上来说,就是上图 chunk 中的 slices 从上往下看,被盖掉的部分都是无效的

因此,读文件时,需要查找「当前读取范围内最新写入的 Slice」,

  • 在大量重叠 Slice 的情况下,这会显著影响读性能,称为文件「碎片化」。
  • 碎片化不仅影响读性能,还会在对象存储、元数据等层面增加空间占用。
  • 每当写入发生时,客户端都会判断文件的碎片化情况,并异步地运行碎片合并,将一个 Chunk 内的所有 Slice 合并。

3.2.3 对象存储:不存在 slice 实体

跟 chunk 类似,在对象存储中 slice 也没有 没有对应实际文件

{volume_name}/
  |-chunks/
  |   |-0/                # <-- id1 = slice_id / 1000 / 1000
  |   |  |-0/             # <-- id2 = slice_id / 1000
  |   |     |-1_0_16      # <-- {slice_id}_{block_id}_{size_of_this_block}
  |   |     |-3_0_4194304 #
  |   |     |-3_1_1048576 #
  |   |     |-...
  |-juicefs_uuid    
  |-meta/

3.3 Slice 切分成固定大小 Block(e.g. 4MB):并发读写对象存储

为了加速写到对象存储,JuiceFS 将 Slice 进一步拆分成一个个「Block」(默认 4MB),多线程并发写入。

Fig. JuiceFS: slices are composed of blocks (4MB by default), each block is an object in object storage.

Block 是 JuiceFS 数据切分设计中最后一个层级,也是 chunk/slice/block 三个层级中唯一能在 bucket 中看到对应文件的

Fig. MinIO bucket browser: objects in a bucket.

  • 连续写:前面 Block 默认都是 4MB,最后一个 Block 剩多少是多少。
  • 追加写:数据不足 4MB 时,最终存入对象存储的也会是一个小于 4M 的 Block。

从上图的名字和大小其实可以看出分别对应我们哪个文件:

  1. 1_0_16:对应我们的 file1_1KB
    • 我们上一篇的的追加写 echo "hello" >> file1_1KB 并不是写入了 1_0_16, 而是创建了一个新对象 7_0_16,这个 object list 最后面,所以在截图中没显示出来;
    • 换句话说,我们的 file1_1KB 虽然只有两行内容,但在 MinIO 中对应的却是两个 object,各包含一行。
    • 通过这个例子,大家可以体会到 JuiceFS 中连续写和追加写的巨大区别
  2. 3_0_4194304 + 3_1_1048576:总共 5MB,对应我们的 file2_5MB
  3. 4_*:对应我们的 file3_129MB

3.4 object key 命名格式(及代码)

格式:{volume}/chunks/{id1}/{id2}/{slice_id}_{block_id}_{size_of_this_block},对应的代码,

// pkg/chunk/cached_store.go

func (s *rSlice) key(blockID int) string {
    if s.store.conf.HashPrefix  // false by default
        return fmt.Sprintf("chunks/%02X/%v/%v_%v_%v", s.id%256, s.id/1000/1000, s.id, blockID, s.blockSize(blockID))

    return fmt.Sprintf("chunks/%v/%v/%v_%v_%v", s.id/1000/1000, s.id/1000, s.id, blockID, s.blockSize(blockID))
}

3.5 将 chunk/slice/block 对应到对象存储

最后,我们将 volume 的数据切分和组织方式对应到 MinIO 中的路径和 objects,

Fig. JuiceFS object key naming and the objects in MinIO.

3.6 小结:光靠对象存储数据和 slice/block 信息无法还原文件

至此,JuiceFS 解决了数据如何切分和存放的问题,这是一个正向的过程:用户创建一个文件,我们能按这个格式切分、命名、上传到对象存储。

对应的反向过程是:给定对象存储中的 objects,我们如何将其还原成用户的文件呢? 显然,光靠 objects 名字中包含的 slice/block ID 信息是不够的,例如,

  1. 最简单情况下,每个 chunk 都没有任何 slice 重叠问题,那我们能够根据 object 名字中的 slice_id/block_id/block_size 信息拼凑出一个文件, 但仍然无法知道这个文件的文件名、路径(父目录)、文件权限(rwx)等等信息;
  2. chunk 一旦存在 slice 重叠,光靠对象存储中的信息就无法还原文件了;
  3. 软链接、硬链接、文件属性等信息,更是无法从对象存储中还原。

解决这个反向过程,我们就需要文件的一些元数据作为辅助 —— 这些信息在文件切分和写入对象存储之前,已经记录到 JuiceFS 的元数据引擎中了。

4 JuiceFS 元数据的设计(TKV 版)

JuiceFS 支持不同类型的元数据引擎,例如 Redis、MySQL、TiKV/etcd 等等,每种类型的元数据引擎都有自己的 key 命名规则。 本文讨论的是 JuiceFS 使用 transactional key-value(TKV)类型的元数据引擎时的 key 命名规则。

更具体地,我们将拿 TiKV 作为元数据引擎来研究。

4.1 TKV 类型 key 列表

这里的 key 是 JuiceFS 定义元数据 key,key/value 写入元数据引擎; 请注意跟前面提到的对象存储 key 区别开,那个 key/value 是写入对象存储的

key 是一个字符串,所有 key 的列表,

// pkg/meta/tkv.go

  setting                           format
  C{name}                           counter
  A{8byte-inode}I                   inode attribute
  A{8byte-inode}D{name}             dentry
  A{8byte-inode}P{8byte-inode}      parents // for hard links
  A{8byte-inode}C{4byte-blockID}    file chunks
  A{8byte-inode}S                   symlink target
  A{8byte-inode}X{name}             extented attribute
  D{8byte-inode}{8byte-length}      deleted inodes
  F{8byte-inode}                    Flocks
  P{8byte-inode}                    POSIX locks
  K{8byte-sliceID}{8byte-blockID}   slice refs
  Ltttttttt{8byte-sliceID}          delayed slices
  SE{8byte-sessionID}               session expire time
  SH{8byte-sessionID}               session heartbeat // for legacy client
  SI{8byte-sessionID}               session info
  SS{8byte-sessionID}{8byte-inode}  sustained inode
  U{8byte-inode}                    usage of data length, space and inodes in directory
  N{8byte-inode}                    detached inde
  QD{8byte-inode}                   directory quota
  R{4byte-aclID}                    POSIX acl

在 TKV 的 Keys 中,所有整数都以编码后的二进制形式存储 [2]:

  • inode 和 counter value 占 8 个字节,使用小端编码
  • SessionID、sliceID 和 timestamp 占 8 个字节,使用大端编码

setting 是一个特殊的 key,对应的 value 就是这个 volume 的设置信息。 前面的 JuiceFS 元数据引擎系列文章中介绍过 [3],这里不再赘述。

其他的,每个 key 的首字母可以快速区分 key 的类型,

  • C:counter,这里面又包含很多种类,例如 name 可以是:
    • nextChunk
    • nextInode
    • nextSession
  • A:inode attribute
  • D:deleted inodes
  • F:Flocks
  • P:POSIX lock
  • S:session related
  • K:slice ref
  • L: delayed (to be deleted?) slices
  • U:usage of data length, space and inodes in directory
  • N:detached inode
  • QD:directory quota
  • R:POSIX acl

需要注意的是,这里是 JuiceFS 定义的 key 格式,在实际将 key/value 写入元数据引擎时, 元数据引擎可能会对 key 再次进行编码,例如 TiKV 就会在 key 中再插入一些自己的字符。 前面的 JuiceFS 元数据引擎系列文章中也介绍过,这里不再赘述。

4.2 元数据引擎中的 key/value

4.2.1 扫描相关的 TiKV key

TiKV 的 scan 操作类似 etcd 的 list prefix,这里扫描所有 foo-dev volume 相关的 key,

$ ./tikv-ctl.sh scan --from 'zfoo-dev' --to 'zfoo-dew'
key: zfoo-dev\375\377A\000\000\000\020\377\377\377\377\177I\000\000\000\000\000\000\371
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile1_\3771KB\000\000\000\000\000\372
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile2_\3775MB\000\000\000\000\000\372
...
key: zfoo-dev\375\377SI\000\000\000\000\000\000\377\000\001\000\000\000\000\000\000\371
        default cf value: start_ts: 453485726123950084 value: 7B225665727369...33537387D
key: zfoo-dev\375\377U\001\000\000\000\000\000\000\377\000\000\000\000\000\000\000\000\370
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 453485722598113282 value: 7B0A224E616D65223A202266...0A7D

4.2.2 解码成 JuiceFS metadata key

tikv-ctl --decode <key> 可以解码出来,注意去掉最前面的 z,得到的就是 JuiceFS 的原始 key,看着会更清楚一点,

foo-dev\375A\000\000\000\020\377\377\377\177I
foo-dev\375A\001\000\000\000\000\000\000\000Dfile1_1KB
foo-dev\375A\001\000\000\000\000\000\000\000Dfile2_5MB
foo-dev\375A\001\000\000\000\000\000\000\000Dfile3_129MB
foo-dev\375A\001\000\000\000\000\000\000\000I
foo-dev\375A\002\000\000\000\000\000\000\000C\000\000\000\000
foo-dev\375A\002\000\000\000\000\000\000\000I
foo-dev\375A\003\000\000\000\000\000\000\000C\000\000\000\000
foo-dev\375A\003\000\000\000\000\000\000\000I
foo-dev\375A\004\000\000\000\000\000\000\000C\000\000\000\000
foo-dev\375A\004\000\000\000\000\000\000\000C\000\000\000\001
foo-dev\375A\004\000\000\000\000\000\000\000C\000\000\000\002
foo-dev\375A\004\000\000\000\000\000\000\000I
foo-dev\375ClastCleanupFiles
foo-dev\375ClastCleanupSessions
foo-dev\375ClastCleanupTrash
foo-dev\375CnextChunk
foo-dev\375CnextCleanupSlices
foo-dev\375CnextInode
foo-dev\375CnextSession
foo-dev\375CtotalInodes
foo-dev\375CusedSpace
foo-dev\375SE\000\000\000\000\000\000\000\001
foo-dev\375SI\000\000\000\000\000\000\000\001
foo-dev\375U\001\000\000\000\000\000\000\000
foo-dev\375setting

从上面的 keys,可以看到我们创建的三个文件的元信息了, 这里面是用 slice_id 等信息关联的,所以能和对象存储里的数据 block 关联上

可以基于上一节的 key 编码规则进一步解码,得到更具体的 sliceID/inode 等等信息,这里我们暂时就不展开了。

5 总结

这一篇我们深入到 JuiceFS 内部,从数据和元数据存储中的东西反观 JuiceFS 切分数据和记录元数据的设计。 站在这个层次看,已经跟前一篇的理解程度全然不同。

如果说第一篇是“见自己”(功能如所见),这第二篇就是“见天(元数据引擎)地(对象存储)”, 那必然还得有一篇“见众生”。

参考资料

  1. 官方文档:JuiceFS 如何存储文件, juicefs.com
  2. 官方文档:JuiceFS 开发:内部实现, juicefs.com
  3. JuiceFS 元数据引擎初探:高层架构、引擎选型、读写工作流(2024)

Written by Human, Not by AI Written by Human, Not by AI

直观解读 JuiceFS 的数据和元数据设计(一):看山是山(2024)

2024年10月27日 08:00

本系列分为三篇文章,试图通过简单的实地环境来直观理解 JuiceFS 的数据(data)和元数据(metadata)设计。

Fig. MinIO bucket browser: one object was created ({volume}/juicefs_uuid) on a new juicefs volume creation.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



本篇首先快速了解下 JuiceFS 架构和组件,然后将搭建一个极简 JuiceFS 集群, 并以 JuiceFS 用户的身份来体验下它的基本功能。

1 JuiceFS 高层架构与组件

JuiceFS 的高层架构和组件,

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

三大组件:

  1. 元数据引擎:存储文件元数据,例如文件名、权限等。JuiceFS 支持多种元数据引擎,比如 TiKV、sqlite、redis 等。
  2. 对象存储:存储文件本身。JuiceFS 支持多种对象存储,比如 MinIO、AWS S3、阿里云 OSS 等。
  3. JuiceFS 客户端:将 JuiceFS volume 挂载到机器上,提供文件系统视图给用户。

更多架构信息,见 [1]。

2 搭建极简 JuiceFS 集群

接下来搭建一个极简 JuiceFS 环境,方便我们做一些功能测试。 按上一节提到的,只需要搭建以下 3 个组件:

  1. 元数据引擎,这里我们用 TiKV
  2. 对象存储,这里我们用 MinIO
  3. JuiceFS 客户端。

2.1 搭建元数据集群

对于功能测试来说,使用哪种元数据引擎都无所谓,比如最简单的 sqlite 或 redis。

不过,本系列第二篇会介绍 TiKV 相关的一些设计,所以本文用的 TiKV 集群作为元数据引擎, 相关的搭建步骤见社区文档。

本篇假设搭建的是三节点的 TiKV 集群,IP 地址分别是 192.168.1.{1,2,3}

2.2 搭建对象存储(MinIO)

这里我们用 MinIO 搭建一个对象存储服务,主要是空集群方便观察其中的文件变化

2.2.1 启动 MinIO server

MinIO 是一个兼容 S3 接口的开源对象存储产品,部署非常简单,就一个可执行文件,下载执行就行了。

也可以用容器,一条命令启动:

$ sudo docker run -p 9000:9000 -p 8080:8080 \
    quay.io/minio/minio server /data --console-address "0.0.0.0:8080"

访问 http://localhost:8080/ 就能看到 MinIO 的管理界面了。默认账号密码都是 minioadmin

2.2.2 创建 bucket

通过 MinIO 管理界面创建一个 bucket,这里我们命名为 juicefs-bucket

Fig. MinIO bucket list: an empty bucket.

可以看到现在里面一个对象也没有,已使用空间也是 0 字节

2.3 下载 juicefs 客户端

从 https://github.com/juicedata/juicefs/releases 下载一个可执行文件就行了,

$ wget https://github.com/juicedata/juicefs/releases/download/v1.2.1/juicefs-1.2.1-linux-amd64.tar.gz
$ tar -xvf juicefs-1.2.1-linux-amd64.tar.gz
$ chmod +x juicefs

2.4 创建 JuiceFS volume

接下来就可以创建一个 JuiceFS volume 了,这里命名为 foo-dev

2.4.1 创建/格式化 volume:juicefs format

$ juicefs format --storage minio --bucket http://localhost:9000/juicefs-bucket \
        --access-key minioadmin \
        --secret-key minioadmin \
        tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev  \
        foo-dev

<INFO>: Meta address: tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev [interface.go:504]
<INFO>: Data use minio://localhost:9000/juicefs-bucket/foo-dev/ [format.go:528]
<INFO>: Volume is formatted as {
  "Name": "foo-dev",
  "UUID": "3b4e509b-a7c8-456f-b726-cb8395cf8eb6",
  "Storage": "minio",
  "Bucket": "http://localhost:9000/juicefs-bucket",
  "AccessKey": "minioadmin",
  "SecretKey": "removed",
  "BlockSize": 4096,
  "UploadLimit": 0,
  "DownloadLimit": 0,
  ...
}

2.4.2 查看 MinIO bucket:多了一个 juicefs_uuid 文件

再查看 MinIO bucket,会发现多了一个 object,

Fig. MinIO bucket browser: one object was created on a new juicefs volume creation.

点进去,发现是一个叫 juicefs_uuid 的文件,

Fig. MinIO bucket browser: one object was created after juicefs format.

可以把这个文件下载下来,其内容就是上面 juicefs format 命令输出的 uuid 信息,也就是说 juicefs client 会把 volume 的 uuid 上传到对象存储中。

3 将 JuiceFS volume 挂载到本地路径

这么我们将这个 volume 挂载到本地路径 /tmp/foo-dev

$ ./juicefs mount --debug --backup-meta 0 \
     tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev /tmp/foo-dev

[INFO] [client.go:405] ["[pd] create pd client with endpoints"] [component=tikv] [pid=2881678] [pd-address="[192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379]"]
[INFO] [base_client.go:378] ["[pd] switch leader"] [component=tikv] [pid=2881678] [new-leader=https://192.168.1.3:2379] [old-leader=]
[INFO] [base_client.go:105] ["[pd] init cluster id"] [component=tikv] [pid=2881678] [cluster-id=7418858894192002550]
[INFO] [client.go:698] ["[pd] tso dispatcher created"] [component=tikv] [pid=2881678] [dc-location=global]
<INFO>: Data use minio://localhost:9000/juicefs-bucket/foo-dev/ [mount.go:650]
...

进入目录:

$ cd /tmp/foo-dev
$ ls -ahl
-r--------  1 root root    0 Oct 26 10:45 .accesslog
-r--------  1 root root 2.9K Oct 26 10:45 .config
-r--r--r--  1 root root    0 Oct 26 10:45 .stats
dr-xr-xr-x  2 root root    0 Oct 26 10:45 .trash

可以看到几个隐藏文件,

  • 这些是 JuiceFS 的元数据文件,在 [1] 系列文章中有过详细介绍。
  • 这些都是 volume 本地文件,不会上传到 MinIO。此时,MinIO juicefs-bucket 里面还是只有一个 uuid 文件。

4 在 JuiceFS volume 挂载的本地路径内读写

接下来进行一些 POSIX 操作测试。

4.1 创建和写入文件

创建三个文件,一个只有几十字节(但命名为 file1_1KB), 一个 5MB,一个 129MB

$ cd /tmp/foo-dev

$ echo "Hello, JuiceFS!" > file1_1KB

$ dd if=/dev/zero of=file2_5MB bs=1M count=5
5+0 records in
5+0 records out
5242880 bytes (5.2 MB, 5.0 MiB) copied, 0.0461253 s, 114 MB/s

$ dd if=/dev/zero of=file3_129MB bs=1M count=129
129+0 records in
129+0 records out
135266304 bytes (135 MB, 129 MiB) copied, 0.648757 s, 209 MB/s

4.2 查看文件属性

$ ls -ahl file*
-rw-r----- 1 root root   16  file1_1KB
-rw-r----- 1 root root 5.0M  file2_5MB
-rw-r----- 1 root root 129M  file3_129MB

$ file file2_5MB
file2_5MB: data

4.3 读取和追加文件

$ cat file1_1KB
Hello, JuiceFS!

$ echo "Hello, JuiceFS!" >> file1_1KB
$ cat file1_1KB
Hello, JuiceFS!
Hello, JuiceFS!

4.4 查找文件

$ find /tmp -name file1_1KB
/tmp/foo-dev/file1_1KB

4.5 删除文件

直接用 rm 删除就行了,不过这几个文件我们还有用,先不删。

4.6 目录操作

目录的创建、移动、修改权限、删除等待也是一样的,大家可以自己试试,这里不再赘述。

4.7 小结

根据以上测试,在 JuiceFS 挂载路径里创建/读写/查找/删除文件,都跟本地目录没什么区别 —— 这也正是「分布式“文件系统”」的意义所在 —— 兼容 POSIX 语义,用户无需关心数据存在哪, 当本地目录使用就行了(性能另当别论)。

5 总结

本篇中,我们作为 JuiceFS 用户对它进行了一些最基本的功能测试,结论是和本地文件系统没什么区别。

对于普通用户来说,了解到这一层就够了; 但对于高阶用户以及 JuiceFS 的开发/运维来说,这只是表象,必有第二重境界等着他们。

参考资料

  1. JuiceFS 元数据引擎初探:高层架构、引擎选型、读写工作流(2024)

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎五探:元数据备份与恢复(2024)

2024年10月10日 08:00

Fig. TiKV backup with different CLI tools (and their problems).

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 JuiceFS 元数据备份方式

再复习下 JuiceFS 架构,如下图所示:

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

JuiceFS 的元数据都存储在元数据引擎(例如,TiKV)里, 因此元数据的备份有两种实现方式:

  1. 从上层备份:JuiceFS client 扫描 volume,将 volume 内所有元数据备份;
  2. 元数据引擎(例如 TiKV)备份。

下面分别看看这两种方式。

2 JuiceFS 自带方式(volume 级别)

2.1 juicefs dump 手动备份 volume metadata

对指定 volume 进行备份,

$ juicefs dump tikv://ip:2379/foo-dev foo-dev-dump.json
<INFO>: Meta address: tikv://<ip>:2379/foo-dev [interface.go:406]
<WARNING>: Secret key is removed for the sake of safety [tkv.go:2571]
           Scan keys count: 357806 / 357806 [===========================]  done
      Dumped entries count: 122527 / 122527 [===========================]  done
<INFO>: Dump metadata into dump succeed [dump.go:76]

生成的是一个 JSON 文件,包含了 volume 的所有元数据信息,

{  "Setting": {
    "Name": "foo-dev",
    "UUID": "ca95c258",
    "Storage": "OSS",
    "Bucket": "http://<url>",
    "AccessKey": "ak",
    "BlockSize": 4096,
    "Capacity": 0,
    "Inodes": 0,
    "MetaVersion": 0,
    "MinClientVersion": "",
    "MaxClientVersion": "",
  },
  "Counters": {
    "usedSpace": 6164512768,
    "usedInodes": 5010,
    "nextInodes": 10402,
    "nextChunk": 25001,
    "nextSession": 118,
  },
  "FSTree": {
    "attr": {"inode":1,"type":"directory","mode":511,"atime":1645791488,"mtime":1652433235,"ctime":1652433235,"mtimensec":553010494,"ctimensec":553010494,"nlink":2,"length":0},
    "xattrs": [{"name":"lastBackup","value":"2024-05-30T13:50:25+08:00"}],
    "entries": {
      "001eb8b": {
        "attr": {...},
        "chunks": [{"index":0,"slices":[{"chunkid":15931,"size":32,"len":32}]}]
        ...
      }
   }
  },
}

其中,volume 中的所有文件和目录信息都描述在 FSTree 字段中。

2.2 juicefs mount --backup-meta <duration> 自动备份

juicefs client 默认会自动备份 volume 的元数据,

  • 备份间隔通过 --backup-meta {duration} 选项控制,默认 1h
  • 备份文件在对象存储的 meta 特殊目录中,该目录在挂载点中不可见,用对象存储的文件浏览器可以查看和管理,
  • 多 client 挂载同一个 volume 也不会发生备份冲突,因为 JuiceFS 维护了一个全局的时间戳,确保同一时刻只有一个客户端执行备份操作,但是,
  • 当文件数太多(默认达到 100w)且备份频率为默认值 1h 时,为避免备份开销太大,JuiceFS 会自动停止元数据备份,并打印相应的告警。

2.3 juicefs load 从元数据备份文件恢复

$ juicefs load tikv://<ip>:2379/foo-dev-new foo-dev-dump.json

2.4 限制及问题

根据官方文档,以上两种方式都有一些限制或问题:

  • 导出过程中如果业务仍在写入,导出的文件可能不可用。如果对一致性有更高要求,需要在导出前停写。
  • 对规模较大的 volume,直接在线上进行导出可能会影响业务稳定性。

另外,以上方式都是 volume 级别的备份,如果要备份整个 JuiceFS 集群,需要逐个 volume 备份,比较麻烦。 下面再看看直接从元数据引擎进行备份的方式。

3 从 TiKV 层面对 JuiceFS 元数据进行备份

这里假设 JuiceFS 的元数据引擎是 TiKV。

3.1 TiKV backup/restore 原理

从上层来说,很简单:

  1. 发请求给 TiKV 集群的管理者 PD,让它对集群的所有数据进行备份;

  2. 接下来,PD 会发请求给集群的所有 TiKV 节点,通知它们各自进行备份

    • TiKV 是按 region 进行多副本存储的,因此只需要一个副本进行备份就行了,
    • 在当前的设计里面就是让每个 region 的 leader 副本进行备份,
  3. TiKV region leaders 把这个 region 内的数据写到指定位置。可以是本地磁盘或分布式存储。

3.2 备份工具 TiDB br 和 TiKV tikv-br

理论上,有两个工具可能实现以上效果,它们分别来自 TiDB 和 TiKV 社区,

Fig. TiKV backup with different CLI tools (and their problems).

  1. br:以前是个独立项目(图中 A.1),后来合到 tidb 仓库里了(图中 B.1),

    这个工具主要是给 TiDB 备份用的(虽然底层备份的是 TiDB 的 TiKV), 所以需要一些 TiDB 知识(上下文),例如 db/table 都是 TiDB 才有的概念。 理论上,它也能备份独立部署的 TiKV 集群(“不依赖 TiDB 的 TiKV”), 所以加了 raw/txn 支持,但不是 TiDB 社区的重点,所以目前还是 experimental 特性,且用下来有 bug。

  2. tikv-br:是个独立项目,应该是当时 TiKV 作为独立项目推进时,想搞一个配套的独立备份工具, 但目前看起来跟 TiKV 社区一样已经不活跃了,它也没法对 txn 进行备份(JuiceFS 用的 txnkv 接口)。

至少对于 5.x TiKV 集群,测试下来以上哪个工具都无法完成备份: 有的工具备份和恢复都提示完成,看起来是成功的,但实际上是失败的,JuiceFS 挂载时才能发现。

最后,我们是基于目前(2024.09)最新的 TiDB br,修改了两个地方,才成功完成 TiKV 的备份与恢复。

3.3 基于 TiDB br 对 JuiceFS TiKV 集群进行备份与恢复的步骤

之所以要强调 “JuiceFS TiKV 集群”,是因为 JuiceFS 用的 txnkv 接口,这个比较特殊; 如果是 rawkv 接口,那 tikv 自带的备份工具 tikv-br 也许就能用了(没测过)。

  1. (可选)关闭 TiKV MVCC GC;
  2. br 执行备份,

    br-dev 是我们基于最新 master(202409)改过的版本。

     $ ./br-dev backup txn \
             --ca /tmp/pki/root.crt --cert /tmp/pki/pd.crt --key /tmp/pki/pd.key \
             --pd https://$pd_addr \
             --s3.endpoint $s3_addr \
             --storage $storage_path \
             --log-file /var/log/tikv/br.log \
             --ratelimit $bw_limit_per_node \
             --log-level debug \
             --check-requirements=false
    

    可以设置限速等参数,避免备份占用的 CPU/Memory/DiskIO/… 过大。根据 db size 等等因素,备份的耗时是可估算的,下面拿一个真实集群的备份为例:

    • 每个 TiKV 的 DB size:监控能看到,一般每个节点的 DB size 都差不太多,这里是 25GB per TiKV node;
    • MVCC 保留了数据的多个版本:假设平均保留两个版本,那就是 DB size * 2
    • 限速带宽:设置为 30MB/s,这个带宽不算大,不会是磁盘和网络瓶颈,因此可以全速运行

    根据以上参数,估算耗时:25GB * 2 / 30MBps = 1700s = 28min

    Fig. TiKV backup resource usage with br --ratelimit=30MB/s.

    可以看到跟预估的差不多。资源销毁方面:

    • CPU 利用率比平时翻倍
    • 其中两台机器的 CPU 数量比较少,所以会比其他节点更明显。
  3. 检查备份

    如果是备份到 S3,可以用 s3cmd 或 web 控制台查看,

     $ s3cmd du s3://{bucket}/<backup>/
     295655971082   18513 objects s3://{bucket}/<backup>/
    

    290GB 左右,比监控看到的 DB size 大一倍,因为保留了 MVCC 多版本。 大多少倍与 MVCC GC 间隔有密切关系, 比如写或更新很频繁的场景,1h 和 3h 的 MVCC 数据量就差很多了。

  4. br 恢复:将备份数据恢复到一个新的 JuiceFS TiKV 集群,

     $ ./br-dev restore txn \
             --ca /tmp/pki/root.crt --cert /tmp/pki/pd.crt --key /tmp/pki/pd.key \
             --pd https://$pd_addr \
             --s3.endpoint $s3_addr \
             --storage $storage_path \
             --log-file /var/log/tikv/br.log \
             --ratelimit $bw_limit_per_node \
             --log-level debug \
             --check-requirements=false
    

    可能的问题:ratelimit 好像不起作用,全速恢复,网络带宽打的很高。

  5. JuiceFS client 挂载,验证恢复成功

    用 juicefs 挂载目录,指定新 TiKV 集群的 PD 地址,

     $ juicefs mount tikv://<new-pd-ip>:2379/<volume name> /tmp/test
    
     $ cd /tmp/test && ls
     # 原来 volume 内的文件都在
    

3.4 TiDB br 备份逻辑

感兴趣的可以看看 br 源码的备份逻辑,

3.4.1 RunBackupTxn()

// tidb br/pkg/task/backup_txn.go

// RunBackupTxn starts a backup task inside the current goroutine.
func RunBackupTxn(c context.Context, g glue.Glue, cmdName string, cfg *TxnKvConfig) error {
    mgr := NewMgr(ctx, g, cfg.PD, cfg.TLS, GetKeepalive(&cfg.Config), cfg.CheckRequirements, false)
    client := backup.NewBackupClient(ctx, mgr)

    backupRanges := make([]rtree.Range, 0, 1)

    // current just build full txn range to support full txn backup
    minStartKey := []byte{}
    maxEndKey := []byte{}
    backupRanges = append(backupRanges, rtree.Range{
        StartKey: minStartKey,
        EndKey:   maxEndKey,
    })

    // Backup
    req := backuppb.BackupRequest{
        ClusterId:        client.GetClusterID(),
        StartVersion:     0,
        EndVersion:       client.GetCurrentTS(ctx), // gets a new timestamp (TSO) from PD
        RateLimit:        cfg.RateLimit,
        Concurrency:      cfg.Concurrency,
        StorageBackend:   client.GetStorageBackend(),
        IsRawKv:          false,
    }

    ranges, schemas, policies := client.BuildBackupRangeAndSchema(mgr.GetStorage(), cfg.TableFilter, backupTS, isFullBackup(cmdName))

    // StartWriteMetasAsync writes four kind of meta into backupmeta.
    // 1. file
    // 2. schema
    // 3. ddl
    // 4. rawRange( raw kv )
    metaWriter := metautil.NewMetaWriter(client.GetStorage(), metautil.MetaFileSize, false, metautil.MetaFile, &cfg.CipherInfo)
    metaWriter.StartWriteMetasAsync(ctx, metautil.AppendDataFile)

    // Start TiKV backup
    client.BackupRanges(ctx, backupRanges, req, 1, nil, metaWriter, progressCallBack)

    // Backup has finished
    metaWriter.Update(func(m *backuppb.BackupMeta) {
        m.StartVersion = req.StartVersion
        m.EndVersion = req.EndVersion
        m.IsRawKv = false
        m.IsTxnKv = true
        m.ClusterId = req.ClusterId
        m.ClusterVersion = mgr.GetClusterVersion(ctx)
        m.BrVersion = brVersion
        m.ApiVersion = client.GetApiVersion()
    })
    metaWriter.FinishWriteMetas(ctx, metautil.AppendDataFile)
    metaWriter.FlushBackupMeta(ctx)
}

几点说明:

  1. KV 的 backup range 是全量(start/end key 都是空);
  2. MVCC 的 start/end version 分别是 0 和当前 PD 最新的 TSO;

3.4.2 调用栈

BackupRanges // make a backup of the given key ranges.
  |-mainBackupLoop := &MainBackupLoop
  |     BackupSender:       &MainBackupSender{},
  |     BackupReq:          request,
  |     Concurrency:        concurrency,
  |     GlobalProgressTree: &globalProgressTree,
  |     ReplicaReadLabel:   replicaReadLabel,
  |     GetBackupClientCallBack: func(ctx , storeID uint64, reset bool) (backuppb.BackupClient, error) {
  |         return bc.mgr.GetBackupClient(ctx, storeID)
  |     },
  | }
  |-bc.RunLoop(ctx, mainBackupLoop) // infinite loop to backup ranges on all tikv stores
      |-for {
          inCompleteRanges = iter.GetIncompleteRanges() // 还未完成备份的 key 范围
          loop.BackupReq.SubRanges = getBackupRanges(inCompleteRanges)
          allStores := bc.getBackupStores(mainCtx, loop.ReplicaReadLabel)
          for _, store := range allStores {
            cli := loop.GetBackupClientCallBack(mainCtx, storeID, reset)
            loop.SendAsync(round, storeID, loop.BackupReq, loop.Concurrency, cli, ch, loop.StateNotifier)
                  |-go startBackup(storeID, request, cli, concurrency, respCh)
                        |-for i, req := range reqs {
                            doSendBackup(ectx, backupCli, bkReq, ...)
                              |-ctx, timerecv := StartTimeoutRecv(pctx, TimeoutOneResponse)
                              |-bCli := client.Backup(ctx, &req) // protobuf grpc method
                              |-for {
                              |-    resp := bCli.Recv()
                              |-    timerecv.Refresh()
                              |-    respFn(resp)
                              |-}
          }
        }

3.4.3 tikv-server 备份代码

// components/backup/src/service.rs

impl<H> Backup for Service<H>
{
    fn backup(
        req: BackupRequest,
        mut sink: ServerStreamingSink<BackupResponse>,
    ) {
        if let Err(status) = match Task::new(req, tx) {
            Ok((task, c)) => {
                self.scheduler.schedule(task)
            }
        }

        let send_task = async move {
            let mut s = rx.map(|resp| Ok((resp, WriteFlags::default())));
            sink.send_all(&mut s).await?;
        }

        ctx.spawn(send_task);
    }
}
/// Backup Task.
pub struct Task {
    request: Request,
    pub(crate) resp: UnboundedSender<BackupResponse>,
}

// components/backup/src/endpoint.rs
impl Task {
    /// Create a backup task based on the given backup request.
    pub fn new(
        req: BackupRequest,
        resp: UnboundedSender<BackupResponse>,
    ) -> Result<(Task, Arc<AtomicBool>)> {
        let speed_limit = req.get_rate_limit();
        let limiter = Limiter::new(if speed_limit > 0  else f64::INFINITY });
        let cf = name_to_cf(req.get_cf())

        let task = Task {
            request: Request {
                start_key: req.get_start_key().to_owned(),
                end_key: req.get_end_key().to_owned(),
                sub_ranges: req.get_sub_ranges().to_owned(),
                start_ts: req.get_start_version().into(),
                end_ts: req.get_end_version().into(),
                backend: req.get_storage_backend().clone(),
                limiter,
                is_raw_kv: req.get_is_raw_kv(),
                dst_api_ver: req.get_dst_api_version(),
                cf,
                replica_read: req.get_replica_read(),
                resource_group_name: .get_resource_group_name().to_owned(),
                }),
            },
            resp,
        };
    }
}

// components/backup/src/endpoint.rs
BackupRanges -> BackupWriterBuilder -> S3Uploader

self.writer.put(&data_key_write, value) -> s3 put

参考资料

  1. 官方文档:元数据备份和恢复, juicefs.com

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎四探:元数据大小评估、限流与限速的设计思考(2024)

2024年9月22日 08:00

Fig. JuiceFS upload/download data bandwidth control.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 元数据存储在哪儿?文件名到 TiKV regions 的映射

1.1 pd-ctl region 列出所有 region 信息

$ pd-ctl.sh region | jq .
{
  "regions": [
    {
      "id": 11501,
      "start_key": "6161616161616161FF2D61692D6661742DFF6261636B7570FD41FFCF68030000000000FF4900000000000000F8",
      "end_key": "...",
      "epoch": {
        "conf_ver": 23,
        "version": 300
      },
      "peers": [
        {
          "id": 19038,
          "store_id": 19001,
          "role_name": "Voter"
        },
        ...
      ],
      "leader": {
        "id": 20070,
        "store_id": 20001,
        "role_name": "Voter"
      },
      "written_bytes": 0,
      "read_bytes": 0,
      "written_keys": 0,
      "read_keys": 0,
      "approximate_size": 104,
      "approximate_keys": 994812
    },
  ]
}

1.2 tikv-ctl region-properties 查看 region 属性详情

$ ./tikv-ctl.sh region-properties -r 23293
mvcc.min_ts: 438155461254971396
mvcc.max_ts: 452403302095650819
mvcc.num_rows: 1972540
mvcc.num_puts: 3697509
mvcc.num_deletes: 834889
mvcc.num_versions: 4532503
mvcc.max_row_versions: 54738
num_entries: 4549844
num_deletes: 17341
num_files: 6
sst_files: 001857.sst, 001856.sst, 002222.sst, 002201.sst, 002238.sst, 002233.sst
region.start_key: 6e6772...
region.end_key: 6e6772...
region.middle_key_by_approximate_size: 6e6772...

1.3 tikv-ctl --to-escaped:从 region 的 start/end key 解码文件名范围

如上,每个 region 都会有 start_key/end_key 两个属性, 这里面编码的就是这个 region 内存放是元数据的 key 范围。我们挑一个来解码看看:

$ tikv-ctl.sh --to-escaped '6161616161616161FF2D61692D6661742DFF6261636B7570FD41FFCF68030000000000FF4900000000000000F8'
aaaaaaaa\377-ai-fat-\377backup\375A\377\317h\003\000\000\000\000\000\377I\000\000\000\000\000\000\000\370

再 decode 一把会更清楚:

$ tikv-ctl.sh --decode 'aaaaaaaa\377-ai-fat-\377backup\375A\377\317h\003\000\000\000\000\000\377I\000\000\000\000\000\000\000\370'
aaaaaaaa-ai-fat-backup\375A\317h\003\000\000\000\000\000I

对应的是一个名为 aaaaaaa-ai-fat-backup 的 volume 内的一部分元数据。

1.4 filename -> region:相关代码

这里看一下从文件名映射到 TiKV region 的代码。

PD 客户端代码,

    // GetRegion gets a region and its leader Peer from PD by key.
    // The region may expire after split. Caller is responsible for caching and
    // taking care of region change.
    // Also, it may return nil if PD finds no Region for the key temporarily,
    // client should retry later.
    GetRegion(ctx , key []byte, opts ...GetRegionOption) (*Region, error)

// GetRegion implements the RPCClient interface.
func (c *client) GetRegion(ctx , key []byte, opts ...GetRegionOption) (*Region, error) {
    options := &GetRegionOp{}
    for _, opt := range opts {
        opt(options)
    }
    req := &pdpb.GetRegionRequest{
        Header:      c.requestHeader(),
        RegionKey:   key,
        NeedBuckets: options.needBuckets,
    }
    serviceClient, cctx := c.getRegionAPIClientAndContext(ctx, options.allowFollowerHandle && c.option.getEnableFollowerHandle())
    resp := pdpb.NewPDClient(serviceClient.GetClientConn()).GetRegion(cctx, req)
    return handleRegionResponse(resp), nil
}

PD 服务端代码,

func (h *regionHandler) GetRegion(w http.ResponseWriter, r *http.Request) {
    rc := getCluster(r)
    vars := mux.Vars(r)
    key := url.QueryUnescape(vars["key"])
    // decode hex if query has params with hex format
    paramsByte := [][]byte{[]byte(key)}
    paramsByte = apiutil.ParseHexKeys(r.URL.Query().Get("format"), paramsByte)

    regionInfo := rc.GetRegionByKey(paramsByte[0])
    b := response.MarshalRegionInfoJSON(r.Context(), regionInfo)

    h.rd.Data(w, http.StatusOK, b)
}

// GetRegionByKey searches RegionInfo from regionTree
func (r *RegionsInfo) GetRegionByKey(regionKey []byte) *RegionInfo {
    region := r.tree.search(regionKey)
    if region == nil {
        return nil
    }
    return r.getRegionLocked(region.GetID())
}

返回的是 region info,

// RegionInfo records detail region info for api usage.
// NOTE: This type is exported by HTTP API. Please pay more attention when modifying it.
// easyjson:json
type RegionInfo struct {
    ID          uint64              `json:"id"`
    StartKey    string              `json:"start_key"`
    EndKey      string              `json:"end_key"`
    RegionEpoch *metapb.RegionEpoch `json:"epoch,omitempty"`
    Peers       []MetaPeer          `json:"peers,omitempty"` // https://github.com/pingcap/kvproto/blob/master/pkg/metapb/metapb.pb.go#L734

    Leader            MetaPeer      `json:"leader,omitempty"`
    DownPeers         []PDPeerStats `json:"down_peers,omitempty"`
    PendingPeers      []MetaPeer    `json:"pending_peers,omitempty"`
    CPUUsage          uint64        `json:"cpu_usage"`
    WrittenBytes      uint64        `json:"written_bytes"`
    ReadBytes         uint64        `json:"read_bytes"`
    WrittenKeys       uint64        `json:"written_keys"`
    ReadKeys          uint64        `json:"read_keys"`
    ApproximateSize   int64         `json:"approximate_size"`
    ApproximateKeys   int64         `json:"approximate_keys"`
    ApproximateKvSize int64         `json:"approximate_kv_size"`
    Buckets           []string      `json:"buckets,omitempty"`

    ReplicationStatus *ReplicationStatus `json:"replication_status,omitempty"`
}

// GetRegionFromMember implements the RPCClient interface.
func (c *client) GetRegionFromMember(ctx , key []byte, memberURLs []string, _ ...GetRegionOption) (*Region, error) {
    for _, url := range memberURLs {
        conn := c.pdSvcDiscovery.GetOrCreateGRPCConn(url)
        cc := pdpb.NewPDClient(conn)
        resp = cc.GetRegion(ctx, &pdpb.GetRegionRequest{
            Header:    c.requestHeader(),
            RegionKey: key,
        })
        if resp != nil {
            break
        }
    }

    return handleRegionResponse(resp), nil
}

2 JuiceFS 集群规模与元数据大小(engine size)

2.1 二者的关系

一句话总结:并没有一个线性的关系

2.1.1 文件数量 & 平均文件大小

TiKV engine size 的大小,和集群的文件数量每个文件的大小都有关系。 例如,同样是一个文件,

  1. 小文件可能对应一条 TiKV 记录;
  2. 大文件会被拆分,对应多条 TiKV 记录。

2.1.2 MVCC GC 快慢

GC 的勤快与否也会显著影响 DB size 的大小。第三篇中有过详细讨论和验证了,这里不再赘述,

Fig. TiKV DB size soaring in a JuiceFS cluster, caused by TiKV GC lagging.

2.2 两个集群对比

  • 集群 1:~1PB 数据,以小文件为主,~30K regions,~140GB TiKV engine size (3 replicas);
  • 集群 2:~7PB 数据,以大文件为主,~800 regions,~3GB TiKV engine size (3 replicas);

如下面监控所示,虽然集群 2 的数据量是前者的 7 倍,但元数据只有前者的 1/47

Fig. TiKV DB sizes and region counts of 2 JuiceFS clusters: cluster-1 with ~1PB data composed of mainly small files, cluster-2 with ~7PB data composed of mainly large files.

3 限速(上传/下载数据带宽)设计

限速(upload/download bandwidth)本身是属于数据平面(data)的事情,也就是与 S3、Ceph、OSS 等等对象存储关系更密切。

但第二篇中已经看到,这个限速的配置信息是保存在元数据平面(metadata)TiKV 中 —— 具体来说就是 volume 的 setting 信息; 此外,后面讨论元数据请求限流(rate limiting)时还需要参考限速的设计。所以,这里我们稍微展开讲讲。

3.1 带宽限制:--upload-limit/--download-limit

  • --upload-limit,单位 Mbps
  • --download-limit,单位 Mbps

3.2 JuiceFS 限速行为

  1. 如果 juicefs mount 挂载时指定了这两个参数,就会以指定的参数为准;
  2. 如果 juicefs mount 挂载时没指定,就会以 TiKV 里面的配置为准,

    • juicefs client 里面有一个 refresh() 方法一直在监听 TiKV 里面的 Format 配置变化,
    • 当这俩配置发生变化时(可以通过 juicefs config 来修改 TiKV 中的配置信息),client 就会把最新配置 reload 到本地(本进程)
    • 这种情况下,可以看做是中心式配置的客户端限速,工作流如下图所示,

Fig. JuiceFS upload/download data bandwidth control.

3.3 JuiceFS client reload 配置的调用栈

juicefs mount 时注册一个 reload 方法,

mount
 |-metaCli.OnReload
    |-m.reloadCb = append(m.reloadCb, func() {
                                        updateFormat(c)(fmt) //  fmt 是从 TiKV 里面拉下来的最新配置
                                        store.UpdateLimit(fmt.UploadLimit, fmt.DownloadLimit)
                                      })

然后有个后台任务一直在监听 TiKV 里面的配置,一旦发现配置变了就会执行到上面注册的回调方法,

refresh()
  for {
        old := m.getFormat()
        format := m.Load(false) // load from tikv

        if !reflect.DeepEqual(format, old) {
            cbs := m.reloadCb
            for _, cb := range cbs {
                cb(format)
        }
  }

4 限流(metadata 请求)设计

4.1 为什么需要限流?

如下图所示,

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

  • 限速保护的是 5;
  • 限流保护的是 3 & 4

下面我们通过实际例子看看可能会打爆 3 & 4 的几种场景。

4.2 打爆 TiKV API 的几种场景

4.2.1 mlocate (updatedb) 等扫盘工具

一次故障复盘

下面的监控,左边是 TiKV 集群的请求数量,右边是 node CPU 利用率(主要是 PD leader 在用 CPU),

Fig. PD CPU soaring caused by too much requests.

大致时间线,

  • 14:30 开始,kv_get 请求突然飙升,导致 PD leader 节点的 CPU 利用率大幅飙升;
  • 14:40 介入调查,确定暴增的请求来自同一个 volume,但这个 volume 被几十个用户的 pod 挂载, 能联系到的用户均表示 14:30 没有特殊操作;
  • 14:30~16:30 继续联系其他用户咨询使用情况 + 主动排查;期间删掉了几个用户暂时不用的 pod,减少挂载这个 volume 的 juicefs client 数量,请求量有一定下降;
  • 16:30 定位到请求来源
    • 确定暴增的请求不是用户程序读写导致的
    • 客户端大部分都 ubuntu 容器(AI 训练),
    • 使用的是同一个容器镜像,里面自带了一个 daily 的定时 mlocate 任务去扫盘磁盘,

这个扫盘定时任务的时间是每天 14:30,因此把挂载到容器里的 JuiceFS volume 也顺带扫了。 确定这个原因之后,

  • 16:40 开始,逐步强制停掉(pkill -f updatedb.mlocate) 并禁用(mv /etc/cron.daily/mlocate /tmp/)这些扫盘任务, 看到请求就下来了,PD CPU 利用率也跟着降下来了;
  • 第二天早上 6:00 又发生了一次(凌晨 00:00 其实也有一次),后来排查发生是还有几个基础镜像也有这个任务,只是 daily 时间不同。

juicefs mount 时会自动禁用 mlocate,但 CSI 部署方式中部分失效

其实官方已经注意到了 mlocate,所以 juicefs mount 的入口代码就专门有检测,开了之后就自动关闭,

// cmd/mount_unix.go

func mountMain(v *vfs.VFS, c *cli.Context) {
    if os.Getuid() == 0 {
        disableUpdatedb()
          |-path := "/etc/updatedb.conf"
          |-file := os.Open(path)
          |-newdata := ...
          |-os.WriteFile(path, newdata, 0644)
    }
    ...

但是,在 K8s CSI 部署方式中,这个代码是部分失效的

Fig. JuiceFS K8s CSI deployment

JuiceFS per-node daemon 在创建 mount pod 时,会把宿主机的 /etc/updatedb.conf 挂载到 mount pod 里面, 所以它能禁掉宿主机上的 mlocate,

  volumes:
  - hostPath:
      path: /etc/updatedb.conf
      type: FileOrCreate
    name: updatedb

但正如上一小结的例子看到的,业务 pod 里如果开了 updatedb,它就管不到了。 而且业务容器很可能是同一个镜像启动大量 pod,挂载同一个 volume,所以扫描压力直线上升

4.2.2 版本控制工具

类似的工具可能还有版本控制工具(git、svn)、编程 IDE(vscode)等等,威力可能没这么大,但排查时需要留意。

4.3 需求:对元数据引擎的保护能力

以上 case,包括上一篇看到的用户疯狂 update 文件的 case,都暴露出同一个问题: JuiceFS 缺少对元数据引擎的保护能力。

4.3.1 现状:JuiceFS 目前还没有

社区版目前(2024.09)是没有的,企业版不知道有没有。

下面讨论下如果基于社区版,如何加上这种限流能力。

4.4 客户端限流方案设计

Fig. JuiceFS upload/download data bandwidth control.

基于 JuiceFS 已有的设计,再参考其限速实现,其实加上一个限流能力并不难,代码也不多:

  1. 扩展 Format 结构体,增加限流配置;
  2. juicefs format|config 增加配置项,允许配置具体限流值;这会将配置写到元数据引擎里面的 volume setting
  3. juicefs mount 里面解析 setting 里面的限流配置,传给 client 里面的 metadata 模块;
  4. metadata 模块做客户端限流,例如针对 txnkv 里面的不到 10 个方法,在函数最开始的地方增加一个限流检查,allow 再继续,否则就等待。

这是一种(中心式配置的)客户端限流方案。

4.5 服务端限流方案设计

在 TiKV 集群前面挡一层代理,在代理上做限流,属于服务端限流

参考资料

  1. 图解 JuiceFS CSI 工作流:K8s 创建带 PV 的 Pod 时,背后发生了什么(2024)

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎三探:从实践中学习 TiKV 的 MVCC 和 GC(2024)

2024年9月22日 08:00

Fig. TiKV MVCC GC mechanisms.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 概念与实测

1.1 MVCC(多版本并发控制)

来自 wikipedia 的定义

Multiversion concurrency control (MCC or MVCC), is a non-locking concurrency control method commonly used by database management systems to provide concurrent access to the database and in programming languages to implement transactional memory.

TiKV 支持 MVCC,当更新数据时,旧的数据不会被立即删掉,而是新老同时保留,以时间戳来区分版本。 官方有几篇很不错的博客 [1,3]。

下面进行一个简单测试来对 MVCC 有一个初步的直观认识。

1.1.2 TiKV MVCC 测试

参考上一篇,新创建一个新 volume,里面什么文件都没有,有 8 条记录

$ tikv-ctl.sh scan --from 'zfoo' --to 'zfop' | grep "key:" | wc -l
8

然后进入这个 volume 的挂载目录,在里面创建一个文件

$ cd <mount dir>
$ echo 1 > foo.txt

再次扫描这个 volume 对应的所有 keys,

$ tikv-ctl.sh scan --from 'zfoo' --to 'zfop' | grep "key:" | wc -l
16

可以看到变成 16 条记录,比之前多了 8 条。内容如下,依稀能看出大部分条目的用途 (行末的注释是本文加的),

key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfoo.tx\377t\000\000\000\000\000\000\000\370 # foo.txt
key: zfoo-dev\375\377A\002\000\000\000\000\000\000\377\000C\000\000\000\000\000\000\375
key: zfoo-dev\375\377A\002\000\000\000\000\000\000\377\000I\000\000\000\000\000\000\371
key: zfoo-dev\375\377ClastCle\377anupFile\377s\000\000\000\000\000\000\000\370                         # lastCleanupFile
key: zfoo-dev\375\377ClastCle\377anupSess\377ions\000\000\000\000\373                                  # lastCleanupSessions
key: zfoo-dev\375\377CtotalIn\377odes\000\000\000\000\373                                              # totalInodes
key: zfoo-dev\375\377CusedSpa\377ce\000\000\000\000\000\000\371                                        # UsedSpace
key: zfoo-dev\375\377U\001\000\000\000\000\000\000\377\000\000\000\000\000\000\000\000\370

接下来继续更新这个文件 1000 次(每次都是一个整数,由于文件内容极小,不会导致 TiKV 的 region split 等行为),

$ for n in {1..1000}; do echo $n > bar.txt; done

再次查看元数据条目数量:

$ tikv-ctl.sh scan --from 'zfoo' --to 'zfop' | grep key | wc -l
59

多了 43 条。多的条目大致长这样:

key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\231\000\000\000\000\000\000\000\3777\000\000\000\000\000\000\000\370
key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\233\000\000\000\000\000\000\000\377j\000\000\000\000\000\000\000\370
key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\234\000\000\000\000\000\000\000\377\235\000\000\000\000\000\000\000\370
...
key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\271\000\000\000\000\000\000\003\377\362\000\000\000\000\000\000\000\370

TiKV supports MVCC, which means that there can be multiple versions for the same row stored in RocksDB. All versions of the same row share the same prefix (the row key) but have different timestamps as a suffix.

https://tikv.org/deep-dive/key-value-engine/rocksdb/

下面我们再看看执行以上文件更新操作期间,juicefs 客户端的日志。

1.1.2 JuiceFS client 日志

在执行以上 for 循环期间,JuiceFS client 的日志,

$ juicefs mount ...
...
<DEBUG>: PUT chunks/0/0/170_0_4 (req_id: "xx", err: <nil>, cost: 32.002516ms) [cached_store.go:669]
<DEBUG>: PUT chunks/0/0/171_0_4 (req_id: "xx", err: <nil>, cost: 32.002516ms) [cached_store.go:669]
<DEBUG>: PUT chunks/0/0/172_0_4 (req_id: "xx", err: <nil>, cost: 32.002516ms) [cached_store.go:669]
...

这个似乎对应的就是以上多出来的条目。

1.1.3 小结

本节的例子让我们看到,虽然 volume 里面从头到尾只有一个文件, 但随着我们不断覆盖这个文件内的值,元数据引擎 TiKV 内的条目数量就会持续增加。 多出来的这些东西,对应的就是这份数据的多个版本,也就是 MVCC 里面 multi-version 的表现。

显然,没有冲突的话,只保留最后一个版本就行了,其他版本都可以删掉 —— 这就是垃圾回收(GC)的作用。

1.2 GC(垃圾回收)

垃圾回收 (GC) 的功能是清理 MVCC 留下的旧版本。比如同一份数据保存了 1000 个版本,那原则上前面大部分版本都可以清掉了,只保留最新的一个或几个。

那如何判断哪些版本可以安全地清掉呢?TiKV 引入了一个时间戳概念: safepoint

GC is a process to clean up garbage versions (versions older than the configured lifetime) of each row.

https://tikv.org/deep-dive/key-value-engine/rocksdb/

1.3 Safepoint(可安全删除这个时间戳之前的版本)

In order to ensure the correctness of all read and write transactions, and make sure the GC mechanism works, TiKV/TiDB introduced the concept of safe-point. There is a guarantee that all active transactions and future transactions’ timestamp is greater than or equal to the safe-point. It means old versions whose commit-ts is less than the safe-point can be safely deleted by GC. [3]

2 TiKV MVCC GC

以上看到,TiKV 有 GC 功能,但由于其“历史出身”,也存在一些限制。

2.1 历史:从 TiDB 里面拆分出来,功能不完整

TiKV 是从 TiDB 里面拆出来的一个产品,并不是从一开始就作为独立产品设计和开发的。 这导致的一个问题是:MVCC GC 功能在使用上有点蹩脚:

  1. 默认情况下,靠底层 RocksDB 的 compaction 触发 GC,这周触发周期不确定且一般比较长;
  2. TiKV+PD 也内置了另一种 GC 方式,但并不会自己主动去做,而是将 GC 接口暴露出来,靠 TiDB 等在使用 TiKV 的更上层组件来触发(见下节的图);
  3. tikv-ctl/pd-ctl 等等命令行工具也都没有提供 GC 功能,这导致 TiKV 的运维很不方便,比如有问题想快速手动触发时用不了。

下面具体看看 TiKV 中的 GC 设计。

2.2 TiKV GC 设计和配置项

Fig. TiKV MVCC GC mechanisms.

2.2.1 设计:两种 GC 触发方式

  1. 被动 GC:TiKV 底层的 RocksDB compact 时进行垃圾回收。
    • 通过 tikv-server 的 enable-compaction-filter 配置项控制;
    • 默认启用
    • 触发 RocksDB compaction 时才能进行 GC。
    • tikv-ctl compact/compact-cluster 可以手动触发这种 compact,进而 GC。
  2. 半主动 GC:内置了 GC worker,
    • 定期获取 PD 里面的 gc safepoint,然后进行 GC;会占用一些 CPU/IO 资源;
    • PD 不会主动更新这个 gc safepoint,一般是由在使用 TiKV 的更外围组件来更新的,例如 TiDB、JuiceFS 等等;
    • 所以本文把这种方式称为“半主动”。

2.2.2 tikv-server 启动日志中的 GC 配置信息

tikv-server.log

[INFO] [server.rs:274] ["using config"] [config="{..., "enable-compaction-filter":true, ...}"]
[INFO] [compaction_filter.rs:138] ["initialize GC context for compaction filter"]
[INFO] [gc_worker.rs:786] ["initialize compaction filter to perform GC when necessary"]

2.2.3 tikv-ctl compact/compact-cluster 触发被动 GC 例子

# compact-cluster 必须要指定 --pd 参数,因为针对是整个集群。指定 --host 会失败,但没有提示错在哪,TiKV 的命令行工具经常这样
$ tikv-ctl.sh compact-cluster --from 'zfoo' --to 'zfop' 

$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop'
store:"192.168.1.1:20160" compact db:Kv cf:default range:[[122, 122, 121, 110], [122, 122, 121, 111]) success!

$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop' -c default  # 很快
$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop' -c lock     # 很快
store:"192.168.1.1:20160" compact db:Kv cf:lock range:[[122, 122, 121, 110], [122, 122, 121, 111]) success!
$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop' -c write    # 非常慢
store:"192.168.1.1:20160" compact db:Kv cf:write range:[[122, 122, 121, 110], [122, 122, 121, 111]) success!

# 还可以指定本地 TiKV 数据路径直接 compact
# -d: specify the RocksDB that performs compaction. default: kv. Valid values: {kv, raft}
$ tikv-ctl --data-dir /path/to/tikv compact -d kv

2.2.4 小结

“半主动方式”需要外围组件去更新 PD 中的 gc safepoint 信息,这样下面的 TiKV 才会去执行 GC 操作。作为两个具体例子,我们接下来看看 TiDB 和 JuiceFS 在使用 TiKV 时,分别是怎么去更新这个信息的。

2.3 TiDB 中触发 TiKV GC 的方式

TiDB 有 GC 相关的配置和 worker,会按照配置去触发底层的 TiKV GC,

Fig. TiDB SQL layer overview. GC worker is outside of TiKV. Image Source: pingcap.com

更多信息可以参考 [3,4]。

2.4 JuiceFS 触发 TiKV GC 的方式

TiKV 作为元数据引擎时,JuiceFS 并没有使用 TiDB,而是直接使用的 TiKV(和 PD), 所以就需要 JuiceFS client 来触发这个 GC (因为不考虑 CSI 部署方式的话,JuiceFS 就一个客户端组件,也没有其他 long running 服务来做这个事情了)。

Fig. Typical JuiceFS cluster.

2.4.1 定期更新 gc safepoint 的代码

JuiceFS v1.0.4+ 客户端会周期性地设置 PD 中的 gc safepoint,默认是 now-3h,也就是可以删除 3 小时之前的旧版本数据,

// pkg/meta/tkv_tikv.go

func (c *tikvClient) gc() {
    if c.gcInterval == 0 {
        return
    }

    safePoint := c.client.GC(context.Background(), oracle.GoTimeToTS(time.Now().Add(-c.gcInterval)))
}

接下来的调用栈:

gc                                          // github.com/juicedata/juicefs  pkg/meta/tkv_tikv.go
 |-c.client.GC                              // github.com/tikv/client-go     tikv/gc.go
     |-s.pdClient.UpdateGCSafePoint         // github.com/tikv/pd            client/client.go
        |-ctx = grpcutil.BuildForwardContext(ctx, c.GetLeaderAddr())
        |-c.getClient().UpdateGCSafePoint(ctx, req)
                        /
               gRPC    /
          /----<--<----/
         /
UpdateGCSafePoint                           // github.com/tikv/pd  server/grpc_service.go
  |-rc := s.GetRaftCluster()
  |-oldSafePoint := s.storage.LoadGCSafePoint()
  |-s.storage.SaveGCSafePoint(newSafePoint)
              |-key := path.Join(gcPath, "safe_point")  // gcPath = "gc"
              |-value := strconv.FormatUint(safePoint, 16)
              |-return s.Save(key, value)

2.4.2 配置:META URL \?gc-interval=1h

这个 gc-interval 可在 juicefs 挂载卷时加到 TiKV URL 中,

  • 默认值:3h
  • 最小值:1h,设置的值小于这个值会打印一条 warning,然后强制设置为 1h。

juicefs client 挂载时显式设置 gc-interval

$ juicefs mount tikv://localhost:2379\?gc-interval=1h ~/mnt/jfs
<INFO>: Meta address: tikv://localhost:2379?gc-interval=1h [interface.go:491]
<INFO>: TiKV gc interval is set to 1h0m0s [tkv_tikv.go:84]
...

2.4.3 juicefs gc 手动触发 TiKV GC

还可以通过 juicefs gc 子命令来主动触发 TiKV GC。这个例子中设置的时间太短,可以看到被强制改成了允许的最小值 1h

$ juicefs gc tikv://<ip>:2379/foo-dev\?gc-interval=1m --delete
...
<WARNING>: TiKV gc-interval (1m0s) is too short, and is reset to 1h [tkv_tikv.go:133]
<INFO>: TiKV gc interval is set to 1h0m0s [tkv_tikv.go:138]
Cleaned pending slices: 0                      0.0/s
 Pending deleted files: 0                      0.0/s
  Pending deleted data: 0.0 b   (0 Bytes)      0.0 b/s
 Cleaned pending files: 0                      0.0/s
  Cleaned pending data: 0.0 b   (0 Bytes)      0.0 b/s
         Cleaned trash: 0                      0.0/s
Cleaned detached nodes: 0                      0.0/s
         Listed slices: 2047                   4930.4/s
          Trash slices: 2026                   55423.8/s
            Trash data: 7.7 KiB (7883 Bytes)   211.8 KiB/s
  Cleaned trash slices: 0                      0.0/s
    Cleaned trash data: 0.0 b   (0 Bytes)      0.0 b/s
       Scanned objects: 2047/2047 [===========================================]  18138.6/s used: 113.115519ms
         Valid objects: 21                     187.2/s
            Valid data: 85.0 b  (85 Bytes)     758.0 b/s
     Compacted objects: 2026                   18064.2/s
        Compacted data: 7.7 KiB (7883 Bytes)   68.6 KiB/s
        Leaked objects: 0                      0.0/s
           Leaked data: 0.0 b   (0 Bytes)      0.0 b/s
       Skipped objects: 0                      0.0/s
          Skipped data: 0.0 b   (0 Bytes)      0.0 b/s
<INFO>: scanned 2047 objects, 21 valid, 2026 compacted (7883 bytes), 0 leaked (0 bytes), 0 delslices (0 bytes), 0 delfiles (0 bytes), 0 skipped (0 bytes) [gc.go:379]

2.5 外挂组件 github.com/tikv/migration/gc-worker

代码仓库,是个在 TiKV 之上的组件, 从 PD 获取 service safepoint 信息,然后计算 gc safepoint 并更新到 PD,从而触发 TiKV GC。

3 GC 不及时导致的问题一例

这里挑一个典型的问题讨论下。

3.1 问题现象

3.1.1 监控:TiKV db size 暴增,磁盘空间不断减小

如下面监控所示,

Fig. TiKV DB size soaring in a JuiceFS cluster, caused by TiKV GC lagging.

  • TiKV DB size 暴增;
  • TiKV region 分布出现显著变量,总数量也有一定程度上升;
  • TiKV node 可用磁盘空间不断下降。

3.1.2 tikv-server 错误日志:failed to split region

查看 tikv-server 日志,看到一直在刷下面这样的 warning/error:

[WARN] [split_observer.rs:73] ["invalid key, skip"] [err="\"key 6E677... should be in (6E677..., 6E677...)\""] [index=0] [region_id=39179938]
[ERROR] [split_observer.rs:136] ["failed to handle split req"] [err="\"no valid key found for split.\""] [region_id=39179938]
[WARN] [peer.rs:2971] ["skip proposal"] [error_code=KV:Raftstore:Coprocessor] [err="Coprocessor(Other(\"[components/raftstore/src/coprocessor/split_observer.rs:141]: no valid key found for split.\"))"] [peer_id=39179939] [region_id=39179938]

也就是 region split 失败。

3.2 问题排查

  1. 根据日志报错,网上搜到一些帖子,初步了解问题背景(JuiceFS/TiKV 新人,接触没多久);
  2. 对报错日志进行分析,发现:

    • 报错集中在几十个 regiongrep "failed to handle split req" tikv.log | awk '{print $NF}' | sort | uniq -c | sort -n -k1,1),相对总 region 数量很少;
    • pd-ctl region-properties -r <region> 看,发现 start/end key 都来自同一个 volume(命令行操作见下一篇);
    • 根据 volume 监控看,只有一个客户端 set 请求非常高,每秒 400 次请求,而这个 volume 只有几个 GB,可以说非常小;
  3. tikv-ctl mvcc -k <key> 查看有问题的 key,发现超时了,报错说文件(元数据)太大

结合以上三点,判断是某个或少数几个文件的 MVCC 版本太多,导致 TiKV split region 失败,进而不断累积垃圾数据。

3.3 问题根因

以上,猜测直接原因是这个用户 非正常使用 JuiceFS疯狂更新文件,也就是我们 1.1 中例子的极端版。 这导致部分文件的历史版本极其多,TiKV 在 auto split region 时失败。网上也有一些类似的 case(大部分是 TiDB 用户)。

但本质上,还是因为 TiKV 的 GC 太滞后,

  1. 被动 GC(RocksDB compact 方式)的频率不可控,跟集群所有客户端的总 write/update/delete 行为有关;
  2. JuiceFS 的主动 GC 频率太慢,跟不上某些文件的版本增长速度。

    • JuiceFS 默认 now-3h,最小 now-1h,也就是至少会保留一个小时内的所有版本(实际上我们是有个外部服务在定期更新 PD 的 gc safepoint,但也是设置的 now-1h);
    • 根据监控看,异常的 juicefs client 每秒有 400+ set 请求,一个小时就是 144w 次的更新(这些请求更新的文件很集中)。

3.4 解决方式

  1. 写了个程序,允许以非常小的粒度去更新 PD 的 gc safepoint,例如 now-5m, 也就是最多保留最近 5 分钟内的版本,其他的都删掉;这一步下去就有效果了,先稳住了,DB 不再增长,开始缓慢下降;
  2. 通知用户去处理那个看起来异常的客户端(我们没权限登录用户的机器,客户端不可控,这是另一个问题了)。

1+2,DB 开始稳步下降,最终完全恢复正常。

3.5 问题小结

对于 TiKV 这种 MVCC 的元数据引擎来说,JuiceFS 的一条元数据可能会保留多个版本,老版本什么时候删掉很大程度上依赖外部 GC 触发。 如果 GC 间隔太长 + 文件更新太频繁,单条元数据极端情况下就可以占几个 GB,这时候不仅 DB size 暴大,还会导致 TiKV split region 工作不正常。

4 问题讨论

前面看到,JuiceFS 支持配置 TiKV 的 GC 间隔,但从管理和运维层面,这里面也有几个问题可以探讨。

4.1 允许的最小 GC 间隔太大

目前最小是 now-1h,极端情况会导致第 3 节中的问题,TiKV DB size 暴增,集群被打爆。

4.2 GC 配置放在客户端,增加了用户的认知负担和学习成本

  • 用户必需感知 TiKV gc 这个东西,增加认知成本和使用负担;

    用户只是用 JuiceFS volume 读写文件,原则上没有必要去知道 JuiceFS 集群用什么元数据引擎, 甚至还必现了解这种元数据引擎的 GC 知识,后者都是 JuiceFS 集群管理员需要关心和解决的;

  • 用户如果没有配置,就只完全依赖 RocksDB compaction 来 GC,更容易触发版本太多导致的问题。

4.3 管理员运维困境

用户一旦没有显式配置 gc-interval(使用很大的默认值),TiKV 可能就被打爆, 这种情况下用户不知道,管理员知道但可能没短平快的解决办法(不一定有权限管理用户的机器)。

4.4 小结

对集群管理员来说,更好的方式可能是,

  1. 有个(内部或外部)服务,可以按管理员的需求随时和/或定时去 GC;
  2. 用户侧完全不用感知这个事情;
  3. 有 Meta 操作的限流能力(可以隔离有问题的 volume 或 client),下一篇讨论。

参考资料

  1. MVCC in TiKV, pingcap.com, 2016
  2. JuiceFS 元数据引擎最佳实践:TiKV, juicefs.com
  3. Deep Dive into Distributed Transactions in TiKV and TiDB, medium.com, 2024
  4. MVCC garbage collection, TiDB doc, 2024

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎再探:开箱解读 TiKV 中的 JuiceFS 元数据(2024)

2024年9月12日 08:00

Fig. JuiceFS upload/download data bandwidth control.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



有了第一篇的铺垫,本文直接进入正题。

  • 首先创建一个 volume,然后在其中做一些文件操作,然后通过 tikv-ctl 等工具在 TiKV 中查看对应的元数据。
  • 有了这些基础,我们再讨论 JuiceFS metadata key 和 TiKV 的编码格式。

之前有一篇类似的,开箱解读 etcd 中的 Cilium 元数据: What’s inside Cilium Etcd (kvstore)

1 创建一个 volume

创建一个名为 foo-dev 的 JuiceFS volume。

1.1 JuiceFS client 日志

用 juicefs client 的 juicefs format 命令创建 volume,

$ juicefs format --storage oss --bucket <bucket> --access-key <key> --secret-key <secret key> \
  tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev foo-dev

<INFO>: Meta address: tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev
<INFO>: Data use oss://xxx/foo-dev/
<INFO>: Volume is formatted as {
  "Name": "foo-dev",
  "UUID": "ec843b",
  "Storage": "oss",
  "BlockSize": 4096,
  "MetaVersion": 1,
  "UploadLimit": 0,
  "DownloadLimit": 0,
  ...
}
  • 对象存储用的是阿里云 OSS;
  • TiKV 地址指向的是 PD 集群地址,上一篇已经介绍过,2379 是 PD 接收客户端请求的端口;

1.2 JuiceFS client 中的 TiKV/PD client 初始化/调用栈

下面我们进入 JuiceFS 代码,看看 JuiceFS client 初始化和连接到元数据引擎的调用栈:

mount
 |-metaCli = meta.NewClient
 |-txnkv.NewClient(url)                                          // github.com/juicedata/juicefs: pkg/meta/tkv_tikv.go
 |  |-NewClient                                                  // github.com/tikv/client-go:    txnkv/client.go
 |     |-pd.NewClient                                            // github.com/tikv/client-go:    tikv/kv.go
 |     |    |-NewClient                                          // github.com/tikv/pd:           client/client.go
 |     |       |-NewClientWithContext                            // github.com/tikv/pd:           client/client.go
 |     |          |-createClientWithKeyspace                     // github.com/tikv/pd:           client/client.go
 |     |             |-c.pdSvcDiscovery = newPDServiceDiscovery  // github.com/tikv/pd:           client/pd_xx.go
 |     |             |-c.setup()                                 // github.com/tikv/pd:           client/pd_xx.go
 |     |                 |-c.pdSvcDiscovery.Init()
 |     |                 |-c.pdSvcDiscovery.AddServingURLSwitchedCallback
 |     |                 |-c.createTokenDispatcher()
 |     |-spkv, err := tikv.NewEtcdSafePointKV
 |     |-tikv.NewRPCClient
 |     |-tikv.NewKVStore(uuid, pdClient, spkv, rpcClient)        // github.com/tikv/client-go:    tikv/kv.go
 |         |-oracles.NewPdOracle
 |         |-store := &KVStore{}
 |         |-go store.runSafePointChecker()
 |         |     |-check key "/tidb/store/gcworker/saved_safe_point" from etcd every 10s
 |         |-go store.safeTSUpdater()
 |-metaCli.NewSession
    |-doNewSession
       |-m.setValue(m.sessionKey(m.sid), m.expireTime())  // SE
       |-m.setValue(m.sessionInfoKey(m.sid), sinfo)       // SI

这里面连接到 TiKV/PD 的代码有点绕,

  • 传给 juicefs client 的是 PD 集群地址
  • 但代码使用的是 tikv 的 client-go 包,创建的是一个 tikv transaction client
  • 这个 tikv transaction client 里面会去创建 pd client 连接到 PD 集群,

所以,架构上看 juicefs 是直连 PD,但实现上并没有直接创建 pd client, 也没有直接使用 pd 的库。

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

1.3 tikv-ctl 查看空 volume 的系统元数据

现在再把目光转到 TiKV。看看这个空的 volume 在 TiKV 中对应哪些元数据:

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop'
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000I\000\000\000\000\000\000\371  # attr?
key: zfoo-dev\375\377ClastCle\377anupSess\377ions\000\000\000\000\373                    # lastCleanupSessions
key: zfoo-dev\375\377CnextChu\377nk\000\000\000\000\000\000\371                          # nextChunk
key: zfoo-dev\375\377CnextIno\377de\000\000\000\000\000\000\371                          # nextInode
key: zfoo-dev\375\377CnextSes\377sion\000\000\000\000\373                                # nextSession
key: zfoo-dev\375\377SE\000\000\000\000\000\000\377\000\001\000\000\000\000\000\000\371  # session
key: zfoo-dev\375\377SI\000\000\000\000\000\000\377\000\001\000\000\000\000\000\000\371  # sessionInfo
key: zfoo-dev\375\377setting\000\376                                                     # setting

以上就是我们新建的 volume foo-dev 的所有 entry 了。 也就是说一个 volume 创建出来之后,默认就有这些 JuiceFS 系统元数据

TiKV 中的每个 key 都经过了两层编码(JuiceFS 和 TiKV),我们后面再介绍编码规则。 就目前来说,根据 key 中的字符还是依稀能看出每个 key 是干啥用的, 为方便起见直接注释在上面每行的最后了。比如,下面两个 session 相关的 entry 就是上面调用栈最后两个创建的:

  • session
  • sessionInfo

1.4 例子:tikv-ctl mvcc 解码 volume setting 元数据

TiKV 中的每个 entry 都是 key/value。现在我们尝试解码最后一个 entry,key 是 zfoo-dev\375\377setting\000\376, 我们来看看它的 value —— 也就是它的内容 —— 是什么

$ value_hex=$(./tikv-ctl.sh mvcc -k 'zfoo-dev\375\377setting\000\376' --show-cf=default | awk '/default cf value:/ {print $NF}')
$ value_escaped=$(./tikv-ctl.sh --to-escaped $value_hex)
$ echo -e $value_escaped | sed 's/\\"/"/g' | jq .

输出:

{
  "Name": "foo-dev",
  "UUID": "1ce2973b",
  "Storage": "S3",
  "Bucket": "http://xx/bucket",
  "AccessKey": "xx",
  "SecretKey": "xx",
  "BlockSize": 4096,
  "MetaVersion": 1,
  "UploadLimit": 0,
  "DownloadLimit": 0,
  ...
}

可以看到是个 JSON 结构体。这其实就是这个 volume 的配置信息。如果对 JuiceFS 代码有一定了解, 就会看出来它对应的其实就是 type Format 这个 struct。

1.4.1 对应 JuiceFS Format 结构体

// https://github.com/juicedata/juicefs/blob/v1.2.0/pkg/meta/config.go#L72

type Format struct {
    Name             string
    UUID             string
    Storage          string
    StorageClass     string `json:",omitempty"`
    Bucket           string
    AccessKey        string `json:",omitempty"`
    SecretKey        string `json:",omitempty"`
    SessionToken     string `json:",omitempty"`
    BlockSize        int
    Compression      string `json:",omitempty"`
    Shards           int    `json:",omitempty"`
    HashPrefix       bool   `json:",omitempty"`
    Capacity         uint64 `json:",omitempty"`
    Inodes           uint64 `json:",omitempty"`
    UploadLimit      int64  `json:",omitempty"` // Mbps
    DownloadLimit    int64  `json:",omitempty"` // Mbps
    ...
}

2 将 volume 挂载(mount)到机器

接下来我们找一台机器,把这个 volume 挂载上去,这样就能在这个 volume 里面读写文件了。

2.1 JuiceFS client 挂载日志

$ juicefs mount --verbose --backup-meta 0 tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev /tmp/foo-dev
<INFO>:  Meta address: tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev [interface.go:406]
<DEBUG>: Creating oss storage at endpoint http://<url> [object_storage.go:154]
<INFO>:  Data use oss://xx/foo-dev/ [mount.go:497]
<INFO>:  Disk cache (/var/jfsCache/ec843b85/): capacity (10240 MB), free ratio (10%), max pending pages (15) [disk_cache.go:94]
<DEBUG>: Scan /var/jfsCache/ec843b85/raw to find cached blocks [disk_cache.go:487]
<DEBUG>: Scan /var/jfsCache/ec843b85/rawstaging to find staging blocks [disk_cache.go:530]
<DEBUG>: Found 8 cached blocks (32814 bytes) in /var/jfsCache/ec843b85/ with 269.265µs [disk_cache.go:515]
<INFO>:  Create session 4 OK with version: 1.2.0 [base.go:279]
<INFO>:  Prometheus metrics listening on 127.0.0.1:34849 [mount.go:165]
<INFO>:  Mounting volume foo-dev at /tmp/foo-dev ... [mount_unix.go:203]
<INFO>:  OK, foo-dev is ready at /tmp/foo-dev [mount_unix.go:46]

可以看到成功挂载到了本机路径 /tmp/foo-dev/

2.2 查看挂载信息

$ mount | grep juicefs
JuiceFS:foo-dev on /tmp/foo-dev type fuse.juicefs (rw,relatime,user_id=0,group_id=0,default_permissions,allow_other)

$ cd /tmp/foo-dev
$ ls # 空目录

2.3 查看 JuiceFS 隐藏(系统)文件

新建的 volume 里面其实有几个隐藏文件:

$ cd /tmp/foo-dev
$ ll
-r-------- 1 root root  .accesslog
-r-------- 1 root root  .config
-r--r--r-- 1 root root  .stats
dr-xr-xr-x 2 root root  .trash/

2.3.1 .accesslog

可以通过 cat 这个文件看到一些 JuiceFS client 底层的操作日志,我们一会会用到。

2.3.2 .config

包括 Format 在内的一些 volume 配置信息:

$ cat .config
{
 "Meta": {
  "Strict": true,
  "Retries": 10,
  "CaseInsensi": false,
  "ReadOnly": false,
  "NoBGJob": false,
  "OpenCache": 0,
  "Heartbeat": 12000000000,
  "MountPoint": "/tmp/foo-dev",
  "Subdir": "",
  "CleanObjFileLever": 1
 },
 "Format": {
  "Name": "foo-dev",
  "UUID": "ec843b85",
  "Storage": "oss",
  "Bucket": "http://<url>",
  "UploadLimit": 0,
  "DownloadLimit": 0,
  ...
 },
 "Chunk": {
  "CacheDir": "/var/jfsCache/ec843b85",
  "CacheMode": 384,
  "CacheSize": 10240,
  "FreeSpace": 0.1,
  "AutoCreate": true,
  "Compress": "none",
  "MaxUpload": 20,
  "MaxDeletes": 2,
  "MaxRetries": 10,
  "UploadLimit": 0,
  "DownloadLimit": 0,
  "Writeback": false,
  "UploadDelay": 0,
  "HashPrefix": false,
  "BlockSize": 4194304,
  "GetTimeout": 60000000000,
  "PutTimeout": 60000000000,
  "CacheFullBlock": true,
  "BufferSize": 314572800,
  "Readahead": 0,
  "Prefetch": 1,
  "UseMountUploadLimitConf": false,
  "UseMountDownloadLimitConf": false
 },
 "Version": "1.2.0",
 "AttrTimeout": 1000000000,
 "DirEntryTimeout": 1000000000,
 "EntryTimeout": 1000000000,
 "BackupMeta": 0,
 "HideInternal": false
}

2.3.3 .stats

cat 能输出一些 prometheus metrics:

$ cat .stats
...
juicefs_uptime 374.021754516
juicefs_used_buffer_size_bytes 0
juicefs_used_inodes 7
juicefs_used_space 28672

用 prometheus 采集器把这个数据收上去,就能在 grafana 上展示 volume 的各种内部状态。

2.3.4 .trash

类似于 Windows 的垃圾箱。如果启用了,删掉的文件会在里面保存一段时间再真正从对象存储删掉。

3 创建、更新、删除文件

接下来做一些文件操作,看看 TiKV 中对应元数据的变化。

3.1 创建文件

3.1.1 创建文件

$ cd /tmp/foo-dev
$ echo test3 > file3.txt

3.1.2 JuiceFS .accesslog

$ cat .accesslog
[uid:0,gid:0,pid:169604] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585251,1725585251,4096]) <0.001561>
[uid:0,gid:0,pid:169604] lookup (1,file3.txt): no such file or directory <0.000989>
[uid:0,gid:0,pid:169604] create (1,file3.txt,-rw-r-----:0100640): OK (103,[-rw-r-----:0100640,1,0,0,1725585318,1725585318,1725585318,0]) [fh:27] <0.003850>
[uid:0,gid:0,pid:169604] flush (103,27): OK <0.000005>
[uid:0,gid:0,pid:169604] write (103,6,0,27): OK <0.000048>
[uid:0,gid:0,pid:169604] flush (103,27): OK <0.026205>
[uid:0,gid:0,pid:0     ] release (103): OK <0.000006>
[uid:0,gid:0,pid:169749] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585318,1725585318,4096]) <0.000995>
[uid:0,gid:0,pid:169750] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585318,1725585318,4096]) <0.001219>

3.1.3 TiKV 元数据

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
...
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372
...

可以看到 meta 中多了几条元数据,依稀可以分辨出对应的就是我们创建的文件,

  1. 这个 key 经过了 juicefs 和 tikv 两次编码,
  2. 简单来说,它是 volume + 0xFD(8 进制的 \375)+ 文件名 + tikv 编码,最终得到的就是上面看到的这个 key。

对应的 value 一般长这样:

$ ./tikv-ctl.sh mvcc -k 'zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372' --show-cf default,lock,write
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372
         write cf value: start_ts: 452330816414416901 commit_ts: 452330816414416903 short_value: 010000000000000002

先粗略感受一下,后面再具体介绍 key/value 的编解码规则。

3.2 删除文件操作

3.2.1 删除文件

rm file4.txt

3.2.2 JuiceFS .accesslog

$ cat .accesslog
[uid:0,gid:0,pid:169604] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585532,1725585532,4096]) <0.001294>
[uid:0,gid:0,pid:169902] lookup (1,file4.txt): OK (104,[-rw-r-----:0100640,1,0,0,1725585532,1725585532,1725585532,6]) <0.001631>
[uid:0,gid:0,pid:169902] unlink (1,file4.txt): OK <0.004206>
[uid:0,gid:0,pid:169904] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585623,1725585623,4096]) <0.000718>
[uid:0,gid:0,pid:169905] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585623,1725585623,4096]) <0.000843>

3.2.3 TiKV 元数据

对应的元数据就从 TiKV 删掉了。

3.3 更新(追加)文件

3.3.1 更新文件

$ echo test3 >> file3.txt

3.3.2 JuiceFS .accesslog

$ cat .accesslog
[uid:0,gid:0,pid:169604] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585623,1725585623,4096]) <0.001767>
[uid:0,gid:0,pid:169604] lookup (1,file3.txt): OK (103,[-rw-r-----:0100640,1,0,0,1725585318,1725585318,1725585318,6]) <0.001893>
[uid:0,gid:0,pid:169604] open (103): OK [fh:51] <0.000884>
[uid:0,gid:0,pid:169604] flush (103,51): OK <0.000011>
[uid:0,gid:0,pid:169604] write (103,6,6,51): OK <0.000068>
[uid:0,gid:0,pid:169604] flush (103,51): OK <0.036778>
[uid:0,gid:0,pid:0     ] release (103): OK <0.000024>

3.3.3 TiKV 元数据

  • 如果追加的内容不多,TiKV 中还是那条元数据,但 value 会被更新;
  • 如果追加的内容太多(例如几百兆),文件就会被切分,这时候元数据就会有多条了。

4 元数据操作和 TiKV key/value 编码规则

上一节简单看了下创建、更新、删除 volume 中的文件,TiKV 中对应的元数据都有什么变化。 我们有意跳过了 key/value 是如何编码的,这一节就来看看这块的内容。

4.1 JuiceFS key 编码规则

4.1.1 每个 key 的公共前缀:<vol_name> + 0xFD

TiKV 客户端初始化:每个 key 的 base 部分<vol_name> + 0xFD

// pkg/meta/tkv_tikv.go

func init() {
    Register("tikv", newKVMeta)
    drivers["tikv"] = newTikvClient
}

func newTikvClient(addr string) (tkvClient, error) {
    client := txnkv.NewClient(strings.Split(tUrl.Host, ","))
    prefix := strings.TrimLeft(tUrl.Path, "/")
    return withPrefix(&tikvClient{client.KVStore, interval}, append([]byte(prefix), 0xFD)), nil
}

4.1.2 每个 key 后面的部分

根据对应的是文件、目录、文件属性、系统元数据等等,会有不同的编码规则:

// pkg/meta/tkv.go

/**
  Ino     iiiiiiii
  Length  llllllll
  Indx    nnnn
  name    ...
  sliceId cccccccc
  session ssssssss
  aclId   aaaa

All keys:
  setting            format
  C...               counter
  AiiiiiiiiI         inode attribute
  AiiiiiiiiD...      dentry
  AiiiiiiiiPiiiiiiii parents // for hard links
  AiiiiiiiiCnnnn     file chunks
  AiiiiiiiiS         symlink target
  AiiiiiiiiX...      extented attribute
  Diiiiiiiillllllll  delete inodes
  Fiiiiiiii          Flocks
  Piiiiiiii          POSIX locks
  Kccccccccnnnn      slice refs
  Lttttttttcccccccc  delayed slices
  SEssssssss         session expire time
  SHssssssss         session heartbeat // for legacy client
  SIssssssss         session info
  SSssssssssiiiiiiii sustained inode
  Uiiiiiiii          data length, space and inodes usage in directory
  Niiiiiiii          detached inde
  QDiiiiiiii         directory quota
  Raaaa                 POSIX acl
*/

具体可以再看看这个文件中的代码。

4.1.3 最终格式:字节序列

// pkg/meta/tkv.go

func (m *kvMeta) fmtKey(args ...interface{}) []byte {
    b := utils.NewBuffer(uint32(m.keyLen(args...)))
    for _, a := range args {
        switch a := a.(type) {
        case byte:
            b.Put8(a)
        case uint32:
            b.Put32(a)
        case uint64:
            b.Put64(a)
        case Ino:
            m.encodeInode(a, b.Get(8))
        case string:
            b.Put([]byte(a))
        default:
            panic(fmt.Sprintf("invalid type %T, value %v", a, a))
        }
    }
    return b.Bytes()
}

4.2 TiKV 对 JuiceFS key 的进一步编码

JuiceFS client 按照以上规则拼好一个 key 之后,接下来 TiKV 会再进行一次编码:

  1. 加一些 TiKV 的前缀,例如给文件 key 加个 z 前缀;

  2. 转义,例如 8 个字节插入一个 \377(对应 0xFF),不够 8 字节的补全等等;

最终得到的就是我们用 tikv-ctl scan 看到的那些 key。

4.3 例子:查看特殊元数据:volume 的 setting/format 信息

JuiceFS 的 Format 配置保存在 tikv 中,原始 key 是 setting,经过以上两层编码就变成了下面的样子:

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 452330324173520898 value: 7B0A22...

其中的 value 是可以解码出来的,

# hex -> escaped string
$ ./tikv-ctl.sh --to-escaped '7B0A22...'
{\n\"Name\": \"foo-dev\",\n\"UUID\": \"8cd1ac73\",\n\"Storage\": \"S3\",\n\"Bucket\": \"http://xxx\",\n\"AccessKey\": \"...\",\n\"BlockSize\": 4096,\n\"Compression\": \"none\",\n\"KeyEncrypted\": true,\n\"MetaVersion\": 1,\n\"UploadLimit\": 0,\n\"DownloadLimit\": 0,\n\"\": \"\"\n}

对应的就是 pkg/meta/config.go 中的 Format 结构体。

5 总结

本文结合一些具体 JuiceFS 操作,分析了 TiKV 内的元数据格式与内容。

参考资料

  1. What’s inside Cilium Etcd (kvstore)

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎初探:高层架构、引擎选型、读写工作流(2024)

2024年9月12日 08:00

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 JuiceFS 高层架构与组件

Fig. JuiceFS components and architecutre.

如图,最粗的粒度上可以分为三个组件。

1.1 JuiceFS client

  • juicefs format ... 可以创建一个 volume;
  • juicefs config ... 可以修改一个 volume 的配置;
  • juicefs mount ... 可以把一个 volume 挂载到机器上,然后用户就可以在里面读写文件了;

1.2 Metatdata engine(元数据引擎)

  • 用于存储 JuiceFS 的元数据,例如每个文件的文件名、最后修改时间等等;
  • 可选择 etcd、TiKV 等等;

1.3. Object store

实际的对象存储,例如 S3、Ceph、阿里云 OSS 等等,存放 JuiceFS volume 内的数据。

2 JuiceFS 元数据存储引擎对比:tikv vs. etcd

2.1 设计与优缺点对比

  TiKV as metadata engine etcd as metadata engine
管理节点(e.g. leader election) PD (TiKV cluster manager) etcd server
数据节点(存储 juicefs metadata) TiKV server etcd server
数据节点对等 无要求 完全对等
数据一致性粒度 region-level (TiKV 的概念,region < node) node-level
Raft 粒度 region-level (multi-raft,TiKV 的概念) node-level
缓存多少磁盘数据在内存中 一部分 所有
集群支持的最大数据量 PB 级别 几十 GB 级别
性能(JuiceFS 场景) 高(猜测是因为 raft 粒度更细,并发读写高)
维护和二次开发门槛 高(相比 etcd)
流行度 & 社区活跃度 低(相比 etcd)
适用场景 大和超大 JuiceFS 集群 中小 JuiceFS 集群

2.2 几点解释

etcd 集群,

  • 每个节点完全对等,既负责管理又负责存储数据;
  • 所有数据全部缓存在内存中,每个节点的数据完全一致。 这一点限制了 etcd 集群支持的最大数据量和扩展性, 例如现在官网还是建议不要超过 8GB(实际上较新的版本在技术上已经没有这个限制了, 但仍受限于机器的内存)。

TiKV 方案可以可以理解成把管理和数据存储分开了,

  • PD 可以理解为 TiKV cluster manager,负责 leader 选举、multi-raft、元数据到 region 的映射等等;
  • 节点之间也不要求对等,PD 按照 region(比如 96MB)为单位,将 N(默认 3)个副本放到 N 个 TiKV node 上,而实际上 TiKV 的 node 数量是 M,M >= N
  • 数据放在 TiKV 节点的磁盘,内存中只缓存一部分(默认是用机器 45% 的内存,可控制)。

2.3 例子:TiKV 集群 engine size 和内存使用监控

TiKV 作为存储引擎,总结成一句话就是:根据硬件配置干活,能者多劳 —— 内存大、磁盘大就多干活,反之就少干活。

下面的监控展示是 7 台 TiKV node 组成的一个集群,各 node 内存不完全一致: 3 台 256GB 的,2 台 128GB 的,2 台 64GB 的, 可以看到每个 TiKV server 确实只用了各自所在 node 一半左右的内存:

Fig. TiKV engine size and memory usage of a 7-node (with various RAMs) cluster.

3 JuiceFS + TiKV:集群启动和宏观读写流程

3.1 架构

用 TiKV 作为元数据引擎,架构如下(先忽略其中的细节信息,稍后会介绍):

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

3.2 TiKV 集群启动

3.2.1 TiKV & PD 配置差异

两个组件的几个核心配置项,

$ cat /etc/tikv/pd-config.toml
name = "pd-node1"
data-dir = "/var/data/pd"

client-urls = "https://192.168.1.1:2379" # 客户端(例如 JuiceFS)访问 PD 时,连接这个地址
peer-urls   = "https://192.168.1.1:2380" # 其他 PD 节点访问这个 PD 时,连接这个地址,也就是集群内互相通信的地址

# 创建集群时的首批 PD
initial-cluster-token = "<anything you like>"
initial-cluster = "pd-node1=https://192.168.1.3:2380,pd-node2=https://192.168.1.2:2380,pd-node3=https://192.168.1.1:2380"

可以看到,PD 的配置和 etcd 就比较类似,需要指定其他 PD 节点地址,它们之间互相通信。

TiKV 节点(tikv-server)的配置就不一样了,

$ cat /etc/tikv/tikv-config.toml
[pd]
endpoints = ["https://192.168.1.1:2379", "https://192.168.1.2:2379", "https://192.168.1.3:2379"]

[server]
addr = "192.168.1.1:20160"        # 服务地址,JuiceFS client 会直接访问这个地址读写数据
status-addr = "192.168.1.1:20180" # prometheus 

可以看到,

  1. TiKV 会配置所有 PD 节点的地址,以便自己注册到 PD 作为一个数据节点(存储JuiceFS 元数据);
  2. TiKV 还会配置一个地址的 server 地址,这个读写本节点所管理的 region 内的数据用的; 正常流程是 JuiceFS client 先访问 PD,拿到 region 和 tikv-server 信息, 然后再到 tikv-server 来读写数据(对应 JuiceFS 的元数据);
  3. TiKV 不会配置其他 TiKV 节点的地址,也就是说 TiKV 节点之间不会 peer-to-peer 互连。 属于同一个 raft group 的多个 region 通信,也是先通过 PD 协调的,最后 region leader 才发送数据给 region follower。 详见 [1]。

3.2.2 服务启动

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

对应图中 step 1 & 2:

  • step 1. PD 集群启动,选主;
  • step 2. TiKV 节点启动,向 PD 注册;每个 TiKV 节点称为一个 store,也就是元数据仓库。

3.3 宏观读写流程

对应图中 step 3~5:

  • step 3. JuiceFS 客户端连接到 PD;发出读写文件请求;

    • JuiceFS 客户端中会初始化一个 TiKV 的 transaction kv client,这里面又会初始化一个 PD client,
    • 简单来说,此时 JuiceFS 客户端就有了 PD 集群的信息,例如哪个文件对应到哪个 region,这个 region 分布在哪个 TiKV 节点上,TiKV 服务端连接地址是多少等等;
  • step 4. JuiceFS (内部的 TiKV 客户端)直接向 TiKV 节点(准确说是 region leader)发起读写请求;
  • step 5. 元数据处理完成,JuiceFS 客户端开始往对象存储里读写文件。

4 TiKV 内部数据初探

TiKV 内部存储的都是 JuiceFS 的元数据。具体来说又分为两种:

  1. 用户文件的元数据:例如用户创建了一个 foo.txt,在 TiKV 里面就会对应一条或多条元数据来描述这个文件的信息;
  2. JuiceFS 系统元数据:例如每个 volume 的配置信息,这些对用户是不可见的。

TiKV 是扁平的 KV 存储,所以以上两类文件都放在同一个扁平空间,通过 key 访问。 本文先简单通过命令看看里面的元数据长什么样,下一篇再结合具体 JuiceFS 操作来深入解读这些元数据。

4.1 简单脚本 tikv-ctl.sh/pd-ctl.sh

简单封装一下对应的命令行工具,使用更方便,

$ cat pd-ctl.sh
tikv-ctl \
        --ca-path /etc/tikv/pki/root.crt --cert-path /etc/tikv/pki/tikv.crt --key-path /etc/tikv/pki/tikv.key \
        --host 192.168.1.1:20160 \
        "$@"

$ cat pd-ctl.sh
pd-ctl \
        --cacert /etc/tikv/pki/root.crt --cert /etc/tikv/pki/pd.crt --key /etc/tikv/pki/pd.key \
        --pd https://192.168.1.1:2379  \
        "$@"

4.2 tikv-ctl scan 扫描 key/value

tikv-ctl 不支持只列出所有 keys,所以只能 key 和 value 一起打印(扫描)。

扫描前缀是 foo 开头的所有 key:

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
...
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile4.\377txt\000\000\000\000\000\372
...
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 452330324173520898 value: 7B0A22...

扫描的时候一定要在 key 前面加一个 z 前缀,这是 TiKV 的一个设计

The raw-scan command scans directly from the RocksDB. Note that to scan data keys you need to add a ‘z’ prefix to keys.

代码出处 components/keys/src/lib.rs。 但对用户来说不是太友好,暴露了太多内部细节,没有 etcdctl 方便直接。

4.3 tikv-ctl mvcc 查看给定 key 对应的 value

$ ./tikv-ctl.sh mvcc -k 'zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile1.\377txt\000\000\000\000\000\372' --show-cf default,lock,write
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile1.\377txt\000\000\000\000\000\372
         write cf value: start_ts: 452330816414416901 commit_ts: 452330816414416903 short_value: 010000000000000002

CF 是 column family 的缩写,进一步了解,可参考 Google bigtable 中关于 CF 的定义 译 | Bigtable: A Distributed Storage System for Structured Data (OSDI, 2006)

4.4 tikv-ctl --decode <key> 解除字符转义

# tikv escaped format -> raw format
./tikv-ctl.sh --decode 'foo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile4.\377txt\000\000\000\000\000\372'
foo-dev\375A\001\000\000\000\000\000\000\000Dfile4.txt

4.5 tikv-ctl --to-hex:转义表示 -> 十六进制表示

$ ./tikv-ctl.sh --to-hex '\375'
FD

4.6 tikv-ctl --to-escaped <value>:十六进制 value -> 带转义的字符串

./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 452330324173520898 value: 7B0A22...

其中的 value 是可以解码出来的,

# hex -> escaped string
$ ./tikv-ctl.sh --to-escaped '7B0A22...'
{\n\"Name\": \"...\",\n\"UUID\": \"8cd1ac73\",\n\"Storage\": \"S3\",\n\"Bucket\": \"http://xxx\",\n\"AccessKey\": \"...\",\n\"BlockSize\": 4096,\n\"Compression\": \"none\",\n\"KeyEncrypted\": true,\n\"MetaVersion\": 1,\n\"UploadLimit\": 0,\n\"DownloadLimit\": 0,\n\"\": \"\"\n}

5 总结

本文介绍了一些 JuiceFS 元数据引擎相关的内容。

参考资料

  1. A Deep Dive into TiKV, 2016, pincap.com

Written by Human, Not by AI Written by Human, Not by AI

GPU 进阶笔记(四):NVIDIA GH200 芯片、服务器及集群组网(2024)

2024年8月19日 08:00

记录一些平时接触到的 GPU 知识。由于是笔记而非教程,因此内容不求连贯,有基础的同学可作查漏补缺之用。

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 传统原厂 GPU 服务器:Intel/AMD x86 CPU + NVIDIA GPU

2024 之前,不管是 NVIDIA 原厂还是第三方服务器厂商的 NVIDIA GPU 机器,都是以 x86 CPU 机器为底座, GPU 以 PCIe 板卡或 8 卡模组的方式连接到主板上,我们在第一篇中有过详细介绍,

典型 8 卡 A100 主机硬件拓扑

这时 CPU 和 GPU 是独立的,服务器厂商只要买 GPU 模组(例如 8*A100),都可以自己组装服务器。 至于 Intel/AMD CPU 用哪家,就看性能、成本或性价比考虑了。

2 新一代原厂 GPU 服务器:NVIDIA CPU + NVIDIA GPU

随着 2024 年 NVIDIA GH200 芯片的问世,NVIDIA 的 GPU 开始自带 CPU 了。

  • 桌面计算机时代:CPU 为主,GPU(显卡)为辅,CPU 芯片中可以集成一块 GPU 芯片, 叫集成显卡
  • AI 数据中心时代:GPU 反客为主,CPU 退居次席,GPU 芯片/板卡中集成 CPU。

所以 NVIDIA 集成度越来越高,开始提供整机或整机柜。

2.1 CPU 芯片:Grace (ARM)

基于 ARMv9 设计。

2.2 GPU 芯片:Hopper/Blackwell/…

比如 Hopper 系列,先出的 H100-80GB,后面继续迭代:

  1. H800:H100 的阉割版,
  2. H200:H100 的升级版,
  3. H20:H200 的阉割版,比 H800 还差,差多了。

算力对比:GPU Performance (Data Sheets) Quick Reference (2023)

2.3 芯片产品(命名)举例

2.3.1 Grace CPU + Hopper 200 (H200) GPU:GH200

一张板子:

NVIDIA GH200 芯片(板卡)渲染图。左:Grace CPU 芯片;右:Hopper GPU 芯片 [2]

2.3.2 Grace CPU + Blackwell 200 (B200) GPU:GB200

一个板子(模块),功耗太大,自带液冷:

NVIDIA GB200 渲染图,一个模块包括 2 Grace CPU + 4 B200 GPU,另外自带了液冷模块。 [3]

72 张 B200 组成一个原厂机柜 NVL72:

NVIDIA GB200 NVL72 机柜。 [3]

3 GH200 服务器内部设计

3.1 GH200 芯片逻辑图:CPU+GPU+RAM+VRAM 集成到单颗芯片

NVIDIA GH200 芯片(单颗)逻辑图。[2]

3.1.1 核心硬件

如上图所示,一颗 GH200 超级芯片集成了下面这些核心部件:

  1. 一颗 NVIDIA Grace CPU;
  2. 一颗 NVIDIA H200 GPU;
  3. 最多 480GB CPU 内存;
  4. 96GB 或 144GB GPU 显存。

3.1.2 芯片硬件互连

  1. CPU 通过 4 个 PCIe Gen5 x16 连接到主板,

    • 单个 PCIe Gen5 x16 的速度是双向 128GB/s,
    • 所以 4 个的总速度是 512GB/s;
  2. CPU 和 GPU 之间,通过 NVLink® Chip-2-Chip (NVLink-C2C) 技术互连,

    • 900 GB/s,比 PCIe Gen5 x16 的速度快 7 倍;
  3. GPU 互连(同主机扩跨主机):18x NVLINK4

    • 900 GB/s

NVLink-C2C 提供了一种 NVIDIA 所谓的“memory coherency”:内存/显存一致性。好处:

  • 内存+显存高达 624GB,对用户来说是统一的,可以不区分的使用;提升开发者效率;
  • CPU 和 GPU 可以同时(concurrently and transparently)访问 CPU 和 GPU 内存。
  • GPU 显存可以超分(oversubscribe),不够了就用 CPU 的内存,互连带宽够大,延迟很低。

下面再展开看看 CPU、内存、GPU 等等硬件。

3.2 CPU 和内存

3.2.1 72-core ARMv9 CPU

  • 72-core Grace CPU (Neoverse V2 Armv9 core)

3.2.2 480GB LPDDR5X (Low-Power DDR) 内存

  • 最大支持 480GB LPDDR5X 内存;
  • 500GB/s per-CPU memory bandwidth。

参考下这个速度在存储领域的位置:

Fig. Peak bandwidth of storage media, networking, and distributed storage solutions. [1]

3.2.3 三种内存对比:DDR vs. LPDDR vs. HBM

  • 普通服务器(绝大部分服务器)用的是 DDR 内存,通过主板上的 DIMM 插槽连接到 CPU,[1] 中有详细介绍;
  • 1-4 代的 LPDDR 是对应的 1-4 代 DDR 的低功耗版,常用于手机等设备。
    • LPDDR5 是独立于 DDR5 设计的,甚至比 DDR5 投产还早;
    • 直接和 CPU 焊到一起的,不可插拔,不可扩展,成本更高,但好处是速度更快
    • 还有个类似的是 GDDR,例如 RTX 4090 用的 GDDR。
  • HBM 在第一篇中已经介绍过了;

下面列个表格对比三种内存的优缺点,注意其中的高/中/低都是三者相对而言的:

  DDR LPDDR HBM
容量
速度
带宽
可扩展性
可插拔 不可 不可
成本
功耗

更多细节,见 [1]。

例如,与 8-channel DDR5(目前高端 x86 服务器的配置)相比, GH200 的 LPDDR5X 内存带宽高 53%,功耗还低 1/8

3.3 GPU 和显存

3.3.1 H200 GPU

算力见下面。

3.3.2 显存选配

支持两种显存,二选一:

  • 96GB HBM3
  • 144GB HBM3e,4.9TB/s,比 H100 SXM 的带宽高 50%;

3.4 变种:GH200 NVL2,用 NVLINK 全连接两颗 GH200

在一张板子内放两颗 GH200 芯片,CPU/GPU/RAM/VRAM 等等都翻倍,而且两颗芯片之间是全连接。

例如,对于一台能插 8 张板卡的服务器,

  • 用 GH200 芯片:CPU 和 GPU 数量 8 * {72 Grace CPU, 1 H200 GPU}
  • 用 GH200 NVL2 变种:CPU 和 GPU 数量 8 * {144 Grace CPU, 2 H200 GPU}

3.5 GH200 & GH200 NVL2 产品参数(算力)

NVIDIA GH200 产品参数。上半部分是 CPU、内存等参数,从 "FP64" 往下是 GPU 参数。[2]

4 GH200 服务器及组网

两种服务器规格,分别对应 PCIe 板卡和 NVLINK 板卡。

4.1 NVIDIA MGX with GH200:原厂主机及组网

下图是单卡 node 的一种组网方式:

NVIDIA GH200 MGX 服务器组网。每台 node 只有一片 GH200 芯片,作为 PCIe 板卡,没有 NVLINK。[2]

  1. 每台 node 只有一片 GH200 芯片(所以只有一个 GPU),作为 PCIe 板卡,没有 NVLINK;
  2. 每台 node 的网卡或加速卡 BlueField-3 (BF3) DPUs 连接到交换机;
  3. 跨 node 的 GPU 之间没有直连,而是通过主机网络(走 GPU->CPU-->NIC 出去)的方式实现通信;
  4. 适合 HPC workload、中小规模的 AI workload。

4.2 NVIDIA GH200 NVL32:原厂 32 卡机柜

通过 NVLINk 将 32 个 GH200 芯片全连接为一个逻辑 GPU 模块,所以叫 NVL32

NVIDIA GH200 NVL32 组网。[2]

  1. NVL32 模块实际形态是一个机柜
    • 一个机柜能提供 19.5TB 内存+显存;
    • NVLink TLB 能让任意一个 GPU 访问这个机柜内的任意内存/显存;

      NVIDIA GH200 NVL32 中 3 种内存/显存访问方式。[2]

    • Extended GPU Memory (EGM)
  2. 多个机柜再通过网络互连,形成集群,适合超大规模 AI workload。

5 总结

本文粗浅地整理了一些 NVIDIA GH200 相关技术知识。

其他:

参考资料

  1. Practical Storage Hierarchy and Performance: From HDDs to On-chip Caches(2024)
  2. NVIDIA GH200 Grace Hopper Superchip & Architecture, datasheet, 2024
  3. NVIDIA GB200 NVL72 Delivers Trillion-Parameter LLM Training and Real-Time Inference, 2024

Written by Human, Not by AI Written by Human, Not by AI

大模型 RAG 基础:信息检索、文本向量化及 BGE-M3 embedding 实践(2024)

2024年8月4日 08:00

本文整理一些文本向量化(embedding)和信息检索的知识,它们是如今大模型生成文本时常用的技术 —— “增强检索生成”(RAG)—— 的基础:

Fig. Similarity score based on BERT embedding. Image source

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



RAG (Retrieval-Augmented Generation,检索增强生成),是一种利用信息检索(Information Retrieval) 技术增强大模型生成效果(generation)的技术。RAG 在步骤上很简单,

  1. 搭建高质量文档数据库
    • 对优质文档进行某种格式的转换(或称编码),例如基于 BERT 将文本段落转换成 数值格式的向量(这个过程称为 embedding),然后
    • 将这些 embeddings 存储到合适的数据库(例如 ES 或向量数据库);
  2. 针对用户输入进行数据库检索
    • 对用户输入的 query 进行相同的转换(embedding),然后
    • 利用最近邻等相似性算法,在文档库中寻找最相似的文本段落(与给定问题最相关的段落);
  3. 大模型生成返回给用户的内容
    • 将找到文本段落送到大模型,辅助生成最终的输出文本,返回给用户。

本文主要关注以上 1 & 2 步骤中的 embedding & retrieval 阶段。

1 信息检索(information retrieval)技术三大发展阶段

信息检索的技术发展大致可分为三个阶段:

  1. 基于统计信息关键字匹配(statistical keyword matching)

    • 是一种 sparse embedding —— embedding 向量的大部分字段都是 0;
  2. 基于深度学习模型的上下文和语义理解

    • 属于 dense embedding —— embedding 向量的大部分字段都非零;
  3. 所谓的“学习型”表示,组合上面两种的优点,称为 learned sparse embedding

    • 既有深度学习模型的上下文和语义理解能力;
    • 又具备稀疏表示的可解释性(interpretability of sparse representations)和低计算复杂度。

下面分别来看。

1.1 基于统计信息和关键词匹配(1970s-2010s

1.1.1 典型算法:TF-IDFBM25

早期信息检索系统主要是基于统计信息 + 匹配关键词,算法包括,

  • TF-IDF (term frequency - inverse document frequency), 1970s
  • BM25 (Best Matching), 1980s

1.1.2 原理

分析语料库的词频和分布(term frequency and distribution), 作为评估文档的相关性(document relevance)的基础。

1.1.3 优缺点

  • 优点:方法简单,效果不错,所以使用很广泛。
  • 缺点:单纯根据词频等统计和关键字检索做判断,不理解语义。

1.2 基于深度学习和上下文语义

1.2.1 Word2Vec (Google, 2013)

2013 年,谷歌提出了 Word2Vec

  • 首次尝试使用高维向量来表示单词,能分辨它们细微的语义差别;
  • 标志着向机器学习驱动的信息检索的转变。

1.2.2 BERT (Google, 2019)

基于 transformer 的预训练(pretrain)语言模型 BERT 的出现,彻底颠覆了传统的信息检索范式。

核心设计和优点

  1. transformer 的核心是 self-attention,
    • self-attention 能量化给定单词与句子中其他单词的关联性程度
    • 换句话说就是:能在上下文中分辨单词的含义;
  2. BERT 是双向(前向+后向)transformer,
    • 可以理解为在预训练时,每个句子正向读一遍,反向再读一遍;
    • 能更好地捕获句子的上下文语义(contextual semantics);
    • 最终输出是一个 dense vector,本质上是对语义的压缩;
  3. 基于 dense vector 描述,用最近邻算法就能对给定的 query 进行检索,强大且语义准确。

局限性:领域外(Out-of-Domain)信息检索效果差

BERT 严重依赖预训练数据集的领域知识(domain-specific knowledge), 预训练过程使 BERT 偏向于预训练数据的特征, 因此在领域外(Out-Of-Domain),例如没有见过的文本片段,表现就不行了。

解决方式之一是fine-tune(精调/微调),但成本相对较高, 因为准备高质量数据集的成本是很高的。

另一方面,尽管传统 sparse embedding 在词汇不匹配问题时虽然也存在挑战, 但在领域外信息检索中,它们的表现却优于 BERT。 这是因为在这类算法中,未识别的术语不是靠“学习”,而是单纯靠“匹配”。

1.3 学习型:组合前两种的优点

1.3.1 原理:传统 sparse vector 与上下文化信息的融合

  1. 先通过 BERT 等深度学习模型生成 dense embedding;
  2. 再引入额外的步骤对以上 dense embedding 进行稀疏化,得到一个 sparse embedding;

代表算法:BGE-M3。

1.3.2 与传统 sparse embedding 的区别

根据以上描述,乍一看,这种 learned sparse embedding 与传统 sparse embedding 好像没太大区别, 但实际上二者有着本质不同,这种 embedding,

  • 引入了 Token Importance Estimation;
  • 既保留了关键词搜索能力,又利用上下文信息,丰富了 embedding 的稀疏表示;
  • 能够辨别相邻或相关的 token 的重要性,即使这些 token 在文本中没有明确出现。

1.3.3 优点

  • 将稀疏表示与学习上下文结合,同时具备精确匹配和语义理解两大能力,在领域外场景有很强的泛化能力;
  • 与 dense embedding 相比更简洁,只保留了最核心的文本信息;
  • 固有的稀疏性使向量相似性搜索所需的计算资源极少;
  • 术语匹配特性还增强了可解释性,能够更精确地洞察底层的检索过程,提高了系统的透明度。

2 信息检索:三种 embedding 的对比

简单来说, vector embedding,或称向量表示,是一个单词或句子在高维向量空间中的数值表示

  • 高维空间:一个维度能代表一个特征或属性,高维意味着分辨率高,能区分细微的语义差异;
  • 数值表示:一个 embedding 一般就是一个浮点数数组,所以方便计算。

对应上一节介绍的三个主要发展阶段,常见的有三种 embedding 类型:

  1. traditional sparse embedding
  2. dense embedding
  3. learned sparse embedding

2.1 Sparse embedding (lexical matching)

  • 映射成一个高维(维度一般就是 vocabulary 空间大小)向量
  • 向量的大部分元素都是 0,非零值表明 token 在特定文档中的相对重要性,只为那些输入文本中出现过的 token 计算权重
  • 典型模型:BM25(对 TF-IDF 的改进)

非常适合关键词匹配任务(keyword-matching tasks)。

2.2 Dense embedding (e.g. BERT-based)

  • 映射到一个(相对低维)向量,所有维度都非零
  • 相比 sparse embedding 维度要低很多,例如基于 BERT 默认 1x768 维度;
  • 典型模型:BGE-v1.5

所有维度都非零,包含语义理解,信息非常丰富,因此适用于 语义搜索任务(semantic search tasks)。

Multi-vector retrieval

  • 用多个向量表示一段文本,可以看做是对 dense retrieval 的一种扩展
  • 模型:ColBERT

2.3 Learned sparse embedding

结合了传统 sparse embedding 的精确度和 dense embedding 的语义丰富性,

  • 可以通过深度学习模型“学习”相关 token 的重要性,即使是一些并未出现过的 token,
  • 生成的“学习型”稀疏表示,能有效捕捉 query 和 doc 中的关键词。

3 Embedding & retrieval 工作原理详解

这里主要介绍 BGE-M3 模型的原理。BGE-M3 建立在 BERT 之上,因此需要先回顾 BERT 的基本原理。

3.1 BERT 是如何工作的

3.1.1 理论基础

3.1.2 BERT dense embedding 工作流

以输入 "Milvus is a vector database built for scalable similarity search" 为例,工作过程 [2]:

Fig. BERT dense embedding.

  1. Tokenization
    1. 将输入文本转成 token 序列
    2. BERT 还会插入两个特殊的 token:[CLS] token 表示开始,[SEP] token 表示一个句子的结束。
  2. Embedding:使用 embedding matrix 将每个 token 转换为一个向量,详见 BERT 论文;
  3. Encoding:这些向量通过多层 encoder,每层由 self-attention 和 feed-forward 神经网络组成
    1. 会根据所有其他 token 提供的上下文细化每个 token 的表示。
  4. Output:输出一系列最终的 embedding vectors

最终生成的 dense embedding 能够捕捉单个单词的含义及其在句子中的相互关系。

理解 BERT 是如何生成 dense embedding 之后,接下来看看基于 BERT dense embedding 的信息检索是如何工作的。

3.2 基于 BERT dense embedding 的文档检索是如何工作的

有了 dense embedding 之后,针对给定文本输入检索文档就很简单了,只需要再加一个最近邻之类的算法就行。

下面是两个句子的相似度判断,原理跟文档检索是一样的:

Fig. Similarity score based on BERT embedding. Image source

下面看个具体的 embedding & retrieval 模型:BGE-M3。

3.3 BGE-M3(BERT-based learned sparse embedding)是如何工作的?

BGE 是一系列 embedding 模型,扩展了 BERT 的能力。BGE-M3 是目前最新的一个,3 个 M 是强调的多个 multi- 能力:

  • Multi-Functionality
  • Multi-Linguisticity
  • Multi-Granularity

3.3.1 设计 & 特点

BGE-M3 通过更精细的方法来捕捉每个 token 的重要性,

  1. Token importance estimation:BERT 在分类/相似性比较时仅关注第一个 token([CLS]), BGE-M3 则扩大到关注序列中的每个 token Hi
  2. 线性变换:在 encoder 的输出层上又增加一个线性层,计算每个 token 的 importance weights Wlex
  3. 激活函数:
    • WlexHi 的乘积经过 Rectified Linear Unit (ReLU) 激活函数,得到每个 token 的术语权重 Wt
    • ReLU 的结果是非负的,有助于 embedding 的稀疏性。
  4. learned sparse embedding:以上输出的是一个 sparse embedding,其中每个 token 都有一个相关的 weights,表明在整个输入文本上下文中的重要性。

下面看个例子。

3.3.2 BGE-M3 生成 learned sparse embedding 的过程

还是前面例子提到的输入,

  1. 先走 BERT dense embedding 的流程,
  2. 最后加一个 linear 层,得到 learned sparse embedding。

Fig. BGE-M3 learned sparse embedding. Image source

In M3-Embedding, the [CLS] embedding is used for dense retrieval, while embeddings from other tokens are used for sparse retrieval and multi-vector retrieval [3].

4 BGE-M3 实战

4.1 相似度判断(检索)

$ pip install FlagEmbedding peft sentencepiece

来自官方的代码,稍作修改:

from FlagEmbedding import BGEM3FlagModel

model = BGEM3FlagModel('/root/bge-m3', use_fp16=True)

queries = ["What is BGE M3?",
           "Defination of BM25"]
docs = ["BGE M3 is an embedding model supporting dense retrieval, lexical matching and multi-vector interaction.",
        "BM25 is a bag-of-words retrieval function that ranks a set of documents based on the query terms appearing in each document"]

query_embeddings = model.encode(queries, batch_size=12, max_length=8192,)['dense_vecs']
docs_embeddings  = model.encode(docs)['dense_vecs']
similarity = query_embeddings @ docs_embeddings.T
print(similarity)

这个例子是两个问题,分别去匹配两个答案,看彼此之间的相似度(四种组合),运行结果:

[[0.626  0.348 ]
 [0.3499 0.678 ]]
  • 问题 1 和答案 1 相似度是 0.6265
  • 问题 2 和答案 2 相似度是 0.678
  • 问题 1 和答案 2,以及问题 2 和答案 1,相似度只有 0.3x

符合预期。

4.2 精调(fine-tune)

精调的目的是让正样本和负样本的分数差变大。

4.2.1 官方文档

  1. fine-tune the dense embedding
  2. fine-tune all embedding function of m3 (dense, sparse and colbert)

4.2.2 训练数据格式及要求

  1. 文件为 jsonl 格式,每行一个 sample;
  2. 每个 sample 的格式:{"query": str, "pos": List[str], "neg":List[str]}
    • query:用户问题;
    • pos:正样本列表,简单说就是期望给到用户的回答;不能为空,也就是说必需得有正样本;
    • neg:负样本列表,是避免给到用户的回答。
      • 空要写成 "neg": [""],写 "neg": [] 会报错。
      • 另外为空时试过删掉 "neg": [] 也不行,必须得留着这个字段。

注意:

  1. 不是标准 json 格式,所以 python 直接导出一个 json 文件作为训练数据集是不行的。
  2. sample 不能分行,一个 sample 一行。

4.2.3 精调命令及参数配置

从 huggingface 或国内的 modelscope 下载 BGE-M3 模型,

$ git lfs install
$ git clone https://www.modelscope.cn/Xorbits/bge-m3.git

精调命令:

$ cat sft.sh
#!/bin/bash

num_gpus=1
output_dir=/root/bge-sft-output
model_path=/root/bge-m3
train_data=/data/share/bge-dataset
batch_size=2
query_max_len=128    # max 8192
passage_max_len=1024 # max 8192

torchrun --nproc_per_node $num_gpus \
    -m FlagEmbedding.BGE_M3.run \
    --output_dir $output_dir \
    --model_name_or_path $model_path \
    --train_data $train_data \
    --learning_rate 1e-5 \
    --fp16 \
    --num_train_epochs 5 \
    --per_device_train_batch_size $batch_size \
    --dataloader_drop_last True \
    --normlized True \
    --temperature 0.02 \
    --query_max_len $query_max_len \
    --passage_max_len $passage_max_len \
    --train_group_size 2 \
    --negatives_cross_device \
    --logging_steps 10 \
    --same_task_within_batch True \
    --save_steps 10000 \
    --unified_finetuning True \
    --use_self_distill True

几个参数要特别注意下:

  1. query & doc 最大长度

    • query_max_len:支持的最长 query,最大 8192
    • passage_max_len:支持的最长文档(一条 pos 或 neg 记录)长度,最大 8192

    BGE-M3 会分别针对 query 和 doc 初始化两个 tokenizer,以上两个参数其实对应 tokenizer 的 max_length,而 tokenizer 最大支持 8192(见模型目录 tokenizer_config.json)。

  2. batch_size:并行度,直接决定了显存占用大小和精调快慢;
    • BGE-M3 跑起来之后显存占用是恒定的,所以可以多试几个 batch size 配置,把显存用到最大;
  3. save_steps:多少个 step 保存一次 checkpoint,默认值 500 太小,每个 checkpoint ~7GB,多了之后可能会打爆磁盘导致任务失败。

精调快慢取决于 GPU 算力、显存和参数配置,精调开始之后也会打印出预估的完成时间,还是比较准的。

4.2.4 测试精调之后的效果

还是用 4.1 的代码,稍微改一下,不要把 queries 和 docs 作为列表,而是针对每个 query 和 pos/neg 计算相似度得分。 然后针对测试集跑一下,看相似性分数是否有提升。

数据集质量可以的话,精调之后区分度肯定有提升。

4.3 CPU 运行速度优化:将模型转 onnx 格式

如果是在 CPU 上跑模型(不用 GPU), 根据之前实际的 BERT 工程经验,转成 onnx 之后能快几倍,尤其是在 Intel CPU 上 (Intel 公司做了很多优化合并到社区库了)。

但 BGE-M3 官方没有转 onnx 文档,根据第三方的库能成功(稍微改点代码,从本地加载模型),效果待验证。

5 rerank 增强:对 BGE-M3 的检索结果进行重排序

5.1 rerank/reranker 是什么?

rerank 的意思是“重新排序” —— 对 embedding model 检索得到的多个结果(对应多个分数), 重新计算它们的相似性分数,给出一个排名。这是一个可选模块, 用于对检索结果进行增强,把相似度最高的结果返回给用户。

5.1.1 另一种相似度模型

reranker 也是一类计算相似度的模型,例如这个列表 里的都是 rerank/reranker 模型,

  1. bge-reranker-v2-m3:与 bge-m3 配套的 reranker
  2. bge-reranker-v2-gemma:与 google gemma-2b 配套的 reranker

但它们的原理与 BGE-M3 这种 embedding model 有差异。

5.1.2 与 BGE-M3 等模型的差异:cross-encoder vs. bi-encoder

以两个句子的相似度检测为例,

Fig. bi-encoder embedding model vs. cross-encoder model. Image source

  • BGE-M3 属于左边那种,所谓的 bi-encoder embedding model, 简单说就是两个句子分别输入模型,得到各自的 embedding, 然后根据 embedding vector 计算相似度;
  • reranker 属于右边那种,所谓的 cross-encoder model,直接得到结果; 如果对 BERT 的工作原理比较熟悉(见 BERT paper),就会明白这其实就是 BERT 判别两个句子 (next sentense prediction, NSP)任务的延伸。

5.2 embedding 和 reranker 工作流

  1. 用户输入 query 和 doc 列表 doc1/doc2/doc3/...
  2. BGE-M3 计算相似分,返回 topN,例如 [{doc1, score1}, {doc2, score2}, {doc3, score3}],其中 score1 >= score2 >= score3
  3. reranker 接受 query 和 BGE-M3 的结果,用自己的模型重新计算 querydoc1/doc2/doc3 的相似度分数。

5.3 BGE-M3 得到相似分之后,为什么要通过 reranker 再计算一遍?

这里可能有个疑问:step 2 不是已经检索出最相关的 N 个 doc 了吗? 为什么又要进入 step3,用另外一个完全不同的模型(reranker)再计算一种相似分呢?

简单来说,embdding 和 rerank 都是 NLP 中理解给定的两个句子(或文本片段)的关系的编码技术。 再参考刚才的图,

Fig. bi-encoder embedding model vs. cross-encoder model. Image source

  • bi-encoder
    • 分别对两个句子进行编码,得到两个独立的 embedding,再计算相似度。
    • 速度快,准确性相对低。
  • cross-encoder

    • 同时对两个句子编码,输出一个相似度分数;也可以换句话说,把两个句子合成一个句子编码,所以两个句子是彼此依赖的
    • 速度慢,准确性高

总结起来:embedding model 计算的相似度是粗粒度的,只能算粗排; reranker 对 embedding model 得到的若干结果再进行细排; 要体会和理解这种差异,还是要看基础 paper BERT:预训练深度双向 Transformers 做语言理解(Google,2019)

6 总结

本文整理了一些 BGE-M3 相关的 RAG 知识。前两篇参考资料非常好,本文很多内容都来自它们,感谢作者。

参考资料

  1. Enhancing Information Retrieval with Sparse Embeddings, zilliz.com/learn, 2024
  2. Exploring BGE-M3 and Splade: Two Machine Learning Models for Generating Sparse Embeddings, medium.com/@zilliz_learn, 2024
  3. BGE-M3 paper
  4. Cross encoders and bi-encoders, medium.com, 2024

Written by Human, Not by AI Written by Human, Not by AI

Linux 时钟源之 TSC:软硬件原理、使用场景、已知问题(2024)

2024年7月28日 08:00

本文整理了一些 Linux 时钟源 tsc 相关的软硬件知识,在一些故障排查场景可能会用到。

Fig. Scaling up crystal frequency for different components of a computer. Image source Youtube

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 计算机组件的运行频率

1.1 时钟源:~20MHz 的石英晶体谐振器(quartz crystal resonator)

石英晶体谐振器是利用石英晶体(又称水晶)压电效应 来产生高精度振荡频率的一种电子器件。

  • 1880 年由雅克·居里与皮埃尔·居里发现压电效应。
  • 一战期间 保罗·朗之万首先探讨了石英谐振器在声纳上的应用。
  • 1917 第一个由晶体控制的电子式振荡器。
  • 1918 年贝尔实验室的 Alexander M. Nicholson 取得专利,虽然与同时申请专利的 Walter Guyton Cady 曾有争议。
  • 1921 年 Cady 制作了第一个石英晶体振荡器。

Wikipedia 石英晶体谐振器

现在一般长这样,焊在计算机主板上,

Fig. A miniature 16 MHz quartz crystal enclosed in a hermetically sealed HC-49/S package, used as the resonator in a crystal oscillator. Image source wikipedia

受物理特性的限制,只有几十 MHz

1.2 Clock generator:针对不同部分(内存、PCIe、CPU 等)倍频

计算机的内存、PCIe 设备、CPU 等等组件需要的工作频率不一样(主要原因之一是其他组件跟不上 CPU 的频率), 而且都远大于几十 MHz,因此需要对频率做提升。工作原理:

  1. What is a CPU clock physically?
  2. Wikipedia: Phase-locked_loop (PLL)

有个视频解释地很形象,

Fig. Scaling up crystal frequency for different components of a computer. Image source Youtube

图中的 clock generator 是个专用芯片,也是焊在主板上,一般跟晶振挨着。

1.3 CPU 频率是如何从 ~20MHz 提升到 ~3GHz

本节稍微再开展一下,看看 CPU 频率是如何提升到我们常见的 ~3GHz 这么高的。

1.3.1 传递路径:最终连接到 CPU CLK 引脚

结合上面的图,时钟信号的传递/提升路径

  1. 晶振(~20MHz
  2. 主板上的 clock generator 芯片
  3. 北桥芯片
  4. CPU

时钟信号连接到 CPU 的一个名为 CLK 的引脚。 两个具体的 CLK 引脚实物图:

  • Intel 486 处理器(1989

    Fig. Intel 486 pin mapImage Source

    这种 CPU 引脚今天看来还是很简单的,CLK 在第三行倒数第三列。

  • AMD SP3 CPU Socket (2017)

    EPYC 7001/7002/7003 系列用的这种。图太大了就不放了,见 SP3 Pin Map

1.3.2 CPU 内部:还有一个 clock generator

现代 CPU 内部一般还有一个 clock generator,可以继续提升频率, 最终达到厂商宣传里的基频(base frequency)或标称频率(nominal frequency),例如 EPYC 6543 的 2795MHz。 这跟原始晶振频率比,已经提升了上百倍。

2 x86 架构的寄存器

介绍点必要的背景知识,有基础的可跳过。

2.1 通用目的寄存器

Fig. 32-bit x86 general purpose registers [1]

计算机执行的所有代码,几乎都是经由通用寄存器完成的。 进一步了解:简明 x86 汇编指南(2017)

2.2 特殊目的寄存器

如名字所示,用于特殊目的,一般也需要配套的特殊指令读写。大致分为几类:

  • control registers
  • debug registers
  • mode-specific registers (MSR)

接下来我们主要看下 MSR 类型。

2.2.1 model-specific register (MSR)

MSR 是 x86 架构中的一组控制寄存器(control registers), 设计用于 debugging/tracing/monitoring 等等目的,以下是 AMD 的一些系统寄存器, 其中就包括了 MSR 寄存器们,来自 AMD64 Architecture Programmer’s Manual, Volume 3 (PDF)

Fig. AMD system registers, which include some MSR registers

几个相关的指令:

  • RDMSR/WRMSR 指令:读写 MSR registers;
  • CPUID 指令:检查 CPU 是否支持某些特性。

RDMSR/WRMSR 指令使用方式:

  • 需要 priviledged 权限。
  • Linux msr 内核模块创建了一个伪文件 /dev/cpu/{id}/msr,用户可以读写这个文件。还有一个 msr-tools 工具包。

2.2.2 MSR 之一:TSC

今天我们要讨论的是 MSR 中与时间有关的一个寄存器,叫 TSC (Time Stamp Counter)。

3 TSC(时间戳计数器)

3.1 本质:X86 处理器中的一个 特殊寄存器

Time Stamp Counter (TSC) 是 X86 处理器 (Intel/AMD/…)中的一个 64-bit 特殊目的 寄存器,属于 MRS 的一种。 还是 AMD 编程手册中的图,可以看到 MSR 和 TSC 的关系:

Fig. AMD system registers, which include some MSR registers

注意:在多核情况下(如今几乎都是多核了),每个物理核(processor)都有一个 TSC register, 或者说这是一个 per-processor register

3.2 作用:记录 cpu 启动以来累计的 cycles 数量

前面已经介绍过,时钟信号经过层层提升之后,最终达到 CPU 期望的高运行频率,然后就会在这个频率上工作。

这里有个 CPU cycles(指令周期)的概念: 频率没经过一个周期(1Hz),CPU cycles 就增加 1 —— TSC 记录的就是从 CPU 启动(或重置)以来的累计 cycles。 这也呼应了它的名字:时间戳计数器

3.3 实际:经常被当做(高精度)时钟用

根据以上原理,如果 CPU 频率恒定且不存在 CPU 重置的话,

  • TSC 记录的就是系统启动以来的 cycles 数量
  • cycles 可以精确换算成时间
  • 这个时间的精度还非常高!
  • 使用开销还很低(这涉及到操作系统和内核实现了)。

所以无怪乎 TSC 被大量用户空间程序当做开销地高精度的时钟

3.3.1 使用代码

本质上用户空间程序只需要一条指令(RDTSC),就能读取这个值。非常简单的几行代码:

unsigned long long rdtsc() {
    unsigned int lo, hi;
    __asm__ volatile ("rdtsc" : "=a" (lo), "=d" (hi));
    return ((unsigned long long)hi << 32) | lo;
}

就能拿到当前时刻的 cpu cycles。所以统计耗时就很直接:

    start = rdtsc();

    // business logic here

    end = rdtsc();
    elapsed_seconds = (end-start) / cycles_per_sec;

3.3.1 潜在问题

以上的假设是 TSC 恒定,随着 wall time 均匀增加。

如果 CPU 频率恒定的话(也就是没有超频、节能之类的特殊配置),cycles 就是以恒定速率增加的, 这时 TSC 确实能跟时钟保持同步,所以可以作为一种获取时间或计时的方式。 但接下来会看到,cycles 恒定这个前提条件如今已经很难满足了,内核也不推荐用 tsc 作为时间度量。

乱序执行会导致 RDTSC 的执行顺序与期望的顺序发生偏差,导致计时不准,两种解决方式:

  • 插入一个同步指令(a serializing instruction),例如 CPUID,强制前面的指令必现执行完,才能才执行 RDTSC;
  • 使用一个变种指令 RDTSCP,但这个指令只是对指令流做了部分顺序化(partial serialization of the instruction stream),并不完全可靠。

3.4 挑战:TSC 的准确性越来越难以保证

如果一台机器只有一个处理器,并且工作频率也一直是稳定的,那拿 TSC 作为计时方式倒也没什么问题。 但随着下面这些技术的引入,TSC 作为时钟就不准了:

  • 多核处理器:意味着每个核上都有一个 TSC,如何保持这些 TSC 寄存器值的严格同步;
  • 不同处理器的温度差异也会导致 TSC 偏差
  • 超线程:一个处理器上两个硬件线程(Linux 中看就是两个 CPU);
  • 超频、降频等等功耗管理功能:导致时钟不再是稳定的;
  • CPU 指令乱序执行功能:获取 TSC 的指令的执行顺序和预期的可能不一致,导致计时不准;
  • 休眠状态:恢复到运行状态时重置 TSC;

还有其他一些方面的挑战,都会导致无法保证一台机器多个 CPU 的 TSC 严格同步

3.5 改进:引入 constant/invariant TSC

解决方式之一,是一种称为恒定速率(constant rate) TSC 的技术,

  • 在 Linux 中,可以通过 cat /proc/cpuinfo | grep constant_tsc 来判断;
  • 有这个 flag 的 CPU,TSC 以 CPU 的标称频率(nominal frequency)累积;超频或功耗控制等等导致的实际 CPU 时钟频率变化,不会影响到 TSC。

较新的 Intel、AMD 处理器都支持这个特性。

但是,constant_tsc 只是表明 CPU 有提供恒定 TSC 的能力, 并不表示实际工作 TSC 就是恒定的。后面会详细介绍。

3.5 小结:计数器(counter),而非时钟(clock)

从上面的内容已经可以看出, TSC 如其名字“时间戳计数器”所说,确实本质上只是一个计数器, 记录的是 CPU 启动以来的 cpu cycles 次数

虽然在很多情况下把它当时钟用,结果也是正确的,但这个是没有保证的,因为影响它稳定性的因素太多了 —— 不稳拿它计时也就不准了。

另外,它是一个 x86 架构的特殊寄存器,换了其他 cpu 架构可能就不支持,所以依赖 TSC 的代码可移植性会变差。

4 查看和监控 TSC 相关信息

以上几节介绍的基本都是硬件问题,很好理解。接下来设计到软件部分就复杂了,一部分原因是命名导致的。

4.1 Linux 系统时钟源(clocksource)配置

我们前面提到不要把 tsc 作为时钟来看待,它只是一个计数器。但另一方面,内核确实需要一个时钟,

  • 内核自己的定时器、调度、网络收发包等等需要时钟;
  • 用户程序也需要时间功能,例如 gettimeofday() / clock_gettime()

在底层,内核肯定是要基于启动以来的计数器,这时 tsc 就成为它的备选之一(而且优先级很高)。

$ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
tsc hpet acpi_pm

$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
tsc

4.1.1 tsc:优先

  • 高精度:基于 cycles,所以精度是几个 GHz,对应 ns 级别;
  • 低开销:跟内核实现有关。

4.1.2 hpet:性能开销太大

原理暂不展开,只说结论:相比 tsc,hpet 在很多场景会明显导致系统负载升高。所以能用 tsc 就不要用 hpet。

4.2 turbostat 查看实际 TSC 计数(可能不准)

前面提到用户空间程序写几行代码就能方便地获取 TSC 计数。所以对监控采集来说,还是很方便的。 我们甚至不需要自己写代码获取 TSC,一些内核的内置工具已经实现了这个功能,简单地执行一条 shell 命令就行了。

turbostat 是 Linux 内核自带的一个工具,可以查看包括 TSC 在内的很多信息。

turbostat 源码在内核源码树中:tools/power/x86/turbostat/turbostat.c

不加任何参数时,turbostat 会 5s 打印一次统计信息,内容非常丰富。 我们这里用精简模式,只打印每个 CPU 在过去 1s 的 TSC 频率和所有 CPU 的平均 TSC:

# sample 1s and only one time, print only per-CPU & average TSCs
$ turbostat --quiet --show CPU,TSC_MHz --interval 1 --num_iterations 1
CPU     TSC_MHz
-       2441
0       2445
64      2445
1       2445

turbostat 如果执行的时间非常短,比如 1s,统计到数据就不太准,偏差比较大; 持续运行一段时间后,得到的数据才比较准。

4.3 rdtsc/rdtscp 指令采集 TSC 计数

4.3.1 C 代码

完整代码:

#include <stdio.h>
#include <time.h>
#include <unistd.h>

// https://stackoverflow.com/questions/16862620/numa-get-current-node-core
unsigned long rdtscp(int *chip, int *core) {
    unsigned a, d, c;
    __asm__ volatile("rdtscp" : "=a" (a), "=d" (d), "=c" (c));

    *chip = (c & 0xFFF000)>>12;
    *core = c & 0xFFF;
    return ((unsigned long)a) | (((unsigned long)d) << 32);;
}

int main() {
    int sleep_us = 100000;
    unsigned long tsc_nominal_hz = 2795000000;
    unsigned long expected_inc = (unsigned long)(1.0 * sleep_us / 1000000 * tsc_nominal_hz);
    unsigned long low = (unsigned long)(expected_inc * 0.95);
    unsigned long high = (unsigned long)(expected_inc * 1.05);
    printf("Sleep interval: %d us, expected tsc increase range [%lu,%lu]\n", sleep_us, low, high);

    unsigned long start, delta;
    int start_chip=0, start_core=0, end_chip=0, end_core=0;

    while (1) {
        start = rdtscp(&start_chip, &start_core);
        usleep(sleep_us);
        delta = rdtscp(&end_chip, &end_core) - start;

        if (delta > high || delta < low) {
            time_t seconds = time(NULL); // seconds since Unix epoch (1970.1.1)
            struct tm t = *localtime(&seconds);
            printf("%02d-%02d %02d:%02d:%02d TSC jitter: %lu\n",
                    t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, delta);
            fflush(stdout);
        }
    }

    return 0;
}

几点说明:

  1. 程序 hardcode 了预期的 TSC 频率是 2795MHz
  2. 每 100ms 采集一次 TSC 计数,如果 TSC 计数的偏差超过 +/- 5%,就将这个异常值打印出来;
  3. 在哪个 chip/cpu 上执行的,这里没打印出来,有需要可以打印;
  4. 这个程序虽然采集很频繁,但开销很小,主要是因为 rdtscp 指令的开销很小。

4.3.2 执行效果

编译运行,

$ gcc tsc-checker.c -o tsc-checker

# print to stdout and copy to a log file, using stream buffer instead of line buffers
$ stdbuf --output=L ./tsc-checker | tee tsc.log
Sleep interval: 100000 us, expected tsc increase range [265525000,293475000]
08-05 19:46:31 303640792
08-05 20:13:06 301869652
08-05 20:38:27 300751948
08-05 22:40:39 324424884
...

可以看到这台机器(真实服务器)有偶发 TSC 抖动, 能偏离正常范围 324424884/2795000000 - 1 = 16%, 也就是说 100ms 的时间它能偏离 16ms,非常离谱。TSC 短时间连续抖动时, 机器就会出现各种奇怪现象,比如 load 升高、网络超时、活跃线程数增加等等,因为内核系统因为时钟抖动乱了。

4.4 监控

用合适的采集工具把以上数据送到监控平台(例如 Prometheus/VictoriaMetrics),就能很直观地看到 TSC 的状态。

4.4.1 基于 turbostat(不推荐)

例如下面是 1 分钟采集一次,每次采集过去 1s 内的平均 TSC,得到的结果:

Fig. TSC runnning average of an AMD EPYC 7543 node

但前面提到, turbostat 如果执行的时间非常短,统计到数据就不太准,偏差比较大; 持续运行一段时间后,得到的数据才比较准。但作为采集程序,可能不方便执行太长时间。

4.4.2 基于 rdtscp

基于上面的 rdtscp 自己写代码采集,就非常准确了,例如,下面是 1 分钟采集一次得到的结果展示:

Fig. TSC jitter of an AMD EPYC 7543 node

不过,要抓一些偶发抖动导致的问题,1 分钟采集一次粒度太粗了。比如我们上一小节的 C 程序是 100ms 采集一次, 相当于 1 分钟采集 600 次,一小时采集 3.6w 次。我们 3 个小时总共 10 万多次跑下来,也才能抓到几次抖动,这已经算很幸运了。

4.4.3 基于 rdtscp + 内核模块

还是 rdtscp,但作为内核模块 + 定时器运行,应该会比用户空间程序更准,可以避免 Linux 内核调度器的调度偏差。

5 TSC 若干坑

5.1 constant_tsc: a feature, not a runtime guarantee

5.1.1 Lenovo SR645 (AMD EPYC 7543 CPU) TSC 不稳定

CPU 信息:

$ cat /proc/cpuinfo
...
processor       : 127
vendor_id       : AuthenticAMD
model name      : AMD EPYC 7543 32-Core Processor
cpu MHz         : 3717.449
flags           : fpu ... tsc msr rdtscp constant_tsc nonstop_tsc cpuid tsc_scale ...

flags 里面显式支持 constant_tscnonstop_tsc,所以按照文档的描述 TSC 应该是恒定的。

但是,看一下下面的监控,都是这款 CPU,机器来自两个不同的服务器厂商,

Fig. TSC fluctuations (delta of running average) of AMD EPYC 7543 nodes, from two server vendors

可以看到,

  • 联想和浪潮的 TSC 都有波动,
  • 联想的偶尔波动非常剧烈(相对 base 2795MHz 偏离 16% 甚至更高);
  • 浪潮的相对较小(base 2445 MHz)。

这个波动可能有几方面原因,比如各厂商的 BIOS 逻辑,或者 SMI 中断风暴。

5.1.2 原因及解决方式

最后定位到是厂商 BIOS (UEFI) 设置导致的,做如下修改之后稳定多了,

No. Option Before After
1 OperatingModes.ChooseOperatingMode Maximum Efficiency Custom Mode
2 Processors.DeterminismSlider Performance Power
3 Processors.CorePerformanceBoost Enable Enable
4 Processors.cTDP Auto Maximum
5 Processors.PackagePowerLimit Auto Maximum
6 Processors.GlobalC-stateControl Enable Enable
7 Processors.SOCP-states Auto P0
8 Processors.DFC-States Enable Disable
9 Processors.P-state1 Enable Disable
10 Processors.SMTMode Enable Enable
11 Processors.CPPC Enable Enable
12 Processors.BoostFmax Auto Manual
13 Processors.BoostFmaxManual   0
14 Power EfficiencyMode Enable Disable
15 Memory.NUMANodesperSocket NPS1 NPS0

Note:

5.2 BIOS 设置致使 TSC 不恒定

除了以上具体配置,还有一些可能会导致 TSC 不稳的场景。

5.2.1 TSC 寄存器是可写的!

TSC 可写,所以某些 BIOS 固件代码会修改 TSC 值,导致操作系统时序不同步(或者说不符合预期)。

5.2.2 BIOS SMI handler 通过修改 TSC 隐藏它们的执行

例如,2010 年内核社区的一个讨论 x86: Export tsc related information in sysfs 就提到,某些 BIOS SMI handler 会通过修改 TSC value 的方式来隐藏它们的执行

为什么要隐藏?

5.2.3 服务器厂商出于功耗控制等原因在 BIOS 修改 TSC 同步逻辑

前面提到,恒定 TSC 特性只是说处理器提供了恒定的能力,但用不用这个能力,服务器厂商有非常大的决定权。

某些厂商的固件代码会在 TSC sync 逻辑中中修改 TSC 的值。 这种修改在固件这边没什么问题,但会破坏内核层面的时序视角,例如内核调度器工作会出问题。 因此,内核最后引入了一个 patch 来处理 ACPI suspend/resume,以保证 TSC sync 机制在操作系统层面还是正常的,

x86, tsc, sched: Recompute cyc2ns_offset's during resume from sleep states

TSC's get reset after suspend/resume (even on cpu's with invariant TSC
which runs at a constant rate across ACPI P-, C- and T-states). And in
some systems BIOS seem to reinit TSC to arbitrary large value (still
sync'd across cpu's) during resume.

This leads to a scenario of scheduler rq->clock (sched_clock_cpu()) less
than rq->age_stamp (introduced in 2.6.32). This leads to a big value
returned by scale_rt_power() and the resulting big group power set by the
update_group_power() is causing improper load balancing between busy and
idle cpu's after suspend/resume.

This resulted in multi-threaded workloads (like kernel-compilation) go
slower after suspend/resume cycle on core i5 laptops.

Fix this by recomputing cyc2ns_offset's during resume, so that
sched_clock() continues from the point where it was left off during
suspend.

5.3 SMI 中断风暴导致 TSC 不稳

上一节提到,BIOS SMI handler 通过修改 TSC 隐藏它们的执行。如果有大量这种中断(可能是有 bug), 就会导致大量时间花在中断处理时,但又不会计入 TSC,最终导致系统出现卡顿等问题。

AMD 的机器比较尴尬,看不到 SMI 统计(试了几台 Intel 机器是能看到的),

$ turbostat --quiet --show CPU,TSC_MHz,SMI --interval 1 --num_iterations 1
CPU     TSC_MHz
-       2441
0       2445
64      2445
1       2445
...

5.4 VM TSC 不稳

例如

  1. https://www.phoronix.com/news/AMD-Secure-TSC-Linux-Patches
  2. http://oliveryang.net/2015/09/pitfalls-of-TSC-usage/

6 总结

本文整理了一些 TSC 相关的软硬件知识,在一些故障排查场景可能会用到。

参考资料

  1. 简明 x86 汇编指南(2017)
  2. AMD64 Architecture Programmer’s Manual, Volume 3 (PDF)
  3. Linux 服务器功耗与性能管理(一):CPU 硬件基础(2024)
  4. Pitfalls of TSC usage, 2015
  5. Wikipedia MSR
  6. Wikipedia TSC
  7. Wikipedia Clock Generator

Written by Human, Not by AI Written by Human, Not by AI

图解 JuiceFS CSI 工作流:K8s 创建带 PV 的 Pod 时,背后发生了什么(2024)

2024年7月13日 08:00

JuiceFS 是一个架设在对象存储(S3、Ceph、OSS 等)之上的分布式文件系统, 简单来说,

  • 对象存储:只能通过 key/value 方式使用;
  • 文件系统:日常看到的文件目录,能执行 ls/cat/find/truncate 等等之类的文件读写操作。

本文从 high-level 梳理了 JuiceFS CSI 方案中,当创建一个带 PV 的 pod 以及随后 pod 读写 PV 时, k8s/juicefs 组件在背后都做了什么,方便快速了解 K8s CSI 机制及 JuiceFS 的基本工作原理。

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 背景知识

简单列几个基础知识,有背景的可直接跳过。

1.1 K8s CSI (Container Storage Interface )

The Container Storage Interface (CSI) is a standard for exposing arbitrary block and file storage systems to containerized workloads on Container Orchestration Systems (COs) like Kubernetes.

https://kubernetes-csi.github.io/docs/

CSI 是 K8s 支持的一种容器存储机制,扩展性非常好, 各存储方案只要根据规范实现一些接口,就能集成到 k8s 中提供存储服务。

一般来说,存储方案需要在每个 node 上部署一个称为 “CSI plugin” 的服务, kubelet 在创建带 PV 容器的过程中会调用这个 plugin。但要注意,

  • K8s 的网络插件 CNI plugin 是一个可执行文件, 放在 /opt/cni/bin/ 下面就行了,kubelet 在创建 pod 网络时直接运行 这个可执行文件;
  • K8s 的存储插件 CSI plugin 是一个服务(某种程度上, 称为 agent 更好理解),kubelet 在初始化 PV 时通过 gRPC 调用这个 plugin;

1.2 FUSE (Filesystem in Userspace)

FUSE 是一种用户态文件系统,使得用户开发自己的文件系统非常方便。

懒得再重新画图, 这里借 lxcfs(跟 juicefs 没关系,但也是一种 FUSE 文件系统)展示一下 FUSE 的基本工作原理

Linux 容器底层工作机制:从 500 行 C 代码到生产级容器运行时(2023)

Fig. lxcfs/fuse workflow: how a read operation is handled [2]

JuiceFS 基于 FUSE 实现了一个用户态文件系统。

来自社区文档的一段内容,简单整理:

传统上,实现一个 FUSE 文件系统,需要基于 Linux libfuse 库,它提供两种 API:

  • high-level API:基于文件名和路径

    libfuse 内部做了 VFS 树的模拟,对外暴露基于路径的 API。

    适合元数据本身是基于路径提供的 API 的系统,比如 HDFS 或者 S3 之类。 如果元数据本身是基于 inode 的目录树,这种 inode → path →inode 的转换就会 影响性能。

  • low-level API:基于 inode。内核的 VFS 跟 FUSE 库交互就使用 low-level API。

JuiceFS 的元数据基于 inode 组织,所以用 low-level API 实现( 依赖 go-fuse 而非 libfuse),简单自然,性能好。

1.3 JuiceFS 三种工作模式

JuiceFS 有几种工作或部署方式:

  1. 进程挂载模式

    JuiceFS client 运行在 CSI Node plugin 容器中,所有需要挂载的 JuiceFS PV 都会在这个容器内以进程模式挂载。

  2. CSI 方式,又可分为两种:

    1. mountpod 方式:在每个 node 上,CSI plugin 动态为每个被 local pod 使用的 PV 创建一个保姆 pod,

      • 这个 mount pod 是 per-PV 而非 per-business-pod 的, 也就是说如果 node 上有多个业务 pod 在使用同一 PV,那只会有一个 mount pod, 下图可以看出来,

        Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

      • mount pod 里面装了 juicefs client,替业务 pod 完成 juicefs 相关的读写操作; 为了从字面上更容易理解,本文接下来把 mount pod 称为 dynamic client pod 或 client pod。
      • 这是 JuiceFS CSI 的默认工作方式
      • FUSE 需要 mount pod 具有 privilege 权限;
      • client pod 重启会导致业务 pod 一段时间读写不可用,但 client pod 好了之后业务 pod 就能继续读写了。
    2. . CSI sidecar 方式:给每个使用 juicefs PV 的业务 pod 创建一个 sidecar 容器。

      • per-pod 级别的 sidecar;
      • 注意 sidecar 就不是 JuiceFS plugin 创建的了,CSI Controller 会注册一个 Webhook 来监听容器变动,在创建 pod 时, webhook 给 pod yaml 自动注入一个 sidecar,跟 Istio 自动给 pod 注入 Envoy 容器类似;
      • Sidecar 重启需要重建业务 Pod 才能恢复。
      • 也依赖 FUSE,所以 sidecar 需要 privilege 权限。这会导致每个 sidecar 都能看到 node 上所有设备,有风险,所以不建议;

1.4 小结

有了以上基础,接下来看 k8s 中创建一个业务 pod 并且它要求挂载一个 PV 时,k8s 和 juicefs 组件都做了什么事情。

2 创建一个使用 PV 的 pod 时,k8s 和 juicefs 组件都做了什么

Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

Step 1:kubelet 启动,监听集群的 pod 资源变化

kubelet 作为 k8s 在每个 node 上的 agent,在启动后会监听整个 k8s 集群中的 pod 资源变化。 具体来说就是,kube-apiserver 中有 pod create/update/delete events 发生时,kubelet 都会立即收到。

Step 2:kubelet 收到业务 pod 创建事件,开始创建 pod

kubelet 收到一条 pod create 事件后,首先判断这个 pod 是否在自己的管辖范围内(spec 中的 nodeName 是否是这台 node), 是的话就开始创建这个 pod

Step 2.1 创建业务 pod:初始化部分

kubelet.INFO 中有比较详细的日志:

10:05:57.410  Receiving a new pod "pod1(<pod1-id>)"
10:05:57.411  SyncLoop (ADD, "api"): "pod1(<pod1-id>)"
10:05:57.411  Needs to allocate 2 "nvidia.com/gpu" for pod "<pod1-id>" container "container1"
10:05:57.411  Needs to allocate 1 "our-corp.com/ip" for pod "<pod1-id>" container "container1"
10:05:57.413  Cgroup has some missing paths: [/sys/fs/cgroup/pids/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/systemd/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpuset/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/memory/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/hugetlb/kubepods/burstable/pod<pod1-id>]
10:05:57.413  Cgroup has some missing paths: [/sys/fs/cgroup/memory/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/systemd/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/hugetlb/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/pids/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpuset/kubepods/burstable/pod<pod1-id>]
10:05:57.413  Cgroup has some missing paths: [/sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/pids/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpuset/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/systemd/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/memory/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/hugetlb/kubepods/burstable/pod<pod1-id>]
10:05:57.415  Using factory "raw" for container "/kubepods/burstable/pod<pod1-id>"
10:05:57.415  Added container: "/kubepods/burstable/pod<pod1-id>" (aliases: [], namespace: "")
10:05:57.419  Waiting for volumes to attach and mount for pod "pod1(<pod1-id>)"

10:05:57.432  SyncLoop (RECONCILE, "api"): "pod1(<pod1-id>)"

10:05:57.471  Added volume "meminfo" (volSpec="meminfo") for pod "<pod1-id>" to desired state.
10:05:57.471  Added volume "cpuinfo" (volSpec="cpuinfo") for pod "<pod1-id>" to desired state.
10:05:57.471  Added volume "stat" (volSpec="stat") for pod "<pod1-id>" to desired state.
10:05:57.480  Added volume "share-dir" (volSpec="pvc-6ee43741-29b1-4aa0-98d3-5413764d36b1") for pod "<pod1-id>" to desired state.
10:05:57.484  Added volume "data-dir" (volSpec="juicefs-volume1-pv") for pod "<pod1-id>" to desired state.
...

可以看出里面会依次处理 pod 所需的各种资源:

  1. 设备:例如 GPU
  2. IP 地址;
  3. cgroup 资源隔离配置;
  4. volumes

本文主要关注 volume 资源。

Step 2.2 处理 pod 依赖的 volumes

上面日志可以看到,业务 pod 里面声明了一些需要挂载的 volumes。几种类型

  1. hostpath 类型:直接把 node 路径挂载到容器内;
  2. lxcfs 类型:为了解决资源视图问题 [2];
  3. 动态/静态 PV 类型

本文的 JuiceFS volume 就属于 PV 类型,继续看 kubelet 日志:

# kubelet.INFO
10:05:57.509  operationExecutor.VerifyControllerAttachedVolume started for volume "xxx"
10:05:57.611  Starting operationExecutor.MountVolume for volume "xxx" (UniqueName: "kubernetes.io/host-path/<pod1-id>-xxx") pod "pod1" (UID: "<pod1-id>") 
10:05:57.611  operationExecutor.MountVolume started for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") 
10:05:57.611  kubernetes.io/csi: mounter.GetPath generated [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.611  kubernetes.io/csi: created path successfully [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv]
10:05:57.611  kubernetes.io/csi: saving volume data file [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/vol_data.json]
10:05:57.611  kubernetes.io/csi: volume data file saved successfully [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/vol_data.json]
10:05:57.613  MountVolume.MountDevice succeeded for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") device mount path "/var/lib/k8s/kubelet/plugins/kubernetes.io/csi/pv/juicefs-volume1-pv/globalmount"
10:05:57.616  kubernetes.io/csi: mounter.GetPath generated [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.616  kubernetes.io/csi: Mounter.SetUpAt(/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount)
10:05:57.616  kubernetes.io/csi: created target path successfully [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.618  kubernetes.io/csi: calling NodePublishVolume rpc [volid=juicefs-volume1-pv,target_path=/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.713  Starting operationExecutor.MountVolume for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") 
...
10:05:59.506  kubernetes.io/csi: mounter.SetUp successfully requested NodePublish [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:59.506  MountVolume.SetUp succeeded for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") 
10:05:59.506  kubernetes.io/csi: mounter.GetPath generated [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]

对于每个 volume,依次执行,

  1. operationExecutor.VerifyControllerAttachedVolume() 方法,做一些检查;
  2. operationExecutor.MountVolume() 方法,将指定的 volume 挂载到容器目录;
  3. 对于 CSI 存储,还会调用到 CSI plugin 的 NodePublishVolume() 方法,初始化对应的 PV,JuiceFS 就是这种模式。

接下来 kubelet 会不断检测所有 volumes 是否都挂载好,没好的话不会进入下一步(创建 sandbox 容器)。

Step 3:kubelet --> CSI plugin(juicefs):setup PV

下面进一步看一下 node CSI plugin 初始化 PV 挂载的逻辑。调用栈

         gRPC NodePublishVolume()
kubelet ---------------------------> juicefs node plugin (also called "driver", etc)

Step 4:JuiceFS CSI plugin 具体工作

看一下 JuiceFS CSI node plugin 的日志,这里直接在机器上看:

(node) $ docker logs --timestamps k8s_juicefs-plugin_juicefs-csi-node-xxx | grep juicefs-volume1
10:05:57.619 NodePublishVolume: volume_id is juicefs-volume1-pv

10:05:57.619 NodePublishVolume: creating dir /var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount

10:05:57.620 ceFormat cmd: [/usr/local/bin/juicefs format --storage=OSS --bucket=xx --access-key=xx --secret-key=${secretkey} --token=${token} ${metaurl} juicefs-volume1]
10:05:57.874 Format output is juicefs <INFO>: Meta address: tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1
10:05:57.874 cefs[1983] <INFO>: Data use oss://<bucket>/juicefs-volume1/

10:05:57.875 Mount: mounting "tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1" at "/jfs/juicefs-volume1-pv" with options [token=xx]

10:05:57.884 createOrAddRef: Need to create pod juicefs-node1-juicefs-volume1-pv.
10:05:57.891 createOrAddRed: GetMountPodPVC juicefs-volume1-pv, err: %!s(<nil>)
10:05:57.891 ceMount: mount tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1 at /jfs/juicefs-volume1-pv
10:05:57.978 createOrUpdateSecret: juicefs-node1-juicefs-volume1-pv-secret, juicefs-system
10:05:59.500 waitUtilPodReady: Pod juicefs-node1-juicefs-volume1-pv is successful

10:05:59.500 NodePublishVolume: binding /jfs/juicefs-volume1-pv at /var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount with options []
10:05:59.505 NodePublishVolume: mounted juicefs-volume1-pv at /var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount with options []

可以看到确实执行了 NodePublishVolume() 方法, 这个方法是每个 CSI plugin 方案各自实现的,所以里面做什么事情就跟存储方案有很大关系。 接下来具体看看 JuiceFS plugin 做的什么。

Step 4.1 给 pod PV 创建挂载路径,初始化 volume

默认配置下,每个 pod 会在 node 上对应一个存储路径,

(node) $ ll /var/lib/k8s/kubelet/pods/<pod-id>
containers/
etc-hosts
plugins/
volumes/

juicefs plugin 会在以上 volumes/ 目录内给 PV 创建一个对应的子目录和挂载点,

/var/lib/k8s/kubelet/pods/{pod1-id}/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount

然后用 juicefs 命令行工具格式化

$ /usr/local/bin/juicefs format --storage=OSS --bucket=xx --access-key=xx --secret-key=${secretkey} --token=${token} ${metaurl} juicefs-volume1

例如,如果 JuiceFS 对接的是阿里云 OSS,上面就对应阿里云的 bucket 地址及访问秘钥。

Step 4.2 volume 挂载信息写入 MetaServer

此外,还会把这个挂载信息同步到 JuiceFS 的 MetaServer,这里用的是 TiKV,暂不展开:

Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

Step 4.3 JuiceFS plugin:如果 client pod 不存在,就创建一个

JuiceFS CSI plugin 判断这个 PV 在 node 上是否已经存在 client pod,如果不存在,就创建一个;存在就不用再创建了。

当 node 上最后一个使用某 PV 的业务 pod 销毁后,对应的 client pod 也会被 juicefs CSI plugin 自动删掉。

我们这个环境用的是 dynamic client pod 方式,因此会看到如下日志:

(node) $ docker logs --timestamps <csi plugin container> | grep 
...
10:05:57.884 createOrAddRef: Need to create pod juicefs-node1-juicefs-volume1-pv.
10:05:57.891 createOrAddRed: GetMountPodPVC juicefs-volume1-pv, err: %!s(<nil>)
10:05:57.891 ceMount: mount tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1 at /jfs/juicefs-volume1-pv
10:05:57.978 createOrUpdateSecret: juicefs-node1-juicefs-volume1-pv-secret, juicefs-system
10:05:59.500 waitUtilPodReady:

JuiceFS node plugin 会去 k8s 里面创建一个名为 juicefs-{node}-{volume}-pv 的 dynamic client pod。

Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

Step 5:kubelet 监听到 client pod 创建事件

这时候 kubelet 的业务 pod 还没创建好,“伺候”它的 juicefs client pod 又来“请求创建”了:

(node) $ grep juicefs-<node>-<volume>-pv /var/log/kubernetes/kubelet.INFO | grep "received "
10:05:58.288 SyncPod received new pod "juicefs-node1-volume1-pv_juicefs-system", will create a sandbox for it

所以接下来进入创建 juicefs dynamic client pod 的流程。

兵马未动,粮草先行。juicefs client pod 没有好,业务 pod 即使起来了也不能读写 juicefs volume

Step 6:kubelet 创建 client pod

创建 client pod 的流程跟业务 pod 是类似的,但这个 pod 比较简单,我们省略细节,认为它直接就拉起来了。

查看这个 client pod 内运行的进程

(node) $ dk top k8s_jfs-mount_juicefs-node1-juicefs-volume1-pv-xx
/bin/mount.juicefs ${metaurl} /jfs/juicefs-volume1-pv -o enable-xattr,no-bgjob,allow_other,token=xxx,metrics=0.0.0.0:9567

/bin/mount.juicefs 其实只是个 alias,指向的就是 juicefs 可执行文件

(pod) $ ls -ahl /bin/mount.juicefs
/bin/mount.juicefs -> /usr/local/bin/juicefs

Step 7:client pod 初始化、FUSE 挂载

查看这个 client pod 干了什么:

root@node:~  # dk top k8s_jfs-mount_juicefs-node1-juicefs-volume1-pv-xx
<INFO>: Meta address: tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1
<INFO>: Data use oss://<oss-bucket>/juicefs-volume1/
<INFO>: Disk cache (/var/jfsCache/<id>/): capacity (10240 MB), free ratio (10%), max pending pages (15)
<INFO>: Create session 667 OK with version: admin-1.2.1+2022-12-22.34c7e973
<INFO>: listen on 0.0.0.0:9567
<INFO>: Mounting volume juicefs-volume1 at /jfs/juicefs-volume1-pv ...
<INFO>: OK, juicefs-volume1 is ready at /jfs/juicefs-volume1-pv
  1. 初始化本地 volume 配置
  2. 与 MetaServer 交互
  3. 暴露 prometheus metrics
  4. 以 juicefs 自己的 mount 实现(前面看到的 /bin/mount.juicefs),将 volume 挂载到 /jfs/juicefs-volume1-pv,默认对应的是 /var/lib/juicefs/volume/juicefs-volume1-pv

此时在 node 上就可以看到如下的挂载信息

(node) $ cat /proc/mounts | grep JuiceFS:juicefs-volume1
JuiceFS:juicefs-volume1 /var/lib/juicefs/volume/juicefs-volume1-pv fuse.juicefs rw,relatime,user_id=0,group_id=0,default_permissions,allow_other 0 0
JuiceFS:juicefs-volume1 /var/lib/k8s/kubelet/pods/<pod-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount fuse.juicefs rw,relatime,user_id=0,group_id=0,default_permissions,allow_other 0 0

可以看到是 fuse.juicefs 方式的挂载。 忘了 FUSE 基本工作原理的,再来借 lxcfs 快速回忆一下:

Fig. lxcfs/fuse workflow: how a read operation is handled [2]

这个 dynamic client pod 创建好之后, 业务 pod(此时还不存在)的读写操作都会进入 FUSE 模块, 然后转发给用户态的 juicefs client 处理。juicefs client 针对不同的 object store 实现了对应的读写方法。

Step 8:kubelet 创建业务 pod:完成后续部分

至此,Pod 所依赖的 volumes 都处理好了,kubelet 就会打印一条日志:

# kubelet.INFO
10:06:06.119  All volumes are attached and mounted for pod "pod1(<pod1-id>)"

接下来就可以继续创建业务 pod 了:

# kubelet.INFO
10:06:06.119  No sandbox for pod "pod1(<pod1-id>)" can be found. Need to start a new one
10:06:06.119  Creating PodSandbox for pod "pod1(<pod1-id>)"
10:06:06.849  Created PodSandbox "885c3a" for pod "pod1(<pod1-id>)"
...

小结

更详细的 pod 创建过程,可以参考 [1]。

3 业务 pod 读写 juicefs volume 流程

juicefs dynamic client pod 先于业务 pod 创建,所以业务 pod 创建好之后,就可以直接读写 juicefs PV (volume) 了,

Fig. JuiceFS as K8s CSI solution: workflow when a business pod reads/writes (JuiceFS mountpod mode).

这个过程可以大致分为四步。

Step 1:pod 读写文件(R/W operations)

例如在 pod 内进入 volume 路径(e.g. cd /data/juicefs-pv-dir/),执行 ls、find 等等之类的操作。

Step 2:R/W 请求被 FUSE 模块 hook,转给 juicefs client 处理

直接贴两张官方的图略作说明 [3],这两张图也透露了随后的 step 3 & 4 的一些信息:

读操作:

Fig. JuiceFS Internals: read operations.

写操作:

Fig. JuiceFS Internals: write operations.

Step 3:juicefs client pod 从 meta server 读取(文件或目录的)元数据

上面的图中已经透露了一些 JuiceFS 的元数据设计,例如 chunk、slice、block 等等。 读写操作时,client 会与 MetaServer 有相关的元信息交互。

Step 4:juicefs client pod 从 object store 读写文件

这一步就是去 S3 之类的 object store 去读写文件了。

4 总结

以上就是使用 JuiceFS 作为 k8s CSI plugin 时,创建一个带 PV 的 pod 以及这个 pod 读写 PV 的流程。 限于篇幅,省略了很多细节,感兴趣的可移步参考资料。

参考资料

  1. 源码解析:K8s 创建 pod 时,背后发生了什么(系列)(2021)
  2. Linux 容器底层工作机制:从 500 行 C 代码到生产级容器运行时(2023)
  3. 官方文档:读写请求处理流程, juicefs.com
  4. kubernetes-csi.github.io/docs/, K8s CSI documentation

Written by Human, Not by AI Written by Human, Not by AI

TCP Requests Stuck After Connection Established(2024)

2024年6月26日 08:00

This post describes a kernel & BPF networking problem and the trouble shooting steps, which is an interesting case for delving into Linux kernel networking intricacies.

Fig. Phenomenon of a reported issue.



1 Trouble report

1.1 Phenomenon: probabilistic health check failures

Users reported intermittent failures of their pods, despite them run as usual with no exceptions.

The health check is a very simple HTTP probe over TCP: kubelet periodically (e.g. every 5s) sends GET requests to local pods, initiating a new TCP connection with each request.

Fig. Intermittent health check failures of pods.

Users suspect this is a network problem.

1.2 Scope: specific pods on specific nodes

This reported issue is confined to a new k8s cluster, with recently introduced OS and kernel:

  • OS: AliOS (AlibabaCloud OS)
  • Kernel: cloud-kernel 5.10.134-16.al8.x86_64 (a fork of Linux, gitee.com/anolis/cloud-kernel), which includes their upstream feature backports and self-maintanined changes, for example,

    1. Intel AMX (Advanced Matrix Extensions) for AI workloads, offering a hardware acceleration alternative to GPUs in certain scenarios, such as inference for LLMs smaller than 13B. AMX support was first introduced in kernel 5.16, cloud-kernel backported the feature to its current version 5.10;
    2. cloud-kernel includes un-upstreamed modifications like new kernel structure fields and new enums/types.

Other environment info:

2 Networking fundamentals

Before starting our exploration, let’s outline our networking infra in this cluster.

2.1 Node network topology: Cilium (with BPF)

Internal networking topology of our k8s node is depicted as below:

Fig. Internal networking topology of a k8s node.

(k8s node) $ route -n
Destination  Gateway   Genmask           Use Iface
0.0.0.0      <GW-IP>   0.0.0.0           eth0
<Node-IP>    0.0.0.0   <Node-IP-Mask>    eth0
<Pod1-IP>    0.0.0.0   255.255.255.255   lxc-1
<Pod2-IP>    0.0.0.0   255.255.255.255   lxc-2
<Pod3-IP>    0.0.0.0   255.255.255.255   lxc-3

As shown in the picture and kernel routing table output, each pod has a dedicated routing entry. Consequently, all health check traffic is directed straight to the lxc device (the host-side device of the pod’s veth pair), subsequently entering the Pod. In another word, all the health check traffic is processed locally.

Cilium has a similar networking topology on AlibabaCloud as on AWS. For more information, refer to Cilium Network Topology and Traffic Path on AWS (2019), which may contain some stale information, but most of the content should still validate.

2.2 Kernel 5.10+: sockmap BPF acceleration for node2localPod traffic

2.2.1 sockops BPF: bypass kernel stack for local traffic

How to use eBPF for accelerating Cloud Native applications offers a practical example of how sockops/sockmap BPF programs work.

Chinese readers can also refer to the following for more information,

  1. (译)利用 ebpf sockmap/redirection 提升 socket 性能(2020)
  2. BPF 进阶笔记(五):几种 TCP 相关的 BPF(sockops、struct_ops、header options)

2.2.2 tcpdump: only TCP 3-way/4-way handshake packets can be captured

sockops acceleration is automatically enabled in kernel 5.10 + Cilium v1.11.10:

Fig. Socket-level acceleration in Cilium. Note that the illustration depicts local processes communicating via loopback, which differs from the scenario discussed here, just too lazy draw a new picture.

One big conceptual change is that when sockops BPF is enabled, you could not see request & response packets in tcpdump output, as in this setup, only TCP 3-way handshake and 4-way close procedure still go through kernel networking stack, all the payload will directly go through the socket-level (e.g. in tcp/udp send/receive message) methods.

A quick test to illustrate the idea: access a server in pod from the node:

(node) $ curl <pod ip>:<port>

The output of tcpdump:

(pod) $ tcpdump -nn -i eth0 host <node ip> and <port>
# TCP 3-way handshake
IP NODE_IP.36942 > POD_IP.8080: Flags [S]
IP POD_IP.8080   > NODE_IP.36942: Flags [S.]
IP NODE_IP.36942 > POD_IP.8080: Flags [.]

# requests & responses, no packets go through there, they are bypassed,
# payloads are transferred directly in socket-level TCP methods

# TCP 4-way close
IP POD_IP.8080   > NODE_IP.36942: Flags [F.]
IP NODE_IP.36942 > POD_IP.8080: Flags [.]
IP NODE_IP.36942 > POD_IP.8080: Flags [F.]
IP POD_IP.8080   > NODE_IP.36942: Flags [.]

2.3 Summary

Now we’ve got a basic undertanding about the problem and environment. It’s time to delve into practical investigation.

3 Quick narrow-down

3.1 Quick reproduction

First, check kubelet log,

$ grep "Timeout exceeded while awaiting headers" /var/log/kubernetes/kubelet.INFO
prober.go] Readiness probe for POD_XXX failed (failure):
  Get "http://POD_IP:PORT/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
...

Indeed, there are many readiness probe failures.

Since the probe is very simple HTTP request, we can do it manually on the node, this should be equivalent to the kubelet probe,

$ curl <POD_IP>:<PORT>/v1/health
OK
$ curl <POD_IP>:<PORT>/v1/health
OK
$ curl <POD_IP>:<PORT>/v1/health # stuck
^C

OK, we can easily reproduce it without relying on k8s facilities.

3.2 Narrow-down the issue

Now let’s perform some quick tests to narrow-down the problem.

3.2.1 ping: OK, exclude L2/L3 problem

ping PodIP from node always succeeds.

(node) $ ping <POD_IP>

This indicates L2 & L3 (ARP table, routing table, etc) connectivity functions well.

3.2.2 telnet connection test: OK, exclude TCP connecting problem

(node) $ telnet POD_IP PORT
Trying POD_IP...
Connected to POD_IP.
Escape character is '^]'.

Again, always succeeds, and the ss output confirms the connections always enter ESTABLISHED state:

(node) $ netstat -antp | grep telnet
tcp        0      0 NODE_IP:34316    POD_IP:PORT     ESTABLISHED 2360593/telnet

3.2.3 Remote-to-localPod curl: OK, exclude pod problem & vanilla kernel stack problem

Do the same health check from a remote node, always OK:

(node2) $ curl <POD_IP>:<PORT>/v1/health
OK
...
(node2) $ curl <POD_IP>:<PORT>/v1/health
OK

This rules out issues with the pod itself and the vanilla kernel stack.

3.2.4 Local pod-to-pod: OK, exclude some node-internal problems

(pod3) $ curl <POD2_IP>:<PORT>/v1/health
OK
...
(pod3) $ curl <POD2_IP>:<PORT>/v1/health
OK

Always OK. Rule out issues with the pod itself.

3.3 Summary: only node-to-localPod TCP requests stuck probabilistically

Fig. Test cases and results.

The difference of three cases:

  1. Node-to-localPod: payload traffic is processed via sockops BPF;
  2. Local Pod-to-Pod: BPF redirection (or kernel stack, based on your kernel version)
  3. RemoteNode-to-localPod: standard kernel networking stack

Combining these information, we guess with confidence that the problem have relationships with sockops BPF and kernel (because kernel does most of the job in sockops BPF scenarios).

From these observations, it is reasonable to deduce that the issue is likely related to sockops BPF and the kernel, given the kernel’s central role in sockops BPF scenarios.

4 Dig deeper

Now let’s explore the issue in greater depth.

4.1 Linux vs. AliOS kernel

As we’ve been using kernel 5.10.56 and cilium v1.11.10 for years and haven’t met this problem before, the first reasonable assumption is that AliOS cloud-kernel 5.10.134 may introduce some incompatible changes or bugs.

So we spent some time comparing AliOS cloud-kernel with the upstream Linux.

Note: cloud-kernel is maintained on gitee.com, which restricts most read privileges (e.g. commits, blame) without logging in, so in the remaining of this post we reference the Linux repo on github.com for discussion.

4.1.1 Compare BPF features

First, compare BPF features automatically detected by cilium-agent on the node. The result is written to a local file on the node: /var/run/cilium/state/globals/bpf_features.h,

$ diff <bpf_features.h from our 5.10.56 node> <bpf_features.h from AliOS node>
59c59
< #define NO_HAVE_XSKMAP_MAP_TYPE
---
> #define HAVE_XSKMAP_MAP_TYPE
71c71
< #define NO_HAVE_TASK_STORAGE_MAP_TYPE
---
> #define HAVE_TASK_STORAGE_MAP_TYPE
243c243
< #define BPF__PROG_TYPE_socket_filter__HELPER_bpf_ktime_get_coarse_ns 0
---
> #define BPF__PROG_TYPE_socket_filter__HELPER_bpf_ktime_get_coarse_ns 1
...

There are indeed some differences, but with further investigation, we didn’t find any correlation to the observed issue.

4.1.2 AliOS cloud-kernel specific changes

Then we spent some time to check AliOS cloud-kernel self-maintained BPF and networking modifications. Such as,

  1. b578e4b8ed6e1c7608e07e03a061357fd79ac2dd ck: net: track the pid who created socks

    In this commit, they added a pid_t pid field to the struct sock data structure.

  2. ea0307caaf29700ff71467726b9617dcb7c0d084 tcp: make sure init the accept_queue’s spinlocks once

But again, we didn’t find any correlation to the problem.

4.2 Check detailed TCP connection stats

Without valuable information from code comparison, we redirected our focus to the environment, collecting some more detailed connection information.

ss (socket stats) is a powerful and convenient tool for socket/connection introspection:

  • -i/--info: show internal TCP information, including couple of TCP connection stats;
  • -e/--extended: show detailed socket information, including inode, uid, cookie.

4.2.1 Normal case: ss shows correct segs_out/segs_in

Initiate a connection with nc (netcat),

(node) $ nc POD_IP PORT

We intentionally not use telnet here, because telnet will close the connection immediately after a request is served successfully, which leaves us no time to check the connection stats in ss output. nc will leave the connection in CLOSE-WAIT state, which is good enough for us to check the connection send/receive stats.

Now the stats for this connection:

(node) $ ss -i | grep -A 1 50504
tcp    ESTAB      0         0         NODE_IP:50504          POD_IP:PORT
         cubic wscale:7,7 rto:200 rtt:0.059/0.029 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 bytes_acked:1 segs_out:2 segs_in:1 send 1963.4Mbps lastsnd:14641 lastrcv:14641 lastack:14641 pacing_rate 3926.8Mbps delivered:1 rcv_space:14480 rcv_ssthresh:64088 minrtt:0.059

Send & receive stats: segs_out=2, segs_in=1.

Now let’s send a request to the server: type GET /v1/health HTTP/1.1\r\n then press Enter,

Actually you can type anything and just Enter, the server will most likely send you a 400 (Bad Request) response, but for our case, this 400 indicate the TCP send/receive path is perfectly OK!

(node) $ nc POD_IP PORT
GET /v1/health HTTP/1.1\r\n
<Response Here>

We’ll get the response and the connection will just entering CLOSE-WAIT state, we have some time to check it before it vanishing:

(node) $ ss -i | grep -A 1 50504
tcp     CLOSE-WAIT   0      0        NODE_IP:50504     POD_IP:http
         cubic wscale:7,7 rto:200 rtt:0.059/0.029 ato:40 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 bytes_acked:1 bytes_received:1 segs_out:3 segs_in:2 send 1963.4Mbps lastsnd:24277 lastrcv:24277 lastack:4399 pacing_rate 3926.8Mbps delivered:1 rcv_space:14480 rcv_ssthresh:64088 minrtt:0.059

As expected, segs_out=3, segs_in=2.

4.2.2 Abnormal case: ss shows incorrect segs_out/segs_in

Repeat the above test to capture a failed one.

On connection established,

$ ss -i | grep -A 1 57424
tcp      ESTAB      0       0         NODE_IP:57424    POD_IP:webcache
         cubic wscale:7,7 rto:200 rtt:0.056/0.028 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 bytes_acked:1 segs_out:2 segs_in:1 send 2068.6Mbps lastsnd:10686 lastrcv:10686 lastack:10686 pacing_rate 4137.1Mbps delivered:1 rcv_space:14480 rcv_ssthresh:64088 minrtt:0.056

After typing the request content and stroking Enter:

(node) $ ss -i | grep -A 1 57424
tcp      ESTAB      0       0         NODE_IP:57424    POD_IP:webcache
         cubic wscale:7,7 rto:200 rtt:0.056/0.028 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 bytes_acked:1 segs_out:2 segs_in:1 send 2068.6Mbps lastsnd:21994 lastrcv:21994 lastack:21994 pacing_rate 4137.1Mbps delivered:1 rcv_space:14480 rcv_ssthresh:64088 minrtt:0.056

That segments sent/received stats remain unchanged (segs_out=2,segs_in=1), suggesting that the problem may reside on tcp {send,receive} message level.

4.3 Trace related call stack

Based on the above hypothesis, we captured kernel call stacks to compare failed and successful requests.

4.3.1 trace-cmd: trace kernel call stacks

Trace 10 seconds, filter by server process ID, save the calling stack graph,

# filter by process ID (PID of the server in the pod)
$ trace-cmd record -P 178501 -p function_graph sleep 10

Caution: avoid tracing in production to prevent large file generation and excessive disk IO.

During this period, send a request,

(node) $ curl POD_IP PORT

By default, it will save data to a local file in the current directory, the content looks like this:

$ trace-cmd report > report-1.graph
CPU  1 is empty
CPU  2 is empty
...
CPU 63 is empty
cpus=64
   <idle>-0     [022] 5376816.422992: funcgraph_entry:    2.441 us   |  update_acpu.constprop.0();
   <idle>-0     [022] 5376816.422994: funcgraph_entry:               |  switch_mm_irqs_off() {
   <idle>-0     [022] 5376816.422994: funcgraph_entry:    0.195 us   |    choose_new_asid();
   <idle>-0     [022] 5376816.422994: funcgraph_entry:    0.257 us   |    load_new_mm_cr3();
   <idle>-0     [022] 5376816.422995: funcgraph_entry:    0.128 us   |    switch_ldt();
   <idle>-0     [022] 5376816.422995: funcgraph_exit:     1.378 us   |  }
...

Use | as delimiter (this preserves the calling stack and the proper leading whitespaces) and save the last fields into a dedicated file:

$ awk -F'|' '{print $NF}' report-1.graph > stack-1.txt

Compare them with diff or vimdiff:

$ vimdiff stack-1.txt stack-2.txt

Here are two traces, the left is a trace of a normal request, and the right is a problematic one:

Fig. Traces (call stacks) of a normal request (left side) and a problematic request (right side).

As can be seen, for a failed request, kernel made a wrong function call: it should call tcp_bpf_recvmsg() but actually called tcp_recvmsg().

4.3.2 Locate the code: inet_recvmsg -> {tcp_bpf_recvmsg, tcp_recvmsg}

Calling into tcp_bpf_recvmsg or tcp_recvmsg from inet_recvmsg is a piece of concise code, illustrated below,

// https://github.com/torvalds/linux/blob/v5.10/net/ipv4/af_inet.c#L838
int inet_recvmsg(struct socket *sock, struct msghdr *msg, size_t size, int flags) {
    struct sock *sk = sock->sk;
    int addr_len = 0;
    int err;

    if (likely(!(flags & MSG_ERRQUEUE)))
        sock_rps_record_flow(sk);

    err = INDIRECT_CALL_2(sk->sk_prot->recvmsg, tcp_recvmsg, udp_recvmsg,
                  sk, msg, size, flags & MSG_DONTWAIT,
                  flags & ~MSG_DONTWAIT, &addr_len);
    if (err >= 0)
        msg->msg_namelen = addr_len;
    return err;
}

sk_prot ("socket protocol") contains handlers to this socket. INDIRECT_CALL_2 line can be simplified into the following pseudocode:

if sk->sk_prot->recvmsg == tcp_recvmsg: // if socket protocol handler is tcp_recvmsg
    tcp_recvmsg()
else:
    tcp_bpf_recvmsg()

This suggests that when requests fail, the sk_prot->recvmsg pointer of the socket is likely incorrect.

4.3.3 Double check with bpftrace

While trace-cmd is a powerful tool, it may contain too much details distracting us, and may run out of your disk space if set improper filter parameters.

bpftrace is a another tracing tool, and it won’t write data to local file by default. Now let’s double confirm the above results with it.

Again, run several times of curl POD_IP:PORT, capture only tcp_recvmsg and tcp_bpf_recvmsg calls, print kernel calling stack:

$ bpftrace -e 'k:tcp_recvmsg /pid==178501/ { printf("%s\n", kstack);} k:tcp_bpf_recvmsg /pid==178501/ { printf("%s\n", kstack);} '
        tcp_bpf_recvmsg+1                   # <-- correspond to a successful request
        inet_recvmsg+233
        __sys_recvfrom+362
        __x64_sys_recvfrom+37
        do_syscall_64+48
        entry_SYSCALL_64_after_hwframe+97

        tcp_bpf_recvmsg+1                   # <-- correspond to a successful request
        inet_recvmsg+233
        __sys_recvfrom+362
        __x64_sys_recvfrom+37
        do_syscall_64+48
        entry_SYSCALL_64_after_hwframe+97

        tcp_recvmsg+1                       # <-- correspond to a failed request
        inet_recvmsg+78
        __sys_recvfrom+362
        __x64_sys_recvfrom+37
        do_syscall_64+48
        entry_SYSCALL_64_after_hwframe+97

You could also filter by client program name (comm field in kernel data structure), such as,

$ bpftrace -e 'k:tcp_bpf_recvmsg /comm=="curl"/ { printf("%s", kstack); }'

As seen above, successful requests were directed to tcp_bpf_recvmsg, while failed ones were routed to tcp_recvmsg.

4.3.4 Summary

tcp_recvmsg waits messages from kernel networking stack, In the case of sockops BPF, messages bypass kernel stack, which explains why some requests fail (timeout), yet TCP connecting always OK.

We reported the above findings to the cloud-kernel team, and they did some further investigations with us.

4.4 recvmsg handler initialization in kernel stack

For short,

Fig. sockops BPF: connection establishement and socket handler initialization.

According to the above picture, recvmsg handler will be incorrectly initialized if to-be-inserted entry already exists sockmap (the end of step 3.1).

What’s the two entries of a connection looks like in BPF map:

(cilium-agent) $ bpftool map dump id 122 | grep "0a 0a 86 30" -C 2 | grep "0a 0a 65 f9" -C 2 | grep -C 2 "db 78"
0a 0a 86 30 00 00 00 00  00 00 00 00 00 00 00 00
0a 0a 65 f9 00 00 00 00  00 00 00 00 00 00 00 00
01 00 00 00 1f 90 00 00  db 78 00 00
--
key:
--
0a 0a 65 f9 00 00 00 00  00 00 00 00 00 00 00 00
0a 0a 86 30 00 00 00 00  00 00 00 00 00 00 00 00
01 00 00 00 db 78 00 00  1f 90 00 00

We’ll explain these binary data later. Now let’s first confirm our above assumption.

4.5 Confirm stale entries in sockmap

4.5.1 bpftrace tcp_bpf_get_prot(): incorrect socket handler (sk_prot)

Two sequent function calls that holding sk_port:

  • tcp_bpf_get_prot(): where sk_prot is initialized;
  • tcp_bpf_recvmsg() or tcp_recvmsg(): where sk_prot is called to receive a message;

Trace these two methods and print the sk_prot variable (pointer).

Successful case:

tcp_bpf_get_proto: src POD_IP (8080), dst NODE_IP(59500), 2232440
tcp_bpf_get_proto: 0xffffffffacc65800                                     # <-- sk_prot pointer
tcp_bpf_recvmsg: src POD_IP (8080), dst NODE_IP(59500) 0xffffffffacc65800 # <-- same pointer

Bad case:

(node) $ ./tcp_bpf_get_proto.bt 178501
Attaching 6 probes...
tcp_bpf_get_proto: src POD_IP (8080), dst NODE_IP(53904), 2231203
tcp_bpf_get_proto: 0xffffffffacc65800                                    # <-- sk_prot pointer
tcp_recvmsg: src POD_IP (8080), dst NODE_IP(53904) 0xffffffffac257300    # <-- sk_prot is modified!!!

4.5.2 bpftrace sk_psock_drop

A succesful case, calling into sk_psock_drop when requests finish and connection was normally closed:

(node) $ ./sk_psock_drop.bt 178501
tcp_bpf_get_proto: src POD_IP (8080), dst NODE_IP(59500), 2232440
tcp_bpf_get_proto: 0xffffffffacc65800                                    # <-- sk_prot pointer
sk_psock_drop: src POD_IP (8080), dst NODE_IP(44566)
    sk_psock_drop+1
    sock_map_remove_links+161
    sock_map_close+50
    inet_release+63
    sock_release+58
    sock_close+17
    fput+147
    task_work_run+89
    exit_to_user_mode_loop+285
    exit_to_user_mode_prepare+110
    syscall_exit_to_user_mode+18
    entry_SYSCALL_64_after_hwframe+97
tcp_bpf_recvmsg: src POD_IP (8080), dst NODE_IP(59500) 0xffffffffacc65800 # <-- same pointer

A failed case, calling into sk_psock_drop when the server side calls sock_map_update() and the to-be-inserted entry already exists:

(node) $ ./sk_psock_drop.bt 178501
tcp_bpf_get_proto: src POD_IP (8080), dst NODE_IP(53904), 2231203
tcp_bpf_get_proto: 0xffffffffacc65800                                    # <-- sk_prot pointer
sk_psock_drop: src POD_IP (8080), dst NODE_IP(44566)
    sk_psock_drop+1
    sock_hash_update_common+789
    bpf_sock_hash_update+98
    bpf_prog_7aa9a870410635af_bpf_sockmap+831
    _cgroup_bpf_run_filter_sock_ops+189
    tcp_init_transfer+333                       // -> bpf_skops_established -> BPF_CGROUP_RUN_PROG_SOCK_OPS -> cilium sock_ops code
    tcp_rcv_state_process+1430
    tcp_child_process+148
    tcp_v4_rcv+2491
    ...
tcp_recvmsg: src POD_IP (8080), dst NODE_IP(53904) 0xffffffffac257300    # <-- sk_prot is modified!!!
// https://github.com/torvalds/linux/blob/v6.5/net/core/sock_map.c#L464

static int sock_map_update_common(struct bpf_map *map, u32 idx, struct sock *sk, u64 flags) {
    struct bpf_stab *stab = container_of(map, struct bpf_stab, map);
    ...

    link = sk_psock_init_link();
    sock_map_link(map, sk);
    psock = sk_psock(sk);

    osk = stab->sks[idx];
    if (osk && flags == BPF_NOEXIST) {     // sockmap entries already exists
        ret = -EEXIST;
        goto out_unlock;                   // goto out_unlock, which will release psock
    } else if (!osk && flags == BPF_EXIST) {
        ret = -ENOENT;
        goto out_unlock;
    }

    sock_map_add_link(psock, link, map, &stab->sks[idx]);
    stab->sks[idx] = sk;
    if (osk)
        sock_map_unref(osk, &stab->sks[idx]);
    return 0;                              // <-- should return from here
out_unlock:                                // <-- actually hit here
    if (psock)
        sk_psock_put(sk, psock);           // --> further call sk_psock_drop
out_free:
    sk_psock_free_link(link);
    return ret;
}

This early release of psock leads to the sk->sk_prot->recvmsg to be initialized as tcp_recvmsg.

4.5.3 bpftool: confirm stale connection info in sockops map

Key and value format in the BPF map:

// https://github.com/cilium/cilium/blob/v1.11.10/pkg/maps/sockmap/sockmap.go#L20

// SockmapKey is the 5-tuple used to lookup a socket
// +k8s:deepcopy-gen=true
// +k8s:deepcopy-gen:interfaces=github.com/cilium/cilium/pkg/bpf.MapKey
type SockmapKey struct {
    DIP    types.IPv6 `align:"$union0"`
    SIP    types.IPv6 `align:"$union1"`
    Family uint8      `align:"family"`
    Pad7   uint8      `align:"pad7"`
    Pad8   uint16     `align:"pad8"`
    SPort  uint32     `align:"sport"`
    DPort  uint32     `align:"dport"`
}

// SockmapValue is the fd of a socket
// +k8s:deepcopy-gen=true
// +k8s:deepcopy-gen:interfaces=github.com/cilium/cilium/pkg/bpf.MapValue
type SockmapValue struct {
    fd uint32
}

Trip.com: Large Scale Cloud Native Networking & Security with Cilium/eBPF, 2022 shows how to decode the encoded entries of Cilium BPF map.

$ cat ip2hex.sh
echo $1 | awk -F. '{printf("%02x %02x %02x %02x\n",$1,$2,$3,$4);}'
$ cat hex2port.sh
echo $1 | awk '{printf("0x%s%s 0x%s%s\n", $1, $2, $5, $6) }' | sed 's/ /\n/g' | xargs -n1 printf '%d\n'

(node) $ ./ip2hex.sh "10.10.134.48"
0a 0a 86 30
(node) $ ./ip2hex.sh "10.10.101.249"
0a 0a 65 f9
(cilium-agent) $ bpftool map dump id 122 | grep "0a 0a 86 30" -C 2 | grep "0a 0a 65 f9" -C 2 | grep -C 2 "db 78"
0a 0a 86 30 00 00 00 00  00 00 00 00 00 00 00 00
0a 0a 65 f9 00 00 00 00  00 00 00 00 00 00 00 00
01 00 00 00 1f 90 00 00  db 78 00 00
--
key:
--
0a 0a 65 f9 00 00 00 00  00 00 00 00 00 00 00 00
0a 0a 86 30 00 00 00 00  00 00 00 00 00 00 00 00
01 00 00 00 db 78 00 00  1f 90 00 00
(node) $ ./hex2port.sh "1f 90 00 00  b6 8a 00 00"
8080
46730 # you can verify this connection in `ss` output

Almost all of the following entries are stale (because this is an empty, no node-to-pod traffic unless we do manually):

(cilium-agent) $ bpftool map dump /sys/fs/bpf/cilium_sock_ops | grep "0a 0a 86 30" | wc -l
7325
(cilium-agent) $ bpftool map dump /sys/fs/bpf/cilium_sock_ops | grep "0a 0a 8c ca" | wc -l
1288
(cilium-agent) $ bpftool map dump /sys/fs/bpf/cilium_sock_ops | grep "0a 0a 8e 40" | wc -l
191

5 Technical summary

5.1 Normal sockops/sockmap BPF workflow

Fig. sockops BPF: connection establishement and socket handler initialization.

  1. Node client (e.g. kubelet) -> server: initiate TCP connection to the server
  2. Kernel (and the BPF code in kernel): on listening on connection established
    1. write two entries to sockmap
    2. link entries to bpf handlers (tcp_bpf_{sendmsg, recvmsg})
  3. Node client (e.g. kubelet) -> server: send & receive payload: BPF handlers were executed
  4. Node client (e.g. kubelet) -> server: close connection: kernel removes entries from sockmap

5.2 Direct cause

The problem arises in step 4, for an unknown reason, some entries are not deleted when connections closed. This leads to incorrect handler initialization in new connections in step 2 (or section 3.1 in the picture). When hit a stale entry,

  • sender side uses BPF message handlers for transmission;
  • server side treats the the socket as standard, and waits for message via default message handler, then stucks there as no payload goes to default handler.

5.3 Root cause

The Alibaba cloud-kernel team digged further into the issue, and thanks for their efforts, they finally found that bpf, sockmap: Remove unhash handler for BPF sockmap usage was the root cause, which was introduced in Linux 5.10.58. The AliOS kernel we were using was 5.10.134 based, so it suffered from this.

Upstream patch bpf, sockmap: Fix sk->sk_forward_alloc warn_on in sk_stream_kill_queues has already fixed it, but it was only backported to 6.x series.

5.4 Quick restoration/remediation

If the issue already happened, you can use one of the following methods to restore:

  1. Kernel restart: drain the node then restart it, thish will refresh the kernel state;
  2. Manual clean with bpftool: with caution, avoid to remove valid entries.

5.5 Another issue with similar phenomenon

There is another issue with the similar phenomenon when sockops is enabled:

  1. Local pod runs nginx (of recent versions, e.g. >= 1.18);
  2. Sending http requests from node to the local pod, with a large enough cookie length (e.g. > 1024 Byte);

TCP connection will be OK, but requests will always stuck there.

Cilium issue:

ioctl FIONREAD returning incorrect value when sockops is enabled

nginx is reading the headers from the traefik request with a default value of 1024 (client_header_buffer_size 1k;) bytes and then (seemingly) asks via the ioctl how much data is left. Since the return is 0 the request is never fully read and does not proceed further.

Community solution:

Appendix

References

  1. AliOS kernel (a Linux fork), gitee.com/anolis/cloud-kernel
  2. Cilium Network Topology and Traffic Path on AWS (2019)
  3. cilium v1.11.10, bpf_sockops.c
  4. cilium v1.11.10, bpf sockops key & value definition
  5. Differentiate three types of eBPF redirections
  6. Trip.com: Large Scale Cloud Native Networking & Security with Cilium/eBPF, 2022

Written by Human, Not by AI Written by Human, Not by AI

Practical Storage Hierarchy and Performance: From HDDs to On-chip Caches(2024)

2024年5月26日 08:00

This post summarizes bandwidths for local storage media, networking infra, as well as remote storage systems. Readers may find this helpful when identifying bottlenecks in IO-intensive applications (e.g. AI training and LLM inference).

Fig. Peak bandwidth of storage media, networking, and distributed storage solutions.

Note: this post may contain inaccurate and/or stale information.



1 Fundamentals

Before delving into the specifics of storage, let’s first go through some fundamentals about data transfer protocols.

1.1 SATA

From wikepedia SATA:

SATA (Serial AT Attachment) is a computer bus interface that connects host bus adapters to mass storage devices such as hard disk drives, optical drives, and solid-state drives.

1.1.2 Real world pictures

Fig. SATA interfaces and cables on a computer motherboard. Image source wikipedia

1.1.1 Revisions and data rates

The SATA standard has evolved through multiple revisions. The current prevalent revision is 3.0, offering a maximum IO bandwidth of 600MB/s:

Table: SATA revisions. Data source: wikipedia

Spec Raw data rate Data rate Max cable length
SATA Express 16 Gbit/s 1.97 GB/s 1m
SATA revision 3.0 6 Gbit/s 600 MB/s 1m
SATA revision 2.0 3 Gbit/s 300 MB/s 1m
SATA revision 1.0 1.5 Gbit/s 150 MB/s 1m

1.2 PCIe

From wikipedia PCIe (PCI Express):

PCI Express is high-speed serial computer expansion bus standard.

PCIe (Peripheral Component Interconnect Express) is another kind of system bus, designed to connect a variety of peripheral devices, including GPUs, NICs, sound cards, and certain storage devices.

1.1.2 Real world pictures

Fig. Various slots on a computer motherboard, from top to bottom:
PCIe x4 (e.g. for NVME SSD)
PCIe x16 (e.g. for GPU card)
PCIe x1
PCIe x16
Conventional PCI (32-bit, 5 V)
Image source wikipedia

As shown in the above picture, PCIe electrical interface is measured by the number of lanes. A lane is a single data send+receive line, functioning similarly to a “one-lane road” with traffic in both directions.

1.2.2 Generations and data rates

Each new PCIe generation doubles the bandwidth of a lane than the previous generation:

Table: PCIe Unidirectional Bandwidth. Data source: trentonsystems.com

Generation Year of Release Data Transfer Rate Bandwidth x1 Bandwidth x16
PCIe 1.0 2003 2.5 GT/s 250 MB/s 4.0 GB/s
PCIe 2.0 2007 5.0 GT/s 500 MB/s 8.0 GB/s
PCIe 3.0 2010 8.0 GT/s 1 GB/s 16 GB/s
PCIe 4.0 2017 16 GT/s 2 GB/s 32 GB/s
PCIe 5.0 2019 32 GT/s 4 GB/s 64 GB/s
PCIe 6.0 2021 64 GT/s 8 GB/s 128 GB/s

Currently, the most widely used generations are Gen4 and Gen5.

Note: Depending on the document you’re referencing, PCIe bandwidth may be presented as either unidirectional or bidirectional, with the latter indicating a bandwidth that is twice that of the former.

1.3 Summary

With the above knowledge, we can now proceed to discuss the performance characteristics of various storage devices.

2 Disk

2.1 HDD: ~200 MB/s

From wikipedia HDD:

A hard disk drive (HDD) is an electro-mechanical data storage device that stores and retrieves digital data using magnetic storage with one or more rigid rapidly rotating platters coated with magnetic material.

2.1.1 Real world pictures

A real-world picture is shown below:

Fig. Internals of a real world HDD. Image source hardwaresecrets.com

2.1.2 Supported interfaces (bus types)

HDDs connect to a motherboard over one of several bus types, such as,

  • SATA
  • SCSI
  • Serial Attached SCSI (SAS)

Below is a SATA HDD:

Fig. A real world SATA HDD. Image source hardwaresecrets.com

and how an HDD connects to a computer motherboard via SATA cables:

Fig. An HDD with SATA cables. Data source datalab247.com

2.1.3 Bandwidth: constrained by machanical factors

HDDs are machanical devices, and their peak IO performance is inherently limited by various mechanical factors, including the speed at which the actuator arm can function. The current upper limit of HDDs is ~200MB/s, which is significantly below the saturation point of a SATA 3.0 interface (600MB/s).

2.1.4 Typical latencies

Table. Latency characteristics typical of HDDs. Data source: wikipedia

Rotational speed (rpm) Average rotational latency (ms)
15,000 2
10,000 3
7,200 4.16
5,400 5.55
4,800 6.25

2.2 SATA SSD: ~600MB/s

What’s a SSD? From wikipedia SSD:

A solid-state drive (SSD) is a solid-state storage device. It provides persistent data storage using no moving parts.

Like HDDs, SSDs support several kind of bus types:

  • SATA
  • PCIe (NVME)

Let’s see the first one: SATA-interfaced SSD, or SATA SSD for short.

2.2.1 Real world pictures

SSDs are usually smaller than HDDs,

Fig. Size of different drives, left to right: HDD, SATA SSD, NVME SSD. Image source avg.com

2.2.2 Bandwidth: constrained by SATA bus

The absence of mechanical components (such as rotational arms) allows SATA SSDs to fully utilize the capabilities of the SATA bus. This results in an upper limit of 600MB/s IO bandwidth, which is 3x faster than that of SATA HDDs.

2.3 NVME SSD: ~7GB/s, ~13GB/s

Let’s now explore another type of SSD: the PCIe-based NVME SSD.

2.3.1 Real world pictures

NVME SSDs are even smaller than SATA SSDs, and they connect directly to the PCIe bus with 4x lanes instead of SATA cables,

Fig. Size of different drives, left to right: HDD, SATA SSD, NVME SSD. Image source avg.com

2.3.2 Bandwidth: contrained by PCIe bus

NVME SSDs has a peak bandwidth of 7.5GB/s over PCIe Gen4, and ~13GB/s over PCIe Gen5.

2.4 Summary

We illustrate the peak bandwidths of afore-mentioned three kinds of local storage media in a graph:

Fig. Peak bandwidths of different storage media.

These (HDDs, SSDs) are commonly called non-volatile or persistent storage media. And as the picture hints, in next chapters we’ll delve into some other kinds of storage devices.

3 DDR SDRAM (CPU Memory): ~400GB/s

DDR SDRAM nowadays serves mainly as the main memory in computers.

3.1 Real world pictures

Fig. Front and back of a DDR RAM module for desktop PCs (DIMM). Image source wikipedia

Fig. Corsair DDR-400 memory with heat spreaders. Image source wikipedia

DDR memory connects to the motherboard via DIMM slots:

Fig. Three SDRAM DIMM slots on a ABIT BP6 computer motherboard. Image source wikipedia

3.2 Bandwidth: contrained by memory clock, bus width, channel, etc

Single channel bandwidth:

  Transfer rate Bandwidth
DDR4 3.2GT/s 25.6 GB/s
DDR5 4–8GT/s 32–64 GB/s

if Multi-channel memory architecture is enabled, the peak (aggreated) bandwidth will be increased by multiple times:

Fig. Dual-channel memory slots, color-coded orange and yellow for this particular motherboard. Image source wikipedia

Such as [4],

  • Intel Xeon Gen5: up to 8 memory-channels running at up to 5600MT/s (358GB/s)
  • Intel Xeon Gen4: up to 8 memory-channels running at up to 4800MT/s (307GB/s)

3.3 Summary

DDR5 bandwidth in the hierarchy:

Fig. Peak bandwidths of different storage media.

4 GDDR SDRAM (GPU Memory): ~1000GB/s

Now let’s see another variant of DDR, commonly used in graphics cards (GPUs).

4.1 GDDR vs. DDR

From wikipedia GDDR SDRAM:

Graphics DDR SDRAM (GDDR SDRAM) is a type of synchronous dynamic random-access memory (SDRAM) specifically designed for applications requiring high bandwidth, e.g. graphics processing units (GPUs).

GDDR SDRAM is distinct from the more widely known types of DDR SDRAM, such as DDR4 and DDR5, although they share some of the same features—including double data rate (DDR) data transfers.

4.2 Real world pictures

Fig. Hynix GDDR SDRAM. Image Source: wikipedia

4.3 Bandwidth: contrained by lanes & clock rates

Unlike DDR, GDDR is directly integrated with GPU devices, bypassing the need for pluggable PCIe slots. This integration liberates GDDR from the bandwidth limitations imposed by the PCIe bus. Such as,

  • GDDR6: 1008GB/s. Peak per-pin data rate 16Gb/s, max memory bus width 384-bits.
  • GDDR6x: 1008GB/s, used by NVIDIA RTX 4090

4.4 Summary

With GDDR included:

Fig. Peak bandwidths of different storage media.

5 HBM: 1~5 TB/s

If you’d like to achieve even more higher bandwidth than GDDR, then there is an option: HBM (High Bandwidth Memory).

A great innovation but a terrible name.

5.1 What’s new

HBM is designed to provide a larger memory bus width than GDDR, resulting in larger data transfer rates.

Fig. Cut through a graphics card that uses HBM. Image Source: wikipedia

HBM sits inside the GPU die and is stacked – for example NVIDIA A800 GPU has 5 stacks of 8 HBM DRAM dies (8-Hi) each with two 512-bit channels per die, resulting in a total width of 5120-bits (5 active stacks * 2 channels * 512 bits) [3].

As another example, HBM3 (used in NVIDIA H100) also has a 5120-bit bus, and 3.35TB/s memory bandwidth,

Fig. Bandwidth of several HBM-powered GPUs from NVIDIA. Image source: nvidia.com

5.2 Real world pictures

The 4 squares in left and right are just HBM chips:

Fig. AMD Fiji, the first GPU to use HBM. Image Source: wikipedia

5.3 Bandwidth: contrained by lanes & clock rates

From wikipedia HBM

  Bandwidth Year GPU
HBM 128GB/s/package    
HBM2 256GB/s/package 2016 V100
HBM2e ~450GB/s 2018 A100, ~2TB/s; Huawei Ascend 910B
HBM3 600GB/s/site 2020 H100, 3.35TB/s
HBM3e ~1TB/s 2023 H200, 4.8TB/s

5.4 HBM-powered CPUs

HBM is not exclusive to GPU memory; it is also integrated into some CPU models, such as the Intel Xeon CPU Max Series.

5.5 Summary

This chapter concludes our exploration of dynamic RAM technologies, which includes

  • DDR DRAM
  • GDDR DRAM
  • HBM DRAM

Fig. Peak bandwidths of different storage media.

In the next, let’s see some on-chip static RAMs.

6 SRAM (on-chip): 20+ TB/s

The term “on-chip” in this post refers to memory storage that's integrated within the same silicon as the processor unit.

6.1 SRAM vs. DRAM

From wikipedia SRAM:

Static random-access memory (static RAM or SRAM) is a type of random-access memory that uses latching circuitry (flip-flop) to store each bit. SRAM is volatile memory; data is lost when power is removed.

The term static differentiates SRAM from DRAM:

  SRAM DRAM
data freshness stable in the presence of power decays in seconds, must be periodically refreshed
speed (relative) fast (10x) slow
cost (relative) high low
mainly used for cache main memory

SRAM requires more transistors per bit to implement, so it is less dense and more expensive than DRAM and also has a higher power consumption during read or write access. The power consumption of SRAM varies widely depending on how frequently it is accessed.

6.2 Cache hierarchy (L1/L2/L3/…)

In the architecture of multi-processor (CPU/GPU/…) systems, a multi-tiered static cache structure is usually used:

  • L1 cache: typically exclusive to each individual processor;
  • L2 cache: commonly accessible by a group of processors.

NVIDIA H100 chip layout (L2 cache in the middle, shared by many SM processors). Image source: nvidia.com

6.3 Groq LPU: eliminating memory bottleneck by using SRAM as main memory

From the official website: Groq is the AI infra company that builds the world’s fastest AI inference technology with both software and hardware. Groq LPU is designed to overcome two LLM bottlenecks: compute density and memory bandwidth.

  • An LPU has greater compute capacity than a GPU and CPU in regards to LLMs. This reduces the amount of time per word calculated, allowing sequences of text to be generated much faster.
  • Eliminating external memory bottlenecks (using on-chip SRAM instead) enables the LPU Inference Engine to deliver orders of magnitude better performance on LLMs compared to GPUs.

Regarding to the chip:

Fig. Die photo of 14nm ASIC implementation of the Groq TSP. Image source: groq paper [2]

The East and West hemisphere of on-chip memory module (MEM)

  • Composed of 44 parallel slices of SRAM and provides the memory concurrency necessary to fully utilize the 32 streams in each direction.
  • Each slice provides 13-bits of physical addressing of 16-byte memory words, each byte maps to a lane, for a total of 220 MiBytes of on-chip SRAM.

6.4 Bandwidth: contrained by clock rates, etc

6.5 Summary

This chapter ends our journey to various physical storage media, from machanical devices like HDDs all the way to on-chip cache. We illustrate their peak bandwidth in a picture, note that the Y-axis is log10 scaled:

Fig. Speeds of different storage media.

These are the maximum IO bandwidths when performing read/write operations on a local node.

Conversely, when considering remote I/O operations, such as those involved in distributed storage systems like Ceph, AWS S3, or NAS, a new bottleneck emerges: networking bandwidth.

7 Networking bandwidth: 400GB/s

7.1 Traditional data center: 2*{25,100,200}Gbps

For traditional data center workloads, the following per-server networking configurations are typically sufficient:

  • 2 NICs * 25Gbps/NIC, providing up to 6.25GB/s unidirectional bandwidth when operating in active-active mode;
  • 2 NICs * 100Gbps/NIC, delivering up to 25GB/s unidirectional bandwidth when operating in active-active mode;
  • 2 NICs * 200Gbps/NIC, achieving up to 50GB/s unidirectional bandwidth when operating in active-active mode.

7.2 AI data center: GPU-interconnect: 8*{100,400}Gbps

This type of networking facilitates inter-GPU communication and is not intended for general data I/O. The data transfer pathway is as follows:

            HBM <---> NIC <---> IB/RoCE <---> NIC <--> HBM
                Node1                            Node2

7.3 Networking bandwidths

Now we add networking bandwidths into our storage performance picture:

Fig. Speeds of different storage media, with networking bandwidth added.

7.4 Summary

If remote storage solutions (such as distributed file systems) is involved, and networking is fast enough, IO bottleneck would shift down to the remote storage solutions, that’s why there are some extremely high performance storage solutions dedicated for today’s AI trainings.

8 Distributed storage: aggregated 2+ TB/s

8.1 AlibabaCloud CPFS

AlibabaCloud’s Cloud Parallel File Storage (CPFS) is an exemplar of such high-performance storage solutions. It claims to offer up to 2TB/s of aggregated bandwidth.

But, note that the mentioned bandwidth is an aggregate across multiple nodes, no single node can achieve this level of IO speed. You can do some calcuatations to understand why, with PCIe bandwidth, networking bandwidth, etc;

8.2 NVME SSD powered Ceph clusters

An open-source counterpart is Ceph, which also delivers impressive results. For instance, with a cluster configuration of 68 nodes * 2 * 100Gbps/node, a user achieved aggregated throughput of 1TB/s, as documented.

8.3 Summary

Now adding distributed storage aggregated bandwidth into our graph:

Fig. Peak bandwidth of storage media, networking, and distributed storage solutions.

9 Conclusion

This post compiles bandwidth data for local storage media, networking infrastructure, and remote storage systems. With this information as reference, readers can evaluate the potential IO bottlenecks of their systems more effectively, such as GPU server IO bottleneck analysis [1]:

Fig. Bandwidths inside a 8xA100 GPU node

References

  1. Notes on High-end GPU Servers (in Chinese), 2023
  2. Think Fast: A Tensor Streaming Processor (TSP) for Accelerating Deep Learning Workloads, ISCA paper, 2020
  3. GDDR6 vs HBM - Defining GPU Memory Types, 2024
  4. 5th Generation Intel® Xeon® Scalable Processors, intel.com

Written by Human, Not by AI Written by Human, Not by AI

[译] 什么是 GPT?Transformer 工作原理的动画展示(2024)

2024年5月12日 08:00

译者序

本文翻译自 2024 年的一个视频(前半部分),这是原作者 Deep Learning 系列的第 5 章,强烈推荐原视频:

Transformer 预测下一个单词四部曲。MLP 也称为 feed-forward。

作者以深厚的技术积累,将一些复杂系统以可视化的方式讲给普通人,这种能力是极其难得的。 本译文希望通过“文字+动图”这种可视化又方便随时停下来思考的方式介绍 Transformer 的内部工作原理。 如果想进一步从技术和实现上了解 Transformer/GPT/LLM,可参考:

水平及维护精力所限,译文不免存在错误或过时之处,如有疑问,请查阅原视频。 传播知识,尊重劳动,年满十八周岁,转载请注明出处

以下是译文。



1 图解 “Generative Pre-trained Transformer”(GPT)

GPT 是 Generative Pre-trained Transformer 的缩写,直译为“生成式预训练 transformer”, 我们先从字面上解释一下它们分别是什么意思。

1.1 Generative:生成式

“Generative”(生成式)意思很直白,就是给定一段输入(例如,最常见的文本输入), 模型就能续写(“编”)下去。

1.1.1 可视化

下面是个例子,给定 “The most effective way to learn computer science is” 作为输入, 模型就开始续写后面的内容了。

“Generative”:生成(续写)文本的能力。

1.1.2 生成式 vs. 判别式(译注)

文本续写这种生成式模型,区别于 BERT 那种判别式模型(用于分类、完形填空等等),

1.2 Pre-trained:预训练

“Pre-trained”(预训练)指的是模型是用大量数据训练出来的

1.2.1 可视化

“Pre-trained”:用大量数据进行训练。
图中的大量旋钮/仪表盘就是所谓的“模型参数”,训练过程就是在不断优化这些参数,后面会详细介绍。

1.2.2 预训练 vs. 增量训练(微调)

“预”这个字也暗示了模型还有在特定任务中进一步训练的可能 —— 也就是我们常说的“微调”(finetuning)。

如何对预训练模型进行微调: InstructGPT:基于人类反馈训练语言模型遵从指令的能力(OpenAI,2022)。 译注。

1.3 Transformer:一类神经网络架构

“GPT” 三个词中最重要的其实是最后一个词 Transformer。 Transformer 是一类神经网络/机器学习模型,作为近期 AI 领域的核心创新, 推动着这个领域近几年的极速发展。

Transformer 直译为“变换器”或“转换器”,通过数学运算不断对输入数据进行变换/转换。另外,变压器、变形金刚也是这个词。 译注。

Transformer:一类神经网络架构的统称。

Transformer 最后的输出层。后面还会详细介绍

1.4 小结

如今已经可以基于 Transformer 构建许多不同类型的模型,不限于文本,例如,

本文希望通过“文字+动图”这种可视化又方便随时停下来思考的方式,解释 Transformer 的内部工作原理。

2 Transformer 起源与应用

2.1 Attention Is All You Need, Google, 2017,机器翻译

Transformer 是 Google 2017 年在 Attention Is All You Need paper 中提出的, 当时主要用于文本翻译

2.2 Generative Transformer

之后,Transformer 的应用场景扩展到了多个领域,例如 ChatGPT 背后也是 Transformer, 这种 Transformer 接受一段文本(或图像/音频)作为输入,然后就能预测接下来的内容。 以预测下一个单词为例,如下图所示,下一个单词有多种可能,各自的概率也不一样:

但有了一个这样的预测下一个单词模型,就能通过如下步骤让它生成更长的文字,非常简单:

  1. 初始文本输入模型;
  2. 模型预测出下一个可能的单词列表及其概率,然后通过某种算法(不一定挑概率最大的) 从中选一个作为下一个单词,这个过程称为采样(sampling);
  3. 将新单词追加到文本结尾,然后将整个文本再次输入模型;转 2;

以上 step 2 & 3 不断重复,得到的句子就越来越长。

2.3 GPT-2/GPT-3 生成效果(文本续写)预览

来看看生成的效果,这里拿 GPT-2 和 GPT-3 作为例子。

下面是在我的笔记本电脑上运行 GPT-2,不断预测与采样,逐渐补全为一个故事。 但结果比较差,生成的故事基本上没什么逻辑可言:

下面是换成 GPT-3(模型不再开源,所以是通过 API), GPT-3 和 GPT-2 基本架构一样,只是规模更大, 但效果突然变得非常好, 生成的故事不仅合乎逻辑,甚至还暗示 “物种 π” 居住在一个数学和计算王国:

2.4 ChatGPT 等交互式大模型

以上这个不断重复“预测+选取”来生成文本的过程,就是 ChatGPT 或其他类似大语言模型(LLM) 的底层工作原理 —— 逐单词(token)生成文本

2.5 小结

以上是对 GPT 及其背后的 Transformer 的一个感性认识。接下来我们就深入到 Transformer 内部, 看看它是如何根据给定输入来预测(计算)出下一个单词的。

3 Transformer 数据处理四部曲

为理解 Transformer 的内部工作原理,本节从端到端(从最初的用户输入,到最终的模型输出)的角度看看数据是如何在 Transformer 中流动的。 从宏观来看,输入数据在 Transformer 中经历如下四个处理阶段:

Transformer 数据处理四部曲

下面分别来看。

3.1 Embedding:分词与向量表示

首先,输入内容会被拆分成许多小片段(这个过程称为 tokenization),这些小片段称为 token

  • 对于文本:token 通常是单词、词根、标点符号,或者其他常见的字符组合;
  • 对于图片:token 可能是一小块像素区域;
  • 对于音频:token 可能是一小段声音。

然后,将每个 token 用一个向量(一维数组)来表示。

3.1.1 token 的向量表示

这实际上是以某种方式在编码该 token;

Embedding:每个 token 对应一个 N*1 维度的数值格式表示的向量。

3.1.2 向量表示的直观解释

如果把这些向量看作是在高维空间中的坐标, 那么含义相似的单词在这个高维空间中是相邻的

词义相近的四个单词 “leap/jump/skip/hop” 在向量空间中是相邻的

将输入进行 tokenization 并转成向量表示之后,输入就从一个句子就变成了一个向量序列。 接下来,这个向量序列会进行一个称为 attention 的运算。

3.2 Attention:embedding 向量间的语义交流

3.2.1 语义交流

attention 使得向量之间能够相互“交流”信息。这个交流是双向的,在这个过程中,每个向量都会更新自身的值。

这种信息“交流”是有上下文和语义理解能力的。

3.2.2 例子:”machine learning model” / “fashion model

例如,“model” 这个词在 “machine learning model”(机器学习模型)和在 “fashion model”(时尚模特)中的意思就完全不一样, 因此虽然是同一个单词(token),但对应的 embedding 向量是不同的

Attention 模块的作用就是确定上下文中哪些词之间有语义关系,以及如何准确地理解这些含义(更新相应的向量)。 这里说的“含义”(meaning),指的是编码在向量中的信息。

3.3 Feed-forward / MLP:向量之间无交流

Attention 模块让输入向量们彼此充分交换了信息(例如,单词 “model” 指的应该是“模特”还是“模型”), 然后,这些向量会进入第三个处理阶段:

第三阶段:多层感知机(multi-layer perceptron),也称为前馈层(feed-forward layer)。

3.3.1 针对所有向量做一次性变换

这个阶段,向量之间没有互相“交流”,而是并行地经历同一处理:

3.3.2 直观解释

后面会看,从直观上来说,这个步骤有点像对每个向量都提出一组同样的问题,然后根据得到的回答来更新对应的向量

以上解释中省略了归一化等一些中间步骤,但已经可以看出: attention 和 feed-forward 本质上都是大量的矩阵乘法

本文的一个目的就是让读者理解这些矩阵乘法的直观意义。

3.3.3 重复 Attention + Feed-forward 模块,组成多层网络

Transformer 基本上是不断复制 Attention 和 Feed-forward 这两个基本结构, 这两个模块的组合成为神经网络的一层。在每一层,

  • 输入向量通过 attention 更新彼此;
  • feed-forward 模块将这些更新之后的向量做统一变换,得到这一层的输出向量;

3.4 Unembedding:概率

3.4.1 最后一层 feed-forward 输出中的最后一个向量

如果一切顺利,最后一层 feed-forward 输出中的最后一个向量(the very last vector in the sequence), 就已经包含了句子的核心意义(essential meaning of the passage)。对这个向量进行 unembedding 操作(也是一次性矩阵运算), 得到的就是下一个单词的备选列表及其概率:

图:原始输入为 "To date, the cleverest thinker of all time was",让模型预测下一个 token。经过多层 attention + feed-forward 之后, 最后一层输出的最后一个向量已经学习到了输入句子表达的意思,(经过简单转换之后)就能作为下一个单词的概率

3.4.2 下一个单词的选择

根据一定的规则选择一个 token,

  • 注意这里不一定选概率最大的,根据工程经验,一直选概率最大的,生成的文本会比较呆板;
  • 实际上由一个称为 temperature 的参数控制;

3.5 小结

以上就是 Transformer 内部的工作原理。

前面已经提到,有了一个这样的预测下一个单词模型,就能通过如下步骤让它生成更长的文字,非常简单:

  1. 初始文本输入模型;
  2. 模型预测出下一个可能的单词列表及其概率,然后通过某种算法(不一定挑概率最大的) 从中选一个作为下一个单词,这个过程称为采样(sampling);
  3. 将新单词追加到文本结尾,然后将整个文本再次输入模型;转 2;

4 GPT -> ChatGPT:从文本补全到交互式聊天助手

GPT-3 的早期演示就是这样的:给 GPT-3 一段起始文本,它就自动补全(续写)故事和文章。 这正式以上介绍的 Transformer 的基本也是核心功能。

ChatGPT 的核心是 GPT 系列(GPT 3/3.5/4),但它怎么实现聊天这种工作方式的呢?

4.1 系统提示词,伪装成聊天

其实很简单,将输入文本稍作整理,弄成聊天内容,然后把这样的文本再送到 GPT/Transformer, 它就会把这个当前是聊天内容,续写下去。最后只需要把它续写的内容再抽出来返回给用户, 对用户来说,就是在聊天。

这段文本设定用户是在与一个 AI 助手交互的场景,这就是所谓的系统提示词(system prompt)。

4.2 如何训练一个企业级 GPT 助手(译注)

OpenAI 官方对 GPT->ChatGPT 有过专门分享:如何训练一个企业级 GPT 助手(OpenAI,2023)

基础模型不是助手,它们不想回答问题,只想补全文档。 因此,如果让它们“写一首关于面包和奶酪的诗”,它们不仅不“听话”,反而会有样学样,列更多的任务出来,像下面左图这样,

这是因为它只是在忠实地补全文档。 但如果你能成功地提示它,例如,开头就说“这是一首关于面包和奶酪的诗”, 那它接下来就会真的补全一首这样的诗出来,如右图。

我们还可以通过 few-shot 来进一步“欺骗”它。把你想问的问题整理成一个“提问+回答”的文档格式, 前面给一点正常的论述,然后突然来个问题,它以为自己还是在补全文档,其实已经把问题回答了:

这就是把基础模型调教成一个 AI 助手的过程。

5 总结

本文整理翻译了原视频的前半部分,通过可视化方式解释 GPT/Transformer 的内部工作原理。 原视频后面的部分是关于 general deep learning, machine learning 等等的基础,想继续学习的,强烈推荐。


Written by Human, Not by AI Written by Human, Not by AI

❌
❌