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

Why does `File::read_to_end` get slower the larger the buffer capacity?

提问人:ducktherapy 提问时间:4/19/2023 最后编辑:Sven Marnachducktherapy 更新时间:4/25/2023 访问量:688

问:

通知:截至 2023 年 4 月 23 日,此问题的修复程序落在 rust-lang/rust:master。您很快就可以使用File::read_to_end而不必担心这些。


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

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

令人困惑的是,如果我将文件包装在 or use 中,则不会发生减速。Takeread_exact()

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

下面是一个简单的复制品,它演示了这些方法之间的巨大(在这台机器上是 50x+)性能差异,忽略磁盘速度:

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]),
    }
}

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

Windows Rust IO

评论

0赞 Sven Marnach 4/19/2023
我无法在我的机器上重现它。您如何衡量绩效?你使用的是哪个版本的 Rust?
0赞 ducktherapy 4/19/2023
在装有 VS22 的最新 Windows 10 计算机上与目标@SvenMarnach。在示例代码中,a 显示它们之间的差异是 50 倍,无论顺序和热身回合如何。但即使在我的实际代码库上,差异也是巨大的——以至于我开始调查,因为很明显出了问题,因为 IO 在第一个大文件之前运行得非常快,之后每个文件都需要更长的时间来处理。rustc 1.68.2 (9eb3afe9e 2023-03-27)x86_64-pc-windows-msvchyperfine 'binary naive' 'binary take' 'binary exact'
1赞 Chayim Friedman 4/19/2023
只是指出你的用法是UB。猜猜这对示例无关紧要。unsafe
0赞 Kevin Anderson 4/19/2023
但是,您如何衡量它@ducktherapy - 涉及哪些工具?或者这是由于运行.exe并且时间太长了,很明显?在您的示例中,您只有一个 1k 文件。这仍然是看到问题所需的全部内容吗?我正在工作,所以我现在不能尝试这个,或者我会尝试,因为我在类似的环境中。请注意,您的案例在宏中使用了一个数组,这与其他两个案例不同。希望不会有太大的影响。exactvec!
1赞 ducktherapy 4/19/2023
@KevinAnderson 是的,示例代码在这台机器上有如此大的差异,即使没有测量工具也很明显。然而,50 倍的数字来自使用三种方法运行“超精细”。两者都比彼此快 50 倍,并且在误差范围内——我用 for 初始化它的原因是因为我使用了并且需要初始化元素。在我的实际代码库中,我从一个小缓冲区开始,让它随着文件的读取而增长......这时差异就出现了:文件越大>缓冲区越大>速度变慢。exacttakenaivevec!exactset_len

答:

8赞 drewtato 4/20/2023 #1

我尝试使用逐渐增加的数字: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");

运行时几乎完全线性地缩放。

graph of time vs. take showing a linear correlation

使用货物火焰图可以清楚地显示:NtReadFile 需要 95% 的时间。

flamegraph of the executable

在版本中只需要 10%。换句话说,你的 Rust 代码没有错。exact

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

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

确保你选择的任何内容都比每次重新创建都快,我尝试了一下,得到了几乎相同的性能。释放未使用的内存对性能有好处,因此它是否使您的程序更快将取决于具体情况。Vecexact

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

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

评论

0赞 ducktherapy 4/20/2023
谢谢你确认我没有发疯。在测试中,重新创建确实没有什么区别,但在实际代码库中,保留缓冲区显然是赢家,所以我会继续!谢谢!Vecread_exact
0赞 ducktherapy 4/20/2023
等等,我看错了图表,我以为是.这是否意味着您同时观察两者,并以线性速度慢运行以缓冲容量而不是?在我的电脑上,只有做到了,其他两个没有。又是一个谜。naive vs takenaivetakeexactnaive
0赞 drewtato 4/20/2023
@ducktherapy是的,我想这不是很清楚。轴上的数字是 u64 作为 的参数给出。taketake
0赞 ducktherapy 4/20/2023
我明白了——我这边重做了一遍,我现在看到了同样的行为。我跑步的时候一定是看错了东西,所以我松了一口气。再次感谢您的帮助。taketake(BUFFER_SIZE)