Clang, not C: what makes recursive Fibonacci fast

I wrote the same naive recursive Fibonacci in Python, Go, C and x86-64 assembly to see how far apart they are. The interesting part was not "C is fast". It was that C is only fast because clang rewrites my code, and assembly is only fast if I write it with the same trick clang uses.

The function

static int fib(const int n) {
    if (n < 2) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

Exponential on purpose. fib(30) makes 2,692,537 calls, so this is a benchmark of function calls, not of math.

How it started

On my Linux x86-64 box I had fib.c, fib.go and fib.py, all computing fib(30). The first C version looked like this, timed with clock():

static int fib (const int n) {
    if (n == 0) {
        return 0;
    }
    if (n == 1) {
        return 1;
    }
    return fib (n - 1) + fib (n - 2);
}

I built it the lazy way, clang fib.c, no flags:

$ clang fib.c && ./a.out
In C Fibonacci function took 0.005658 seconds to execute
$ go run fib.go
In Go Fibonacci function took 0.003153 seconds to execute
$ python3.13 fib.py
In Python Fibonacci function took 0.05898761749267578 seconds to execute

Go beat C. Almost 2x. Not what "C is the fastest" promises. The only thing I changed next was one flag:

$ clang -O2 fib.c && ./a.out
In C Fibonacci function took 0.003675 seconds to execute
$ go run fib.go
In Go Fibonacci function took 0.003179 seconds to execute

Close to Go now, still not ahead. Then I cleaned up the code: if (n < 2) return n; as the base case, clock_gettime(CLOCK_MONOTONIC) instead of clock(), printing the result so nothing can be thrown away, and a run-all.sh that builds everything the same way:

fib(30) = 832040
In Python Fibonacci function took 0.058293 seconds to execute
fib(30) = 832040
In C Fibonacci function took 0.001640 seconds to execute
fib(30) = 832040
In Go Fibonacci function took 0.003153 seconds to execute

(It took a couple of tries: go build fib.go -o fib-go fails with named files must be .go files: -o, flags go before the file: go build -o fib-go fib.go.)

Now C at -O2 is 2x faster than Go. But careful: the cleanup itself did not make fib faster. I compiled the early version with -O2 and read the assembly. The n == 0 / n == 1 checks become one n < 2 compare and clang emits the same loop as for the clean version (shown below). The call is not removed either, even though the early version ignores the result. The 0.0037 s vs 0.0016 s gap is measurement noise: at fib(30) the whole run is a few milliseconds, so CPU clock ramp-up and scheduling are a big part of the number.

Then I wrote the same function in x86-64 assembly. The first version was the simple approach: plain recursion, two calls per frame, just like the C source:

fib(30) = 832040
In Python Fibonacci function took 0.057940 seconds to execute
fib(30) = 832040
In C Fibonacci function took 0.001678 seconds to execute
fib(30) = 832040
In Go Fibonacci function took 0.003210 seconds to execute
fib(30) = 832040
In Assembly Fibonacci function took 0.002506 seconds to execute

Hand-written assembly lost to C. 0.0025 s vs 0.0017 s. So I looked at what clang -O2 emits, rewrote my assembly with the same trick (more on that below), and ran again:

fib(30) = 832040
In Python Fibonacci function took 0.058673 seconds to execute
fib(30) = 832040
In C Fibonacci function took 0.001619 seconds to execute
fib(30) = 832040
In Go Fibonacci function took 0.003156 seconds to execute
fib(30) = 832040
In Assembly Fibonacci function took 0.001174 seconds to execute

Steady 0.001174 s on every run, now faster than C. Summary, fib(30):

Python 3.13                  0.058  s
C    clang (-O0)             0.0057 s
Go                           0.0032 s
Assembly  simple recursion   0.0025 s
C    clang -O2               0.0016 s
Assembly  clang-style        0.0012 s

Same C source sits both below Go and above it, depending on one compiler flag. Same for assembly: the simple version is slower than C, the clang-style one is faster. So I wanted to know what exactly clang does.

C without optimization

clang fib.c with no flags means -O0. Here is what it produces for fib:

fib:
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $16, %rsp
    movl    %edi, -8(%rbp)        # n goes to the stack
    cmpl    $2, -8(%rbp)          # and is read back from memory
    jge     .LBB1_2
    movl    -8(%rbp), %eax
    movl    %eax, -4(%rbp)
    jmp     .LBB1_3
.LBB1_2:
    movl    -8(%rbp), %edi
    subl    $1, %edi
    callq   fib                   # fib(n - 1)
    movl    %eax, -12(%rbp)       # spill result
    movl    -8(%rbp), %edi
    subl    $2, %edi
    callq   fib                   # fib(n - 2)
    movl    %eax, %ecx
    movl    -12(%rbp), %eax       # reload
    addl    %ecx, %eax
    movl    %eax, -4(%rbp)
.LBB1_3:
    movl    -4(%rbp), %eax
    addq    $16, %rsp
    popq    %rbp
    retq

A literal translation: every variable lives in memory, every use is a load, and there are two calls per frame. That is the C that lost to Go.

What -O2 does

This is fib from my -O2 binary (Debian clang 19.1.7), disassembled:

fib:
    pushq   %r14
    pushq   %rbx
    pushq   %rax
    movl    %edi, %r14d           # n in a register
    xorl    %ebx, %ebx            # acc = 0
    cmpl    $2, %edi
    jge     .LBB1_2
    movl    %r14d, %ecx
    jmp     .LBB1_4
.LBB1_2:
    xorl    %ebx, %ebx
.LBB1_3:                          # loop
    leal    -1(%r14), %edi
    callq   fib                   # acc += fib(n - 1)
    leal    -2(%r14), %ecx
    addl    %eax, %ebx
    cmpl    $3, %r14d
    movl    %ecx, %r14d           # n -= 2
    ja      .LBB1_3
.LBB1_4:
    addl    %ecx, %ebx            # acc + base case
    movl    %ebx, %eax
    addq    $8, %rsp
    popq    %rbx
    popq    %r14
    retq

Registers instead of stack slots, and no frame pointer, are the small wins. The big one: **there is only one call**. Clang turned the second recursive call into a loop with an accumulator. In C it would look like:

int fib(int n) {
    int acc = 0;
    while (n >= 2) {
        acc += fib(n - 1);
        n -= 2;
    }
    return acc + n;
}

fib(n - 2) is no longer a call, it is the next loop iteration. LLVM can do this because + is associative, so the last call can be folded into an accumulator (tail-call elimination with accumulator recursion).

Does assembly save you?

If assembly is "the fastest", hand-written assembly with plain recursion should beat C. It did not: 0.0025 s vs 0.0017 s.

My first approach was the obvious translation. Registers only, no stack spills, no frame pointer, but still two calls per frame:

fib_asm:
    cmpq    $2, %rdi
    jl      1f
    pushq   %rbx
    pushq   %r12
    subq    $8, %rsp              # keep stack 16-byte aligned
    movq    %rdi, %rbx
    leaq    -1(%rdi), %rdi
    call    fib_asm               # fib(n - 1)
    movq    %rax, %r12
    leaq    -2(%rbx), %rdi
    call    fib_asm               # fib(n - 2)
    addq    %r12, %rax
    addq    $8, %rsp
    popq    %r12
    popq    %rbx
    ret
1:
    movq    %rdi, %rax
    ret

The second version uses the same loop + accumulator idea as -O2, plus one extra step: when the next call would be fib(1), it just adds 1 instead of calling.

fib_asm:
    cmpq    $2, %rdi
    jl      1f
    pushq   %rbx
    pushq   %r12
    pushq   %rax
    movq    %rdi, %rbx            # n
    xorl    %r12d, %r12d          # acc = 0
2:
    leaq    -1(%rbx), %rdi
    cmpq    $2, %rdi
    jl      3f                    # fib(1) = 1, skip the call
    call    fib_asm
    addq    %rax, %r12            # acc += fib(n - 1)
    subq    $2, %rbx              # n -= 2
    cmpq    $2, %rbx
    jge     2b
    leaq    (%r12,%rbx), %rax     # acc + n
    jmp     4f
3:
    leaq    1(%r12), %rax         # acc + 1
4:
    popq    %rcx
    popq    %r12
    popq    %rbx
    ret
1:
    movq    %rdi, %rax
    ret

Counting calls

This benchmark is all function calls, so the number of calls explains the results. For fib(30):

naive recursion (C -O0, simple asm)   2,692,537 calls
clang -O2                             1,346,269 calls
clang-style asm                         832,040 calls

Running it

clang fib.c -o fib-c && ./fib-c              # -O0
clang -O2 fib.c -o fib-c && ./fib-c
clang -O2 -S fib.c -o -                      # read what clang did
clang -O2 fib_asm_main.c fib_asm.S -o fib-asm && ./fib-asm
go build -o fib-go fib.go && ./fib-go
python3.13 fib.py

Takeaway

The speed of "C" in language benchmarks is mostly the optimizer. The optimizer is not just picking better instructions, it changes the shape of the program, here halving the number of calls. Assembly gives control, not speed. And the real fix is still an O(n) loop, which makes fib(30) take nanoseconds in every one of these languages.


Disclaimer: all the code and experiments in this post are my own work. I used a Claude agent to help write the blog post itself.

← all posts