16 July 20264 min read #optimization #meson #clang #pgo #lto #cpp #build-systems

How to Use PGO and LTO With Meson and Clang

A tested walkthrough for enabling Profile Guided Optimization and Link Time Optimization in a Meson project built with Clang, including the llvm-profdata merge step most guides forget.

I covered why PGO and LTO barely moved the needle on my SPH simulation in part 2 of my optimization series. This post skips the “does it actually help” question and just gives you the commands, because half the guides I found online gloss over a step that will make your build fail with a cryptic clang error.

The short version #

PGO (Profile Guided Optimization) runs an instrumented build of your program on real input, records which branches and functions actually get hit, then feeds that back into a second build so the compiler can lay out hot/cold paths and inline based on evidence instead of guesses.

LTO (Link Time Optimization) lets the compiler optimize across translation units instead of one .cpp file at a time, so it can inline across files, drop dead code, and see through calls that would otherwise be opaque at the object-file level.

They’re orthogonal. You can use either alone, or stack both for the final shipping build.

Prerequisites #

  • Clang. I’m using clang 22 here, but anything from the last several years works fine.
  • Meson 0.57 or newer if you want thin LTO (b_lto_mode).
  • lld is not mandatory, but it makes thin LTO caching simpler. GNU ld and gold both support LTO through the plugin interface, it’s just more moving parts.

Enabling LTO #

text
meson setup build --buildtype=release -Db_lto=true
meson compile -C build

That’s full LTO: every translation unit gets bundled into one giant module at link time. It’s thorough, and it’s slow, both in link time and in RAM used by the linker. On a big project this can hurt.

Thin LTO fixes that by keeping per-module optimization but sharing summaries across modules, so the linker can parallelize the codegen step:

text
meson setup build --buildtype=release -Db_lto=true -Db_lto_mode=thin -Db_lto_threads=8
meson compile -C build

If you want to confirm meson is actually passing the flag through and not silently ignoring it, -v your ninja build:

text
ninja -C build -v | grep -o '\-flto[^ ]*'
-flto=thin

Want lld specifically? Set it before meson setup, not as a -D option:

text
CXX_LD=lld meson setup build --buildtype=release -Db_lto=true -Db_lto_mode=thin

Enabling PGO #

This is the two-pass part.

Pass 1: instrument and run #

text
meson setup build-pgo --buildtype=release -Db_pgo=generate
meson compile -C build-pgo
./build-pgo/your-binary --some-representative-workload

Running the instrumented binary drops a file next to wherever you ran it from, named something like default_15853201381332895877_0.profraw. Not in the build directory. Wherever your current shell was when you launched the binary.

Pass 2: use the profiled data #

Meson does not merge the profile for you. There is no profdata or profraw string anywhere in mesonbuild’s source. If you just reconfigure with -Db_pgo=use and compile, you get this:

text
clang: error: Error in reading profile default.profdata: No such file or directory
ninja: build stopped: subcommand failed.

Clang wants an already-merged, indexed default.profdata, and it looks for it in its own working directory, which for the ninja backend is the root of your build directory. So merge it there yourself:

text
llvm-profdata merge -output=build-pgo/default.profdata *.profraw

Then reconfigure and rebuild:

text
meson configure build-pgo -Db_pgo=use
meson compile -C build-pgo

If you have several representative workloads, run the instrumented binary once per workload and hand all the resulting .profraw files to the same llvm-profdata merge call. More coverage, one profile.

Never ship the instrumented build. -Db_pgo=generate binaries pay for counters on every branch and function call, and that’s not a rounding error.

Both at once #

For a real release build, generate the profile on a plain optimized build first (faster iteration, and LTO doesn’t help you find hot paths, it just acts on them), then do the final build with both flags together:

text
meson configure build-pgo -Db_pgo=use -Db_lto=true -Db_lto_mode=thin
meson compile -C build-pgo

Caveats #

  • Stale profiles. If you change the code enough that a function’s control flow no longer matches what was profiled, clang detects the hash mismatch and quietly drops the profile for that function instead of failing the build. Convenient, but it means a profile can go stale without telling you.
  • Compiler version drift. The raw profile format is tied to the clang version that produced it. Generate with one clang and try to use it with another, and you’ll get a version mismatch instead of a build.
  • Full LTO memory usage. If your linker starts swapping on a large project, that’s full LTO holding the entire program in memory at once. Switch to b_lto_mode=thin before you throw more RAM at it.
  • The merge step, again. I’m repeating this because it’s the one that actually stops your build: no merged default.profdata in the build directory means no -Db_pgo=use build.

If you’re wondering whether any of this is worth the extra build complexity for your workload, that’s the actual subject of part 2: on a branch-light numerical loop, it bought me about 3%. Your mileage will vary with how branchy your hot path actually is.

More posts

Part 2: Profiling, PGO & LTO

Measuring PGO and LTO on a C++ SPH simulation with perf and flamegraphs, and why they barely helped.

7 min