x64 Windows下的快速光纤/协同程序

时间:2018-05-19 20:46:47

标签: 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 */

我坚持实施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

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

1 个答案:

答案 0 :(得分:4)

此答案仅解决了#34;它是否快速"问题的一部分。

返回地址预测

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

  • 每次创建call时,在实际堆栈上推送的返回地址也会存储在称为返回地址缓冲区的CPU结构中或类似的内容中。
  • 当进行ret(返回)时,CPU假定目标将是当前位于返回地址缓冲区顶部的地址,并且来自返回地址缓冲区的条目是"弹出&#34 ;

效果是完全 1 预测call / ret对,只要它们以通常的正确嵌套模式出现并且ret实际上是call删除call在每种情况下推送的未修改的返回地址。有关详细信息,您可以start here

C或C ++(或几乎任何其他语言)的普通函数调用通常总是遵循这个正确嵌套的模式 2 。所以你不需要做任何特别的事情来利用回报预测。

失败模式

如果ret / ret未正常配对,预测可能会(至少)以两种不同的方式失败:

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

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

错误预测成本

错误预测的实际成本取决于周围的代码。通常给出~20个周期的数字并且在实践中经常看到,但实际成本可以更低:例如,如果CPU能够resolve the misprediction early并且在没有新路径的情况下开始获取,则低至零中断关键路径或更高:例如,如果分支预测失败需要很长时间来解决并减少长延迟操作的有效并行性。无论我们可以说惩罚通常是重要的,当它发生在其他只需要少量指令的操作中时。

快速协同程序

Coresume和Coyield的现有行为

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

例如,考虑一些函数coresumeB1进行正常函数调用的情况,它调用coyield 4 来恢复协程A1,后来调用coresume以回复A0, A1。在对coresume的调用中,返回堆栈看起来像rsp,但随后B1交换B1以指向coyield的堆栈和最高值该堆栈是紧随B1代码ret之后的coresume内的地址。因此B1内的A1会跳转到ret中的某个点,而会跳转到A0中的一个点,就像返回堆栈所期望的那样。因此,您会对B1进行错误预测,并且返回堆栈看起来像coyield

现在考虑一下coresume调用coyield时会发生什么,B1调用A0, B1的方式基本相同A1调用ret推送A1返回堆栈,现在看起来像ret,然后交换堆栈以指向A0堆栈,然后执行将返回coresume的{​​{1}}。因此coyield错误预测将以相同的方式发生,并且堆栈保留为A1

所以坏消息是,对A1A0的一系列紧密调用(例如,对于基于产量的迭代器来说是典型的),每次都会错误预测。好消息是,现在A0内部至少返回堆栈是正确的(没有错位) - 如果coresume返回其调用者coyield,则返回被正确预测(等等coresume返回调用者等)。因此,每次都会遭受误预测惩罚,但至少在这种情况下你不会错误地使用返回堆栈。这相对重要性取决于您调用coresume / coyield与调用ret函数下方正常调用函数的频率。

快速完成

那么我们可以修复错误预测吗?不幸的是,在C和外部ASM调用的组合中很棘手,因为调用retcoresume 意味着编译器插入了一个调用,并且在asm中很难解开这个问题。

尽管如此,试试吧。

使用间接呼叫

一种方法是完全使用coyield,只使用间接跳转。

也就是说,只需将pop r11 jmp r11 ret来电时的coresume替换为:

coyield

这在功能上等同于A0, A1, B1, A1, B1, ...,但会以不同的方式影响返回堆栈缓冲区(特别是它不会影响它)。

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

该预测器的工作原理取决于jmp r11jmp的实施方式。如果他们都调用了一个不会内联的共享A1功能,那么只有一个B1位置,此_yield会轮流转到coresume中的某个位置coyield。大多数现代间接预测器都会认真地重复这种简单的重复模式,尽管只跟踪单个位置的旧模式不会。如果将jmp r11内联到coyieldcoresume,或者您只是将代码复制粘贴到每个函数中,则会有两个不同的A1调用网站,每个网站只能看到一个每个位置,并且应该由具有间接分支预测器 6 的任何CPU进行良好预测。

所以这应该通常预测一系列紧张的A0A0调用 7 ,但代价是要删除返回缓冲区,所以当{{1}时}决定返回coresume/yield这将被错误预测以及call后续返回等等。这个惩罚的大小超过了返回堆栈缓冲区的大小,所以如果你进行许多紧密的co调用,这可能是一个很好的权衡。

在外部调用ASM编写的函数的约束下,我能想到的最好,因为你的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] ,你必须从那里跳到另一个couroutine并且我无法看到如何保持堆栈平衡并使用这些约束返回到正确的位置。

呼叫站点的内联代码

如果您可以在couroutine方法的调用站点内联代码(例如,使用编译器支持或内联asm),那么您可以做得更好。

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 正确地推送我们应该返回到调用者堆栈的位置。

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

after_ret

mov rsp, r11调用跳转到协程时,它会以r11结束,并且在执行用户代码之前,coresume指令交换到已被存储的协程的正确堆栈在coyieldret

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

我已经掩盖了这种方法的一些细节:例如,由于没有涉及函数调用,ABI指定的非易失性寄存器并不是特别的:在内联汇编的情况下,你&& #39;我需要向编译器指出你将要删除哪些变量并保存其余变量,但是你可以选择任何方便的设置。选择一组更大的修饰变量会使call next; next: pop rax / call代码序列本身更短,但可能会对周围的代码施加更多的寄存压力,并可能迫使编译器溢出更多的代码。也许理想只是声明所有被破坏的东西,然后编译器只会溢出它需要的东西。

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

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

2.5 这意味着多少错误预测取决于 net 返回调用函数的方式:如果它立即开始调用另一个深度调用链,那么未对齐的返回堆栈例如,条目可能永远不会被使用。

3 或者,也许,直到返回地址堆栈被coresume重新对齐而没有相应的调用,一个"两个错误的情况是正确的&#34 ;

4 您实际上并未显示_yield_yield实际上如何致电coyield,因此对于问题的其余部分,我是ll假设它们基本上是coresume实现的,直接在_yield_yield内,而不调用_yield:即,将coresume代码复制并粘贴到每个coyield中功能,可能通过一些小编辑来解释差异。你也可以通过调用coyield来完成这项工作,但是你有一层额外的调用和rets会使分析变得复杂。

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

6 当然,此分析仅适用于您通过单个coresume调用调用协程的单个jmp r11调用的简单情况。更复杂的场景是可能的,例如被调用者内部的多个ret调用,或调用者内部的多个{{1}}调用(可能是不同的couroutines)。但是,相同的模式适用:分割{{1}}站点的情况将比组合情况提供更简单的蒸汽(可能以更多的iBTB资源为代价)。

7 一个例外是第一个或两个:{{1}}预测器不需要"预热"但间接分支预测器可能会,特别是在过渡期间调用另一个协程时。