Profile-Guided Optimization
Compile once with counters, run a realistic workload, feed the counts back, compile again. The optimizer stops guessing which branch is taken and which function is hot — and the largest real win is usually not what people expect.
How does a compiler find out which paths are hot, and what does it do differently once it knows?
The program plus an *edge profile*: a count attached to every control-flow edge and every function entry, from which block frequencies and call counts are derived. This is the first representation in the pipeline that contains a fact about execution rather than a fact about the program text — everything the optimizer knew until now was true of all possible runs, and this is true of some particular ones.
A profile is evidence about frequency, never a proof about possibility, so it may only be used to *choose between* transformations that are already legal without it — which function to inline, where to place a block, which value to keep in a register. It may never license a transformation that would be wrong on an input the profile did not cover. Deleting a branch because the profile never took it is illegal in a static compiler for exactly this reason: the untaken path is still reachable, and removing it changes behavior for an input the workload did not exercise. Acting on a profile as though it were a proof requires a runtime guard and an undo path, which is what a JIT has and an ahead-of-time compiler does not.
Key points
- The loop is instrument, run a realistic workload, merge the counts, recompile — and the profile is evidence about frequency, never proof about possibility.
- A profile may only choose among transformations that are already legal; it may not license one that would be wrong on an unexercised input.
- What changes: inlining budgets, block and function layout, branch arrangement, register allocation and spill placement, unroll thresholds and switch lowering.
- Block layout and instruction-cache locality is usually the largest real win, not branch prediction, because modern predictors already learn the branches a profile would describe.
- Cold code moved out of line makes hot cache lines and iTLB entries carry more useful instructions, which is a front-end effect no predictor can address.
- PGO frequently improves speed and size at once, because cold call sites stop being inlined while hot ones inline harder.
The loop
The classic instrumented form is four steps and one non-negotiable requirement. The requirement is that the workload in step two resembles production, because everything downstream is a faithful optimization of whatever you actually ran.
Step one compiles with counters inserted: at function entries and at control-flow edges, enough to reconstruct every block frequency by solving the flow equations rather than by counting every block directly. Step two runs that binary — slowly, because the counters cost real time — against a workload. Step three merges the raw counter files into a single profile, which is a separate step because a realistic profile is usually the sum of many runs. Step four recompiles from the original sources, feeding the profile in, and the optimizer now annotates every block and edge with a frequency before it makes a single decision.
Note what does not change: the source, the language semantics, and the set of legal transformations. What changes is the ordering of decisions that were always available, and the budgets attached to them.
- Instrumented buildbuild timeThe program plus counter increments on entries and edges.The ability to observe frequency at all.Speed — the instrumented binary is substantially slower and is not shippable.
- Training runrun timeA running process writing raw counter files.Evidence about one particular workload.Nothing yet, but everything not exercised is now invisible.
- Mergebuild timeOne profile: counts per function, per edge, indexed by a hash of each function's structure.A single artifact combining many runs.Which run contributed what — a bimodal workload becomes an average of two behaviours.
- Optimizing rebuildbuild timeIR with block frequencies attached, from the original sources.Frequency-aware inlining, layout, hinting and allocation priorities.Correspondence with the profile if the source has drifted since it was collected.
- Shipped binarybuild timeMachine code laid out around the profiled hot path.Dense, contiguous hot code and cold code moved out of the way.Any advantage at all for a workload unlike the training one.
Read it asThe only stage that touches the real world is the training run, and every later stage is a faithful amplification of it. That is why the quality of the profile dominates every other consideration in this lesson — the mechanism has no way to be better than its evidence.
What the compiler actually does differently
Four decisions change, and it is worth ranking them by how much they matter in practice, because the popular ranking is wrong.
Block layout is usually the biggest win. With frequencies known, the compiler orders basic blocks so that the hot path falls through contiguously and cold blocks — error handling, rare branches, logging, the slow path of an inline cache — are moved out of line, sometimes into a separate .text.unlikely section. The effect is on the instruction cache and the front end: hot code becomes dense, so a cache line and an iTLB entry carry more useful instructions and fewer never-executed ones. On a large binary with a diffuse hot path, this is where most of the measured gain lives.
Inlining decisions change. A call executed a billion times justifies inlining a body that a call executed twice does not, and inlining is the transformation that exposes constants and types for everything else. Frequency turns a size heuristic into a cost-benefit calculation, and it works in both directions: cold callers stop inlining and the binary gets smaller where it does not matter.
Branch hints and layout of conditionals. The compiler arranges the condition so the common outcome is the fall-through, and on targets with static hint encodings it can emit them. This matters far less than it sounds — see below.
Register allocation priorities. When values compete for registers, the one live across the hot loop wins and the one live only on the cold path spills. Spill placement moves into cold blocks, so the cost of spilling is paid on paths that rarely execute — see [[spilling]].
Secondary effects follow from the same information: loop unrolling thresholds respond to trip counts, switch lowering picks a jump table or a comparison chain based on case frequency, and function ordering in the binary places callers next to callees so they share pages.
- Block layout and function ordering — hot code contiguous, cold code out of line. Instruction-cache and iTLB locality. Usually the largest real effect.
- Inlining — frequency-weighted budgets, so hot call sites inline aggressively and cold ones stop, which improves speed and size simultaneously.
- Branch arrangement and hints — the common outcome becomes the fall-through.
- Register allocation and spill placement — hot values win registers; spills are pushed onto cold paths.
- Loop and switch shaping — unroll thresholds from trip counts, jump table versus comparison chain from case frequency.
Why layout beats branch prediction
The intuitive story is that PGO tells the CPU which way branches go. That story is largely obsolete, and understanding why is the most useful thing in this lesson.
Modern branch predictors are dynamic, history-based and very accurate on the predictable branches a static hint would have described — a branch that a profile says goes one way 99% of the time is a branch the hardware learns in a handful of executions and then predicts correctly on its own. Some architectures removed static hint encodings entirely because they were not earning their space. So the compiler telling the processor what a profile said is, on those branches, telling it something it already knows.
The front end is a different matter, because no amount of prediction accuracy fixes a fetch that misses in the instruction cache. Instruction fetch pulls whole cache lines; if a hot loop is interleaved with rarely executed error-handling code, every line fetched carries dead weight, the effective cache is smaller than its size suggests, and the same is true one level up for iTLB coverage. Rearranging blocks and functions so hot code is dense does not make any single branch more predictable — it makes the instructions already be there, which is a cost the predictor cannot address at all.
That is the mechanism behind the whole family of post-link layout tools, and it is why they can win on a binary that was already built with PGO: they are attacking the front end, not the predictor.
loop: r = step() if (r != OK) goto handle ; taken ~0.01% of the time n = n + 1 if (n < limit) goto loop goto done handle: log_error(r) ; sits between the branch and its target cleanup() goto done done:
; .text.hot loop: r = step() if (r != OK) goto handle ; forward branch out of line n = n + 1 if (n < limit) goto loop ; hot path is now contiguous done: ; .text.unlikely — a different part of the binary entirely handle: log_error(r) cleanup() goto done
Reordering basic blocks preserves observable behavior for any input, because the control-flow graph is unchanged — only the addresses are. The profile is used purely to choose among orderings that are all correct, which is why layout is the safest possible use of profile data.
The rewrite stops being a reordering and starts being a removal. Deleting the handle block because the profile never reached it changes what the program does for an input the training workload did not cover. A static compiler may not do that; a JIT may, and only because it installs a guard and can deoptimize back — see [[guards]] and [[deoptimization]].
How it works
The steps, in the order the compiler takes them.
- The compiler inserts counters at function entries and on control-flow edges, chosen so every block frequency can be reconstructed from the flow equations rather than counted directly.
- The instrumented binary runs against a workload and writes raw counter files, one or many.
- A merge tool combines the raw files into a single profile, keyed by function and by a hash of each function's control-flow structure.
- The optimizing rebuild reads the profile and attaches a frequency to every block and edge before any decision is made.
- Inlining runs with frequency-weighted budgets, so hot call sites get much larger allowances than cold ones.
- Block placement orders the hot path for fall-through and moves cold blocks into a separate section; function ordering places callers near callees.
- Register allocation prioritises values live on hot paths and places spill code in cold blocks.
- A function whose structure hash no longer matches the profile is compiled without profile data, silently, because the counts can no longer be attributed to its blocks.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- The profile is collected from a benchmark rather than production, and the shipped binary is faster on the benchmark and unchanged or slower on the real workload.
- A function is edited after the profile was collected, its structure hash stops matching, and it is quietly compiled with no profile at all — no error, just a hot function that lost its optimization.
- The instrumented binary is two to three times slower, a load test built around it times out, and the profile ends up describing a degenerate run rather than a realistic one.
- Counter updates in a heavily multithreaded program contend on shared cache lines, distorting the very timing the profile is meant to capture.
- The build becomes two-phase, a CI job runs the second phase without the first, and a release ships with no profile applied while everyone believes PGO is on.
- A binary with
.text.unlikelypopulated confuses a symbolication or coverage tool that assumed one contiguous text section.
When it helps
- Large binaries with a diffuse hot path — servers, browsers, compilers, databases — where instruction-cache locality is a first-order cost and layout has the most to fix.
- Programs with a stable, well-understood workload that a training run can genuinely represent.
- Code with abundant cold paths interleaved into hot ones: error handling, assertions, logging, rare fallbacks.
- Interpreters and dispatch loops, where the hot path is small, extremely hot, and surrounded by rarely taken cases.
- Situations where binary size matters as well as speed, since frequency-guided inlining improves both.
When it hurts
- Small compute kernels that already fit in instruction cache, where there is no layout win and the machinery is not worth the build complexity.
- Programs with no representative workload — a general-purpose library used differently by every consumer, or a tool whose inputs vary wildly.
- Rapidly changing code, where the profile is stale before it is useful and most functions silently fall back to no profile.
- Teams without the CI capacity to run the two-phase build reliably, where the failure mode is a release that silently skipped the profile.
What it costs
Every one of these is paid by something.
- A profile buys frequency-aware decisions and pays with a two-phase build: two compiles, a training run in between, and a pipeline that can fail in the middle.
- Instrumentation buys exact counts and pays in a binary too slow to ship and, on threaded programs, in counter contention that perturbs what is being measured.
- Hot/cold splitting buys instruction-cache density and pays in a fragmented text section that complicates symbolication, coverage tooling and anything assuming contiguity.
- Frequency-guided inlining buys speed on hot paths and pays in code size there, offset by savings on cold paths — a trade that has to be measured rather than assumed.
- The whole mechanism buys performance and pays in reproducibility: the artifact now depends on a training run, which is an input that no source control system holds — see
[[reproducible-compilation]].
What else you could do
What a different compiler or language does instead, and when that is better.
- Sampling-based PGO — AutoFDO and its relatives — collects a profile from an ordinary optimized binary running in production, at very low overhead, avoiding the instrumented build entirely. The profile is coarser; the trade is the subject of
[[pgo-tradeoffs]]. - Post-link optimizers such as BOLT and Propeller apply layout to an already-linked binary using samples. They capture most of the layout win without a second compile, and are complementary to a PGO build rather than a replacement for it.
- Manual annotations — C++'s
likelyandunlikelyattributes,__builtin_expect, hot and cold function attributes — put a human's belief where a measurement would go. Cheap, never stale in the same way, and frequently wrong. - A JIT collects the profile continuously during the run it is optimizing, and can act on it speculatively because it can undo the assumption —
[[jit-compilation]]. - Ordering functions by a linker script from any source of hotness information, which captures part of the layout benefit with almost no build complexity.
See it for yourself
The flag, dump or tool that shows you this directly.
- Clang:
-fprofile-generateto build instrumented, run the workload,llvm-profdata merge -output=app.profdata *.profraw, then rebuild with-fprofile-use=app.profdata. - GCC:
-fprofile-generatethen-fprofile-use, plus-fprofile-partial-trainingto control how untrained functions are treated. llvm-profdata show --all-functions app.profdataprints per-function counts — the fastest way to see whether the training run reached what you expected.clang -fprofile-use=... -Rpass-missed=inlinereports which hot call sites were still declined and why.nm -S --size-sortandobjdump -hshow whether.text.unlikelyexists and how much of the binary ended up there.perf stat -e L1-icache-load-misses,iTLB-load-missesbefore and after: if PGO helped through layout, this is the counter that moves.
Plausible wrong readings
Stated the way a confident engineer states them.
- "PGO tells the CPU which way branches go." Mostly the hardware already knows. The gain is dominated by where the instructions physically sit, which the predictor cannot help with.
- "The profile lets the compiler remove paths that never run." Never, in a static compiler. An unexecuted path is still reachable; removing it needs a runtime guard, which is a JIT's mechanism.
- "Any profile is better than none." An unrepresentative profile is worse than none, because it optimizes confidently for the wrong path — the subject of the next lesson.
- "PGO makes the binary bigger." It commonly makes it smaller, because cold call sites stop being inlined while hot ones inline harder.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
The compiler does not know which branch your program usually takes or which functions run constantly, so it guesses from heuristics. PGO replaces the guess with a measurement: build a version that counts, run it on realistic work, then rebuild using the counts. The compiler then inlines what is actually hot and physically arranges the binary so the code that runs constantly sits together.
practical
Get the workload right; nothing else in the mechanism matters as much. Collect from something that resembles production — a load test with the right mix, not a microbenchmark. Check llvm-profdata show to confirm the training run actually reached the code you care about. Expect the instrumented binary to be badly slow and plan the training run around that. Measure both speed and binary size afterwards, and if you want to know whether the win came from layout, watch instruction-cache miss counters rather than branch mispredictions. And build the two-phase pipeline so that a missing profile fails the build rather than silently producing an unoptimized release.
advanced
The design tension is that a profile is per-function evidence being applied to a program whose source has moved. Profiles are keyed by a hash of each function's control-flow structure precisely so that a stale profile is *dropped* rather than misapplied — attributing counts to the wrong blocks would be worse than having none. That makes staleness a silent, per-function degradation rather than a build error, which is why large deployments treat profile freshness as an operational metric and not a build artifact. It also explains why sampling-based approaches are attractive despite being coarser: a profile continuously harvested from production is never more than a day stale, and staleness turns out to cost more than resolution does. The generalisation is worth holding onto — in feedback-directed systems, the recency of the evidence usually dominates its precision.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
-fprofile-generate/-fprofile-use with llvm-profdata, GCC uses .gcda files and its own semantics for untrained functions, MSVC uses /GENPROFILE and /USEPROFILE, and Go's PGO consumes a pprof CPU profile placed in the package directory. A profile produced by one toolchain is meaningless to another, and formats change across major versions of the same one.If you were asked this in an interview
- Walk me through the PGO build loop, and say what makes it a legal use of a profile.
- PGO improved your binary by 12%. Which of the transformations it enabled most likely produced that, and how would you confirm it?
- Why can a JIT delete a branch a profile never took, and a static compiler cannot?
Connections
- Programming Languages & Runtime Internals — Continuous in-process profiling and the runtime structures a JIT keeps to act on itPGO stops where the runtime begins: it collects evidence beforehand and can only choose among legal transformations. A runtime that profiles continuously can also speculate, because it owns the guard and the undo path, and those structures are that domain's subject.