Почему `File::read_to_end` становится медленнее, чем больше емкость буфера?

ВНИМАНИЕ: по состоянию на 23 апреля 2023 г. исправление для этого появилось на rust-lang/rust:master. Вскоре вы сможете использовать File::read_to_end без этих забот.


Я работал над довольно специфической проблемой, которая требовала от меня чтения сотен тысяч файлов размером от нескольких байтов до нескольких сотен мегабайт. Поскольку основная часть операции состояла в перечислении файлов и перемещении данных с диска, я прибегнул к повторному использованию буферов Vec для чтения файлов в надежде избежать некоторых операций по управлению памятью.

Вот тут-то я и столкнулся с неожиданным: file.read_to_end(&mut buffer)? становится все медленнее, чем больше емкость буфера. Намного медленнее сначала прочитать файл размером 300 МБ, а затем тысячу файлов размером 1 КБ, чем наоборот (пока мы не усекаем буфер).

Как ни странно, если я заверну файл в Take или использую 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]),
    }
}

Пробовал несколько комбинаций режима --release, LTO и даже +crt-static без существенной разницы.

Я не могу воспроизвести это на своей машине. Как вы измеряете производительность? Какую версию Rust вы используете?

Sven Marnach 19.04.2023 16:20

@SvenMarnach rustc 1.68.2 (9eb3afe9e 2023-03-27) с целью x86_64-pc-windows-msvc на современной машине с Windows 10 и VS22. В примере кода hyperfine 'binary naive' 'binary take' 'binary exact' показывает 50-кратную разницу между ними, независимо от порядка и раундов разминки. Но даже в моей фактической кодовой базе разница была огромной — настолько большой, что я начал расследование, потому что было сразу очевидно, что что-то не так, поскольку ввод-вывод выполнялся довольно быстро до первого большого файла, а после этого обработка каждого файла занимала намного больше времени.

ducktherapy 19.04.2023 16:28

Просто указываю, что вы используете unsafe UB. Думаю, это не имеет значения для примера.

Chayim Friedman 19.04.2023 16:43

Но как вы измеряете это @ducktherapy - какие инструменты задействованы? Или это из-за запуска .exe и это так долго, что это очевидно? И в вашем примере у вас есть только один файл размером 1k. Это все еще все, что нужно, чтобы увидеть проблему? Я на работе, поэтому я не могу попробовать это прямо сейчас, или я бы сделал это, так как я нахожусь в похожей среде. Обратите внимание, что в вашем случае exact используется массив в макросе vec!, который отличается от ваших двух других случаев. Надеюсь, большого эффекта не будет.

Kevin Anderson 19.04.2023 16:44

@ChayimFriedman Это действительно не основное внимание, поскольку это всего лишь пример, и я бы использовал версию Take, если не могу понять проблему, но в качестве примечания, почему это? В документах Vec просто указано, что элементы должны быть инициализированы и в пределах емкости.

ducktherapy 19.04.2023 16:48

Ой, извините, я думал, вы используете with_capacity(). Да, это безопасно, но и ненужно, ведь можно использовать truncate().

Chayim Friedman 19.04.2023 16:52

Кроме того, код будет паниковать на больших длинах, так как resize() не меняет размер, а только емкость.

Chayim Friedman 19.04.2023 16:53

@KevinAnderson Да, пример кода на этой машине настолько отличается, что это очевидно даже без измерительных инструментов. Тем не менее, 50-кратная цифра была получена при использовании «сверхтонкого» использования трех методов. И exact, и take в 50 раз быстрее, чем naive, и находятся в пределах погрешности друг друга — причина, по которой я инициализировал его с помощью vec! вместо exact, заключалась в том, что я использовал set_len, а это требует инициализации элементов. В моей фактической кодовой базе я начал с небольшого буфера и позволил ему увеличиваться по мере чтения файлов... и вот тогда-то и обнаружилось несоответствие: файл большего размера > буфер большего размера > замедление.

ducktherapy 19.04.2023 16:53

@ChayimFriedman Хороший улов. Я должен был выбрать примеры получше, но для простоты предположим, что BUFFER_SIZE — известный верхний предел. truncate() прямо сейчас это лишило бы смысла повторное использование буферов. Причина, по которой я хочу сохранить буферы, заключается в том, что все файлы легко помещаются в памяти, но мне, возможно, придется анализировать их случайным образом в отношении размеров: [100B, 100MB, 100KB, ...]. Вместо того, чтобы расширять/усекать Vec снова и снова, я просто хочу использовать буфер приличного размера. Фактический проект использует эвристику в течение более длительного периода времени для восстановления памяти.

ducktherapy 19.04.2023 17:06

^Ну, если предположить, что truncate()drop_in_place() означают, что мне нужно их перезапустить. Но я не уверен в этом — можно повторно использовать u8, верно? Они просто непрозрачные кусочки, нет?

ducktherapy 19.04.2023 17:26

@ducktherapy - Как я это вижу, единственная разница между naive и take буквально в том, что file.read_to_end против file.take(len).read_to_end - и вот возьми. Может ли происходить какая-то другая оптимизация, которая улучшает взаимодействие с более поздним аккумулятором? На первый взгляд, я даже не знаю, что заставило вас попробовать take в первую очередь, так как не похоже, что это будет иметь значение, но я верю вашему аккаунту.

Kevin Anderson 19.04.2023 18:16

@KevinAnderson Поверь мне, я так же озадачен. Единственная причина, по которой я попробовал Take, — это комментарий на форумах Rust давным-давно об исключении предварительной инициализации из read_to_end... Но я уверен, что это больше не применяется — с тех пор было несколько обновлений этих функций. Тот факт, что они оба быстрее, до сих пор для меня загадка. В стандартном коде реализации ничего не выскочило, но я не усвоил всю цепочку read_to_end -> sys_impl. Похоже, в какой-то момент мне придется глубоко погрузиться, но это может быть просто ошибка оптимизации бэкэнда.

ducktherapy 19.04.2023 18:44
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
8
12
660
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 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");

И время выполнения масштабировалось с ним почти точно линейно.

Использование cargo flamegraph дает четкую картину: NtReadFile занимает 95% времени.

В версии exact требуется всего 10%. Другими словами, ваш код ржавчины не виноват.

Документы Windows ничего не предлагают в отношении длины буфера, но из чтения стандартной библиотеки ржавчины видно, что NtReadFile предоставляется вся свободная емкость Vec, и из теста видно, что NtReadFile делает что-то на каждый байт в буфере.

Я считаю, что метод exact был бы лучшим здесь. std::fs::read также запрашивает длину файла перед чтением, хотя у него всегда есть буфер нужного размера, так как он создает Vec. Он также по-прежнему использует read_to_end, чтобы возвращать более правильный файл, даже если длина между ними изменилась. Если вы хотите повторно использовать Vec, вам нужно будет сделать это другим способом.

Убедитесь, что все, что вы выберете, будет быстрее, чем воссоздание Vec каждый раз, что я немного попробовал и получил почти такую ​​​​же производительность, как exact. Освобождение неиспользуемой памяти дает преимущества в производительности, поэтому от ситуации зависит, ускорит ли это вашу программу.

Вы также можете разделить пути кода для коротких и длинных файлов.

Наконец, убедитесь, что вам нужен весь файл. Если вы можете выполнять обработку фрагментами BufReader одновременно, с fill_buf и потреблением, вы можете полностью избежать этой проблемы.

Спасибо, что подтвердили, что я не схожу с ума. В тесте воссоздание Vec действительно мало что меняет, но в реальной кодовой базе сохранение буфера — явный победитель, поэтому я продолжу read_exact! Спасибо!

ducktherapy 20.04.2023 08:35

Подождите, я неправильно прочитал график, я думал, что это naive vs take. Означает ли это, что вы наблюдаете, как naive и take работают линейно медленнее, чем буферная емкость, в отличие от exact? На моем компьютере это сделал только naive, остальные два нет. Еще одна загадка.

ducktherapy 20.04.2023 08:58

@ducktherapy Да, я думаю, это не очень ясно. Числа на оси take - это u64, заданный в качестве аргумента для take.

drewtato 20.04.2023 09:59

Я вижу — я переделал это со своей стороны, и теперь я вижу то же самое поведение с take. Должно быть, я не на то смотрел, когда бежал take(BUFFER_SIZE), так что я расслабился. Спасибо еще раз за помощь.

ducktherapy 20.04.2023 11:06

Другие вопросы по теме