Является ли leaq THAT медленным или есть другая причина, потому что сборка сборок медленнее, чем более длинная?

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

Этот вопрос не столько о профилировании и контрольных показателях, сколько об образовании. Мне нужно, чтобы кто-то объяснил мне, почему [1] фрагмент не быстрее второго.

Ну, привык думать: "Да, некоторые операции, такие как MUL, довольно дороги, но если одна сборка в X раз больше другой, она должна быть медленнее".

Это было верно, пока я не встретил этих двух:

unsigned char bytes[4] = {0, 0, 0, 5};

// 1
int32_t val = *((int32_t*)bytes);      
/* produces:
        leaq    -16(%rbp), %rax
        movl    (%rax), %eax
        movl    %eax, -4(%rbp)
        movl    $0, %eax
*/

// 2   
val = bytes[3] |                               
      (bytes[2] << 8) |                        
      (bytes[1] << 16) |
      (bytes[0] << 24);
/* produces: 
        movzbl  -13(%rbp), %eax
        movzbl  %al, %eax
        movzbl  -14(%rbp), %edx
        movzbl  %dl, %edx
        sall    $8, %edx
        orl     %eax, %edx
        movzbl  -15(%rbp), %eax
        movzbl  %al, %eax
        sall    $16, %eax
        orl     %eax, %edx
        movzbl  -16(%rbp), %eax
        movzbl  %al, %eax
        sall    $24, %eax
        orl     %edx, %eax
        movl    %eax, -4(%rbp)
        movl    $0, %eax
*/

И тесты показывают, что 2-й на 5-10% быстрее. Что здесь происходит?

Единственное существенное различие и "разум", которые я могу себе представить, - это LEAQ - это что-то очень медленное. Последние 2 строки идентичны, поэтому, возможно, цена MOV настолько высока, что 1 дополнительный MOV хуже, чем тонны инструкций.

Вот что я использовал для измерения времени выполнения:

#include <stdio.h>
#include <time.h>
#include <stdint.h>

#define REPETITIONS 32
#define ITERATIONS 90000

#define CODE1                   \
  for (int i = 0; i < ITERATIONS; ++i) {    \
    val = *((int32_t*)bytes);           \
  }

#define CODE2                   \
  for (int i = 0; i < ITERATIONS; ++i) {    \
    val = bytes[3] |                \
      (bytes[2] << 8) |             \
      (bytes[1] << 16) |            \
      (bytes[0] << 24);             \
  }

int main(void) {
  clock_t minTs = 999999999, ts;
  unsigned char bytes[4] = {0, 0, 0, 5};    
  int32_t val;                  

  for (int i = 0; i < REPETITIONS; ++i) {
    ts = clock();

    CODE1; // Or CODE2

    ts = clock() - ts;
    if (ts < minTs) minTs = ts;
  }

  printf("ts: %ld\n", minTs);

  return val;
}


Обновить: как оказалось, результаты специфичны для аппаратного обеспечения, поэтому пока [1] работает медленнее на моем ноутбуке (x64 i5-4260U), он быстрее работает на моем ПК (но очень малой фракцией, например 5%).

Ответ 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.