При выполнении цикла суммы над массивом в 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());
}
arrays
performance
rust
llvm-codegen
Гай Корланд
источник
источник
Ответы:
Резюме : ниже 240 LLVM полностью развертывает внутренний цикл, и это позволяет ему заметить, что он может оптимизировать повторный цикл, ломая ваш эталонный тест.
Вы нашли магический порог, выше которого LLVM прекращает выполнение определенных оптимизаций . Порог составляет 8 байт * 240 = 1920 байт (ваш массив представляет собой массив
usize
s, поэтому длина умножается на 8 байт, предполагая, что процессор x86-64). В этом тесте одна конкретная оптимизация - только для длины 239 - отвечает за огромную разницу в скорости. Но давайте начнем медленно:(Весь код в этом ответе скомпилирован
-C opt-level=3
)Этот простой код будет производить примерно ожидаемую сборку: цикл, добавляющий элементы. Однако, если вы измените
240
на239
, излучаемая сборка сильно отличается. Смотрите это на Godbolt Compiler Explorer . Вот небольшая часть сборки:Это то, что называется развертыванием цикла : LLVM вставляет тело цикла много времени, чтобы избежать необходимости выполнять все эти «инструкции по управлению циклом», т.е. увеличивать переменную цикла, проверять, завершился ли цикл, и переходить к началу цикла. ,
В случае, если вам интересно:
paddq
и подобные инструкции являются SIMD-инструкциями, которые позволяют суммировать несколько значений параллельно. Кроме того, два 16-байтовых регистра SIMD (xmm0
иxmm1
) используются параллельно, так что параллелизм ЦП на уровне команд может в основном выполнять две из этих команд одновременно. Ведь они независимы друг от друга. В конце оба регистра складываются вместе и затем суммируются по горизонтали до скалярного результата.Современные основные x86-процессоры (не Atom с низким энергопотреблением) действительно могут выполнять 2 векторных загрузки за такт, когда они попадают в кэш L1d, и
paddq
пропускная способность также составляет не менее 2 на такт, с задержкой в 1 цикл на большинстве процессоров. См. Https://agner.org/optimize/, а также этот раздел вопросов и ответов о нескольких аккумуляторах, чтобы скрыть задержку (FP FMA для точечного продукта) и узкое место по пропускной способности.LLVM делает раскатать маленькую петлю некоторые , когда он не в полной мере разворачивание, и до сих пор использует несколько аккумуляторов. Поэтому, как правило, узкие места полосы пропускания и задержек на стороне сервера не являются большой проблемой для циклов, генерируемых LLVM, даже без полного развертывания.
Но развертывание цикла не несет ответственности за разницу производительности в 80 раз! По крайней мере, не раскручивание петли в одиночку. Давайте посмотрим на реальный код тестирования, который помещает один цикл в другой:
( В Godbolt Explorer Compiler Explorer )
Сборка для
CAPACITY = 240
выглядит нормально: две вложенные петли. (В начале функции есть довольно небольшой код для инициализации, который мы проигнорируем.) Однако для 239 это выглядит совсем иначе! Мы видим, что цикл инициализации и внутренний цикл были развернуты: до сих пор так ожидалось.Важным отличием является то, что для 239 LLVM удалось выяснить, что результат внутреннего цикла не зависит от внешнего цикла! Как следствие, LLVM испускает код, который в основном сначала выполняет только внутренний цикл (вычисляя сумму), а затем моделирует внешний цикл, складывая
sum
кучу раз!Сначала мы видим почти ту же сборку, что и выше (сборка, представляющая внутренний цикл). После этого мы видим это (я прокомментировал, чтобы объяснить сборку; комментарии с
*
особенно важны):Как вы можете видеть здесь, результат внутреннего цикла берется, складывается так часто, как внешний цикл запускался, а затем возвращался. LLVM может выполнять эту оптимизацию только потому, что понимает, что внутренний цикл не зависит от внешнего.
Это означает, что время выполнения меняется с
CAPACITY * IN_LOOPS
наCAPACITY + IN_LOOPS
. И это ответственно за огромную разницу в производительности.Дополнительное примечание: вы можете что-нибудь с этим сделать? На самом деле, нет. У LLVM должны быть такие магические пороги, что без них LLVM-оптимизация могла бы длиться вечно для определенного кода. Но мы также можем согласиться с тем, что этот код был очень искусственным. На практике я сомневаюсь, что такая огромная разница будет иметь место. Разница, связанная с развертыванием полного цикла, в этих случаях обычно даже не равна коэффициенту 2. Так что не нужно беспокоиться о реальных случаях использования.
Последнее замечание об идиоматическом коде Rust:
arr.iter().sum()
это лучший способ суммировать все элементы массива. И изменение этого во втором примере не приводит к заметным различиям в испускаемой сборке. Вы должны использовать короткие и идиоматические версии, если только вы не измерили, что это ухудшает производительность.источник
sum
напрямую не на локальном компьютере,s
работал намного медленнее.for i in 0..arr.len() { sum += arr[i]; }
Instant
предотвращения этого?В дополнение к ответу Лукаса, если вы хотите использовать итератор, попробуйте это:
Спасибо @Chris Morgan за предложение о шаблоне диапазона.
Оптимизированная сборка довольно хорошо:
источник
(0..CAPACITY).sum::<usize>() * IN_LOOPS
, что дает тот же результат.rustc
упускаю возможность сделать это снижение силы. Однако в этом конкретном контексте это похоже на цикл синхронизации, и вы намеренно хотите, чтобы он не был оптимизирован. Весь смысл в том, чтобы повторить вычисление это число раз с нуля и поделить на количество повторений. В C (неофициальной) формой для этого является объявление счетчика цикла какvolatile
, например, счетчика BogoMIPS в ядре Linux. Есть ли способ достичь этого в Rust? Может быть, но я этого не знаю. Вызов внешнегоfn
может помочь.volatile
заставляет эту память синхронизироваться. Применение его к счетчику цикла вызывает только фактическую перезагрузку / сохранение значения счетчика цикла. Это напрямую не влияет на тело цикла. Вот почему лучше использовать его, как правило, присваивая фактический важный результатvolatile int sink
или что-либо либо после цикла (если есть зависимость, переносимая в цикле), либо после каждой итерации, чтобы компилятор мог оптимизировать счетчик цикла, как ему хотелось бы, но принудительно заставить его материализовать результат, который вы хотите в регистр, чтобы он мог сохранить его.