处理来自(可能)远离JITed代码的提前编译函数的调用[关闭]

问题描述 投票:9回答:1

这个问题被搁置得过于宽泛,大概是因为我在努力“展示我的工作”而不是提出一个低效率的问题。为了解决这个问题,请允许我用一句话来概括整个问题(@PeterCordes为这个短语的信用):

如何从JITed代码(我正在生成)中有效地调用(x86-64)提前编译的函数(我控制的,可能超过2GB)?

我怀疑,仅此一项将被搁置为“过于宽泛”。特别是,它缺乏“你尝试过的东西”。因此,我觉得有必要添加更多信息来展示我的研究/思考以及我所尝试的内容。以下是对此的一种意识流。

请注意,下面提出的问题都不是我希望得到回答的问题;他们更有修辞。他们的目的是证明为什么我不能回答上述问题(尽管我的研究,我缺乏这方面的经验来制作明确的陈述,如@ PeterCordes的“分支预测隐藏了从内存中获取和检查函数指针的延迟,假设它预测得很好。“)。另请注意,Rust组件在很大程度上与此无关,因为这是一个程序集问题。我将其包含在内的原因是提前编译的函数是用Rust编写的,所以我不确定Rust是否有某些东西(或指示LLVM要做)在这种情况下可能是有利的。完全可以接受不考虑Rust的答案;事实上,我预计会出现这种情况。

在数学考试的背面考虑以下内容作为临时工作:


注意:我在这里混淆了内在函数这个术语。正如评论中指出的那样,“提前编译的函数”是一个更好的描述。下面我将缩写AOTC的功能。

我正在Rust写一个JIT(尽管Rust只与我的一些问题有关,其中很大一部分与JIT约定有关)。我有我在Rust中实现的AOTC函数,我需要能够从我的JIT发出的代码中使用call。我的JIT mmap(_, _, PROT_EXEC, MAP_ANONYMOUS | MAP_SHARED)s一些页面用于jitted代码。我有我的AOTC功能的地址,但不幸的是它们比32位偏移更远。我现在正在尝试决定如何发出对这些AOTC函数的调用。我考虑了以下选项(这些都不是要回答的问题,只是为了说明为什么我自己无法回答这个SO线程的核心问题):

  1. (特定于Rust)以某种方式使Rust将AOTC函数放置在堆附近(可能在?),以便calls在32位偏移量内。目前还不清楚是否可以使用Rust(有一种方法可以指定custom linker args,但我无法告诉它们应用的是什么以及我是否可以将单个函数作为重定位目标。即使我可以在哪里放置它? )。如果堆足够大,似乎这可能会失败。
  2. (特定于Rust)将我的JIT页面分配到AOTC函数附近。这可以通过mmap(_, _, PROT_EXEC, MAP_FIXED)来实现,但我不确定如何选择一个不会破坏现有Rust代码的地址(并保持在arch限制内 - 是否有一种理智的方式来获得这些限制?)。
  3. 在JIT页面中创建处理绝对跳转的存根(下面的代码),然后call存根。这有利于JITted代码中的(初始)调用站点是一个很好的小型相对调用。但是必须跳过某些东西感觉不对。这似乎对性能有害(可能干扰RAS /跳转地址预测)。此外,看起来这个跳跃会慢一些,因为它的地址是间接的,它取决于该地址的mov
mov rax, {ABSOLUTE_AOTC_FUNCTION_ADDRESS}
jmp rax
  1. 与(3)相反,只是在JITed代码中的每个内部调用站点处内联上述内容。这解决了间接问题,但使JITted代码变大(可能这会产生指令缓存和解码后果)。它仍然存在跳跃是间接的问题,并且取决于mov
  2. 将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]
  1. 具有段寄存器的Futz使其更接近AOTC功能,以便可以相对于该段寄存器进行调用。这种调用的编码很长(所以这可能有解码管道问题),但除此之外,这在很大程度上避免了它之前的一切棘手的问题。但是,也许相对于非cs片段的调用表现不佳。或者也许这样的未来是不明智的(例如,与Rust运行时混淆)。 (正如@prl所指出的,没有远程调用就行不通,这对性能来说太糟糕了)
  2. 不是真正的解决方案,但我可以使编译器32位并且根本没有这个问题。这不是一个很好的解决方案,它也会阻止我使用扩展的通用寄存器(我利用它们)。

所有选项都有缺点。简而言之,1和2是唯一似乎没有性能影响的,但目前还不清楚是否有一种非hacky方式来实现它们(或者根本没有任何方式)。 3-5独立于Rust,但具有明显的性能缺陷。

鉴于这种意识流,我得出了以下修辞问题(不需要明确答案)来证明我缺乏自己回答这个SO线程的核心问题的知识。我已经打动他们,让我非常清楚地表明我并不是所有这些都是我的问题的一部分。

  1. 对于方法(1),是否可以强制Rust将特定的extern "C"函数链接到特定地址(堆附近)?我应该如何选择这样的地址(在编译时)?假设mmap(或Rust分配)返回的任何地址都在此位置的32位偏移量内是否安全?
  2. 对于方法(2),我如何找到一个合适的位置放置JIT页面(这样它不会破坏现有的Rust代码)?

还有一些JIT(非Rust)特定问题:

  1. 对于方法(3),存根是否会妨碍我应该关心的性能?间接jmp怎么样?我知道这有点类似于链接器存根,除非我理解链接器存根至少只解析一次(因此它们不需要是间接的?)。任何JIT都采用这种技术吗?
  2. 对于方法(4),如果3中的间接呼叫是可以的,那么内联呼叫值得吗?如果JIT通常采用方法(3/4),这个选项更好吗?
  3. 对于方法(5),跳转对内存的依赖性(假设地址在编译时已知)是不是很糟糕?这会使(3)或(4)的性能降低吗?任何JIT都采用这种技术吗?
  4. 对于方法(6),这样的未来是不明智的吗? (特定于Rust)是否有可用的段寄存器(运行时或ABI未使用)?相对于非cs段的调用是否与cs相关的调用性能相同?
  5. 最后(也是最重要的),我在这里缺少一种更好的方法(可能更常用于JIT)吗?

如果没有我的Rust问题有答案,我无法实现(1)或(2)。当然,我可以实现和测试3-5(可能是6,虽然事先了解段寄存器会很好),但考虑到这些是截然不同的方法,我希望有关于此的现有文献我找不到,因为我不知道谷歌的正确条款(我目前也正在研究这些基准)。或者也许有人深入研究JIT内部人员可以分享他们的经验或他们常见的东西?

我知道这个问题:Jumps for a JIT (x86_64)。它与我的不同之处在于它正在讨论将基本块串联起来(并且所接受的解决方案对于经常被称为内在块的指令来说太多了)。我也知道Call an absolute pointer in x86 machine code,虽然它讨论与我类似的主题,但它是不同的,因为我不假设绝对跳跃是必要的(例如1-2方法会避免它们)。

assembly rust compiler-construction x86-64 jit
1个回答
1
投票

摘要:尝试在静态代码附近分配内存。但对于rel32无法达到的召唤,可以回到call qword [rel pointer]或内联mov r64,imm64 / call r64

你的机制5.对于表现来说可能是最好的,如果你不能使2.工作,但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)使其可执行)。

大多数操作系统(包括Linux)为BSS进行延迟​​分配(COW映射到零页面,仅在分配时分配物理页面帧以支持该分配,就像没有MAP_POPULATE的mmap一样),因此它只会浪费虚拟地址空间来拥有BSS中的512MiB阵列,你只使用底部的10kB。

但是,不要使它大于或接近2GiB,因为这会将BSS中的其他东西推得太远。默认的“小”代码模型(如x86-64 System V ABI中所述)将所有静态地址放在彼此2GiB内,用于RIP相关数据寻址和rel32调用/ jmp。

缺点:您必须自己编写至少一个简单的内存分配器,而不是使用mmap / munmap处理整个页面。但如果你不需要任何东西,这很容易。也许只是从地址开始生成代码,并在结束后更新指针并发现代码块的长度。 (但这不是多线程的......)为了安全起见,请记得检查何时到达此缓冲区的末尾并中止,或者回退到mmap


如果您的绝对目标地址位于虚拟地址空间的低2GiB中,请在Linux上使用mmap(MAP_32BIT)。 (例如,如果您的Rust代码被编译为x86-64 Linux的非PIE可执行文件。但PIE可执行文件(common these days)或共享库中的目标不会是这种情况。您可以在运行时检测到这一点。通过检查您的一个辅助函数的地址。)

一般情况下(如果MAP_32BIT没有帮助/可用),你最好的选择可能是没有mmapMAP_FIXED,但是你认为它是免费的非NULL提示地址。

Linux 4.17引入了MAP_FIXED_NOREPLACE,它可以让你轻松搜索附近未使用的区域(例如步进64MB,如果你得到EEXIST则重试,然后记住该地址以避免下次搜索)。否则你可以在启动时解析/proc/self/maps一次,找到包含你的一个辅助函数地址的映射附近的一些未映射的空间。将紧密相连。

请注意,不识别MAP_FIXED_NOREPLACE标志的旧内核通常(在检测到与先前存在的映射的冲突时)会回退到“非MAP_FIXED”类型的行为:它们将返回与请求的地址不同的地址。

在下一个更高或更低的空闲页面中,非常适合具有非稀疏内存映射,因此页表不需要太多不同的顶级页面目录。 (HW页表是基数树。)一旦找到有效的点,就可以使将来的分配与之相连。如果你最终在那里使用了大量的空间,内核可以机会性地使用2MB的巨页,并且让你的页面再次连续意味着它们在HW页面表中共享相同的父页面目录,因此iTLB错过触发页面遍历可能会稍微便宜一些(如果这些更高级别在数据缓存中保持热,或者甚至在页面行走硬件本身内部缓存)。并且有效地让内核跟踪为一个更大的映射。当然,如果有空间,使用更多已经分配的页面会更好。页面级别上更好的代码密度有助于指令TLB,并且还可能在DRAM页面内(但不一定与虚拟内存页面的大小相同)。


然后当您为每个调用执行代码生成时,只需检查目标是否在call rel32off == (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检查。第3个mov-immediate编码,7字节mov r/m64, sign_extended_imm32可能不是很有趣,除非你是JITing内核代码,用于映射在高2GiB虚拟地址空间中的内核。)

尽可能检查和使用直接呼叫的美妙之处在于,它将代码生成与分配附近页面或地址来源的任何知识分离,并且只是机会性地生成良好的代码。 (您可以记录计数器或记录一次,以便您/您的用户至少注意到您附近的分配机制是否失败,因为perf diff通常不易测量。)


Alternatives to mov-imm / call reg

mov r64,imm64是一个10字节的指令,有点大,用于获取/解码,以及uop-cache存储。根据Agner Fog的microarch pdf(https://agner.org/optimize),可能需要额外的周期来读取SnB系列上的uop缓存。但现代CPU具有相当好的带宽,可用于代码获取和强大的前端。

如果分析发现前端瓶颈是代码中的一个大问题,或者大代码大小导致从L1 I-cache中驱逐其他有价值的代码,那么我将使用选项5。

顺便说一句,如果你的任何函数是可变参数,x86-64 System V要求你传递AL = XMM args的数量,你可以使用r11作为函数指针。它被称为破坏,不用于arg传递。但是RAX(或其他“遗留”寄存器)将在call上保存REX前缀。


  1. mmap将分配的位置附近分配Rust功能

不,我认为没有任何机制可以让您的静态编译函数接近mmap可能会放置新页面的位置。

mmap有超过4GB的免费虚拟地址空间可供选择。你事先并不知道它要分配的地方。 (虽然我认为Linux至少会保留一定的位置,以优化HW页表。)

理论上你可以复制Rust函数的机器代码,但它们可能引用其他静态代码/数据与RIP相关的寻址模式。


  1. qazxsw poi使用qazxsw poi / qazxsw poi的存根

这似乎对性能有害(可能干扰RAS /跳转地址预测)。

完整的缺点是只有前端有2个总的呼叫/跳转指令才能通过,然后才能为后端提供有用的指令。这不是很好; 5.好多了。

这基本上是PLT如何在Unix / Linux上调用共享库函数,并将执行相同的操作。通过PLT(过程链接表)存根函数调用几乎就是这样。因此,对性能影响进行了充分研究,并与其他做事方式进行了比较。我们知道动态库调用不是性能灾难。

如果你好奇的话,call rel32会显示AT&T对一个或像Cazxswpoi这样的C级程序的反汇编。 (在第一次调用时,它会推送一个arg并跳转到一个惰性动态链接器函数;在后续调用中,间接跳转目标是共享库中函数的地址。)

mov解释更多。地址通过延迟链接更新的jmp regAsterisk before an address and push instructions, where is it being pushed to?。 (是的,PLT确实在这里使用了内存间接main(){puts("hello"); puts("world");},即使在i386上,重写的Why does the PLT exist in addition to the GOT, instead of just using the GOT?也能正常工作。如果GNU / Linux历史上曾用于重写jmp中的偏移量,则IDK。)

jmp qword [xxx@GOTPLT]只是一个标准的尾部调用,并不会使返回地址预测器堆栈失衡。目标函数中的最终jmp将返回到原始jmp rel32之后的指令,即返回到jmp rel32推入调用堆栈并进入微架构RAS的地址。只有当你使用push / ret(比如“retpoline”用于Spectre缓解)时才会使RAS失衡。

但是,您链接的jmp中的代码很不幸(请参阅我的评论)。它将打破RAS以备将来回报。您认为只有通过调用才能打破它(要获得要调整的返回地址)才能平衡推/回,但实际上ret是一个特殊情况,在大多数情况下都没有在RAS上进行CPU:call。 (调用call可能会改变我的想法,但整个事情对Jumps for a JIT (x86_64)完全是疯了,除非它试图防御幽灵攻击。)通常在x86-64上,你使用RIP相对LEA来获取附近的地址寄存器,而不是call +0


  1. 内联http://blog.stuffedcow.net/2018/04/ras-microbenchmarks / nop

这可能比3好;较大代码大小的前端成本可能低于使用call rax调用存根的成本。

但这也可能足够好,特别是如果你的alloc-within-2GiB方法在你关心的大多数目标上大部分时间都能很好地工作。

可能会出现比5慢的情况。分支预测隐藏了从内存中获取和检查函数指针的延迟,假设它可以很好地预测。 (通常它会,或者它很少运行,因此它与性能无关。)


  1. call/pop

这就是mov r64, imm64如何在Linux(call reg)上编译对共享库函数的调用,以及如何正常完成Windows DLL函数调用。 (这就像jmp中的一个建议)

call qword [rel nearby_func_ptr]是6个字节,比gcc -fno-plt大1个字节,因此它对代码大小与调用存根的影响可以忽略不计。有趣的事实:您有时会在机器代码中看到call [rip + symbol@GOTPCREL](地址大小前缀除了填充之外没有任何效果)。如果在链接期间在另一个http://www.macieira.org/blog/2012/01/sorry-state-of-dynamic-libraries-on-linux/中找到具有非隐藏ELF可见性的符号,而不是一个不同的共享对象,则来自链接器将call [RIP-relative]放宽到call rel32

对于共享库调用,这通常比PLT存根更好,唯一的缺点是程序启动较慢,因为它需要早期绑定(非延迟动态链接)。这对你来说不是问题;目标地址在代码生成时间之前已知。

addr32 call rel32与某些未知的x86-64硬件上的传统PLT相比。 Clang可能是共享库调用的最坏情况,因为它会对不需要花费太多时间的小型LLVM函数进行多次调用,并且它长时间运行,因此早期绑定的启动开销可以忽略不计。在使用call [RIP + symbol@GOTPCREL]call rel32编译clang之后,.o编译tramp3d的时间从41.6s(PLT)变为36.8s(-fno-plt)。 The patch author tested its performance变得稍慢。

(x86-64 PLT存根使用gcc,而不是gcc -fno-plt / clang -O2 -g。内存间接clang --help在现代Intel CPU上只是一个uop,所以它在正确预测上更便宜,但在不正确的预测上可能更慢,特别是如果GOTPLT高速缓存中的条目未命中。如果经常使用,它通常会正确预测。但是无论如何,10字节的jmp qword [symbol@GOTPLT]和2字节的mov r64,imm64可以作为一个块(如果它适合16字节对齐的提取块),并且在一个周期内解码,所以3.并非完全不合理。但这样更好。)

为指针分配空间时,请记住它们是作为数据提取到L1d缓存中,而是用dTLB条目而不是iTLB。不要将它们与代码交错,否则会浪费I-cache中的空间占用此数据,并在包含一个指针且主要是代码的行上浪费D-cache中的空间。将指针组合在一个单独的64字节块中,这样就可以将代码放在L1I和L1D中。如果它们与某些代码位于同一页面,那就没关系了;它们是只读的,因此不会导致自我修改代码管道核武器。

© www.soinside.com 2019 - 2024. All rights reserved.