x64 Windows 下的快速纤程/协程

Jes*_*tin 2 c windows assembly x86-64 coroutine

所以我有这个协程 API,由我扩展,基于我在这里找到的代码:https ://the8bitpimp.wordpress.com/2014/10/21/coroutines-x64-and-visual-studio/

struct mcontext {
  U64 regs[8];
  U64 stack_pointer;
  U64 return_address;
  U64 coroutine_return_address;
};

struct costate {
   struct mcontext callee;
   struct mcontext caller;
   U32 state;
};

void coprepare(struct costate **token,
       void *stack, U64 stack_size, cofunc_t func); /* C code */
void coenter(struct costate *token, void *arg);     /* ASM code */
void coyield(struct costate *token);                /* ASM code */
int  coresume(struct costate *token);               /* ASM code, new */
Run Code Online (Sandbox Code Playgroud)

我坚持实施 coyield()。coyield() 可以用 C 语言编写,但我遇到问题的是程序集。这是我到目前为止所得到的(MASM/VC++ 语法)。

;;; function: void _yield(struct mcontext *callee, struct mcontext *caller)
;;; arg0(RCX): callee token
;;; arg2(RDX): caller token
_yield proc
    lea RBP, [RCX + 64 * 8]
    mov [RCX +  0], R15
    mov [RCX +  8], R14
    mov [RCX + 16], R13
    mov [RCX + 24], R12
    mov [RCX + 32], RSI
    mov [RCX + 40], RDI
    mov [RCX + 48], RBP
    mov [RCX + 56], RBX

    mov R11, RSP
    mov RSP, [RDX + 64]
    mov [RDX + 64], R11

    mov R15, [RDX + 0]
    mov R14, [RDX + 8]
    mov R13, [RDX + 16]
    mov R12, [RDX + 24]
    mov RSI, [RDX + 32]
    mov RDI, [RDX + 40]
    mov RBP, [RDX + 48]    
        mov RBX, [RDX + 56]

    ret
_yield endp
Run Code Online (Sandbox Code Playgroud)

这是 8bitpimp 代码的直接改编。如果我正确理解这段代码,它不会做的是将 mcontext->return_address 和 mcontext->coroutine_return_address 放在堆栈上,由 ret 弹出。还有,这么快吗?IIRC,它会导致现代 x64 部件中的返回分支预测器不匹配。

Bee*_*ope 6

此答案仅解决问题的“快吗”部分。

返回地址预测

首先,简要描述典型返回地址预测器的行为。

  • 每次call创建 a 时,压入实际堆栈的返回地址也存储在称为返回地址缓冲区或类似内容的 CPU 结构内。
  • 当ret进行(返回)时,CPU 假设目的地将是当前位于返回地址缓冲区顶部的地址,并且返回地址缓冲区中的条目被“弹出”。

效果是完美地预测1call /ret对,只要它们以通常的正确嵌套模式出现,并且实际上删除了每种情况下ret推送的未修改的返回地址。call欲了解更多详情,您可以从这里开始。

C 或 C++(或几乎任何其他语言)中的正常函数调用通常始终遵循此正确嵌套模式2。因此,您无需执行任何特殊操作即可利用回报预测。

失效模式

call在/未正常配对的情况下ret,预测可能会(至少)以几种不同的方式失败:

  • 如果堆栈指针或堆栈上的返回值被操纵,使得 aret不返回相应call压入的位置,您将得到该分支目标预测失败ret,但后续正常嵌套ret指令将继续正确预测,如下所示只要它们正确嵌套即可。例如,如果在 at 函数中向 at 的值添加几个字节[rsp],以便跳过调用call函数中紧随其后的指令,则 nextret将会错误预测,但ret调用函数内紧随其后的指令应该没问题。
  • 另一方面,call和ret函数没有正确嵌套,整个返回预测缓冲区可能会变得不对齐,导致未来的ret指令(如果有)使用现有值错误预测2.5。例如,如果您call进入一个函数,但随后使用jmp返回给调用者,则存在call不匹配的情况ret。调用者的ret内部将错误预测,ret调用者的调用者的内部也会错误预测,依此类推,直到所有未对齐的值都用完或被覆盖3。如果你有一个ret与相应的调用不匹配的情况,也会出现类似的情况(这种情况对于后续的分析很重要)。

除了上面的两条规则之外,您还可以通过跟踪代码并跟踪每个点的返回堆栈的样子来简单地确定返回预测器的行为。每次你有一条ret指令时,看看它是否返回到返回堆栈的当前顶部 - 如果没有,你就会得到错误的预测。

错误预测成本

错误预测的实际成本取决于周围的代码。通常给出约 20 个周期的数字,并且在实践中经常看到,但实际成本可能更低:例如,如果 CPU 能够尽早解决错误预测并开始沿新路径获取而不中断,则成本可低至零关键路径或更高:例如,如果分支预测失败需要很长时间才能解决并降低长延迟操作的有效并行性。不管怎样,我们可以说,当它发生在其他只需要少量指令的操作中时,惩罚通常是显着的。

快速协程

Coresume 和 Coyield 的现有行为

现有的_yield(上下文切换)函数交换堆栈指针rsp,然后用于返回到与实际调用者推送的位置不同的位置(特别是,它返回到调用者之前调用时ret推送到堆栈的位置)。这通常会导致内部的错误预测。calleryieldret_yield

例如,考虑这样的情况:某个函数A0对 进行普通函数调用A1,它会调用coresume4来恢复协程B1,稍后又调用coyieldyield 返回到A1。在对 的调用中coresume,返回堆栈看起来像A0, A1,但随后coresume交换rsp为指向堆栈 for ,并且该堆栈的顶部值是紧随在代码 for 中的B1地址。因此内部跳转到 中的一个点,而不是像返回堆栈期望的那样跳转到 中的一个点。因此,您会对此做出错误预测,并且返回堆栈看起来像.B1coyieldB1retcoresumeB1A1retA0

B1现在考虑调用时会发生什么coyield,其实现方式基本相同coresume:调用压coyield入B1返回堆栈(现在看起来像)A0, B1,然后交换堆栈以指向A1堆栈,然后执行ret将返回的操作A1。因此ret错误预测也会以同样的方式发生,并且堆栈保留为A0。

coresume因此,坏消息是,对和 的一系列紧密调用coyield(例如,典型的基于收益的迭代器)每次都会出现错误预测。好消息是,现在A1至少在内部返回堆栈是正确的(没有错位) - 如果A1返回到其调用者A0,则返回被正确预测(等等,当A0返回到其调用者时,等等)。因此,您每次都会遭受错误预测的惩罚,但至少在这种情况下您不会错位返回堆栈。其相对重要性取决于您调用coresume/coyield的频率与通常在正在调用的函数下面调用函数的频率coresume。

让它变得更快

那么我们可以纠正错误预测吗?不幸的是,C 和外部 ASM 调用的组合很棘手,因为调用coresumeorcoyield 意味着编译器插入的调用,并且很难在 asm 中展开它。

尽管如此,我们还是尝试一下吧。

使用间接调用

ret一种方法是根本不使用并且仅使用间接跳转。

也就是说,只需将and调用ret末尾的替换为:coresumecoyield

pop r11
jmp r11
Run Code Online (Sandbox Code Playgroud)

这在功能上等同于ret,但对返回堆栈缓冲区的影响不同(特别是,它不影响它)。

coresume如果像上面那样分析和调用的重复序列coyield,我们会得到返回堆栈缓冲区像 一​​样开始无限增长的结果A0, A1, B1, A1, B1, ...。发生这种情况是因为实际上我们ret在此实现中根本没有使用 。所以我们不会遭受返回错误预测,因为我们没有使用ret! 相反,我们依靠间接分支预测器的准确性来预测jmp11。

该预测器如何工作取决于如何coresume实施coyeild。如果它们都调用_yield未内联的共享函数,则只有一个jmp r11位置,并且这jmp将交替转到A1和中的位置B1。大多数现代间接预测器都会很好地重新预测这种简单的重复模式,尽管仅跟踪单个位置的旧预测器不会。如果_yield内联到每个函数中coresume或者coyield您只是将代码复制粘贴到每个函数中,则会有两个不同的调用站点,每个调用站点只能看到一个位置,并且应该可以由具有间接分支预测器6jmp r11的任何 CPU 进行良好预测。

因此,这通常应该预测一系列紧密coyield并coresume调用7,但代价是消除返回缓冲区,因此当A1决定返回到A0此时将被错误预测以及后续返回A0等等。此惩罚的大小受返回堆栈缓冲区大小的限制,因此如果您进行许多紧密coresume/yield调用,这可能是一个很好的权衡。

这是我在对 ASM 编写的函数进行外部调用的限制下能想到的最好的方法,因为你已经有了一个隐含的call例程co,并且你必须从那里跳转到另一个协程,而我不知道如何保持堆栈平衡并在这些约束下返回到正确的位置。

调用站点的内嵌代码

如果您可以在协程方法的调用位置内联代码(例如,使用编译器支持或内联汇编),那么您也许可以做得更好。

对的调用coresume可以像这样内联(我省略了寄存器保存和恢复代码,因为这很简单):

; rcx - current context
; rdc - context for coroutine we are about to resume

; save current non-volatile regs (not shown)
; load non-volatile regs for dest (not shown)
lea r11, [rsp - 8]
mov [rcx + 64], r11 ; save current stack pointer
mov r11, [rdx + 64] ; load dest stack pointer
call [r11]
Run Code Online (Sandbox Code Playgroud)

请注意,coresume实际上并没有进行堆栈交换 - 它只是将目标堆栈加载到其中r11,然后执行call反对[r11]跳转到协程。这是必要的,以便call正确地将我们应该返回的位置推送到调用者的堆栈上。

然后,coyield看起来像(内联到调用函数中):

; save current non-volatile regs (not shown)
; load non-volatile regs for dest (not shown)
lea r11, [after_ret]
push r11             ; save the return point on the stack
mov  rsp, [rdx + 64] ; load the destination stack
ret
after_ret:
mov rsp, r11
Run Code Online (Sandbox Code Playgroud)

当coresume调用跳转到协程时,它最终会到达after_ret,并且在执行用户代码之前,指令会交换到由mov rsp, r11隐藏的协程的正确堆栈。r11coresume

因此本质上coyield有两部分:上半部分在yield之前执行(在调用时发生ret),下半部分完成由 开始的工作coresume。这允许您使用作为进行跳跃和进行跳跃的call机制。在这种情况下 / 是平衡的。coresumeretcoyieldcallret

我已经掩盖了这种方法的一些细节:例如,由于不涉及函数调用,因此 ABI 指定的非易失性寄存器并不真正特殊:在内联汇编的情况下,您需要向编译器将破坏哪些变量并保存其余变量,但您可以选择任何对您方便的设置。选择更大的破坏变量集会使coresume/coyield代码序列本身更短,但可能会给周围的代码带来更多的寄存器压力,并可能迫使编译器溢出更多的周围代码。也许理想的情况就是声明所有内容都被破坏,然后编译器就会泄漏它需要的内容。


1当然,实践中存在限制:返回堆栈缓冲区的大小可能限制为某个较小的数字(例如 16 或 24),因此一旦调用堆栈的深度超过该深度,一些返回地址就会丢失并丢失。无法正确预测。此外,诸如上下文切换或中断之类的各种事件都可能会扰乱返回堆栈预测器。

2一个有趣的例外是在 x86(32 位)代码中读取当前指令指针的常见模式:没有指令可以直接执行此操作,因此call next; next: pop rax可以使用序列:acall到仅用于推送的下一条指令弹出堆栈上的地址。没有对应的ret。然而,当前的 CPU 实际上可以识别这种模式,并且在这种特殊情况下不会使返回地址预测器失衡。

2.5这意味着有多少错误预测取决于调用函数的net返回值:例如,如果它立即开始向下调用另一个深层调用链,则未对齐的返回堆栈条目可能根本不会被使用。

3或者,也许,在没有相应调用的情况下重新对齐返回地址堆栈之前ret,可能会出现“两个错误构成一个正确”的情况。

4您实际上尚未展示如何coyield并coresume实际调用_yield,因此对于问题的其余部分,我将假设它们基本上按原样实现_yield,直接在调用内coyield或不调用:即,将代码复制并粘贴到每个函数中,可以使用一些小的修改来解释差异。您还可以通过调用 来完成这项工作,但是这样您就会有额外的调用和 ret 层,这会使分析变得复杂。coresume_yield_yield_yield

5在某种程度上,这些术语在对称协程实现中甚至有意义,因为实际上在这种情况下不存在调用者和被调用者的绝对概念。

6当然,这种分析仅适用于简单的情况,即您有一个coresume调用通过单个coyield调用调用协程。更复杂的场景是可能的,例如被coyield调用者内部的多个调用,或coresume调用者内部的多个调用(可能对不同的协程)。然而,同样的模式也适用:具有分割站点的情况jmp r11将比组合情况提供更简单的流(可能以更多 iBTB 资源为代价)。

7一个例外是第一次或两次调用:ret预测器不需要“预热”,但间接分支预测器可能需要“预热”,特别是在中间调用另一个协程时。

  • @PeterCordes - 我从这里得到它(http://blog.stuffedcow.net/2018/04/ras-microbenchmarks/) - 寻找“get_current_ip”。在测试的 CPU 中,只有 PPro 和 Nano 未能解决此问题。因此,gcc 的策略可能是在 PPro(也许 PII 和 PIII 都基于类似设计)周围选择的,当时这不起作用,但今天大多数芯片似乎都做到了这一点。 (2认同)
  • 这是个好消息,我将更新几个 SO 问答,其中 call/pop 被警告不要!有趣的是,较旧的 Intel CPU 显然将 RAS 视为循环缓冲区。我(在 Spectre 缓解补丁中)读到,当返回预测器下溢时,SKL 使用常规间接分支预测器,所以也许这是一个新功能。我注意到 Henry 没有测试提及 SKL/Skylake。:/ (2认同)