Debug Infoimplementation

Reading What the Compiler Produced

The most directly useful skill in the domain. Every question of the form "did it inline that", "did that vectorize", "is this bounds check still there" is answerable in under a minute with a flag you can memorise — and optimization remarks will tell you *why* the answer was no.

The question

How do I actually check what the compiler did, instead of guessing?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The compiler's own intermediate and final artifacts, made readable on request: the IR at any point in the pass pipeline, the assembly before it is encoded, the encoded bytes in the object file, the symbol and relocation tables, and — most usefully — a structured record of which optimizations fired, which did not, and what blocked them. These are not reconstructions. They are the compiler printing what it has, which is why they settle arguments that reasoning about the source cannot.

What this phase may assume or do

Every flag here is required to be observation-only: adding -S, -emit-llvm, -Rpass= or -fopt-info may change what is printed and must not change the code that would otherwise be generated. The one that is not free is -fsave-optimization-record, which only writes an additional file, and disassembly, which reads a finished artifact. The precondition on *interpreting* the output is stricter than the precondition on producing it: a listing is evidence about one compiler, one version, one optimization level and one target triple, and carrying a conclusion from it to a different configuration is unjustified — which is why every claim in this domain is labelled.

Key points

  • -S prints assembly, -S -emit-llvm prints LLVM IR, and diffing -O0 against -O2 shows you the entire middle-end.
  • Optimization remarks answer *why* a transformation did not happen: -Rpass= / -Rpass-missed= / -Rpass-analysis= in Clang, -fopt-info-* in GCC.
  • -Rpass-analysis=loop-vectorize and -fopt-info-vec-missed are the two most useful flags in this lesson, because they name the blocker.
  • -fsave-optimization-record plus opt-viewer.py renders every remark against the source, for working through a whole file.
  • objdump -d, nm, readelf and size answer questions about an artifact you already have, with no rebuild.
  • Most of an assembly listing is directives — CFI, line records, alignment, section bookkeeping — and none of it executes.
  • Isolate the smallest function, give it external linkage, find the backward branch, and count the loop body.
  • Every listing is evidence about one compiler, one version, one level and one target; state the triple with any claim.
  • Compiler Explorer is best used as a diff between two configurations, and its results must be re-checked against your real build.
  • Reading the output tells you what happened, not what is fast. Whether it helped is a profiler question.

The four questions and the flag that answers each

Almost every question an engineer has about compiler behaviour is one of four, and each has a direct answer that takes less than a minute. Learning these four is worth more than any amount of reasoning about what the compiler "probably" did.

"What did it generate?"clang -O2 -S -o - file.c or gcc -O2 -S -o - file.c prints assembly to stdout. Add -fverbose-asm on GCC for comments naming the variables behind the registers, and -masm=intel (GCC) or -masm=intel/--x86-asm-syntax=intel (Clang/LLVM tools) if you prefer Intel syntax. For an already-built artifact, objdump -d --demangle or llvm-objdump -d --demangle, and objdump -dS to interleave the source.

"What did the middle-end see?"clang -O2 -S -emit-llvm -o - file.c prints LLVM IR. The single most informative thing in this whole lesson is to run it twice, once at -O0 and once at -O2, and diff: the difference is the entire middle-end, made concrete.

"Did optimization X happen, and if not, why not?" — optimization remarks, covered in the next section. This is the flag most engineers do not know exists and the one that most often ends an investigation immediately.

"What is in this binary?"nm -C --defined-only for symbols, readelf -sW for the full symbol table with sizes, readelf -SW for sections, size -A for section sizes, readelf -d for dynamic dependencies, strings for embedded text. On macOS nm, otool -tv, otool -L; on Windows dumpbin /disasm, /symbols, /dependents.

The question, the flag, and what to look forimplementation
QuestionCommandWhat you are looking for
What instructions?clang -O2 -S -o - f.cThe loop body; count the instructions between the label and the branch back
What IR?clang -O2 -S -emit-llvm -o - f.cWhether the call is still there, whether the load survived, what attributes were inferred
Did it inline?clang -O2 -Rpass=inline, gcc -fopt-info-inlineA remark naming the callee, or its absence — then -Rpass-missed=inline for the reason
Did it vectorize?clang -Rpass-analysis=loop-vectorize, gcc -fopt-info-vec-missedThe stated blocker: dependence, unknown trip count, non-contiguous access, cost model
What symbols?nm -C --defined-only ./binWhether a function survived, and whether the name is mangled as you expect
What size, where?size -A ./bin, bloaty ./binWhich sections and which compile units dominate
What does it depend on?readelf -d ./bin, ldd ./bin, otool -LUnexpected dynamic dependencies
Everything, many compilersCompiler Explorer (godbolt.org)The *diff* between two flag sets or two versions — the highest-value view

Optimization remarks: the compiler explaining itself

implementationRemark text, pass names and flag spellings differ between GCC and Clang and change between versions — -Rpass names are LLVM pass names and have been renamed across releases, and -fopt-info group names are GCC-specific. MSVC has a much smaller equivalent (/Qvec-report:2 and a handful of others). The *technique* of asking the compiler why is universal and the spelling is not, so check --help or the version's documentation rather than carrying a flag across toolchains.

This is the part most engineers have never used and the part that most often ends an investigation in one command. Both GCC and Clang can report, per transformation, whether it fired — and when it did not, what specifically prevented it.

GCC uses -fopt-info, with a group and a filter: -fopt-info-vec-optimized for vectorized loops, -fopt-info-vec-missed for the ones that were not and why, -fopt-info-inline-optimized, -fopt-info-loop-all, and -fopt-info-all=report.txt to write everything to a file. -fopt-info-vec-missed is the one to memorise: it prints lines such as "not vectorized: complicated access pattern" or "not vectorized: unsupported data-type" against the source line, which is a direct answer to the question everyone asks about a hot loop.

Clang uses -Rpass=, -Rpass-missed= and -Rpass-analysis=, each taking a regular expression matching pass names. -Rpass=loop-vectorize reports successes; -Rpass-missed=loop-vectorize reports failures; -Rpass-analysis=loop-vectorize reports the analysis that led to the failure, which is the one with the actual reason in it. -Rpass=inline and -Rpass-missed=inline do the same for inlining, and the missed remarks name the cost-model decision.

For anything more than a quick look, -fsave-optimization-record writes a YAML file per translation unit, and LLVM ships opt-viewer.py to render it as source annotated with every remark. That is the right tool when you are working through a whole file rather than one loop.

The reason this matters more than reading assembly: assembly tells you *what*, and a remark tells you *why*. "It did not vectorize" is where most investigations stop; "not vectorized: possible dependence between loads and stores" tells you to add restrict or restructure the access, and that is an actionable answer you did not have to infer.

Asking why a loop did not vectorize, and getting a usable answer
1$ clang -O2 -Rpass-analysis=loop-vectorize -c sum.c
2sum.c:4:3: remark: loop not vectorized: cannot identify array bounds
3 for (int i = 0; i < n; i++)
4 ^
5
6$ gcc -O3 -fopt-info-vec-missed -c sum.c
7sum.c:4:3: missed: couldn't vectorize loop
8sum.c:5:10: missed: possible dependence between data-refs
9
10# The fix the remark is pointing at:
11void add(float *restrict a, const float *restrict b, int n) {
12 for (int i = 0; i < n; i++) a[i] += b[i];
13}
14
15$ clang -O2 -Rpass=loop-vectorize -c sum.c
16sum.c:4:3: remark: vectorized loop (vectorization width: 8, interleaved count: 4)

"Possible dependence between data-refs" means the compiler could not prove a and b do not overlap, so reordering the accesses might change the result — see [[alias-analysis]]. restrict is the programmer asserting they do not, which discharges the obligation the compiler could not. Reading the remark turns "the compiler will not vectorize this" from a complaint into a two-word fix.

Reading assembly without drowning in it

Assembly output contains a great deal that is not instructions, and knowing what to ignore is most of the skill. .cfi_startproc, .cfi_def_cfa_offset and their relatives are unwind directives — [[stack-unwinding]] metadata, not code. .loc directives are line-table entries. .p2align is padding. .section, .globl, .type and .size are bookkeeping for the assembler and linker. On a first read, skip all of them and look only at the mnemonics.

Then apply a few habits that turn an unreadable dump into an answer.

Compile the smallest possible function. A twenty-line function produces readable output; a real translation unit does not. Extract the loop into its own file with the same flags.

Give the function external linkage and no callers, so it is not inlined away and does actually get emitted. A static function with no uses will simply not appear.

Find the loop first. Look for a backward branch to a label above the current position. Everything between the label and that branch is the loop body, and counting its instructions is the single most useful measurement you can take.

Name the target. Register names, instruction availability and the calling convention all depend on the triple. Any conclusion drawn without knowing whether you compiled for x86-64 or AArch64 is not transferable, which is why every listing in this domain carries a target label.

Diff, do not read. The highest-value use of Compiler Explorer is two panes with different flags or different compiler versions side by side. The absolute output is hard to judge; the difference between two outputs is usually obvious.

  • Ignore .cfi_*, .loc, .p2align, .section, .type, .size — none of them execute.
  • Isolate the function into its own file at the same optimization level, or you will be reading someone else's code.
  • Locate the backward branch; the loop body is what lies above it.
  • Use -fverbose-asm (GCC) so registers are annotated with the variables they hold.
  • Use objdump -dS on a -g build to see source interleaved with instructions.
  • Always state the target triple with any claim, and prefer diffs of two configurations over reading one.

Compiler Explorer, and what it is actually for

The browser tool at godbolt.org compiles a snippet with dozens of compilers and versions and shows the assembly next to the source, with colour-coded correspondence between them. It is the fastest way to answer a question about compiler behaviour and it is worth being precise about which questions it is good for.

It is excellent for: comparing two flag sets on the same code; comparing GCC against Clang against MSVC on the same code; comparing two versions of the same compiler to see when behaviour changed; checking whether an idiom compiles to what you assumed; and settling an argument in a code review with a link. The colour mapping between source lines and instruction groups makes the correspondence legible without reading a line table.

It is not good for: anything depending on your real build flags, your headers, your inlining context, or link-time optimization. A snippet compiled in isolation is not the code in your project, and a function that looks tight standalone can be inlined into a caller and optimized completely differently. It is a hypothesis generator, and the hypothesis should then be checked against your actual build.

The final and most important point, which applies to every tool in this lesson: reading the output tells you what the compiler did; it does not tell you what is fast. Instruction count is not time. A shorter sequence with a dependency chain can lose to a longer one with independent work; a vectorized loop can be slower if it is memory-bound; the out-of-order machine underneath reorders again, which is Computer Architecture’s subject and not visible at this level. The compiler output answers "did the transformation happen". Whether it helped is a measurement, and it belongs to a profiler.

How it works

The steps, in the order the compiler takes them.

  • The driver stops the pipeline early on request: -S halts after code generation and before assembly, -emit-llvm emits the IR instead of target code, -c halts after assembly.
  • Optimization passes emit structured remarks as they run, recording the transformation, the source location and — for a failure — the analysis result that blocked it.
  • Remark filters compare the requested pattern against the pass name, printing matching remarks as diagnostics or serialising all of them to a YAML record.
  • Dump flags (-fdump-tree-all, -print-after-all) print the intermediate representation between passes so a transformation can be located to the pass that made it.
  • Object-file tools read the file format directly: the symbol table for names, the section headers for layout, the relocation entries for unresolved references.
  • A disassembler decodes the bytes in the text section back into mnemonics, using the symbol table for labels and, with -S, the line table for interleaved source.
  • None of these change code generation; they observe a pipeline that would have run identically without them.

How it breaks

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

  • A team argues for an afternoon about whether a function is inlined, when -Rpass=inline would have answered it in ten seconds.
  • An engineer concludes a loop cannot be vectorized and rewrites it by hand, when the remark said "possible dependence between data-refs" and restrict was the whole fix.
  • A conclusion drawn on Compiler Explorer does not hold in the real build, because the function is inlined into a caller there and optimized in a completely different context.
  • A benchmark decision is made on instruction count from an assembly listing, and the shorter sequence is slower because of a dependency chain the listing does not show.
  • A function looked for in the assembly is absent, because it was static with no callers and never emitted — read as evidence that it was optimized away.
  • An optimization claim is carried from x86-64 to AArch64 and is simply false there, because the available instructions and the register count differ.
  • A dump at -O0 is used to reason about production behaviour, and none of the interesting transformations are present in it at all.

When it helps

  • Answering any concrete question about compiler behaviour: did it inline, did it vectorize, is the bounds check gone, did the atomic become a lock-free instruction.
  • Performance work, where the remark explaining why a transformation was blocked converts a dead end into a specific source change.
  • Code review, where a Compiler Explorer link showing two versions side by side settles a disagreement faster than any argument.
  • Learning the domain: nothing teaches what an optimization does like diffing the IR before and after the pass that performs it.
  • Debugging a build-size or link problem, where size -A, nm and bloaty locate the cause directly.

When it hurts

  • When it becomes the measurement. Instruction count, listing length and "it looks tighter" are not performance data, and treating them as such produces confident wrong optimizations.
  • On a snippet that is not representative of the real build — different flags, different headers, different inlining context, no LTO.
  • When a conclusion is generalised past its configuration, which is the single most common error in this whole area and the reason the accuracy labels exist.
  • As a substitute for a profiler. The compiler output cannot tell you where the time goes; only that the transformations you were curious about did or did not occur.

What it costs

Every one of these is paid by something.

  • Reading the compiler's own output buys certainty about what happened and pays the time to isolate a small enough case for the output to be readable.
  • Optimization records buy a complete picture of every decision and pay a YAML file per translation unit that can be large on a real build, plus the need for a viewer to make it usable.
  • Compiler Explorer buys a fast answer across many compilers and pays fidelity to your actual build, so every result needs re-checking in context.
  • Reasoning from assembly buys precision about the instruction stream and pays the fact that the instruction stream is not the schedule — the out-of-order machine reorders again underneath it.
  • Disabling inlining or optimization to make the output readable buys legibility and pays a listing that is no longer the code you ship.
  • Learning these tools buys a permanent shortcut around a whole class of speculation and pays the up-front time to memorise flags that differ between toolchains.

What else you could do

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

  • Profile instead of reading: a profiler answers "where does the time go", which is a different and usually more important question — see the perf links below.
  • Microbenchmark the two variants and measure. Slower to set up, and it answers whether a change helped rather than whether a transformation occurred.
  • Hardware performance counters (perf stat) for cache misses, branch mispredictions and instructions retired, which explain *why* a listing that looks good performs badly.
  • Trust the compiler and move on. Frequently correct: most code is not hot, and the fastest way to make a program faster is usually an algorithmic change, not an inspection of its assembly.
  • Intrinsics or hand-written assembly when the remark says the compiler cannot do what you need. The last resort, and the remark is what tells you that you have reached it.

See it for yourself

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

  • Assembly: clang -O2 -S -o - f.c, gcc -O2 -S -fverbose-asm -o - f.c, objdump -d --demangle ./bin, objdump -dS ./bin (source interleaved, needs -g), llvm-objdump -d --x86-asm-syntax=intel ./bin.
  • IR: clang -O2 -S -emit-llvm -o - f.c; opt -passes='default<O2>' -print-after-all f.ll to see it after every pass; gcc -fdump-tree-all -fdump-rtl-all for the GCC equivalents.
  • Optimization remarks, Clang: -Rpass=inline, -Rpass-missed=loop-vectorize, -Rpass-analysis=loop-vectorize, and -fsave-optimization-record plus llvm/tools/opt-viewer/opt-viewer.py for the rendered report.
  • Optimization remarks, GCC: -fopt-info-vec-optimized, -fopt-info-vec-missed, -fopt-info-inline-optimized, -fopt-info-all=report.txt.
  • Symbols and sections: nm -C --defined-only ./bin, readelf -sW ./bin, readelf -SW ./bin, size -A ./bin, readelf -d ./bin, c++filt to demangle by hand. macOS: nm, otool -tv, otool -L. Windows: dumpbin /disasm /symbols /dependents.
  • Size attribution: bloaty ./bin (and bloaty new -- old to diff two binaries), cargo llvm-lines for Rust monomorphization cost.
  • Compiler Explorer at godbolt.org — and use the two-pane diff view, which is what makes it more than a curiosity.
  • Language-specific: go build -gcflags='-m -m' for inlining and escape decisions, python -m dis for CPython bytecode, javap -c for JVM bytecode, cargo asm or cargo show-asm for a single Rust function.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "Fewer instructions means faster." Not reliably. A short dependent chain can lose to a longer independent one, and the out-of-order machine schedules underneath whatever you read.
  • "The compiler did not do X." Check with a remark before asserting it. And if it genuinely did not, the missed remark usually names the specific fact it could not prove.
  • "I saw it on Compiler Explorer, so that is what my build does." Your build has different flags, different headers, a different inlining context and possibly LTO. Re-check in context.
  • "The assembly is the truth." It is the contract handed to the CPU, not the schedule the CPU executes. Instruction-level parallelism, renaming and speculation all happen after it.
  • "This function is missing from the output, so it was optimized away." A static function with no callers is simply never emitted. Give it external linkage and look again.

Misconceptions

The claim, and what is actually true.

Reading assembly is an advanced skill you need before this is useful.
The highest-value tools here — optimization remarks — produce English sentences. You can get the actionable answer without reading a single instruction.
You have to guess why the compiler did not optimize something.
It will tell you. -Rpass-analysis= and -fopt-info-*-missed exist specifically to name the fact the compiler could not establish.
Compiler output is a substitute for benchmarking.
It answers whether a transformation occurred. Whether it made the program faster is a measurement, and the two questions have different tools.

Go deeper

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

overview

You never have to guess what the compiler did. -S prints the assembly it generated. -S -emit-llvm prints the intermediate form it optimized. And a family of flags — -Rpass-missed= in Clang, -fopt-info-vec-missed in GCC — makes it explain, in a sentence, why a loop was not vectorized or a function was not inlined. That last one is the highest-value flag most engineers have never used, because it turns "the compiler will not optimize this" into a specific reason with a specific fix.

practical

A workflow that answers most questions in under a minute. Isolate the function into its own file with your real flags. Run -Rpass-missed= and -Rpass-analysis= for the pass you care about, or the GCC -fopt-info-*-missed equivalent, and read the reason. If you need to see the code, -S -o - and find the backward branch — the loop body is what is above it. If you need to compare, open both versions in Compiler Explorer side by side. And when you are done, verify with a profiler, because none of this tells you whether the program got faster.

advanced

The structural reason this works is that every optimization has a legality precondition it must establish before firing, and a missed remark is the compiler reporting *which precondition it could not discharge*. Read them that way and they stop being diagnostics and become a map of the analysis. "Possible dependence between data-refs" is [[alias-analysis]] failing, and restrict or a restructured access discharges it. "Cannot identify array bounds" is the value analysis failing to bound the trip count. "Cost model" is the transformation being legal and judged unprofitable, which is a completely different conversation and often means the loop is too short. "Call is not inlinable" may mean the callee is external, or its body was not available across the translation unit — which is an argument for [[link-time-optimization]], not for restructuring the source. Once you can map remarks to the analyses behind them, the compiler stops being opaque, and the ordinary next step becomes supplying the fact the compiler could not derive rather than rewriting code in the hope that something changes.

How much this depends on

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

implementationEvery flag in this lesson is toolchain- and version-specific. -Rpass takes LLVM pass names, which have been renamed across releases; -fopt-info groups are GCC-specific and have grown over time; -fdump-tree-* exists only in GCC and -print-after-all only in LLVM; MSVC exposes a much smaller set. Check --help or the version documentation rather than carrying a flag between toolchains, and expect remark wording to change between releases even where the flag does not.
targetAny assembly listing is evidence about one target triple. Register names, argument-passing registers, available instructions, addressing modes and whether arithmetic sets flags all differ between x86-64 System V, Windows x64 and AArch64 AAPCS. A conclusion about instruction count or about whether an idiom compiles well transfers between targets only by coincidence, which is why every listing in this domain is labelled and why -target should be stated explicitly when the claim depends on it.
typicalOptimization remarks describe what a given compiler chose to report, not an exhaustive account of what it did. A missing remark does not prove a transformation did not happen — some passes emit no remarks at all, and coverage differs by pass and by version. Remarks are strong positive evidence and weak negative evidence, so an absent remark should send you to the IR or the assembly rather than closing the question.

If you were asked this in an interview

  • How would you check, in under a minute, whether a specific function was inlined in your release build?
  • A hot loop is not vectorizing. What do you run, and what would the answer look like?
  • Why is instruction count a poor proxy for speed?

Connections

OS & Networkingdebugging-tools
Domains that do not exist yet
  • Observability & Performance Engineering — Proving a change helped, with a profiler and a benchmark rather than by reading output
    Everything in this lesson answers "did the transformation happen", and nothing in it answers "did the program get faster". Those are different questions with different tools, and taking a shorter instruction sequence as evidence of speed is the most common way this skill is misused.
  • Testing & Reliability Engineering — Asserting compiler behaviour as a regression test
    A transformation you depend on — a vectorized loop, an eliminated bounds check, an inlined hot call — can silently stop happening on a compiler upgrade or after an unrelated refactor. Turning an optimization remark into a build assertion is a testing practice owned there; which remark to assert on, and what it actually proves, is ours.