Чому під час циклічного перегляду масиву з 240 і більше елементами великий вплив на продуктивність?


230

Під час запуску циклу суми над масивом в Rust, я помітив величезний спад продуктивності, коли CAPACITY> = 240. CAPACITY= 239 приблизно в 80 разів швидше.

Чи є спеціальна оптимізація компіляції, яку Rust робить для "коротких" масивів?

Укладено з rustc -C opt-level=3.

use std::time::Instant;

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

fn main() {
    let mut arr = [0; CAPACITY];
    for i in 0..CAPACITY {
        arr[i] = i;
    }
    let mut sum = 0;
    let now = Instant::now();
    for _ in 0..IN_LOOPS {
        let mut s = 0;
        for i in 0..arr.len() {
            s += arr[i];
        }
        sum += s;
    }
    println!("sum:{} time:{:?}", sum, now.elapsed());
}


4
Можливо, з 240 ви переповнюєте рядок кешу CPU? Якщо це так, ваші результати будуть дуже специфічними для процесора.
rodrigo

11
Відтворено тут . Тепер я здогадуюсь, що це має щось спільне з розкручуванням циклу.
rodrigo

Відповіді:


355

Підсумок : нижче 240, LLVM повністю розгортає внутрішній цикл, і це дозволяє помітити, що він може оптимізувати повторний цикл, порушуючи ваш показник.



Ви знайшли чарівний поріг, над яким LLVM перестає виконувати певні оптимізації . Поріг становить 8 байт * 240 = 1920 байт (ваш масив - це масив usizes, тому довжина множиться на 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()це кращий спосіб підбити підсумки всіх елементів масиву. І зміна цього у другому прикладі не призводить до жодних помітних відмінностей у складі випромінюваної збірки. Ви повинні використовувати короткі та ідіоматичні версії, якщо ви не вимірювали, що це шкодить продуктивності.


2
@ lukas-kalbertodt дякую за чудову відповідь! Тепер я також розумію, чому оригінальний код, який оновлювався sumбезпосередньо на локальному, sпрацював набагато повільніше. for i in 0..arr.len() { sum += arr[i]; }
Гай Корланд

4
@LukasKalbertodt Щось інше відбувається в LLVM, увімкнення AVX2 не повинно мати великої різниці. Repro'd також у іржі
Mgetz

4
@Mgetz Цікаво! Але мені здається не надто божевільним, щоб зробити цей поріг залежним від наявних інструкцій SIMD, оскільки це в кінцевому підсумку визначає кількість інструкцій у повністю розкрученому циклі. Але, на жаль, не можу сказати точно. Було б солодко, щоб на це відповів розробник LLVM.
Лукас Кальбертт

7
Чому компілятор або LLVM не усвідомлюють, що весь розрахунок можна зробити за час компіляції? Я б очікував, що результат циклу буде жорстким. Або використання Instantзапобігання цьому?
Некритичне ім’я

4
@JosephGarvin: Я припускаю, що це відбувається тому, що повністю розгортання відбувається, щоб дати можливість пізніше пройти оптимізацію, щоб побачити це. Пам'ятайте, що оптимізація компіляторів все ще піклується про швидку компіляцію, а також про створення ефективної ASM, тому їм доводиться обмежувати найгірший складність будь-якого аналізу, який вони роблять, так що не потрібно годин / днів, щоб скласти якийсь неприємний вихідний код із складними циклами . Але так, це, очевидно, пропущена оптимізація для розміру> = 240. Цікаво, якщо не оптимізувати віддалені петлі всередині циклів, щоб уникнути зламу простих орієнтирів? Напевно, ні, але можливо.
Пітер Кордес

30

Окрім відповіді Лукаша, якщо ви хочете використовувати ітератор, спробуйте:

const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;

pub fn bar() -> usize {
    (0..CAPACITY).sum::<usize>() * IN_LOOPS
}

Дякую @Chris Morgan за пропозицію щодо діапазону діапазону.

Оптимізована збірка досить добре:

example::bar:
        movabs  rax, 14340000000
        ret

3
Або ще краще (0..CAPACITY).sum::<usize>() * IN_LOOPS, що дає такий же результат.
Кріс Морган

11
Я б фактично пояснив, що збірка насправді не робить розрахунок, але LLVM попередньо обчислила відповідь у цьому випадку.
Йозеп

Я дуже здивований, що rustcне вистачає можливості зробити це зменшення сили. У цьому конкретному контексті це, мабуть, є тимчасовим циклом, і ви свідомо хочете, щоб його не оптимізували. Вся справа в тому, щоб повторити обчислення стільки разів з нуля, і розділити на кількість повторень. У C (неофіційна) ідіома для цього полягає в оголошенні лічильника циклу як volatile, наприклад, лічильник BogoMIPS в ядрі Linux. Чи є спосіб досягти цього в Іржі? Можливо, є, але я цього не знаю. Виклик зовнішньої fnможе допомогти.
Девіслор

1
@Davislor: volatileпримушує синхронізувати цю пам'ять. Застосування його до лічильника циклу примушує фактично перезавантажувати / зберігати значення лічильника циклу. Це не впливає безпосередньо на тіло петлі. Ось чому кращим способом його використання є присвоєння фактично важливого результату volatile int sinkабо чомусь після циклу (якщо є залежність, що переноситься циклом) або кожної ітерації, щоб дозволити компілятору оптимізувати лічильник циклу, проте він хоче, але примушує його матеріалізувати потрібний результат в реєстрі, щоб він міг його зберігати.
Пітер Кордес

1
@Davislor: Я думаю, що у Rust є вбудований синтаксис asm чимось на зразок GNU C. Ви можете використовувати inline asm, щоб змусити компілятор матеріалізувати значення в реєстрі, не змушуючи його зберігати. Використання цього в результаті кожного циклу ітерація може запобігти його оптимізації. (Але також від автоматичного векторизації, якщо ви не обережні). наприклад, еквівалент "Escape" та "Clobber" в MSVC пояснює 2 макроси (запитуючи, як перенести їх у MSVC, що насправді неможливо) та посилання на розмову Чендлера Каррута, де він показує їх використання.
Пітер Кордес
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.