Сравнение производительности: циклы и итераторы
Чтобы определить, что использовать — циклы или итераторы, — нужно знать, какая реализация быстрее: версия функции search с явным циклом for или версия с итераторами.
Мы провели бенчмарк, загрузив полное содержимое книги «Приключения Шерлока Холмса» сэра Артура Конана Дойля в String и выполнив поиск слова «the. Вот результаты для версии searchс цикломfor` и версии с итераторами:
test bench_search_for ... bench: 19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench: 19,234,900 ns/iter (+/- 657,200)
Обе реализации имеют схожую производительность! Мы не будем объяснять код бенчмарка, так как цель — не доказать полную эквивалентность, а получить общее представление о сравнении производительности.
Для более комплексного бенчмарка стоит использовать тексты разного размера в качестве contents, разные слова и слова разной длины в качестве query, а также другие варианты. Суть в следующем: итераторы, несмотря на высокоуровневую абстракцию, компилируются в код, примерно равный тому, который вы написали бы на низком уровне самостоятельно. Итераторы — это одна из абстракций нулевой стоимости Rust, под которой мы подразумеваем, что использование абстракции не несёт дополнительных накладных расходов во время выполнения. Это аналогично определению нулевых накладных расходов (zero-overhead) Бьёрном Страуструпом, оригинальным разработчиком и реализатором C++, в работе «Основы C++» (2012):
В целом, реализации C++ соблюдают принцип нулевых накладных расходов: то, что вы не используете, вы не платите за это. И далее: то, что вы используете, вы не можете написать вручную лучше.
В качестве другого примера приведён код из аудио-декодера. Алгоритм декодирования использует математическую операцию линейного предсказания для оценки будущих значений на основе линейной функции предыдущих сэмплов. Этот код использует цепочку итераторов для выполнения вычислений с тремя переменными в области видимости: срезом данных buffer, массивом из 12 coefficients и величиной сдвига qlp_shift. Мы объявили переменные в этом примере, но не задали им значения; хотя этот код имеет мало смысла вне контекста, он остаётся кратким реальным примером того, как Rust преобразует высокоуровневые идеи в низкоуровневый код.
let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;
for i in 12..buffer.len() {
let prediction = coefficients.iter()
.zip(&buffer[i - 12..i])
.map(|(&c, &s)| c * s as i64)
.sum::<i64>() >> qlp_shift;
let delta = buffer[i];
buffer[i] = prediction as i32 + delta;
}
Чтобы вычислить prediction, этот код проходит по каждому из 12 значений в coefficients и использует метод zip для сопоставления значений коэффициентов с предыдущими 12 значениями в buffer. Затем для каждой пары мы перемножаем значения, суммируем результаты и сдвигаем биты в сумме на qlp_shift бит вправо.
Вычисления в таких приложениях, как аудио-декодеры, часто ставят производительность на первое место. Здесь мы создаём итератор, используем два адаптера, а затем потребляем значение. В какой ассемблерный код скомпилируется этот Rust-код? На момент написания он компилируется в тот же ассемблерный код, который вы написали бы вручную. Здесь вообще нет цикла, соответствующего итерации по значениям в coefficients: Rust знает, что итераций 12, поэтому «разворачивает» цикл. Разворачивание — это оптимизация, которая удаляет накладные расходы на управление циклом и вместо этого генерирует повторяющийся код для каждой итерации цикла.
Все коэффициенты сохраняются в регистрах, что делает доступ к значениям очень быстрым. Нет проверок границ массива во время выполнения. Все эти оптимизации, которые Rust способен применить, делают итоговый код чрезвычайно эффективным. Теперь, когда вы это знаете, вы можете смело использовать итераторы и замыкания! Они делают код более высокоуровневым, но не добавляют штрафа за производительность во время выполнения.
Краткий итог
Замыкания и итераторы — это возможности Rust, вдохновлённые идеями функциональных языков программирования. Они способствуют способности Rust ясно выражать высокоуровневые идеи с производительностью низкого уровня. Реализации замыканий и итераторов устроены так, что не влияют на производительность во время выполнения. Это часть цели Rust — стремиться предоставлять абстракции нулевой стоимости.
Теперь, когда мы улучшили выразительность нашего проекта ввода-вывода, рассмотрим некоторые дополнительные возможности cargo, которые помогут нам поделиться проектом с миром.