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

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Тайна 240 элементов: почему ваш код на Rust внезапно тормозит?

Представьте ситуацию: вы пишете высокопроизводительный код на Rust, всё летает. Вы тестируете алгоритм на массиве из 200 элементов — отлично. Добавляете ещё 40 элементов (итого 240) — и производительность падает в 2-3 раза. Звучит как магия? Нет, это физика. Точнее — архитектура современных процессоров.

На Stack Overflow вопрос "Why is there a large performance impact when looping over an array with 240 or more elements?" собрал тысячи просмотров и стал классикой. В 2025 году, с появлением новых микроархитектур и усложнением кэш-иерархии, понимание этой проблемы критически важно для любого Rust-разработчика.

В этой статье мы не просто перескажем ответ. Мы покажем, как это выглядит в Rust, как диагностировать проблему и, главное, — как её избежать, используя современные практики 2025 года.

Корень зла: кэш-промахи (Cache Misses)

Процессор не работает напрямую с оперативной памятью (RAM). Он использует кэш — сверхбыструю память на кристалле. Данные подгружаются в кэш блоками, которые называются cache lines (обычно 64 байта).

Магия числа 240 связана с размером кэша L1 (обычно 32 КБ для данных) и размером элемента. Если массив i32 (4 байта), то 240 элементов занимают 960 байт. Казалось бы, это помещается в L1. Но проблема в том, что алгоритм может работать с двумя-тремя массивами одновременно, или компилятор не может эффективно векторизовать код, что приводит к промахам кэша (cache misses).

В оригинальном вопросе на C++ порог был именно 240 элементов, потому что он упирался в особенности предвыборки (prefetching) и размера кэша L1/L2.

Как это выглядит в Rust?

Давайте смоделируем ситуацию. Мы будем суммировать элементы массива, но с "шагом", который ломает кэш.

use std::time::Instant;

fn main() {
    // Массив из 240 элементов
    let size = 240;
    let data: Vec<i32> = (0..size).collect();
    
    // Холодный запуск
    let start = Instant::now();
    let sum: i32 = data.iter().sum();
    let duration = start.elapsed();
    println!("240 элементов (последовательный): {:?}", duration);
    
    // Теперь сделаем "плохой" доступ с шагом 64 (размер cache line)
    let mut sum2 = 0i32;
    let start = Instant::now();
    for i in (0..size).step_by(64) {
        sum2 += data[i];
    }
    let duration2 = start.elapsed();
    println!("240 элементов (шаг 64): {:?}", duration2);
}

Вы удивитесь, но разница может быть в 10 раз! Первый цикл использует последовательный доступ — процессор предсказывает и загружает cache lines заранее. Второй — произвольный доступ с большим шагом, который выбивает из кэша.

Почему именно 240? Глубинная причина

В 2025 году процессоры имеют кэш L1 данных от 32 КБ до 64 КБ. Но проблема не просто в размере. Дело в ассоциативности кэша (set-associative cache).

Представьте кэш как библиотеку с 64 полками (sets). На каждой полке может лежать 8 книг (ways). Когда процессор обращается к памяти, он вычисляет "номер полки" по адресу. Если на полке уже есть 8 книг, одну придётся выкинуть.

Для массивов, размер которых кратен степени двойки (например, 256), все элементы могут попадать на одну и ту же "полку", вызывая cache conflict misses. 240 — это 256 минус 16, что ломает эту кратность и... парадоксальным образом делает ситуацию ещё хуже из-за особенностей предвыборки.

Практический тест в Rust

use std::time::Instant;

fn bench_access(size: usize) {
    let data: Vec<i32> = (0..size as i32).collect();
    
    // "Плохой" паттерн: доступ к каждому 16-му элементу
    let mut sum = 0i32;
    let start = Instant::now();
    for i in (0..size).step_by(16) {
        sum += data[i];
    }
    let dur = start.elapsed();
    
    println!("Размер: {}, время: {:?}, сумма: {}", size, dur, sum);
}

fn main() {
    // Сравним 224, 240, 256, 288
    for &size in [224, 240, 256, 288].iter() {
        bench_access(size);
    }
}

На современных процессорах (Intel Core i7-14700K, 2025 год) вы увидите:

  • 224 элемента — быстро (укладывается в L1)
  • 240 элементов — заметно медленнее (критический порог)
  • 256 элементов — может быть быстрее 240! (из-за выравнивания)
  • 288 элементов — снова медленно

Это и есть тот самый "performance cliff" — обрыв производительности.

Лучшие практики 2025 года для Rust

Теперь, когда мы понимаем физику процесса, давайте посмотрим, как писать код, который не будет страдать от этой проблемы.

1. Используйте итераторы и адаптеры (они умнее, чем вы)

Компилятор Rust (особенно с флагом -C target-cpu=native) отлично оптимизирует итераторы. Он может автоматически применять SIMD (Single Instruction, Multiple Data) и векторизацию.

use std::time::Instant;

fn main() {
    let data: Vec<i32> = (0..1000).collect();
    
    // Плохо: ручной цикл с индексами
    let start = Instant::now();
    let mut sum1 = 0;
    for i in 0..data.len() {
        sum1 += data[i];
    }
    let dur1 = start.elapsed();
    
    // Хорошо: итератор
    let start = Instant::now();
    let sum2: i32 = data.iter().sum();
    let dur2 = start.elapsed();
    
    // Отлично: с явным SIMD (требует nightly или crate)
    // Но iter().sum() часто достаточно
    
    println!("Ручной цикл: {:?}", dur1);
    println!("Итератор: {:?}", dur2);
    assert_eq!(sum1, sum2);
}

Второй вариант не только короче, но и быстрее — компилятор может развернуть цикл и использовать SIMD-инструкции (AVX-512 на новых процессорах).

2. Используйте crate `packed_simd` или `std::simd` (стабильно с Rust 1.75+)

С 2025 года std::simd стабилизирован. Это даёт прямой контроль над векторизацией.

#![feature(portable_simd)] // или используйте crate
use std::simd::prelude::*;                

Блоги

Рекомендаций по книгам