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