Я вижу неожиданно низкую производительность для простого цикла хранилища, который имеет два магазина: один с шагом вперед 16 байт и один, который всегда находится в одном и том же месте 1 например:
volatile uint32_t value;
void weirdo_cpp(size_t iters, uint32_t* output) {
uint32_t x = value;
uint32_t *rdx = output;
volatile uint32_t *rsi = output;
do {
*rdx = x;
*rsi = x;
rdx += 4; // 16 byte stride
} while (--iters > 0);
}
В сборке этот цикл, вероятно, 3 выглядит следующим образом:
weirdo_cpp:
...
align 16
.top:
mov [rdx], eax ; stride 16
mov [rsi], eax ; never changes
add rdx, 16
dec rdi
jne .top
ret
Когда область доступа к памяти находится в L2, я ожидаю, что она будет работать менее чем за 3 цикла на итерацию. Второй магазин просто продолжает попадать в одно и то же место и должен добавить цикл. Первый магазин подразумевает вхождение линии из L2 и, следовательно, выселение линии каждые 4 итерации. Я не уверен, как вы оцениваете стоимость L2, но даже если вы консервативно оцениваете, что L1 может выполнять только один из следующих циклов: (a) зафиксировать хранилище или (b) получить строку из L2 или (c) высекайте линию до L2, вы получите что-то вроде 1 + 0,25 + 0,25 = 1,5 цикла для потока хранилища stride-16.
В самом деле, вы закомментируете один магазин, вы получаете ~ 1.25 циклов на итерацию только для первого магазина и ~ 1.01 циклов на итерацию для второго хранилища, поэтому 2.5 цикла на итерацию кажутся консервативной оценкой.
Однако фактическая производительность очень странная. Здесь типичный прогон испытательного жгута:
Estimated CPU speed: 2.60 GHz
output size : 64 KiB
output alignment: 32
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.89 cycles/iter, 1.49 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
4.73 cycles/iter, 1.81 ns/iter, cpu before: 0, cpu after: 0
7.33 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.33 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.34 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.26 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.31 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.29 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.29 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.27 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.30 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.30 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
Две вещи здесь странные.
Во-первых, это бимодальные тайминги: есть быстрый режим и медленный режим. Мы начинаем в медленном режиме, занимая около 7,3 цикла на итерацию, а в какой-то момент переходим к примерно 3,9 циклам на итерацию. Такое поведение является последовательным и воспроизводимым, и два тайминга всегда довольно согласованы вокруг двух значений. Переход проявляется в обоих направлениях от медленного режима до быстрого режима и наоборот (а иногда и нескольких переходов за один проход).
Другая странная вещь - очень плохая производительность. Даже в быстром режиме, примерно в 3,9 цикла, производительность намного хуже, чем максимальный эффект 1.0 + 1.3 = 2.3, который вы ожидаете от добавления каждого из этих случаев с одним хранилищем (и считая, что абсолютно нулевое произведение может перекрываться когда оба магазина находятся в цикле). В медленном режиме производительность страшна по сравнению с тем, что вы ожидаете, основываясь на первых принципах: она занимает 7,3 цикла, чтобы сделать 2 магазина, и если вы поместили их в L2, то это примерно 29 циклов за хранилище L2 (поскольку мы сохраняем только одну полную строку кэша каждые 4 итерации).
Skylake записан как имеющий пропускную способность 64B/цикл между L1 и L2, что намного выше наблюдаемой пропускной способности (около 2 байтов)/цикл в медленном режиме).
Что объясняет низкую пропускную способность и бимодальную производительность, и я могу избежать этого?
Мне также интересно, если это воспроизводится на других архитектурах и даже на других блоках Skylake. Не стесняйтесь включать локальные результаты в комментарии.
Вы можете найти тестовый код и использовать его в github. Существует платформа Makefile
для Linux или Unix-подобных платформ, но ее также следует относительно легко создавать на Windows. Если вы хотите запустить вариант asm
, вам понадобится nasm
или yasm
для сборки 4 - если у вас нет этого, вы можете просто попробовать версию на С++.
Исключенные возможности
Вот некоторые возможности, которые я рассмотрел и в значительной степени устранил. Многие из возможностей устраняются простым фактом, что вы случайно видите переход производительности в середине цикла бенчмаркинга, когда многие вещи просто не изменились (например, если это было связано с выравниванием выходного массива, оно не могло изменение в середине прогона, так как все время используется тот же буфер). Я упоминаю об этом как устранение по умолчанию ниже (даже для тех вещей, которые по умолчанию устраняются, часто возникает другой аргумент).
- Факторы выравнивания: выходной массив равен 16 байтам, и я попытался выполнить выравнивание до 2 МБ без изменений. Также устраняется устранение по умолчанию.
- Конфликт с другими процессами на машине: эффект наблюдается более или менее одинаково на холостом компьютере и даже на сильно загруженном (например, используя
stress -vm 4
). Сам тест должен быть полностью локально-локальным, так как он соответствует L2, аperf
подтверждает, что на итерацию очень мало пропусков L2 (около 1 пропустить каждые 300-400 итераций, вероятно, связанных с кодомprintf
). - TurboBoost: TurboBoost полностью отключен, подтвержденный тремя различными показаниями МГц.
- Энергосберегающий материал: регулятор производительности
intel_pstate
в режимеperformance
. Во время теста не наблюдаются изменения частоты (процессор остается практически заблокированным на 2,59 ГГц). - Эффекты TLB: эффект присутствует, даже если выходной буфер расположен на огромной странице размером 2 МБ. В любом случае, записи 64 4k TLB больше, чем покрытие выходного буфера 128K.
perf
не сообщает о каких-либо особенно странных действиях TLB. - 4k aliasing: более старые версии с более сложными версиями этого теста показали некоторое сглаживание на 4k, но это было устранено, так как в эталоне нет загрузок (он загружает, что может неправильно использовать предыдущие хранилища). Также устраняется устранение по умолчанию.
- Конфликты ассоциативности L2: устранены устранением по умолчанию и тем фактом, что это не исчезает даже с 2MB-страницами, где мы можем быть уверены, что выходной буфер выстроен линейно в физической памяти.
- Эффекты гиперпотока: HT отключен.
- Предварительная выборка: здесь могут быть задействованы только два из prefetchers ( "DCU", а также L1 ↔ L2 prefetchers), поскольку все данные хранятся в L1 или L2, но производительность одинакова со всеми включенными префаймерами или все отключены.
- Прерывания: нет корреляции между количеством прерываний и медленным режимом. Существует ограниченное количество полных прерываний, в основном, тиков часов.
toplev.py
Я использовал toplev.py, который реализует Intel Top Down метод анализа, и неудивительно, что он идентифицирует контрольный показатель как привязанный к магазину:
BE Backend_Bound: 82.11 % Slots [ 4.83%]
BE/Mem Backend_Bound.Memory_Bound: 59.64 % Slots [ 4.83%]
BE/Core Backend_Bound.Core_Bound: 22.47 % Slots [ 4.83%]
BE/Mem Backend_Bound.Memory_Bound.L1_Bound: 0.03 % Stalls [ 4.92%]
This metric estimates how often the CPU was stalled without
loads missing the L1 data cache...
Sampling events: mem_load_retired.l1_hit:pp mem_load_retired.fb_hit:pp
BE/Mem Backend_Bound.Memory_Bound.Store_Bound: 74.91 % Stalls [ 4.96%] <==
This metric estimates how often CPU was stalled due to
store memory accesses...
Sampling events: mem_inst_retired.all_stores:pp
BE/Core Backend_Bound.Core_Bound.Ports_Utilization: 28.20 % Clocks [ 4.93%]
BE/Core Backend_Bound.Core_Bound.Ports_Utilization.1_Port_Utilized: 26.28 % CoreClocks [ 4.83%]
This metric represents Core cycles fraction where the CPU
executed total of 1 uop per cycle on all execution ports...
MUX: 4.65 %
PerfMon Event Multiplexing accuracy indicator
На самом деле это не проливает много света: мы уже знали, что это, должно быть, магазины, которые могут испортить вещи, но почему? Описание Intel условия не говорят много.
Ниже приведено разумное резюме некоторых проблем, связанных с взаимодействием L1-L2.
1 Это очень упрощенная MCVE моей первоначальной петли, которая была по крайней мере в 3 раза больше размера и сделала много дополнительной работы, но показала ту же производительность, что и эта простая версия, узкое место по той же загадочной проблеме.
3 В частности, это выглядит так, как если вы пишете сборку вручную или компилируете ее с помощью gcc -O1
(версия 5.4.1) и, возможно, наиболее разумных компиляторов (volatile
используется, чтобы избежать погружения в основном мертвого второго магазина за пределы цикла).
4 Без сомнения, вы можете преобразовать это в синтаксис MASM с небольшими изменениями, поскольку сборка настолько тривиальна. Принимать запросы на передачу.