Требуется ли знак или нулевое расширение при добавлении 32-битного смещения к указателю для x86-64 ABI?

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

void Test(int *out, int offset)
{
    out[offset] = 1;
}
-------------------------------------
movslq  %esi, %rsi
movl    $1, (%rdi,%rsi,4)
ret

Сначала я думал, что мой компилятор столкнулся с проблемой добавления 32-битных целых чисел в 64-битные, но я подтвердил это поведение с помощью Intel ICC 11, ICC 14 и GCC 5.3.

Этот поток подтверждает мои выводы, но непонятно, нужно ли расширение знака или нуля. Это расширение знака / нуля будет необходимо только в том случае, если верхние 32 бита еще не установлены. Но разве ABI x86-64 не будет достаточно умен, чтобы требовать этого?

Я как бы неохотно меняю смещения всех моих указателей на ssize_t, потому что разливы регистров увеличивают объем кэша кода.


person Yale Zhang    schedule 19.04.2016    source источник
comment
int - это знаковый тип. Используйте unsigned или лучше size_t, если вам не нужно расширение знака.   -  person EOF    schedule 19.04.2016
comment
Я пробовал это, но компилятор просто заменяет знак расширения нулевым расширением, чего я тоже не хочу   -  person Yale Zhang    schedule 19.04.2016
comment
Я просмотрел SYS V x86-64 ABI и не нашел много ссылок на расширение подписи / нуля. Но я обнаружил, что та же проблема возникает при смешивании 32-битных указателей с 16-битными смещениями.   -  person Yale Zhang    schedule 19.04.2016
comment
Что ж, SysV ABI не требует, чтобы старшие 32 бита 64-битного регистра обнулялись при передаче 32-битного типа. Скомпилируйте это и посмотрите на созданную сборку: void foo(uint32_t); void bar(uint64_t x){foo(x);}   -  person EOF    schedule 19.04.2016
comment
@EOF отличная точка. Таким образом, это доказывает, что верхние 32 бита могут быть неопределенными довольно часто. Я думал, что в 64-битном режиме каждая инструкция вычисляет 64-битный результат, делая преобразование в вызываемом файле ненужным, возможно, единственным исключением является устаревший 32-битный ассемблерный код (например, mov ah, 3), что не рекомендуется, поскольку частичная запись в регистр выполняется медленно. Я думаю, что это лучший ответ, поскольку он объясняет, насколько часто верхние 32 бита не определены, поскольку C преобразует int64 в int32 путем усечения. Если вы это напишете, я приму это.   -  person Yale Zhang    schedule 19.04.2016
comment
Рассмотрим, как компилировать int64_t t = something; foo((int)t, arr[t]). Вам нужно вычислить t в 64-битном регистре, потому что индексирование массива использует все 64-битные. Если вы вычислили его в %rdi, он уже находится в нужном месте для вызова foo, но имеет много мусора. Кстати, @EOF: у ABI, похоже, есть некоторые неписаные правила о расширении 8b и 16b до 32b. Я был удивлен, но вижу свой ответ.   -  person Peter Cordes    schedule 21.04.2016
comment
I thought in 64-bit mode, every instruction computes a 64bit нет, размер параметра по умолчанию в x86_64 составляет 32 бита. Для каждой 64-битной операции или доступа к старшим регистрам требуется префикс REX, поэтому он будет длиннее и не будет использоваться без необходимости.   -  person phuclv    schedule 21.04.2016


Ответы (2)


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

Вам необходимо подписать или расширить нулем до 64 бит, чтобы использовать значение в 64-битном эффективном адресе. В x32 ABI gcc часто использует 32-битные эффективные адреса вместо 64-битных операндов. size для каждой инструкции, изменяющей потенциально отрицательное целое число, используемое в качестве индекса массива.


Стандарт:

x86-64 SysV ABI говорит только о том, какие части регистра обнуляются для _Bool (он же bool). Стр. 20:

Когда значение типа _Bool возвращается или передается в регистр или в стек, бит 0 содержит значение истинности, а биты с 1 по 7 должны быть нулевыми (сноска 14: другие биты не указаны, поэтому потребительская сторона этих значений может полагаться на 0 или 1 при усечении до 8 бит)

Кроме того, информация о %al хранении количества аргументов регистра FP для функций varargs, а не всего %rax.

Есть открытая проблема github по этому вопросу на страница github для документов ABI x32 и x86-64 .

ABI не предъявляет никаких дополнительных требований или гарантий к содержимому старших частей целочисленных или векторных регистров, содержащих аргументы или возвращаемые значения, поэтому их нет. У меня есть подтверждение этого факта по электронной почте от Майкла Матца (одного из сопровождающих ABI): «Обычно, если ABI не говорит, что что-то определено, вы не можете полагаться на это».

Он также подтвердил, что, например, clang> = 3.6 использование addps, которое может замедлить или вызвать дополнительные исключения FP с мусор в высоких элементах - это ошибка (что напоминает мне, что я должен сообщить об этом). Он добавляет, что однажды это было проблемой при реализации AMD математической функции glibc. Нормальный код C может оставлять мусор в верхних элементах векторных регистров при передаче скалярных double или float аргументов.


Фактическое поведение, которое (пока) не задокументировано в стандарте:

Узкие аргументы функции, даже _9 _ / _ 10_, расширяются знаком или нулем до 32 бит. clang даже создает код, зависящий от этого поведения (очевидно, с 2007 года). ICC17 не работает, поэтому ICC и clang несовместимы с ABI, даже для C. Не вызывайте скомпилированные clang функции из кода, скомпилированного с помощью ICC, для x86-64 SysV ABI, если любой из первых 6 целочисленных аргументов уже, чем 32-битный.

Это не относится к возвращаемым значениям, только args: gcc и clang предполагают, что возвращаемые значения, которые они получают, имеют только допустимые данные до ширины типа. gcc создаст функции, возвращающие char, которые, например, оставляют мусор в старших 24 битах %eax.

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

Вот пример (проверьте это с помощью других компиляторов или измените код в Godbolt Compiler Explorer, где я включил много простых примеров, которые демонстрируют только одну часть головоломки, а также то, что демонстрирует многое):

extern short fshort(short a);
extern unsigned fuint(unsigned int a);

extern unsigned short array_us[];
unsigned short lookupu(unsigned short a) {
  unsigned int a_int = a + 1234;
  a_int += fshort(a);                 // NOTE: not the same calls as the signed lookup
  return array_us[a + fuint(a_int)];
}

# clang-3.8 -O3  for x86-64.    arg in %rdi.  (Actually in %di, zero-extended to %edi by our caller)
lookupu(unsigned short):
    pushq   %rbx                      # save a call-preserved reg for out own use.  (Also aligns the stack for another call)
    movl    %edi, %ebx                # If we didn't assume our arg was already zero-extended, this would be a movzwl (aka movzx)
    movswl  %bx, %edi                 # sign-extend to call a function that takes signed short instead of unsigned short.
    callq   fshort(short)
    cwtl                              # Don't trust the upper bits of the return value.  (This is cdqe, Intel syntax.  eax = sign_extend(ax))
    leal    1234(%rbx,%rax), %edi     # this is the point where we'd get a wrong answer if our arg wasn't zero-extended.  gcc doesn't assume this, but clang does.
    callq   fuint(unsigned int)
    addl    %ebx, %eax                # zero-extends eax to 64bits
    movzwl  array_us(%rax,%rax), %eax # This zero-extension (instead of just writing ax) is *not* for correctness, just for performance: avoid partial-register slowdowns if the caller reads eax
    popq    %rbx
    retq

Примечание: movzwl array_us(,%rax,2) будет эквивалентным, но не меньше. Если бы мы могли полагаться на обнуление старших битов %rax в возвращаемом значении fuint(), компилятор мог бы использовать array_us(%rbx, %rax, 2) вместо add insn.


Последствия для производительности

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

При выполнении 32-битных операций игнорирование высоких 32 можно бесплатно. 32-битная операция с нулевым значением расширяет свой результат до 64-битной бесплатно, поэтому вам понадобится только дополнительный mov edx, edi или что-то в этом роде, если вы могли бы использовать регистр напрямую в 64-битном режиме адресации или в 64-битном режиме. битовая операция.

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

Однако расширение нуля до 64-битных систем независимо от подписи было бы бесплатным для большинства вызывающих и могло бы стать хорошим выбором для дизайна ABI. Поскольку регистры arg в любом случае затираются, вызывающему уже нужно сделать что-то дополнительное, если он хочет сохранить полное 64-битное значение в вызове, где он передает только младшие 32. Таким образом, обычно это требует дополнительных затрат только тогда, когда вам нужен 64-битный результат для чего-то перед вызовом, а затем передать усеченную версию функции. В x86-64 SysV вы можете сгенерировать свой результат в RDI и использовать его, а затем call foo, который будет смотреть только на EDI.

16-битные и 8-битные размеры операндов часто приводят к ложным зависимостям (AMD, P4 или Silvermont, а позднее семейство SnB), частичным остановкам регистров (до SnB) или незначительным замедлениям (Sandybridge), поэтому недокументированное поведение необходимость расширения типов 8 и 16b до 32b для передачи аргументов имеет некоторый смысл. См. Почему GCC не использует частичные регистры? для получения дополнительных сведений об этих микроархитектурах. .


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

Я не уверен, поможет ли изменение сигнатур функций для использования uintptr_t общую производительность с 64-битными указателями или снизит ее. Я бы не стал беспокоиться о пространстве стека для скаляров. В большинстве функций компилятор выталкивает / выталкивает достаточно регистров с сохранением вызовов (например, %rbx и %rbp), чтобы сохранить свои собственные переменные в регистрах. Крошечное дополнительное пространство для разливов 8B вместо 4B незначительно.

Что касается размера кода, для работы с 64-битными значениями требуется префикс REX для некоторых insns, которые в противном случае не потребовались бы. Расширение нуля до 64-битного значения происходит бесплатно, если требуются какие-либо операции с 32-битным значением, прежде чем оно будет использовано в качестве индекса массива. Sign-extension всегда требует дополнительных инструкций, если это необходимо. Но компиляторы могут подписывать-расширять и работать с ним как с 64-битным подписанным значением с самого начала, чтобы сохранить инструкции, за счет необходимости большего количества префиксов REX. (Переполнение со знаком - это UB, не определено для переноса, поэтому компиляторы часто могут избежать повторения расширения знака внутри цикла с int i, который использует arr[i].)

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

Например, одна дополнительная инструкция LEA, позволяющая [reg + disp8] адресацию для дюжины последующих инструкций вместо disp32. Или xor eax,eax перед несколькими mov [rdi+n], 0 инструкциями по замене imm32 = 0 на источник регистра. (Особенно, если это допускает микрослияние там, где это было бы невозможно с RIP-relative + немедленным, потому что на самом деле имеет значение количество операций на интерфейсе, а не количество инструкций.)

person Peter Cordes    schedule 21.04.2016
comment
Это много загадочного, но все же сокровищница информации, которую вы откопали. Спасибо. Главный вопрос сейчас заключается в том, какие передовые практики следует использовать для выбора типов индекса массива. В настоящее время я использую ssize_t практически для всех чисел, участвующих в вычислении адресов. Похоже, что в целом это работает хорошо, но, судя по вашим выводам, может оказаться ненужным или даже неоптимальным. Итак, я думаю, что я изменю свою стратегию на использование ssize_t во всех функциях верхнего уровня (чтобы никогда не было знака или нулевого расширения). Затем для конечного кода или горячих циклов по возможности используйте int32. - person Yale Zhang; 21.04.2016
comment
Случаи, когда int32 работает так же быстро или быстрее, как вы упомянули: 1. бесплатное расширение нуля для 32-битных операций. Я не хочу этого делать, потому что тогда вам придется использовать беззнаковые типы, которые более подвержены ошибкам из-за переполнения. 2. 32-битное умножение быстрее, чем 64-битное до Nehalem и, вероятно, также и на других архитектурах. 3. меньший размер кода из-за отсутствия префикса REX. --------------- Жалко, что у x86-64 есть все эти причуды. ARM64 не имеет этой проблемы - он может напрямую использовать нижнюю половину 64-битного регистра при вычислении адресов. - person Yale Zhang; 21.04.2016
comment
@YaleZhang: Если вы заметите заметную разницу в скорости, дайте мне знать. Мне было интересно то же самое, и я иногда смотрел на код с signed int vs. unsigned int, и он может быть неуклюжим в любом случае в зависимости от контекста. Самым большим недостатком unsigned является то, что компилятор должен выдавать код, который ведет себя правильно при переносе, в отличие от int (подписанное переполнение - неопределенное поведение). Это может обеспечить дополнительную оптимизацию - person Peter Cordes; 23.04.2016
comment
Как бы то ни было, icc, похоже, нарушает стандарт де-факто (недокументированный), о котором вы упомянули выше: он не знак / ноль расширяет аргументы, меньшие, чем 32-битные, до 32-битных. Вот пример . Обратите внимание, что он просто вызывает consumer(char a) с произвольным мусором в битах 8-31 edi. - person BeeOnRope; 18.03.2017
comment
@BeeOnRope: хорошо замечено. Это неприятно, поэтому очевидно, что вы не можете безопасно вызывать функции, скомпилированные с помощью clang, из кода, скомпилированного с помощью ICC, если есть какие-либо узкие целочисленные аргументы. - person Peter Cordes; 24.03.2017
comment
Ваше второе предложение мне не нравится. Вы имеете в виду, что вы не ..., или вы имеете в виду, что вы тоже ... (и в последнем случае последнее слово наиболее сбивает с толку). - person Greg A. Woods; 30.04.2019
comment
@ GregA.Woods: спасибо за отзыв. Отредактировано для уточнения. Я сказал, потому что если вы посмотрите на это с другой точки зрения, это даст вам больше свободы / это возможное преимущество. Но я согласен с тем, что смысл может не проясняться. - person Peter Cordes; 30.04.2019

Как указывает комментарий EOF, компилятор не может предположить, что старшие 32 бита 64-битного регистра, используемого для передачи 32-битного аргумента, имеют какое-либо конкретное значение. Это делает необходимым знак или нулевое расширение.

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

Если вас действительно беспокоит объем используемой памяти и вам не нужно большее 64-битное адресное пространство, вы можете посмотреть x32 ABI, который использует типы ILP32, но поддерживает полный 64-битный набор инструкций.

person Ross Ridge    schedule 19.04.2016
comment
x32 - это выигрыш в размере данных для структур данных с большим количеством указателей, и я думаю, что и для размера кода. Однако часто приходится использовать префиксы размера адреса, когда компилятор не может доказать, что 64-битный режим адресации не выйдет за пределы низкого 4G. (например, [eax + disp] будет переноситься, а [rax + disp] - нет, поэтому необходим префикс размера адреса, если компилятор не может каким-либо образом доказать что-то об адресе и / или индексе при индексировании с другим регистром). - person Peter Cordes; 19.04.2016
comment
@PeterCordes Я удивлен, что компилятор беспокоится об этом, поскольку я не вижу, как адрес может быть перенесен без вызова неопределенного поведения. С другой стороны, я могу видеть это с помощью переопределения, чтобы избежать необходимости обнулять регистр в ситуации, аналогичной проблеме исходного плаката. - person Ross Ridge; 19.04.2016
comment
Я тоже был удивлен по той же причине: чем это полезно? AFAICT использует его даже после обнуления верхних 32, выполнив 32-битную операцию или что-то в этом роде. На данный момент это, вероятно, более безопасная, но не совсем оптимальная реализация. - person Peter Cordes; 19.04.2016
comment
Хороший момент в оплате стоимости расширения с нулем / знаком в вызывающей стороне вместо вызываемой, что может увеличить размер кода без уменьшения количества выполняемых инструкций #. Я не очень беспокоюсь о разливе регистров, поскольку 32-битные операции чтения / записи имеют такую ​​же задержку и пропускную способность, как и 64-битные, если они попадают в кеш, и вероятность возникновения дополнительных промахов в кеше мала. - person Yale Zhang; 19.04.2016
comment
Я добавил свой ответ с более подробной информацией. Самое интересное: clang зависит от знака вызывающего абонента или нуля, расширяя узкие аргументы до 32 бит. - person Peter Cordes; 21.04.2016
comment
Еще более интересно: согласно моему комментарию выше, icc не знак / ноль распространяется даже на 32-битные. Итак, clang и icc несовместимы. gcc совместим с icc, поскольку, несмотря на то, что он расширяется до 32-битных, он, похоже, не полагается на него (пока) при реализации функций. - person BeeOnRope; 18.03.2017