如何在没有隐式锁定最新的64位Intel CPU的情况下用寄存器交换堆栈顶部?

问题描述 投票:0回答:2

x64调用约定使用最多4个参数(rcxrdxr8r9)的寄存器,并传递堆栈上的其余参数。在这种情况下,处理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;

但是这里有一个很大的问题:如果涉及内存操作,英特尔CPU中的XCHG指令有一个隐含的LOCK,这也意味着巨大的性能损失;也就是说,在最坏的情况下,总线将被锁定数百个时钟周期。 (顺便说一句,我不能真正理解这个隐含的LOCK有真正可用和智能的互锁指令,如XADDCMPXCHGBTS/BTR等;如果我需要线程同步,裸体XCHG将是我的最后一个选项。)那么我该怎么办呢如果我想要一些简短而优雅的东西来使用/保存/恢复寄存器中的params5和params6?是否有一个黑客可以阻止XCHG指令的总线锁定?一般来说,这种情况的标准,广泛使用的方式是什么?

assembly locking x86-64 intel micro-optimization
2个回答
3
投票

正如罗斯的回答所解释的那样,标准广泛使用的方法是溢出(以及后来重新加载)其他东西以释放tmp reg。

您首先将所有内容加载到寄存器中,而不是根据需要加载,从而使自己陷入困境。有时您甚至可以使用arg作为内存源操作数,而根本没有单独的mov加载。


但要回答标题问题:

尽管问题标题,我对swapping 2 registers in 8086 assembly language(16 bits)的答案确实解决了有效地交换记忆的记忆,避免xchg因为隐含的lock前缀。溢出(以及稍后重新加载)tmp reg,或者在最坏的情况下,reg和mem之间的XOR交换。这太可怕了,基本上可以说明为什么你的整个方法会导致效率低下。

(正如罗斯所说,你可能还没有能够编写比编译器更高效的文章。一旦你理解了如何创建高效的asm(Agner Fog的优化指南和微指南:https://agner.org/optimize/,以及https://stackoverflow.com/tags/x86/info中的其他链接)和可以发现优化的编译器输出中的实际效率低下,如果你愿意,你有时可以手动编写更好的asm。(通常用编译器输出作为起点)。但通常你只是利用这种经验来调整你的C源来获得如果可能的话,从你的编译器中获得更好的asm,因为这对于长期来说更有用/可移植。而且很少值得手写的asm。

在这一点上,你更有可能学习通过查看gcc -O3输出来提高效率的方法。但错过优化并不罕见,如果你发现一些,你可能会在GCC的bugzilla上报告它们。)


lock的隐式xchg语义来自原始的8086.当时lock前缀确实存在,用于add/or/and/etc [mem], reg or immediate等指令。

您提到的其他说明稍后添加:386中的bts / btr / btc,486中的xadd,以及直到奔腾的cmpxchg。 (486有cmpxchg的无证操作码,请参阅an old version of the NASM appendix A对其进行评论)。

正如你所说,英特尔明智地选择不为这些新指令隐含lock,即使主要用例是多线程代码中的原子操作。 SMP x86机器开始成为486和奔腾的东西,但UP机器上的线程之间的同步不需要lock。这是Is x86 CMPXCHG atomic, if so why does it need LOCK?的相反问题

8086是一个单处理器机器,因此对于软件线程之间的同步,普通的add [mem], reg已经是关于中断的原子,因此对于上下文切换。 (并且不可能同时执行多个线程)。传统的#LOCK外部信号,文档仍然提到只有重要的wrt。 DMA观察器,或MMIO到设备上的I / O寄存器(而不是普通DRAM)。

(在现代CPU上,可缓存内存上的xchg [mem], reg不会在缓存行边界上分割,只需要一个缓存锁定,确保该行从负载读取L1d到提交到L1d的存储保持MESI Exclusive或Modified状态。 )

我不知道为什么8086架构师(主要是斯蒂芬莫尔斯设计的指令集)选择不制作具有内存的非原子xchg。也许在8086上,在执行store + load事务时让CPU断言#LOCK并不是那么慢?但是后来我们对x86的其余部分一直坚持使用那些语义。 x86设计很少有前瞻性,如果xchg的主要用例是原子I / O,那么它保存了代码大小以使lock隐含。


无法在xchg [mem], reg中禁用隐式锁定

您需要使用多个不同的说明。 xor-swap是可能的,但非常低效。仍然可能没有xchg那么糟糕,取决于微体系结构和周围的代码(等待所有先前的存储执行并在执行任何后续加载之前提交到L1d缓存是多么糟糕)。例如一些飞行中的高速缓存缺失存储可能使它非常昂贵,而内存目标xor可以将数据留在存储缓冲区中。

编译器基本上从不在寄存器之间使用xchg(因为it's not cheaper than 3 mov instructions on Intel,所以它通常不是一个有用的窥孔优化来寻找)。他们只使用它来实现std::atomic存储与seq_cst内存顺序(因为它在大多数uql上比mov + mfence更有效:Why does a std::atomic store with sequential consistency use XCHG?),并实现std::atomic::exchange

如果x86有一个微编码但非原子的swap reg,mem,它偶尔会有用,但事实并非如此。没有这样的指示。

但是特别是x86-64有16个寄存器,你只是因为你自己创建了这个问题。为计算留下一些划痕。


2
投票

只做编译器做的事情。根据需要将堆栈中的参数加载到寄存器中,根据需要将寄存器溢出到堆栈中自己的位置,以释放寄存器。这是用于处理需要比可用寄存器更多的寄存器的问题的标准且广泛使用的(如果不是非常优雅的)方法。

另请注意,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 info指令,但如果您希望代码与编译器生成的代码一样高效,同时仍然保持相对容易维护,那么您仍需要其他所有内容。

但有一种方法可以避免必须完成所有这些,只需使用C / C ++编译器。这些天真的不需要组装。你不可能编写比编译器更快的代码,你可以使用内在函数来访问你想要使用的任何特殊汇编指令。编译器可以担心堆栈中存在的东西,它可以很好地完成寄存器分配,最大限度地减少寄存器保存和溢出所需的数量。

(微软的C / C ++编译器甚至可以生成我之前提到的链式展开信息,这样只有在必要时才能保存被调用者保存的寄存器。)

© www.soinside.com 2019 - 2024. All rights reserved.