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

Viewed 146

I had an assignment from my professor and one part of it sparked a conversation on branchless programming. The goal was to convert this C code to MIPS assembly (assuming a and b were in registers $s0 and $s1, respectively) using the slt instruction.

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

The expected response was:

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

However, I submitted the following branchless solution (from what I understand, branchless programming is preferred in terms of performance):

        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)

I understand this is a small case wherein any performance gain would be negligible, but I am wondering if the line of thinking I used was on the right track.

My professor pointed out that the mul instruction is complex (read: hardware intensive), thus (if implemented in hardware) any performance gains made by using less instructions would ultimately be lost due to that complexity. Is this true?

2 Answers

Its impossible to say as you haven't specified a MIPS implementation, which there have been a lot of over the years.

However, multiplication can be more expensive, especially on earlier MIPS processors, where each could cost two (or three) cycles.

An alternative along the lines of unconditional control flow is to subtract one from the slt and use and instead of mul, later the xor with -1.  That will cost one extra instruction over the mul version, so unclear as to which will be faster, but does involve "simpler" instructions.

    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

Whether this is faster than the conditional logic depends on the processor, and the data set, assuming this is in a loop (if not in a loop at some level, then the question is of lessor importance).

The data set will determine whether the branch prediction is effective or not.  Each time the branch prediction is wrong, it will cost a couple of cycles (of course depending on the processor implementation).  If the branch prediction is 100% effective, then the conditional logic would probably be best.  But if the data set defies branch prediction then several cycles of overhead per execution might be incurred, so the unconditional logic might be best.

It would be nice if processors had a few extra opcodes so that the subtraction of 1 was unnecessary, then also the inversion for the else part.  In such hypothetical case, we would have:

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

These would be simple instructions for hardware to implement — they would not tax the ALU, or instruction and operand encodings.


Some more advanced processors may be able to fuse operations, or dual issue, on some of the above so that the execution cost might come down.

Interesting and clever use of mul by 0 or 1, but if you have 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), earlier than 1999's MIPS 4K (MIPS32 ISA) MIPS 5K (MIPS64 ISA).

movn and movz are very much like x86 cmov, AArch64 csel, and equivalent instructions on other ISAs. Since MIPS has a zero register, you can condition-move from it to conditionally zero something based on another register being zero (movz) or non-zero (movn).

That allows GCC to compile your logic into 3 instructions, when I put it in front of a tailcall that uses the values in their incoming registers.

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     $2,$5,$4
        movn    $5,$0,$2
        movz    $4,$0,$2
        j       bar
        nop

This is pretty much unbeatable.


Note that in your proposed version, the dynamic instruction count is at worst 4 (no path of execution runs both all 5), and at best 3 (if the bne is taken). But it avoid branching which is good, and does save static code size.

(If you assemble for a real MIPS with an assembler that tries but fails to fill branch-delay slots here, the branchy version might get an extra NOP after one or both branches, widening the advantage of branchless.)

On a fast wide machine (like 4-wide MIPS R8000 or R10000 although those are MIPS IV CPUs with movn/z but not mul) it's certainly interesting to consider mul if you don't do this often; the front-end can decode these instructions and then get more into the pipeline while it chews on these. Modern x86 CPUs have fully pipelined integer multiply units with 3 cycle latency, 1 cycle throughput (so they can start a new multiply every clock cycle). But much older MIPS CPUs almost certainly didn't, likely not even fully pipelined, and probably higher latency. (This is why until MIPS32/64 they only had mult, which wrote the result to a special hi/lo registers, to decouple that logic from the regular integer pipeline.)

I don't know where the tradeoff might be in terms of how predictable the branch would have to be (by the older simpler predictors in old MIPS CPUs) before that would be better than mul, for some realistic historical mul latency/throughput numbers. MIPS pipelines are shorter than modern CPUs; it helps some that the ISA is literally designed from the ground up to pipeline easily. But the branch delay slot only really helps scalar CPUs.

With a slow enough mul (or mult / mfo), even worst-case branch prediction might be better.

See also Modern Microprocessors A 90-Minute Guide!

I didn't look at details of MIPS III CPUs to see if any had longer pipelines / superscalar or out-of-order exec which might favour less branchy code even without movn / movz

But as Erik showed in his answer, it's possible to just use bitwise operations even if you don't have MIPS IV movn/movz, so this consideration of mul only goes from purely hypothetical on MIPS IV to basically hypothetical on MIPS III CPUs.

(Or I guess if you have code that needs to be able to run on old MIPS CPUs, but is being run on a newer MIPS.)

Related