【问题标题】:How can I write self-modifying code that runs efficiently on modern x64 processors?如何编写在现代 x64 处理器上高效运行的自修改代码?
【发布时间】:2013-07-18 06:29:53
【问题描述】:

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

一般技术被称为“子程序线程”(或“调用线程”,尽管这也有其他定义)。目标是利用处理器有效的调用/调用预测以避免停顿。该方法在这里得到了很好的描述: 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 系列和 Pentium 处理器检查 对代码段的写入是否可以修改具有 被预取执行。如果写入影响预取 指令,预取队列无效。后一项检查是 基于指令的线性地址。对于奔腾 4 和 英特尔至强处理器,代码中指令的写入或窥探 段,其中目标指令已被解码并驻留 在跟踪缓存中,使整个跟踪缓存无效。后者 行为意味着自我修改代码的程序可能会导致严重的 在 Pentium 4 和 Intel Xeon 上运行时性能下降 处理器。

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

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

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

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

  3. MACHINE_CLEARS.SMC 事件的实际循环惩罚是多少 在哈斯韦尔?

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

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

【问题讨论】:

  • 确实很有趣的问题,但我觉得你列出的大部分观点都是研究问题,即我怀疑找出答案的方法是做适当的实验。
  • 实验可能确实是最好的方法。如今,自修改代码被认为是邪恶的和设计缺陷。有时它可能是必要的,但由于人们避免它,所以不会有太多可用的信息......
  • 实验肯定是必要的,但鉴于动态语言 JIT 编译的最新进展,我希望有一些更好的指南。但最大的区别在于 JIT 通常只执行一次,之后就不会被修改。
  • JIT 不必执行一次,您可以根据您达到的代码分析级别进行多级优化。
  • 2. SnB 系列(包括 Haswell)中的 uop 缓存不是跟踪缓存。每种方式(最多 6 个微指令的组,Agner Fog 称之为一行)都知道它正在缓存的 x86 指令的地址范围。无条件跳转(如 JMP)确实结束了一条路/线,但 uops 在某种程度上必须是静态连续的。

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


【解决方案1】:

非常好的问题,但答案并不那么容易......可能最终的结果将是实验 - 现代世界不同架构的常见案例。

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

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

多远才算够?我认为这不是到目前为止。距离应该可能是处理器缓存行的倍数,这样就不会那么大了。我有类似 64 字节的东西(用于 L1)。在动态分配内存的情况下,您将有许多页面距离。

这种方法 IMO 的主要问题是生成的程序的代码只会执行一次。这样,程序就会失去缓存内存模型的主要优势——循环代码的高效执行。

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

【讨论】:

  • 我想不出一个好方法来每次分配一个具有唯一物理地址的新块。你是否有一个?相反,我的希望是循环穿过少数几个街区,并假设最旧的街区已被遗忘。
  • 您可以简单地不释放先前分配的块,直到整个大小变得大于可用缓存(L1 或 L2?),然后从已分配的块中循环。作为一个实验,您可以尝试为它们分配不同的大小(从 1 页开始),然后比较产生的性能。
【解决方案2】:

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

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

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

【讨论】:

    【解决方案3】:

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

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

    主要困难在于call 是IP 相关的(大多数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
    ...
    

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

    然后,您在运行时修补跳转目标。这类似于.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)) 初始化空/未使用的跳转表,然后根据需要使用跳转操作码和目标地址填写字段。

    【讨论】:

    • 这是对该方法的一个很好的总结。将其视为动态生成的代码可能更好。我称它为自修改,因为目标是防止处理器执行“自修改代码机器清除”,并且因为它将在循环中运行而不是仅在启动时生成。您的方法的问题是通过循环的连续时间的性能:我需要可预测的分支,我认为这排除了间接调用。调用本身需要更改,而困难在于在不刷新缓存的情况下执行此操作,并且最好不要停止。
    • 自行更改call 的问题在于您被限制在+- 2GB 范围内;但是,如果这已经足够了,您可以使 call 16 字节对齐并在它们之间用 11 字节 NOP 填充它们;这样,每个 call 插槽是 16 字节,您可以使用 movntdq 更新缓存写入中的表?
    • 我认为我需要更改调用本身,并且调用必须是直接且无条件的,才能在第一次遇到代码时正确预测它。这种技术的目的是避免每次约 50 次循环调用产生约 15 次循环分支预测错误,并且很可能每次都是该特定代码的第一次。不需要非临时写入:如果新代码进入缓存并取代旧代码,那就没问题了。相反,目标是防止在处理器担心它们过时时刷新整个指令缓存和管道。
    • x86-64 没有jmp abs64(而 32 位没有jmp abs32。唯一绝对的直接跳转是远跳转(jmp ptr16:32,你肯定没有'不希望 CPU 花时间加载段描述符,而且无论如何它在 64 位模式下不可用)。有JMP r/m64,所以你可以movabs $imm64, %rax / jmp *%rax,但你还不如使用间接call *%rax。您可以使用 call *table+0 / call *table+8 / ... 进行间接调用(如果这是从内存中加载函数指针的正确 AT&T 语法)。无论如何,这是您可以修改的数据内存。
    • @NathanKurz:直接call 也必须进行分支预测。错误预测成本低于间接调用或 jmp,但 fetch 不能等到代码块被解码后才开始从正确的地址获取。如果您在生成后重复使用相同的块,我认为call 8(%rbx) /` call 16(%rbx)` 等序列是一个不错的选择。数据加载可能会出现错误预测,但无法通过 SMC 机器清除。
    【解决方案4】:

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

    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 性能问题可能是由在代码页上执行数据引起的 指示。这很可能在执行时发生 跟随不驻留在跟踪缓存中的间接分支。 如果这明显导致性能问题,请尝试移动数据 其他地方,或插入非法操作码或暂停指令 直接在间接分支之后。注意后两者 在某些情况下,替代方案可能会降低性能。

    汇编/编译器编码规则 58。(H 影响,L 通用性)始终放置 代码和数据在不同的页面上。避免在任何地方自行修改代码 可能的。如果要修改代码,请尝试一次全部完成并制作 确保执行修改的代码和代码是 修改在单独的 4-KByte 页面上或单独对齐的 1-KByte 子页面。

    3.6.9.1 自修改代码。

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

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

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

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-06-16
      • 2011-04-04
      • 1970-01-01
      • 2016-06-06
      • 1970-01-01
      • 2021-05-07
      相关资源
      最近更新 更多