Twelve small functions, each a C++ idiom that people argue about in performance reviews. The build emits their assembly at -O0, -O2 and -O2 -march=native; this header shows the -O2 output so you can see what the compiler actually did. The skill: open the .s file, find the function (`awk '/^_Z.../,/\.Lfunc_end/'`), ignore .cfi/.p2align noise, read.
$ git clone https://github.com/adit4443ya/debug_labs.git && cd debug_labs
$ (cd codegen && ./build.sh) -> bin/01_idioms.O2.s, .O0.s, .native.s
$ (-fno-asynchronous-unwind-tables removes the .cfi_* directives so the
$ output is readable; it changes nothing about the instructions.)
$
$ WHAT -O2 EMITTED (from bin/01_idioms.O2.s, AT&T syntax, x86-64 baseline):
$
$ min_branch(a,b) = a<b ? a : b -> NO branch: a conditional move
$ movl %esi, %eax
$ cmpl %esi, %edi
$ cmovll %edi, %eax
$ retq
$ (At -O0 the same function is 15 instructions with a jge and two spills.
$ "Branchless min" hand-tricks are re-inventing this; lab 04 measures.)
$
$ div10(x) = x / 10 -> multiply by the magic constant, shift
$ movl %edi, %ecx
$ movl $3435973837, %eax # 0xCCCCCCCD = ceil(2^35 / 10)
$ imulq %rcx, %rax
$ shrq $35, %rax
$ Division by a constant never divides. A 3-cycle multiply replaces a
$ ~20-cycle div. Division by a VARIABLE stays a div: hoist it.
$
$ is_pow2(x) = x && !(x & (x-1)) -> bit tricks; with -march=native: popcnt
$ testl %edi, %edi ; je .LBB2_1 ; leal -1(%rdi),%eax ; testl %eax,%edi ; sete %al
$ native: popcntl %edi, %eax ; cmpl $1, %eax ; sete %al
$
$ sum_vec(const vector<int>&) -> vectorized (SSE2) loop, 4 ints per step
$ movq (%rsi,%rax,4), %xmm2 ; movq 8(%rsi,%rax,4), %xmm3
$ pcmpgtd %xmm2, %xmm4 ; paddq %xmm2, %xmm0 ; ... ; addq $4, %rax ; cmpq %rax, %r8
$ The range-for over a vector is two pointer loads (begin/end) and a
$ plain loop; no abstraction survives to the machine code.
$
$ zero(p, n): for i: p[i] = 0 -> jmp memset@PLT (loop idiom recognized)
$ testq %rsi, %rsi ; je ; movq %rsi,%rdx ; xorl %esi,%esi ; jmp memset@PLT
$ Your loop became a library call (a tail call, even). This is why
$ "hand-written memset loops are faster" claims are always wrong at -O2.
$
$ call_virtual(Base&, x) -> load vptr, load slot 0, indirect jump
$ movq (%rdi), %rax ; movq (%rax), %rax ; jmpq *%rax
$ Two dependent loads then an indirect branch (predicted by the BTB).
$ The cost that matters is the missed INLINING of the callee, not these
$ three instructions (HFT module, "virtual calls").
$
$ call_std_function(const std::function&, x) -> null check + indirect call
$ cmpq $0, 16(%rdi) ; je .throw ; leaq 4(%rsp),%rsi ; callq *24(%rdi)
$ std::function costs an indirect call through its invoker plus a check,
$ and the argument is passed by ADDRESS (spilled to the stack: movl %esi,
$ 4(%rsp)). Prefer templates or function_ref in hot paths.
$
$ call_lambda(x): lambda(y){ y*3 }(x) -> leal (%rdi,%rdi,2), %eax
$ The lambda vanished. Zero cost, fully inlined, x*3 as one lea.
$
$ switch_dense(k) 0..7 -> a table LOAD, no jump table needed
$ cmpl $7, %edi ; ja default ; movl .Lswitch.table(,%rax,4), %eax
$ Because every case returns a constant, clang built a lookup table of
$ RESULTS. The "replace switch with a table" advice is done for you.
$
$ switch_sparse(k) {1,100,10000,1000000} -> a compare tree (binary search)
$ cmpl $9999, %edi ; jg ; cmpl $1, %eax ; je ; cmpl $100, %eax ; ...
$ Sparse cases can't be a table; you get log2(n) compares. Dense enums
$ starting at 0 are the cheap kind.
For each function: predict the instructions BEFORE you look. Then look. Then compare -O2 with -O2 -march=native (popcnt, AVX2 in sum_vec) and with -O0 (everything through memory). The habit this builds, reading the asm for the 20 lines that matter, is what separates "I think the compiler will..." from "it emits...". Use tools/asmdiff.sh for any function under any two flag sets.
1. Add `int64_t mul_add(int64_t a, int64_t b, int64_t c) { return a*b+c; }` and check for a single `imul` + `add` (x86 has no integer FMA). Then the same for double with -march=native: vfmadd. 2. Add a function that returns a std::string by value; read the SSO handling. Then std::string_view: two registers. 3. Give Base::f a `final` override in a derived class and call through a `Derived&`: the virtual call becomes direct (devirtualization). ============================================================
#include <cstddef>
#include <cstring>
#include <functional>
#include <vector>
int min_branch(int a, int b) { return a < b ? a : b; } // -> cmov, no branch
unsigned div10(unsigned x) { return x / 10; } // -> multiply by a magic constant
bool is_pow2(unsigned x) { return x && !(x & (x - 1)); } // -> a few bit ops (popcnt with -march=native)
long sum_vec(const std::vector<int> &v) { long s = 0; for (int x : v) s += x; return s; } // -> vectorized loop
void zero(char *p, size_t n) { for (size_t i = 0; i < n; i++) p[i] = 0; } // -> call memset (loop idiom recognized)
struct Base { virtual int f(int x) = 0; virtual ~Base() = default; };
int call_virtual(Base &b, int x) { return b.f(x); } // -> load vptr, load slot, indirect jump
int call_std_function(const std::function<int(int)> &f, int x) { return f(x); } // -> check, indirect call through the invoker
int call_lambda(int x) { auto triple = [](int y) { return y * 3; }; return triple(x); } // -> lea (inlined away)
int switch_dense(int k) { // -> result table
switch (k) { case 0: return 10; case 1: return 22; case 2: return 35; case 3: return 41;
case 4: return 57; case 5: return 63; case 6: return 78; case 7: return 84; default: return -1; }
}
int switch_sparse(int k) { // -> compare tree
switch (k) { case 1: return 1; case 100: return 2; case 10000: return 3; case 1000000: return 4; default: return 0; }
}A lab is done when you can say what it showed on your machine — not when you have read it.
It decides which problems come first, which drills open, which labs you see and how the Learn surface is organised. Change it any time from the switch in the top bar, or press Escape to look around first.