LINUX.ORG.RU

Зависимость скорости работы циклов от выравнивания кода

 , ,


5

6

Обнаружил сильную зависимость скорости работы одной вычислительной функции от того, на каком адресе она начинается. Локализовал проблему с помощью вставки NOP-ов в разные её места, выяснилось что корень всех странностей находится в этом цикле (подозреваю, что различие в скорости работы тоже локализовано в нём, хотя как это точно проверить не знаю):

00000000000016b9 <.bL11>:
    16b9:       48 8b 04 ce             mov    rax,QWORD PTR [rsi+rcx*8]
    16bd:       48 19 04 cf             sbb    QWORD PTR [rdi+rcx*8],rax
    16c1:       48 ff c1                inc    rcx
    16c4:       75 f3                   jne    16b9 <.bL11>
    16c6:       48 83 1f 00             sbb    QWORD PTR [rdi],0x0
^ вот так всё работает с «обычной» скоростью. А так (всё сдвинуто на 1 байт вниз) - на 30-35% медленнее:
00000000000016ba <.bL11>:
    16ba:       48 8b 04 ce             mov    rax,QWORD PTR [rsi+rcx*8]
    16be:       48 19 04 cf             sbb    QWORD PTR [rdi+rcx*8],rax
    16c2:       48 ff c1                inc    rcx
    16c5:       75 f3                   jne    16ba <.bL11>
    16c7:       48 83 1f 00             sbb    QWORD PTR [rdi],0x0
двигаем дальше, всё медленно, вплоть до сюда:
00000000000016c5 <.bL11>:
    16c5:       48 8b 04 ce             mov    rax,QWORD PTR [rsi+rcx*8]
    16c9:       48 19 04 cf             sbb    QWORD PTR [rdi+rcx*8],rax
    16cd:       48 ff c1                inc    rcx
    16d0:       75 f3                   jne    16c5 <.bL11>
    16d2:       48 83 1f 00             sbb    QWORD PTR [rdi],0x0
Если сдвинуть ещё на 1 байт вниз - лаги исчезают.

Собственно, если отметить первый лагающий вариант (где .bL11=0x16BA) за точку отсчёта, то итоги такие:

-7..-1 - хорошая скорость
0..11 - замедление на 30-35%
12..15 - хорошая скорость
16..23 - замедление на 15-20%
24..31 - хорошая скорость
32..32+11 - замедление на 30-35%
32+12..32+31 - хорошая скорость
Именно так, 30-35% замедление циклично повторяется через 32 байта (и дальше тоже повторяется), а вот второе замедление, сдвинутое на 16 байт от первого, и меньше по величине, и короче по байтам, и отсутствует во втором цикле. Более того, оно отсутствует и при сдвиге на 64+16, и 96+16, и 128+16, но опять появляется на 256+16 (на 512+16 его опять нет, промежуточные уже не проверял) - странно. А ещё оно появляется/исчезает в зависимости от изменений в других линкуемых модулей. Например, простое дописывание к линкеру -lm (без изменений исходников) может на него повлиять. 30-35% же замедление присутствует стабильно на своём месте.

Ещё я проверил что будет если вставлять NOP-ы внутрь цикла. Так вот, добавление например 2 NOP-ов приводит к тому, что лаги начинаются на 2 байта раньше (т.е. при той же позиции JNE как и раньше начинались), а вот заканчиваются на том же месте как раньше, т.е. при той же позиции метки (т.е. лаги от -2 до 11). Вторая зона лагов стала какой-то размазанной с усилившимся пиком в самом начале, а так же она появилась таки во втором цикле на 32+16-2.

сдвиг   время выполнения бенчмарка
-6       511.9 +- 14.8
-5       515.2 +- 12.3
-4       511.6 +- 8.9
-3       507.3 +- 7.2
-2       683.0 +- 9.2    <-- первый круг
-1       672.1 +- 12.3
0        670.5 +- 8.6
1        674.8 +- 17.1
2        669.4 +- 7.4
3        672.5 +- 11.0
4        675.0 +- 9.1
5        669.9 +- 8.9
6        675.5 +- 8.8
7        679.1 +- 7.6
8        696.9 +- 23.6
9        691.6 +- 14.8
10       680.5 +- 9.5
11       684.0 +- 9.0
12       516.5 +- 7.9
13       517.2 +- 11.6
14       792.3 +- 36.8   <--
15       605.4 +- 37.5
16       575.8 +- 24.2
17       566.0 +- 16.0
18       555.8 +- 21.6
19       562.0 +- 14.5
20       555.7 +- 8.7
21       537.2 +- 10.7
22       534.4 +- 11.2
23       549.9 +- 14.6
24       522.1 +- 10.6
25       517.8 +- 9.5
26       515.5 +- 8.4
27       517.0 +- 4.2
28       512.2 +- 7.4
29       516.8 +- 8.6
30       680.3 +- 9.2  <-- второй круг
31       675.0 +- 19.7
32       671.7 +- 8.9
33       669.2 +- 9.1
34       668.3 +- 10.8
35       667.0 +- 9.6
36       669.0 +- 8.9
37       671.0 +- 9.5
38       670.6 +- 10.8
39       676.5 +- 10.1
40       681.0 +- 10.5
41       682.1 +- 12.7
42       693.5 +- 16.7
43       689.8 +- 13.5
44       512.5 +- 4.9
45       516.9 +- 7.1
46       685.5 +- 12.3   <--
47       515.2 +- 7.6
48       519.0 +- 8.7
49       513.2 +- 7.9
50       512.4 +- 6.2
51       510.8 +- 4.7
52       510.3 +- 7.3
53       507.4 +- 7.0
54       509.2 +- 6.9

Вообще мне казалось что для более быстрой работы кода надо выравнивать начала циклов по 16-байтным границам, но тут ничего подобного незаметно, все адреса не пойми какие. Есть замеченная закономерность: когда JNE доползает до адреса 0x16c5 - лаги начинаются, когда до него доползает и начало цикла - заканчиваются. Но JNE двухбайтовый, т.е. когда он на 0x16c4 то второй его байт на 0x16c5 и лагов ещё нет.

Есть ли шанс объяснить почему так? А то мне совсем не нравится что код рандомно теряет треть скорости на ровном месте, тут я заметил, где-то не замечу, да и на разных процах это может быть по-разному, что делать?

---------------

Почти итог: адреса в дампах выше считал от начала объектного модуля, оказывается модуль автоматически (если явно не указать) по 16-байт границе не выравнивается, и адреса в бинарнике уплыли. Реальное замедление получается если тело цикла пересекает 32-байт границу (кроме случая когда за неё вылезает только один байт последней инструкции, почему-то), выглядит логично.

★★★★★

Последнее исправление: firkax (всего исправлений: 1)
Ответ на: комментарий от shdown

Не хотел терять 30% производительности, а потерял 2x.

Нет, тема была не про то как улучшить эту функцию, а про то, почему такое отличие в скорости в зависимости от адреса. В итоге ответ найден.

Как улучшить - это другая задача, про неё я темы не создаю, но делать всё хочу сам.

У тебя какой-то странный ассемблер, там всё местами поменяно и сигилов нет.

Обычно наоборот ругаются... Лично мне AT&T-синтаксис (тот что у тебя и он дефолтный в gcc/gas) крайне некомфортен, хотя при желании его и можно читать. Всегда пользовался интеловским - он используется в официальных мануалах к x86-совместимым процам, и наверно по этой причине именно он всегда использовался в ассемблерах/компиляторах/отладчиках, которые писались нативно под x86, на которых в досе я его и осваивал (ну и книжки по x86 ассемблеру все были именно в этом синтаксисе).

1:
.align 32

Метка то до align-а, туда походу куча nop-ов напихана в итоге которые часть цикла, отчего и всё медленно (цикл мало того что пересекает границу, так ещё и парсинг 29 байт nop-ов скорее всего кучу времени съедает дополнительно).

firkax ★★★★★
() автор топика
Последнее исправление: firkax (всего исправлений: 1)
Ответ на: комментарий от firkax

Как улучшить - это другая задача, про неё я темы не создаю, но делать всё хочу сам.

No problems. Я про то, что можешь черпать вдохновение из gmp (mpn/x86_64/aors_n.asm).

Метка то до align-а, туда походу куча nop-ов напихана в итоге которые часть цикла, отчего и всё медленно

Согласен, моя ошибка (а ещё то, что я запостил, обычным as не компилируется, потому что нужно заменить `;` на `#` в комменте). Но с align’ом до метки у меня результаты не отличаются.

shdown ★★
()
Последнее исправление: shdown (всего исправлений: 1)

Старая статья ровно про то, о чем ты спрашиваешь. На «.NET» в названии можно не смотреть, статья низкоуровневая, и специфики дотнета там практически нет.

https://devblogs.microsoft.com/dotnet/loop-alignment-in-net-6/

Siborgium ★★★★★
()
Последнее исправление: Siborgium (всего исправлений: 1)

Даже десятикратное замедление - это не то, о чём надо париться. Это всего лишь константа, а не принципиальное увеличение сложности алгоритма. У меня имеющаяся вычислительная мощность недоиспользуется.

Saakx
()
Ответ на: комментарий от Saakx

атное замедление - это не то, о чём надо париться. Это всего лишь константа, а не принципиальное увеличение сложности алгоритма. У меня имеющаяся вычислительная мощность недо

да конечно. и разницы между процом на 3 гигагерца и на 300 мегагерц нет никакой. это же всего лишь константа

ckotctvo
()
Ответ на: комментарий от ckotctvo

ты как это в железе представляешь?

Ровно так, делают в riscv. Сейчас там 32 и 16 бит сжатые, которые сначала афаик разжимаются. Признак - два младших бита 11 в опкоде. На дальнейшее расширение, которое заложено по 16бит в стандарте все забили, хватает. ARM вообще забыл про THUMB и тоже прекрасно живёт на 32бит.

прямо из кэша в рожу декодеру пихать выровненные 16 байт легко и просто. ничего лучше пока не придумали.

А где начало-то? )
Вот скорее всего как раз из-за того, что надо в буфер декодера поймать инструкцию потенциально до 16 байт длиной целиком, САБЖевый проц и выбирает по 32 байта.
Рад за твой прогресс!

GAMer ★★★★★
()
Ответ на: комментарий от GAMer

Это может работать только в каком-нибудь очень простом процессоре, который по 1 инструкции за цикл выполняет. Там всё вообще просто, ну охренеть как просто.

А когда тебе надо и быстро и много инструкций, не остается места для художеств. Поэтому сабжевое поведение будет у всех процессоров. Даже у пентиума1.

ckotctvo
()
Ответ на: комментарий от ckotctvo

Это может работать только в каком-нибудь очень простом процессоре, который по 1 инструкции за цикл выполняет. Там всё вообще просто, ну охренеть как просто.

Zen5 4+4, Lion Cove 8, Apple M5 10-way декодеры. Мне кажется, ARM тут показывает себя с лучшей стороны.

А когда тебе надо и быстро и много инструкций, не остается места для художеств.

И ты делаешь фиксированную длину инструкций с простым, тупым, но параллельным до усрачки декодером?

GAMer ★★★★★
()
Ответ на: комментарий от firkax

Кстати, в чём там дело оказалось? В спеке процессора которая говорит что нельзя NOP-ы ставить больше одного? Или со смещениями путаница вышла?

peregrine ★★★★★
()
Последнее исправление: peregrine (всего исправлений: 1)

Вопрос ко всем.

Кто знает почему до сих пор не решили в архитектуре CPU задачу быстрой пересылки данных без необходимости выравнивания памяти?

anonymous
()
Ответ на: комментарий от anonymous

Кто знает почему до сих пор не решили в архитектуре CPU задачу быстрой пересылки данных без необходимости выравнивания памяти?

Алиса вот что ответила

Вопрос отличный! На первый взгляд действительно кажется, что проблему можно решить раз и навсегда, но на практике есть серьёзные технические и экономические барьеры. Я бы выделила несколько ключевых причин, почему эту задачу пока не удалось решить повсеместно. Шина и контроллер памяти — слабое звено. Часто проблема начинается ещё до самого процессора. Многие шины (например, Front-Side Bus) в принципе не рассчитаны на невыровненные адреса: если процессор выставит такой адрес, шина может выдать ошибку или потребовать дополнительных циклов для «сборки» данных из нескольких физических адресов. Контроллер памяти тоже устроен так, что чтение по выровненному адресу требует меньше операций: память часто работает по строкам (например, по 64 байта), и выровненный запрос попадает ровно в одну строку. А вот при невыровненном доступе одна часть слова может «лежать» в одной строке матрицы, а вторая — в соседней. Контроллеру придётся считать две строки, а затем процессору — вручную «склеить» результат. ru.stackoverflow.com Кэш работает эффективнее с выровненными данными. Аналогично: кэш хранит данные строками. Если объект в памяти выровнен, при его загрузке в кэш гарантированно загружается целая кэш-линия. А вот при невыровненном доступе часть объекта может оказаться в одной линии, а часть — в другой. Это приводит к лишним копиям данных между кэшем и основной памятью, что снижает пропускную способность. ru.stackoverflow.com +1 Специализированные инструкции и железо. Многие современные наборы инструкций (например, SSE, AVX) спроектированы так, чтобы эффективно работать с выровненными векторами. Некоторые процессоры на аппаратном уровне вообще не поддерживают невыровненный доступ — это вызывает исключение или требует сложной обработки. Если убрать требование выравнивания, придётся усложнять декодер инструкций и логику обработки исключений, что резко удорожает производство чипа. ru.stackoverflow.com Компромисс между гибкостью и скоростью. С одной стороны, полная свобода в выравнивании даёт программисту гибкость (не нужно вручную заботиться о макетах структур). С другой — за эту гибкость приходится платить в производительности. Разработчики процессоров выбирают такой компромисс: они оптимизируют частые сценарии (выровненные доступы) и допускают невыровненные в редких, сложных случаях, но с ощутимой потерей скорости. ru.stackoverflow.com Эволюция против рисков. Многие решения, которые сейчас кажутся устоявшимися, когда-то были инновацией. Внедрение полностью гибкой модели доступа к памяти потребовало бы масштабной перестройки огромного количества компонентов: шин, кэшей, блоков декодирования инструкций. Это огромный технический и финансовый риск для производителей. Проще и безопаснее оптимизировать узкие места в тех сценариях, где они действительно бьют по производительности, и оставить исключения для действительно редких случаев. ru.stackoverflow.com При этом важно отметить: в отдельных нишах (например, в специализированных ускорителях, GPU или при работе с определёнными типами данных) проблему частично решают через другие механизмы — например, через техники вроде «закреплённой памяти» (pinned memory) или оптимизированные библиотеки для передачи данных. gpuservercase.com Так что дело не в том, что задачу не пытаются решить. Просто на текущем технологическом уровне преимущества полного снятия требования к выравниванию пока не перевешивают связанные с этим сложности и потери в других областях. Оценить ответ

anonymous
()
  • Markdown
Пустая строка (два раза Enter) начинает новый абзац. Знак '>' в начале абзаца выделяет абзац курсивом цитирования.
Внимание: прочитайте описание разметки Markdown.
Используйте Ctrl-Enter для размещения комментария