“测试 Rust 的 I/O 性能”

我们将尝试使用 Rust 来比较读取文件的各种不同方法。除了 wc -l 之外,我们将使用 criterion 对每个函数运行 10 次,然后取平均值。

以下基准测试的代码存放在 Benchmark code for Linux IO using Rust
在以下代码中,BUFFER_SIZE 为 8192,NUM_BUFFERS 为 32。

原文: # Linux File IO using Rust](https://opdroid.org/rust-io.html)) by opdroid

测试机器细节

  1. Framework 16 笔记本,带有锐龙 7840 HS 处理器和 64 G 内存的电脑。电源已接通并启用了性能模式。(这个笔记本是一个模块化的笔记本)
  2. SSD: WD_BLACK SN850X 4000GB。使用Gnome Disks进行测试显示读取速度为3.6 GB/s(样本大小为1000MB,共100个样本)。
  3. 文件系统:btrfs
  4. 操作系统版本 (uname 结果):Linux fedora 6.8.8-300. Fc 40. X 86_64 #1 SMP PREEMPT_DYNAMIC Sat Apr 27 17:53:31 UTC 2024 x 86_64 GNU/Linux

文件细节

  • 未压缩大小:22G
  • 行数:200,000,000
  • 使用 btrfs 压缩(zstd)后的压缩大小:5.3G

对于不耐烦的读者:结果概述

读取方法 时间 (单位:秒)
Mmap with AVX512 2.61
Mmap with AVX2 2.64
io_uring with Vectored IO 2.86
Vectored IO 2.89
Mmap 3.43
io_uring 5.26
wc -l (baseline) 8.01
Direct IO 10.56
BufReader without appends 15.94
BufReader with lines().count() 33.50

一个有趣的观察是,AVX512 需要 2.61 秒,文件大小约为 22G,而 SSD 基准测试显示读取速度为 3.6 GB/s。这意味着文件应该在大约 6 秒内被读取完毕。但 AVX512 的实现实际上是以约 8.4 GB/s 的速度读取文件。

这是怎么回事呢?比磁盘的读取速度都快不少?不科学啊?

原来 Fedora 使用了 btrfs 文件系统,它默认启用了 zstd 压缩。实际的磁盘上大小可以使用 compsize 命令来查看。

1
2
3
4
5
6
opdroid@box:~/tmp$ sudo compsize data
Processed 1 file, 177437 regular extents (177437 refs), 0 inline.
Type Perc Disk Usage Uncompressed Referenced
TOTAL 24% 5.3G 21G 21G
none 100% 32K 32K 32K
zstd 24% 5.3G 21G 21G

感谢这些优秀的人

  • @alextjensen - 感谢他指导我使用 BufReader 的合理默认值,并编译为本机架构。
  • @aepau2 - 感谢他发现了 wc 数字中的一个明显错误。我在使用 wc 测量之前忘记了清空缓存。
  • @rflaherty71 - 感谢他建议我使用更多且更大的缓冲区(64 x 64k)。
  • @daniel_c0deb0t - 感谢他建议我使用更大的缓冲区。

不使用我们编写的代码作为基线总是一个好主意,这样比较客观。

使用 wc -l 作为基线

1
2
3
4
5
6
opdroid@box:~/tmp$ time wc -l data
200000000 data
real 0m8.010s
user 0m0.193s
sys 0m7.591s

在每个函数的末尾,我们使用以下命令重置文件缓存。我还没有弄清楚如何在 criterion 中使用 teardown 函数,以便这个时间不被计入总耗时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// TODO: move to a teardown function in criterion
fn reset_file_caches() {
// Execute the command to reset file caches
let output = Command::new("sudo")
.arg("sh")
.arg("-c")
.arg("echo 3 > /proc/sys/vm/drop_caches")
.output()
.expect("Failed to reset file caches");
// Check if the command executed successfully
if !output.status.success() {
panic!("Failed to reset file caches: {:?}", output);
}
}

方法1:使用 BufReader 读取文件,并使用 reader.lines().count() 计算行数

1
2
3
4
5
6
7
8
9
fn count_newlines_standard(filename: &str) -> Result<usize, std::io::Error> {
let file = File::open(filename)?;
let reader = BufReader::with_capacity(16 * 1024, file);
let newline_count = reader.lines().count();
reset_file_caches();
Ok(newline_count)
}

在我的机器上,这需要大约 36.5 秒的时间。


count_newlines_standard 函数中,字符串拼接(String appends)可能是导致性能问题的原因。

方法 2:使用 BufReader 读取文件并避免字符串拼接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub fn count_newlines_standard_non_appending(filename: &str) -> Result<usize, std::io::Error> {
let file = File::open(filename)?;
let mut reader = BufReader::with_capacity(64 * 1024, file);
let mut newline_count = 0;
loop {
let len = {
let buffer = reader.fill_buf()?;
if buffer.is_empty() {
break;
}
newline_count += buffer.iter().filter(|&&b| b == b'\n').count();
buffer.len()
};
reader.consume(len);
}
reset_file_caches();
Ok(newline_count)
}

在我的机器上,这大约需要 15.94 秒。这比使用字符串拼接的版本快了一半以上。

当我们查看火焰图时,我们可以确认字符串拼接的操作已经不存在了。

方法 3:使用 Direct I/O 读取文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn count_newlines_direct_io(filename: &str) -> Result<usize, Error> {
let mut open_options = File::options();
open_options.read(true).custom_flags(libc::O_DIRECT);
let mut file = open_options.open(filename)?;
let mut buffer = vec![0; BUFFER_SIZE];
let mut newline_count = 0;
loop {
let bytes_read = file.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
let chunk_newline_count = buffer[..bytes_read].iter().filter(|&&b| b == b'\n').count();
newline_count += chunk_newline_count;
}
reset_file_caches();
Ok(newline_count)
}

在我的机器上,这大约需要 35.7 秒。

方法 4:使用内存映射(Mmap)读取文件

1
2
3
4
5
6
7
8
fn count_newlines_memmap(filename: &str) -> Result<usize, Error> {
let file = File::open(filename)?;
let mmap = unsafe { Mmap::map(&file)? };
let newline_count = mmap.iter().filter(|&&b| b == b'\n').count();
reset_file_caches();
Ok(newline_count)
}

在我的机器上,这大约需要 8.3 秒。

方法 5:使用内存映射(Mmap)和 AVX2 指令集读取文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
unsafe fn count_newlines_memmap_avx2(filename: &str) -> Result<usize, Error> {
let file = File::open(filename)?;
let mmap = unsafe { Mmap::map(&file)? };
let newline_byte = b'\n';
let newline_vector = _mm256_set1_epi8(newline_byte as i8);
let mut newline_count = 0;
let mut ptr = mmap.as_ptr();
let end_ptr = unsafe { ptr.add(mmap.len()) };
while ptr <= end_ptr.sub(32) {
let data = unsafe { _mm256_loadu_si256(ptr as *const __m256i) };
let cmp_result = _mm256_cmpeq_epi8(data, newline_vector);
let mask = _mm256_movemask_epi8(cmp_result);
newline_count += mask.count_ones() as usize;
ptr = unsafe { ptr.add(32) };
}
// Count remaining bytes
let remaining_bytes = end_ptr as usize - ptr as usize;
newline_count += mmap[mmap.len() - remaining_bytes..].iter().filter(|&&b| b == newline_byte).count();
reset_file_caches();
Ok(newline_count)
}

这个方法在我的机器上大约耗时 2.64 秒。

方法 6:使用内存映射(Mmap)和 AVX-512 指令集读取文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
unsafe fn count_newlines_memmap_avx512(filename: &str) -> Result<usize, Error> {
let file = File::open(filename)?;
let mmap = unsafe { Mmap::map(&file)? };
let newline_byte = b'\n';
let newline_vector = _mm512_set1_epi8(newline_byte as i8);
let mut newline_count = 0;
let mut ptr = mmap.as_ptr();
let end_ptr = unsafe { ptr.add(mmap.len()) };
while ptr <= end_ptr.sub(64) {
let data = unsafe { _mm512_loadu_si512(ptr as *const i32) };
let cmp_result = _mm512_cmpeq_epi8_mask(data, newline_vector);
newline_count += cmp_result.count_ones() as usize;
ptr = unsafe { ptr.add(64) };
}
// Count remaining bytes
let remaining_bytes = end_ptr as usize - ptr as usize;
newline_count += mmap[mmap.len() - remaining_bytes..].iter().filter(|&&b| b == newline_byte).count();
reset_file_caches();
Ok(newline_count)
}

这个方法在我的机器上大约需要 2.61 秒。

方法 7:使用向量 I/O 读取文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
fn count_newlines_vectored_io(path: &str) -> Result<usize, Error> {
let mut file = File::open(path)?;
let mut buffers_: Vec<_> = (0..16).map(|_| vec![0; BUFFER_SIZE]).collect();
let mut buffers: Vec<_> = buffers_.iter_mut().map(|buf| io::IoSliceMut::new(buf)).collect();
let mut newline_count = 0;
loop {
let bytes_read = file.read_vectored(&mut buffers)?;
if bytes_read == 0 {
break;
}
// Calculate how many buffers were filled
let filled_buffers = bytes_read / BUFFER_SIZE;
// Process the fully filled buffers
for buf in &buffers[..filled_buffers] {
newline_count += buf.iter().filter(|&&b| b == b'\n').count();
}
// Handle the potentially partially filled last buffer
if filled_buffers < buffers.len() {
let last_buffer = &buffers[filled_buffers];
let end = bytes_read % BUFFER_SIZE;
newline_count += last_buffer[..end].iter().filter(|&&b| b == b'\n').count();
}
}
Ok(newline_count)
}

在我的机器上,这大约需要 7.7 秒。

方法 8:使用 io_uring 读取文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
fn count_lines_io_uring(path: &str) -> io::Result<usize> {
let file = File::open(path)?;
let fd = file.as_raw_fd();
let mut ring = IoUring::new(8)?;
let mut line_count = 0;
let mut offset = 0;
let mut buf = vec![0; 4096];
let mut read_size = buf.len();
loop {
let mut sqe = opcode::Read::new(types::Fd(fd), buf.as_mut_ptr(), read_size as _)
.offset(offset as _)
.build()
.user_data(line_count as _);
unsafe {
ring.submission()
.push(&mut sqe)
.expect("submission queue is full");
}
ring.submit_and_wait(1)?;
let cqe = ring.completion().next().expect("completion queue is empty");
let bytes_read = cqe.result() as usize;
line_count = cqe.user_data() as usize;
if bytes_read == 0 {
break;
}
let data = &buf[..bytes_read];
line_count += data.iter().filter(|&&b| b == b'\n').count();
offset += bytes_read as u64;
read_size = (buf.len() - (offset as usize % buf.len())) as usize;
}
Ok(line_count)
}

在我的机器上,这大约需要 10.5 秒。

方法 9:使用带有向量 I/O 的 io_uring 读取文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
fn count_lines_io_uring_vectored(path: &str) -> io::Result<usize> {
let file = File::open(path)?;
let fd = file.as_raw_fd();
let mut ring = IoUring::new(NUM_BUFFERS as u32)?;
let mut line_count = 0;
let mut offset = 0;
let mut buffers = vec![vec![0; 8192]; NUM_BUFFERS];
let mut iovecs: Vec<iovec> = buffers
.iter_mut()
.map(|buf| iovec {
iov_base: buf.as_mut_ptr() as *mut _,
iov_len: buf.len(),
})
.collect();
loop {
let mut sqe = opcode::Readv::new(types::Fd(fd), iovecs.as_mut_ptr(), iovecs.len() as _)
.offset(offset as _)
.build()
.user_data(0);
unsafe {
ring.submission()
.push(&mut sqe)
.expect("submission queue is full");
}
ring.submit_and_wait(1)?;
let cqe = ring.completion().next().expect("completion queue is empty");
let bytes_read = cqe.result() as usize;
if bytes_read == 0 {
break;
}
let mut buffer_line_count = 0;
let mut remaining_bytes = bytes_read;
for buf in &buffers[..iovecs.len()] {
let buf_size = buf.len();
let data_size = remaining_bytes.min(buf_size);
let data = &buf[..data_size];
buffer_line_count += data.iter().filter(|&&b| b == b'\n').count();
remaining_bytes -= data_size;
if remaining_bytes == 0 {
break;
}
}
line_count += buffer_line_count;
offset += bytes_read as u64;
}
Ok(line_count)
}

在我的机器上,这大约需要 7.6 秒。