One Value, Many Destinations

A switch poses a question no single cmp can answer: "of these many cases, which one matches?" The compiler has two very different ways to implement it, and which one it picks depends entirely on the shape of the cases. Learning to recognise both, and to predict which the compiler will choose, is the last piece of reading control flow.

The two strategies are a chain of compares and a jump table. We will read each.

Dense Cases Become a Jump Table

When the case labels are packed close together, like 0, 1, 2, 3, 4, the compiler can do something clever. Instead of testing each value in turn, it uses the value as an index into a table of addresses and jumps directly to the right one. Here is a switch over five consecutive cases:

int dayKind(int day)
{
    switch (day)
    {
        case 0: return 10;
        case 1: return 20;
        case 2: return 30;
        case 3: return 40;
        case 4: return 50;
        default: return 0;
    }
}

At -O0:

dayKind(int):
    push rbp
    mov  rbp, rsp
    mov  DWORD PTR -4[rbp], edi
    cmp  DWORD PTR -4[rbp], 4
    ja   .L2
    mov  eax, DWORD PTR -4[rbp]
    lea  rdx, 0[0+rax*4]
    lea  rax, .L4[rip]
    mov  eax, DWORD PTR [rdx+rax]
    cdqe
    lea  rdx, .L4[rip]
    add  rax, rdx
    jmp  rax
.L4:
.L8:
    mov  eax, 10
    jmp  .L9
.L7:
    mov  eax, 20
    jmp  .L9
.L6:
    mov  eax, 30
    jmp  .L9
.L5:
    mov  eax, 40
    jmp  .L9
.L3:
    mov  eax, 50
    jmp  .L9
.L2:
    mov  eax, 0
.L9:
    pop  rbp
    ret

This looks dense, but it is just three phases.

1. The bounds check. cmp DWORD PTR -4[rbp], 4 followed by ja .L2 checks whether day is within range. .L2 is the default case. Notice the jump is ja ("above"), from the unsigned family: by treating day as unsigned, a single comparison catches both day > 4 and day < 0 at once, because a negative int reinterpreted as unsigned becomes a huge number that is also "above" 4. This is a recurring compiler trick.

2. The table lookup. If day is in range, the next few lines compute an address. lea rax, .L4[rip] loads the address of the table .L4. The value day (in rax) is scaled and used to read an entry out of that table, which is then added back to the table's base. The exact arithmetic is fiddly, but the intent is simple: turn day into the address of the matching case.

3. The indirect jump. jmp rax is the payoff. This is an indirect jump: instead of a fixed label, it jumps to whatever address is held in rax. That address came from the table, so day == 2 lands directly on the case 2 code with no per-case comparisons at all.

The block at .L4: is the jump table itself, a list of code addresses. Each case body (.L8, .L7, ...) sets eax and jumps to the common exit .L9. The standout instruction to recognise is jmp rax: a jump whose target is a register, not a label, almost always means a jump table is in play.

Sparse Cases Become a Chain of Compares

A jump table only pays off when the cases are dense, because the table must have a slot for every value in the range. If the cases are far apart, say 1, 50, and 999, a table covering 1 through 999 would be almost entirely wasted entries. So the compiler falls back to the obvious approach: compare against each case one at a time.

int pick(int code)
{
    switch (code)
    {
        case 1: return 100;
        case 50: return 200;
        case 999: return 300;
        default: return 0;
    }
}
pick(int):
    push rbp
    mov  rbp, rsp
    mov  DWORD PTR -4[rbp], edi
    cmp  DWORD PTR -4[rbp], 999
    je   .L2
    cmp  DWORD PTR -4[rbp], 999
    jg   .L3
    cmp  DWORD PTR -4[rbp], 1
    je   .L4
    cmp  DWORD PTR -4[rbp], 50
    je   .L5
    jmp  .L3
.L4:
    mov  eax, 100
    jmp  .L6
.L5:
    mov  eax, 200
    jmp  .L6
.L2:
    mov  eax, 300
    jmp  .L6
.L3:
    mov  eax, 0
.L6:
    pop  rbp
    ret

There is no table and no jmp rax here. Instead it is a sequence of cmp/je pairs, one per case: compare code against 999, against 1, against 50, jumping to the matching body when equal, and falling through to .L3 (the default) when nothing matches. This is exactly what a hand-written chain of if/else if would produce, which is the point: a sparse switch is just an if/else if chain wearing different syntax.

Telling the Two Apart

When you open a switch in assembly, one glance settles which strategy the compiler used:

  • See a jmp to a register (jmp rax) and a block of addresses? That is a jump table: dense cases, constant-time dispatch regardless of how many there are.
  • See a run of cmp/je pairs? That is a compare chain: sparse or few cases, checked one by one.

The trade-off is the same one a programmer would weigh by hand. A jump table reaches any case in the same few instructions but needs contiguous values; a compare chain handles any values but gets slower as cases pile up. The compiler measures the density of your cases and picks for you, and now you can read its decision straight off the listing.

Key Takeaways

  • A switch compiles to either a jump table or a chain of compares, chosen by how dense the case values are.
  • A jump table dispatches in constant time via an indirect jump (jmp rax) through a table of code addresses; the giveaway is a jump whose target is a register.
  • The bounds check before a jump table often uses an unsigned jump (ja) to catch both too-high and negative values with one comparison.
  • Sparse or few cases compile to a compare chain of cmp/je pairs, identical to an if/else if ladder.
  • Recognising jmp rax plus an address block (table) versus repeated cmp/je (chain) tells you instantly which strategy is in use.