【问题标题】:Why is the "start small" algorithm for branch displacement not optimal?为什么分支位移的“start small”算法不是最优的?
【发布时间】:2016-01-20 21:40:33
【问题描述】:

[底部加粗的问题]

当汇编器生成二进制编码时,它需要决定是使每个分支长还是短,如果可能的话,短的更好。这部分汇编程序称为分支置换优化(BDO)算法。一种典型的方法是,汇编器使 all 分支编码变短(如果它们小于某个阈值),然后迭代地增加任何分支跳转到未达到的 longs。当然,这可能会导致其他分支转换为跳远。因此,汇编程序必须不断通过跳转列表,直到不再需要升迁。这种二次时间方法对我来说似乎是一种最优算法,但据说 BDO 是 NP 完全的,这种方法实际上并不是最优的。

Randall Hyde 提供了一个反例:

                  .386 
                  .model  flat, syscall
00000000          .code
00000000          _HLAMain        proc


00000000  E9 00000016          jmpLbl:         jmp     [near ptr] target
00000005 = 00000005            jmpSize         =       $-jmpLbl
00000005  00000016 [                           byte    32 - jmpSize*2
dup
(0)
            00
           ]
0000001B                       target:
0000001B                       _HLAMain        endp
                                                end

通过在括号“[near ptr]”中添加部分并强制使用 5 字节编码,二进制文件实际上最终会更短,因为分配的数组小了两倍的跳转大小。因此,通过缩短跳转编码,最终的代码实际上会更长。

这对我来说似乎是一个非常病态的案例,并且并不真正相关,因为分支编码仍然更小,它只是对程序的非分支部分的这种奇怪的副作用,导致二进制文件变得更大。由于分支编码本身仍然更小,我并不认为这是“start small”算法的有效反例。

我是否可以将 start-small 算法视为最佳 BDO 算法,或者是否存在一种 现实 的情况,即它不为所有分支提供最小编码大小?

【问题讨论】:

  • 但是对齐的存在会影响跳跃的大小。无论如何,为了触发 NP 完全性,您需要“异常跳跃” - 首先必须很长的跳跃,然后当其他跳跃变得很长时可以变短。如果你只考虑跳转和无聊的指令,那么这不可能发生。
  • 在某处有一些对齐可能意味着在“填充物”上向后的短跳只能在其目标展开之前的一些跳跃之后“成功”,因此扩展一些不需要它的跳跃允许其他跳跃要小。这也不算太疯狂,循环通常是对齐的。
  • @harold:如果有一种方法可以让汇编器通过对先前指令使用更长的编码来对齐,那就太好了(例如,寻址模式或立即数中的 4B 位移,或不必要/重复的前缀),而不是插入 NOP。这对于对齐循环以及 NOP 实际运行的其他情况非常有用。
  • 即使这个问题在形式上是 NP 完全的,被优化的例程不是必须非常大才能成为一个重要的问题吗?
  • @j_random_hacker:经典的 LFP 算法是从一个已知太小的近似值开始,然后逐渐添加到它,直到没有任何变化(或者从大开始,然后减去,有时效果会更好)。关键是数据结构:如果您可以在恒定时间内处理加法(包括识别未来加法的候选者),那么算法就是线性时间。 (在另一类解决方案中,候选识别是 log time,最终算法是 n log n。)例如,参见分区细化。

标签: algorithm assembly optimization encoding


【解决方案1】:

这是一个证明,在没有 harold 在 cmets 中提到的异常跳跃的情况下,“start small”算法是最优的:

首先,让我们确定“从小处着手”总是会产生一个可行的解决方案——也就是说,一个不包含任何过长跳转的短编码的解决方案。该算法本质上等于反复问“可行吗?”这个问题。如果没有,则延长一些跳转编码,所以很明显 if 它终止了,那么它产生的解决方案必须是可行的。由于每次迭代都会延长一些跳跃,并且没有任何跳跃被延长超过一次,因此该算法最终必须在最多 nJumps 次迭代后终止,因此该解决方案必须是可行的。

现在假设相反,该算法可以产生次优解 X。设 Y 为某个最优解。我们可以将解决方案表示为被延长的跳转指令的子集。我们知道 |X \ Y| >= 1——也就是说,在 X 中至少有 1 条指令在 Y 中没有延长——因为否则 X 将是 Y 的子集,并且由于假设 Y 是最优的,并且已知 X 是可行的,因此 X = Y,这意味着 X 本身就是一个最优解,这与我们最初关于 X 的假设相矛盾。

从 X\Y 中的指令中,选择 i 作为“start small”算法首先被延长的指令,并令 Z 是 Y(和 X)的子集,包含所有已经延长了之前的算法。由于“start small”算法决定延长 i 的编码,所以在那个时间点(即,在延长 Z 中的所有指令之后),i 的跳转位移对于短编码来说太大了。 (请注意,虽然 Z 中的一些延长可能已将 i 的跳跃位移推到了临界点之上,但这绝不是必要的——也许 i 的位移从一开始就高于阈值。我们所知道的,以及我们所需要的知道,在 Z 完成处理时,我的跳跃位移是否高于阈值。)但现在回头看看最优解 Y,并注意 Y 中没有其他延长 - 即 Y \ Z -- 能够将i的跳转位移向后减小,所以,由于i的位移高于阈值但其编码没有被Y延长,Y甚至不可行!不可行的解决方案不可能是最优的,因此在 Y 中存在这样的非延长指令 i 将与 Y 是最优的假设相矛盾——这意味着不存在这样的 i。

【讨论】:

    【解决方案2】:

    j_random_hacker 关于 Start Small 的论点对于没有填充的简化情况是最佳的,这听起来很合理。但是,它在优化大小函数之外并不是很有用。 真正的 asm 确实ALIGN 指令,并且它确实有所作为

    这是我可以构建的最简单示例,其中 Start Small 没有给出最佳结果(使用 NASM 和 YASM 测试)。 使用jz near .target0 强制进行长编码,将another_function: 提前32 个字节并减少func 内的填充。

    func:
    .target0:               ; anywhere nearby
        jz  .target0        ; (B0)  short encoding is easily possible
    .target1:
       times 10 vpermilps xmm14, xmm15, [rdi+12345]
            ; A long B0 doesn't push this past a 32B boundary, so short or long B0 doesn't matter
    ALIGN 32
    .loop:
       times 12 xor r15d,r15d
        jz  .target1         ; (B1)  short encoding only possible if B0 is long
       times 18 xor r15d,r15d
        ret   ; A long B1 does push this just past a 32B boundary.
    
    ALIGN 32
    another_function:
        xor  eax,eax
        ret
    
    • 如果 B0 很短,那么 B1 必须很长才能到达 target1。

    • 如果 B0 很长,它会将 target1 推到更靠近 B1 的位置,从而允许短编码到达。

    所以B0和B1最多可以有一个短编码,但是重要的是哪个短。短 B0 意味着多 3 个字节的对齐填充,不节省代码大小。长 B0 允许短 B1 确实 节省总代码大小。在我的示例中,我说明了可能发生的最简单的方法:将 B1 之后的代码末尾推到下一个对齐的边界之外。它也可能影响其他分支,例如分支到.loop 需要长编码。

    • 期望:B0 多头,B1 空头。
    • Start-Small 结果:B0 空头,B1 多头。 (它们最初的第一次通过状态。)Start-Small 不会尝试延长 B0 并缩短 B1 以查看它是否减少了总填充,或者只是执行的填充(理想情况下由行程计数加权)。

      .loop 之前的 4 字节 NOP,another_func 之前的 31 字节 NOP,所以它从 0x400160 开始,而不是我们使用 jz near .target0 得到的 0x400140,这会导致短编码B1。


    请注意,B0 本身的长编码不是实现 B1 短编码的唯一方法。对.target1 之前的任何指令进行长于必要的编码也可以解决问题。 (例如 4B 位移或立即数,而不是 1B。或不必要或重复的前缀。)

    不幸的是,我所知道的没有一个汇编器支持这种方式的填充;只有nopWhat methods can be used to efficiently extend instruction length on modern x86?


    通常,在循环开始时甚至没有跳过 long-NOP,因此更多的填充可能会降低性能(如果需要多个 NOP,或者代码在 Atom 等 CPU 上运行或者 Silvermont,它有很多前缀,非常慢,因为汇编器没有针对 Silvermont 进行调整而被使用。


    请注意,编译器输出很少有函数之间的跳转(通常仅用于尾调用优化)。 x86 没有call 的短编码。手写 asm 可以为所欲为,但意大利面条式代码(希望如此?)在大规模上仍然不常见。

    我认为对于大多数 asm 源文件,BDO 问题很可能可以分解为多个独立的子问题,通常每个函数都是一个单独的问题。这意味着即使是非多项式复杂度算法也可能是可行的。

    一些有助于解决问题的捷径会有所帮助:例如检测何时确实需要长编码,即使所有中间分支都使用短编码。当连接子问题的唯一方法是两个远程函数之间的尾调用时,这将允许打破子问题之间的依赖关系。

    我不确定从哪里开始制定算法以找到全局最优解。如果我们愿意考虑扩展其他指令来移动分支目标,搜索空间是相当大的。但是,我认为我们只需要考虑跨对齐填充的分支。

    可能的情况有:

    • 向后分支的分支目标前填充
    • 前向分支的分支指令前填充

    如果我们将一些微架构优化的知识嵌入到汇编器中,那么做好这件事可能会更容易:例如总是尝试让分支目标在 16B insn 获取块的开头附近开始,而且绝对不是在结尾处。 Intel uop 缓存行只能缓存一个 32B 块内的 uop,因此 32B 边界对于 uop 缓存很重要。 L1 I$ 行大小为 64B,页面大小为 4kiB。 (不过,汇编器不会知道哪些代码是热的,哪些是冷的。热代码跨越两页可能比代码稍大一些更糟糕。)

    对于 Intel 和 AMD,在指令解码组的开头使用多指令也比在其他任何地方都好得多。 (对于具有 uop 缓存的 Intel CPU 而言,情况较少)。弄清楚 CPU 大部分时间将通过哪条路径处理代码,以及指令解码边界在哪里,可能远远超出了汇编程序所能管理的范围。

    【讨论】:

    • 另一种可以快速决定是否应该扩展跳转编码的情况是,即使自身与其目标地址之间的所有其他跳转都已扩展,它的位移仍然足够小以允许一个小的编码。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-03-28
    • 2011-02-09
    • 2011-01-17
    • 1970-01-01
    相关资源
    最近更新 更多