Почему GCC не использует частичные регистры?

Дизассемблирование write(1,"hi",3) на Linux, построенный с gcc -s -nostdlib -nostartfiles -O3 результаты в:

ba03000000     mov edx, 3 ; thanks for the correction jester!
bf01000000     mov edi, 1
31c0           xor eax, eax
e9d8ffffff     jmp loc.imp.write

Я не занимаюсь разработкой компиляторов, но поскольку каждое значение, перемещаемое в эти регистры, является постоянным и известным во время компиляции, мне любопытно, почему gcc не использует dl, dil, а также al вместо. Некоторые могут утверждать, что эта функция не будет иметь никакого значения в производительности, но есть большая разница в размере исполняемого файла между mov $1, %rax => b801000000 а также mov $1, %al => b001 когда мы говорим о тысячах обращений к регистру в программе. Не только небольшой размер, если он является частью элегантности программного обеспечения, но и влияет на производительность.

Может кто-нибудь объяснить, почему "GCC решил", что это не имеет значения?

5 ответов

Решение

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

Но когда инструкция читает весь регистр, ЦПУ должен обнаружить тот факт, что он не имеет правильного значения архитектурного регистра, доступного в одном физическом регистре. (Это происходит на этапе выпуска / переименования, поскольку ЦП готовится отправить моп в планировщик с нарушением порядка.)

Это называется частичной регистрацией киосков. Руководство по микроархитектуре Agner Fog объясняет это довольно хорошо:

6.8 Частичные регистрационные киоски (PPro/PII/PIII и ранний Pentium-M)

Частичная остановка регистра - это проблема, которая возникает, когда мы записываем часть 32-битного регистра, а затем читаем из всего регистра или большей его части.
Пример:

; Example 6.10a. Partial register stall
mov al, byte ptr [mem8]
mov ebx, eax ; Partial register stall

Это дает задержку в 5 - 6 часов. Причина в том, что временный регистр был назначен AL сделать его независимым от AH, Исполнительный блок должен ждать, пока запись в AL удалился до того, как можно объединить значение из AL со значением остальных EAX,

Поведение в разных процессорах:

Без частичного переименования регистров входная зависимость для записи является ложной зависимостью, если вы никогда не читаете полный регистр. Это ограничивает параллелизм на уровне команд, поскольку повторное использование 8- или 16-разрядного регистра для чего-то другого фактически не зависит от точки зрения процессора (16-разрядный код может обращаться к 32-разрядным регистрам, поэтому он должен поддерживать правильные значения в верхнем половинки). А также, это делает AL и AH не независимыми. Когда Intel разработала семейство P6 (PPro, выпущенный в 1993 году), 16-битный код был все еще распространенным, поэтому переименование с частичным регистром было важной функцией для ускорения работы существующего машинного кода. (На практике многие двоичные файлы не перекомпилируются для новых процессоров.)

Вот почему компиляторы в основном избегают записи частичных регистров. Они используют movzx / movsx по возможности обнулять или расширять узкие значения до полного регистра, чтобы избежать ложных зависимостей частичного регистра (AMD) или зависаний (семейство Intel P6). Таким образом, большинство современных машинных кодов мало выигрывают от переименования с частичным регистром, поэтому последние процессоры Intel упрощают логику переименования с частичным регистром.

Как указывает ответ @BeeOnRope, компиляторы все еще читают частичные регистры, потому что это не проблема. (Чтение AH/BH/CH/DH может добавить дополнительный цикл задержки на Haswell/Skylake, хотя, смотрите предыдущую ссылку о частичных регистрах на недавних членах семьи Sandybridge.)


Также обратите внимание, что write принимает аргументы, что для обычно настраиваемого GCC для x86-64 требуются целые 32-битные и 64-битные регистры, чтобы его нельзя было просто собрать в mov dl, 3, Размер определяется типом данных, а не значением данных.

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


Ваша разборка вводит в заблуждение, как отметил @Jester.
Например mov rdx, 3 на самом деле mov edx, 3 хотя оба имеют одинаковый эффект - то есть, положить 3 в целом rdx,
Это верно, потому что непосредственное значение 3 не требует расширения знака и MOV r32, imm32 неявно очищает верхние 32 бита регистра.

Все три предыдущих ответа ошибочны по-разному.

Принятый ответ Маргарет Блум подразумевает, что виноваты частичные киоски регистрации. Частичные киоски регистрации - это реальная вещь, но вряд ли они будут иметь отношение к решению GCC здесь.

Если GCC заменить by, тогда код будет просто неправильным, потому что запись в байтовые регистры (в отличие от записи в регистры двойного слова) не обнуляет остальную часть регистра. Параметр in имеет 64-битный тип, поэтому вызываемый объект будет читать полный регистр, который будет содержать мусор в битах с 8 по 63. Частичные остановки регистров - это чисто проблема производительности; неважно, насколько быстро работает код, если он неправильный.

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

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

      loop:
  ...
  dec edx
  jnz loop

тогда GCC мог бы сделать вывод, что это был ноль в точке, где он хочет поместить в него 3, и был бы правильным, но это было бы плохой идеей в целом, потому что это могло бы вызвать задержку частичного регистра. (Здесь это не имело бы значения, потому что системные вызовы в любом случае такие медленные, но я не думаю, что GCC имеет атрибут «медленная функция, для которой нет необходимости оптимизировать скорость вызовов» в его внутренней системе типов.)


Почему GCC не излучает с последующим перемещением байта, если не из-за частичного зависания регистра? Я не знаю, но могу предположить.

Это экономит место только при инициализации через , и даже тогда он сохраняет только один байт. Это увеличивает количество инструкций, что имеет свои собственные затраты (декодеры инструкций часто являются узким местом). В отличие от стандартного, он также сбивает флаги, а это значит, что это не замена. GCC должен был бы отслеживать отдельную последовательность инициализации регистра слияния флагов, что в большинстве случаев (11/15 возможных регистров назначения) было бы однозначно менее эффективным.

Если вы агрессивно оптимизируете размер, вы можете сделать с последующим , что экономит 2 байта независимо от регистра назначения и не сбивает флаги. Но он, вероятно, намного медленнее, потому что он записывает в память и имеет ложную зависимость чтения-записи от , и экономия места вряд ли того стоит. (Он также изменяет красную зону , так что это тоже не прямая замена.)


ответ суперкота говорит

Ядра процессора часто включают логику для одновременного выполнения нескольких 32-битных или 64-битных инструкций, но могут не включать логику для выполнения 8-битной операции одновременно с чем-либо еще. Следовательно, хотя использование 8-битных операций на 8088, когда это было возможно, было полезной оптимизацией на 8088, на самом деле это может привести к значительному снижению производительности на новых процессорах.

Современные оптимизирующие компиляторы на самом деле довольно часто используют 8-битные GPR. (Они относительно редко используют 16-битные георадары, но я думаю, что это потому, что 16-битные количества необычны в современном коде.) 8-битные и 16-битные операции выполняются как минимум так же быстро, как 32-битные и 64-битные операции в лучшем случае. этапы выполнения, а некоторые быстрее.

Ранее я писал здесь: «Насколько мне известно, 8-битные операции выполняются так же или быстрее, чем 32/64-битные операции на абсолютно каждом 32/64-битном процессоре x86/x64, когда-либо сделанном». Но я был неправ. Довольно много суперскалярных процессоров x86/x64 объединяют 8- и 16-разрядные адреса назначения в полный регистр при каждой записи, что означает, что инструкции только для записи, такие как имеют ложную зависимость чтения, когда место назначения составляет 8/16 бит, чего не существует, когда это 32/64 бит. Ложные цепочки зависимостей могут замедлить выполнение, если вы не очищаете регистр перед каждым ходом (или во время, используя что-то вроде ). У новых процессоров есть эта проблема, хотя в самых ранних суперскалярных процессорах (Pentium Pro/II/III) ее не было. Несмотря на это, по моему опыту, современные оптимизирующие компиляторы действительно используют меньшие регистры.


В ответе BeeOnRope говорится

Короткий ответ для вашего конкретного случая заключается в том, что gcc всегда подписывает или расширяет аргументы нулями до 32 бит при вызове функции C ABI.

Но эта функция изначально не имеет параметров короче 32 бит. Дескрипторы файлов имеют длину ровно 32 бита и имеет длину ровно 64 бита. Не имеет значения, что многие из этих битов часто равны нулю. Это не целые числа переменной длины, закодированные в 1 байт, если они маленькие. Было бы правильно использовать , с остальными возможно, ненулевое значение для параметра, если в ABI не было целочисленного требования к продвижению, а фактический тип параметра был или какой-нибудь другой 8-битный тип.

На самом деле, gcc очень часто использует частичные регистры. Если вы посмотрите сгенерированный код, вы найдете много случаев, когда используются частичные регистры.

Краткий ответ для вашего конкретного случая заключается в том, что gcc всегда подписывает или обнуляет аргументы до 32 бит при вызове функции C ABI.

Фактически SysV x86 и x86-64 ABI приняты gcc а также clang требует, чтобы параметры, меньшие 32 бита, были равны нулю или были расширены до 32 бит. Интересно, что их не нужно расширять до 64-битных.

Таким образом, для функции, подобной следующей на 64-битной платформе SysV ABI:

void foo(short s) {
 ...
}

... Аргумент s передается в rdi и биты s будут следующими (но см. мое предостережение ниже относительно icc):

  bits 0-31:  SSSSSSSS SSSSSSSS SPPPPPPP PPPPPPPP
  bits 32-63: XXXXXXXX XXXXXXXX XXXXXXXX XXXXXXXX
  where:
  P: the bottom 15 bits of the value of `s`
  S: the sign bit of `s` (extended into bits 16-31)
  X: arbitrary garbage

Код для foo может зависеть от S а также P биты, но не на X биты, которые могут быть чем угодно.

Точно так же для foo_unsigned(unsigned short u)вы бы 0 в битах 16-31, но в противном случае он будет идентичен.

Обратите внимание, что я сказал " де-факто" - потому что на самом деле не задокументировано, что делать для меньших типов возвращаемых данных, но вы можете увидеть ответ Питера здесь для подробностей. Я также задал связанный вопрос здесь.

После некоторого дальнейшего тестирования я пришел к выводу, что icc на самом деле нарушает этот стандарт де-факто. gcc а также clang кажется, придерживаются этого, но gcc только консервативным способом: при вызове функции она делает аргументы с нулевым / знаковым расширением до 32-битных, но в своих реализациях функций она не зависит от того, кто ее делает. clang реализует функции, зависящие от вызывающего, расширяющие параметры до 32-битных Так ведь clang а также icc несовместимы друг с другом даже для простых функций C, если они имеют параметры меньше int,

Обратите внимание, что с помощью -O3 явно просит компилятор агрессивно поддерживать производительность по сравнению с размером кода. использование -Os размер, если вы не готовы пожертвовать около 20% размера.

На чем-то похожем на оригинальный IBM PC, если известно, что AH содержит 0, и необходимо было загрузить AX со значением, например 0x34, использование "MOV AL,34h" обычно занимает 8 циклов, а не 12, необходимых для "MOV AX",0034h"- довольно большое улучшение скорости (любая инструкция может выполняться в 2 цикла при предварительной выборке, но на практике 8088 тратит большую часть своего времени на ожидание извлечения инструкций по цене четырех циклов на байт). Однако на процессорах, используемых в современных компьютерах общего назначения, время, необходимое для извлечения кода, как правило, не является существенным фактором общей скорости выполнения, и размер кода, как правило, не представляет особой проблемы.

Кроме того, производители процессоров пытаются максимизировать производительность тех типов кода, которые, вероятно, будут выполнять люди, а 8-разрядные инструкции по загрузке вряд ли будут использоваться сегодня так же часто, как 32-разрядные инструкции по загрузке. Ядра процессора часто включают логику для одновременного выполнения нескольких 32-битных или 64-битных инструкций, но могут не включать логику для выполнения 8-битной операции одновременно с чем-либо еще. Следовательно, хотя использование 80-битных операций на 8088, когда это было возможно, было полезной оптимизацией на 8088, на новых процессорах это может существенно снизить производительность.

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