从 JITed 代码处理对(可能)遥远的提前编译函数的调用
Handling calls to (potentially) far away ahead-of-time compiled functions from JITed code
这个问题被搁置了,因为它太宽泛了,大概是因为我在 "show my work" 的努力中包含了研究,而不是问一个低难度的问题。为了解决这个问题,请允许我用一句话总结整个问题(这句话归功于@PeterCordes):
How do I efficiently call (x86-64) ahead-of-time compiled functions (that I control, may be further than 2GB away) from JITed code (that I am generating)?
我怀疑,仅此一项就会被搁置,因为 "too broad." 特别是,它缺少 "what have you tried." 所以,我觉得有必要添加额外的信息来显示我的 research/thinking以及我尝试过的。下面是对此的一些意识流。
请注意,none 下面提出的问题是我希望得到回答的问题;他们更加修辞。 他们的目的是证明为什么我不能回答上述问题(尽管我进行了研究,但我缺乏在这方面的经验来做出明确的陈述,例如@PeterCordes "branch prediction hides the latency of fetching and checking the function pointer from memory, assuming that it predicts well.") .另请注意,Rust 组件在这里基本上无关紧要,因为这是一个装配问题。我之所以包含它是因为提前编译的函数是用 Rust 编写的,所以我不确定 Rust 所做的(或指示 LLVM 做的)在这种情况下是否有优势。完全不考虑 Rust 的答案是完全可以接受的;事实上,我预计会是这样。
将以下内容视为数学考试后的草稿作业:
注意:我在这里搞混了内在函数这个词。正如评论中指出的那样, "ahead-of-time compiled functions" 是一个更好的描述。下面我将缩写 AOTC 函数。
我正在用 Rust 编写 JIT(虽然 Rust 只与我的问题的一部分相关,但大部分与 JIT 约定有关)。我在 Rust 中实现了 AOTC 函数,我需要能够从我的 JIT 发出的代码中 call
。我的 JIT mmap(_, _, PROT_EXEC, MAP_ANONYMOUS | MAP_SHARED)
s 一些页面用于 jitted 代码。我有我的 AOTC 函数的地址,但不幸的是它们比 32 位偏移更远。我现在正在尝试决定如何发出对这些 AOTC 函数的调用。我考虑了以下选项(这些不是要回答的问题,只是说明为什么我不能自己回答这个 SO 线程的核心问题):
(Rust 特定)以某种方式让 Rust 将 AOTC 函数放置在靠近(也许在?)堆上,以便 call
s 将在 32 位偏移量内。目前尚不清楚 Rust 是否可以做到这一点(有一种方法可以指定 custom linker args,但我不知道这些应用了哪些内容,以及我是否可以针对单个函数进行重定位。即使我可以在哪里做我把它?)。如果堆足够大,这似乎也可能会失败。
(特定于 Rust)将我的 JIT 页面分配到更靠近 AOTC 函数的位置。这可以通过 mmap(_, _, PROT_EXEC, MAP_FIXED)
来实现,但我不确定如何选择一个不会破坏现有 Rust 代码的地址(并保持在 arch 限制内——是否有一种明智的方法来获得这些限制?)。
在处理绝对跳转(下面的代码)的 JIT 页面中创建存根,然后 call
创建存根。这样做的好处是 JIT 代码中的(初始)调用站点是一个很好的小型相对调用。但是必须跳过某些东西感觉不对。这似乎对性能有害(可能会干扰 RAS/jump 地址预测)。此外,由于它的地址是间接的并且它取决于该地址的 mov
,因此该跳转似乎会更慢。
mov rax, {ABSOLUTE_AOTC_FUNCTION_ADDRESS}
jmp rax
与 (3) 相反,只是在 JIT 代码中的每个内部调用站点内联上述内容。这解决了间接问题,但使 JITted 代码更大(可能这具有指令缓存和解码后果)。它仍然存在跳转是间接的并且取决于 mov
.
的问题
将 AOTC 函数的地址放在 JIT 页面附近的 PROT_READ(仅)页面上。使所有呼叫站点都在附近,绝对间接呼叫(下面的代码)。这从 (2) 中删除了第二级间接。但不幸的是,这条指令的编码很大(6 字节),所以它和 (4) 有同样的问题。此外,现在不再依赖于寄存器,不必要的跳转(只要地址在 JIT 时间已知)依赖于内存,这肯定会影响性能(尽管可能正在缓存此页面?)。
aotc_function_address:
.quad 0xDEADBEEF
# Then at the call site
call qword ptr [rip+aotc_function_address]
Futz 使用段寄存器将其放置在更靠近 AOTC 函数的位置,以便可以相对于该段寄存器进行调用。这种调用的编码很长(所以这可能有解码管道问题),但除此之外,这在很大程度上避免了它之前的许多棘手问题。但是,也许相对于非 cs
段的调用表现不佳。或者这样的 futzing 可能是不明智的(例如,与 Rust 运行时混淆)。(正如@prl 所指出的,如果没有远程调用,这是行不通的,这对性能来说很糟糕)
不是真正的解决方案,但我可以将编译器设为 32 位并且根本不会出现这个问题。这并不是一个很好的解决方案,它还会阻止我使用扩展的通用寄存器(我全部使用)。
提供的所有选项都有缺点。简而言之,1 和 2 是唯一似乎没有性能影响的方法,但尚不清楚是否有一种非 hacky 的方法来实现它们(或与此相关的任何方法)。 3-5 独立于 Rust,但有明显的性能缺陷。
鉴于这种意识流,我得出了以下修辞问题(不需要明确的答案)来证明我缺乏自己回答这个 SO 线程的核心问题的知识。 我已经打动了他们,让他们非常清楚地表明我没有提出所有这些都是我问题的一部分。
对于方法 (1),是否可以强制 Rust 在特定地址(堆附近)link 某些 extern "C"
函数?我应该如何选择这样的地址(在编译时)?假设 mmap
返回的任何地址(或 Rust 分配的地址)都在该位置的 32 位偏移量内是否安全?
对于方法 (2),我如何找到合适的位置来放置 JIT 页面(这样它就不会破坏现有的 Rust 代码)?
以及一些 JIT(非 Rust)特定问题:
对于方法 (3),存根是否会严重影响性能,我应该关心?间接 jmp
呢?我知道这有点类似于 linker 存根,除了据我了解 linker 存根至少只解析一次(所以它们不需要是间接的?)。有 JIT 使用这种技术吗?
对于方法(4),如果 3 中的间接调用没问题,那么内联调用是否值得?如果 JIT 通常采用方法 (3/4),这个选项是否更好?
对于方法 (5),跳转对内存的依赖性(假定地址在编译时已知)是否不好?这会降低 (3) 或 (4) 的性能吗?有 JIT 使用这种技术吗?
对于方法(6),这样的futzing是不明智的吗? (特定于 Rust)是否有可用的段寄存器(运行时或 ABI 未使用)用于此目的?相对于非 cs
段的调用是否与相对于 cs
的调用一样高效?
并且 最后(也是最重要的),是否有我在这里缺少的更好的方法(可能更常被 JIT 使用)?
如果我的 Rust 问题没有答案,我无法实施 (1) 或 (2)。当然,我可以实施和基准测试 3-5(也许 6,尽管事先了解段寄存器 futzing 会很好),但考虑到这些是截然不同的方法,我希望有关于此的现有文献我找不到,因为我不知道 google 的正确术语(我目前也在研究这些基准)。或者,也许深入研究 JIT 内部的人可以分享他们的经验或他们的常见情况?
我知道这个问题:. It differs from mine because it is talking about stringing together basic blocks (and the accepted solution is way too many instructions for a frequently called intrinsic). I am also aware of Call an absolute pointer in x86 machine code,虽然它讨论的主题与我的类似,但有所不同,因为我不认为绝对跳转是必要的(方法 1-2 会避免它们,因为例如)。
总结:尝试在您的静态代码附近分配内存。但是对于无法通过 rel32
到达的呼叫,回退到 call qword [rel pointer]
或内联 mov r64,imm64
/ call r64
.
如果你不能使 2. 工作,你的机制 5. 可能是性能最好的,但是 4. 很简单,应该没问题。直接 call rel32
也需要一些分支预测,但它肯定更好。
术语:“内在函数”可能应该是“辅助”函数。 “内在”通常意味着内置语言(例如 Fortran 的意思)或“不是真正的 函数 ,只是内联到机器指令的东西”(C/C++ / Rust 的意思,比如 SIMD,或者 _mm_popcnt_u32()
、_pdep_u32()
或 _mm_mfence()
之类的东西。您的 Rust 函数将编译为机器代码中存在的真实函数,您将使用 call
指令调用这些函数。
是的,在目标函数的 +-2GiB 范围内分配 JIT 缓冲区显然是理想的,允许 rel32 直接调用。
最直接的方法是在 BSS 中使用一个大型静态数组(链接器会将其放置在您代码的 2GiB 内)并从中分割出您的分配。 (使用mprotect
(POSIX)或VirtualProtect
(Windows)使其执行table)。
大多数操作系统(包括 Linux)对 BSS 进行惰性分配(COW 映射到零页,只分配物理页框以在写入时支持该分配,就像没有 [=21= 的 mmap ]), 所以它只会浪费虚拟地址 space 在 BSS 中有一个 512MiB 数组,而你只使用底部的 10kB。
不过,不要让它大于或接近 2GiB,因为这会将 BSS 中的其他内容推得太远。默认的“小型”代码模型(如 x86-64 System V ABI 中所述)将所有静态地址彼此相距 2GiB,用于 RIP 相关数据寻址和 rel32 call/jmp.
缺点:您至少必须自己编写一个简单的内存分配器,而不是使用 mmap/munmap 处理整个页面。但如果您不需要释放任何东西,那很容易。也许只是从一个地址开始生成代码,一旦你到达终点并发现你的代码块有多长就更新一个指针。 (不过那不是多线程的。。。)为了安全,记得检查一下什么时候到达这个buffer的末尾然后中止,或者回退到mmap
.
如果您的绝对目标地址在虚拟地址 space 的低 2GiB 中,请在 Linux 上使用 mmap(MAP_32BIT)
。 (例如,如果你的 Rust 代码被编译成一个非 PIE executable for x86-64 Linux。但 PIE executables 就不是这样了(common these days),或共享库中的目标。您可以在 运行 时通过检查您的辅助函数之一的地址检测到这一点。)
一般来说(如果 MAP_32BIT
不是 helpful/available),你最好的选择可能是 mmap
没有 MAP_FIXED
,但是with一个你认为是免费的非NULL提示地址。
Linux 4.17 引入了 MAP_FIXED_NOREPLACE
,它可以让您轻松搜索附近未使用的区域(例如,步进 64MB,如果您得到 EEXIST
,请重试,然后记住该地址以避免下次搜索)。否则,您可以在启动时解析 /proc/self/maps
一次,以在包含您的辅助函数之一的映射附近找到一些未映射的 space 。将靠在一起。
Note that older kernels which do not recognize the MAP_FIXED_NOREPLACE
flag will typically (upon detecting a collision with a preexisting mapping) fall back to a "non-MAP_FIXED" type of behavior: they will return an address that is different from the requested address.
在下一个更高或更低的空闲页面中对于拥有非稀疏内存映射是理想的,因此页面 table 不需要太多不同的顶级页面目录。 (HW 页面 tables 是一棵基数树。)一旦你找到一个可用的位置,就让未来的分配与它相邻。如果你最终在那里使用了很多 space ,内核可以机会性地使用一个 2MB 的大页面,并且让你的页面再次连续意味着它们在硬件页面 tables 中共享相同的父页面目录所以 iTLB触发页面遍历的未命中可能稍微便宜(如果那些更高级别在数据缓存中保持热,甚至缓存在页面遍历硬件本身内)。并且为了使内核有效地跟踪为一个更大的映射。当然,如果有空间,使用更多已分配的页面会更好。页面级别更好的代码密度有助于指令 TLB,也可能在 DRAM 页面内(但不一定与虚拟内存页面大小相同)。
然后当你为每个调用做代码生成时,只需检查目标是否在call rel32
[=206=的范围内] off == (off as i32) as i64
否则退回到 10 字节 mov r64,imm64
/ call r64
。 (rustcc 会将其编译为 movsxd
/cmp
,因此每次检查仅对 JIT 编译时间产生微不足道的成本。)
(或者 5 字节 mov r32,imm32
如果可能的话。不支持 MAP_32BIT
的操作系统可能仍然有目标地址在那里。用 target == (target as u32) as u64
检查一下。第三个mov
-立即编码,7 字节 mov r/m64, sign_extended_imm32
可能并不有趣,除非您正在为映射到高 2GiB 虚拟地址 space 中的内核 JITing 内核代码。)
尽可能检查和使用直接调用的好处在于,它将代码生成与有关分配附近页面或地址来源的任何知识分离开来,并且只是机会主义地编写好的代码。 (您可能会记录一次计数器或日志,这样您/您的用户至少会注意到附近的分配机制是否出现故障,因为性能差异通常不容易测量。)
mov-imm / call reg 的替代方法
mov r64,imm64
是一个 10 字节的指令,相对于 fetch/decode 来说有点大,并且要存储在 uop-cache 中。根据 Agner Fog 的 microarch pdf (https://agner.org/optimize),从 SnB 系列上的 uop 缓存中读取可能需要一个额外的周期。但是现代 CPU 具有相当好的代码获取带宽和强大的前端。
如果分析发现前端瓶颈是您代码中的一个大问题,或者较大的代码大小导致从 L1 I-cache 中驱逐其他有价值的代码,我会选择选项 5。
顺便说一句,如果您的任何函数是可变的,x86-64 System V 要求您传递 AL=number of XMM args,您可以使用 r11
作为函数指针。它被调用破坏并且不用于 arg 传递。但是 RAX(或其他“遗留”寄存器)将在 call
.
上保存一个 REX 前缀
- Allocate Rust functions near where
mmap
will allocate
不,我认为没有任何机制可以让您的静态编译函数靠近 mmap
可能碰巧放置新页面的位置。
mmap
有超过 4GB 的可用虚拟地址 space 可供选择。您不会提前知道它将分配到哪里。 (尽管我认为 Linux 至少保留了一些局部性,以优化 HW 页面 tables。)
理论上你可以 copy 你的 Rust 函数的机器代码,但它们可能引用 other static code/data with RIP 相对寻址模式。
call rel32
to stubs that use mov
/jmp reg
This seems like it would be detrimental to performance (perhaps interfering with RAS/jump address prediction).
perf 的缺点仅在于前端总共有 2 条 call/jump 指令要通过,然后才能为后端提供有用的指令。这不是很好; 5.好多了。
这基本上就是 PLT 在 Unix/Linux 上调用共享库函数的工作方式,并将执行相同的操作。通过PLT(Procedure Linking Table)存根函数调用几乎就是这样。因此,性能影响已得到充分研究,并与其他做事方式进行了比较。我们知道动态库调用不是性能灾难。
显示 AT&T 反汇编,或者单步执行一个像 main(){puts("hello"); puts("world");}
这样的 C 程序,如果你好奇的话。 (在第一次调用时,它压入一个 arg 并跳转到惰性动态链接器函数;在后续调用中,间接跳转目标是函数在共享库中的地址。)
解释更多。 jmp
的地址被惰性链接更新为 jmp qword [xxx@GOTPLT]
。 (是的,PLT 确实在这里使用了内存间接 jmp
,即使在 i386 上,重写的 jmp rel32
也可以工作。IDK if GNU/Linux 曾经用于重写偏移量在 jmp rel32
.)
jmp
只是一个标准的尾调用,不会不平衡Return-地址预测器堆栈.目标函数中最终的 ret
将 return 到原始 call
之后的指令,即 call
推入调用堆栈和微架构 RAS 的地址。只有当你使用 push / ret(如用于 Spectre 缓解的“retpoline”)时,你才会使 RAS 不平衡。
但是 that you linked is unfortunately terrible (see my comment under it). It will break the RAS for future returns. You'd think it would only break it for this one with the call (to get a return address to be adjusted) should balance out the push/ret, but actually call +0
is a special case that doesn't go on the RAS in most CPUs: http://blog.stuffedcow.net/2018/04/ras-microbenchmarks中的代码。 (调用 nop
可能会改变我的猜测,但整个事情与 call rax
相比完全是疯狂的,除非它试图抵御 Spectre 攻击。)通常在 x86-64 上,你使用 RIP-相对 LEA 将附近的地址放入寄存器,而不是 call/pop
。
- inline
mov r64, imm64
/ call reg
这可能比3好;较大代码大小的前端成本可能低于通过使用 jmp
.
的存根调用的成本
但这也可能足够好,特别是如果您的 alloc-within-2GiB 方法在大多数时候对您关心的大多数目标都运行良好。
但在某些情况下它可能比 5. 慢。分支预测隐藏了从内存中获取和检查函数指针的延迟,假设它预测得很好。 (通常它会,否则它 运行 的频率太低以至于与性能无关。)
call qword [rel nearby_func_ptr]
这就是 gcc -fno-plt
如何编译对 Linux (call [rip + symbol@GOTPCREL]
) 上的共享库函数的调用,以及 Windows DLL 函数调用的方式通常完成。(这就像http://www.macieira.org/blog/2012/01/sorry-state-of-dynamic-libraries-on-linux/中的建议之一)
call [RIP-relative]
是 6 个字节,仅比 call rel32
大 1 个字节,因此它对代码大小和调用存根的影响可以忽略不计。有趣的事实:您有时会在机器代码中看到 addr32 call rel32
(地址大小前缀除了填充外没有任何作用)。如果在链接期间在另一个 .o
中发现具有非隐藏 ELF 可见性的符号,那么链接器将 call [RIP + symbol@GOTPCREL]
放宽为 call rel32
,毕竟不是不同的共享对象。
对于共享库调用,这通常优于 PLT 存根,唯一的缺点是程序启动较慢,因为它需要早期绑定(非惰性动态链接)。这对你来说不是问题;目标地址在代码生成时间之前已知。
The patch author tested its performance 与一些未知 x86-64 硬件上的传统 PLT。 Clang 可能是共享库调用的最坏情况,因为它会多次 调用不需要太多时间的小型 LLVM 函数,而且它很长 运行ning 所以早期绑定启动开销可以忽略不计。使用gcc
和gcc -fno-plt
编译clang后,clang -O2 -g
编译tramp3d的时间从41.6s(PLT)缩短到36.8s(-fno-plt)。 clang --help
变得稍微慢一些。
(x86-64 PLT 存根使用 jmp qword [symbol@GOTPLT]
,而不是 mov r64,imm64
/jmp
。内存间接 jmp
在现代英特尔 CPU 上只是一个 uop ,因此它在正确预测时更便宜,但在错误预测时可能更慢,特别是如果 GOTPLT 条目在缓存中未命中。不过,如果它经常使用,它通常会正确预测。但无论如何 10 字节 movabs
一个 2 字节 jmp
可以作为一个块获取(如果它适合 16 字节对齐的获取块),并在一个周期内解码,所以 3. 并非完全不合理。但这样更好。)
为您的指针分配 space 时,请记住它们是作为数据提取到 L1d 缓存 中的,并且带有 dTLB 条目而不是 iTLB。 不要将它们与代码交织在一起,这会在 I-cache 中浪费 space 在此数据上,并在 D-cache 中浪费 space 在包含一个指针,主要是代码。将您的指针分组到代码中一个单独的 64 字节块中,这样该行就不需要同时位于 L1I 和 L1D 中。如果它们与某些代码位于同一 页面 中,那很好;它们是只读的,因此不会导致自修改代码管道核弹。
这个问题被搁置了,因为它太宽泛了,大概是因为我在 "show my work" 的努力中包含了研究,而不是问一个低难度的问题。为了解决这个问题,请允许我用一句话总结整个问题(这句话归功于@PeterCordes):
How do I efficiently call (x86-64) ahead-of-time compiled functions (that I control, may be further than 2GB away) from JITed code (that I am generating)?
我怀疑,仅此一项就会被搁置,因为 "too broad." 特别是,它缺少 "what have you tried." 所以,我觉得有必要添加额外的信息来显示我的 research/thinking以及我尝试过的。下面是对此的一些意识流。
请注意,none 下面提出的问题是我希望得到回答的问题;他们更加修辞。 他们的目的是证明为什么我不能回答上述问题(尽管我进行了研究,但我缺乏在这方面的经验来做出明确的陈述,例如@PeterCordes "branch prediction hides the latency of fetching and checking the function pointer from memory, assuming that it predicts well.") .另请注意,Rust 组件在这里基本上无关紧要,因为这是一个装配问题。我之所以包含它是因为提前编译的函数是用 Rust 编写的,所以我不确定 Rust 所做的(或指示 LLVM 做的)在这种情况下是否有优势。完全不考虑 Rust 的答案是完全可以接受的;事实上,我预计会是这样。
将以下内容视为数学考试后的草稿作业:
注意:我在这里搞混了内在函数这个词。正如评论中指出的那样, "ahead-of-time compiled functions" 是一个更好的描述。下面我将缩写 AOTC 函数。
我正在用 Rust 编写 JIT(虽然 Rust 只与我的问题的一部分相关,但大部分与 JIT 约定有关)。我在 Rust 中实现了 AOTC 函数,我需要能够从我的 JIT 发出的代码中 call
。我的 JIT mmap(_, _, PROT_EXEC, MAP_ANONYMOUS | MAP_SHARED)
s 一些页面用于 jitted 代码。我有我的 AOTC 函数的地址,但不幸的是它们比 32 位偏移更远。我现在正在尝试决定如何发出对这些 AOTC 函数的调用。我考虑了以下选项(这些不是要回答的问题,只是说明为什么我不能自己回答这个 SO 线程的核心问题):
(Rust 特定)以某种方式让 Rust 将 AOTC 函数放置在靠近(也许在?)堆上,以便
call
s 将在 32 位偏移量内。目前尚不清楚 Rust 是否可以做到这一点(有一种方法可以指定 custom linker args,但我不知道这些应用了哪些内容,以及我是否可以针对单个函数进行重定位。即使我可以在哪里做我把它?)。如果堆足够大,这似乎也可能会失败。(特定于 Rust)将我的 JIT 页面分配到更靠近 AOTC 函数的位置。这可以通过
mmap(_, _, PROT_EXEC, MAP_FIXED)
来实现,但我不确定如何选择一个不会破坏现有 Rust 代码的地址(并保持在 arch 限制内——是否有一种明智的方法来获得这些限制?)。在处理绝对跳转(下面的代码)的 JIT 页面中创建存根,然后
call
创建存根。这样做的好处是 JIT 代码中的(初始)调用站点是一个很好的小型相对调用。但是必须跳过某些东西感觉不对。这似乎对性能有害(可能会干扰 RAS/jump 地址预测)。此外,由于它的地址是间接的并且它取决于该地址的mov
,因此该跳转似乎会更慢。
mov rax, {ABSOLUTE_AOTC_FUNCTION_ADDRESS}
jmp rax
与 (3) 相反,只是在 JIT 代码中的每个内部调用站点内联上述内容。这解决了间接问题,但使 JITted 代码更大(可能这具有指令缓存和解码后果)。它仍然存在跳转是间接的并且取决于
mov
. 的问题
将 AOTC 函数的地址放在 JIT 页面附近的 PROT_READ(仅)页面上。使所有呼叫站点都在附近,绝对间接呼叫(下面的代码)。这从 (2) 中删除了第二级间接。但不幸的是,这条指令的编码很大(6 字节),所以它和 (4) 有同样的问题。此外,现在不再依赖于寄存器,不必要的跳转(只要地址在 JIT 时间已知)依赖于内存,这肯定会影响性能(尽管可能正在缓存此页面?)。
aotc_function_address:
.quad 0xDEADBEEF
# Then at the call site
call qword ptr [rip+aotc_function_address]
Futz 使用段寄存器将其放置在更靠近 AOTC 函数的位置,以便可以相对于该段寄存器进行调用。这种调用的编码很长(所以这可能有解码管道问题),但除此之外,这在很大程度上避免了它之前的许多棘手问题。但是,也许相对于非(正如@prl 所指出的,如果没有远程调用,这是行不通的,这对性能来说很糟糕)cs
段的调用表现不佳。或者这样的 futzing 可能是不明智的(例如,与 Rust 运行时混淆)。不是真正的解决方案,但我可以将编译器设为 32 位并且根本不会出现这个问题。这并不是一个很好的解决方案,它还会阻止我使用扩展的通用寄存器(我全部使用)。
提供的所有选项都有缺点。简而言之,1 和 2 是唯一似乎没有性能影响的方法,但尚不清楚是否有一种非 hacky 的方法来实现它们(或与此相关的任何方法)。 3-5 独立于 Rust,但有明显的性能缺陷。
鉴于这种意识流,我得出了以下修辞问题(不需要明确的答案)来证明我缺乏自己回答这个 SO 线程的核心问题的知识。 我已经打动了他们,让他们非常清楚地表明我没有提出所有这些都是我问题的一部分。
对于方法 (1),是否可以强制 Rust 在特定地址(堆附近)link 某些
extern "C"
函数?我应该如何选择这样的地址(在编译时)?假设mmap
返回的任何地址(或 Rust 分配的地址)都在该位置的 32 位偏移量内是否安全?对于方法 (2),我如何找到合适的位置来放置 JIT 页面(这样它就不会破坏现有的 Rust 代码)?
以及一些 JIT(非 Rust)特定问题:
对于方法 (3),存根是否会严重影响性能,我应该关心?间接
jmp
呢?我知道这有点类似于 linker 存根,除了据我了解 linker 存根至少只解析一次(所以它们不需要是间接的?)。有 JIT 使用这种技术吗?对于方法(4),如果 3 中的间接调用没问题,那么内联调用是否值得?如果 JIT 通常采用方法 (3/4),这个选项是否更好?
对于方法 (5),跳转对内存的依赖性(假定地址在编译时已知)是否不好?这会降低 (3) 或 (4) 的性能吗?有 JIT 使用这种技术吗?
对于方法(6),这样的futzing是不明智的吗? (特定于 Rust)是否有可用的段寄存器(运行时或 ABI 未使用)用于此目的?相对于非
cs
段的调用是否与相对于cs
的调用一样高效?并且 最后(也是最重要的),是否有我在这里缺少的更好的方法(可能更常被 JIT 使用)?
如果我的 Rust 问题没有答案,我无法实施 (1) 或 (2)。当然,我可以实施和基准测试 3-5(也许 6,尽管事先了解段寄存器 futzing 会很好),但考虑到这些是截然不同的方法,我希望有关于此的现有文献我找不到,因为我不知道 google 的正确术语(我目前也在研究这些基准)。或者,也许深入研究 JIT 内部的人可以分享他们的经验或他们的常见情况?
我知道这个问题:
总结:尝试在您的静态代码附近分配内存。但是对于无法通过 rel32
到达的呼叫,回退到 call qword [rel pointer]
或内联 mov r64,imm64
/ call r64
.
如果你不能使 2. 工作,你的机制 5. 可能是性能最好的,但是 4. 很简单,应该没问题。直接 call rel32
也需要一些分支预测,但它肯定更好。
术语:“内在函数”可能应该是“辅助”函数。 “内在”通常意味着内置语言(例如 Fortran 的意思)或“不是真正的 函数 ,只是内联到机器指令的东西”(C/C++ / Rust 的意思,比如 SIMD,或者 _mm_popcnt_u32()
、_pdep_u32()
或 _mm_mfence()
之类的东西。您的 Rust 函数将编译为机器代码中存在的真实函数,您将使用 call
指令调用这些函数。
是的,在目标函数的 +-2GiB 范围内分配 JIT 缓冲区显然是理想的,允许 rel32 直接调用。
最直接的方法是在 BSS 中使用一个大型静态数组(链接器会将其放置在您代码的 2GiB 内)并从中分割出您的分配。 (使用mprotect
(POSIX)或VirtualProtect
(Windows)使其执行table)。
大多数操作系统(包括 Linux)对 BSS 进行惰性分配(COW 映射到零页,只分配物理页框以在写入时支持该分配,就像没有 [=21= 的 mmap ]), 所以它只会浪费虚拟地址 space 在 BSS 中有一个 512MiB 数组,而你只使用底部的 10kB。
不过,不要让它大于或接近 2GiB,因为这会将 BSS 中的其他内容推得太远。默认的“小型”代码模型(如 x86-64 System V ABI 中所述)将所有静态地址彼此相距 2GiB,用于 RIP 相关数据寻址和 rel32 call/jmp.
缺点:您至少必须自己编写一个简单的内存分配器,而不是使用 mmap/munmap 处理整个页面。但如果您不需要释放任何东西,那很容易。也许只是从一个地址开始生成代码,一旦你到达终点并发现你的代码块有多长就更新一个指针。 (不过那不是多线程的。。。)为了安全,记得检查一下什么时候到达这个buffer的末尾然后中止,或者回退到mmap
.
如果您的绝对目标地址在虚拟地址 space 的低 2GiB 中,请在 Linux 上使用 mmap(MAP_32BIT)
。 (例如,如果你的 Rust 代码被编译成一个非 PIE executable for x86-64 Linux。但 PIE executables 就不是这样了(common these days),或共享库中的目标。您可以在 运行 时通过检查您的辅助函数之一的地址检测到这一点。)
一般来说(如果 MAP_32BIT
不是 helpful/available),你最好的选择可能是 mmap
没有 MAP_FIXED
,但是with一个你认为是免费的非NULL提示地址。
Linux 4.17 引入了 MAP_FIXED_NOREPLACE
,它可以让您轻松搜索附近未使用的区域(例如,步进 64MB,如果您得到 EEXIST
,请重试,然后记住该地址以避免下次搜索)。否则,您可以在启动时解析 /proc/self/maps
一次,以在包含您的辅助函数之一的映射附近找到一些未映射的 space 。将靠在一起。
Note that older kernels which do not recognize the
MAP_FIXED_NOREPLACE
flag will typically (upon detecting a collision with a preexisting mapping) fall back to a "non-MAP_FIXED" type of behavior: they will return an address that is different from the requested address.
在下一个更高或更低的空闲页面中对于拥有非稀疏内存映射是理想的,因此页面 table 不需要太多不同的顶级页面目录。 (HW 页面 tables 是一棵基数树。)一旦你找到一个可用的位置,就让未来的分配与它相邻。如果你最终在那里使用了很多 space ,内核可以机会性地使用一个 2MB 的大页面,并且让你的页面再次连续意味着它们在硬件页面 tables 中共享相同的父页面目录所以 iTLB触发页面遍历的未命中可能稍微便宜(如果那些更高级别在数据缓存中保持热,甚至缓存在页面遍历硬件本身内)。并且为了使内核有效地跟踪为一个更大的映射。当然,如果有空间,使用更多已分配的页面会更好。页面级别更好的代码密度有助于指令 TLB,也可能在 DRAM 页面内(但不一定与虚拟内存页面大小相同)。
然后当你为每个调用做代码生成时,只需检查目标是否在call rel32
[=206=的范围内] off == (off as i32) as i64
否则退回到 10 字节 mov r64,imm64
/ call r64
。 (rustcc 会将其编译为 movsxd
/cmp
,因此每次检查仅对 JIT 编译时间产生微不足道的成本。)
(或者 5 字节 mov r32,imm32
如果可能的话。不支持 MAP_32BIT
的操作系统可能仍然有目标地址在那里。用 target == (target as u32) as u64
检查一下。第三个mov
-立即编码,7 字节 mov r/m64, sign_extended_imm32
可能并不有趣,除非您正在为映射到高 2GiB 虚拟地址 space 中的内核 JITing 内核代码。)
尽可能检查和使用直接调用的好处在于,它将代码生成与有关分配附近页面或地址来源的任何知识分离开来,并且只是机会主义地编写好的代码。 (您可能会记录一次计数器或日志,这样您/您的用户至少会注意到附近的分配机制是否出现故障,因为性能差异通常不容易测量。)
mov-imm / call reg 的替代方法
mov r64,imm64
是一个 10 字节的指令,相对于 fetch/decode 来说有点大,并且要存储在 uop-cache 中。根据 Agner Fog 的 microarch pdf (https://agner.org/optimize),从 SnB 系列上的 uop 缓存中读取可能需要一个额外的周期。但是现代 CPU 具有相当好的代码获取带宽和强大的前端。
如果分析发现前端瓶颈是您代码中的一个大问题,或者较大的代码大小导致从 L1 I-cache 中驱逐其他有价值的代码,我会选择选项 5。
顺便说一句,如果您的任何函数是可变的,x86-64 System V 要求您传递 AL=number of XMM args,您可以使用 r11
作为函数指针。它被调用破坏并且不用于 arg 传递。但是 RAX(或其他“遗留”寄存器)将在 call
.
- Allocate Rust functions near where
mmap
will allocate
不,我认为没有任何机制可以让您的静态编译函数靠近 mmap
可能碰巧放置新页面的位置。
mmap
有超过 4GB 的可用虚拟地址 space 可供选择。您不会提前知道它将分配到哪里。 (尽管我认为 Linux 至少保留了一些局部性,以优化 HW 页面 tables。)
理论上你可以 copy 你的 Rust 函数的机器代码,但它们可能引用 other static code/data with RIP 相对寻址模式。
call rel32
to stubs that usemov
/jmp reg
This seems like it would be detrimental to performance (perhaps interfering with RAS/jump address prediction).
perf 的缺点仅在于前端总共有 2 条 call/jump 指令要通过,然后才能为后端提供有用的指令。这不是很好; 5.好多了。
这基本上就是 PLT 在 Unix/Linux 上调用共享库函数的工作方式,并将执行相同的操作。通过PLT(Procedure Linking Table)存根函数调用几乎就是这样。因此,性能影响已得到充分研究,并与其他做事方式进行了比较。我们知道动态库调用不是性能灾难。
main(){puts("hello"); puts("world");}
这样的 C 程序,如果你好奇的话。 (在第一次调用时,它压入一个 arg 并跳转到惰性动态链接器函数;在后续调用中,间接跳转目标是函数在共享库中的地址。)
jmp
的地址被惰性链接更新为 jmp qword [xxx@GOTPLT]
。 (是的,PLT 确实在这里使用了内存间接 jmp
,即使在 i386 上,重写的 jmp rel32
也可以工作。IDK if GNU/Linux 曾经用于重写偏移量在 jmp rel32
.)
jmp
只是一个标准的尾调用,不会不平衡Return-地址预测器堆栈.目标函数中最终的 ret
将 return 到原始 call
之后的指令,即 call
推入调用堆栈和微架构 RAS 的地址。只有当你使用 push / ret(如用于 Spectre 缓解的“retpoline”)时,你才会使 RAS 不平衡。
但是call +0
is a special case that doesn't go on the RAS in most CPUs: http://blog.stuffedcow.net/2018/04/ras-microbenchmarks中的代码。 (调用 nop
可能会改变我的猜测,但整个事情与 call rax
相比完全是疯狂的,除非它试图抵御 Spectre 攻击。)通常在 x86-64 上,你使用 RIP-相对 LEA 将附近的地址放入寄存器,而不是 call/pop
。
- inline
mov r64, imm64
/call reg
这可能比3好;较大代码大小的前端成本可能低于通过使用 jmp
.
但这也可能足够好,特别是如果您的 alloc-within-2GiB 方法在大多数时候对您关心的大多数目标都运行良好。
但在某些情况下它可能比 5. 慢。分支预测隐藏了从内存中获取和检查函数指针的延迟,假设它预测得很好。 (通常它会,否则它 运行 的频率太低以至于与性能无关。)
call qword [rel nearby_func_ptr]
这就是 gcc -fno-plt
如何编译对 Linux (call [rip + symbol@GOTPCREL]
) 上的共享库函数的调用,以及 Windows DLL 函数调用的方式通常完成。(这就像http://www.macieira.org/blog/2012/01/sorry-state-of-dynamic-libraries-on-linux/中的建议之一)
call [RIP-relative]
是 6 个字节,仅比 call rel32
大 1 个字节,因此它对代码大小和调用存根的影响可以忽略不计。有趣的事实:您有时会在机器代码中看到 addr32 call rel32
(地址大小前缀除了填充外没有任何作用)。如果在链接期间在另一个 .o
中发现具有非隐藏 ELF 可见性的符号,那么链接器将 call [RIP + symbol@GOTPCREL]
放宽为 call rel32
,毕竟不是不同的共享对象。
对于共享库调用,这通常优于 PLT 存根,唯一的缺点是程序启动较慢,因为它需要早期绑定(非惰性动态链接)。这对你来说不是问题;目标地址在代码生成时间之前已知。
The patch author tested its performance 与一些未知 x86-64 硬件上的传统 PLT。 Clang 可能是共享库调用的最坏情况,因为它会多次 调用不需要太多时间的小型 LLVM 函数,而且它很长 运行ning 所以早期绑定启动开销可以忽略不计。使用gcc
和gcc -fno-plt
编译clang后,clang -O2 -g
编译tramp3d的时间从41.6s(PLT)缩短到36.8s(-fno-plt)。 clang --help
变得稍微慢一些。
(x86-64 PLT 存根使用 jmp qword [symbol@GOTPLT]
,而不是 mov r64,imm64
/jmp
。内存间接 jmp
在现代英特尔 CPU 上只是一个 uop ,因此它在正确预测时更便宜,但在错误预测时可能更慢,特别是如果 GOTPLT 条目在缓存中未命中。不过,如果它经常使用,它通常会正确预测。但无论如何 10 字节 movabs
一个 2 字节 jmp
可以作为一个块获取(如果它适合 16 字节对齐的获取块),并在一个周期内解码,所以 3. 并非完全不合理。但这样更好。)
为您的指针分配 space 时,请记住它们是作为数据提取到 L1d 缓存 中的,并且带有 dTLB 条目而不是 iTLB。 不要将它们与代码交织在一起,这会在 I-cache 中浪费 space 在此数据上,并在 D-cache 中浪费 space 在包含一个指针,主要是代码。将您的指针分组到代码中一个单独的 64 字节块中,这样该行就不需要同时位于 L1I 和 L1D 中。如果它们与某些代码位于同一 页面 中,那很好;它们是只读的,因此不会导致自修改代码管道核弹。