首页 文章

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

提问于
浏览
13

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

一般技术称为"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 回答

  • 3

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

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

    话虽如此,让我重申之前的说法 - 实验,实验实验:)

  • 2

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

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

    这方面的主要困难是 call 是IP相对的(大多数是 jmp ),因此您必须计算蹦床的内存位置和"target funcs"之间的偏移量 . 这样就足够简单了 - 但是将它与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];
    }
    

    这里唯一关键的,有点依赖于系统的东西是"packed"这个性质,需要告诉编译器(即不要将 call 数组填充出来),并且应该对缓存行对齐跳转表 .

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

  • 2

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

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

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

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

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

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

  • 3

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

    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.在极少数情况下,执行代码页上的数据作为指令可能会导致性能问题 . 当执行在跟踪缓存中没有驻留的间接分支之后,很可能发生这种情况 . 如果这显然导致性能问题,请尝试将数据移动到其他地方,或者在间接分支之后立即插入非法操作码或PAUSE指令 . 请注意,后两种选择可能会在某些情况下降低性能 . 汇编/编译器编码规则58.(H影响,L一般性)始终将代码和数据放在不同的页面上 . 尽可能避免自我修改代码 . 如果要修改代码,请尝试立即执行所有操作,并确保执行修改的代码和要修改的代码位于单独的4 KB页面上或单独对齐的1 KBy子页面上 . 3.6.9.1自修改代码 . 在Pentium III处理器上正确运行的自修改代码(SMC)以及之前的实现将在后续实现中正确运行 . 当需要高性能时,应避免SMC和交叉修改代码(当多处理器系统中的多个处理器写入代码页时) . 软件应该避免写入正在执行的同一个1 KB的子页面中的代码页,或者在正在写入的同一个2 KB的子页面中获取代码 . 此外,将包含直接或推测执行的代码的页面与另一个处理器共享作为数据页面可以触发SMC条件,该条件导致机器的整个管道和跟踪高速缓存被清除 . 这是由于自修改代码条件 . 如果在该页面作为代码访问之前编写的代码填满数据页,则动态代码不需要导致SMC条件 . 动态修改的代码(例如,来自目标修复)可能会受到SMC条件的影响,应尽可能避免 . 通过使用寄存器间接调用在数据页(而不是代码页)上引入间接分支和使用数据表来避免这种情况 .

相关问题