Whole Programimplementation

What a Profile Costs You

A profile is not neutral evidence. An unrepresentative one does not fail to help — it actively points the optimizer at the wrong code, and it decays quietly as the source moves underneath it.

The question

What can go wrong with profile-guided optimization, and when is a sampled profile the better choice?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A profile as a dated, workload-specific artifact rather than a fact: counts attributed to functions by a structural hash, gathered from one set of runs, valid for the source revision it was collected against and degrading from there. Naming it this way is the point — treating a profile as a property of the program rather than of a particular execution on a particular day is what produces every failure in this lesson.

What this phase may assume or do

The optimizer may attribute a profile's counts to a function only if the function is the one that was profiled — which is why profiles are keyed by a hash of the function's control-flow structure, and why a mismatch must cause the profile to be dropped rather than approximated. Attributing counts to blocks that have shifted would place hot layout on cold paths and misdirect inlining, so the sound behavior on a stale profile is to compile without one. The wider precondition is unchanged from [[profile-guided-optimization]]: the counts may bias choices among legal transformations and may never license one that would be wrong on an unprofiled input.

Key points

  • An unrepresentative profile is worse than none: it replaces weak unbiased heuristics with confident wrong commitments, and can regress against a plain optimized build.
  • The realistic failure is omission — happy paths only, small datasets, warm caches — not an obviously wrong workload.
  • Merging two dissimilar workloads produces a profile describing neither, and can be worse than optimizing for one of them.
  • Profiles decay silently: a structural hash mismatch makes the compiler correctly drop the profile per function, with no error, on exactly the functions people are editing.
  • PGO makes the build two-phase and inserts a process into what was a pure function of the sources, which breaks hermeticity unless the profile is a versioned, content-addressed input.
  • Sampling-based PGO removes the instrumented build and the training run, at the cost of statistical resolution and attribution that depends on debug information quality.
  • Instrumentation gives precise counts of a possibly wrong run; sampling gives approximate counts of the right one. For production services the second usually wins.

An unrepresentative profile is worse than no profile

Without a profile the optimizer uses heuristics: loops are probably hot, error paths probably are not, small functions are probably worth inlining. These are weak and broadly unbiased — wrong in a diffuse, low-stakes way.

With a profile the optimizer stops hedging. It commits: this function gets a large inlining budget, this block goes in the hot section, this value wins the register, this other code goes to .text.unlikely where fetching it costs a miss. If the profile described the wrong workload, every one of those commitments is confidently wrong, and the cold section now holds code your users execute constantly. The failure is not a missing gain; it is a real regression relative to the heuristic build.

The realistic version of this is rarely a comically wrong workload. It is a training run that omits the paths that matter: profiling only warm-cache requests when production is dominated by cold-start work; profiling with a small dataset so a size-dependent branch never flips; profiling the happy path when a tenth of production traffic is retries and error handling. Each produces a binary optimized for a program adjacent to the one you ship.

The multi-modal case deserves its own mention because merging hides it. A service that spends mornings serving reads and nights running batch jobs has two hot paths; summing their counters produces a profile that describes neither, and the optimizer will lay out an average that is worse for both than optimizing for one would have been.

How the training workload turns into the shipped binarytypical
Training workloadWhat the optimizer concludesWhat production gets
Representative production mixThe real hot pathsThe intended win
A microbenchmark of one functionThat function is the programOne fast function; everything around it laid out as cold
Happy path onlyError handling and retries are coldRetry-heavy traffic executes out-of-line code with poor locality
Small datasetSize-dependent branches always go one wayThe other branch is out of line and it is the one that runs
Two workloads mergedAn average path that neither run tookWorse than optimizing for either one alone
Stale — source has movedNothing, for changed functionsSilent per-function fallback to heuristics, on exactly the functions being edited

Decay, and why it is silent

implementationHow a toolchain handles a partial or stale profile is a real, differing design decision. GCC's -fprofile-partial-training changes whether functions absent from the profile are treated as cold or as unknown, and the two produce materially different binaries. Clang warns on profile mismatch only when asked (-Wprofile-instr-out-of-date, -Wprofile-instr-unprofiled), and both are off in a default build. Assume nothing about the default; check which one you are on.

A profile is collected against one revision of the source. Every edit afterwards moves it further out of correspondence, and the decay is not uniform: it hits precisely the functions people are working on, which are disproportionately the ones being optimized for.

The mechanism that keeps this sound also makes it invisible. Counts are attributed per function via a hash of that function's control-flow structure. Edit the function so its structure changes, and the hash no longer matches, and the compiler correctly declines to apply the counts — because attributing them to shifted blocks would be worse than having none. What it does not do is fail the build. The function is simply compiled with heuristics, and nothing in the output says so unless you ask.

So the operational picture is a slowly widening gap between what the team believes is optimized and what is: the profile is still there, the flags are still on, the build is green, and an increasing fraction of the hot code is being compiled as if PGO were off. Teams that run PGO seriously monitor profile coverage — what fraction of executed functions matched — as a metric, not as a build check.

The build becomes two-phase, and stops being reproducible

The pipeline is no longer source in, binary out. It is: build instrumented, run a workload, merge, build again. That middle step is a *process*, with a runtime, a dataset, an environment and a failure mode, sitting inside what used to be a pure function of the source tree.

The immediate consequence is for [[hermetic-compilation]]: the profile is an input that no source-control system holds and that nothing in the build declares. Two engineers on the same commit get different binaries if they trained differently. A cache key computed over the sources is now incomplete unless the profile is hashed into it — and once it is, regenerating the profile invalidates every artifact at once.

The disciplined answer is to make the profile an explicit, versioned artifact: check it in or store it in an artifact registry, address it by content hash, include that hash in the cache key, and regenerate it on a schedule with a recorded workload description. That restores reproducibility — the same sources plus the same profile give the same binary — without pretending the profile came from nowhere. What it does not restore is [[reproducible-compilation]] in the "anyone can rebuild this from the source tree" sense, because now they also need the profile artifact.

The second consequence is a new class of pipeline failure. A CI job that skips the training step, a workload that crashes halfway, a merge that produces an empty profile — all of these can produce a release build that silently applied no profile. This is worth a hard failure rather than a warning, because it is invisible in every other way.

Sampling-based PGO: less friction, coarser evidence

AutoFDO and its relatives take a different route. Instead of building an instrumented binary, they sample an ordinary optimized binary already running in production — hardware performance counters, typically taken with last-branch-record support so that both instruction addresses and taken branches are captured. The samples are converted back into a source-level profile using the binary's debug information, and fed into the next build.

What this buys is the removal of every friction point above. There is no instrumented binary, so no unshippable slow build and no training run to operate. The overhead is small enough — on the order of a percent — to run continuously on real traffic, so the profile is representative by construction and never more than a day or so stale. For a service with continuous deployment, that combination is decisive.

What it costs is resolution and attribution. Samples are statistical, so rarely executed but important paths may be represented by very few samples or none. Attribution runs backwards through debug information, so it is only as good as the line tables — and after inlining, attributing a sample to the right *inlined instance* of a function requires the compiler to have emitted enough inline-frame information, which is exactly the information optimized builds are stingiest with. Getting a sample count for a specific control-flow edge, which instrumentation gives exactly, is inference rather than measurement here.

The honest summary is that instrumentation gives precise counts of a possibly unrepresentative run, and sampling gives approximate counts of a definitely representative one. In production systems the second is usually the better trade, and the reason is the recency point from the previous lesson: staleness costs more than resolution.

Instrumented against sampledimplementation
Instrumented (`-fprofile-generate`)Sampled (AutoFDO and relatives)
What runsA special, much slower binaryThe ordinary optimized binary
OverheadLarge — often multiples of runtimeAround a percent; safe on production traffic
Where the workload comes fromA training run someone constructedReal traffic, by construction
PrecisionExact edge and entry countsStatistical; rare paths may be unsampled
AttributionDirect — counters are in the codeThrough debug info; inlined frames are the hard case
FreshnessAs of the last training campaignContinuous; usually hours old
Operational costA two-phase build pipeline to runA collection and conversion pipeline to run
Main riskThe training workload was not productionDebug info is too thin to attribute samples correctly

How it works

The steps, in the order the compiler takes them.

  • The profile is attributed per function through a hash of the function's control-flow structure, so counts cannot be applied to a function whose shape has changed.
  • On a hash mismatch the compiler drops the profile for that function and falls back to heuristics, without failing the build.
  • Merging combines counter files by summing, which is correct for repeated runs of one workload and lossy for two different ones.
  • Sampling-based collection reads hardware counters, ideally with branch-record support, from an ordinary optimized binary under real traffic.
  • Samples are mapped back to source locations through the binary's line tables and inline-frame records, then aggregated into a source-level profile.
  • That profile is applied on the next build the same way an instrumented one would be, with the same legality restriction: bias, never licence.
  • Making the pipeline reproducible requires storing the profile as a content-addressed artifact and including its hash in the build cache key.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A binary built with PGO is measurably slower in production than the plain optimized build, because the training workload exercised different paths and the real ones were placed in the cold section.
  • PGO stops helping over a quarter with no visible change: profile coverage decayed function by function and nothing reported it.
  • Two engineers on the same commit produce different binaries, and the difference is which profile each had lying around.
  • A release ships with no profile applied because the training step failed silently in CI, and the regression is attributed to the application change in the same release.
  • A retry storm in production runs entirely through out-of-line cold code, turning a partial outage into a slower one, because the profile never saw a retry.
  • A sampled profile attributes time to the wrong inlined function because the optimized build emitted thin inline-frame information, and the optimizer then inlines the wrong callee harder.
  • A profile regenerated on schedule invalidates every cached build artifact at once, and a routine day turns into a full rebuild of the world.

When it helps

  • Long-lived services with continuously available production traffic, where sampling makes representativeness free and staleness bounded.
  • Programs with one dominant, stable workload that a training run can honestly reproduce.
  • Organisations with the pipeline maturity to treat the profile as a versioned artifact rather than a file someone generated once.
  • Deciding *not* to use PGO: this lesson's framing is most valuable when it shows the training workload cannot be made representative, which is a cheap finding.

When it hurts

  • Libraries and tools whose consumers use them in incompatible ways, where no single profile serves and the merged one serves nobody.
  • Bimodal or seasonal systems, where the correct answer may be separate binaries rather than an averaged profile.
  • Fast-moving codebases where the profile is stale before it is deployed and most of the hot code silently falls back.
  • Any project unwilling to fail the build on a missing profile, because the alternative is releases that quietly skip the optimization.

What it costs

Every one of these is paid by something.

  • A precise instrumented profile buys exact edge counts and pays with an unshippable binary, an operated training run, and a real chance the run was not representative.
  • A sampled profile buys representativeness and freshness and pays in resolution: rare-but-important paths may be unsampled, and attribution depends on debug information the optimized build had every incentive to shrink.
  • Any profile buys frequency-aware decisions and pays in reproducibility: the artifact now depends on an input that no source-control system holds.
  • Storing the profile as a content-addressed build input buys reproducibility back and pays with a cache-invalidation cliff — regenerating it rebuilds everything.
  • Silent per-function fallback on stale profiles buys soundness and pays with invisibility: the degradation is correct behavior and nothing reports it, so coverage has to be monitored deliberately.
  • Splitting a bimodal workload into two binaries buys a profile that fits and pays with two artifacts to build, test, ship and route between.

What else you could do

What a different compiler or language does instead, and when that is better.

  • No profile at all: heuristics are weak but unbiased, and for many programs the honest measurement shows the difference is not worth a two-phase pipeline.
  • Post-link layout optimizers such as BOLT and Propeller, driven by production samples, which capture much of the layout win without changing the compile pipeline and can be re-run against an existing binary.
  • A JIT, which sidesteps representativeness entirely by profiling the run it is optimizing — and pays in warmup, memory and less predictable latency, see [[jit-costs]].
  • Hand annotations for the handful of paths you genuinely know are cold, which never go stale in the hash-mismatch sense and are frequently wrong in a different way.
  • Separate builds for genuinely separate workloads, each with its own profile, when the modes are distinct enough that averaging serves neither.

See it for yourself

The flag, dump or tool that shows you this directly.

  • llvm-profdata show --all-functions app.profdata and llvm-profdata overlap a.profdata b.profdata — the second quantifies how similar two workloads actually were.
  • Build with -Wprofile-instr-out-of-date -Wprofile-instr-unprofiled to make stale and missing profiles visible instead of silent.
  • GCC: -fprofile-use -Wcoverage-mismatch reports functions whose structure no longer matches, and -fprofile-partial-training controls what happens to unprofiled ones.
  • perf record -b collects branch records for sampling-based collection; create_llvm_prof or llvm-profgen converts them into a usable profile.
  • Diff nm --size-sort output between the plain and PGO builds to see which functions moved into the cold section — and ask whether you believe every one of them is cold.
  • The decisive test is an A/B in production against the non-PGO build on the same commit. Everything else is a proxy for it.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Some profile is better than no profile." Only if it resembles production. A confidently wrong profile puts hot code in the cold section, which a heuristic build would never have done.
  • "The compiler will warn me if the profile is stale." Only if you asked it to. The sound behavior — dropping the profile for that function — is silent by default in mainstream toolchains.
  • "Sampled profiles are just lower-quality instrumented ones." They are a different trade: worse resolution, far better representativeness and freshness, and freshness is usually the one that matters.
  • "We checked in the profile, so the build is reproducible again." It is reproducible given the profile. It is no longer rebuildable from the source tree alone, and the cache key has to say so.

Misconceptions

The claim, and what is actually true.

The profile is part of the source.
It is an artifact of running a program on a workload on a day. Treating it as source is what makes the build irreproducible and the decay invisible.
A stale profile just gets less useful.
Per function it becomes no profile at all, silently. The build stays green and the coverage falls, which is a different and harder problem than gradual degradation.
Sampling is only for people who cannot run an instrumented build.
It is often the better choice on the merits, because it removes the representativeness risk that dominates the outcome and keeps the profile fresh.
Merging more workloads makes a more robust profile.
Summing dissimilar workloads produces an average path that none of them takes, and the optimizer commits to it.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

PGO is only as good as the run you trained on. If that run did not do what your users do, the compiler will confidently optimize for the wrong code and can end up slower than if you had never given it a profile. Profiles also go out of date as the code changes, and when they do, the compiler quietly stops using them rather than telling you.

practical

Treat the profile as a versioned artifact: store it, hash it into the build key, and record what workload produced it. Turn on the stale-profile warnings, because they are off by default. Monitor how much of your executed code the profile still covers, and regenerate on a schedule rather than when someone remembers. Make a missing profile fail the release build. And if you run a service with real traffic, look hard at sampling-based collection first — it removes the representativeness question, which is the one that decides whether any of this works.

advanced

The deep problem is that a profile is a point estimate being used as a distribution. The optimizer commits as though the training run were the whole story, and there is no standard way to express "hot, but with high variance" or "hot only under this mode" — so a bimodal service gets an average that fits neither mode, and the mechanism has no vocabulary to say so. That is the structural reason production systems drift towards continuous sampling and post-link layout: not because the counts are better, but because refreshing constantly turns a point estimate into something closer to a moving average of current behavior, and because a post-link tool can be re-run against yesterday's binary when behavior shifts. The remaining gap — being wrong about a path and being able to undo it — is not closeable ahead of time at all, and that is precisely the boundary where a static compiler ends and [[speculative-optimization]] begins.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationStale-profile behavior differs meaningfully between toolchains and versions. GCC controls the treatment of unprofiled functions with -fprofile-partial-training and reports structure mismatches under -Wcoverage-mismatch; Clang has separate opt-in warnings and drops silently otherwise. Profile formats are not interchangeable across toolchains and are not stable across major versions of the same one, so a stored profile is tied to a compiler version as much as to a source revision.
typicalThe claim that an unrepresentative profile can be worse than none is characteristic rather than guaranteed. On a program whose hot path is small and unavoidable, a bad profile mostly wastes the effort; on a large binary where hot/cold splitting is doing the work, misplacing the hot set is a genuine regression. The magnitude follows how much layout freedom the binary has.
targetSampling-based collection depends on hardware facilities that are not uniform: last-branch-record support of the kind AutoFDO relies on exists on some x86 cores, has different analogues on others, and may be unavailable inside a virtual machine or restricted by the host. A collection pipeline that works on bare metal can silently gather much poorer data in a container on a different host.

If you were asked this in an interview

  • Why can an unrepresentative profile make a binary slower than building with no profile at all?
  • Your PGO win faded over six months with no configuration change. What happened, and how would you have detected it earlier?
  • When would you choose sampling-based PGO over an instrumented build, and what are you giving up?

Connections

Domains that do not exist yet
  • DevOps / Production Engineering — Operating a continuous profile-collection pipeline and treating profile coverage as a monitored signal
    Collecting samples from production fleets, storing and promoting profile artifacts, and alerting when coverage decays are ongoing operations work rather than compiler work. We stop at what the compiler does with a profile and what makes one unusable; keeping a fresh one available is theirs.