所以我根据我在此处找到的代码扩展了我的协程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件中找到的返回分支预测器不匹配。
答案 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
应该没问题。ret
和ret
函数未正确嵌套,整个返回预测缓冲区可能会不对齐,从而导致将来call
指令(如果有)使用现有值来错误预测 2.5 。例如,如果您jmp
加入某个函数,然后使用call
返回给调用者,则会有一个不匹配ret
而没有ret
。调用者内部的ret
将错误预测,调用者调用者内部的ret
也是如此,依此类推,直到所有未对齐的值被用完或覆盖 3 。如果ret
与相应的调用不匹配,则会发生类似的情况(这种情况对后续分析很重要)。除了上面的两个规则之外,您还可以通过跟踪代码并跟踪返回堆栈在每个点的外观来简单地确定返回预测器的行为。每次你有一个_yield
指令,看看它是否返回到返回堆栈的当前顶部 - 如果没有,你会得到错误的预测。
错误预测的实际成本取决于周围的代码。通常给出~20个周期的数字并且在实践中经常看到,但实际成本可以更低:例如,如果CPU能够resolve the misprediction early并且在没有新路径的情况下开始获取,则低至零中断关键路径或更高:例如,如果分支预测失败需要很长时间来解决并减少长延迟操作的有效并行性。无论我们可以说惩罚通常是重要的,当它发生在其他只需要少量指令的操作中时。
现有的rsp
(上下文切换)函数交换堆栈指针ret
,然后使用caller
返回与实际调用者推送的位置不同的位置(特别是,它返回到调用者之前调用yield
时被推送到ret
堆栈的位置。这通常会导致_yield
A0
内的A1
误预测。
例如,考虑一些函数coresume
对B1
进行正常函数调用的情况,它调用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
。
所以坏消息是,对A1
和A0
的一系列紧密调用(例如,对于基于产量的迭代器来说是典型的),每次都会错误预测。好消息是,现在A0
内部至少返回堆栈是正确的(没有错位) - 如果coresume
返回其调用者coyield
,则返回被正确预测(等等coresume
返回其调用者等)。因此,每次都会遭受误预测惩罚,但至少在这种情况下你不会错误地使用返回堆栈。这相对重要性取决于您调用coresume
/ coyield
与调用ret
函数下方正常调用函数的频率。
那么我们可以修复错误预测吗?不幸的是,在C和外部ASM调用的组合中很棘手,因为调用ret
或coresume
意味着编译器插入了一个调用,并且在asm中很难解开这个问题。
尽管如此,试试吧。
一种方法是完全使用coyield
,只使用间接跳转。
也就是说,只需将pop r11
jmp r11
和ret
来电时的coresume
替换为:
coyield
这在功能上等同于A0, A1, B1, A1, B1, ...
,但会以不同的方式影响返回堆栈缓冲区(特别是它不会影响它)。
如果如上所述分析ret
和ret
调用的重复序列,我们得到的结果是返回堆栈缓冲区只是像jmp11
一样无限期地开始增长。发生这种情况是因为实际上我们在此实现中根本没有使用coresume
。所以我们不会遭受回报错误预测,因为我们没有使用coyeild
!相反,我们依靠间接分支预测器的准确性来预测_yield
。
该预测器的工作原理取决于jmp r11
和jmp
的实施方式。如果他们都调用了一个不会内联的共享A1
功能,那么只有一个B1
位置,此_yield
会轮流转到coresume
中的某个位置coyield
。大多数现代间接预测器都会认真地重复这种简单的重复模式,尽管只跟踪单个位置的旧模式不会。如果将jmp r11
内联到coyield
和coresume
,或者您只是将代码复制粘贴到每个函数中,则会有两个不同的A1
调用网站,每个网站只能看到一个每个位置,并且应该由具有间接分支预测器 6 的任何CPU进行良好预测。
所以这应该通常预测一系列紧张的A0
和A0
调用 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
指令交换到已被存储的协程的正确堆栈在coyield
中ret
。
所以基本上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}}预测器不需要"预热"但间接分支预测器可能会,特别是在过渡期间调用另一个协程时。