Ответ 1
что вы должны были сделать
Если вам интересно, как ваш код компилируется в asm, поместите свой код в функцию, где он не может быть оптимизирован. На эта ссылка godbolt вы можете увидеть, как gcc 5.1 и new (at -O3 -march=native -mtune=native
) просматривают ORing вместе с байтами и используют movbe
(переместить big-endian), чтобы "на лету" загружаться. icc, clang и более старые gcc выдают инструкции, которые загружают отдельные байты и сдвигают /OR их на место.
Я был разочарован тем, что компиляторы не видели через ORing байтов, даже когда я отменил порядок, чтобы сделать нагрузку little-endian вместо нагрузки на большой конец. (см. родные, большие и малоконечные функции на godbolt.) Даже изменение типов на uint32_t и uint8_t не помогло компиляторам, отличным от gcc >= 5.1.
Очевидно, с оптимизацией on, компиляторы отбрасывают петли, которые просто устанавливают неиспользованную переменную. Они просто называют clock
дважды, printf, а затем mov-немедленный ответ в eax
. Если вы хотите что-то сравнить, скомпилируйте его отдельно от функции, которая будет вызывать ее с постоянными данными. Таким образом, вы можете иметь простой код, который выполняет свою работу как аргументы функции, и он не может попасть в вызывающий, который передает ему постоянные данные.
Кроме того, gcc рассматривает main
как "холодную" функцию и не оптимизирует ее так же сильно, как обычные функции. В большинстве программ main
не содержит внутреннего цикла.
Почему asm -O0
так медленно?
Очевидно, что код ужасен из -O0
, сохраняется в памяти и даже увеличивает счетчик циклов в памяти. Мне все же интересно узнать, почему он работает еще медленнее, чем я ожидал, CODE1 под одним insn за часы.
Вы не показывали весь цикл для любой части кода. Вероятно, удаление тела петли все равно оставит медленный цикл. Я думаю, что сам цикл является проблемой и настолько неэффективен, что время для процессора выполняет все дополнительные инструкции в CODE2, не наблюдая замедление.
TL; DR: обе петли узки в узле add $1, -0x24(%rbp)
, что увеличивает счетчик циклов в памяти. 6 задержка цикла в цепочке зависимостей, зависящей от цикла, объясняет, почему обе петли с узкой полосой пропускания имеют одинаковую скорость.
Я не знаю точно, почему дополнительные инструкции в CODE2 каким-то образом помогают приблизиться к 6 циклам на теоретический максимум итерации, но это не узкое место, которое должно появиться в коде, который кто-либо когда-либо напишет. Храните ваши счетчики циклов в регистрах и не включайте в них команду чтения-изменения-записи одного и того же адреса в цепочке зависимостей, связанной с циклом. (увеличение объема памяти на разных адресах, каждая итерация прекрасна, например, для CountingSort.)
См. godbolt для изменений, внесенных мной в код. (С ИТЕРАЦИЯми, набитыми в 100 раз, поэтому время выполнения доминирует над шумом служебных сообщений при запуске.) Эта ссылка оптимизирована с учетом первых трех функций.
godbolt не имеет режима C, только С++, и я получил менее плохой цикл из gcc 4.9.2 локально, чем показывает godbolt. (g++ реализует цикл for(){}
точно так же, как написано, с cmp/jcc в верхней части и jmp внизу. gcc даже в -O0
использует структуру do{} while(count++ < ITERATIONS);
, всего лишь cmp/jle внизу.
Я не знаю никакой реальной сборки, но могу читать вывод GCC -S оценить фактические затраты на данный код C.
Ну, привык думать: "Да, некоторые операции, такие как MUL, довольно дорого, но если одна сборка в X раз больше другой, должен быть медленнее".
Первое, на что нужно обратить внимание - это узкие места пропускной способности и задержки. В Intel это означает, что 4 часа на одну тактовую пропускную способность или меньше, если длинная цепочка зависимостей является пределом. Затем есть узкие места для каждого исполнения. Например. два операнда памяти за такт, причем максимум один из них является хранилищем. Не более одного mul
за такт, потому что только один из 3 портов ALU имеет целочисленный множитель.
См. сайт Agner Fog для руководств по оптимизации, документов микроархитектуры и таблиц задержек/пропускных способностей/портов/портов команд, которые они могут запускать на.
Ваши петли плохо помешаны, сохраняя счетчик циклов в памяти. В SandyBridge (моя система) и Haswell (ваш) таблица Agner Fog имеет задержку add
с местом назначения памяти в 6 тактов. Нет способа, чтобы он мог работать быстрее, чем одна итерация за 6 тактов за итерацию. С 6 инструкциями в цикле, что 1 insn за цикл.
На практике я получаю меньше пропускной способности. Возможно, хранилище как часть операции чтения-изменения-записи add
иногда задерживается другими нагрузками/хранилищами в цикле. IDK, почему CODE2 немного быстрее, это странно. Может быть, он заказывает вещи по-другому, так что зависимость от цикла add
задерживается реже.
Тело цикла с использованием lea
и 32-разрядной нагрузкой, очевидно, быстрее. IDK, почему вы думаете, что это медленный lea
.
Это не проблема с выравниванием/uop-кешем. Цикл должен перетекать из буфера цикла в любом случае, даже если в блоке кода 32B было больше 18 uops (это означает, что он не может попасть в кеш-память). Фундаментальные узкие места (за исключением неверных событий в отрасли, которых у нас нет) не могут быть проблемой, когда наши insns на часы настолько низки. Интерфейс может легко поддерживать большое количество устройств, стоящих в очереди для диспетчера отправки.
От perf report
, профилирующие часы, взятые по каждой команде: внутренний цикл CODE1. Счета не являются точными. Вероятно, мы видим, что процессор застрял в инструкциях сразу после add $1, mem
, что, я уверен, является зависимой от цикла с узким местом. Он должен переслать хранилище на нагрузку на следующей итерации, которая по-прежнему занимает 6 часов.
###### CODE1 inner loop, profiling on cycles
13.97 │400680: lea -0x10(%rbp),%rax
│400684: mov (%rax),%eax
│400686: mov %eax,-0x2c(%rbp)
│400689: addl $0x1,-0x24(%rbp)
13.84 │40068d: cmpl $0x89543f,-0x24(%rbp)
72.19 │400694: ↑ jle 400680 <code1+0x4a>
## end of the loop
400696: callq 4004e0 <[email protected]>
40069b: sub -0x18(%rbp),%rax
#CODE2
15.08 │400738: movzbl -0xd(%rbp),%eax
0.88 │40073c: movzbl %al,%eax
0.05 │40073f: movzbl -0xe(%rbp),%edx
│400743: movzbl %dl,%edx
13.91 │400746: shl $0x8,%edx
0.70 │400749: or %eax,%edx
0.05 │40074b: movzbl -0xf(%rbp),%eax
│40074f: movzbl %al,%eax
12.70 │400752: shl $0x10,%eax
0.60 │400755: or %eax,%edx
0.09 │400757: movzbl -0x10(%rbp),%eax
0.05 │40075b: movzbl %al,%eax
13.03 │40075e: shl $0x18,%eax
0.70 │400761: or %edx,%eax
0.14 │400763: mov %eax,-0x2c(%rbp)
0.09 │400766: addl $0x1,-0x24(%rbp)
13.63 │40076a: cmpl $0x89543f,-0x24(%rbp)
28.29 │400771: ↑ jle 400738 <code2+0x4a>
## end of the loop
400773: → callq 4004e0 <[email protected]>
400778: sub -0x18(%rbp),%rax
Ничего себе, это довольно весело. gcc выполняет резервирование movzbl %al, %eax
после загрузки %eax
из 8-разрядной ячейки памяти с помощью movzbl
.
Итак, в 6 часах на итерацию, может ли процессор обрабатывать всю занятую работу по загрузке комбинированных байтов? Да.
- 4x
movzx reg, mem
: 4 порта загрузки. (Р2/р3) - 4x
movzx reg, reg
: 4 uops для любого порта ALU (p015) - 3x
shl reg, imm
: 3 канала для портов ALU p0/p5 - 3x
or reg, reg
: 3 uops для любого порта ALU (p015) - 1x
mov mem, reg
: 1 uop fused-domain: 1 store-data (p4), 1 store-address (p23) - 1x
add mem, imm
: 2 fused-domain. не используется: 1 ALU uop (p015), 1 load uop (p23), 1 файл-хранилище (p4), 1 адрес-магазин (p23) - 1x
cmp mem, imm
: 1 uop для p015, 1 для p23. - 1x
jle
: 1 uop для p5. (не может макро-fuse с cmp, из-за imm и mem)
общее количество скомпилированных доменов: 4 + 4 + 3 + 3 + 1 + 2 + 1 + 1 = 19. Это подходит для буфера потока 28uop, избегая любой возможности узких мест uop-cache и может выдавать в 5 часы. (В 4 раза за цикл, причем последний цикл выдает только 3).
load uops: 4 + 1 + 1 = 6. store uops: 2.
ALU uops: 4 + 3 + 3 + 1 + 1 + 1 = 13. SnB 3 ALU порты uop могут обрабатывать это в 4,33 часах. Большинство устройств могут работать на любом порту, поэтому ни один порт не является узким местом. (Haswell имеет 4-й порт ALU, p6. У него еще более простое время. Но ALU uops не является узким местом.)
Задержка течений контуров не имеет значения, поскольку следующая итерация не считывает никакого результата. Каждая итерация считывает некоторые данные и сохраняет их независимо от того, что делала предыдущая итерация. Многие петли похожи на это. Обычно для таких циклов каждый раз загружается и сохраняется на другой адрес, но процессор просто делает то, что он сказал.
В любом случае, даже если цепочка зависимостей внутри каждого цикла занимает более 6 часов, работа с несколькими итерациями может быть в полете. Ничто в одной итерации не должно ждать предыдущего, за исключением приращения счетчика контуров с местом назначения памяти.
Таким образом, все, что работает в цикле CODE2, не является узким местом.
Для SnB/HSW добавление-немедленное с назначением памяти - 2 раза, в то время как инк по адресу памяти - 3, согласно таблице Agner Fog, что странно. Интересно, была ли эта ошибка или если процессоры Intel на самом деле медленнее при использовании inc
в месте назначения памяти вместо add $1
?
Тестовые тайминги (из gcc 4.9.2). Я не вижу ничего очевидного, чтобы объяснить, почему CODE2 приближается к теоретическому максимуму одной итерации за 6 тактов. Мое единственное предположение: CODE1 путается с помощью call
сразу после jle
, но CODE1 нет? Возможно, перфоманс на uops
Sandybridge i5 (2500k):
## CODE1 ##
Performance counter stats for './a.out 1' (4 runs):
589.117019 task-clock (msec) # 0.999 CPUs utilized ( +- 1.30% )
2,068,118,847 cycles # 3.511 GHz ( +- 0.48% )
1,729,505,746 instructions # 0.84 insns per cycle
# 0.86 stalled cycles per insn ( +- 0.01% )
2,018,533,639 uops_issued_any # 3426.371 M/sec ( +- 0.02% )
5,648,003,370 uops_dispatched_thread # 9587.235 M/sec ( +- 2.51% )
3,170,497,726 uops_retired_all # 5381.779 M/sec ( +- 0.01% )
2,018,126,488 uops_retired_retire_slots # 3425.680 M/sec ( +- 0.01% )
1,491,267,343 stalled-cycles-frontend # 72.11% frontend cycles idle ( +- 0.66% )
27,192,780 stalled-cycles-backend # 1.31% backend cycles idle ( +- 68.75% )
0.589651097 seconds time elapsed ( +- 1.32% )
Очень необычно видеть, что uops_dispatched_thread не соответствует uops_retired_all. Они, как правило, одинаковы и равны количеству неиспользуемых uops для инструкций в цикле. Fused-domain uops_issued_any и uops_retired_retire_slots обычно равны, и они в этом случае. Может быть, операторы ALU для адресации памяти по-разному рассчитываются в отправленном или retired_all? (Микро-синтез). Я думаю, что мое предыдущее тестирование только рассматривало микро-слияние нагрузок.
Я не думаю, что он выпускает uops, которые не нужны. (Это не проблема с ошибкой ветки, я проверил, и в обеих версиях есть ошибочные предсказания с ошибкой 0.00% (только ~ 10k для 288M ветвей).)
## CODE2 ##
[email protected]:~/src/SO$ ocperf.py stat -r4 -e task-clock,cycles,instructions,uops_issued.any,uops_dispatched.thread,uops_retired.all,uops_retired.retire_slots,stalled-cycles-frontend,stalled-cycles-backend ./a.out 2
perf stat -r4 -e task-clock,cycles,instructions,cpu/event=0xe,umask=0x1,name=uops_issued_any/,cpu/event=0xb1,umask=0x1,name=uops_dispatched_thread/,cpu/event=0xc2,umask=0x1,name=uops_retired_all/,cpu/event=0xc2,umask=0x2,name=uops_retired_retire_slots/,stalled-cycles-frontend,stalled-cycles-backend ./a.out 2
CODE2: ts: 16499
CODE2: ts: 16535
CODE2: ts: 16517
CODE2: ts: 16529
Performance counter stats for './a.out 2' (4 runs):
543.886795 task-clock (msec) # 0.999 CPUs utilized ( +- 1.01% )
2,007,623,288 cycles # 3.691 GHz ( +- 0.01% )
5,185,222,133 instructions # 2.58 insns per cycle
# 0.11 stalled cycles per insn ( +- 0.00% )
5,474,164,892 uops_issued_any # 10064.898 M/sec ( +- 0.00% )
7,586,159,182 uops_dispatched_thread # 13948.048 M/sec ( +- 0.00% )
6,626,044,316 uops_retired_all # 12182.764 M/sec ( +- 0.00% )
5,473,742,754 uops_retired_retire_slots # 10064.121 M/sec ( +- 0.00% )
566,873,826 stalled-cycles-frontend # 28.24% frontend cycles idle ( +- 0.03% )
3,744,569 stalled-cycles-backend # 0.19% backend cycles idle ( +- 2.32% )
0.544190971 seconds time elapsed ( +- 1.01% )
В согласованном домене, выданном и retired_slots соответствует CODE2.