LINUX.ORG.RU

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

 , ,


1

3

Обнаружил сильную зависимость скорости работы одной вычислительной функции от того, на каком адресе она начинается. Локализовал проблему с помощью вставки 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 и лагов ещё нет.

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

★★★★★

А до и после этого участка код есть? Если да, то воспроизводятся ли замеренные скорости, если добавить вокруг по сотне 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 для размещения комментария