Порівняння швидкодії: цикли проти ітераторів

Щоб визначити, використовувати цикли чи ітератори, вам треба знати, яка реалізація швидша: версія функції 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, що означає, що абстракція не накладає додаткових витрат часу виконання. Це аналогічно тому, як Б'ярне Строуструп, оригінальний дизайнер і реалізатор 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, які допоможуть нам поділитися проєктом зі світом.