PermalLink GitHub是5000多万开发人员的家园,他们一起工作,共同托管和审查代码、管理项目和构建软件。
报名。
这本质上是一个JIT,其中代码只被使用一次,然后被覆盖并再次使用。(请注意,此代码在实施W^X安全性的操作系统上不起作用。目前,x86上使用最广泛的操作系统不强制执行W^X,因此上述技术在大多数计算机上都是可行的。对于此调查,我将假设没有强制执行W^X)。
x86处理器完全支持自修改代码(SMC),不需要程序向处理器发出任何信号(例如,不需要刷新指令),这意味着x86处理器需要自动检测对指令数据的写入并立即处理它们。
上述代码虽然不是真正的SMC方案,但由于JIT代码在需要写入时驻留在指令高速缓存中,因此通常会检测到它。此类写入通常涉及使缓存无效和刷新管道,这会导致严重的性能损失。
我在研究类似于上面的执行GF(216)乘法的算法时遇到了这个问题,并注意到有一些技术可以减轻这种性能损失。
然而,我一直无法找到更多关于这方面的信息,更不用说实际的测试结果了。因此,此存储库托管一些测试代码和实验结果,旨在研究减少此类代码开销的方法。
我试着想出一些可能对上述行为有影响的场景,并将它们实现到此测试应用程序中。
最好查看代码以查看正在运行的测试,但是下面列出了所尝试的想法的摘要:
显而易见的方法-即,只需编写代码并执行它,如本页面顶部所示(在结果中标记为jit_Plain)。还会测试反向编写代码,以查看这是否有任何效果(标记为jit_verse)。
参考:JIT-TO并执行单独的位置。这并没有达到上面的目的,因为它实际上并没有执行JIT代码,而是用来演示没有代价的理想情况(标记为jit_only),以及计算处理器上的SMC代价。
将代码写入不可执行内存(临时位置),然后使用包括memcpy(标记为jit_memcpy*)在内的一系列技术将其复制到分配的可执行内存。如果内存复制意味着命中可执行内存的写操作较少,则可能意味着需要较少的同步/刷新。
在编写代码之前‘清除’可执行内存,即使用Memset用空字节覆盖区域,或者通过向每个64字节缓存线(标记为jit_clr*)写入一次来采取快捷方式。也许这有助于在JIT进程缓慢执行之前快速使缓存失效?
在编写代码之前/之后刷新缓存线(标记为jit_clflush*)。也许这有助于提前将数据从单独的L1指令/数据高速缓存中推送出来?
将代码预取到二级缓存,带/不带写提示(标记为jit_prefetch*)。L1缓存单独的数据和指令,但L2是共享的,因此对指令数据的任何更改在到达指令缓存之前都必须经过L2。预取通常用于将数据提升到较低级别的高速缓存,而不是降级到较高级别,但也许某些处理器可以注意到写入提示并相应地执行操作。
在可执行代码的开头保留一条UD2指令,直到JIT过程完成(标记为jit_ud2)。也许这有助于防止处理器在写入代码时预取代码
在多个预先分配的页面(标记为jit_*region)之间交替。这可能有助于降低JIT写入触及缓存以供执行的内存的可能性,但是,就缓存使用而言,这是相当昂贵的。
尝试通过跳过大量缓存行(标记为jit_jmp*)来清除指令缓存。但是,可能会颠簸指令缓存。
查看在编写和执行代码之间是应用内存围栏(MFENCE指令)还是序列化操作(CPUID指令)会有什么不同(标记为jit_mfigure和jit_Serialize)。
向映射到同一物理页的不同虚拟地址写入和执行(标记为jit_DUAL_MAPPING)。英特尔手册建议,在执行编写的代码之前,此类行为需要序列化指令,这可能意味着无需调用自修改代码行为即可编写代码。
请注意,JIT例程本身会逐个写出指令,并且不会尝试将多条指令批处理到一次写入中。
使用RDTSC指令测试性能。为了减少可变性,在多次迭代中对其进行测量,并执行多次试验以找到最快的度量。
下面列出的结果在不同的处理器微体系结构中差异很大。测试在各种系统上执行,包括在虚拟机上执行。所有代码都是用GCC为x86-64编译的。
在足以超过一级缓存大小的多个区域之间交替似乎是最有效的技术(Jit_16region),但是这可能会影响其他代码的性能。
在编写代码之前清除目标是第二有效的方法(比上面略差),特别是在每个缓存行只清除一个字节(Jit_Clr_1byte)的情况下。
在Haswell及更高版本(Jit_Jmp32k_Unalign)中,使用触及所有缓存线的跳转清除指令缓存似乎也是有效的。这可能会影响其他代码的性能,因为它会有效地清除指令高速缓存。
原始结果:Nehalem,[Sandy Bridge](Results/Sandy Bridge.txt),[Ivy Bridge](Results/Ivy Bridge(Win64).txt),Haswell,[Broadwell](Results/Broadwell(Xen).txt),Skylake,[Skylake-X](Results/Skylake-X(Win64).txt)。
Silvermont(原始结果)拷贝似乎是最有效的,特别是在拷贝中使用非临时写入时(Jit_Memcpy_SsE2_Nt)。
在该区域发出CLFLUSH(Jit_Clflush)或PREFETCHW(Jit_Prefetchw)似乎也有效(我以为Silvermont不支持Broadwell中添加的PREFETCHW指令,但事实证明,它支持来自3DNow的PREFETCHW指令!)。
Goldmont([RAW RESULT](Results/Goldmont(Win64).txt))最有效的技术似乎是使用非临时写入进行复制(Jit_Memcpy_Sse2_Nt),使用非临时写入清除内存(Jit_Clr_SsE2_Nt),或者在写入之前对可执行区域发出CLFLUSHOPT(Jit_Clflushopt)。
使用非临时写入(Jit_Memcpy_Sse2_Nt)进行拷贝似乎比直接方法略有优势。
Zen1([RAW RESULT](Results/Zen1(VM).txt))复制似乎是唯一有效的方法(jit_memcpy*)。与此处的其他CPU不同,非临时写入的性能较差
与英特尔酷睿CPU不同,在英特尔酷睿CPU上,覆盖指令代码似乎会使指令缓存中的数据无效,而Zen1似乎保持了两者之间的某种同步。这意味着,重复覆盖指令高速缓存中存在的相同代码可能会产生减慢效果,即使该代码以后永远不会执行也是如此。
piledriver(原始结果)复制(jit_memcpy*)和清除(jit_clr*)以及发出PREFETCHW指令(Jit_Prefetchw)都是有效的。
尽管根据英特尔的文档,如果x86处理器位于不同的虚拟地址,则它们不需要同步代码/指令数据,但我发现,如果这些处理器指向相同的物理地址,则它们无论如何都会同步代码/指令数据。我发现,即使删除了同步指令,代码仍然可以工作。
在这里的结果中没有显示,但是我确实使用4KB的代码而不是1KB的代码进行了测试,并且发现测量到的时间也大约是原来的四倍。这表明SMC损失与编写的代码量成正比,因此在执行任何函数之前编写多个函数的想法似乎没有太大帮助(除非它足以超过一级指令高速缓存,并且处理器是Intel Core处理器)。
为了使本页不只是文本,这里有一个图表向您展示了一些可视化的东西,将SMC惩罚与上面指出的显而易见的解决方案和最佳解决方案进行了比较:
请注意,所测试的处理器/系统之间存在显著差异,因此可能不应该比较处理器之间的测量结果,但它仍然可以显示使用上面找到的技术可以进行多大程度的改进。
通过应用上面列出的一些技术,可以提高本文档顶部显示的示例的性能,通常会有相当大的提高。不幸的是,没有单一的“最佳解决方案”,因为它随底层微体系结构的不同而不同。
从我已有的结果(以及我对我没有的结果的猜测)来看,最好的解决方案是:
如果CPU是Intel Core(Nehalem或更高版本):在内存区域(足以大于一级缓存大小)之间交替,即JIT到位置1,执行位置1,JIT到位置2,等等,并循环(Jit_16region)。
如果上述情况不可能实现,或者缓存命中不可接受,则在实际写入指令(Jit_Clr_1byte)之前,清除要写入的区域中的每64字节缓存行1个字节。
否则,如果CPU是英特尔凌动、英特尔酷睿2、AMD系列15h或AMD K10:将代码写入临时位置,然后使用非临时写入(Jit_Memcpy_Sse2_Nt)复制到目标。SSE1/2写入就足够了,因为这些CPU都没有256位加载/存储端口。
否则,如果CPU是AMD系列17h、18h或更新的AMD:将代码编写到临时位置,然后复制到目标位置(rep mov似乎是最佳选择(Jit_Memcpy_Movsb),但memcpy也可能工作得很好(Jit_Memcpy))。
可能没有得到足够的重视,但我要强调的是,这里的结果特定于这里检查的测试用例。如果您实施单次使用的JIT算法,结果可能会因编写的代码量和写入方式的不同而有所不同(例如,较少的写入操作将比大量较小的写入执行得更好)。
欢迎对此处未列出的处理器进行测试,以及任何其他发现、更正和建议。
该代码当前未实现动态运行时调度,因此应在运行该代码的计算机上使用带有-MARCH=NATIVE标志的GCC/Clang进行编译。可使用以下命令编译此测试:
注意,在某些Linux发行版上,您可能还需要在末尾添加-lrt。
在运行测试时,我注意到结果有很大的变异性。代码确实尝试通过运行多个试验和运行速度最快的方式来满足这一要求,但是在运行测试之前将CPU调控器/电源配置文件设置为性能并禁用turbo Boost可能是有益的。请注意,虽然我没有针对任何结果执行此操作。