Почему цикл по массиву из 240+ элементов работает в 10 раз медленнее в Rust?
Причина: оптимизация цикла и LLVM
Вы столкнулись с известным эффектом, связанным с тем, как LLVM (бэкенд компилятора Rust) оптимизирует циклы. Когда размер массива меньше 240 элементов, компилятор применяет полную развёртку цикла (loop unrolling) — он копирует тело цикла для каждой итерации, превращая его в последовательность операций без проверок и прыжков. Это даёт огромный прирост скорости, но только если массив не слишком большой. Порог в 240 элементов — это лимит, установленный в LLVM для развёртки циклов по умолчанию (флаг -unroll-threshold). При размере 240+ компилятор отказывается от развёртки и генерирует обычный цикл с проверкой границ, что и вызывает падение производительности.
Как это проверить?
Скомпилируйте код с флагом --emit=llvm-ir и посмотрите на сгенерированный LLVM IR. Для массива из 239 элементов вы увидите множество повторяющихся инструкций add, а для 240 — короткий цикл с br (ветвлением).
Решение: явное управление развёрткой
Вы можете принудительно управлять развёрткой циклов с помощью атрибутов или библиотечных функций. Вот несколько подходов:
- Использовать
#[inline(always)]и ручную развёртку — не рекомендуется для больших массивов. - Изменить порог развёртки через
RUSTFLAGS— например,RUSTFLAGS="-C llvm-args=-unroll-threshold=1000". - Использовать итераторы и
fold— они лучше оптимизируются LLVM, чем явные циклыfor. - Применить
#[repr(align(64))]и выравнивание — иногда проблема усугубляется из-за кэш-линий.
Пример оптимизированного кода
fn sum_array_optimized(arr: &[i32]) -> i32 {
// Используем fold, который LLVM лучше разворачивает
arr.iter().fold(0, |acc, &x| acc + x)
}
// Альтернатива: ручная развёртка для малых массивов
fn sum_array_unrolled(arr: &[i32]) -> i32 {
let mut sum = 0;
let chunks = arr.chunks_exact(4);
let remainder = chunks.remainder();
for chunk in chunks {
sum += chunk[0] + chunk[1] + chunk[2] + chunk[3];
}
for &x in remainder {
sum += x;
}
sum
}
Бенчмарк для проверки
fn benchmark() {
let sizes = [239, 240, 241, 1000];
for &size in &sizes {
let arr: Vec<i32> = (0..size).collect();
let start = std::time::Instant::now();
for _ in 0..10000 {
std::hint::black_box(sum_array_optimized(&arr));
}
let dur = start.elapsed();
println!("Size {}: {:?}", size, dur);
}
}
Использование std::hint::black_box предотвращает удаление "мёртвого кода" оптимизатором. Вы увидите, что для размера 240 производительность останется высокой, если использовать fold или явную развёртку.
Вывод
Это не баг, а особенность эвристик LLVM. Для критичных к производительности участков кода используйте итераторы с fold или ручную развёртку. И всегда проверяйте сгенерированный ассемблер через cargo asm или godbolt.org.
Альтернативный взгляд: проблема с кэшем и предсказанием ветвлений
Хотя основная причина — развёртка циклов в LLVM, есть и другой аспект. При размере массива 240+ элементов (особенно если это 240 * 4 байта = 960 байт) данные могут пересекать границы кэш-линий (обычно 64 байта). Это увеличивает количество промахов кэша L1. Но главное — скачок производительности именно на границе 239/240 указывает на оптимизацию компилятора, а не на аппаратные ограничения.
Дополнительная рекомендация: попробуйте изменить тип элементов с i32 на i64 или u8. Если порог сместится (например, для i64 он будет около 120), это подтвердит теорию с LLVM. Для u8 порог может быть около 960. Используйте cargo bench с крейтом criterion для точных замеров.
Простое быстрое решение: если массив статический, объявите его как const или static — компилятор часто может вычислить сумму на этапе компиляции, полностью устранив цикл. Для динамических данных используйте rayon для параллельной обработки: arr.par_iter().sum() — это обходит проблему развёртки за счёт параллелизма.