我正在编写一个 JIT 编译器,我惊讶地发现这么多 x86-64 寄存器在 Win64 调用约定中是非易失性的(被调用者保留)。在我看来,非易失性寄存器只是在所有可以使用这些寄存器的功能中完成更多的工作。在您希望在叶函数中使用许多寄存器的数值计算的情况下,这似乎尤其正确,比如某种高度优化的矩阵乘法。但是,例如,16 个 SSE 寄存器中只有 6 个是易失性的,因此如果您需要使用更多的内容,您将需要做很多溢出操作。
所以,是的,我不明白。这里的权衡是什么?
我正在编写一个 JIT 编译器,我惊讶地发现这么多 x86-64 寄存器在 Win64 调用约定中是非易失性的(被调用者保留)。在我看来,非易失性寄存器只是在所有可以使用这些寄存器的功能中完成更多的工作。在您希望在叶函数中使用许多寄存器的数值计算的情况下,这似乎尤其正确,比如某种高度优化的矩阵乘法。但是,例如,16 个 SSE 寄存器中只有 6 个是易失性的,因此如果您需要使用更多的内容,您将需要做很多溢出操作。
所以,是的,我不明白。这里的权衡是什么?
如果寄存器是调用者保存的,那么调用者总是必须在函数调用周围保存或重新加载这些寄存器。但是如果寄存器是被调用者保存的,那么被调用者只需要保存它使用的寄存器,并且只有当它知道它们将被使用时(即在提前退出场景中可能根本不需要)。这种约定的缺点是被调用者不知道调用者,所以它可能会保存无论如何都死的寄存器,但我想这被视为一个较小的问题。
只有 6 个调用破坏的 xmm 寄存器的 Windows x86-64 调用约定不是一个很好的设计,你是对的。大多数 SIMD(以及许多标量 FP)循环不包含任何函数调用,因此它们从将数据保存在调用保留寄存器中没有任何收获。保存/恢复是纯粹的缺点,因为它的任何调用者都很少使用这种非易失性状态。
在 x86-64 System V 中,所有向量寄存器都被调用破坏,这可能是另一种方式太远了。在许多情况下,保留 1 或 2 个调用会很好,特别是对于进行一些数学库函数调用的代码。(用来gcc -fno-math-errno
让简单的内联更好;有时他们不这样做的唯一原因是他们需要设置errno
NaN。)
相关:如何选择 x86-64 SysV 调用约定:查看 gcc 编译 SPECint/SPECfp 的代码大小和指令数。
对于整数 regs,每个都有一些肯定是好的,并且所有“正常”调用约定(对于所有架构,不仅仅是 x86)实际上都有混合。 这减少了调用者和被调用者合并完成的溢出/恢复工作总量。
强制调用者在每个函数调用周围溢出/重新加载所有内容对代码大小或性能不利。在函数的开始/结束时保存/恢复一些调用保留的 reg 可以让非叶函数将一些东西保存在跨call
s 的寄存器中。
考虑一些计算几件事然后执行cout << "result: " << a << "foo" << b*c << '\n';
That's 4 函数调用的代码std::ostream operator<<
,它们通常不会内联。将您刚刚计算的地址cout
和本地变量保存在非易失性寄存器中意味着您只需要一些便宜mov reg,reg
的指令来为下一次调用设置参数。(或push
在堆栈参数调用约定中)。
但是拥有一些可以在不保存的情况下使用的调用破坏寄存器也非常重要。不需要所有架构寄存器的函数可以只使用调用破坏寄存器作为临时寄存器。这避免了在调用者的依赖链(对于非常小的被调用者)的关键路径中引入溢出/重新加载,以及保存指令。
有时,一个复杂的函数会保存/恢复一些调用保留的寄存器,只是为了获得更多的总寄存器(就像你在 XMM 中看到的数字运算一样)。这通常是值得的;保存/恢复调用者的非易失性寄存器通常比将自己的局部变量溢出/重新加载到堆栈更好,特别是如果你必须在任何循环中这样做。
call-clobbered 寄存器的另一个原因是,通常你的一些值在函数调用之后是“死的”:你只需要它们作为函数的参数。在调用破坏寄存器中计算它们意味着您不必保存/恢复任何内容来释放这些寄存器,而且您的被调用者也可以自由使用它们。这在调用在寄存器中传递 args 的约定时甚至更好:您可以直接在 arg 传递寄存器中计算输入。(如果在函数之后还需要它们,请将它们复制到保留调用的 regs 或将它们溢出到堆栈内存。)
(我喜欢 call-preserved vs. call-clobbered 这两个术语,而不是 caller-saved vs. callee-saved。后一个术语暗示有人必须保存寄存器,而不是让死值死掉。易失性/非易失性是不错,但这些术语也有其他技术含义,如 C 关键字,或闪存与 DRAM。)
nonvolatile
拥有寄存器的好处是:性能。
移动的数据越少,CPU 的效率就越高。
volatile
寄存器越多,CPU 需要的能量就越多。
被调用者只需要保存/恢复被调用者保存的(非易失性,调用保留)寄存器,它需要暂时更改值(其中一些可能不会被堆栈链/堆栈跟踪中的任何调用者使用,但被调用者没有不知道这一点),调用者只需要保存/恢复调用者保存的(易失性,调用破坏)寄存器,它在调用之后需要(未来堆栈链中的被调用者可能实际上不会修改,但调用者不会不知道这个)。
通常,至少在 Microsoft x64 调用约定上,您会在堆栈上看到很多显式保存的非易失性寄存器,但没有显式保存的易失性寄存器——我认为这个想法是编译器永远不会到达调用者需要显式保存的阶段调用前的寄存器,尤其是在程序本身中不是变量的表达式;相反,它可以提前计划并避免完全使用这些寄存器,使用寄存器但不优化堆栈中的变量后备存储,将寄存器用于传递给被调用函数的参数,这些参数在调用被调用函数后由于它们不是而死t 在程序中定义为变量,或使用易失性寄存器。
被调用者在函数序言中显式地推送它需要保持修改的任何非易失性寄存器,并在函数序言中对堆栈进行调用,并在结尾处恢复它们。它可以将它们保存在易失性寄存器中,但如果被调用函数自己调用并且不能将其存储在另一个非易失性寄存器,因为那样该寄存器也需要保存。
我同意调用者保存意味着无论调用者是否使用它都需要保存寄存器。这不是真的,即使它确实使用了寄存器,甚至可能不必保存寄存器,因为它知道在调用之后它不需要它,或者可能根本不进行调用。
有一个平衡的平衡是很好的。拥有一个而没有其他只是一个缺点,但有时最好偏向一种类型,例如非易失性,其中该寄存器可能主要用于被调用函数而不是调用者功能,就像彼得建议的xmm
寄存器一样。
我认为拥有所有非易失性寄存器比拥有所有非易失性寄存器更有害,因为您将保存调用后可能在调用者中死亡的参数(这就是参数易失性的原因;此外,保留返回值寄存器是不可能,因此您必须为此至少有一个易失性寄存器或在堆栈上返回值,这会更慢),并且您也无法在不将值保存到堆栈的情况下暂时修改寄存器,因为有只有非易失性寄存器可用,而如果它们都是易失性寄存器,则您可以将值存储在寄存器中,直到进行调用或根本没有调用。总会有一个调用函数(除非它是基本框架),但是叶函数比基本框架要多得多,
如果所有寄存器都是易失的,这仍然是一个缺点,因为非易失寄存器可以更容易地编译您自己的应用程序,因为被调用函数的负担可能在某些单独编译的库中。此外,由于在制作陷阱帧时保存了所有易失性寄存器而不是非易失性寄存器(至少在 Microsoft x64 调用约定中就是这种情况,除非有异常或上下文切换),因此会有更多的时间/空间损失如果所有寄存器都是易失的,则用于常规系统调用。