FForge
← Labscodegen-01Code generation●○○○

What common C++ idioms compile to at -O2 (read, don't guess)

codegen/01_idioms.cppneeds: clang++, the .s files from build.sh
what this lab teaches

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.

evidence to leave with
Twelve asm excerpts predicted then verified; zero() became jmp memset; dense switch became a result table
first, get the code
run this
$ git clone https://github.com/adit4443ya/debug_labs.git && cd debug_labs
then build it
run this
$ (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.
how to use this lab

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.

take it further

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). ============================================================

read the source · codegen/01_idioms.cpp
#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; } }
◇
Finish the lab: write the evidence note

A lab is done when you can say what it showed on your machine — not when you have read it.