Avatar ·

Почему в Rust цикл по массиву из 240+ элементов работает в 10 раз медленнее?

Приветствую, коллеги. Я изучаю Rust и столкнулся с необъяснимым, на мой взгляд, поведением. Написал простой бенчмарк: суммирую элементы массива в цикле `for`. Для массивов размером до 239 элементов всё летает (менее 1 мкс). Как только размер достигает 240, время выполнения скачкообразно возрастает до 10–15 мкс. Дальнейший рост размера даёт плавное увеличение времени, но этот первый скачок в 10 раз меня озадачил. Вот код, который я тестирую: ```rust use std::time::Instant; fn sum_array(arr: &[i32]) -> i32 { let mut sum = 0; for &x in arr { sum += x; } sum } fn main() { let size = 240; let arr: Vec = (0..size).collect(); let start = Instant::now(); let _ = sum_array(&arr); println!("{:?}", start.elapsed()); } ``` Я компилирую с `cargo build --release`. Пробовал менять тип элементов на `i64` — эффект сохраняется. Пробовал разные размеры: 239 — быстро, 240 — медленно. В чём может быть дело? Это особенность LLVM? Или Rust каким-то образом оптимизирует маленькие массивы? Буду благодарен за объяснение и пути решения. <<>>

Причина: оптимизация циклов с помощью разворачивания (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% к разнице. Рекомендую комбинировать оба подхода: выравнивание данных и оптимизацию циклов.