ВНИМАНИЕ: по состоянию на 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
без существенной разницы.
@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-кратную разницу между ними, независимо от порядка и раундов разминки. Но даже в моей фактической кодовой базе разница была огромной — настолько большой, что я начал расследование, потому что было сразу очевидно, что что-то не так, поскольку ввод-вывод выполнялся довольно быстро до первого большого файла, а после этого обработка каждого файла занимала намного больше времени.
Просто указываю, что вы используете unsafe
UB. Думаю, это не имеет значения для примера.
Но как вы измеряете это @ducktherapy - какие инструменты задействованы? Или это из-за запуска .exe и это так долго, что это очевидно? И в вашем примере у вас есть только один файл размером 1k. Это все еще все, что нужно, чтобы увидеть проблему? Я на работе, поэтому я не могу попробовать это прямо сейчас, или я бы сделал это, так как я нахожусь в похожей среде. Обратите внимание, что в вашем случае exact
используется массив в макросе vec!
, который отличается от ваших двух других случаев. Надеюсь, большого эффекта не будет.
@ChayimFriedman Это действительно не основное внимание, поскольку это всего лишь пример, и я бы использовал версию Take
, если не могу понять проблему, но в качестве примечания, почему это? В документах Vec
просто указано, что элементы должны быть инициализированы и в пределах емкости.
Ой, извините, я думал, вы используете with_capacity()
. Да, это безопасно, но и ненужно, ведь можно использовать truncate()
.
Кроме того, код будет паниковать на больших длинах, так как resize()
не меняет размер, а только емкость.
@KevinAnderson Да, пример кода на этой машине настолько отличается, что это очевидно даже без измерительных инструментов. Тем не менее, 50-кратная цифра была получена при использовании «сверхтонкого» использования трех методов. И exact
, и take
в 50 раз быстрее, чем naive
, и находятся в пределах погрешности друг друга — причина, по которой я инициализировал его с помощью vec!
вместо exact
, заключалась в том, что я использовал set_len
, а это требует инициализации элементов. В моей фактической кодовой базе я начал с небольшого буфера и позволил ему увеличиваться по мере чтения файлов... и вот тогда-то и обнаружилось несоответствие: файл большего размера > буфер большего размера > замедление.
@ChayimFriedman Хороший улов. Я должен был выбрать примеры получше, но для простоты предположим, что BUFFER_SIZE
— известный верхний предел. truncate()
прямо сейчас это лишило бы смысла повторное использование буферов. Причина, по которой я хочу сохранить буферы, заключается в том, что все файлы легко помещаются в памяти, но мне, возможно, придется анализировать их случайным образом в отношении размеров: [100B, 100MB, 100KB, ...]. Вместо того, чтобы расширять/усекать Vec
снова и снова, я просто хочу использовать буфер приличного размера. Фактический проект использует эвристику в течение более длительного периода времени для восстановления памяти.
^Ну, если предположить, что truncate()
drop_in_place()
означают, что мне нужно их перезапустить. Но я не уверен в этом — можно повторно использовать u8
, верно? Они просто непрозрачные кусочки, нет?
@ducktherapy - Как я это вижу, единственная разница между naive
и take
буквально в том, что file.read_to_end
против file.take(len).read_to_end
- и вот возьми. Может ли происходить какая-то другая оптимизация, которая улучшает взаимодействие с более поздним аккумулятором? На первый взгляд, я даже не знаю, что заставило вас попробовать take
в первую очередь, так как не похоже, что это будет иметь значение, но я верю вашему аккаунту.
@KevinAnderson Поверь мне, я так же озадачен. Единственная причина, по которой я попробовал Take
, — это комментарий на форумах Rust давным-давно об исключении предварительной инициализации из read_to_end
... Но я уверен, что это больше не применяется — с тех пор было несколько обновлений этих функций. Тот факт, что они оба быстрее, до сих пор для меня загадка. В стандартном коде реализации ничего не выскочило, но я не усвоил всю цепочку read_to_end
-> sys_impl
. Похоже, в какой-то момент мне придется глубоко погрузиться, но это может быть просто ошибка оптимизации бэкэнда.
Я пытался использовать 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
! Спасибо!
Подождите, я неправильно прочитал график, я думал, что это naive vs take
. Означает ли это, что вы наблюдаете, как naive
и take
работают линейно медленнее, чем буферная емкость, в отличие от exact
? На моем компьютере это сделал только naive
, остальные два нет. Еще одна загадка.
@ducktherapy Да, я думаю, это не очень ясно. Числа на оси take
- это u64, заданный в качестве аргумента для take
.
Я вижу — я переделал это со своей стороны, и теперь я вижу то же самое поведение с take
. Должно быть, я не на то смотрел, когда бежал take(BUFFER_SIZE)
, так что я расслабился. Спасибо еще раз за помощь.
Я не могу воспроизвести это на своей машине. Как вы измеряете производительность? Какую версию Rust вы используете?