The wrong way of benchmarking the most efficient integer comparison function

Raymond

On StackOverflow, there’s a question about the most efficient way to compare two integers and produce a result suitable for a comparison function, where a negative value means that the first value is smaller than the second, a positive value means that the first value is greater than the second, and zero means that they are equal.

There was much microbenchmarking of various options, ranging from the straightforward

int compare1(int a, int b)
{
    if (a < b) return -1;
    if (a > b) return 1;
    return 0;
}

to the clever

int compare2(int a, int b)
{
    return (a > b) - (a < b);
}

to the hybrid

int compare3(int a, int b)
{
    return (a < b) ? -1 : (a > b);
}

to inline assembly

int compare4(int a, int b)
{
    __asm__ __volatile__ (
        "sub %1, %0 \n\t"
        "jno 1f \n\t"
        "cmc \n\t"
        "rcr %0 \n\t"
        "1: "
    : "+r"(a)
    : "r"(b)
    : "cc");
    return a;
}

The benchmark pitted the comparison functions against each other by comparing random pairs of numbers and adding up the results to prevent the code from being optimized out.

But here’s the thing: Adding up the results is completely unrealistic.

There are no meaningful semantics that could be applied to a sum of numbers for which only the sign is significant. No program that uses a comparison function will add the results. The only thing you can do with the result is compare it against zero and take one of three actions based on the sign.

Adding up all the results means that you’re not using the function in a realistic way, which means that your benchmark isn’t realistic.

Let’s try to fix that. Here’s my alternative test:

// Looks for "key" in sorted range [first, last) using the
// specified comparison function. Returns iterator to found item,
// or last if not found.

template<typename It, typename T, typename Comp>
It binarySearch(It first, It last, const T& key, Comp compare)
{
 // invariant: if key exists, it is in the range [first, first+length)
 // This binary search avoids the integer overflow problem
 // by operating on lengths rather than ranges.
 auto length = last - first;
 while (length > 0) {
  auto step = length / 2;
  It it = first + step;
  auto result = compare(*it, key);
  if (result < 0) {
   first = it + 1;
   length -= step + 1;
  } else if (result == 0) {
   return it;
  } else {
   length = step;
  }
 }
 return last;
}

int main(int argc, char **argv)
{
 // initialize the array with sorted even numbers
 int a[8192];
 for (int i = 0; i < 8192; i++) a[i] = i * 2;

 for (int iterations = 0; iterations < 1000; iterations++) {
  int correct = 0;
  for (int j = -1; j < 16383; j++) {
   auto it = binarySearch(a, a+8192, j, COMPARE);
   if (j < 0 || j > 16382 || j % 2) correct += it == a+8192;
   else correct += it == a + (j / 2);
  }
  // if correct != 16384, then we have a bug somewhere
  if (correct != 16384) return 1;
 }
 return 0;
}

Let’s look at the code generation for the various comparison functions. I used gcc.godbolt.org with x86-64 gcc 7.2 and optimization -O3.

If we try compare1, then the binary search looks like this:

    ; on entry, esi is the value to search for

    lea rdi, [rsp-120]          ; rdi = first
    mov edx, 8192               ; edx = length
    jmp .L9
.L25:                           ; was greater than
    mov rdx, rax                ; length = step
    test rdx, rdx               ; while (length > 0)
    jle .L19
.L9:
    mov rax, rdx                ;
    sar rax, 1                  ; eax = step = length / 2
    lea rcx, [rdi+rax*4]        ; it = first + step

    ; result = compare(*it, key), and then test the result
    cmp dword ptr [rcx], esi    ; compare(*it, key)
    jl .L11                     ; if less than
    jne .L25                    ; if not equal (therefore if greater than)
    ... return value in rcx     ; if equal, answer is in rcx

.L11:                           ; was less than
    add rax, 1                  ; step + 1
    lea rdi, [rcx+4]            ; first = it + 1
    sub rdx, rax                ; length -= step + 1
    test rdx, rdx               ; while (length > 0)
    jg .L9
.L19:
    lea rcx, [rsp+32648]        ; rcx = last
    ... return value in rcx

Exercise: Why is rsp - 120 the start of the array?

Observe that despite using the lamest, least-optimized comparison function, we got the comparison-and-test code that is much what we would have written if we had done it in assembly language ourselves: We compare the two values, and then follow up with two branches based on the same shared flags. The comparison is still there, but the calculation and testing of the return value are gone.

In other words, not only was compare1 optimized down to one cmp instruction, but it also managed to delete instructions from the binarySearch function too. It had a net cost of negative instructions!

What happened here? How did the compiler manage to optimize out all our code and leave us with the shortest possible assembly language equivalent?

Simple: First, the compiler did some constant propagation. After inlining the compare1 function, the compiler saw this:

    int result;
    if (*it < key) result = -1;
    else if (*it > key) result = 1;
    else result = 0;
    if (result < 0) {
      ... less than ...
    } else if (result == 0) {
      ... equal to ...
    } else {
      ... greater than ...
    }

The compiler realized that it already knew whether constants were greater than, less than, or equal to zero, so it could remove the test against result and jump straight to the answer:

    int result;
    if (*it < key) { result = -1; goto less_than; }
    else if (*it > key) { result = 1; goto greater_than; }
    else { result = 0; goto equal_to; }
    if (result < 0) {
less_than:
      ... less than ...
    } else if (result == 0) {
equal_to:
      ... equal to ...
    } else {
greater_than:
      ... greater than ...
    }

And then it saw that all of the tests against result were unreachable code, so it deleted them.

    int result;
    if (*it < key) { result = -1; goto less_than; }
    else if (*it > key) { result = 1; goto greater_than; }
    else { result = 0; goto equal_to; }

less_than:
      ... less than ...
      goto done;

equal_to:
      ... equal to ...
      goto done;

greater_than:
      ... greater than ...
done:

That then left result as a write-only variable, so it too could be deleted:

    if (*it < key) { goto less_than; }
    else if (*it > key) { goto greater_than; }
    else { goto equal_to; }

less_than:
      ... less than ...
      goto done;

equal_to:
      ... equal to ...
      goto done;

greater_than:
      ... greater than ...
done:

Which is equivalent to the code we wanted all along:

    if (*it < key) {
      ... less than ...
    } else if (*it > key) {
      ... greater than ...
    } else {
      ... equal to ...
    }

The last optimization is realizing that the test in the else if could use the flags left over by the if, so all that was left was the conditional jump.

Some very straightforward optimizations took our very unoptimized (but easy-to-analyze) code and turned it into something much more efficient.

On the other hand, let’s look at what happens with, say, the second comparison function:

    ; on entry, edi is the value to search for

    lea r9, [rsp-120]           ; r9 = first
    mov ecx, 8192               ; ecx = length
    jmp .L9
.L11:                           ;
    test eax, eax               ; result == 0?
    je .L10                     ; Y: found it
                                ; was greater than
    mov rcx, rdx                ; length = step
    test rcx, rcx               ; while (length > 0)
    jle .L19
.L9:
    mov rdx, rcx
    xor eax, eax                ; return value of compare2
    sar rdx, 1                  ; rdx = step = length / 2
    lea r8, [r9+rdx*4]          ; it = first + step

    ; result = compare(*it, key), and then test the result
    mov esi, dword ptr [r8]     ; esi = *it
    cmp esi, edi                ; compare *it with key
    setl sil                    ; sil = 1 if less than
    setg al                     ; al  = 1 if greater than
                                ; eax = 1 if greater than
    movzx esi, sil              ; esi = 1 if less than
    sub eax, esi                ; result = (greater than) - (less than)
    cmp eax, -1                 ; less than zero?
    jne .L11                    ; N: Try zero or positive

                                ; was less than
    add rdx, 1                  ; step + 1
    lea r9, [r8+4]              ; first = it + 1
    sub rcx, rdx                ; length -= step + 1
    test rcx, rcx               ; while (length > 0)
    jg .L9
.L19:
    lea r8, [rsp+32648]         ; r8 = last
.L10:
    ... return value in r8

The second comparison function compare2 uses the relational comparison operators to generate exactly 0 or 1. This is a clever way of generating -1, 0, or +1, but unfortunately, that was not our goal in the grand scheme of things. It was merely a step toward that goal. The way that compare2 calculates the result is too complicated for the optimizer to understand, so it just does its best at calculating the formal return value from compare2 and testing its sign. (The compiler does realize that the only possible negative value is -1, but that’s not enough insight to let it optimize the entire expression away.)

If we try compare3, we get this:

    ; on entry, esi is the value to search for

    lea rdi, [rsp-120]          ; rdi = first
    mov ecx, 8192               ; ecx = length
    jmp .L12
.L28:                           ; was greater than
    mov rcx, rax                ; length = step
.L12:
    mov rax, rcx
    sar rax, 1                  ; rax = step = length / 2
    lea rdx, [rdi+rax*4]        ; it = first + step

    ; result = compare(*it, key), and then test the result
    cmp dword ptr [rdx], esi    ; compare(*it, key)
    jl .L14                     ; if less than
    jle .L13                    ; if less than or equal (therefore equal)

    ; "length" is in eax now
.L15:                           ; was greater than
    test eax, eax               ; length == 0?
    jg .L28                     ; N: continue looping
    lea rdx, [rsp+32648]        ; rdx = last
.L13:
    ... return value in rdx

.L14:                           ; was less than
    add rax, 1                  ; step + 1
    lea rdi, [rdx+4]            ; first = it + 1
    sub rcx, rax                ; length -= step + 1
    mov rax, rcx                ; rax = length
    jmp .L15

The compiler was able to understand this version of the comparison function: It observed that if a < b, then the result of compare3 is always negative, so it jumped straight to the less-than case. Otherwise, it observed that the result was zero if a is not greater than b and jumped straight to that case too. The compiler did have some room for improvement with the placement of the basic blocks, since there is an unconditional jump in the inner loop, but overall it did a pretty good job.

The last case is the inline assembly with compare4. As you might expect, the compiler had the most trouble with this.

    ; on entry, edi is the value to search for

    lea r8, [rsp-120]           ; r8 = first
    mov ecx, 8192               ; ecx = length
    jmp .L12
.L14:                           ; zero or positive
    je .L13                     ; zero - done
                                ; was greater than
    mov rcx, rdx                ; length = step
    test rcx, rcx               ; while (length > 0)
    jle .L22
.L12:
    mov rdx, rcx
    sar rdx, 1                  ; rdx = step = length / 2
    lea rsi, [r8+rdx*4]         ; it = first + step

    ; result = compare(*it, key), and then test the result
    mov eax, dword ptr [rsi]    ; eax = *it
    sub eax, edi
    jno 1f
    cmc
    rcr eax, 1
1:
    test eax, eax               ; less than zero?
    jne .L14                    ; N: Try zero or positive

                                ; was less than
    add rdx, 1                  ; step + 1
    lea r8, [rsi+4]             ; first = it + 1
    sub rcx, rdx                ; length -= step + 1
    test rcx, rcx               ; while (length > 0)
    jg .L12
.L22:
    lea rsi, [rsp+32648]        ; rsi = last
.L13:
    ... return value in rsi

This is pretty much the same as compare2: The compiler has no insight at all into the inline assembly, so it just dumps it into the code like a black box, and then once control exits the black box, it checks the sign in a fairly efficient way. But it had no real optimization opportunities because you can’t really optimize inline assembly.

The conclusion of all this is that optimizing the instruction count in your finely-tuned comparison function is a fun little exercise, but it doesn’t necessarily translate into real-world improvements. In our case, we focused on optimizing the code that encodes the result of the comparison without regard for how the caller is going to decode that result. The contract between the two functions is that one function needs to package some result, and the other function needs to unpack it. But we discovered that the more obtusely we wrote the code for the packing side, the less likely the compiler would be able to see how to optimize out the entire hassle of packing and unpacking in the first place. In the specific case of comparison functions, it means that you may want to return +1, 0, and -1 explicitly rather than calculating those values in a fancy way, because it turns out compilers are really good at optimizing “compare a constant with zero”.

You have to see how your attempted optimizations fit into the bigger picture because you may have hyper-optimized one part of the solution to the point that it prevents deeper optimizations in other parts of the solution.

Bonus chatter: If the comparison function is not inlined, then all of these optimization opportunities disappear. But I personally wouldn’t worry about it too much, because if the comparison function is not inlined, then the entire operation is going to be dominated by the function call overhead: Setting up the registers for the call, making the call, returning from the call, testing the result, and most importantly, the lost register optimization opportunities not only because the compiler loses opportunities to enregister values across the call, but also because the compiler has to protect against the possibility that the comparison function will mutate global state and consequently create aliasing issues.

0 comments

Comments are closed. Login to edit/delete your existing comments