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)

А до и после этого участка код есть? Если да, то воспроизводятся ли замеренные скорости, если добавить вокруг по сотне nop’ов?

i-rinat ★★★★★
()

Хм.. А 20 лет назад мы это использовали как само собой разумеющееся и считали очевидной оптимизацией. Процессор то у тебя какой? Какой мюоп кэш?

LightDiver ★★★★★
()

Чтобы не гадать на кофейной гуще, как раз для таких случаев придуманы performance counters. Начинать со счётчиков L1i, похоже по какой-то причине код цикла всё время замещается и подкачивается вновь. То, что проблема со страницами кода косвенно подтверждается тем, что линковка левой библиотеки влияет на результаты.

А кстати, в каких единицах и каким образом измеряется время?

VIT ★★★
()

о, сколько нам открытий чудных.

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

>>> 0x16c1 % 64
1
>>> 0x16c2 % 64
2

совпадение? Не думаю

Lrrr ★★★★★
()

почему так?

Потому что процессоры любят выровненный по кэш-линии код.

Можно почитать в любом гайде по оптимизации кода - хоть от amd, хоть от intel.

Про это даже Мышъх писал в своё время.

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

Это был хакерский эксперимент. Очевидно неудачный.

Мыщъх, конешшшно, высокъ (ну или был, пока не прыгнул с парашютом), но кэшфрендли-задротинг не самоцель, а вот рискованное поведение, почему-то происходит, независимо от уровня задротинга. Особенно все смешно и грустно с попытками применить ИТ-задротинг с ложными аналогиями к биологии, «биохакинг» там или вот парашютинг без какой-то продуктивной цели.

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

Однако. Как, все-таки, доступ к иишечкам раздувает самоуверенность, а.

Мюоп кэш!

thesis ★★★★★
()
Ответ на: комментарий от i-rinat

Добавил по 128 nop до и после (хотел не сбивать никакие выравнивания в итоговой таблице, по один jmp из-за этого превратился из 2-байтового в 6-байтовый и всё равно их сбил, ну да ладно). Абсолютные скорости не воспроизводятся, всё стало ожидаемо сильно медленнее, поскольку у цикла 500 итераций и дополнительные 256 nop-ов рядом с ним, ещё и растянутые по 256 байтам памяти, тратят существенно времени. К времени выполнения везде добавилось примерно 240 (к обычным 510). Однако шаблон с (бывшим 35%) замедлением в зависимости от сдвига всего кода остался, и остался на тех же последних цифрах адресов цикла. Абсолютная разница между долгими и быстрыми вариантами чуть сократилась (была 160, стала 140).

Быстро:

    1738:       90                      nop
0000000000001739 <.bL11>:
    1739:       48 8b 04 ce             mov    rax,QWORD PTR [rsi+rcx*8]
    173d:       48 19 04 cf             sbb    QWORD PTR [rdi+rcx*8],rax
    1741:       48 ff c1                inc    rcx
    1744:       75 f3                   jne    1739 <.bL11>
    1746:       90                      nop
Начало медленного:
    1739:       90                      nop    
000000000000173a <.bL11>:
    173a:       48 8b 04 ce             mov    rax,QWORD PTR [rsi+rcx*8]
    173e:       48 19 04 cf             sbb    QWORD PTR [rdi+rcx*8],rax
    1742:       48 ff c1                inc    rcx
    1745:       75 f3                   jne    173a <.bL11>
    1747:       90                      nop
Конец медленного:
    1744:       90                      nop
0000000000001745 <.bL11>:
    1745:       48 8b 04 ce             mov    rax,QWORD PTR [rsi+rcx*8]
    1749:       48 19 04 cf             sbb    QWORD PTR [rdi+rcx*8],rax
    174d:       48 ff c1                inc    rcx
    1750:       75 f3                   jne    1745 <.bL11>
    1752:       90                      nop
Опять быстро:
    1745:       90                      nop
    0000000000001746 <.bL11>:
    1746:       48 8b 04 ce             mov    rax,QWORD PTR [rsi+rcx*8]
    174a:       48 19 04 cf             sbb    QWORD PTR [rdi+rcx*8],rax
    174e:       48 ff c1                inc    rcx
    1751:       75 f3                   jne    1746 <.bL11>
    1753:       90                      nop

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

Левая библиотека, подозреваю, влияет тем что линкер из-за неё смещает исследуемый модуль на другие адреса, пусть и кратно 16 байтам, но как показали измерения, от таких сдвигов результат второй замедленной зоны сильно зависит. Такая же зависимость случалась от того, что я в бенчмарк-исходник (компилируется в отдельный .o без lto но затем линкуется в общий бинарник) добавлял лишний printf в одном месте. Но это всё касалось только второго замедления, а первое (которое 30-35%) стабильно на месте.

А кстати, в каких единицах и каким образом измеряется время?

Измеренное время разумнее всего считать в неких условных единицах, потому что смысл всё равно только в отношении между разными измерениями. Но вообще так:
* в функции кроме этого цикла есть и другой код, но сдвиги остального кода на скорость не влияют; рядом есть похожий цикл с cmp вместо sub, который вызывается примерно в 2 раза чаще, но в память ничего не записывает; в сумме на эти два цикла приходится наверно 90% времени работы
* сам цикл за время одного вызова функции выполняется в сумме примерно 2000 сессий по 500 циклов
* указанная функция вызывается 1000 раз с одними и теми же входными данными, измеряется время в миллисекундах по clock_gettime(CLOCK_THREAD_CPUTIME_ID), затем данные рандомятся на другие, она вызывается ещё 1000 раз итд. Затем серия таких опытов усредняется (в таблице вроде по 20 раз было) + считалось стандартное отклонение которое в +-.

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

Ещё один с баяном. Про выравнивание все знают, теперь покажи каким образом это самое выравнивание резко сбивается на втором примере после первого, и восстанавливается спустя ровно 12 байт сдвигов.

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

Там freebsd, perf нету (наверно какой-то другой инструмент есть но я пока не искал). Кстати проц Athlon II X2. На другом - Celeron N2830 с линуксом - не воспроизводится. Надо бы ещё где-нить проверить но позже.

firkax ★★★★★
() автор топика

А дело же происходит под управлением операционной системы? Может ли это быть следствием действия того же планировщика, например? Что будет если сделать renice процесса?

Jullyfish ★★
()

Обнаружил сильную зависимость скорости работы одной вычислительной функции от того, на каком адресе она начинается.

С разморозкой!

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

Не думаю что планировщик проверяет указатель на текущую команду и в зависимости от этого как-то меняет своё поведение. Да и nice влияет когда ресурсов не хватает, а тут не тот случай.

Попробовал назначать бенчмарку конкретное ядро - на ядре 0 почему-то всегда медленнее чем на ядре 1, но шаблон замедления от сдвигов сохраняется.

firkax ★★★★★
() автор топика

Может это проcто говнокод на ассемблере, не учитывающий нюансы архитектуры?

Что там со скоростью, если написать это на C?

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

Так расскажи об этих нюансах. Вопрос как раз в этом и заключается - что приводит к указанному явлению?

Ну или не рассказывай, если не знаешь.

если написать это на C?

Вопрос не в сравнении разных вариантов кода, а в выявлении причин почему конкретно этот так себя ведёт.

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

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

Частота этого проца фиксированная, режима переменных частот у него нет. ОС может настраивать частоту, но автоматизацию я там не делал. При перегреве скорее всего просто отключится (чуть-чуть более ранняя модель точно так и делала когда я забыл питание на кулер подать).

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

Вопрос - «что делать?», именно им и заканчивается твой пост.

тут я заметил, где-то не замечу, да и на разных процах это может быть по-разному, что делать?

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

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

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

Левая библиотека, подозреваю, влияет тем что линкер из-за неё смещает исследуемый модуль на другие адреса

Абсолютно правильно. Это рабочая гипотеза. Можно написать тест, где этого не происходит и проверить.

Время лучше всего измерять в циклах, причём только для исследуемого кода. Это не просто, но можно, каунтер существует, но доступ к нему дорогой.

У современных ядер частота динамическая и изменяется как попало, но для таких тестов можно понизить и фиксировать частоту.

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

А 0x16c5%64=5 и это тоже не совпадение. И что с этого?

Он пытается намекнуть, что не всякую инструкцию можно поместить по произвольному адресу. Некоторые инструкции будучи помещены на направильный aligned address работают дольше, а некоторые вообще отрабатывают некорректно. Нужно для каждой инструкции смотреть таблицу смещений или, что проще, прогонять ассемблер ассемблером, пардон за тавтологию.

VIT ★★★
()

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

Твоя программа работает под операционной системой? Если да, то любые попытки ускорения исполняемого кода не будут иметь существенного значения, потому что по-настоящему быстрые расчёты почти всегда исполняют без участия операционной системы. За подтверждением моих слов далеко ходить не надо: как только машинное обучение и нейронные сети потребовали сверхвычислений, так сразу расчёты перенесли на видеокарты, считающие без операционной системы внутри, и на заказные микросхемы-ускорители, также работающие с исполняемым кодом напрямую либо содержающие его внутри себя аппаратно.

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

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

Есть два предложения, если, конечно, хочется разобраться. Во первых взять один процессор, не надо мешать разное железо. Вы затрагиваете вопросы, которые специфичны не только от ядра к ядру, но даже от прошивки к прошивке в одном и том же ядре. И второй, если выбирать ОС для таких исследований, то желательно её знать до уровня инженера ядра, ну по крайней мере то, как измерять каунтеры и как запретить прерывания знать обязательно.

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

Это делает decoder процессора. То, что ядро 0 работает медленнее ядра 1 говорит о том, что разрешены прерывания во время исполнения кода под вопросом. Известно, что ядро 0 всегда исполняет системные сервисы, поэтому такие тесты нужно выполнять на любом свободном ядре, но точно не на 0.

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

Частота статическая, проц не современный. Athlon II X2, номинально 3000 МГц, замедлен до 800 в целях снижения нагрева (повысить пока не могу).

Время лучше всего измерять в циклах, причём только для исследуемого кода. Это не просто, но можно, каунтер существует, но доступ к нему дорогой.

FreeBSD считает CLOCK_THREAD_CPUTIME_ID как раз через cputicks, но с учётом работы планировщика (а как я его буду учитывать из юзерспейсного кода вручную непонятно). Что же касается «только для исследуемого кода» - ну, если надо точно замерить время работы цикла то может быть, но тут вроде это не обязательно. Разница на целую треть и так и так видна, можно даже по gettimeofday() её заметить, хотя там результат чуть больше зашумлён получается. А вставление rdtsc в середину кода и его тайминги изменит, и вынудит переделывать использование этим кодом регистров чтобы не пересекаться с выводом rdtsc.

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

Частота этого проца фиксированная

Это новая информация, вроде раньше это не было сказано. Тогда проще, время следует мерять инструкцией rdtsc. Она так и расшифровывается - read time stamp counter. К сожалению она uncore, поэтому стоит 20-30 циклов, но это всяко лучше, чем вызов функции.

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

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

В данном конкретном случае «исправить» можно элементарно - переместить код на удачную позицию, где он быстро работает, и огородить его от остальных функций (чтобы они не сбивали эту настройку) выравниванием. Но дело, повторюсь, не в том, а в том чтобы узнать в чём дело и понять более глобальные закономерности чем этот конкретный случай.

здесь ты заметил, где-то еще не заметишь

Я буду знать механику данной проблемы и в дальнейшем писать код с её учётом, например.

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

Давай ты для начала нам скажешь, что у тебя за проц.

Лучше не надо, так интереснее.

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

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

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

но с учётом работы планировщика (а как я его буду учитывать из юзерспейсного кода вручную непонятно)

Как я уже сказал, запуск на ядре 3 и запрет прерываний на время исполнения этого кода.

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

Я буду знать механику данной проблемы и в дальнейшем писать код с её учётом, например.

Но есть же наверняка и другие проблемы, которые можно огрести на ровном месте, если писать на ассемблере под современные архитектуры.

bigbit ★★★★★
()

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

Я не совсем понял, как написан код. Чтобы делать nop вставки, нужно писать на ассемблере, но выводите вы результат objdump, а не исходный код. Покажите код.

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

Так, код копирует один массив в другой без unroll, 8 byte за раз через rax. Можно каким-то образом исключить обращения к памяти и все начальные и конечные адреса поместить в LLC до этого цикла? Хочу исключить переключения между страницами и проблемы задержки в DRAM.

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

objdump чтобы видеть относительные адреса инструкций. Сами инструкции то везде одинаковые (за исключением nop-ов), ну и исходный код разумеется тот же что и objdump показывает.

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

Нет, он не копирует, он вычитает одно 4096-битное число из другого. Обращения к памяти исключить не получится, sbb же записывает назад результат. Но можно sbb на cmp заменить и посмотреть что будет, правда придётся ещё что-то подправить. Данные, подозреваю, и так и так оказываются в кеше (ну, почти все) т.к. этот цикл вызывается много раз и при первом проходе уже всё закеширует.

Попробую это всё сделать, да и rdtsc тоже как-нить туда вставить.

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

Так, код копирует один массив в другой без unroll, 8 byte за раз через rax.

Копирует? Там же вычитание с заемом.

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

Сами инструкции то везде одинаковые

Не верю. Вы никогда в этой ситуации для сохранения значения rax по адресу [rdi+rcx*8] не будете использовать sbb. Вы напишите mov. Здесь компилятор использует хитрость установить перенос, и избежать cmp.

Важен может быть не только этот код но и код до и после измерений таймера. Этот код слишком простой, но память может всё усложнить.

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

Данные, подозреваю, и так и так оказываются в кеше

Нет, надо гарантировать, что они находятся в кэш до этого кода. Если 4096-битное число, то можно это число сделать 4096-бит alligned, чтобы и источник и приёмник занимали ровно одну страницу памяти и не было перепрыгиваний?

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

Но можно sbb на cmp заменить и посмотреть что будет, правда придётся ещё что-то подправить.

Правильно. Предлагаю сделать два load независимо, потом вычитание, потом store, потом сравнение, потом jump.

VIT ★★★
()

Накину гипотезу, ничем не подкрепленную.

У Athlon II X2 малая ассоциативность кэша инструкций L1 (2-way).

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

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