哪个指令序列对于将一个或另一个寄存器归零具有更好的性能?

Which sequence of instructions has better performance for zeroing one register or another?

我的教授给我布置了一项作业,其中一部分引发了一场关于无分支编程的对话。目标是使用 slt 指令将此 C 代码转换为 MIPS 汇编(假设 ab 分别位于寄存器 $s0$s1 中)。

if (a <= b)
  a = 0;
else
  b = 0;

预期的响应是:

        slt $t0,$s1,$s0    ; Set on less-than (if $s1 < $s0 then set $t0 to 1, else 0).
        bne $t0,[=11=],else    ; Branch on not-equal (jump to `else` if $t0 != [=11=])
        add $s0,[=11=],[=11=]      ; $s0 = [=11=] + [=11=], aka $s0 = [=11=] * 2
        j   done           ; Unconditional jump to `done`
else:   sub $s1,[=11=],[=11=]      ; $s1 = [=11=] - [=11=], aka `$s1 = 0`
done: 

但是,我提交了以下无分支方案(据我了解,就性能而言,无分支编程是首选):

        slt  $t0,$s1,$s0    ; Set on less-than (if $s1 < $s0 then set $t0 to 1, else 0).
        mul  $s0,$s0,$t0    ; $s0 = $s0 * $t0 (truncated to 32 bits)
        xori $t0,$t0,0x1    ; $t0 = XOR( $t0, 1 )
        mul  $s1,$s1,$t0    ; $s1 = $s1 * $t0 (truncated to 32 bits)

我知道这是一个小案例,其中任何性能提升都可以忽略不计,但我想知道我使用的思路是否正确。

我的教授指出 mul 指令很复杂(阅读:硬件密集型),因此(如果在硬件中实现)通过使用较少指令获得的任何性能提升最终都会由于这种复杂性而丢失。这是真的吗?

这不可能说,因为你没有指定 MIPS 实现,多年来已经有很多。

但是,乘法可能更昂贵,尤其是在早期的 MIPS 处理器上,其中每个可能花费两个(或三个)周期。

无条件控制流的另一种方法是从 slt 中减去一个并使用 and 而不是 mul,后来 xor 为 -1 .这将比 mul 版本多花费一条指令,所以不清楚哪个更快,但确实涉及“更简单”的指令。

    slt  $t0,$s1,$s0    ; Set on less-than (if $s1 < $s0 then set $t0 to 1, else 0).
    subi  $t0, $t0, 1   ; 1/true->0, 0/false->-1
    and  $s1,$s1,$t0    ; $s1 &= $t0-1        mask is either 0 or -1
    xori $t0,$t0,-1     ; $t0 = ~ $t0
    and  $s0,$s0,$t0    ; $s1 &= ~ (t0-1)     mask is either -1 or 0

这是否比条件逻辑更快取决于处理器,和数据集,假设这是在循环中(如果在某个级别不在循环中,则这个问题不太重要)。

数据集将决定分支预测是否有效。每次分支预测错误,都会花费几个周期(当然取决于处理器实现)。如果分支预测 100% 有效,那么条件逻辑可能是最好的。但是,如果数据集违背分支预测,则每次执行可能会产生几个循环的开销,因此无条件逻辑可能是最好的。

如果处理器有一些额外的操作码,这样就不需要减去 1,那么 else 部分的倒数也很好。在这种假设情况下,我们将有:

slt $t0, $s1, $s0
sameIfTrueOrZero  $s0, $s0, $t0   # $s0 = ($t0 ? $s0 : 0)
sameIfFalseOrZero $s1, $s1, $t0   # $s1 = ($t0 ? 0 : $s1)

这些是硬件实现的简单指令 — 它们不会对 ALU 或指令和操作数编码造成负担。


一些更高级的处理器可能能够在上述某些方面融合操作或双重问题,从而降低执行成本。

mul 0 或 1 的有趣和巧妙的使用,但如果你有 MIPS32 extensions like mul (not classic MIPS mult/mflo) then you also have movz / movn conditional moves. In fact those were introduced with MIPS IV (first commercial release in 1994, R8000),早于 1999 年的 MIPS 4K (MIPS32 ISA) MIPS 5K (MIPS64 ISA)。

movnmovz 非常类似于 x86 cmov、AArch64 csel 以及其他 ISA 上的等效指令。由于 MIPS 有一个零寄存器,您可以从它 condition-move 根据另一个寄存器为零 (movz) 或 non-zero (movn).[=41 有条件地将某些东西置零=]

这允许 GCC 将您的逻辑编译成 3 条指令,当我将它放在使用其传入寄存器中的值的尾调用前面时。

int bar(int,int);

int foo(int a, int b){
    if (a <= b)
      a = 0;
    else
      b = 0;
    return bar(a, b);
}

Godbolt MIPS GCC 11.2 -O3 -march=mips4 -fno-delayed-branch

foo:
        slt     ,,
        movn    ,[=11=],
        movz    ,[=11=],
        j       bar
        nop

这几乎是无与伦比的。


请注意,在您建议的版本中,动态指令数最多为 4(没有执行路径 运行 都是 5),最多为 3(如果采用 bne ).但它避免了很好的分支,并且确实节省了静态代码大小。

(如果你 assemble 一个带有 assembler 的真实 MIPS 尝试但未能填充这里的 branch-delay 槽,分支版本可能会在一个或两个分支,扩大了无分支的优势。)

在快速宽度的机器上(比如 4-wide MIPS R8000 或 R10000,尽管它们是具有 movn/z 而不是 mul 的 MIPS IV CPU),考虑 mul 当然很有趣,如果你不经常这样做; front-end 可以解码这些指令,然后在咀嚼这些指令时将更多内容放入管道中。现代 x86 CPU 具有完全流水线化的整数乘法单元,具有 3 个周期的延迟和 1 个周期的吞吐量(因此它们可以在每个时钟周期开始一个新的乘法)。但更老的 MIPS CPU 几乎肯定没有,甚至可能没有完全流水线化,并且可能有更高的延迟。 (这就是为什么直到 MIPS32/64 他们只有 multwrote the result to a special hi/lo registers 来将该逻辑与常规整数管道分离。)

我不知道在分支的可预测性(通过旧 MIPS CPU 中较旧的更简单的预测器)方面的权衡可能会比 mul 更好,因为一些现实的历史 mul latency/throughput 数字。 MIPS 流水线比现代 CPU 更短; ISA 的字面意思是从头开始设计的,可以轻松地进行流水线处理,这对某些人有所帮助。但是分支延迟槽只能真正帮助标量 CPU。

如果 mul(或 mult / mfo)足够慢,即使 worst-case 分支预测也可能更好。

另请参阅 现代微处理器 90 分钟指南!

我没有查看 MIPS III CPU 的详细信息,看看是否有更长的流水线/超标量或 out-of-order exec,即使没有 movn / [=16= 也可能有利于减少分支代码]

但正如 Erik 在他的回答中所展示的那样,即使您没有 MIPS IV movn/movz,也可以仅使用按位运算,因此考虑 mul仅从 MIPS IV 上的纯假设到 MIPS III CPU 上的基本假设。

(或者我想如果你有代码需要在旧的 MIPS CPU 上 able 到 运行,但在较新的 MIPS CPU 上是 运行 MIPS.)