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
()
Ответ на: комментарий от peregrine

Я же в шапке добавил итог. Путаница со смещениями, на самом деле замедление было если тело цикла пересекает 32-байтную границу. NOP-ы ни при чём, если их не совсем много то они вообще времени не тратят.

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

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

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

В том что читать из выровненных адресов схемотехнически проще. Аналогично тому как если ты разместишь 8-битную переменную в половине одного байта и половине второго - с ней тоже будет сложно работать во всех отношениях (придётся каждый раз читать оба байта и делать всякие фокусы чтобы не трогать не относящиеся к переменной биты).

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

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

В том что читать из выровненных адресов схемотехнически проще. Аналогично тому как если ты разместишь 8-битную переменную в половине одного байта и половине второго - с ней тоже будет сложно работать во всех отношениях (придётся каждый раз читать оба байта и делать всякие фокусы чтобы не трогать не относящиеся к переменной биты).

Можно прочитать сначала невыровненный байт, а затем остальные байты буфера уже словами.

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

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

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

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

А так для программиста мороки много. Недосточно просто выровнять начало структуры, а должны быть выровнены все её поля. Вообщем-то шину памяти затачивать под передачу байт практически означает двухкратную потерю скорости её работы.

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

Скорее всего причина в том, что привносится мусор в работу конвейера команд и он медленнее из-за этого работает.

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

Поля структур компилятор тоже выравнивает (если ты явно не попросишь его не делать этого).

Бедный дебаггер. Как ему тяжело всё это видеть …

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

Судя по всему у твоего проца частота 3600МГц или чуть больше, и он делает цикл за те же 2 такта как и атлон.

Только за один раз может больше 32 байт подгружать (может 64), поэтому первый вариант с неправильной меткой тоже работал быстро. А gmp скорее всего делает больше одного вычитания за цикл

Если вот так сделать наверно будет быстрее чем 0.36 (но количество добавленных вычитаний не знаю какое нужно). У меня на атлоне пользы от такого не обнаружилось особо (если цикл совсем пустой, только rcx-счётчик и jmp-ы, то его получилось в 2 раза ускорить таким способом).

movq (%rsi,%rcx,8), %rax
sbbq %rax, (%rdi,%rcx,8)
incq %rcx
jz 2b
movq (%rsi,%rcx,8), %rax
sbbq %rax, (%rdi,%rcx,8)
incq %rcx
jnz 1b
2:

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

Если ты пишешь не на ассемблере, то почти всё нужное делает компилятор.

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

Современный пень ест 4 команды, ряженка 5. Феномы и их братья по разуму 3. Low power срань по 2.

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

Если у тебя большой цикл по длительности, то с шагом через 4 вычитай. То есть отдельно 4n, 4n+1, 4n+2, 4n+3 а потом суммы по очереди друг из друга вычти.

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

Судя по всему у твоего проца частота 3600МГц или чуть больше, и он делает цикл за те же 2 такта как и атлон.

11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz

А gmp скорее всего делает больше одного вычитания за цикл

Это действительно так — там unroll. А ещё оно использует jrcxz вместо inc/dec.

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

3.2 это видимо без нагрузки частота, а на одноядерной нагрузке больше становится.

jrcxz

Он не изменяет счётчик, только проверяет. А если ты про loop, то я несколько раз видел инфу что на современных процах он тормозит (причины не объяснялись) и ручное dec+jnz быстрее.

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

Он не изменяет счётчик, только проверяет

Цитирую README:

The new spin of the venerable P6 core, the «Core 2» is much better than the Pentium4 for the GMP loops. Its integer pipeline is somewhat similar to to the Opteron/Athlon64 pipeline, except that the GMP favourites ADC/SBB and MUL are slower. Furthermore, an INC/DEC followed by ADC/SBB incur a pipeline stall of around 10 cycles. The default mpn_add_n and mpn_sub_n code suffers badly from the stall. The code in the core2 subdirectory uses the almost forgotten instruction JRCXZ for loop control, and updates the induction variable using LEA.

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

Ясно, используют LEA вместо INC/DEC. Однако это лишний байт получается, не всегда хорошо.

Furthermore, an INC/DEC followed by ADC/SBB incur a pipeline stall of around 10 cycles

Мне кажется это core2-проблемы и костыль для их обхода. Т.к. такая просадка явно бы тормозила не только gmp но и ещё много чего, скорее всего это исправили в более новых процах как можно скорее. В атлоне такого точно не наблюдается. Кстати упомянутый Athlon64 это вроде бы самый первый (или почти) amd64 проц вообще, мой более новый.

Так что, подозреваю, на твоём проце мой вариант выше, с правильно подобранным количеством вычитаний на цикл, те самые 0.18с тоже должен дать.

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

Увы, 0.32 s.

И там должно быть jz 2f, а не jz 2b — суффикс означает forward или backward, но это уже trivia. Метки-цифры это вообще для «даже заморачиваться не хочу, чтобы идентификатор этой метке присвоить».

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

А так? На атлоне это самое быстрое, что у меня получилось. Но оно работает только если счётчик кратный 8. Без этого сильно уйти вперёд от оригинального кода не получалось, попытки использовать LEA+JCXZ только замедляли всё. Использование ещё двух регистров пользы не дало, от группировки по 4 - становилось медленнее.

Но оно всего в полтора раза быстрее простого цикла.

1:
  mov    (%rsi,%rcx,8),%rax
  mov    0x8(%rsi,%rcx,8),%rdx
  sbb    %rax,(%rdi,%rcx,8)
  sbb    %rdx,0x8(%rdi,%rcx,8)
  mov    0x10(%rsi,%rcx,8),%rax
  mov    0x18(%rsi,%rcx,8),%rdx
  sbb    %rax,0x10(%rdi,%rcx,8)
  sbb    %rdx,0x18(%rdi,%rcx,8)
  mov    0x20(%rsi,%rcx,8),%rax
  mov    0x28(%rsi,%rcx,8),%rdx
  sbb    %rax,0x20(%rdi,%rcx,8)
  sbb    %rdx,0x28(%rdi,%rcx,8)
  mov    0x30(%rsi,%rcx,8),%rax
  mov    0x38(%rsi,%rcx,8),%rdx
  sbb    %rax,0x30(%rdi,%rcx,8)
  sbb    %rdx,0x38(%rdi,%rcx,8)
  lea    0x7(%rcx),%rcx
  inc    %rcx
  jne    1b

Я так понимаю получается по 3 такта на 16 байт данных и ещё 1-2 такта на цикл. Если в середину вставить добавление 4 и проверку на 0 (чтобы можно было счётчик кратный 4) то становится медленнее на 2%, не знаю как это в тактах выражается.

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

А потому что проблема с перегревом. Чем больше элементов, тем сильнее процессор греется. Народ и так репу чешет, как сделать так чтоб меньше грелось, а ты предлагаешь усложнить схему чтения в 8 раз. Ну ладно, может не в 8, а в пять.

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

читал в интернете, что gettimeofday считается deprecated и вместо нее предлагается использовать clock_gettime, rdtsc выполняется быстрее чем clock_gettime

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