Infraspec

Reading LLVM IR

LLVM IR is a typed, SSA-form instruction set that is readable by humans and has three isomorphic forms — text, bitcode and in-memory. Learning to read it turns "the optimizer did something" into a diff you can point at.

The question

What does LLVM IR actually look like, and what do I need to know to read it?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A module: a list of global values and functions, each function a list of basic blocks, each block a list of typed instructions ending in a terminator. Every instruction that produces a value defines a new name — %1, %result — exactly once, so a use points at exactly one definition and data dependencies are explicit without any analysis. Types are on the instructions, not inferred from the operands. And the flags and attributes hanging off instructions and parameters are not decoration: they are the channel through which a frontend tells the optimizer what its source language permitted.

What this phase may assume or do

A transformation over the IR is legal if it preserves the semantics the IR specification defines for the instructions involved — including the meanings of the flags present. That last clause is the sharp edge: add nsw means signed overflow produces a poison value rather than wrapping, so an optimizer may assume it does not overflow, and a rewrite that would be illegal on a plain add is legal on this one. The IR is therefore not a neutral description of what the program does; it is a description plus a set of licensed assumptions, and reading it without reading the flags will lead you to the wrong conclusion about what the optimizer was entitled to do.

Key points

  • A module contains functions; a function contains basic blocks; a block contains typed instructions and ends in a terminator.
  • @ names are global, % names are local, and every value-producing instruction defines its name exactly once — the IR is in SSA form.
  • Types are written on values and instructions rather than inferred, which makes the IR verifiable and readable in isolation.
  • Three isomorphic forms — textual, bitcode, in-memory — carry the same information with very different compatibility promises.
  • Flags such as nsw and nuw, and attributes such as noalias and readonly, are how source-language assumptions reach the optimizer.
  • Reading IR without reading its flags gives the wrong answer about what the optimizer was allowed to do.

The smallest interesting example

Here is a function that adds two integers, in LLVM IR. It is worth reading character by character, because almost every design decision in the format is visible in four lines.

define i32 @add(i32 %a, i32 %b) declares a function returning a 32-bit integer and taking two. @ prefixes global names — functions, globals — and % prefixes local ones. Both parameters are typed, and the type is on the value, not inferred. Inside, %result = add i32 %a, %b names the result of the addition; the type appears again on the instruction, because instructions carry their own types rather than depending on inference. Then ret i32 %result terminates the block.

The %result name is defined exactly once and can never be reassigned. That is static single assignment — [[static-single-assignment]] — and it is not a stylistic choice: it is what makes a use point at exactly one definition, which is what makes most analyses in the middle-end cheap. Where control flow merges and a value could come from two places, the IR has [[phi-functions]] rather than reassignment.

A complete, valid LLVM IR function
1define i32 @add(i32 %a, i32 %b) {
2 %result = add i32 %a, %b
3 ret i32 %result
4}

Four lines and three separate decisions: @ versus % for global versus local scope, an explicit type on every value and every instruction, and a name that is assigned once and never again. There is no declaration of %result anywhere; the assignment is the declaration, and there can only be one.

Three forms of the same thing

implementationThe compatibility positions are LLVM project policy rather than a specification, and the bitcode window has a defined but finite depth — very old bitcode is not readable by current releases. The textual format changes in ways that break parsing: opaque pointers replaced typed pointers over several releases, which invalidated an enormous quantity of published IR examples. Any IR listing older than a few years should be read for shape rather than copied.

LLVM IR exists in three isomorphic representations, and conflating them causes real confusion. The textual form is what you read: .ll files, what -emit-llvm -S prints, what appears in Compiler Explorer. Bitcode is the compact binary serialisation, .bc, which is what -flto puts inside object files. And the in-memory form is the C++ object graph the libraries actually manipulate — Module, Function, BasicBlock, Instruction.

Isomorphic means the three carry the same information and convert between each other without loss: llvm-as turns text into bitcode, llvm-dis turns it back, and the in-memory form is what both parse into. That property is more useful than it sounds, because it means the readable form is not a lossy pretty-printer of the real thing — it *is* the real thing, written down. You can dump IR, edit it in a text editor, and feed it back to opt, and that is a legitimate debugging technique.

The compatibility promises differ sharply between the forms, though, and this is where people get hurt. Bitcode has a backward-compatibility window: newer LLVM reads bitcode from a range of older versions, which is what makes link-time optimization survive a toolchain upgrade. The textual form has no such promise — it is a debugging and testing format, and IR printed by one release may fail to parse in the next. Checking .ll files into a repository as fixtures is a decision with an expiry date.

The three forms and what each is forimplementation
FormWhat it isUsed forCompatibility
Textual (.ll)Human-readable assembly-like syntaxReading, debugging, hand-editing, compiler test suitesNone promised across versions
Bitcode (.bc)A compact binary serialisationLink-time optimization, distribution between build stagesNewer LLVM reads a window of older versions
In-memoryThe C++ object graph the passes mutateEverything the libraries actually doThe API changes every release

Where undefined behavior is encoded

specThe meanings of nsw, nuw and poison are specified by the LLVM Language Reference, so the licence they grant is precise and portable across LLVM versions in a way most things in this domain are not. What is version-sensitive is whether any particular optimization actually exploits a given flag: whether the fold above happens depends on which passes run at which optimization level, and the poison and undef semantics themselves have been refined repeatedly as the community worked out inconsistencies in them.

The plain add above wraps on overflow — the IR defines it that way, so an optimizer may not assume anything about the result. But C says signed overflow is undefined, and Rust says it panics in debug and wraps in release, and each of those is a different licence. The IR carries the difference in flags on the instruction: nsw (no signed wrap) and nuw (no unsigned wrap) say that if the addition overflows in that respect, the result is *poison* — a value whose use makes the program's behavior undefined.

That is the mechanism by which source-level undefined behavior becomes optimizer licence. A C frontend emits add nsw for signed arithmetic, and the middle-end may then assume no signed overflow occurred — which is what allows it to conclude that a + 1 > a is always true, or to promote a loop counter to a wider type, or to prove a loop terminates. A Rust frontend emitting the same addition for wrapping arithmetic emits no flag, and the optimizer may assume none of it. Same instruction, same optimizer, different licence, different generated code. [[ub-and-optimization]] is the general argument; this is where it physically lives.

The flags are not the only such channel. noalias on a parameter says two pointers do not alias, which is restrict in C and comes free from the borrow checker in Rust. readonly, nounwind, align, dereferenceable and nonnull all encode a promise the frontend is making on the source language's behalf. Read them as promises, because that is what they are, and a wrong one is a [[miscompilation]] rather than a missed optimization.

The same addition under three different source-language licences
What the IR permits
%r = add i32 %a, %b        ; wraps; optimizer may assume nothing
%r = add nsw i32 %a, %b    ; signed overflow yields poison
%r = add nuw i32 %a, %b    ; unsigned overflow yields poison
A consequence of the flag
define i1 @f(i32 %a) {
%s = add nsw i32 %a, 1
%c = icmp sgt i32 %s, %a
ret i1 %c ; may fold to `ret i1 true` under nsw
}

Read it asWithout nsw the comparison cannot be folded: %a might be INT_MAX and the sum might wrap negative. With nsw the frontend has stated that case does not arise, so the optimizer may treat the comparison as always true. Nothing about the arithmetic changed — only what the frontend promised.

How it works

The steps, in the order the compiler takes them.

  • A frontend builds a module in memory: global values, functions, basic blocks and typed instructions, each value named once.
  • It attaches flags and attributes encoding what the source language guarantees — overflow behaviour, aliasing, alignment, purity, unwinding.
  • The verifier checks structural well-formedness: types agree, every use is dominated by its definition, every block ends in exactly one terminator.
  • Passes read and rewrite the in-memory form; analyses compute facts over it and the pass manager caches them until a pass invalidates them.
  • The module can be serialised to bitcode for storage or transfer, or printed as text for a human, with no information lost either way.
  • The backend consumes the final module, lowering each function to machine IR and then to instructions for a specific target.

How it breaks

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

  • A hand-written or generated IR module is rejected by the verifier with a message about a use not dominated by its definition, which means the SSA property was violated when a block was rewritten.
  • A frontend emits an overflow flag its language does not actually guarantee, and the optimizer removes a check the program depended on — a miscompilation that appears only at higher optimization levels.
  • A .ll test fixture stops parsing after an LLVM upgrade, and a test suite fails for a reason unrelated to the change being tested.
  • A noalias attribute is emitted for pointers that can in fact alias, and memory operations are reordered into a wrong answer that reproduces only in release builds.
  • Someone reads IR without the flags, concludes the optimizer performed an illegal transformation, and files a compiler bug that is actually a frontend bug.
  • Code copied from an older tutorial fails to parse because it uses typed pointers, which were replaced by opaque pointers over several releases.

When it helps

  • Answering "why did the compiler do that" with a diff between -O0 and -O2 IR rather than with speculation.
  • Writing or debugging a frontend, where reading the IR you emit is the fastest way to find a missing attribute or a wrong type.
  • Isolating whether a bug is in the frontend, the middle-end or the backend, by running each stage separately over the IR.
  • Understanding what a source-language guarantee actually buys, since the IR is where it becomes concrete.

When it hurts

  • Estimating performance from IR. It is not the machine code; instruction selection, scheduling and register allocation all happen afterwards and change the picture.
  • Treating textual IR as durable. It is a debugging format with no compatibility promise, and using it as an archival or interchange format expires.
  • Reading old examples literally. The syntax has changed substantially — most visibly with the move to opaque pointers — and old listings mislead in detail while remaining right in shape.

What it costs

Every one of these is paid by something.

  • A fully typed IR buys a verifier that catches malformed modules at the pass that produced them, and pays with verbosity — the type appears on nearly every operand, which makes real functions long.
  • SSA form buys explicit data dependencies and cheap analyses, and pays with phi nodes at every control-flow merge and the out-of-SSA work that register allocation must then do — see [[out-of-ssa]].
  • Encoding language assumptions as flags buys the optimizer information it could never derive, and pays by making frontend correctness safety-critical: a wrong flag is a miscompilation, not a missed optimization.
  • Three isomorphic forms buy human readability without giving up a compact binary format, and pay with three sets of compatibility expectations that people routinely confuse.

What else you could do

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

  • GCC's GIMPLE is a comparable middle-end representation with a different design — tuple-based, with SSA applied to a subset of variables — and RTL below it for the machine level. See [[gcc]].
  • A stack-based IR such as [[bytecode]] or a WebAssembly module is far more compact and much harder to analyse, which is the trade a distribution format makes — see [[wasm-model]].
  • A tree-shaped IR keeps expression structure and is easier to translate back to source, which is why source-to-source tools use one; it is worse for data-flow analysis.
  • MLIR lets a project define its own dialect at whatever level suits it and lower gradually, rather than starting at LLVM IR's level.
  • [[three-address-code]] without SSA is simpler to produce and requires reaching-definitions analysis to recover what SSA gives for free.

See it for yourself

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

  • clang -S -emit-llvm -o - x.c for the textual IR; add -O2 and diff against -O0 to see the middle-end's entire contribution.
  • llvm-as, llvm-dis, llvm-link and llvm-extract convert between forms and pull out a single function, which makes a large module tractable.
  • opt -passes=instcombine -S x.ll applies one pass by hand, so a transformation can be attributed to a pass rather than to "the optimizer".
  • opt -print-after-all or -print-after=<pass> dumps IR between passes; opt -verify runs the verifier explicitly.
  • Compiler Explorer's LLVM IR pane, with the "opt pipeline" view, shows the IR after each pass with no local toolchain.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The IR is machine-independent, so it is portable." It contains type sizes, ABI-driven parameter lowering and sometimes target intrinsics, decided by the frontend before the middle-end ever ran.
  • "%1 and %2 are registers." They are SSA value names, unbounded in number. Which physical register anything lives in is decided much later, by [[register-allocation]].
  • "add nsw means the compiler checks for overflow." It means the opposite: the frontend has promised overflow does not happen, so the optimizer may assume it away.
  • "IR instruction count predicts performance." Instruction selection, scheduling and register allocation all happen afterwards, and one IR instruction may become none or seven.
  • "I can save this .ll file and use it next year." Textual IR has no compatibility promise. Use bitcode if you need it to survive an upgrade, and even then only within the supported window.

Misconceptions

The claim, and what is actually true.

LLVM IR is a low-level assembly language.
It is strongly typed, has unbounded virtual registers, structured types, and attributes describing semantics no assembly language has. It sits closer to a typed portable machine than to any real instruction set.
Bitcode and textual IR are different representations with different capabilities.
They are two encodings of the same information and convert without loss. Their compatibility guarantees differ, which is the only practical difference.
Poison and undef are the same thing.
They are distinct concepts with different rules — a long-running source of subtle optimizer bugs, and the reason the semantics have been repeatedly refined.
Reading the IR tells you what the CPU will execute.
Instruction selection, scheduling and register allocation happen after it, and the processor reorders again underneath that. The IR tells you what the optimizer was working with.

Go deeper

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

overview

LLVM IR is a readable, typed instruction list. Names starting with % are local, names starting with @ are global, and each name is assigned exactly once. Functions are made of blocks, each block ends in a jump or a return. It is the format almost every modern compiler converts your code into before optimizing it, and you can print it with one compiler flag.

practical

The habit worth forming: compile at -O0 and -O2 with -S -emit-llvm and diff. The diff is exactly what the middle-end did, and it answers most "why is this slow" and "why did my check disappear" questions directly. When something surprising happened, opt -passes=<name> narrows it to a pass. And always read the flags on the instruction before concluding the optimizer overstepped — nsw usually explains it.

advanced

The concept worth understanding properly is poison, because it is where the IR's design gets genuinely subtle. Poison is not a value and not a trap: it is a deferred licence, a marker meaning "if this value is ever used in a way that matters, the program has undefined behavior, so you may assume it is not". That deferral is what lets an optimizer speculatively hoist an arithmetic operation above a branch — the operation might now execute on inputs that would have overflowed, but the result is only poison, and poison is only a problem if it is observed. The cost of this power is that poison propagation rules must be exactly right, and LLVM has fixed a long series of miscompilations arising from passes that treated poison and undef interchangeably, or that introduced a use of poison where the original program had none. It is the clearest example in the domain of a representation designed to carry not just what the program does but what the compiler is permitted to assume — and of how much care that costs.

How much this depends on

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

specInstruction semantics, the meaning of nsw, nuw and poison, and the SSA and dominance requirements are specified by the LLVM Language Reference, so they are precise and stable across versions in a way that most implementation details in this domain are not. What the specification does not say is which optimizations exploit which flag — that is a pass-pipeline question and changes between releases.
implementationThe textual syntax has changed substantially over time; the migration from typed pointers to opaque pointers invalidated a large body of published examples, so IR listings more than a few years old are correct in shape and wrong in detail. Bitcode is readable by newer LLVM within a supported window of prior versions; the textual form carries no compatibility promise at all.
typicalWhether the fold shown in this lesson actually occurs depends on the optimization level and the pass pipeline: at -O0 almost nothing is folded regardless of flags, and the specific passes that exploit nsw have changed over releases. Verify a claim like this against a named compiler and flag set on Compiler Explorer rather than trusting any listing, including this one.

If you were asked this in an interview

  • Read me this IR function and tell me what each part means.
  • What does nsw mean, and what does it let the optimizer do that a plain add does not?
  • Textual IR, bitcode and the in-memory form: what is the same about them and what is not?

Connections

Computer Architectureregisters
Domains that do not exist yet
  • Testing & Reliability Engineering — Differential and property-based testing of a specification with subtle semantics
    Poison and undef semantics have produced a long series of real miscompilations, and the tooling that finds them — Alive2 and similar translation validators — is a testing discipline applied to a specification. The technique is owned there; the specification is ours.