处理(可能)从JIT代码中提前(可能)远的编译函数的调用

时间:2019-03-01 15:07:03

标签: assembly rust compiler-construction x86-64 jit

这个问题之所以被搁置得太广泛,大概是因为我为了“展示我的作品”而不是提出一个费力的问题而进行的研究。为了解决这个问题,请允许我用一个句子来概括整个问题(此短语的信用为@PeterCordes):

  

如何有效地从我生成的JIT代码中调用(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)提供了一些页面,用于显示代码。我有 AOTC 函数的地址,但是不幸的是,它们的地址比32位偏移量远得多。我正在尝试决定如何发出对这些 AOTC 函数的调用。我考虑了以下选项(这些不是要回答的问题,只是说明为什么我自己不能回答这个SO线程的核心问题):

  1. (特定于锈迹)以某种方式使Rust将 AOTC 函数放置在堆附近(也许在堆上),以使call位于32位偏移量之内。尚不清楚Rust是否可以实现(有一种方法可以指定custom linker args,但我无法确定要应用的是什么,是否可以将单个函数作为目标进行重定位。即使可以在哪里执行,也无法确定。我放了吗?)。如果堆足够大,这似乎也可能失败。

  2. (特定于锈迹)分配我的JIT页面,使其更接近 AOTC 函数。这可以通过mmap(_, _, PROT_EXEC, MAP_FIXED)来实现,但是我不确定如何选择不会破坏现有Rust代码的地址(并保持在拱门限制之内-是否有理智的方式来获得这些限制?)。 / p>

  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(非锈蚀)的问题:

  1. 对于方法(3),存根是否会妨碍我足够在意的性能?间接jmp呢?我知道这有点类似于链接器存根,但据我了解,链接器存根至少只能解析一次(因此它们不需要是间接的吗?)。是否有准时生产技术采用这种技术?

  2. 对于方法(4),如果3中的间接调用还可以,则内联调用是否值得?如果准时制生产商通常采用方法(3/4),那么此选项更好吗?

  3. 对于方法(5),跳转对内存的依赖性(假定地址在编译时是已知的)是否不好?这会使它的性能不及(3)或(4)吗?是否有准时生产技术采用这种技术?

  4. 对于方法(6),这样的混淆是不明智的吗? (特定于铁锈)是否有用于此目的的段寄存器(运行时或ABI未使用)?相对于非cs段的呼叫是否会像相对于cs的呼叫一样执行?

  5. 并且最后(也是最重要的),我在这里是否缺少更好的方法(也许是JIT经常使用)?

如果我的Rust问题没有答案,我将无法实现(1)或(2)。我当然可以实现3-5并进行基准测试(也许是6,尽管事先了解分段寄存器的信息会很高兴),但是鉴于这些方法截然不同,我希望有关于此的现有文献。我找不到,因为我不知道适用于google的正确术语(我目前也在制定这些基准)。或者,也许某个精通JIT内部的人可以分享他们的经验或他们通常看到的东西?

我知道这个问题:Jumps for a JIT (x86_64)。它与我的有所不同,因为它是在谈论将基本块串联在一起(对于经常被称为内在函数的方法来说,接受的解决方案太多了)。我也知道Call an absolute pointer in x86 machine code,尽管它讨论了与我类似的话题,但有所不同,因为我不认为绝对跳跃是必要的(例如方法1-2会避免这种情况)。

1 个答案:

答案 0 :(得分:1)

摘要:尝试在静态代码附近分配内存。但是对于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)使其可执行)。

大多数OS(包括Linux)为BSS进行延迟​​分配(COW映射到零页面,仅在写入时分配物理页面框架以支持该分配,就像不带MAP_POPULATE的mmap)一样,因此仅浪费虚拟地址空间在BSS中仅使用底部10kB的512MiB阵列。

不要将其设置为大于或接近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没有帮助/可用),最好的选择可能是 mmap 没有 MAP_FIXED,但是使用一个您认为免费的非NULL提示地址。

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

  

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

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


然后在为每个呼叫进行代码生成时,检查目标是否在call rel32 的范围内,off == (off as i32) as i64 < br />  否则回落到10字节的mov r64,imm64 / call r64。 (rustcc会将其编译为movsxd / cmp,因此每次检查对于JIT编译时间来说都是微不足道的。)

(或5字节mov r32,imm32,如果可能。不支持MAP_32BIT的OS可能仍然有目标地址。用target == (target as u32) as u64检查目标地址。第三个{{ 1}}-立即编码,7字节的mov可能并不有趣,除非您正在为映射在虚拟地址空间的高2GiB中的内核JIT内核代码。)

检查并尽可能使用直接调用的好处在于,它可以将代码源与任何有关分配附近页面或地址来自何处的知识分离开来,并且仅凭机会就可以编写出良好的代码。 (您可能会记录一个计数器或记录一次,因此您/您的用户至少会注意到附近的分配机制是否失败,因为性能差异通常很难衡量。)


替代mov-imm /调用reg

mov r/m64, sign_extended_imm32是一个10字节的指令,要提取/解码有点大,并且要存储到uop缓存中。根据Agner Fog的microarch pdf(https://agner.org/optimize),从SnB系列的uop缓存中读取数据可能要花费额外的时间。但是现代的CPU具有相当不错的带宽用于代码提取和强大的前端。

如果分析发现前端瓶颈是您的代码中的大问题,或者较大的代码大小导致从L1 I缓存中驱逐了其他有价值的代码,我将选择选项5。

顺便说一句,如果您的任何函数都是可变参数,则x86-64 System V要求您传递AL = XMM args个数,可以将mov r64,imm64用作函数指针。它被称为呼叫,不用于arg传递。但是RAX(或其他“旧式”寄存器)会将REX前缀保存在r11上。


  
      
  1. call将分配的位置附近分配Rust函数
  2.   

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

mmap具有超过4GB的可用虚拟地址空间可供选择。您可能不知道它将在哪里分配。 (尽管我认为Linux至少确实保留了一定的局部性,以优化硬件页表。)

理论上,您可以复制 Rust函数的机器代码,但它们可能引用具有RIP相对寻址模式的 other 静态代码/数据。


  
      
  1. mmap到使用call rel32 / mov的存根
  2.   
     

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

性能下降仅是因为前端有2条总的呼叫/跳转指令才能通过,然后才能向后端提供有用的指令。不好5.更好。

基本上,这是PLT在Unix / Linux上对共享库函数的调用的工作方式,并且将执行相同的操作。通过PLT(过程链接表)存根函数进行调用几乎与此完全相同。因此,已经对性能影响进行了深入研究,并将其与其他处理方式进行了比较。我们知道动态库调用不是性能灾难。

Asterisk before an address and push instructions, where is it being pushed to?显示AT&T的反汇编,如果您好奇的话,也可以单步执行诸如jmp reg之类的C程序。 (在第一次调用时,它推送一个arg并跳转到一个惰性动态链接器函数;在后续调用中,间接跳转目标是该函数在共享库中的地址。)

Why does the PLT exist in addition to the GOT, instead of just using the GOT?进一步说明。其地址通过惰性链接更新的main(){puts("hello"); puts("world");}jmp。 (是的,即使在i386上重写了jmp qword [xxx@GOTPLT]的i386上,PLT确实确实在这里使用了内存间接jmp。IDK如果GNU / Linux历史上曾经用来重写偏移量, jmp rel32。)

jmp rel32只是一个标准的尾调用,并且不会使返回地址预测变量堆栈失衡。目标函数中的最终jmp将返回到原始ret之后的指令,即返回call推送到调用堆栈和微体系结构RAS上的地址。只有使用push / ret(像“ retpoline”来减轻Spectre),才能使RAS不平衡。

不幸的是,您链接的Jumps for a JIT (x86_64)中的代码非常糟糕(请参阅下面的评论)。它会 打破RAS,以获取将来的回报。您可能会认为,只有在调用/返回保持平衡的情况下,调用(为了调整寄信人地址)才能打破它,但实际上call是一个特殊情况,不会继续进行大多数CPU中的RAS:http://blog.stuffedcow.net/2018/04/ras-microbenchmarks。 (我猜想,调用call +0可能会改变,但是与nop相比,整个过程都是疯狂的,除非它试图防御Spectre漏洞。)通常在x86-64上,您使用RIP-相对LEA来获取附近地址而不是call rax的寄存器。


  
      
  1. 内联call/pop / mov r64, imm64
  2.   

这可能比3好;较大代码长度的前端成本可能低于通过使用call reg的存根进行调用的成本。

但这可能也足够好,尤其是如果您的alloc-within-2GiB方法在大多数时候对您关心的大多数目标都运行良好的话。

在某些情况下,速度可能慢于5。假设分支预测很好,则分支预测会从内存中隐藏获取和检查函数指针的等待时间。 (而且通常会,否则它会运行得很少,以至于与性能无关。)


  
      
  1. jmp
  2.   

这是call qword [rel nearby_func_ptr]在Linux(gcc -fno-plt)上编译对共享库函数的调用的方式,以及通常如何完成Windows DLL函数的调用。(就像一个http://www.macieira.org/blog/2012/01/sorry-state-of-dynamic-libraries-on-linux/中的建议中的

call [rip + symbol@GOTPCREL]是6个字节,仅比call [RIP-relative]大1个字节,因此与调用存根相比,它对代码大小的影响可忽略不计。有趣的事实:您有时会在机器代码中看到call rel32(地址大小前缀除填充外没有任何作用)。如果链接期间在另一个addr32 call rel32中找到了具有非隐藏ELF可见性的符号,则该链接器将call [RIP + symbol@GOTPCREL]放到call rel32,毕竟不是另一个共享对象。

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

The patch author tested its performance与某些未知x86-64硬件上的传统PLT。对于共享库调用,Clang可能是最坏的情况,因为它对不需要很多时间的小型LLVM函数进行许多调用,而且运行时间长,因此早期绑定的启动开销可以忽略不计。使用.ogcc编译clang之后,gcc -fno-plt编译tramp3d的时间从41.6s(PLT)变为36.8s(-fno-plt)。 clang -O2 -g会稍微变慢。

(x86-64 PLT存根使用clang --help,而不是jmp qword [symbol@GOTPLT] / mov r64,imm64。间接存储的jmp在现代Intel CPU上仅是一个uop,因此在正确的预测中它便宜一些,但在错误的预测中可能要慢一些,特别是如果GOTPLT条目在高速缓存中丢失的话。如果使用频繁,它通常会正确地进行预测。但是无论如何,一个10字节的jmp和一个2字节的movabs可以作为一个块进行获取(如果它适合16字节对齐的获取块),并且可以在一个周期内进行解码,因此3.并非完全不合理。但这会更好。)

为指针分配空间时,请记住,它们是作为数据提取到L1d缓存中的,并且带有dTLB条目而不是iTLB。 不要将它们与代码交织,这样会浪费I-cache中的数据,并浪费D-cache中包含一个指针和大部分代码的行上的空间。将代码中的指针分组到一个单独的64字节代码块中,因此该行不必同时位于L1I和L1D中。如果它们与某些代码位于相同的 page 中,则很好;它们是只读的,因此不会引起自修改代码管道核武器。