在介绍 multi-stream 之前,首先简单介绍一下 flash 写操作的特性。
由于擦除操作相对耗时,因而在对某个 page 进行修改操作时,通常将修改的数据直接写入一个新的已经擦除过的 page,而将原来的旧的 page 设置为 invalid 状态,此时该 page 的修改操作就算完成了,之后 SSD FTL (Flash Translation Layer) 会执行垃圾回收(garbage collection)算法,回收处于 invalid 状态的 page。
FTL 需要对 invalid page 执行擦除操作以回收这些 invalid page,而擦除操作的单位为 block,一个 block 中包含多个 page,其中既包含 invalid page,同时也包含 valid page。因而当 FTL 需要回收 block A 中的 invalid page 时,就必须先将 block A 中的 valid page 先拷贝到其他新的 block 例如 block B 中,并将 block A 中的 valid page 设置为 invalid 状态,此时 block A 中的所有 page 均为 invalid 状态,FTL 可以安全地对 block A 执行擦除操作。
在以上操作过程中,FTL 需要对回收的 block 中的 valid page 进行额外的拷贝操作,从而使得设备实际执行的IO数量大于用户提交的IO数量,这一特性称为 写放大(Write Amplification)
SSD 使用 WAF (Write Amplification Factor) 参数描述这一特性,该参数的值为
WAF = Amount of writes committed to flash / Amount of writes that arrived from the host
由于 FTL 需要对 valid page 进行额外的拷贝操作,WAF 参数的值通常大于1。(当设备支持 compression 特性时,WAF 参数的值是有可能小于1的。)
WAF 参数会影响 SSD 的使用寿命以及性能:
以上问题的根源在于两种不同生命周期(lifetime)的数据存储在同一个block中。
假设当前存在两种生命周期的数据,hot data 与 cold data,其中 hot data 相对 cold data 会更为频繁地进行更新。
在传统的 SSD FTL 实现中,操作系统向 SSD 提交写操作请求后,FTL 会将操作系统提交的数据依次写入可用的 block 中,例如上图中操作系统提交写入H1、C1、H2、C2、C3、H3、H4、C4时,FTL 将这些数据依次写到 block 1、block 2 中(假设一个 block 包含 4 个 page)。
之后操作系统提交对H1、H2、H3、H4进行修改时,必须将修改后的数据写到 block 3 中,并将 block 1 中的 H1、H2 page,block 2 中的 H3、H4 page 设置为 invalid,之后当 garbage collection 需要回收 block 1、block 2 时,就必须对其中的 C1、C2、C3、C4 page 执行额外的拷贝。
multi-stream 特性则是将不同更新频率的数据写到不同的 block 中,从而尽可能地减小 garbage collection 中引入的额外的数据拷贝操作,从而提高 SSD 的有效生命周期,并提升写性能。
当SSD FTL 支持 multi-stream 特性时,操作系统可以将写入的数据与某个 stream 相绑定,例如将 hot data 与 stream x 绑定,将 cold data 与 stream y 绑定,此时 FTL 在受理操作系统提交的写请求时,将不同 stream 的数据分别写到不同的 block,例如将 stream x (hot data)全部写到 block 1,将 stream y(cold data)全部写到 block 2。
之后操作系统提交对 H1、H2、H3、H4 进行修改时,FTL 将修改后的数据写到 block 3 中,此时 block 1 中不包含任何 valid page,之后 garbage collection 就可以直接对 block 1 执行擦除操作,而不会带来额外的拷贝操作。
multi-stream 的本质是针对 flash 特殊的写特性,提供一种机制,使得不同更新频率的数据写到不同的 block 中。multi-stream 机制中使用 stream 抽象不同更新频率的数据,并使用 stream id 标识不同的 stream。
SSD 设备本身掌握的信息很少,其对数据的更新频率基本没有概念,只有数据的生产者即上层软件(包括用户程序、操作系统)才了解数据的更新频率,因而上层软件负责写入的数据与 stream id 的映射,而 SSD FTL 只负责将不同 stream id 的数据写入不同的 block。
以下依次介绍 SSD 接口协议、SSD 设备驱动、文件系统、应用程序如何适配 multi-stream 机制。
SSD 设备支持多种接口协议,目前 SCSI(T10 (SCSI) standard)与 NVMe 1.3 已经正式支持 multi-stream 特性。
下面以 NVMe 1.3 为例介绍其对 multi-stream 特性的支持。
SSD 的 FTL 负责操作系统使用的 LBA (Logical Block Address) 与 SSD 内部使用的 physical address 之间的映射。
传统的不支持 multi-stream 的 SSD FTL 通常只维护一个 log structure,它只会根据操作系统提交的 IO 请求的先后顺序,将这些 IO 请求依次存储到可用的存储空间中。
而支持 multi-stream 的 SSD FTL 则会维护多个 log structure,其中为每个 stream 维护一个单独的 log structure,FTL 以 Stream Granularity Size (SGS) 为单位分配存储空间,即 FTL 一开始会为每个 stream 预先分配 SGS 大小的存储块,之后该 stream 的数据都会存储到这一预分配的 SGS 大小的存储块中。当这一存储块的空间用尽时,FTL 则再次分配一个 SGS 大小的存储块。stream 的 log structure 会维护该 stream 分配的所有存储块,而正是所有的这些 SGS 大小的存储块构成了一个 stream。
NVMe 1.3 标准中以 directive 的形式支持 multi-stream,directive 机制用于实现 SSD 设备与上层软件之间的信息沟通,stream 只是 directive 的一个子集,目前 directive 也只实现 stream 这一个子集。
其中与 stream 相关的命令有
应用程序是数据的主要生产者,因而我们自然会想到在应用层实现写入数据与 stream id 的映射。
例如现有实现中可以通过 fcntl() 或 fadvise() 系统调用将特定 inode / file 与 stream id 进行绑定,这样不同文件的数据,或者同一文件但不同进程生产的数据就会在 SSD 中分开存储。
在应用层实现写入数据与 stream id 的绑定,可以最为准确地描述写入数据的特性,然而这一实现需要显式地修改应用程序的代码。
除了应用程序之外,文件系统也是数据的生产者,文件系统需要维护各种元数据(例如文件的 inode 信息等),此外日志型文件系统还需要写入日志数据,因而文件系统可以将不同类型的元数据与日志数据与 stream id 进行绑定,从而实现这些数据的分开存储。
例如 Samsung 提出的 Fstream based Ext4 文件系统中, 为 journal、inode、directory、inode/block bitmap and group descriptor 等元数据分别绑定不同的 stream id
此外还支持根据文件的名称或后缀,将文件数据绑定不同的stream id,实现垂直优化。
FStream: Managing Flash Streams in the File System
SSD 设备驱动也可以实现写入数据与 stream id 的绑定,例如现有实现 AutoStream 中通过统计各个 SSD block 的 access time 等数据推测 block 中存储的数据是 hot data 还是 cold data,从而实现 hot data 与 cold data 的分开存储。
AutoStream: Automatic Stream Management for Multi-streamed SSDs
Fstream 是三星提出的一个 multistream based Ext4,其在 Ext4 文件系统的基础上,在文件系统这一层将各种元数据与文件数据映射到不同的 stream id,从而使这些数据在 SSD 上分开存储。
由于相关代码未开源,因而本文作者实现了一个简单的原型。目前 Linux mainline (从 4.13.6 开始) 已经支持 multi-stream 特性,因而我们在 mainline 4.13.6 内核版本的基础上实现这一原型。
Ext 4 文件系统中的元数据包括
将这些元数据的更新频率分为 4 个级别,即
stream | data |
---|---|
journal stream | journal |
inode stream | inode table |
directory stream | directory |
misc stream | superblock, group descriptors, inode/block bitmap |
/*
* Write life time hint values.
*/
enum rw_hint {
WRITE_LIFE_NOT_SET = 0,
WRITE_LIFE_NONE = RWH_WRITE_LIFE_NONE,
WRITE_LIFE_SHORT = RWH_WRITE_LIFE_SHORT,
WRITE_LIFE_MEDIUM = RWH_WRITE_LIFE_MEDIUM,
WRITE_LIFE_LONG = RWH_WRITE_LIFE_LONG,
WRITE_LIFE_EXTREME = RWH_WRITE_LIFE_EXTREME,
};
使用 write hint 描述数据的更新频率,即按照数据更新的相对频率,将数据的更新频率分为 4 个级别:short、medium、long与extrem。
当SSD设备支持的 stream 的数量达到或超过 4 时
当用户未显式指定数据的更新频率时,其最终默认使用 stream id 0
用户程序可以通过 fcntl() 设置特定文件的 write hint
struct inode {
+ enum rw_hint i_write_hint;
inode 中增加 i_write_hint 字段,用户程序调用 fcntl() 时,即将用户设置的 write hint 保存在文件对应的 inode 的 i_write_hint 字段
文件系统使用 block buffer 作为接口向generic block layer提交元数据以及用户数据的IO请求,因而在 struct buffer_head 中增加一个字段描述该 block buffer 缓存的数据对应的 write hint
struct buffer_head {
+ enum rw_hint b_write_hint;
};
该字段的初始值为 WRITE_LIFE_NOT_SET,即默认使用 stream id 0
struct buffer_head *alloc_buffer_head(gfp_t gfp_flags)
struct buffer_head *ret = kmem_cache_zalloc(bh_cachep, gfp_flags);
if (ret) {
+ ret->b_write_hint = WRITE_LIFE_NOT_SET;
在将磁盘上存储的元数据拷贝到内存中时,设置对应的 buffer head 的 b_write_hint 字段,将 EXT 4 文件系统的各种元数据分别映射到之前描述的 4 个级别
data | write hint of buffer head | stream id |
---|---|---|
user data | WRITE_LIFE_NOT_SET | 0 |
journal | JBD2_WRITE_LIFE | 1 |
inode table | EXT4_WRITE_LIFE_INODE | 2 |
superblock | EXT4_WRITE_LIFE_MISC | 3 |
group descriptors | EXT4_WRITE_LIFE_MISC | 3 |
inode bitmap | EXT4_WRITE_LIFE_MISC | 3 |
block bitmap | EXT4_WRITE_LIFE_MISC | 3 |
directory | EXT4_WRITE_LIFE_DIR | 4 |
#define JBD2_WRITE_LIFE WRITE_LIFE_SHORT /* stream for journaling */
enum ext4_write_hint {
EXT4_WRITE_LIFE_INODE = WRITE_LIFE_MEDIUM, /* stream for inode table */
EXT4_WRITE_LIFE_MISC = WRITE_LIFE_LONG, /* stream for superblock, inode bitmap, block bitmap and group descriptors */
EXT4_WRITE_LIFE_DIR = WRITE_LIFE_EXTREME, /* stream for directory */
};
Ext 4 文件系统会调用 block_write_full_page() 将 file data 写到SSD,此时 file data 对应的 page cache 的 buffer head 的 write hint 为默认的 WRITE_LIFE_NOT_SET,因而对于 file data 实际使用 inode 中存储的 write hint
用户程序可以通过 fcntl() 设置特定文件的 write hint,当用户未显式设置时,file data 实际使用 stream id 0
int __block_write_full_page(struct inode *inode, struct page *page,
get_block_t *get_block, struct writeback_control *wbc,
bh_end_io_t *handler)
{
+ enum rw_hint write_hint = bh->b_write_hint == WRITE_LIFE_NOT_SET ? inode->i_write_hint : bh->b_write_hint;
+ submit_bh_wbc(REQ_OP_WRITE, write_flags, bh, write_hint, wbc);
Ext 4 文件系统会调用 submit_bh()/block_write_full_page() 将 metadata 回写到 SSD 中,此时使用的 write hint 即为设置的各个 metadata 对应的write hint
int submit_bh(int op, int op_flags, struct buffer_head *bh)
{
+ return submit_bh_wbc(op, op_flags, bh, bh->b_write_hint, NULL);
}
最终将 write hint 保存到 bio 中,即将 write hint 传递给 generic block layer
static int submit_bh_wbc(int op, int op_flags, struct buffer_head *bh,
enum rw_hint write_hint, struct writeback_control *wbc)
{
bio->bi_write_hint = write_hint;
...
submit_bio(bio);
return 0;
generic block layer 中对 multi-stream 的适配有
void blk_init_request_from_bio(struct request *req, struct bio *bio)
{
req->write_hint = bio->bi_write_hint;
}
最终 write hint 传递到 NVMe layer,并保存在 request 的 write_hint 字段
此时调用 nvme_assign_write_stream()将 write hint 映射为对应的 stream id,并将 stream id 保存在 write command 的 directive specific 字段,之后将该 write command 交给SSD设备处理,之后的处理均由 SSD 设备上的 FTL 实现。
static inline blk_status_t nvme_setup_rw(struct nvme_ns *ns,
struct request *req, struct nvme_command *cmnd)
{
if (req_op(req) == REQ_OP_WRITE && ctrl->nr_streams)
nvme_assign_write_stream(ctrl, req, &control, &dsmgmt);
...
cmnd->rw.control = cpu_to_le16(control);
cmnd->rw.dsmgmt = cpu_to_le32(dsmgmt);
return 0;
}
/*
* Check if 'req' has a write hint associated with it. If it does, assign
* a valid namespace stream to the write.
*/
static void nvme_assign_write_stream(struct nvme_ctrl *ctrl,
struct request *req, u16 *control,
u32 *dsmgmt)
{
enum rw_hint streamid = req->write_hint;
if (streamid == WRITE_LIFE_NOT_SET || streamid == WRITE_LIFE_NONE)
streamid = 0;
else {
streamid--;
if (WARN_ON_ONCE(streamid > ctrl->nr_streams))
return;
*control |= NVME_RW_DTYPE_STREAMS;
*dsmgmt |= streamid << 16;
}
if (streamid < ARRAY_SIZE(req->q->write_hints))
req->q->write_hints[streamid] += blk_rq_bytes(req) >> 9;
}
三星在内部测试中使用 PM963 480GB SSD 进行测试
SSD: Samsung PM963 480GB, with the allocation granularity 1 of 1.1GB
from "FStream: Managing Flash Streams in the File System"
我们自己的测试环境中使用的 SSD 为 PM963 1920GB (MZQLW1T9HMJP-00003)。
三星在网上公布的资料均显示 PM963 支持 multi-stream 特性,然而 NVMe 从 1.3 标准开始添加对 multi-stream 的支持,而 PM963 只支持 1.2 标准。
同时测试发现我们使用的 PM963 不支持 NVMe 1.3 中规定的 directive 相关的命令
尽管如此,我们考虑到 PM963 在 NVMe 1.3 之前发布,因而可能使用非标准的方法实现 multi-stream 特性,我们还是希望使用 fio 对 multi-stream 特性开启前后,设备的读写性能进行一次测试。
测试环境
在每次测试前
nvme --format /dev/nvme8n1 -s 1
,对全盘执行擦除操作mkfs.ext4 .dev.nvme8n1
,文件系统格式化其中 4 个写线程具有不同的数据更新频率,分别为 1x, 2x, 3x, 10x。(我们使用 fio 的 thinktime 参数来实现不同的数据更新频率。)
测试分为两次进行
write thread | data lifetime | stream ID |
---|---|---|
1 | 10x | 1 |
2 | 3x | 2 |
3 | 2x | 3 |
4 | 1x | 4 |
注意 该测试中我们没有对我们实现的 Fstream 原型进行测试,而是直接使用 mainline 4.13.6,在用户层通过 fcntl() syscall 实现不同更新频率的用户数据与stream ID的映射。即该测试中我们只对用户进程产生的文件数据分流存储,而没有对文件系统的元数据进行分流存储。
test | write throughput | read latency | read average latency |
---|---|---|---|
multi-stream disabled | 617 MB/s | 82.9 K | 772 us |
multi-stream enabled | 616 MB/s | 82.8 K | 773 us |
上图描述两次测试过程中,设备的实时 write throughput,其中
从以上现象我们可以得出一个结论,即测试过程中垃圾回收算法一直处于相对较重的负载当中。
从以上图表我们可以发现,在 multi-stream 特性开启前后,设备的读写性能并没有出现明显变化。
目前使用 NVMe 1.3 标准的方法不能开启 PM963 的 multi-stream 特性,即测试环境中使用的 PM963 SSD 不支持 directive 相关的命令,同时在 write command 中“强行”添加 stream id 时,写性能也没有明显的提升效果。
之后我们咨询熟悉硬件的同学,了解到当前公司使用的 PM963 均不支持 multi-stream 特性,而支持该特性的 SSD 要等到明年才能上线。
此外当前测试环境也没有 SAS SSD 设备(SCSI 也支持 multi-stream 特性),因而当前尚不能对 multi-stream 特性的实际效果进行测试。
multi-stream 的原理是尽可能将相同更新频率的数据存储到同一个 block,从而在回收 block 时减小数据的拷贝操作,但是该机制的性能提升效果会受以下因素的影响。
这里的高负载具有两重含义,即
multi-stream 只有在 SSD 的空间占用率达到一定程度时才会体现性能提升的效果。当使用一块全新的 SSD 时,每次写操作时总是有充足的 free block 可写,因而就不会运行 garbage collection, multi-stream 也就不会发挥作用。
在 SSD 的空间占用率达到一定程度的基础上,当同时写负载较重时,multi-stream 才能提升性能。这是因为 multi-stream 主要提升 garbage collection 算法的性能,而该算法通常在后台运行;当写负载较轻时,garbage collection 的需求较小,此时带来的性能提升效果不明显;只有当设备可用空间较小,同时写负载较重时,garbage collection 算法的负载才会较重,此时 multi-stream 才会带来明显的性能提升效果。
但值得注意的是,以上描述的是只有在写负载较重时才可以体现 multi-stream 对写操作的性能提升,而无论写负载的高低,multi-stream 机制都可以降低 WAF 参数,从而提高 SSD 的有效生命周期。
由于 multi-stream 的原理,其更加适用于数据库这类小块数据的随机写入操作,因为小块数据更容易与其他不同类型的数据存储在同一个 block 中,同时其随机写操作也更容易使同一个 block 同时包含 valid page 与 invalid page,因而也就给 multi-stream 更多的提升性能的空间。
例如上图为三星发表的论文中 Fstream 的性能测试结果,其中 Fileserver 测试程序主要执行大块数据的顺序写操作,Cassandra 测试程序主要执行小块数据的随机写操作,测试结果显示,multi-stream 机制对于小块数据的写操作具有更为明显的性能提升。
stream id 的分配机制会直接影响 multi-stream 的效果,当同属于一个 stream 的所有数据具有完全相同的更新频率时,multi-stream 能带来最大的性能提升,即上层程序需要准确地判断数据的更新频率,并依此为其分配合适的stream id。
一种最简单同时最有效的方案就是为每一类数据都分配一个单独的 stream id,即这一类数据会独占一个 stream,此时该 stream 的 block 中只存储一类数据,那么自然可以将该 stream 在垃圾回收时带来的额外的数据拷贝操作降到最低,从而具有最好的性能效果。
这种方案同时也是一种理想的方案,现实中 SSD 设备支持的 stream 的个数是有限的,因而不可能为每一类数据都分配一个单独的 stream,因而当将两种数据分配到同一个 stream 中时,这两种数据的更新频率势必存在差异,因而无法达到最为理想的性能提升效果。
需要注意的是,三星的论文中描述的性能测试,即是为每个更新频率的数据分配单独的 stream,因而论文中描述的性能提升是理想环境下的最大性能提升效果。
既然 stream 的数量是有限的,那么就必须提供某种机制,按照数据的更新频率,将数据绑定到合适的 stream 上。
当前 Linux mainline 中提供的机制是,抽象 short、medium、long、extrem 这四个级别描述数据的更新频率,该抽象只是描述数据更新频率的相对值,而非绝对值。
之后 SSD 设备驱动需要将这四个级别映射到对应的 stream id,例如 short 映射到 stream 1,extrem 映射到 stream 4,依此类推。
该机制可能存在的问题有
目前存在两种途径为 user file data 绑定 stream
因而上层实现需要根据不同的应用场景,选择上述的其中一种方法,或是将这两者相结合。
由于应用程序的开发者与内核开发者对数据更新频率的理解可能存在不同,同为 short write hint 的 file data 与 metadata 其实际的更新频率可能存在较大的差异,从而影响 multi-stream 的性能。
因而另一种方案是,为内核与用户态程序提供相隔离的 stream id,例如
该方案可以保证 file data 与 metadata 在 SSD 上绝对是分开存储的。
1. NVMe 1.3
2. FStream: Managing Flash Streams in the File System
3. AutoStream: Automatic Stream Management for Multi-streamed SSDs
4. write stream patch
multi-stream 特性由 Jens Axboe 于 2017 年添加到 Linux 4.13.6,适配内容主要包括