Цикл с вызовом функции быстрее, чем пустой цикл

Я связал некоторую сборку с некоторым c, чтобы проверить стоимость вызова функции, со следующей сборкой и c-источником (используя fasm и gcc соответственно)

монтаж:

format ELF public no_call as "_no_call" public normal_call as "_normal_call" section '.text' executable iter equ 100000000 no_call: mov ecx, iter @@: push ecx pop ecx dec ecx cmp ecx, 0 jne @b ret normal_function: ret normal_call: mov ecx, iter @@: push ecx call normal_function pop ecx dec ecx cmp ecx, 0 jne @b ret 

c источник:

 #include  #include  extern int no_call(); extern int normal_call(); int main() { clock_t ct1, ct2; ct1 = clock(); no_call(); ct2 = clock(); printf("\n\n%d\n", ct2 - ct1); ct1 = clock(); normal_call(); ct2 = clock(); printf("%d\n", ct2 - ct1); return 0; } 

Результаты, которые я получил, были удивительными. Прежде всего, скорость зависела от того порядка, в котором я связан. Если я связан как gcc intern.o extern.o , типичный вывод

 162 181 

Но связывание в обратном порядке gcc extern.o intern.o , я получил результат больше:

 162 130 

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

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

Изменить: просто чтобы упомянуть некоторые из вещей, которые пытались в комментариях:

  • В скомпилированном байт-коде функции вызова не были оптимизированы.
  • Настройка выравнивания функций и циклов на все, от 4 до 64 байтов, не ускорила no_call, хотя некоторые выравнивания замедляли normal_call
  • Предоставление процессору / ОС возможности разогреться, вызывая функции несколько раз, а не только один раз, не имело заметного эффекта от измеренных длительностей времени, также не меняет порядок вызовов или работает отдельно
  • Запуск в течение более длительного времени не влияет на коэффициент, например, работает в 1000 раз дольше. Я получил 162.168 и 131.578 секунд для моего времени выполнения

Кроме того, после изменения кода сборки для выравнивания по байтам, я тестировал предоставление дополнительного набора функций и пришел к еще более странным выводам. Вот обновленный код:

 format ELF public no_call as "_no_call" public normal_call as "_normal_call" section '.text' executable iter equ 100000000 offset equ 23 ; this is the number I am changing times offset nop times 16 nop no_call: mov ecx, iter no_call.loop_start: push ecx pop ecx dec ecx cmp ecx, 0 jne no_call.loop_start ret times 55 nop normal_function: ret times 58 nop normal_call: mov ecx, iter normal_call.loop_start: push ecx call normal_function pop ecx dec ecx cmp ecx, 0 jne normal_call.loop_start ret 

Мне пришлось вручную (и не переносимо) принудительно выравнивать 64 байта, так как FASM не поддерживает более 4 байтовых выравниваний для исполняемого раздела, по крайней мере, на моей машине. Смещение программы по offset байтам, вот что я нашел.

 if (20 <= offset mod 128 <= 31) then we get an output of (approximately): 162 131 else 162 (+/- 10) 162 (+/- 10) 

Не уверен, что с этим делать, но это то, что я обнаружил до сих пор

Изменить 2:

Еще одна вещь, которую я заметил, заключается в том, что если вы удалите push ecx и pop ecx из обеих функций, выход будет

 30 125 

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

Обновление: Задержка хранения / перезагрузки Skylake достигает 3 с , но только если время правильное . Последовательные нагрузки, задействованные в цепочке зависимостей перенаправления хранения, которые естественно распределены на 3 или более циклов, будут испытывать более высокую задержку (например, с 4 imul eax,eax в цикле, mov [rdi], eax / mov eax, [rdi] только цикл отсчитывает от 12 до 15 циклов на итерацию.), но когда нагрузки позволяют выполнять более плотно, чем один, страдают некоторые виды соперничества, и вы получаете около 4,5 циклов на итерацию. Нецелевая средняя пропускная способность также является большой подсказкой, что есть что-то необычное.

Я видел тот же эффект для векторов 32B (наилучший вариант 6.0c, back-to-back 6.2 до 6.9c), но векторы 128b всегда были около 5.0c. См. Подробности на форуме Agner Fog .

Обновление2: добавление избыточного назначения ускоряет кодирование при компиляции без оптимизации и в блоге в блоге 2013 года указывает, что этот эффект присутствует на всех процессорах семейства Sandybridge .

Задержка резервного копирования в худшем случае на Skylake на 1 цикл лучше, чем на предыдущих урче, но изменчивость, когда загрузка не может выполнить сразу, схожа.


При правильном (неправильном) выравнивании дополнительный call в цикле может фактически помочь Skylake наблюдать более низкую задержку пересылки в магазине от push to pop. Я смог воспроизвести это с помощью perf counters (Linux perf stat -r4 ), используя YASM. (Я слышал, что менее удобно использовать перфекционные счетчики в Windows, и в любом случае у меня нет машины Windows dev. К счастью, ОС не имеет отношения к ответу: кто-то должен иметь возможность воспроизвести результаты моего перм-счетчика на Windows с VTune или что-то в этом роде.)

Я видел более быстрые времена при смещении = 0..10, 37, 63-74, 101 и 127 после align 128 в месте, указанном в вопросе. Линии кэша L1I равны 64B, а uop-cache заботится о границах 32B. Он выглядит как выравнивание относительно границы 64B.

Цикл без вызова является постоянным 5 циклов всегда, но цикл call может опускаться до 4c на итерацию из его обычных почти точно-5 циклов. Я видел более медленную, чем обычно, производительность при смещении = 38 (5,68 + – 8,3% циклов на итерацию). Есть небольшие сбои в других точках, например 5.17c + – 3.3%, в соответствии с perf stat -r4 (что делает 4 прогона и усреднение).

Похоже, что взаимодействие между интерфейсом не в очереди, так много впереди, что приводит к тому, что задний конец имеет более низкую задержку для пересылки в хранилище от push to pop.

IDK, если повторное использование одного и того же адреса повторно для пересылки хранилища делает его более медленным (с несколькими адресами хранилища, уже выполненными перед соответствующими данными хранения данных), или что.


Тестовый код: цикл оболочки bash для сборки и профилирования asm с каждым другим смещением :

 (set -x; for off in {0..127};do asm-link -m32 -d call-tight-loop.asm -DFUNC=normal_call -DOFFSET=$off && ocperf.py stat -etask-clock,context-switches,cpu-migrations,page-faults:u,cycles,instructions,uops_issued.any,uops_executed.thread,idq.mite_uops,dsb2mite_switches.penalty_cycles -r4 ./call-tight-loop; done ) |& tee -a call-tight-loop.call.offset-log 

(set -x) в подоболочке – это удобный способ записи команд вместе с их выходом при перенаправлении в файл журнала.

asm-link – это скрипт, который запускает yasm -felf32 -Worphan-labels -gdwarf2 call-tight-loop.asm "[email protected]" && ld -melf_i386 -o call-tight-loop call-tight-loop.o , затем запускает objdumps -drwC -Mintel результат.

Программа тестирования NASM / YASM Linux (собирается в полный статический двоичный файл, который запускает цикл, а затем завершает работу, поэтому вы можете профилировать всю программу.) Прямой порт источника FASM OP, без оптимизации для asm.

 CPU p6 ; YASM directive. For NASM, %use smartalign. section .text iter equ 100000000 %ifndef OFFSET %define OFFSET 0 %endif align 128 ;;offset equ 23 ; this is the number I am changing times OFFSET nop times 16 nop no_call: mov ecx, iter .loop: push ecx pop ecx dec ecx cmp ecx, 0 jne .loop ret times 55 nop normal_function: ret times 58 nop normal_call: mov ecx, iter .loop: push ecx call normal_function pop ecx dec ecx cmp ecx, 0 jne .loop ret %ifndef FUNC %define FUNC no_call %endif align 64 global _start _start: call FUNC mov eax,1 ; __NR_exit from /usr/include/asm/unistd_32.h xor ebx,ebx int 0x80 ; sys_exit(0), 32-bit ABI 

Пример вывода из быстрого call :

 + asm-link -m32 -d call-tight-loop.asm -DFUNC=normal_call -DOFFSET=3 ... 080480d8 : 80480d8: c3 ret ... 08048113 : 8048113: b9 00 e1 f5 05 mov ecx,0x5f5e100 08048118 : 8048118: 51 push ecx 8048119: e8 ba ff ff ff call 80480d8  804811e: 59 pop ecx 804811f: 49 dec ecx 8048120: 83 f9 00 cmp ecx,0x0 8048123: 75 f3 jne 8048118  8048125: c3 ret ... Performance counter stats for './call-tight-loop' (4 runs): 100.646932 task-clock (msec) # 0.998 CPUs utilized ( +- 0.97% ) 0 context-switches # 0.002 K/sec ( +-100.00% ) 0 cpu-migrations # 0.000 K/sec 1 page-faults:u # 0.010 K/sec 414,143,323 cycles # 4.115 GHz ( +- 0.56% ) 700,193,469 instructions # 1.69 insn per cycle ( +- 0.00% ) 700,293,232 uops_issued_any # 6957.919 M/sec ( +- 0.00% ) 1,000,299,201 uops_executed_thread # 9938.695 M/sec ( +- 0.00% ) 83,212,779 idq_mite_uops # 826.779 M/sec ( +- 17.02% ) 5,792 dsb2mite_switches_penalty_cycles # 0.058 M/sec ( +- 33.07% ) 0.100805233 seconds time elapsed ( +- 0.96% ) 

Старый ответ, прежде чем замечать переменную задержку хранения в магазине

Вы нажимаете / выписываете свой счетчик циклов, поэтому все, кроме команд call и retcmp / jcc ), является частью цепи зависимостей с критической цепью, связанной с счетчиком циклов.

Вы ожидаете, что pop придется ждать обновления указателя стека по call / ret , но механизм стека обрабатывает эти обновления с нулевой задержкой . (Intel с Pentium-M, AMD с K10, согласно microarch pdf Agner Fog , поэтому я предполагаю, что у вашего процессора есть один, хотя вы ничего не сказали о том, на какой микроархитектуре процессора вы проводили свои тесты.)

Дополнительный call / ret прежнему необходимо выполнить, но выполнение вне очереди может поддерживать выполнение команд критического пути с максимальной пропускной способностью. Так как это включает в себя латентность хранения -> переадресация нагрузки из push / pop + 1 цикла для dec , это не высокая пропускная способность на любом процессоре, и это удивительно, что интерфейсный сервер может быть узким местом с любым выравниванием.

push -> pop latency – 5 циклов на Skylake, согласно Agner Fog, поэтому на этом уроне ваш цикл может работать в лучшем случае с одной итерацией за 6 циклов. Это достаточно времени для выполнения внеочередного исполнения для выполнения call и ret инструкций. Agner перечисляет максимальную пропускную способность для call одного на 3 цикла и ret на один за 1 цикл. Или на AMD Bulldozer, 2 и 2. В его таблицах ничего не говорится о пропускной способности пары call / ret , поэтому IDK могут ли они перекрываться или нет. На AMD Bulldozer задержка хранения / перезагрузки с mov составляет 8 циклов. Я предполагаю, что это примерно то же самое с push / pop.

Похоже, что различные выравнивания для вершины цикла (т.е. no_call.loop_start: вызывают узкие места переднего no_call.loop_start: . Версия call имеет 3 ветки на итерацию: вызов, ret и ветвь цикла. Обратите внимание, что цель филиала ret является инструкцией сразу после call . Каждый из них потенциально нарушает интерфейс. Поскольку вы наблюдаете фактическое замедление на практике, мы должны видеть более 1 задержки цикла для каждой ветви. Или для версии no_call, одиночный пузырь выборки / декодирования хуже, чем около 6 циклов, что приводит к фактическому запущенному циклу при выпуске uops в нестандартную часть ядра. Это странно.

Слишком сложно догадаться, какие фактические микроархитектурные детали для каждого возможного уарха, поэтому дайте нам знать, на каком CPU вы протестировали.

Я упомянул хотя то, что push / pop внутри цикла на Skylake останавливает его от выдачи из Loop Stream Detector, и его нужно повторно извлекать из кеша uop каждый раз. Руководство по оптимизации Intel говорит, что для Sandybridge несоответствующий push / pop внутри цикла останавливает его от использования LSD. Это означает, что он может использовать LSD для петель со сбалансированным push / pop. В моем тестировании это не относится к Skylake (с использованием lsd.uops производительности lsd.uops ), но я не видел упоминания о том, было ли это изменение, или действительно ли это был SnB.

Кроме того, безусловные ветви всегда заканчивают линию uop-cache. Возможно, что с normal_function: в том же естественно выровненном блоке 32B машинного кода как call и jne , возможно, блок кода не вписывается в кеш-память. (Только 3 строки кэш-кеша могут кэшировать декодированные uops для одного 32-битового кода x86). Но это не объясняет возможности проблем для цикла no_call, поэтому вы, вероятно, не работаете в микроархитектуре семейства Intel SnB.

(Обновление, да, цикл иногда выполняется в основном из устаревшего декодирования ( idq.mite_uops ), но обычно не исключительно. dsb2mite_switches.penalty_cycles обычно ~ 8k и, вероятно, происходит только при прерываниях таймера. dsb2mite_switches.penalty_cycles где цикл call работает быстрее, коррелировать с более низким idq.mite_uops , но он по-прежнему 34M + – 63% для случая offset = 37, где итерации 100M занимали 401M циклов.)

Это действительно один из тех случаев «не делай этого»: встроенные крошечные функции вместо вызова изнутри очень плотных циклов.


Вы можете увидеть разные результаты, если вы нажмете / pop регистр, отличный от вашего счетчика циклов. Это отделит push / pop от счетчика циклов, поэтому будет две отдельные цепи зависимостей. Он должен ускорить как версию вызова, так и no_call, но, возможно, неравномерно. Это могло бы просто сделать узкое место в интерфейсе более очевидным.

Вы должны увидеть огромное ускорение, если вы push edx но pop eax , поэтому инструкции push / pop не образуют цепочку зависимостей, связанных с циклом. Тогда дополнительный call / ret определенно станет узким местом.


Side-note: dec ecx уже устанавливает ZF так, как вы хотите, поэтому вы могли бы просто использовать dec ecx / jnz . Кроме того, cmp ecx,0 менее эффективен, чем test ecx,ecx (больший размер кода и не может замаскировать на столько CPU). Во всяком случае, совершенно не имеет отношения к вопросу об относительной производительности ваших двух циклов. (Отсутствие директивы ALIGN между функциями означает, что изменение первого изменило бы выравнивание ветви цикла во втором, но вы уже исследовали разные выравнивания.)

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

  • Примеры предварительной выборки?
  • Проверьте, равен ли регистр нулю с помощью CMP reg, 0 против OR reg, reg?
  • Как скомпилировать и запустить программу C в Sublime Text 2?
  • C #: зачем подписывать сборку?
  • Maven: добавьте зависимость к банке относительным путем
  • Код C ++ для проверки гипотезы Collatz быстрее, чем assembly вручную - почему?
  • Ошибка в построении gradleа после обновления Android Studio с log4j
  • Почему XCHG reg, reg 3 инструкции по микрооперации на современных архитектурах Intel?
  • Как определить, была ли assembly .NET построена для x86 или x64?
  • Эффективное умножение матрицы 4x4 (C vs assembly)
  • использование ILMerge с библиотеками .NET 4
  • Давайте будем гением компьютера.