我正在尝试加速可变位宽整数压缩方案,我有兴趣在运行中生成和执行汇编代码 . 目前,大量时间花在错误预测的间接分支上,并且基于所发现的一系列位宽生成代码似乎是避免这种惩罚的唯一方法 .
一般技术称为"subroutine threading"(或"call threading",尽管这也有其他定义) . 目标是利用处理器有效的呼叫/返回预测,以避免停顿 . 这种方法在这里有详细描述:http://webdocs.cs.ualberta.ca/~amaral/cascon/CDP05/slides/CDP05-berndl.pdf
生成的代码只是一系列调用,后跟返回 . 如果有5个“块”的宽度[4,8,8,4,16],它看起来像:
call $decode_4
call $decode_8
call $decode_8
call $decode_4
call $decode_16
ret
在实际使用中,它将是一个较长的一系列调用,具有足够的长度,每个系列可能是唯一的,只调用一次 . 无论是在这里还是在其他地方,生成和调用代码都有详细记录 . 但是,除了简单的"don't do it"或考虑周全的"there be dragons"之外,我还没有找到很多关于效率的讨论 . 甚至Intel documentation大多说的都是普遍性:
8.1.3处理自编码和交叉修改代码处理器将数据写入当前正在执行的代码段以将该数据作为代码执行的行为称为自修改代码 . IA-32处理器在执行自修改代码时表现出特定于模型的行为,具体取决于当前执行指针在代码被修改之前的距离 . ...自修改代码将以比非自修改或普通代码更低的性能级别执行 . 性能恶化的程度取决于修改的频率和代码的特定特征 . 11.6自修改代码写入当前在处理器中高速缓存的代码段中的存储器位置会导致相关的高速缓存行(或多个行)无效 . 此检查基于指令的物理地址 . 此外,P6系列和奔腾处理器检查对代码段的写入是否可以修改已经预取执行的指令 . 如果写入影响预取指令,则预取队列无效 . 后一种检查基于指令的线性地址 . 对于Pentium 4和Intel Xeon处理器,代码段中的指令的写入或窥探(其中目标指令已经被解码并驻留在跟踪高速缓存中)使整个跟踪高速缓存无效 . 后一种行为意味着在Pentium 4和Intel Xeon处理器上运行时,自我修改代码的程序可能会导致性能严重下降 .
虽然有一个性能计数器来确定是否发生了坏事(C3 04 MACHINE_CLEARS.SMC:检测到自修改代码机器清除的数量),我通过修改同一页面上的代码来触发SMC检测器(四分之一页? )作为当前正在执行的任何东西,那么我应该获得良好的表现 . 但所有的细节看起来都非常模糊:距离过近有多近?到目前为止还远吗?
试图将这些问题转化为具体问题:
-
Haswell预取器运行的当前指令前面的最大距离是多少?
-
Haswell“跟踪缓存”可能包含的当前指令后面的最大距离是多少?
-
Haswell上的MACHINE_CLEARS.SMC事件的周期实际罚分是多少?
-
如何在预测循环中运行生成/执行循环,同时防止预取程序吃掉自己的尾部?
-
如何安排流程,以便每个生成的代码始终“第一次看到”而不是踩到已经缓存的指令?
4 回答
这不是SMC的范围,而是更多的动态二进制优化,即 - 你没有真正操纵你正在运行的代码(如编写新的指令),你可以生成一段不同的代码,并重新路由在您的代码中适当调用以跳转到那里 . 唯一的修改是在入口点,并且它只执行一次,因此您不必过多担心开销(通常意味着刷新所有管道以确保旧指令在任何地方都不存在机器,我猜这个惩罚是几百个时钟周期,具体取决于CPU的加载方式 . 只有相关的时间才会发生反复) .
从同样的意义上讲,你不应该过分担心这么做 . 顺便说一句,关于你的问题 - CPU只能提前执行它的ROB大小,其中haswell是192 uop(不是指令,但足够接近),根据这个 - http://www.realworldtech.com/haswell-cpu/3/,并且能够由于预测器和获取单元,所以稍微向前看,所以我们说几百个 .
话虽如此,让我重申之前的说法 - 实验,实验实验:)
这根本不必是自修改代码 - 它可以是动态创建的代码,即运行时生成的"trampolines" .
这意味着你保持一个(全局)函数指针,它会重定向到可写/可执行映射的内存部分 - 然后你可以主动插入你想要的函数调用 .
这方面的主要困难是
call
是IP相对的(大多数是jmp
),因此您必须计算蹦床的内存位置和"target funcs"之间的偏移量 . 这样就足够简单了 - 但是将它与64位代码结合起来,并且遇到call
只能处理-2GB范围内的位移的相对位移,它变得更加复杂 - 你需要通过链接表进行调用 .所以你基本上创建代码(/我严重的UN * X偏向,因此AT&T程序集,以及一些对ELF-isms的引用):
这可以在编译时创建(只需创建可写文本部分),也可以在运行时动态创建 .
然后,您可以在运行时修补跳转目标 . 这类似于
.plt
ELF部分(PLT =过程链接表)的工作原理 - 就在那里,它是修补jmp插槽的动态链接器,而在你的情况下,你自己这样做 .如果你选择所有运行时,那么上面的表格很容易通过C / C创建;从以下数据结构开始:
这里唯一关键的,有点依赖于系统的东西是"packed"这个性质,需要告诉编译器(即不要将
call
数组填充出来),并且应该对缓存行对齐跳转表 .你需要创建
calltbl[i].call_displacement = (int32_t)(&jmptbl[i]-&calltbl[i+1])
,用memset(&jmptbl, 0xC3 /* RET */, sizeof(jmptbl))
初始化空/未使用的跳转表,然后根据需要用跳转操作码和目标地址填充字段 .非常好的问题,但答案并不那么容易......可能最后一个词将是实验 - 在不同架构的现代世界中的常见情况 .
无论如何,你想要做的并不是完全自我修改代码 . 程序“decode_x”将存在且不会被修改 . 因此,缓存应该没有问题 .
另一方面,为生成的代码分配的内存可能会从堆中动态分配,因此,地址将远离程序的可执行代码 . 每次需要生成新的调用序列时,您都可以分配新块 .
多远就够了?我认为这不是到目前为止 . 距离应该是处理器缓存线的倍数,这样,不是那么大 . 我有64bytes(对于L1) . 在动态分配内存的情况下,您将有许多页面距离 .
这种方法的主要问题IMO是生成过程的代码只执行一次 . 这样,程序将失去缓存内存模型的主要进步 - 循环代码的高效执行 .
最后 - 实验看起来并不那么难 . 只需在两种变体中编写一些测试程序并测量性能 . 如果您发布这些结果,我会仔细阅读 . :)
我从英特尔那里找到了一些更好的文档,这似乎是将它用于将来参考的最佳位置:
Intel® 64 and IA-32 Architectures Optimization Reference Manual
它只是对问题(测试,测试,测试)的部分答案,但比我找到的其他来源更加坚固 .