GCC Link Time Optimization: When Thin LTO Pays Off and When It Doesn't
Thin LTO gives most whole-program optimization benefits with manageable link-time cost. Enable -flto=thin -O2 on both compile and link lines, install the linker plugin, audit for latent UB, and expect 3-7% speedup on cross-file hot paths.
19 Feb 2026, 19:32 UTC

The problem: optimization stops at file boundaries
You compile with -O2, maybe -O3, and the compiler does a solid job — within each translation unit. But the moment a hot function lives in utils.c and gets called from main.c, the optimizer sees only a function call. It can't inline, can't propagate constants across the boundary, can't eliminate dead code that only becomes dead after inlining. That's the ceiling of per-TU compilation.
Link Time Optimization (LTO) lifts that ceiling by deferring the backend optimization until link time, when the whole program is visible. The useful takeaway: thin LTO (-flto=thin) gives most of the whole-program benefit with a manageable link-time cost, but it only pays off when your hot paths cross file boundaries and your build system can feed consistent flags to every object.
How thin LTO differs from full LTO
Full LTO (-flto) hands the linker a single GIMPLE stream per object and runs one monolithic optimization pass over the union. Memory and wall time grow roughly with program size — fine for small projects, painful for anything with hundreds of translation units.
Thin LTO (-flto=thin, GCC 10+) partitions the call graph into modules, optimizes each partition in parallel, and stitches results together. The linker invokes multiple compiler backends concurrently. On a 16-core machine you'll see near-linear scaling; on a 2-core CI runner you'll want -flto-jobs=2 to avoid OOM kills. GCC 13 added -flto=auto to let the driver pick the mode based on estimated program size, but explicit thin remains the predictable choice for CI.
Flags that actually work
Add -flto=thin -O2 (or -O3) to both compile and link flags:
CFLAGS="-O2 -flto=thin"
CXXFLAGS="-O2 -flto=thin"
LDFLAGS="-flto=thin"
Missing -flto on the link line is the most common silent failure — the linker emits a non-LTO binary and you get zero cross-TU optimization. The linker plugin (liblto_plugin.so) must be discoverable; on Debian/Ubuntu install gcc-13-plugin-dev (or matching version), on Fedora gcc-plugin-devel. If you're cross-compiling, pass -fuse-linker-plugin or set -plugin /path/to/liblto_plugin.so explicitly.
Worked example: two-file inlining visible in the binary
Create math.h:
static inline int square(int x) { return x * x; }
int sum_of_squares(int n);
math.c:
#include "math.h"
int sum_of_squares(int n) {
int sum = 0;
for (int i = 1; i <= n; ++i) sum += square(i);
return sum;
}
main.c:
#include "math.h"
#include <stdio.h>
int main() { printf("%d\n", sum_of_squares(100)); return 0; }
Build twice:
# Without LTO
gcc -O2 -c math.c main.c
gcc -O2 math.o main.o -o no-lto
# With thin LTO
gcc -O2 -flto=thin -c math.c main.c
gcc -O2 -flto=thin math.o main.o -o thin-lto
Check the results:
size no-lto thin-lto
nm -C no-lto | grep square
nm -C thin-lto | grep square
objdump -d thin-lto | grep -A5 ":"
Expected outcome: thin-lto shows no square symbol (inlined and dead-stripped), smaller .text section, and the loop body in main contains the multiplied instructions directly. The non-LTO binary retains a call to square.
Trade-offs that bite in practice
- Latent UB surfaces. Strict-aliasing violations that were harmless per-TU can become miscompilations when the optimizer sees both sides. Run
-fsanitize=undefinedon a non-LTO build first. - Static libraries are all-or-nothing. A
.abuilt without-fltocontributes no GIMPLE; the linker falls back to non-LTO for those objects. Rebuild deps or accept partial coverage. - Assembly and linker scripts break. Hand-written
.Sfiles referencing internal symbols may fail when those symbols are inlined or renamed. Test thoroughly. - Debug line tables get fuzzy.
-g -fltoemits DWARF in the final binary, but inlined frames show approximate line numbers. GDB 10+ and LLDB handle it, but don't expect perfect stepping. - CI memory pressure. Thin LTO parallelism scales with cores. On constrained runners, limit jobs:
export MAKEFLAGS="-j2"or pass-flto-jobs=2.
Build-system integration cheat sheet
| System | Configuration |
|---|---|
| CMake ≥ 3.9 | set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE) or target_link_options(foo PRIVATE "-flto=thin") |
| Meson | b_lto = 'thin' in meson.build or --buildtype=release (defaults to thin) |
| Autotools | Manual: CFLAGS="-flto=thin" LDFLAGS="-flto=thin" ./configure |
Always do a clean rebuild when toggling LTO — object file format changes invalidate incremental builds.
How to verify it's actually working
gcc --version≥ 10 (thin LTO support).gcc -flto=thin -c -xc /dev/null -o /dev/nullsucceeds.- Verbose link:
gcc -flto=thin -O2 a.o b.o -o test -Wl,--verbose 2>&1 | grep liblto_pluginshows plugin loading. - Binary inspection:
readelf --debug-dump=decodedline test | head -30shows DWARF line tables;gdb -batch -ex 'info line main' ./testresolves source lines. - Compare
timeandsizeon a real project (SQLite amalgamation, your own codebase) with and without-flto=thin.
Closing: enable thin LTO by default for release builds
If your project crosses file boundaries in hot paths — and most non-trivial C/C++ codebases do — thin LTO at -O2 is a net win: 3–7% speedup, 5–15% smaller binaries, 1.5–3× link time. The cost is predictable and parallelizable. Add the flags to your release configuration, install the linker plugin package for your distro, run the UBSan audit once, and ship faster binaries without rewriting a line of code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.