Підсумок : нижче 240, LLVM повністю розгортає внутрішній цикл, і це дозволяє помітити, що він може оптимізувати повторний цикл, порушуючи ваш показник.
Ви знайшли чарівний поріг, над яким LLVM перестає виконувати певні оптимізації . Поріг становить 8 байт * 240 = 1920 байт (ваш масив - це масив usize
s, тому довжина множиться на 8 байт, якщо вважати процесор x86-64). У цьому орієнтирі одна особлива оптимізація - виконується лише на довжину 239 - відповідає за величезну різницю швидкостей. Але почнемо повільно:
(Весь код у цій відповіді складено з -C opt-level=3
)
pub fn foo() -> usize {
let arr = [0; 240];
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
s
}
Цей простий код створить приблизно збірку, яку можна було б очікувати: цикл, що додає елементи. Однак, якщо ви перейдете 240
до цього 239
, випромінювана збірка відрізняється досить сильно. Дивіться це в Провіднику компілятора Godbolt . Ось невелика частина збірки:
movdqa xmm1, xmmword ptr [rsp + 32]
movdqa xmm0, xmmword ptr [rsp + 48]
paddq xmm1, xmmword ptr [rsp]
paddq xmm0, xmmword ptr [rsp + 16]
paddq xmm1, xmmword ptr [rsp + 64]
; more stuff omitted here ...
paddq xmm0, xmmword ptr [rsp + 1840]
paddq xmm1, xmmword ptr [rsp + 1856]
paddq xmm0, xmmword ptr [rsp + 1872]
paddq xmm0, xmm1
pshufd xmm1, xmm0, 78
paddq xmm1, xmm0
Це те, що називається розгортання циклу : LLVM вставляє тіло циклу купу часу, щоб уникнути необхідності виконувати всі ці "інструкції з управління циклом", тобто збільшуючи змінну циклу, перевірити, чи закінчилася цикл і перейти до початку циклу. .
У випадку, якщо вам цікаво: paddq
та подібні інструкції - це інструкції SIMD, які дозволяють паралельно підсумовувати кілька значень. Крім того, два 16-байтові регістри ( xmm0
і xmm1
) SIMD використовуються паралельно, щоб паралелізм CPU на рівні інструкцій в основному може одночасно виконувати дві ці інструкції. Адже вони незалежні одна від одної. Зрештою, обидва регістри складаються разом, а потім горизонтально підсумовуються до скалярного результату.
Сучасні основні процесори x86 (не з низькою потужністю Atom) дійсно можуть виконувати 2 векторні навантаження на годину, коли вони потрапляють у кеш L1d, а paddq
пропускна здатність також не менше 2 за такт, затримка 1 циклу на більшості процесорів. Див. Https://agner.org/optimize/, а також це питання щодо декількох акумуляторів, щоб приховати затримку (FP FMA для крапкового продукту) та вузьке місце щодо пропускної здатності.
LLVM робить розкачати маленьку петлю деякі , коли він не в повній мірі розгортання, і до сих пір використовує кілька акумуляторів. Тому зазвичай пропускна здатність передньої та зворотної затримки на задньому кінці не є великою проблемою для циклів, що генеруються LLVM, навіть без повного розгортання.
Але розгортання циклу не відповідає за різницю продуктивності коефіцієнта 80! Принаймні, не розкручуючи петлю поодинці. Давайте розглянемо власне код бенчмаркінгу, який ставить одну петлю всередині іншої:
const CAPACITY: usize = 239;
const IN_LOOPS: usize = 500000;
pub fn foo() -> usize {
let mut arr = [0; CAPACITY];
for i in 0..CAPACITY {
arr[i] = i;
}
let mut sum = 0;
for _ in 0..IN_LOOPS {
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
sum += s;
}
sum
}
( Провідник компілятора Godbolt )
Збірка для CAPACITY = 240
виглядає нормально: дві вкладені петлі. (На початку функції існує досить якийсь код лише для ініціалізації, який ми ігноруємо.) Для 239, однак, це виглядає зовсім інакше! Ми бачимо, що цикл ініціалізації та внутрішній цикл розгорнулися: поки що очікували.
Важлива відмінність полягає в тому, що за 239 LLVM змогла зрозуміти, що результат внутрішньої петлі не залежить від зовнішньої петлі! Як наслідок, LLVM випускає код, який в основному спочатку виконує лише внутрішній цикл (обчислення суми), а потім імітує зовнішній цикл шляхом додаванняsum
купу разів!
Спочатку ми бачимо майже таку ж збірку, як і вище (збірка, що представляє внутрішню петлю). Потім ми бачимо це (я прокоментував пояснення зборів; коментарі з *
особливо важливими):
; at the start of the function, `rbx` was set to 0
movq rax, xmm1 ; result of SIMD summing up stored in `rax`
add rax, 711 ; add up missing terms from loop unrolling
mov ecx, 500000 ; * init loop variable outer loop
.LBB0_1:
add rbx, rax ; * rbx += rax
add rcx, -1 ; * decrement loop variable
jne .LBB0_1 ; * if loop variable != 0 jump to LBB0_1
mov rax, rbx ; move rbx (the sum) back to rax
; two unimportant instructions omitted
ret ; the return value is stored in `rax`
Як ви бачите тут, результат внутрішнього циклу приймається, додається так само часто, як зовнішня петля пробігав би і потім повертався. LLVM може виконати цю оптимізацію лише тому, що він розумів, що внутрішня петля не залежить від зовнішньої.
Це означає, що час виконання змінюється з CAPACITY * IN_LOOPS
наCAPACITY + IN_LOOPS
. І це відповідає за величезну різницю в продуктивності.
Додаткова примітка: чи можете ви щось з цим зробити? Не зовсім. LLVM повинен мати такі магічні пороги, що без них LLVM-оптимізація може тривати назавжди для певного коду. Але ми можемо також погодитися, що цей код був дуже штучним. На практиці я сумніваюся, що відбудеться така величезна різниця. Різниця внаслідок розгортання повного циклу зазвичай не є навіть фактором 2 у цих випадках. Тож не потрібно турбуватися про справжні випадки використання.
Як остання примітка про ідіоматичний код Rust: arr.iter().sum()
це кращий спосіб підбити підсумки всіх елементів масиву. І зміна цього у другому прикладі не призводить до жодних помітних відмінностей у складі випромінюваної збірки. Ви повинні використовувати короткі та ідіоматичні версії, якщо ви не вимірювали, що це шкодить продуктивності.