The compiled difference between switch-case and if-else

Introduction

The C language provides two methods of chaining together conditional statements: an if-else tree; and a switch-case statement. On the surface, these may seem logically identical, and it is indeed possible to create logically equivalent code using both methods. However, the compiler treats the syntax differently, resulting in (marginally) faster code in some circumstances using switch-case statements.

A very basic test bench

To investigate this I created two functions, switch_case_func:

int switch_case_func(int in) {
    int out;

    switch (in) {
        case 0:
            out = 1;
            break;
        case 1:
            out = 5;
            break;
        case 2:
            out = -4;
            break;
        case 3:
            out = 10;
            break;
        case 4:
            out = -93;
            break;
        default:
            out = 0;
            break;
    }

    return out;
}

and if_else_func:

int if_else_func(int in) {
    int out;

    if (in == 0) {
        out = 1;
    } else if (in == 1) {
        out = 5;
    } else if (in == 2) {
        out = -4;
    } else if (in == 3) {
        out = 10;
    } else if (in == 4) {
        out = -93;
    } else {
        out = 0;
    }

    return out;
}

These functions were then used to show how the different syntax affects the compiled binary in a variety of different ways.

The machine code

Looking at the decompilation of these functions in Ghidra it is clear that the compiler has done something different for each function. The if_else_func function has decompiled as expected:

undefined4 if_else_func(int param_1)

{
  undefined4 local_c;
  
  if (param_1 == 0) {
    local_c = 1;
  }
  else if (param_1 == 1) {
    local_c = 5;
  }
  else if (param_1 == 2) {
    local_c = 0xfffffffc;
  }
  else if (param_1 == 3) {
    local_c = 10;
  }
  else if (param_1 == 4) {
    local_c = 0xffffffa3;
  }
  else {
    local_c = 0;
  }
  return local_c;
}

This is pretty much identical to the source input with the variable names stripped out. However, switch_case_func looks like this:

undefined4 switch_case_func(int param_1)

{
  if (param_1 == 4) {
    return 0xffffffa3;
  }
  if (param_1 < 5) {
    if (param_1 == 3) {
      return 10;
    }
    if (param_1 < 4) {
      if (param_1 == 2) {
        return 0xfffffffc;
      }
      if (param_1 < 3) {
        if (param_1 == 0) {
          return 1;
        }
        if (param_1 == 1) {
          return 5;
        }
      }
    }
  }
  return 0;
}

Instead of waiting to the end of the function to return, the return statements are done within the if-else tree, meaning the program doesn’t need to jump to the end of the function. The code also makes use of comparison operators other than equals. Lets take a look at these functions in their assembly form:

switch_case_func:

        .globl  switch_case_func
        .type   switch_case_func, @function
switch_case_func:
.LFB7:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movl    %edi, -20(%rbp)
        cmpl    $4, -20(%rbp)
        je      .L3
        cmpl    $4, -20(%rbp)
        jg      .L4
        cmpl    $3, -20(%rbp)
        je      .L5
        cmpl    $3, -20(%rbp)
        jg      .L4
        cmpl    $2, -20(%rbp)
        je      .L6
        cmpl    $2, -20(%rbp)
        jg      .L4
        cmpl    $0, -20(%rbp)
        je      .L7
        cmpl    $1, -20(%rbp)
        je      .L8
        jmp     .L4
.L7:
        movl    $1, -4(%rbp)
        jmp     .L9
.L8:
        movl    $5, -4(%rbp)
        jmp     .L9
.L6:
        movl    $-4, -4(%rbp)
        jmp     .L9
.L5:
        movl    $10, -4(%rbp)
        jmp     .L9
.L3:
        movl    $-93, -4(%rbp)
        jmp     .L9
.L4:
        movl    $0, -4(%rbp)
        nop
.L9:
        movl    -4(%rbp), %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE7:
        .size   switch_case_func, .-switch_case_func

`if_else_func:

        .globl  if_else_func
        .type   if_else_func, @function
if_else_func:
.LFB8:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movl    %edi, -20(%rbp)
        cmpl    $0, -20(%rbp)
        jne     .L12
        movl    $1, -4(%rbp)
        jmp     .L13
.L12:
        cmpl    $1, -20(%rbp)
        jne     .L14
        movl    $5, -4(%rbp)
        jmp     .L13
.L14:
        cmpl    $2, -20(%rbp)
        jne     .L15
        movl    $-4, -4(%rbp)
        jmp     .L13
.L15:
        cmpl    $3, -20(%rbp)
        jne     .L16
        movl    $10, -4(%rbp)
        jmp     .L13
.L16:
        cmpl    $4, -20(%rbp)
        jne     .L17
        movl    $-93, -4(%rbp)
        jmp     .L13
.L17:
        movl    $0, -4(%rbp)
.L13:
        movl    -4(%rbp), %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE8:
        .size   if_else_func, .-if_else_func

The structure of these functions ion assembly is very different. The if-else method is structures much more like the code, with each part of the tree handled in its own section, and comparisons being done using the jne, or jump-if-not-equal, instruction. In comparison, the compiled switch-case statement has a structure quite different to the code which generated it. Instead of doing the comparison in-line, it is all done at the top of the method, with the logic for each case being handled by a label below the comparison section. We again see the use of comparison operators other than equal, with the use of the jg, or jump-if-greater-than, instruction.

Number of instructions per comparison

So, which is better? One way to determine this is by quantifying which method can do the comparison faster. The table below shows the number of instructions the function takes to execute for each input:

Number of instructions for each input
Input If-Else Switch-Case
0 22 10
1 24 12
2 18 14
3 14 16
4 10 18
>4 12 17

This detracts from the normal wisdom, which states that switch-case statements are faster than if-else trees.

Adding compiler optimizations

Adding compiler optimizations completely changes the output. Using O1 or O2 optimizations results in the code for the if-else and switch-case functions being almost identical. Both end up using look-up table logic which dramatically reduces the amount of comparisons required. The reason this optimization is made in the (very basic) case shown above can potentially be explained by the definition of a switch statement in K&R, the seminal book on the C language:

The switch statement is a multi-way decision that tests whether an expression matches one of a number of constant integer values, and branches accordingly.

As the example used is only comparing to constant integer values, the compiler might well be optimizing it to the equivalent of a switch-case statement, hence the production of equivalent machine code.