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

Я связал некоторую сборку с некоторым 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 <stdio.h>
#include <time.h>

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

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

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

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

  • В скомпилированном байт-коде вызов функции не был оптимизирован.
  • Настройка выравнивания функций и циклов на все, от 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

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

Ответ 1

Обновление. Задержка хранения/перезагрузки 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.


При правильном (неправильном) выравнивании экстренный 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 прогона и усреднение).

Кажется, что взаимодействие между front-end не ставит в очередь так много uops впереди, что приводит к тому, что задний конец имеет более низкую задержку для хранения-пересылки с push на pop.

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


Тестовый код: bash shell loop для создания и профилирования 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 - это script, который запускает 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 (собирается в полный статический двоичный файл, который запускает цикл, а затем завершает работу, поэтому вы можете профилировать всю программу.) Прямой порт источника OP FASM без оптимизации для 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 <normal_function>:
 80480d8:       c3                      ret    
...

08048113 <normal_call>:
 8048113:       b9 00 e1 f5 05          mov    ecx,0x5f5e100
08048118 <normal_call.loop>:
 8048118:       51                      push   ecx
 8048119:       e8 ba ff ff ff          call   80480d8 <normal_function>
 804811e:       59                      pop    ecx
 804811f:       49                      dec    ecx
 8048120:       83 f9 00                cmp    ecx,0x0
 8048123:       75 f3                   jne    8048118 <normal_call.loop>
 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, согласно Agtern Fog microarch pdf, поэтому я предполагаю, что ваш процессор имеет один, хотя вы ничего не сказали о том, на какой микроархитектуре процессора вы проводили тесты.)

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

pushpop латентность - это 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:) вызывают узкие места переднего плана. Версия call имеет три ветки на итерацию: вызов, ret и ветвь цикла. Обратите внимание, что целью ветвления ret является инструкция сразу после call. Каждый из них потенциально нарушает интерфейс. Поскольку вы наблюдаете фактическое замедление на практике, мы должны видеть более 1 задержки цикла для каждой ветки. Или для версии no_call, одиночный пузырь выборки/декодирования хуже, чем около 6 циклов, что приводит к фактическому запущенному циклу при выпуске uops в нестандартную часть ядра. Это странно.

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

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

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

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

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


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

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


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

Ответ 2

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