LINUX.ORG.RU

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

 , ,


2

4

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

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

★★★★★

зависимость была со времён 386, часто связана с размером строки кэша
на большинстве современных архитектур ты никогда не угадаешь, как оно работает
можешь немного положиться на simd, если алгоритм под это заточен

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

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

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

i-rinat ★★★★★
()

От вставления NOP'ов у вас выравнивание данных не нарушается?

И в руководстве по оптимизации под Атлон и Опертрон написано, что нужно вставлять один NOP, а если нужно два и более байт, то перед NOP вставлять от одного до трёх operand-size overrides (66h):

NOP3_OVERRIDE_NOP TEXTEQU <DB 066h,066h,090h>
NOP4_OVERRIDE_NOP TEXTEQU <DB 066h,066h,066h,090h>
NOP5_OVERRIDE_NOP TEXTEQU <DB 066h,066h,090h,066h,090h>
NOP6_OVERRIDE_NOP TEXTEQU <DB 066h,066h,090h,066h,066h,090h>
Может у вас это явления от того, что вы 2 или более подряд NOP вставляете...

Вообще мне казалось что для более быстрой работы кода надо выравнивать начала циклов по 16-байтным границам

Да, там такой совет встречается, но, там ещё написано, что Атлон смотрит 16 байтный блок кода, выровненный по 16 байт и выполняет до трёх команд из этого блока разом. Фиг его знает, какие у вас команды выполняются одновременно при каждом варианте начального адреса блока.

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

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

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

PS. Это конечно же не в поддержку бредней ынтузиаста про «код исполняемый без участия операционной системы».

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

Это подтверждает или опровергает что-то из вышесказанного, или упомянуто просто так? Так-то можно предположить что такое сейчас нужно для реверсинга, а не оптимизаций.

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

Мейнфреймы - это отдельная ниша. Там много легаси-кода. Не только на Коболе и ПЛ/1, но и на Ассемблере. И он там довольно неплох - CISC такой, каким он должен быть, а не то, что мы видим в x86. Видно, что для людей делали.

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

чё там, компилятор PDP-11 в 2026 году хорошо работает?

ЗЫ

Я бы не был сильно уверен что какой-то gcc оптимизирует под какие-то конкретные процессоры.

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

Да, зависимые, но, может, одновременная загрузка операндов из памяти, ведь для того, чтобы сделать sbb ячейки памяти нужно сначала загрузить из неё в АЛУ... А может вобще влияет другой кусок кода, хорошо бы ТС написал минимальный рабочий пример. Может я этот бы пример попробовал на своём Атлоне, если он ещё рабочий, чтобы понять, насколько воспроизводимо наблюдаемое в стартовом посте.

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

Добавлю, почему может влиять другой код. В Software Optimization Guide for AMD Family 10h and 12h Processors написано:

For absolute optimal performance, try to limit branches to one per 16-byte code window.

Может у ТС влияет не начало цикла, а то, что его:

16c4: 75 f3 jne 16b9 <.bL11>

попадает в один 16 байтный блок с командой перехода после цикла.

Вобще, где-то в интернетах написано, что Athlon II X2 3000 МГц — это К10, но без L3-кеша, который исходно должен быть. Фиг знает, может то что наблюдает ТС исключительно Athlon II проблемы.

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

Я бы попробовал начать просто с приведенного тела цикла из 4 инструкций. rdtsc, 64 итерации цикла, rdtsc. Что-то типа того что описано вот тут на 4 странице https://arxiv.org/abs/2601.11696 только там больно современные процы относительно 20 летнего атлона

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

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

Да, там такой совет встречается, но, там ещё написано, что Атлон смотрит 16 байтный блок кода, выровненный по 16 байт и выполняет до трёх команд из этого блока разом.

Так команды пересекают эти блоки же. Ну, в момент перехода от первого быстрого к второму медленному варианту распределение команд по 16-байт блокам не меняется. При дальнейшем переходе от медленного назад к быстрому прада небольшое перераспределение есть: в последнем медленном inc rcx располагается на адресах D,E,F а в следующем за ним быстром разрезано между двумя блоками.

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

Я же отделял nop-ами цикл от всего остального (128 nop-ов до метки и 128 nop-ов после jne), закономерность сохранялась.

Да, надо минимальный пример сделать.

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

Минимальный пример оказался на удивление простым - действительно почти только этот цикл.

Ну точнее может можно и ещё меньше сделать, не проверял, но сейчас уже норм.

work.asm

/* void work(u64 *a, u64 *b, size_t length, size_t count) */
work:/* args = rdi,rsi,rdx,rcx ; must keep: rbx,rsp,rbp,r12-r15 */
	mov	r8,rcx
	lea	r9,[rsi+rdx*8]
	lea	r10,[rdi+rdx*8]
	neg	rdx
.again: /* make inner loop not cross with other labels and jumps within nearest 16-bytes */
	mov	rsi,r9
	mov	rdi,r10
	mov	rcx,rdx
	.byte 0x66,0x66,0x66,0x90
	.byte 0x66,0x66,0x66,0x90
	.byte 0x66,0x66,0x66,0x90
	.byte 0x66,0x66,0x66,0x90
	clc
.bL11:
	mov	rax,[rsi+rcx*8]
	sbb	[rdi+rcx*8],rax
	inc	rcx
	jnz	.bL11
	.byte 0x66,0x66,0x66,0x90
	.byte 0x66,0x66,0x66,0x90
	.byte 0x66,0x66,0x66,0x90
	.byte 0x66,0x66,0x66,0x90
	dec	r8
	jnz	.again
	ret

test.c

#include <sys/types.h>
#include <sys/random.h>
#include <stdio.h>
#include <time.h>

typedef unsigned long long u64;

void work(u64 *a, u64 *b, size_t length, size_t count);


    __asm(".intel_syntax noprefix");
#ifdef ZEROADD
#if ZEROADD
#define _STR(X) #X
#define STR(X) _STR(X)
    __asm(".zero " STR(ZEROADD));
#endif
#endif
    __asm(".include \"work.asm\"");
    __asm(".att_syntax\n");


static unsigned get_ticks(void) {
  struct timespec ts;
  clock_gettime(CLOCK_THREAD_CPUTIME_ID,&ts);
  return ((unsigned)ts.tv_sec)*1000 + ts.tv_nsec/1000000;
}

static u64 values[100000];

#define CYCLES 6
int main(void) {
  unsigned t0, t[CYCLES], j, k;
  size_t sz;
  
  sz = 1024*CYCLES;
  if(getrandom(values, sz, 0)!=(ssize_t)sz) { printf("getrandom() ~~~\n"); return -1; }

  for(k=0; k<CYCLES; k++) {
    t0 = get_ticks();
    for(j=0; j<1000; j++) work(values+128*k, values+128*k+64, 64, 2048);
    t[k] = get_ticks()-t0;
  }

  t0 = 0;
  for(k=0; k<CYCLES; k++) t0 += t[k];
  t0 /= CYCLES;
  printf("avg=%u |", t0);
  for(k=0; k<CYCLES; k++) printf(" %u", t[k]);
  printf("\n");
}
test.sh
for i in `seq 0 64`; do
  echo -n "ZEROADD=$i "
  gcc -o test -DZEROADD=$i test.c
  ./test
done

И проверил на линуксе на Athlon II X4 - тоже есть (неудивительно, проц почти тот же самый, хотя вроде это Phenom с чем-то там бракованным/отключённым, в отличие от X2 который изначально производился как Athlon). Однако конкретные результаты чуть отличаются, уж не знаю связано ли это с экземплярами процов, с ОС или с например с оперативкой, ниже запощу бенчмарки с обоих систем.

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

Athlon II X4 620 (2600MHz, но кажется какой-то cpufreq governor его меняет и ставит 800 при idle), Linux

На первой итерации цикл оказывается такой:

    119d:       48 8b 04 ce             mov    (%rsi,%rcx,8),%rax
    11a1:       48 19 04 cf             sbb    %rax,(%rdi,%rcx,8)
    11a5:       48 ff c1                inc    %rcx
    11a8:       75 f3                   jne    119d <.bL11>
На +0..+2,+23..+34 - замедление (первые три это хвост предыдущего)

Т.е. медленно если цикл начинается на b4..bf, разница всё так же на 30-35%.

ZEROADD=0 avg=168 | 199 162 162 162 162 162
ZEROADD=1 avg=167 | 199 162 161 162 162 161
ZEROADD=2 avg=168 | 199 162 163 162 163 162
ZEROADD=3 avg=125 | 157 119 120 119 119 119
ZEROADD=4 avg=125 | 156 120 119 119 120 119
ZEROADD=5 avg=123 | 143 119 119 120 119 119
ZEROADD=6 avg=125 | 153 120 119 119 120 119
ZEROADD=7 avg=118 | 119 118 119 118 119 118
ZEROADD=8 avg=125 | 157 120 119 120 119 119
ZEROADD=9 avg=120 | 152 114 115 114 115 114
ZEROADD=10 avg=120 | 150 115 115 114 115 115
ZEROADD=11 avg=131 | 160 125 126 126 125 126
ZEROADD=12 avg=122 | 150 116 117 117 117 116
ZEROADD=13 avg=122 | 152 117 117 116 117 117
ZEROADD=14 avg=118 | 119 118 119 118 119 119
ZEROADD=15 avg=122 | 149 117 117 116 117 117
ZEROADD=16 avg=124 | 155 118 119 118 119 118
ZEROADD=17 avg=124 | 156 119 118 119 119 118
ZEROADD=18 avg=123 | 142 120 119 119 120 119
ZEROADD=19 avg=134 | 163 129 130 129 129 129
ZEROADD=20 avg=129 | 162 123 123 124 123 124
ZEROADD=21 avg=127 | 148 124 123 123 123 124
ZEROADD=22 avg=134 | 166 128 128 128 129 127
ZEROADD=23 avg=161 | 162 161 162 162 161 162
ZEROADD=24 avg=168 | 200 161 162 162 161 162
ZEROADD=25 avg=169 | 197 164 164 164 164 164
ZEROADD=26 avg=170 | 199 164 164 164 165 164
ZEROADD=27 avg=167 | 199 162 162 161 162 161
ZEROADD=28 avg=168 | 200 163 162 162 163 162
ZEROADD=29 avg=167 | 199 160 161 161 161 161
ZEROADD=30 avg=161 | 161 163 161 161 161 161
ZEROADD=31 avg=166 | 196 161 161 161 161 160
ZEROADD=32 avg=168 | 199 162 162 162 163 162
ZEROADD=33 avg=161 | 162 161 162 162 162 162
ZEROADD=34 avg=162 | 163 162 162 163 162 162
ZEROADD=35 avg=119 | 119 120 119 119 120 119
ZEROADD=36 avg=119 | 120 119 120 119 119 120
ZEROADD=37 avg=119 | 119 120 119 119 120 119
ZEROADD=38 avg=122 | 122 123 122 122 123 122
ZEROADD=39 avg=119 | 119 121 119 118 119 118
...

Athlon II X2 3000MHz downclock to 800 MHz, FreeBSD

0000000000400992 <.bL11>:
  400992:       48 8b 04 ce             mov    (%rsi,%rcx,8),%rax
  400996:       48 19 04 cf             sbb    %rax,(%rdi,%rcx,8)
  40099a:       48 ff c1                inc    %rcx
  40099d:       75 f3                   jne    400992 <.bL11>


ZEROADD=0 avg=423 | 425 424 426 424 422 422
ZEROADD=1 avg=438 | 442 438 438 435 439 439
ZEROADD=2 avg=552 | 553 552 551 552 556 549
ZEROADD=3 avg=553 | 553 559 555 553 552 550
ZEROADD=4 avg=559 | 560 558 559 563 558 556
ZEROADD=5 avg=560 | 560 559 561 562 561 561
ZEROADD=6 avg=553 | 553 557 553 552 553 555
ZEROADD=7 avg=556 | 555 558 555 562 556 555
ZEROADD=8 avg=549 | 548 548 551 550 546 551
ZEROADD=9 avg=550 | 552 552 549 551 550 551
ZEROADD=10 avg=551 | 550 549 556 552 554 549
ZEROADD=11 avg=553 | 552 551 558 551 554 553
ZEROADD=12 avg=552 | 553 554 553 551 554 552
ZEROADD=13 avg=552 | 554 554 553 552 552 548
ZEROADD=14 avg=408 | 402 407 406 409 418 407
ZEROADD=15 avg=410 | 410 409 411 415 409 407
ZEROADD=16 avg=409 | 409 409 409 411 408 410
ZEROADD=17 avg=409 | 407 407 412 412 408 408
ZEROADD=18 avg=406 | 413 406 404 405 403 407
ZEROADD=19 avg=410 | 419 411 408 408 411 408
ZEROADD=20 avg=390 | 391 394 391 390 390 389
ZEROADD=21 avg=392 | 394 392 392 392 393 391
ZEROADD=22 avg=429 | 429 432 431 428 431 428
ZEROADD=23 avg=407 | 400 401 399 405 419 419
ZEROADD=24 avg=411 | 416 417 418 412 401 402
ZEROADD=25 avg=402 | 403 404 403 399 407 401
ZEROADD=26 avg=401 | 404 403 403 401 399 399
ZEROADD=27 avg=406 | 404 406 405 409 410 405
ZEROADD=28 avg=405 | 405 405 407 406 405 404
ZEROADD=29 avg=408 | 409 409 408 406 409 407
ZEROADD=30 avg=432 | 436 432 431 433 432 432
ZEROADD=31 avg=428 | 426 432 430 430 427 428
ZEROADD=32 avg=423 | 425 422 426 422 422 421
ZEROADD=33 avg=439 | 439 438 443 444 436 438
ZEROADD=34 avg=552 | 552 551 554 556 551 551
...

Замедление если начало 94..9f - в целом то же самое.

Однако это всё не совпадает с изначальным тестом где замедление было на ba..c5 т.е. на 6 байт ниже. Ещё не выяснил от чего зависит, но по-моему это намёк на то что границы 16-байтовых блоков с адресами заканчивающимися на 0 тут ни при чём (да и цикличность тут не 16 а 32-байтовая)

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

для начала давай построим график зависимости скорости от адреса начала цикла а не от какой-то рандомной точки.

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

Сравнил на другом компе

ZEROADD=2 avg=162 | 162 163 162 163 162 162

 Performance counter stats for './test':

       798 780 633      branches:u                                                  
        12 299 566      branch-misses:u           #    1,54% of all branches        

       0,976028543 seconds time elapsed

       0,976143000 seconds user
       0,000000000 seconds sys


ZEROADD=3 avg=127 | 169 120 119 119 120 119

 Performance counter stats for './test':

       798 780 767      branches:u                                                  
        12 311 800      branch-misses:u           #    1,54% of all branches        

       0,768513778 seconds time elapsed

       0,764845000 seconds user
       0,004004000 seconds sys

Разницы нет ни в количестве бранчей ни в промахах.

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

если я правильно понимаю, rcx должен быть отрицательным, чтоб этот цикл не завис. значит в каждом цикле CF после inc будет равно 0 кроме первого цикла. в чем тогда сакральный смысл использования SBB?

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

INC не меняет CF - это сделано ещё в 8086 специально для таких циклов, CF сохраняется от SBB из прошлого цикла.

кроме первого цикла

Кроме последнего, конечно, если бы он менял.

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

Вставление nop-ов в разные места цикла (всего 4 варианта) даёт неожиданные эффекты. Поначалу не заметил и казалось что одинаково.

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