Почему при зацикливании массива с 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());
}
Гай Корланд
источник
github.com/gkorland/benchmark-rust
Гай Корланд,
4
Может быть, с 240 вы переполняете строку кэша процессора? Если это так, ваши результаты будут зависеть от процессора.
Родриго
11
Воспроизводится здесь . Теперь я предполагаю, что это как-то связано с развертыванием цикла.
Родриго

Ответы:

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 Compiler Explorer . Вот небольшая часть сборки:

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-байтовых регистра SIMD ( xmm0и xmm1) используются параллельно, так что параллелизм ЦП на уровне команд может в основном выполнять две из этих команд одновременно. Ведь они независимы друг от друга. В конце оба регистра складываются вместе и затем суммируются по горизонтали до скалярного результата.

Современные основные 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 Explorer Compiler Explorer )

Сборка для 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: Полагаю, это происходит из-за того, что полная развертка позволяет более позднему этапу оптимизации увидеть это. Помните, что оптимизирующие компиляторы по-прежнему заботятся о быстрой компиляции, а также о создании эффективного ассемблера, поэтому им приходится ограничивать сложность наихудшего случая любого анализа, который они делают, чтобы компиляция некоторого неприятного исходного кода со сложными циклами не занимала часы / дни , Но да, это, очевидно, пропущенная оптимизация для размера> = 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. Есть ли способ достичь этого в Rust? Может быть, но я этого не знаю. Вызов внешнего fnможет помочь.
Дэвислор
1
@Davislor: volatileзаставляет эту память синхронизироваться. Применение его к счетчику цикла вызывает только фактическую перезагрузку / сохранение значения счетчика цикла. Это напрямую не влияет на тело цикла. Вот почему лучше использовать его, как правило, присваивая фактический важный результат volatile int sinkили что-либо либо после цикла (если есть зависимость, переносимая в цикле), либо после каждой итерации, чтобы компилятор мог оптимизировать счетчик цикла, как ему хотелось бы, но принудительно заставить его материализовать результат, который вы хотите в регистр, чтобы он мог сохранить его.
Питер Кордес
1
@Davislor: Я думаю, что у Rust есть встроенный синтаксис asm, похожий на GNU C. Вы можете использовать встроенный asm, чтобы заставить компилятор материализовать значение в регистре, не заставляя его хранить его. Использование этого в результате каждой итерации цикла может остановить его от оптимизации. (Но также и от векторизации, если вы не будете осторожны). например, эквивалент «Escape» и «Clobber» в MSVC объясняет 2 макроса (при этом спрашивая, как перенести их в MSVC, что на самом деле невозможно) и ссылки на доклад Чендлера Каррута, где он показывает их использование.
Питер Кордес