Почему эта разница в asm имеет значение для производительности (в неоптимизированном цикле ptr++ vs. ++ptr)?

TL; DR: первый цикл выполняется на 18% быстрее на процессоре Haswell. Зачем? Петли из gcc -O0 (неоптимизированные) циклы с использованием ptr++ против ++ptr, но вопрос в том, почему полученный asm работает по-другому, а не о том, как лучше написать C.


Допустим, у нас есть эти две петли:

    movl    $0, -48(%ebp)     //Loop counter set to 0
    movl    $_data, -12(%ebp) //Pointer to the data array
    movl    %eax, -96(%ebp)
    movl    %edx, -92(%ebp)
    jmp L21
L22:
    // ptr++
    movl    -12(%ebp), %eax   //Get the current address
    leal    4(%eax), %edx     //Calculate the next address
    movl    %edx, -12(%ebp)   //Store the new (next) address
    // rest of the loop is the same as the other
    movl    -48(%ebp), %edx   //Get the loop counter to edx
    movl    %edx, (%eax)      //Move the loop counter value to the CURRENT address, note -12(%ebp) contains already the next one
    addl    $1, -48(%ebp)     //Increase the counter
L21:
    cmpl    $999999, -48(%ebp)
    jle     L22

и второй:

    movl    %eax, -104(%ebp)
    movl    %edx, -100(%ebp)
    movl    $_data-4, -12(%ebp) //Address of the data - 1 element (4 byte)
    movl    $0, -48(%ebp)       //Set the loop counter to 0
    jmp L23
L24:
    // ++ptr
    addl    $4, -12(%ebp)       //Calculate the CURRENT address by adding one sizeof(int)==4 bytes
    movl    -12(%ebp), %eax     //Store in eax the address
    // rest of the loop is the same as the other
    movl    -48(%ebp), %edx     //Store in edx the current loop counter
    movl    %edx, (%eax)        //Move the loop counter value to the current stored address location
    addl    $1, -48(%ebp)       //Increase the loop counter
L23:
    cmpl    $999999, -48(%ebp)
    jle L24

Эти циклы делают то же самое, но немного по-другому, пожалуйста, обратитесь к комментарию для деталей.

Этот код asm генерируется из следующих двух циклов C++:

    //FIRST LOOP:
    for(;index<size;index++){
        *(ptr++) = index;
    }
    //SECOND LOOP:
    ptr = data - 1;
    for(index = 0;index<size;index++){
        *(++ptr) = index;
    }

Теперь первый цикл примерно на 18% быстрее второго, независимо от того, в каком порядке выполняются циклы с ptr++ быстрее, чем тот, с ++ptr,

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


ASM анализ

Глядя на код ASM, второй цикл содержит меньше инструкций, у нас есть 3 movl и 2 addl, тогда как в первом цикле у нас есть 4 movl, один addl и один leal, поэтому у нас есть еще один movl more и один leal вместо addl.

Это правильно, что LEA операция по вычислению правильного адреса выполняется намного быстрее, чем ADD (+4) метод? Это причина разницы в производительности?

Насколько я знаю, после вычисления нового адреса до обращения к памяти некоторые тактовые циклы должны истечь, поэтому второй цикл после addl $4,-12(%ebp) нужно немного подождать, прежде чем продолжить, тогда как в В первом цикле мы можем сразу обратиться к памяти, а тем временем LEAL вычислит следующий адрес (что-то вроде лучшей производительности конвейера здесь).

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

1 ответ

Решение

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


Верно ли, что операция LEAL для вычисления правильного адреса выполняется намного быстрее, чем метод ADDL (+4)? Это причина разницы в производительности?

Нету, add может работать на каждом порту исполнения ALU на любом процессоре x86. lea обычно имеет низкую задержку с простыми режимами адресации, но не так хорошо, как пропускная способность. В Atom он работает на другом этапе конвейера по сравнению с обычными инструкциями ALU, потому что фактически соответствует своему названию и использует AGU в этой упорядоченной микроархитектуре.

Смотрите вики-теги x86, чтобы узнать, что делает код медленным или быстрым на разных микроархитектурах, особенно. Микроархитектура Агнера Фога pdf и таблицы инструкций.

add только хуже, потому что это позволяет GCC -O0 сделать код еще хуже, используя его с адресом памяти и затем загружая его.


Компилирование с -O0 даже не пытается использовать лучшие инструкции для работы. например, вы получите mov $0, %eax вместо xor %eax,%eax Вы всегда получаете оптимизированный код. Вы не должны делать вывод о том, что хорошо, глядя на неоптимизированный вывод компилятора.

-O0 код всегда полон узких мест, обычно при загрузке / хранении или пересылке в магазин. К сожалению, IACA не учитывает задержку пересылки магазина, поэтому не осознает, что эти петли на самом деле являются узким местом


Насколько я знаю, после вычисления нового адреса до обращения к памяти некоторые тактовые циклы должны истечь, поэтому второй цикл после добавления $4,-12(%ebp) нужно немного подождать, прежде чем продолжить,

Да, mov нагрузка -12(%ebp) не будет готов в течение примерно 6 циклов после нагрузки, которая была частью add читай-модифицируй-пиши.

тогда как в первом цикле мы можем сразу обратиться к памяти

да

и тем временем LEAL вычислит следующий адрес

Нет.

Ваш анализ близок, но вы упустили тот факт, что следующая итерация все еще должна загрузить значение, которое мы сохранили в -12(%ebp), Таким образом, цепочка зависимостей, переносимая циклами, имеет ту же длину, и следующая итерация lea на самом деле не может начаться раньше, чем в цикле, используя add


Проблемы с задержкой могут не быть узким местом пропускной способности цикла:

Пропускная способность порта UOP / выполнения должна быть рассмотрена. В этом случае тестирование OP показывает, что это действительно актуально. (Или задержки из-за конфликтов ресурсов.)

Когда GCC -O0 инвентарь ptr++ он сохраняет старое значение в регистре, как вы сказали. Таким образом, адреса магазинов известны заранее, и на один упор нагрузки требуется AGU.

Предполагая, что процессор Intel SnB-семейства:

## ptr++: 1st loop
movl    -12(%ebp), %eax   //1 uop (load)
leal    4(%eax), %edx     //1 uop (ALU only)
movl    %edx, -12(%ebp)   //1 store-address, 1 store-data
//   no load from -12(%ebp) into %eax
... rest the same.


 ## ++ptr:  2nd loop
addl    $4, -12(%ebp)       // read-modify-write: 2 fused-domain uops.  4 unfused: 1 load + 1 store-address + 1 store-data
movl    -12(%ebp), %eax     // load: 1 uop.   ~6 cycle latency for %eax to be ready
... rest the same

Таким образом, часть приращения указателя во втором цикле имеет еще одну загрузку. Вероятно, узкие места кода в пропускной способности AGU (блоки генерации адресов). IACA говорит, что это относится к arch=SNB, но узкие места HSW связаны с пропускной способностью данных хранилища (не AGU).

Однако, не принимая во внимание задержку пересылки в хранилище, IACA говорит, что первый цикл может выполняться с одной итерацией на 3,5 цикла по сравнению с одним на 4 цикла для второго цикла. Это быстрее, чем 6-циклическая циклическая зависимость addl $1, -48(%ebp) счетчик цикла, который указывает, что цикл имеет узкое место из-за задержки до максимальной пропускной способности AGU. (Ресурсные конфликты, вероятно, означают, что он на самом деле работает медленнее, чем одна итерация на 6c, см. Ниже).

Мы могли бы проверить эту теорию:

Добавление дополнительной нагрузки UOP к lea Версия вне критического пути потребует большей пропускной способности, но не будет частью цепочек задержек цикла. например

movl    -12(%ebp), %eax   //Get the current address
leal    4(%eax), %edx     //Calculate the next address
movl    %edx, -12(%ebp)   //Store the new (next) address

mov     -12(%ebp), %edx 

%edx собирается быть перезаписан mov, поэтому нет никаких зависимостей от результата этой загрузки. (Пункт назначения mov только для записи, поэтому он разрывает цепочки зависимостей, благодаря переименованию регистров.).

Так что эта дополнительная нагрузка принесет lea цикл до того же числа и вкуса мопс, как add петля, но с разной задержкой. Если дополнительная нагрузка не влияет на скорость, мы знаем, что первый цикл не является узким местом при загрузке / хранении.


Обновление: тестирование OP подтвердило, что дополнительная неиспользованная нагрузка замедляет lea примерно до той же скорости, что и add петля.

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

Мупы планируются в старшем порядке (из мопов, у которых есть готовые операнды), а не в порядке критического пути. Дополнительные мопы, которые могли бы быть выполнены в резервном цикле позже, фактически задержат мопы, находящиеся на критическом пути (например, часть зависимостей, переносимых циклом). Это называется конфликтом ресурсов и может увеличить задержку критического пути.

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

Точно так же в add Цикл, где дополнительная нагрузка является частью критического пути, дополнительная нагрузка вызывает больше конфликтов ресурсов, задерживая операции на критическом пути.


Другие догадки:

Так что, возможно, готовый адрес магазина готов раньше, поэтому операции с памятью лучше конвейеризуются. (Например, обход страниц TLB может начаться быстрее при приближении к границе страницы. Даже обычная аппаратная предварительная выборка не пересекает границы страниц, даже если они горячие в TLB. Цикл затрагивает 4 МБ памяти, что достаточно для такого рода Важно то, что задержка L3 достаточно высока, чтобы создать пузырек конвейера. Или если ваш L3 маленький, то основная память, безусловно, есть.

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

Другие вопросы по тегам