Неожиданно плохая и странная бимодальная производительность для магазина в Intel Skylake
Я вижу неожиданно низкую производительность для простого цикла хранилища, в котором есть два хранилища: одно с шагом вперед 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);
}
В сборке этот цикл, вероятно, выглядит следующим образом:
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 цикла, а если поместить его в условия пропускной способности магазина L2, это примерно 29 циклов в магазине L2 (так как мы хранить только одну полную строку кэша каждые 4 итерации).
Skylake записан как имеющий пропускную способность 64B/ такт между L1 и L2, что намного выше, чем наблюдаемая здесь пропускная способность (около 2 байтов / такт в медленном режиме).
Чем объясняется низкая пропускная способность и бимодальная производительность и можно ли ее избежать?
Мне также любопытно, воспроизводится ли это на других архитектурах и даже на других коробках Skylake. Не стесняйтесь включать локальные результаты в комментарии.
Вы можете найти код теста и использовать его на github. E сть Makefile
для Linux или Unix-подобных платформ, но его также должно быть относительно легко собрать на Windows. Если вы хотите запустить asm
вариант вам понадобится nasm
или же yasm
для сборки 4 - если у вас ее нет, вы можете просто попробовать версию C++.
Исключенные возможности
Вот некоторые возможности, которые я рассмотрел и в значительной степени исключил. Многие из возможностей исключены тем простым фактом, что вы видите случайный переход производительности в середине цикла тестирования, когда многие вещи просто не изменились (например, если это было связано с выравниванием выходного массива, оно не могло изменение в середине цикла, поскольку один и тот же буфер используется все время). Я буду ссылаться на это как на исключение по умолчанию ниже (даже для вещей, которые являются устранением по умолчанию, часто есть другой аргумент, который нужно сделать).
- Факторы выравнивания: выходной массив выровнен на 16 байт, и я попытался выровнять до 2 МБ без изменений. Также устраняется устранением по умолчанию.
- Конфликт с другими процессами на машине: эффект наблюдается более или менее одинаково на простаивающей машине и даже на сильно загруженной (например, при использовании
stress -vm 4
). Сам тест должен быть полностью локальным в любом случае, так как он подходит для L2, иperf
подтверждает, что на одну итерацию приходится очень мало пропусков L2 (около 1 пропуска на каждые 300-400 итераций, вероятно, связанных сprintf
код). - TurboBoost: TurboBoost полностью отключен, что подтверждается тремя различными значениями МГц.
- Энергосберегающие устройства: регулятор производительности
intel_pstate
вperformance
Режим. Во время теста изменений частоты не наблюдается (процессор остается практически заблокированным на 2,59 ГГц). - Эффекты TLB: Эффект присутствует, даже если выходной буфер расположен на огромной странице размером 2 МБ. В любом случае 64 записи 4K TLB более чем покрывают выходной буфер 128К.
perf
не сообщает о каких-либо странных действиях TLB. - Псевдонимы 4k: более старые и более сложные версии этого эталонного теста действительно демонстрировали псевдонимы 4k, но это было устранено, поскольку в тесте нет нагрузок (это нагрузки, которые могут некорректно создавать псевдонимы в более ранних хранилищах). Также устраняется устранением по умолчанию.
- Конфликты ассоциативности L2: устранены устранением по умолчанию и тем фактом, что это не проходит даже на страницах размером 2 МБ, где мы можем быть уверены, что выходной буфер линейно размещен в физической памяти.
- Эффект гиперпоточности: HT отключен.
- Предварительная выборка: здесь могут быть задействованы только два из предварительных выборщиков ("DCU", или предварительные выборки L1 <-> L2), поскольку все данные живут в L1 или L2, но производительность одинакова со всеми включенными предварительными выборками или отключенными.
- Прерывания: нет корреляции между количеством прерываний и медленным режимом. Общее количество прерываний ограничено, в основном такты.
toplev.py
Я использовал toplev.py, который реализует метод анализа сверху вниз Intel, и не удивительно, что он определяет эталон как привязанный к магазину:
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 с несколькими незначительными правками, поскольку сборка настолько тривиальна. Потяните запросы приняты.
2 ответа
Что я нашел до сих пор. К сожалению, на самом деле это не дает объяснения низкой производительности, а вовсе не бимодального распределения, но представляет собой скорее набор правил, когда вы можете увидеть производительность и замечания по ее снижению:
- Пропускная способность хранилища в L2, по-видимому, составляет не более одной 64-байтовой строки кэша на три цикла0, что накладывает верхний предел ~ 21 байта на цикл на пропускную способность хранилища. Иными словами, для серии магазинов, которые не попадают в L1 и попадают в L2, потребуется не менее трех циклов на каждую прикосновенную строку кэша.
- Выше этой базовой линии существует значительный штраф, когда хранилища, попавшие в L2, чередуются с хранилищами в другую строку кэша (независимо от того, попали ли эти хранилища в L1 или L2).
- Штраф, видимо, несколько больше для магазинов, которые находятся рядом (но все же не в той же строке кэша).
- Бимодальные характеристики, по крайней мере, внешне связаны с вышеупомянутым эффектом, поскольку в случае без чередования оно, по-видимому, не возникает, хотя у меня нет дальнейшего объяснения этого.
- Если вы убедитесь, что строка кэша уже находится в L1 перед сохранением, путем предварительной выборки или фиктивной загрузки, медленная производительность исчезает и производительность больше не является бимодальной.
Детали и картинки
64-байтовый Stride
В первоначальном вопросе произвольно использовался шаг 16, но давайте начнем, пожалуй, с самого простого случая: шаг 64, то есть одна полная строка кэша. Как оказалось, различные эффекты видны с любого шага, но 64 гарантирует отсутствие кеша L2 на каждом шагу и удаляет некоторые переменные.
Давайте также удалим второе хранилище на данный момент - так что мы просто тестируем одно 64-байтовое расширенное хранилище на 64 КБ памяти:
top:
mov BYTE PTR [rdx],al
add rdx,0x40
sub rdi,0x1
jne top
Запустив это в том же самом жгуте, что и выше, я получаю около 3,05 циклов / магазин2, хотя есть небольшая разница по сравнению с тем, что я привык видеть (- вы даже можете найти 3,0 там).
Таким образом, мы уже знаем, что, вероятно, не будем делать лучше, чем это для устойчивых магазинов, до уровня L21. Хотя Skylake, очевидно, имеет пропускную способность в 64 байта между L1 и L2, в случае потока хранилищ эта пропускная способность должна быть разделена как для выселений из L1, так и для загрузки новой линии в L1. 3 цикла кажутся разумными, если, скажем, по 1 циклу каждый (а) выселить грязную линию жертвы из L1 в L2 (b) обновить L1 новой строкой из L2 и (c) зафиксировать хранилище в L1.
Что происходит, когда вы добавляете в цикл повторную запись в ту же строку кэша (к следующему байту, хотя, оказывается, это не имеет значения)? Как это:
top:
mov BYTE PTR [rdx],al
mov BYTE PTR [rdx+0x1],al
add rdx,0x40
sub rdi,0x1
jne top
Вот гистограмма времени для 1000 запусков тестового жгута для вышеуказанного цикла:
count cycles/itr
1 3.0
51 3.1
5 3.2
5 3.3
12 3.4
733 3.5
139 3.6
22 3.7
2 3.8
11 4.0
16 4.1
1 4.3
2 4.4
Таким образом, в большинстве случаев около 3,5 циклов. Это означает, что этот дополнительный магазин добавил только 0,5 цикла к времени. Это может быть что-то вроде буфера хранилища, способного сливать два хранилища в L1, если они находятся в одной строке, но это происходит только примерно в половине случаев.
Учтите, что буфер магазина содержит ряд магазинов, таких как 1, 1, 2, 2, 3, 3
где 1
указывает на строку кэша: половина позиций имеет два последовательных значения из одной и той же строки кэша, а половина - нет. Поскольку буфер хранилища ожидает истощения хранилищ, а L1 активно выселяет и принимает линии от L2, L1 станет доступным для хранилища в "произвольной" точке, и если он находится в позиции 1, 1
возможно магазины истощаются за один цикл, но если это в 1, 2
это занимает два цикла.
Обратите внимание, что есть еще один пик около 6% результатов около 3,1, а не 3,5. Это может быть устойчивое состояние, в котором мы всегда получаем счастливый результат. Есть еще один пик около 3% при ~4.0-4.1 - "всегда неудачный" механизм.
Давайте проверим эту теорию, посмотрев на различные смещения между первым и вторым магазинами:
top:
mov BYTE PTR [rdx + FIRST],al
mov BYTE PTR [rdx + SECOND],al
add rdx,0x40
sub rdi,0x1
jne top
Мы пробуем все значения FIRST
а также SECOND
от 0 до 256 с шагом 8. Результаты с различными FIRST
значения по вертикальной оси и SECOND
по горизонтали:
Мы видим определенный паттерн - значения белого являются "быстрыми" (около значений 3.0-4.1, обсужденных выше для смещения 1). Желтые значения выше, до 8 циклов, а красные - до 10. Фиолетовые выбросы являются самыми высокими и обычно представляют собой случаи, когда включается "медленный режим", описанный в OP (обычно тактирование составляет 18,0 циклов / итера). Мы замечаем следующее:
Из структуры белых ячеек мы видим, что мы получаем быстрый цикл ~3,5, пока второе хранилище находится в той же строке кэша или рядом с первым хранилищем. Это согласуется с представленной выше идеей, что хранение в одной и той же строке кэша обрабатывается более эффективно. Причина, по которой работает второе хранилище в следующей строке кэша, состоит в том, что шаблон в итоге остается тем же, за исключением первого первого доступа:
0, 0, 1, 1, 2, 2, ...
против0, 1, 1, 2, 2, ...
- где во втором случае это второе хранилище, которое первым касается каждой строки кэша. Буфер магазина все равно. Как только вы попадаете в разные строки кэша, вы получаете0, 2, 1, 3, 2, ...
и видимо это отстой?Фиолетовые "выбросы" никогда не появляются в белых областях, поэтому, по-видимому, они ограничены сценарием, который уже является медленным (и более медленное здесь делает его примерно в 2,5 раза медленнее: от ~8 до 18 циклов).
Мы можем немного уменьшить масштаб и посмотреть на еще большие смещения:
Тот же базовый шаблон, хотя мы видим, что производительность улучшается (зеленая область), когда второе хранилище отодвигается (впереди или позади) от первого, до тех пор, пока оно снова не ухудшится со смещением около ~1700 байт. Даже в улучшенной области мы достигаем в лучшем случае только 5,8 циклов / итерация, которые все еще намного хуже, чем производительность в той же строке 3,5.
Если вы добавляете какие- либо инструкции загрузки или предварительной выборки, которые выполняются впереди3 магазинов, как общая медленная производительность, так и выбросы в "медленном режиме" исчезают:
Вы можете перенести это обратно на исходную проблему шага на 16 - любой тип предварительной выборки или загрузки в цикле ядра, практически не зависящий от расстояния (даже если он на самом деле позади), устраняет проблему, и вы получаете 2,3 цикла / итерация, близок к наилучшему возможному идеалу 2,0 и равен сумме двух магазинов с отдельными циклами.
Таким образом, основное правило заключается в том, что хранилища на L2 без соответствующих загрузок выполняются гораздо медленнее, чем если бы вы их программно предварительно выбирали - если только весь поток хранилища не обращается к строкам кэша в одном последовательном паттерне. Это противоречит идее, что линейный шаблон, подобный этому, никогда не выигрывает от предварительной выборки SW.
У меня нет конкретного объяснения, но оно может включать в себя следующие факторы:
- Наличие других хранилищ в буферах хранилища может снизить параллелизм запросов, идущих к L2. Точно неясно, когда магазины, которые будут отсутствовать в L1, выделяют буфер хранилища, но, возможно, это происходит близко к моменту, когда хранилище будет удалено, и в буфере хранилища есть определенное количество "предвкушения", чтобы привести местоположения в L1, поэтому наличие дополнительных хранилищ, которые не будут пропущены в L1, вредит параллелизму, так как ожидающая сторона не может увидеть столько запросов, которые пропустят.
- Возможно, существуют конфликты для ресурсов L1 и L2, таких как порты чтения и записи, пропускная способность между кешами, которые усугубляются при таком типе хранилищ. Например, когда чередуются хранилища с разными строками, возможно, они не могут так быстро истощать данные из очереди хранилища (см. Выше, где показано, что в некоторых сценариях более одного хранилища может истощаться за цикл).
Эти комментарии доктора Маккальпина на форумах Intel также весьма интересны.
0 В основном достижимо только с отключенным стримером L2, так как в противном случае дополнительный конфликт на L2 замедляет это примерно до 1 строки за 3,5 цикла.
1 Сравните это с хранилищами, где я получаю почти точно 1,5 цикла на нагрузку для предполагаемой пропускной способности ~43 байта на цикл. Это имеет смысл: пропускная способность L1<->L2 составляет 64 байта, но при условии, что L1 либо принимает строку от L2, либо обслуживает запросы нагрузки от ядра каждый цикл (но не оба параллельно), тогда у вас есть 3 цикла для двух нагрузок на разные линии L2: 2 цикла для приема линий от L2 и 1 цикл для выполнения двух инструкций по нагрузке.
2 С предварительной загрузкой. Как выясняется, средство предварительной выборки L2 конкурирует за доступ к кэшу L2, когда обнаруживает потоковый доступ: даже если он всегда находит строки-кандидаты и не переходит на L3, это замедляет код и увеличивает изменчивость. Выводы, как правило, сохраняются при включенной предварительной загрузке, но все происходит немного медленнее (вот большой список результатов с включенной предварительной загрузкой - вы видите около 3,3 цикла на загрузку, но с большой вариативностью).
3 Даже не нужно быть впереди - предварительная выборка на несколько строк позади также работает: я думаю, предварительная выборка / загрузка просто быстро опережает магазины с узкими местами, так что они все равно продвигаются вперед. Таким образом, предварительная выборка является своего рода самолечением и, кажется, работает практически с любой ценностью, которую вы вкладываете.
У Sandy Bridge есть "устройства предварительной выборки оборудования L1". Это означает, что изначально, когда вы делаете свое хранилище, ЦП должен извлекать данные из L2 в L1; но после того, как это произошло несколько раз, аппаратный предварительный выборщик замечает хороший последовательный шаблон и начинает предварительную выборку данных из L2 в L1 для вас, так что данные находятся либо в L1, либо "на полпути к L1", прежде чем ваш код сделает это. хранить.