Как обменять верхнюю часть стека с регистром без неявной блокировки на последних 64-битных процессорах Intel? - PullRequest
0 голосов
/ 27 апреля 2019

Соглашение о вызовах x64 использует регистры до первых 4 параметров (rcx, rdx, r8, r9) и передает остальные параметры в стек. В этом случае очевидным способом работы с дополнительными параметрами в процедуре asm будет следующий:

procedure example(
  param1, //rcx
  param2, //rdx
  param3, //r8
  param4, //r9
  param5,
  param6
);
asm
  xchg param5, r14 // non-volatile registers, should be preserved
  xchg param6, r15 // non-volatile registers, should be preserved

  // ... procedure body, use r14–r15 for param5–param6

  mov r15, param6
  mov r14, param5  
end;

Но здесь возникает огромная проблема: если задействованы операции с памятью, XCHG инструкции в процессорах Intel имеют неявное LOCK, что также означает огромное снижение производительности; то есть в худшем случае шина будет заблокирована на сотни тактов. (Кстати, я не могу действительно понять это неявное LOCK как имеющее действительно полезные и умные инструкции по блокировке, такие как XADD, CMPXCHG, BTS/BTR и т. Д .; голый XCHG был бы последним вариантом для меня, если бы мне было нужно синхронизация потоков.) Так что же мне делать здесь, если я хочу что-то короткое и элегантное для использования / сохранения / восстановления params5 и params6 в / из регистров? Возможно, есть какой-нибудь хак для предотвращения блокировки шины для инструкций XCHG? Вообще, каков стандартный, широко используемый способ для этой ситуации?

Ответы [ 2 ]

3 голосов
/ 27 апреля 2019

Как объясняет ответ Росса, стандартный широко используемый способ - это пролить (и позже перезагрузить) что-то еще, чтобы освободить регистр tmp.

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


Но чтобы ответить на заглавный вопрос:

Несмотря на заголовок вопроса, мой ответ о замене 2 регистров на языке ассемблера 8086 (16 бит) точно решает проблему замены регистра с памятью эффективно, избегая xchg из-за неявного префикса lock , Разлейте (и позже перезагрузите) tmp reg или, в худшем случае, XOR-swap между reg и mem. Это ужасно и в основном служит иллюстрацией того, почему весь ваш подход приведет к неэффективной реализации.

(Как говорит Росс, вы, вероятно, (пока) не способны писать asm более эффективно, чем это делают компиляторы. Как только вы поймете, как создать эффективный asm (руководство по оптимизации Agner Fog и руководство по микроархам: https://agner.org/optimize/, и другие ссылки в https://stackoverflow.com/tags/x86/info) и могут обнаружить фактическую неэффективность в оптимизированном выводе компилятора, тогда вы могли бы иногда писать лучше asm вручную, если хотите. (Обычно с выводом компилятора в качестве отправной точки) Но обычно вы просто использовали бы этот опыт для настройки вашего C-кода, чтобы получить лучшую asm от вашего компилятора, если это возможно, потому что это более полезно / переносимо в долгосрочной перспективе. И это редко имеет значение, чтобы стоить писать asm от руки.

На данный момент вы, скорее всего, изучите методы для повышения эффективности asm, взглянув на вывод gcc -O3. Но пропущенные оптимизации не редкость, и если вы заметите некоторые из них, вы можете сообщить о них в bugzilla GCC.)


Неявная семантика lock xchg взята из оригинального 8086. Префикс lock существовал тогда, для использования с такими инструкциями, как add/or/and/etc [mem], reg or immediate.

Другие упомянутые вами инструкции были добавлены позже : bts / btr / btc в 386, xadd в 486 и cmpxchg не раньше Pentium. (486 имел недокументированный код операции для cmpxchg, см. старую версию приложения NASM A для комментариев к нему).

Как вы говорите, Intel мудро решила , а не сделать lock неявным для этих новых инструкций, хотя основной вариант использования был для атомарных операций в многопоточном коде. Машины с SMP x86 начали превращаться в штуку с 486 и Pentium, но синхронизация между потоками на машине UP не требовала lock Это своего рода противоположный вопрос Является ли x86 CMPXCHG атомарным, если да, то зачем ему нужен LOCK?

8086 был однопроцессорным компьютером, поэтому для синхронизации между программными потоками обычный add [mem], reg уже атомарен по отношению к прерываниям и, следовательно, к контекстным переменным . (И невозможно одновременно выполнять несколько потоков). Устаревший внешний сигнал #LOCK, который до сих пор упоминается в документах, имеет значение только для него. Наблюдатели DMA или регистры ввода-вывода MMIO на устройствах (а не на простом DRAM).

(На современных процессорах xchg [mem], reg на кешируемой памяти, которая не разделена по границе строки кэша, требуется только блокировка кеша, гарантируя, что строка остается в состоянии MESI Exclusive или Modified от загрузки, считывающей L1d до магазин, передающий L1d.)

Я не знаю, почему архитектор (ы) 8086 (в первую очередь Стивен Морс разработал набор инструкций) решили не делать неатомарную xchg с доступной памятью. Может быть, на 8086 было немного медленнее, чтобы процессор утверждал #LOCK при выполнении транзакции store + load? Но затем мы застряли с этой семантикой для остальной части x86. Дизайн x86 редко был очень дальновидным, и если основной вариант использования для xchg был для атомарного ввода-вывода, то он сохранял размер кода, чтобы сделать lock неявным.


Нет способа отключить неявную блокировку в xchg [mem], reg

Вам нужно использовать несколько разных инструкций.Обмен xor возможен, но очень неэффективен.Тем не менее, возможно, не так плохо, как xchg, в зависимости от микроархитектуры и окружающего кода (насколько отстойно, чтобы все предыдущие хранилища выполнялись и фиксировали кэш L1d, прежде чем выполнять какие-либо последующие загрузки).например, некоторые из хранилищ с отсутствием кэша рейсов могут сделать его очень дорогим по сравнению с местом назначения памяти xor, которое может оставить данные в буфере хранилища.

Компиляторы в основном никогда не используют xchg даже между регистрами (потому что это не дешевле, чем 3 mov инструкции для Intel , так что обычно это не полезная оптимизация глазка).Они используют его только для реализации std::atomic хранилищ с seq_cst порядком памяти (потому что он эффективнее, чем mov + mfence на большинстве uarches: Почему хранилище std :: atomic с последовательной последовательностью использует XCHG?) и для реализации std::atomic::exchange.

Иногда было бы полезно, если бы x86 имел микрокодированный, но не атомарный swap reg,mem, но это не так.Нет такой инструкции.

Но особенно с x86-64, имеющим 16 регистров, эта проблема возникает только потому, что вы создали ее для себя.Оставьте себе несколько правил для вычисления.

2 голосов
/ 27 апреля 2019

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

Также обратите внимание, что соглашение о вызовах Windows x64 требует, чтобы «энергонезависимые» (сохраняемые вызываемые) регистры должны сохраняться только в прологе. (Несмотря на то, что вы можете использовать объединенную информацию о размотке, чтобы иметь несколько «прологов» в функции.)

Итак, при условии, что вам нужно использовать все регистры, сохраненные вызываемым абонентом, и строго соблюдать соглашение о вызовах Windows x64, вам понадобится что-то вроде этого:

example PROC    FRAME

_stack_alloc =  8   ; total stack allocation for local variables
                    ; must be MOD 16 = 8, so the stack is aligned properly;
_push_regs =    32  ; total size in bytes of the callee-saved registers
                    ; pushed on the stack

_param_adj =    _stack_alloc + _push_regs

; location of the parameters relative to RSP, including the incoming
; slots reserved for spilling parameters passed in registers

param1  =   _param_adj + 8h
param2  =   _param_adj + 10h
param3  =   _param_adj + 18h
param4  =   _param_adj + 20h
param5  =   _param_adj + 28h
param6  =   _param_adj + 30h

; location of local variables relative to RSP

temp1   =   0

    ; Save some of the callee-preserved registers
    push    rbp
    .PUSHREG rbp
    push    rbx
    .PUSHREG rbx
    push    rsi
    .PUSHREG rsi
    push    rdi
    .PUSHREG rdi

    ; Align stack and allocate space for temporary variables
    sub rsp, _stack_alloc
    .ALLOCSTACK 8

    ; Save what callee-preserved registers we can in the incoming
    ; stack slots reserved for arguments passed in registers under the
    ; assumption there's no need to save the later registers

    mov [rsp + param1], r12
    .SAVEREG r12, param1
    mov [rsp + param2], r13
    .SAVEREG r13, param2
    mov [rsp + param3], r14
    .SAVEREG r14, param3
    mov [rsp + param4], r15
    .SAVEREG r15, param4

    .ENDPROLOG

    ; ...

    ; lets say we need to access param5 and param6, but R14 
    ; is the only register available at the moment.  

    mov r14, [rsp + param5]
    mov [rsp + temp1], rax  ; spill RAX 
    mov rax, [rsp + param6]

    ; ...

    mov rax, [rsp + temp1]  ; restore RAX

    ; ...

    ; start of the "unofficial" prologue

    ; restore called-preserved registers that weren't pushed

    mov r12, [rsp + param1]
    mov r13, [rsp + param2]
    mov r14, [rsp + param3]
    mov r15, [rsp + param4]

    ; start of the "official" prologue
    ; instructions in this part are very constrained. 

    add rsp, _stack_alloc
    pop rdi
    pop rsi
    pop rbx
    pop rbp
    ret

example ENDP

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

Но есть способ избежать всего этого, просто используйте компилятор C / C ++. Там действительно не так много необходимости в сборке в эти дни. Маловероятно, что вы можете написать более быстрый код, чем компилятор, и вы можете использовать встроенные функции для доступа практически к любой специальной инструкции по сборке, которую вы хотите использовать. Компилятор может беспокоиться о том, где что-то находится в стеке, и он может очень хорошо справляться с распределением регистров, сводя к минимуму сохранение и разлив регистров суммы.

(компилятор Microsoft C / C ++ может даже генерировать эту цепочечную информацию о раскрутке, о которой я упоминал ранее, так что регистры, сохраненные вызываемыми, могут быть сохранены только при необходимости.)

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...