14

我正在尝试加速可变位宽整数压缩方案,并且我对动态生成和执行汇编代码感兴趣。目前很多时间都花在了错误预测的间接分支上,并且根据发现的一系列位宽生成代码似乎是避免这种惩罚的唯一方法。

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

在实际使用中,它会是一个较长的调用序列,具有足够的长度,每个序列可能是唯一的,并且只调用一次。生成和调用代码在此处和其他地方都有很好的记录。但是除了简单的“不要这样做”或经过深思熟虑的“有龙”之外,我还没有找到太多关于效率的讨论。甚至英特尔文档也大多笼统地说:

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

处理器将数据写入当前正在执行的代码段以将该数据作为代码执行的行为称为自修改代码。IA-32 处理器在执行自我修改的代码时表现出特定于模型的行为,具体取决于代码已被修改的当前执行指针提前多远。...自修改代码将以比非自修改或普通代码更低的性能水平执行。性能恶化的程度将取决于修改频率和代码的具体特性。

11.6 自修改代码

对当前缓存在处理器中的代码段中的内存位置的写入会导致相关的缓存行(或多个行)无效。此检查基于指令的物理地址。此外,P6 系列和 Pentium 处理器检查对代码段的写入是否会修改已预取执行的指令。如果写入影响预取指令,则预取队列无效。后一种检查基于指令的线性地址。对于 Pentium 4 和 Intel Xeon 处理器,在目标指令已解码并驻留在跟踪缓存中的代码段中写入或窥探指令会使整个跟踪缓存无效。

虽然有一个性能计数器来确定是否发生了坏事(C3 04 MACHINE_CLEARS.SMC: Number of self-modifying-code machine clears detected)我想知道更多细节,特别是对于 Haswell。我的印象是,只要我可以提前足够远地编写生成的代码,指令预取还没有到达那里,并且只要我不通过修改同一页面上的代码来触发 SMC 检测器(四分之一 -页?)作为当前正在执行的任何内容,那么我应该获得良好的性能。但所有细节似乎都非常模糊:太近了?多远才够远?

试图把这些变成具体的问题:

  1. Haswell 预取器运行的当前指令之前的最大距离是多少?

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

  3. Haswell 上 MACHINE_CLEARS.SMC 事件的实际循环惩罚是多少?

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

  5. 如何安排流程,以便始终“第一次看到”每条生成的代码,而不是踩到已经缓存的指令?

4

4 回答 4

3

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

这意味着您保留一个(全局)函数指针,该指针将重定向到内存的可写/可执行映射部分 - 然后您在其中主动插入您希望进行的函数调用。

这样做的主要困难是与callIP 相关(大多数情况下jmp),因此您必须计算蹦床的内存位置与“目标函数”之间的偏移量。这样就很简单了——但是将它与 64 位代码结合起来,你会遇到call只能处理 +-2GB 范围内的位移的相对位移,它变得更加复杂——你需要通过链接表调用.

所以你基本上会创建类似(/me 严重 UN*X 偏见,因此 AT&T 汇编,以及一些对 ELF 主义的引用)的代码:

.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
...

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

然后,您在运行时修补跳转目标。这类似于.pltELF 部分(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数组),并且应该缓存行对齐跳转表。

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

于 2013-07-22T10:15:43.127 回答
3

这在 SMC 的范围内较少,而在动态二进制优化的范围内更多,即 - 您并没有真正操纵正在运行的代码(如编写新指令),您可以生成一段不同的代码,然后重新路由在您的代码中适当调用以跳转到那里。唯一的修改是在入口点,并且只完成一次,所以你不需要太担心开销(这通常意味着刷新所有管道以确保旧指令在机器,我猜惩罚是几百个时钟周期,这取决于 CPU 的负载情况。只有在重复发生时才相关)。

从同样的意义上说,你不应该太担心提前做这件事。顺便说一句,关于您的问题-根据此-http://www.realworldtech,CPU只能开始执行其ROB大小,在haswell中为192 uop(不是指令,但足够接近).com/haswell-cpu/3/,并且由于预测器和获取单元,我们能够看到稍微更远的地方,所以我们谈论的是整体,比如说几百个)。

话虽如此,让我重申之前在这里所说的 - 实验,实验实验 :)

于 2013-07-21T20:20:29.133 回答
2

Very good question, but the answer is not so easy... Probably the final word will be for the experiment - common case in modern world of different architectures.

Anyway, what you want to do is not exactly self modifying code. The procedures "decode_x" will exists and will not to be modified. So, there should be no problems with the cache.

On the other hand, the memory allocated for the generated code, probably will be dynamically allocated from the heap, so, the addresses will be far enough from the executable code of the program. You can allocate new block every time you need to generate new call sequence.

How far is enough? I think that this is not so far. The distance should be probably a multiply of the cache line of the processor and this way, not so big. I have something like 64bytes (for L1). In the case of dynamically allocated memory you will have many of pages a distance.

The main problem in this approach IMO is that the code of the generated procedures will be executed only once. This way, the program will lost the main advance of the cached memory model - efficient execution of cycling code.

And at the end - the experiment does not look so hard to be made. Just write some test program in both variants and measure the performance. And if you publish these results I will read them carefully. :)

于 2013-07-19T18:33:07.210 回答
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.

英特尔® 64 和 IA-32 架构优化参考手册

这只是对问题(测试、测试、测试)的部分答案,但比我找到的其他来源更可靠的数字。

3.6.9 混合代码和数据。

根据英特尔架构处理器的要求,自修改代码可以正常工作,但会导致显着的性能损失。尽可能避免自修改代码。• 将可写数据放在代码段中可能无法与自修改代码区分开来。代码段中的可写数据可能会遭受与自修改代码相同的性能损失。

汇编/编译器编码规则 57。(M 影响,L 通用性)如果(希望是只读的)数据必须与代码出现在同一页面上,请避免将其放在间接跳转之后。例如,跟随一个带有最可能目标的间接跳转,并将数据放在无条件分支之后。调优建议 1. 在极少数情况下,将代码页上的数据作为指令执行可能会导致性能问题。当执行跟随一个不驻留在跟踪缓存中的间接分支时,很可能会发生这种情况。如果这明显导致性能问题,请尝试将数据移至其他位置,或在间接分支后立即插入非法操作码或暂停指令。请注意,后两种替代方案在某些情况下可能会降低性能。

汇编/编译器编码规则 58。(H 影响,L 通用性)始终将代码和数据放在不同的页面上。尽可能避免自我修改代码。如果要修改代码,请尝试一次全部完成,并确保执行修改的代码和正在修改的代码位于单独的 4 KB 页面或单独对齐的 1 KB 子页面上。

3.6.9.1 自修改代码。

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

软件应避免写入正在执行的同一 1-KByte 子页中的代码页或获取正在写入的同一 2-KByte 子页中的代码。此外,与另一个处理器共享包含直接或推测执行代码的页面作为数据页面可能会触发 SMC 条件,导致机器的整个管道和跟踪缓存被清除。这是由于自修改代码条件。如果编写的代码在该页作为代码访问之前填满了该页,则动态代码不需要引起 SMC 条件。

动态修改的代码(例如,来自目标修复)可能会受到 SMC 条件的影响,应尽可能避免。通过引入间接分支和使用寄存器间接调用在数据页(不是代码页)上使用数据表来避免这种情况。

于 2013-07-29T05:56:26.980 回答