· 4 min read
The call was cheaper
A controlled inline benchmark shows how disassembly and code size can explain a performance result that call-count intuition gets wrong.

Force a hot function inline at all 1,024 of its call sites and the program gets slower. Not subtly slower — the forced-inline build in this fixture took 288.6 milliseconds against 200.9 for the build that kept the function out of line, same source, same flags, same checksum, alternating on one pinned CPU. The version that removed thirty million calls lost by 44 percent.
What I like about this result is how the argument ends. You can trade intuitions about call overhead all afternoon, but the generated program is sitting right there, willing to testify: one wrapper's disassembly, one text-section number, and a fixture small enough that you can rerun it and tell me I'm wrong.
Hold the work still#
The fixture wraps one integer transform in 1,024 template instantiations, then hops between them through a deterministic table permutation, 30 million dispatches per run. One binary forces the transform inline; the other forbids it. Everything else stays fixed.
template <std::size_t Kind>__attribute__((noinline))std::uint64_t invoke_transform(std::uint64_t value, std::uint64_t key) { return transform(value, key, Kind);}constexpr auto transformTable = make_transform_table(std::make_index_sequence<1024>{});for (std::uint64_t index = 0; index < 30'000'000; ++index) { const auto slot = (index * 2654435761U) & 1023; checksum ^= transformTable[slot](checksum + index, index | 1);}The recorded run used Clang 22.1.6 with -O3 -march=native and no LTO, on an AMD Ryzen 7 7800X3D running Linux x86-64 — each binary warmed once, then seven alternating samples pinned to logical CPU zero. The full fixture and the raw nanosecond samples live in the repository's benchmark directory, so none of the numbers below have to be taken on faith.
| Observation | Forced inline | Outlined |
|---|---|---|
| Median of seven runs | 288.6 ms | 200.9 ms |
| Executable text | 153,275 bytes | 71,499 bytes |
| Checksum | 5086929079838441426 | 5086929079838441426 |
Each guardrail exists to close an escape route. The checksum rejects a fast wrong program, alternation smooths out temperature and background drift, and the disclosed flags keep a generic build from impersonating the recorded one. Even so, this establishes what happened on one processor with one compiler — nothing grander.
Ask the binary what changed#
Counting calls at the source level, the inlined build should have won. The disassembly points at a different suspect. Wrapper 513 in the forced build carries the transform's entire arithmetic, constants specialized for its template argument; the outlined wrapper is two instructions long.
<invoke_transform<513>>: xorq %rsi, %rdi imulq %rdi, %rax rorxq $0x33, %rax, %rax addq $0x201, %rax # twelve more instructions from transform retq<invoke_transform<513>>: movl $0x201, %edx jmp <transform>So specialization saved some argument shuffling and paid for it by pasting nearly the same instruction sequence into every one of the 1,024 wrappers. GNU size puts the bill at 153,275 text bytes against 71,499. The dispatch table deliberately bounces between distant wrappers, which asks the instruction side of the processor to keep twice the code warm — and that, on this machine, is where the 88 milliseconds went.
The toolchains already hedge here, if you read them closely. LLVM's optimization remarks will happily report that a call was inlined and why, without ever promising the program got faster, and the Rust reference says outright that every form of #[inline] is a hint and poor choices can slow a program down. The people who built the attribute do not trust it unsupervised. Why would you?
Keep the claim inside the fixture#
To be fair to inlining: this benchmark was built to punish duplicated code, and it says nothing about the tiny accessor in one hot loop, where inlining exposes constant folding and deletes far more work than it adds. Reduce the wrapper count, reorder the dispatch, enable LTO, or move to another processor and the gap may shrink or flip. I would expect it to.
That fragility is exactly why the rerun kit matters: source, compiler version, flags, warm-up and sample order, CPU placement, checksum, and the two artifacts the explanation leaned on — code size and disassembly. Miss one and "inlining regressed performance" is a story with too many exits.
clang++ main.cpp -std=c++20 -O3 -march=native -DINLINE_VARIANT=1 -o inlineclang++ main.cpp -std=c++20 -O3 -march=native -DINLINE_VARIANT=0 -o outlinedsize inline outlinedllvm-objdump --demangle --no-show-raw-insn -d inlineThe two instructions that settle the experiment belong to the winning build. In the smaller binary, invoke_transform<513> loads 0x201 and jumps to transform, and that jump survived because, for this table and this machine, its five bytes were cheaper than another copy of the answer.