Почему в Rust цикл по массиву из 240+ элементов работает в 10 раз медленнее?
Причина: оптимизация циклов с помощью разворачивания (loop unrolling) и ограничения LLVM
Вы столкнулись с классическим эффектом, связанным с тем, как LLVM (бэкенд компилятора Rust) оптимизирует циклы. Дело не в магии и не в баге, а в пороге, после которого компилятор меняет стратегию генерации кода.
Ключевой фактор — разворачивание циклов (loop unrolling). Это оптимизация, при которой компилятор заменяет цикл на последовательность повторяющихся операций, чтобы уменьшить накладные расходы на проверку условия и переходы. Например, цикл из 4 итераций может быть превращён в 4 последовательных сложения без цикла.
Однако разворачивание не бесплатно: оно увеличивает размер бинарного кода. LLVM имеет эвристики, которые ограничивают разворачивание, чтобы не раздувать код слишком сильно. Одна из таких эвристик — порог размера тела цикла. Если после разворачивания размер сгенерированного кода превышает лимит (обычно около 240–250 инструкций для простых циклов), LLVM отказывается от полного разворачивания и генерирует обычный цикл с проверкой на каждой итерации.
В вашем случае:
- Для массива из 239 элементов цикл полностью разворачивается: получается 239 операций сложения без цикла. Это очень быстро.
- Для 240 элементов размер развёрнутого кода превышает порог LLVM. Компилятор генерирует обычный цикл с инструкциями
cmpиjmpна каждой итерации. Это вносит задержки на проверку границ и управление потоком.
Вот как это выглядит в ассемблере (упрощённо). Для 239 элементов:
; Развёрнутый код: просто сложения
add rdi, [rsi]
add rdi, [rsi+4]
add rdi, [rsi+8]
...
; Нет циклов, нет проверок
Для 240 элементов:
; Цикл с проверкой
loop_start:
add rdi, [rsi + rcx*4]
inc rcx
cmp rcx, 240
jl loop_start
Разница в производительности обусловлена не только дополнительными инструкциями, но и промахами предсказания ветвлений (branch misprediction) на последней итерации, а также влиянием на конвейер процессора.
Как это проверить и обойти?
Вы можете убедиться в эффекте, посмотрев на ассемблерный код. Скомпилируйте с флагом --emit=asm:
cargo rustc --release -- --emit=asm -C opt-level=3
В полученном файле .s вы увидите, что для массивов до 239 элементов нет инструкций цикла, а для 240+ — появляется метка цикла и условный переход.
Если вам критична производительность на таких размерах, есть несколько подходов:
- Использовать итераторы с явным размером:
arr.chunks(239)— разбить массив на блоки, которые компилятор сможет развернуть. - Форсировать разворачивание атрибутом:
#[inline(always)]на функции и#[repr(align(64))]для выравнивания данных. - Использовать
std::simd(если nightly): векторные инструкции работают с блоками по 128/256 бит и менее чувствительны к этому порогу. - Просто принять: в реальных приложениях массивы редко имеют фиксированный размер 240, и эффект может быть незначительным на фоне других операций.
Это не баг, а компромисс между размером кода и скоростью. LLVM выбирает оптимальную стратегию для среднего случая, и для 240 элементов он считает, что лучше сгенерировать компактный цикл, чем раздувать бинарник.
<<Альтернативный взгляд: роль выравнивания и кэша
Коллега выше верно указал на разворачивание циклов, но есть и другая сторона — выравнивание памяти и границы кэш-линий. В Rust массивы по умолчанию выравниваются по размеру элемента, но не обязательно по 64 байта (размер кэш-линии в современных x86-процессорах).
Когда массив имеет размер 240 элементов типа i32 (960 байт), он почти гарантированно пересекает границу кэш-линии. Для 239 элементов (956 байт) — может поместиться в одну кэш-линию, если начало выровнено. Это влияет на скорость доступа: одна кэш-линия загружается за один раз, а две — за два обращения к памяти.
Вы можете проверить это, добавив выравнивание:
use std::alloc::Layout;
fn main() {
let size = 240;
let layout = Layout::from_size_align(size * std::mem::size_of::(), 64).unwrap();
let ptr = unsafe { std::alloc::alloc(layout) } as *mut i32;
let arr = unsafe { std::slice::from_raw_parts_mut(ptr, size) };
for i in 0..size { arr[i] = i as i32; }
let start = std::time::Instant::now();
let sum: i32 = arr.iter().sum();
println!("{:?}", start.elapsed());
}
С выравниванием на 64 байта разрыв между 239 и 240 может уменьшиться, но не исчезнуть полностью. Основная причина всё же в разворачивании циклов, но кэш добавляет до 20–30% к разнице. Рекомендую комбинировать оба подхода: выравнивание данных и оптимизацию циклов.
Ответов пока нет.
Войдите чтобы оставить ответ