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.
How do I actually check what the compiler did, instead of guessing?
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.
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
-Sprints assembly,-S -emit-llvmprints LLVM IR, and diffing-O0against-O2shows 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-vectorizeand-fopt-info-vec-missedare the two most useful flags in this lesson, because they name the blocker.-fsave-optimization-recordplusopt-viewer.pyrenders every remark against the source, for working through a whole file.objdump -d,nm,readelfandsizeanswer 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.
| Question | Command | What you are looking for |
|---|---|---|
| What instructions? | clang -O2 -S -o - f.c | The loop body; count the instructions between the label and the branch back |
| What IR? | clang -O2 -S -emit-llvm -o - f.c | Whether the call is still there, whether the load survived, what attributes were inferred |
| Did it inline? | clang -O2 -Rpass=inline, gcc -fopt-info-inline | A 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-missed | The stated blocker: dependence, unknown trip count, non-contiguous access, cost model |
| What symbols? | nm -C --defined-only ./bin | Whether a function survived, and whether the name is mangled as you expect |
| What size, where? | size -A ./bin, bloaty ./bin | Which sections and which compile units dominate |
| What does it depend on? | readelf -d ./bin, ldd ./bin, otool -L | Unexpected dynamic dependencies |
| Everything, many compilers | Compiler Explorer (godbolt.org) | The *diff* between two flag sets or two versions — the highest-value view |
Optimization remarks: the compiler explaining itself
-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.
1$ clang -O2 -Rpass-analysis=loop-vectorize -c sum.c2sum.c:4:3: remark: loop not vectorized: cannot identify array bounds3 for (int i = 0; i < n; i++)4 ^5 6$ gcc -O3 -fopt-info-vec-missed -c sum.c7sum.c:4:3: missed: couldn't vectorize loop8sum.c:5:10: missed: possible dependence between data-refs9 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.c16sum.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 -dSon a-gbuild 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:
-Shalts after code generation and before assembly,-emit-llvmemits the IR instead of target code,-chalts 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=inlinewould 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
restrictwas 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
staticwith 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
-O0is 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,nmandbloatylocate 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.llto see it after every pass;gcc -fdump-tree-all -fdump-rtl-allfor the GCC equivalents. - Optimization remarks, Clang:
-Rpass=inline,-Rpass-missed=loop-vectorize,-Rpass-analysis=loop-vectorize, and-fsave-optimization-recordplusllvm/tools/opt-viewer/opt-viewer.pyfor 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++filtto demangle by hand. macOS:nm,otool -tv,otool -L. Windows:dumpbin /disasm /symbols /dependents. - Size attribution:
bloaty ./bin(andbloaty new -- oldto diff two binaries),cargo llvm-linesfor 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 disfor CPython bytecode,javap -cfor JVM bytecode,cargo asmorcargo show-asmfor 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
staticfunction with no callers is simply never emitted. Give it external linkage and look again.
Misconceptions
The claim, and what is actually true.
-Rpass-analysis= and -fopt-info-*-missed exist specifically to name the fact the compiler could not establish.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.
-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.-target should be stated explicitly when the claim depends on it.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
- Observability & Performance Engineering — Proving a change helped, with a profiler and a benchmark rather than by reading outputEverything 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 testA 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.