Почему 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
,
Поведение в разных процессорах:
- Intel раннее семейство P6: см. Выше: остановка на 5-6 часов, пока частичная запись не прекратится.
- Intel Pentium-M (модель D) / Core2 / Nehalem: задержка в течение 2-3 циклов при вставке объединяющего UOP. (см. этот раздел вопросов и ответов для микробенчмарка, пишущего AX и читающего EAX с или без обнуления вначале)
- Intel Sandybridge: вставьте объединяющуюся опцию для low8/low16 (AL/AX) без остановки или для AH/BH/CH/DH во время остановки в течение 1 цикла.
- Intel IvyBridge (может быть), но определенно Haswell / Skylake: AL/AX не переименованы, но AH по-прежнему: Как именно работают частичные регистры на Haswell / Skylake? Написание AL, похоже, ложно зависит от RAX, а AH противоречиво.
Все остальные процессоры x86: Intel Pentium4, Atom / Silvermont / Knight's Landing. Все AMD (и через, и т. Д.):
Частичные регистры никогда не переименовываются. Запись частичного регистра сливается с полным регистром, что делает запись зависимой от старого значения полного регистра в качестве входа.
Без частичного переименования регистров входная зависимость для записи является ложной зависимостью, если вы никогда не читаете полный регистр. Это ограничивает параллелизм на уровне команд, поскольку повторное использование 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 заменить
Эту ошибку можно исправить, вставив
Единственная ситуация, когда частичные задержки регистров станут актуальными, - это если GCC узнает, что регистр равен нулю, но он не был обнулен одной из инструкций в специальном регистре. Например, если этому системному вызову предшествовал
loop:
...
dec edx
jnz loop
тогда GCC мог бы сделать вывод, что это был ноль в точке, где он хочет поместить в него 3, и был бы правильным, но это было бы плохой идеей в целом, потому что это могло бы вызвать задержку частичного регистра. (Здесь это не имело бы значения, потому что системные вызовы в любом случае такие медленные, но я не думаю, что GCC имеет атрибут «медленная функция, для которой нет необходимости оптимизировать скорость вызовов» в его внутренней системе типов.)
Почему GCC не излучает
Это экономит место только при инициализации
Если вы агрессивно оптимизируете размер, вы можете сделать
ответ суперкота говорит
Ядра процессора часто включают логику для одновременного выполнения нескольких 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-разрядные адреса назначения в полный регистр при каждой записи, что означает, что инструкции только для записи, такие как
В ответе BeeOnRope говорится
Короткий ответ для вашего конкретного случая заключается в том, что gcc всегда подписывает или расширяет аргументы нулями до 32 бит при вызове функции C ABI.
Но эта функция изначально не имеет параметров короче 32 бит. Дескрипторы файлов имеют длину ровно 32 бита и
На самом деле, 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, на новых процессорах это может существенно снизить производительность.