如何编写在现代x64处理器上高效运行的自修改代码?

时间:2013-07-19 04:33:51

标签: assembly 64-bit intel dispatch self-modifying

我正在尝试加速可变位宽整数压缩方案,我有兴趣在运行中生成和执行汇编代码。目前,大量时间花在错误预测的间接分支上,并且基于所发现的一系列位宽生成代码似乎是避免这种惩罚的唯一方法。

一般技术称为“子程序线程”(或“调用线程”,尽管这也有其他定义)。目标是利用处理器有效的呼叫/后退预测,以避免停顿。这种方法在这里有很好的描述: 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

在实际使用中,它将是一个较长的一系列调用,具有足够的长度,每个系列可能是唯一的,只调用一次。无论是在这里还是在其他地方,生成和调用代码都有详细记录。但是,除了简单的“不要做”或者考虑周全的“有龙”之外,我还没有找到很多关于效率的讨论。即使Intel documentation大多说的也是一般性的:

  

8.1.3处理自我修改和交叉修改代码

     

处理器将数据写入当前正在执行的代码的行为   段的目的是在调用代码时执行该数据   自修改代码。 IA-32处理器表现出特定于模型的行为   执行自修改代码时,取决于前面的距离   当前执行指针代码已被修改。 ...   自修改代码的执行性能低于   非自修改或普通代码。表现的程度   恶化将取决于修改的频率和   代码的具体特征。

     

11.6自修改代码

     

写入当前代码段中的内存位置   缓存在处理器中导致关联的缓存行(或行)   失效。此检查基于的物理地址   指令。此外,还有P6系列和奔腾处理器   写入代码段是否可以修改具有的指令   已被预取执行。如果写入影响预取   指令,预取队列无效。后一种检查是   基于指令的线性地址。对于奔腾4和   英特尔至强处理器,代码中的指令的写入或窥探   段,目标指令已经被解码和驻留   在跟踪缓存中,使整个跟踪缓存无效。后者   行为意味着自我修改代码的程序可能会导致严重的问题   在Pentium 4和Intel Xeon上运行时性能下降   处理器。

虽然有一个性能计数器来确定是否发生了坏事(C3 04 MACHINE_CLEARS.SMC:检测到的自修改代码机器数量)我想知道更多细节,尤其是Haswell。我的印象是,只要我能够提前编写生成的代码,指令预取还没有到达那里,并且只要我不通过修改同一页面上的代码来触发SMC检测器(四分之一 - 页面?)作为当前正在执行的任何内容,那么我应该获得良好的性能。但所有的细节看起来都非常模糊:距离过近有多近?到目前为止还远吗?

尝试将这些问题转化为具体问题:

  1. 当前指令前面的最大距离是多少 Haswell prefetcher曾经运行过吗?

  2. 当前指令背后的最大距离是多少 Haswell的“跟踪缓存”可能包含?

  3. MACHINE_CLEARS.SMC事件的周期实际惩罚是多少 在Haswell?

  4. 如何在预测循环中运行生成/执行循环 防止预取者吃掉自己的尾巴?

  5. 如何安排流程以便生成每段代码 总是“第一次看到”,而不是踩到指示 已缓存?

4 个答案:

答案 0 :(得分:3)

这不是SMC的范围,而是更多的动态二进制优化,即 - 你没有真正操纵你正在运行的代码(如编写新指令),你可以生成一段不同的代码,并在您的代码中重新路由相应的调用以跳转到那里。唯一的修改是在入口点,并且它只执行一次,因此您不必过多担心开销(通常意味着刷新所有管道以确保旧指令在任何地方都不存在机器,我猜这个惩罚是几百个时钟周期,具体取决于CPU的负载程度。只有在重复发生时才相关)。

从同样的意义上说,你不应该过分担心这么做。顺便说一下,关于你的问题 - 根据这个 - http://www.realworldtech.com/haswell-cpu/3/,CPU只能提前执行它的ROB大小,其中haswell是192 uop(不是指令,但足够接近),并且由于预测器和获取单元,我们可以看到稍微提前一点,所以我们谈论总体来说让我们说几百个。)

说完了,让我重申之前的说法 - 实验,实验实验:)

答案 1 :(得分:3)

这根本不必是自修改代码 - 它可以是动态创建的代码,即运行时生成的“trampolines”。

意味着你保持一个(全局)函数指针,它会重定向到可写/可执行映射的内存部分 - 然后你可以主动插入你想做的函数调用。

这方面的主要困难是call与IP相关(大多数jmp),因此您必须计算蹦床的内存位置与“{1}}之间的偏差。目标函数“。这样就足够简单 - 但是将它与64位代码结合起来,你遇到call只能处理+ -2GB范围内的位移的相对位移,它会变得更复杂 - 你需要通过链接表调用。

所以你基本上创建的代码就像(/我严重的UN * X偏向,因此AT& T程序集,以及对ELF-isms的一些引用):

.Lstart_of_modifyable_section:
callq 0f
callq 1f
callq 2f
callq 3f
callq 4f
....
ret
.align 32
0:        jmpq tgt0
.align 32
1:        jmpq tgt1
.align 32
2:        jmpq tgt2
.align 32
3:        jmpq tgt3
.align 32
4:        jmpq tgt4
.align 32
...

这可以在编译时创建(只需创建可写文本部分),也可以在运行时动态创建。

然后,您在运行时修补跳转目标。这类似于.plt ELF部分(PLT =过程链接表)的工作方式 - 就在那里,它是修补jmp插槽的动态链接器,而在你的情况下,你自己这样做。

如果你选择所有的运行时,那么上面的表格很容易通过C / C ++创建;从以下数据结构开始:

typedef struct call_tbl_entry __attribute__(("packed")) {
    uint8_t call_opcode;
    int32_t call_displacement;
};
typedef union jmp_tbl_entry_t {
    uint8_t cacheline[32];
    struct {
        uint8_t jmp_opcode[2];    // 64bit absolute jump
        uint64_t jmp_tgtaddress;
    } tbl __attribute__(("packed"));
}

struct mytbl {
    struct call_tbl_entry calltbl[NUM_CALL_SLOTS];
    uint8_t ret_opcode;
    union jmp_tbl_entry jmptbl[NUM_CALL_SLOTS];
}

这里唯一关键的,有点依赖于系统的东西是这种“打包”的性质,需要告诉编译器(即不要将call数组填充出来),而且应该是高速缓存 - 对齐跳转表。

您需要进行calltbl[i].call_displacement = (int32_t)(&jmptbl[i]-&calltbl[i+1]),使用memset(&jmptbl, 0xC3 /* RET */, sizeof(jmptbl))初始化空/未使用的跳转表,然后根据需要使用跳转操作码和目标地址填充字段。

答案 2 :(得分:2)

非常好的问题,但答案并不那么简单......可能最后一个词将用于实验 - 在不同架构的现代世界中的常见情况。

无论如何,你想要做的并不是完全自我修改代码。程序“decode_x”将存在且不会被修改。因此,缓存应该没有问题。

另一方面,为生成的代码分配的内存可能会从堆中动态分配,因此,地址将远远超出程序的可执行代码。每次需要生成新的呼叫序列时,您都可以分配新块。

到底有多远?我认为这不是到目前为止。距离应该是处理器缓存线的倍数,这样,不是那么大。我有64bytes(对于L1)。在动态分配内存的情况下,您将有许多页面距离。

这种方法的主要问题IMO是生成过程的代码只执行一次。这样,程序将失去缓存内存模型的主要进展 - 循环代码的高效执行。

最后 - 实验看起来并不那么难。只需在两种变体中编写一些测试程序并测量性能。如果您发布这些结果,我会仔细阅读。 :)

答案 3 :(得分:2)

我找到了一些来自英特尔的更好的文档,这似乎是将它用于将来参考的最佳位置:

Software should avoid writing to a code page in the same 1-KByte
subpage that is being executed or fetching code in the same 2-KByte
subpage of that is being written.

Intel® 64 and IA-32 Architectures Optimization Reference Manual

这只是问题(测试,测试,测试)的部分答案,但比我找到的其他来源更加坚固。

  

3.6.9混合代码和数据。

     

根据英特尔的说法,自我修改代码可以正常工作   架构处理器要求,但会产生重大影响   性能惩罚。如果可能,请避免自行修改代码。 •放置   代码段中的可写数据可能无法区分   来自自修改代码。代码段中的可写数据可能   遭受与自修改代码相同的性能损失。

     

汇编/编译器编码规则57.(M影响,L一般性)如果   (希望只读)数据必须与代码在同一页面上,避免   在间接跳跃后立即放置它。例如,按照   间接跳转与其最可能的目标,然后放置数据   无条件的分支。调整建议1.在极少数情况下,a   性能问题可能是由于在代码页上执行数据而引起的   说明。执行时很可能发生这种情况   跟随未驻留在跟踪缓存中的间接分支。   如果这显然导致性能问题,请尝试移动数据   在其他地方,或插入非法操作码或PAUSE指令   间接分支后立即。注意后两者   在某些情况下,替代方案可能会降低性能。

     

汇编/编译器编码规则58.(H影响,L一般性)总是放   单独页面上的代码和数据。避免在任何地方自行修改代码   可能。如果要修改代码,请尝试一次完成所有操作并进行修改   确保执行修改的代码和代码   修改后的是单独的4 KB页面或单独对齐的1 KByte   子页面。

     

3.6.9.1自修改代码。

     

正确运行的自修改代码(SMC)   在奔腾III处理器和先前的实现将运行   正确地在后续实现上。 SMC和交叉修改代码   (当多处理器系统中的多个处理器正在写入   当需要高性能时,应避免使用代码页。

     

软件应避免以相同的1 KB内容写入代码页   正在执行的子页面或以相同的2 KB字节提取代码   正在编写的子页面。另外,共享页面   包含直接或推测执行的代码与另一个   处理器作为数据页面可以触发   SMC条件导致机器的整个管道和   要清除的跟踪缓存。这是由于自修改代码   条件。如果代码,动态代码不需要导致SMC条件   写入之前填写数据页面作为代码访问该页面。

     

可能是动态修改的代码(例如,来自目标修正)   患有SMC病症,应尽可能避免。   通过引入间接分支和使用数据来避免这种情况   使用寄存器间接调用在数据页(而不是代码页)上的表。