Avatar ·

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

Привет, коллеги! Я изучаю Rust и столкнулся со странной проблемой производительности при переборе массивов. Написал простой бенчмарк, который суммирует элементы массива в цикле `for`. Для массивов размером меньше 240 элементов код выполняется очень быстро, но как только размер достигает 240 или больше, производительность резко падает — примерно в 10 раз. Вот мой код: ```rust 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 = std::time::Instant::now(); let result = sum_array(&arr); let duration = start.elapsed(); println!("Sum: {}, Time: {:?}", result, duration); } ``` Я пробовал менять `size` с 239 на 240, и разница колоссальная. Сначала думал, что это связано с кэшем процессора, но тогда падение должно быть плавным, а не скачкообразным. Использую Rust 1.75, компилирую с `--release`. Может, это баг LLVM? Или я неправильно меряю время? Подскажите, в чём дело и как это исправить. Заранее спасибо! <<>>

Причина: оптимизация цикла и 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() — это обходит проблему развёртки за счёт параллелизма.