为什么缓冲区容量越大,“File::read_to_end”的速度就越慢?

duc*_*apy 8 windows io rust

注意:截至 2023 年 4 月 23 日,此问题的修复已在rust-lang/rust:master. 您很快就可以File::read_to_end不用再担心这些问题了。


我正在解决一个非常具体的问题,需要我读取数十万个文件,从几个字节到几百兆字节不等。由于大部分操作包括枚举文件和从磁盘移动数据,因此我重新使用Vec缓冲区来读取文件,希望避免一些内存管理。

就在那时,我遇到了意想不到的情况:file.read_to_end(&mut buffer)?缓冲区容量越大,速度就越慢。先读取 300MB 文件,然后再读取一千个 1KB 文件比相反读取要慢得多(只要我们不截断缓冲区)。

令人困惑的是,如果我将文件包装在 aTake或 use中read_exact(),则不会出现速度减慢的情况。

有谁知道那是关于什么的吗?是否有可能在每次调用时(重新)初始化整个缓冲区?这是 Windows 特有的怪癖吗?在处理此类问题时,您会推荐哪些(基于 Windows 的)分析工具?

下面是一个简单的复制,展示了这些方法之间巨大的性能差异(在本机上为 50 倍以上),不考虑磁盘速度:

use std::io::Read;
use std::fs::File;

// with a smaller buffer, there's basically no difference between the methods...
// const BUFFER_SIZE: usize = 2 * 1024;

// ...but the larger the Vec, the bigger the discrepancy.
// for simplicity's sake, let's assume this is a hard upper limit.
const BUFFER_SIZE: usize = 300 * 1024 * 1024;


fn naive() {
    let mut buffer = Vec::with_capacity(BUFFER_SIZE);

    for _ in 0..100 {
        let mut file = File::open("some_1kb_file.txt").expect("opening file");

        let metadata = file.metadata().expect("reading metadata");
        let len = metadata.len();
        assert!(len <= BUFFER_SIZE as u64);

        buffer.clear();
        file.read_to_end(&mut buffer).expect("reading file");

        // do "stuff" with buffer
        let check = buffer.iter().fold(0usize, |acc, x| acc.wrapping_add(*x as usize));

        println!("length: {len}, check: {check}");
    }
}

fn take() {
    let mut buffer = Vec::with_capacity(BUFFER_SIZE);

    for _ in 0..100 {
        let file = File::open("some_1kb_file.txt").expect("opening file");

        let metadata = file.metadata().expect("reading metadata");
        let len = metadata.len();
        assert!(len <= BUFFER_SIZE as u64);

        buffer.clear();
        file.take(len).read_to_end(&mut buffer).expect("reading file");

        // this also behaves like the straight `read_to_end` with a significant slowdown:
        // file.take(BUFFER_SIZE as u64).read_to_end(&mut buffer).expect("reading file");

        // do "stuff" with buffer
        let check = buffer.iter().fold(0usize, |acc, x| acc.wrapping_add(*x as usize));

        println!("length: {len}, check: {check}");
    }
}

fn exact() {
    let mut buffer = vec![0u8; BUFFER_SIZE];

    for _ in 0..100 {
        let mut file = File::open("some_1kb_file.txt").expect("opening file");

        let metadata = file.metadata().expect("reading metadata");
        let len = metadata.len() as usize;
        assert!(len <= BUFFER_SIZE);

        // SAFETY: initialized by `vec!` and within capacity by `assert!`
        unsafe { buffer.set_len(len); }
        file.read_exact(&mut buffer[0..len]).expect("reading file");

        // do "stuff" with buffer
        let check = buffer.iter().fold(0usize, |acc, x| acc.wrapping_add(*x as usize));

        println!("length: {len}, check: {check}");
    }
}

fn main() {
    let args: Vec<String> = std::env::args().collect();

    if args.len() < 2 {
        println!("usage: {} <method>", args[0]);
        return;
    }

    match args[1].as_str() {
        "naive" => naive(),
        "take" => take(),
        "exact" => exact(),
        _ => println!("Unknown method: {}", args[1]),
    }
}
Run Code Online (Sandbox Code Playgroud)

尝试了几种--release模式的组合,LTO甚至+crt-static没有显着的差异。

dre*_*ato 8

我尝试使用take逐渐增大的数字:

// Run with different values of `take` from 10_000_000 to 300_000_000
file.take(take)
    .read_to_end(&mut buffer)
    .expect("reading file");
Run Code Online (Sandbox Code Playgroud)

运行时间几乎与它成线性比例。

时间与拍摄时间的图表显示线性相关性

使用cargo flamegraph可以提供清晰的图片:NtReadFile需要 95% 的时间。

可执行文件的火焰图

版本中只占10% exact。换句话说,你的 Rust 代码没有问题。

Windows 文档没有对缓冲区的长度提出任何建议,但从阅读 rust 标准库来看,确实NtReadFile给出了 的全部备用容量,Vec并且从基准测试中可以明显看出,它在每个缓冲区上NtReadFile都做了一些事情缓冲区中的字节。

我相信exact这里的方法是最好的。std::fs::read还在读取之前查询文件的长度,尽管它始终具有正确大小的缓冲区,因为它创建了Vec. 它还仍然使用read_to_end,以便即使长度之间发生变化,它也会返回更正确的文件。如果您想重用Vec,则需要以其他方式执行此操作。

确保您选择的任何内容都比Vec每次重新创建更快,我尝试了一下并获得了与exact. 释放未使用的内存会带来性能优势,因此是否能让程序更快取决于具体情况。

您还可以分离短文件和长文件的代码路径。

最后,确保您需要整个文件。BufReader如果您可以一次使用 和fill_buf进行大块的处理consume,则可以完全避免此问题。