IRimplementation

Many Frontends, One Backend

Clang, rustc, swiftc, flang, Julia and Zig do not share a parser, a type system or an opinion about memory. They share an optimizer and a set of code generators, because all six agreed to emit the same instruction set — and that agreement is what LLVM actually sells.

The question

How do six languages with nothing in common end up sharing an optimizer, and what does each of them give up to get it?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

One instruction set with several producers. From the middle-end's side the program is just LLVM IR and the source language is unknowable; from a frontend's side the IR is a target it compiles *to*. The question this arrangement exists to answer: what is the smallest interface through which a language can inherit an optimizer and a set of backends?

What this phase may assume or do

A frontend may emit only IR whose meaning under the IR's own semantics matches its language's semantics for every input. This is stricter than it sounds and is where real miscompilations come from: emitting add nsw asserts that signed overflow does not happen, and a frontend that emits it for a language where overflow wraps has told the optimizer something false and licensed it to act. The IR's semantics govern, not the source language's.

Key points

  • Frontends share the optimizer and the code generators; they share nothing that makes them languages.
  • The interface is an instruction set, which is why it can have producers outside the project — that is the difference between LLVM and a compiler with an internal IR.
  • Every language-specific guarantee is either expressible as an IR attribute, kept in a private higher IR, or lost.
  • Read the same interface backwards and it is one language inheriting every target, which is how WebAssembly reached so many languages so fast.
  • One shared middle-end means one miscompilation affects every language at once, which is why compiler fuzzing and differential testing are funded work.

What is shared and what is not

The frontends share nothing that makes them languages. Clang has a C++ template instantiator; rustc has a borrow checker; swiftc has an ARC optimizer; flang has Fortran array semantics. None of that is in LLVM, and none of it could be — it is exactly the part that makes each language itself.

What they share is everything below the point where the program stops being about its source language: inlining, constant propagation, dead-code elimination, loop-invariant code motion, vectorization, instruction selection, scheduling, register allocation, and object-file emission for every supported architecture. That is the majority of the code in a serious compiler by volume, and every one of those six projects would otherwise have written it.

The word "backend" in the lesson title is doing double duty and it is worth separating. Sharing the *optimizer* is one benefit; sharing the *code generators* is a different one. A language could plausibly want the second without the first — and that is roughly what a frontend does when it emits IR and runs it at -O0, using LLVM purely as a portable assembler.

What each layer owns, and where the language stops being visibleimplementation
  1. Language frontendsbuild time
    Source-specific: C++ templates, Rust borrows, Swift ARC, Fortran arrays.
    Everything that makes each language a language, including all diagnostics.
    Nothing yet.
  2. Lowering to LLVM IRbuild time
    A typed, SSA instruction set, identical in form regardless of producer.
    A single agreed interface.
    The source language, and every guarantee not expressible as an IR attribute or flag.
  3. Middle-endbuild time
    The same IR, rewritten by roughly a hundred and fifty passes at -O2.
    Target-independent optimization, shared by every producer.
    Source correspondence, except where debug metadata was maintained.
  4. Code generatorsbuild time
    Machine instructions for x86-64, AArch64, RISC-V, WebAssembly, and the rest.
    A specific architecture and its ABI.
    Portability.

Read it asThe second row is the whole arrangement. Everything above it is per-language and everything below it is shared, and the only reason that split is possible is that the row itself is a documented instruction set rather than an internal data structure — which is the substance of [[llvm-architecture]].

What each language gives up

implementationThese are LLVM attributes as of the mid-2020s and the set grows most releases. noalias, nonnull, dereferenceable, nsw, nuw and range all exist because some frontend could assert something the base instruction set could not say. A claim about which attributes exist, or about which of them rustc currently emits, needs a version attached.

Rust knows that two &mut references cannot alias. LLVM IR can express that with the noalias parameter attribute, and rustc emitting it was a years-long saga: every time it was enabled, it surfaced latent LLVM miscompilations in code paths nothing else exercised, and it was disabled and re-enabled repeatedly before finally sticking. That is the shape of the cost — not that the guarantee is inexpressible, but that expressing it exercises parts of a shared optimizer nobody else was using.

Swift knows about reference counting, and keeps SIL above LLVM IR because ARC optimization needs to reason about retain/release pairs in terms Swift understands. Once lowered, those are just calls, and the optimizer cannot tell that a retain and a release cancel.

Fortran knows that arrays do not alias by default, which is the single biggest reason Fortran numeric code has historically outperformed the equivalent C. Preserving that through to LLVM IR is again an attribute question, and getting it wrong in either direction either loses the performance or licenses a wrong transformation.

The pattern is consistent: the shared IR is the intersection of what its producers have in common, plus a growing set of attributes for the things they do not. Every attribute is a guarantee a frontend can assert and an obligation every pass must respect, and each one is a place a frontend can assert something false.

What each frontend knows, and what happens to it at the IR boundaryimplementation
FrontendKnows something LLVM IR does notHow it survives, or does not
rustc&mut T references never aliasEncoded as the noalias parameter attribute — expressible, and historically a source of latent optimizer bugs when actually used
swiftcWhich retain/release pairs cancelKept in SIL and optimized before lowering; once in LLVM IR they are opaque calls
flangFortran array arguments do not alias by defaultEncoded as noalias, which is where much of Fortran's numeric performance advantage actually lives
clang (C/C++)Signed overflow is undefinedEncoded as nsw on arithmetic — the flag that turns a language rule into an optimizer licence, see [[ub-and-optimization]]

The arrangement in reverse: one frontend, many backends

The same interface read the other way is what gives a single language many targets. Rust supports a long list of architectures, and rustc contains code for approximately none of them — the target list is LLVM's target list, and the tier system that Rust publishes is largely a statement about how well-tested each LLVM target is under Rust rather than about rustc itself.

This is also why WebAssembly arrived so quickly across so many languages. Once there was an LLVM WebAssembly backend, every LLVM-based frontend could target it; the work each language then did was runtime work — how to represent its heap, how to interact with the host — not code generation. [[webassembly]] and [[wasm-vs-native]] are where that story continues.

And it is why a language that writes its own backend, as Go did, gets something real in exchange for real cost: complete control over compile speed and over the code generated for its own idioms, and an obligation to implement every architecture itself. Go's famously fast builds are not unrelated to its refusal of this arrangement — see [[go-pipeline]].

The shared blast radius

One optimizer for six languages means one optimizer bug for six languages. This is not a theoretical concern: an LLVM miscompilation is simultaneously a C bug, a Rust bug, a Swift bug and a Julia bug, and the affected projects usually discover it independently before anyone connects the reports.

The mitigations are the ones this domain names elsewhere. [[compiler-fuzzing]] generates programs and checks that optimization does not change their behavior. [[differential-testing]] compiles the same program at two optimization levels or with two compilers and compares. [[translation-validation]] checks individual transformations rather than whole compilers. All three are funded activities at every organisation that depends on LLVM, which is the practical measure of how much this cost is taken seriously.

It is also why [[ir-verification]] runs after every pass in a debug build. Shared infrastructure amplifies both the value of catching a bug early and the cost of not doing so.

How it works

The steps, in the order the compiler takes them.

  • Each frontend performs its own lexing, parsing, name resolution, type checking and language-specific analysis, producing its own internal representation.
  • Each frontend lowers that representation to LLVM IR, encoding whatever language guarantees the IR can express as attributes and flags on instructions and parameters.
  • The middle-end runs target-independent passes over the IR with no knowledge of which frontend produced it.
  • A code generator for the chosen target performs instruction selection, scheduling and register allocation, and emits an object file.
  • The system linker composes those object files, at which point the frontends are indistinguishable again — which is what makes cross-language linking work at all.

How it breaks

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

  • A frontend emits an attribute asserting something its language does not actually guarantee, and the optimizer produces code that is wrong only at higher optimization levels — the hardest class of bug to attribute, because the frontend and the optimizer are each individually defensible.
  • A frontend declines to emit an attribute it could, and the language is quietly slower than a competitor for reasons no profile explains, because the missing information is invisible in every representation you can look at.
  • An optimizer bug is fixed for one language's reproducer and the fix breaks another language's working code, because both were relying on different readings of the same under-specified behaviour.
  • A new backend lands and one frontend's output breaks on it, because that frontend had been relying on unstated behaviour common to the existing backends.
  • A frontend pins an old LLVM release to avoid churn, and slowly loses access to target support and bug fixes that only exist upstream.

When it helps

  • Starting a new language. Emitting LLVM IR gets a competent optimizer and a long target list on day one, which is the difference between a two-year project and a ten-year one.
  • Cross-language interoperability at the object-file level. Once two languages emit compatible object files with compatible ABIs, linking them is the linker's ordinary job — [[abi]].
  • Adding a target. One backend serves every frontend, which is how new architectures reach entire language ecosystems rather than one language at a time.

When it hurts

  • When compile time is the product. LLVM at -O2 is not fast, and a language that values build speed above generated-code quality will find that constraint immovable — which is precisely the tradeoff Go declined.
  • When the language's distinguishing guarantees do not survive the boundary. If the interesting optimizations all need information LLVM IR cannot carry, you will write a private IR too, and then you are maintaining two optimizers.

What it costs

Every one of these is paid by something.

  • Sharing an optimizer buys an enormous amount of engineering for free and costs a large dependency with its own release cadence, its own compile-time profile and its own bugs — none of which you control.
  • Encoding a language guarantee as an IR attribute buys the optimization and pays with exposure: you are now exercising paths in a shared optimizer that other producers may never have tested.
  • Keeping a private IR above the shared one buys language-specific analysis and pays with a second optimizer to build, test and maintain.
  • Inheriting every target buys reach and pays in the obligation to test on targets nobody on the team uses, because a target that is not tested under your frontend is a target that works by coincidence.

What else you could do

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

  • Write your own backend, as Go did: fast compilation, full control, and every architecture is your problem — [[go-pipeline]].
  • Target a portable bytecode and ship it, as the JVM and .NET ecosystems do. Many languages share one runtime rather than one compiler, which moves the sharing point from build time to run time — [[bytecode]].
  • Transpile to an existing language and inherit its entire toolchain. Cheapest of all, and you inherit its semantics and its diagnostics too — [[typescript-pipeline]].
  • Use Cranelift instead of LLVM when compile speed matters more than peak code quality, which is the tradeoff a JIT or a fast development build actually wants.

See it for yourself

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

  • clang -S -emit-llvm -o - hello.c and rustc --emit=llvm-ir -o - hello.rs: two languages, one instruction set. Reading them side by side is the fastest demonstration of the claim.
  • llc -version lists every target the local LLVM build supports — that list is exactly what an LLVM-based frontend inherits.
  • rustc --print target-list prints Rust's target list, which is largely LLVM's with Rust-specific tiering applied.
  • opt -passes='default<O2>' -print-after-all file.ll prints the IR after every pass, showing the shared middle-end operating on IR whose origin it cannot determine.
  • Compiler Explorer with two panes in two languages and the LLVM IR output filter, which makes the convergence visible without any local toolchain.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "LLVM is a compiler." LLVM is a middle-end and a set of code generators. Clang is the C and C++ compiler built on it, and confusing the two loses the entire point of the arrangement.
  • "All LLVM languages produce similar code." They share the optimizer, not the IR they feed it. What each frontend emits differs enormously, and that is where language performance differences mostly live.
  • "If a language uses LLVM it inherits every LLVM target." It inherits the code generators. Whether the target works depends on runtime support — unwinding, threading, GC — that the backend has nothing to do with.
  • "Using LLVM means you cannot have language-specific optimization." Swift and Rust both have substantial language-specific optimizers, above LLVM, on their own IRs. Using a shared backend does not preclude a private middle-end.

Misconceptions

The claim, and what is actually true.

Sharing a backend means the languages are similar.
They share the part of compilation that stopped being about the source language. Everything that makes C++ and Rust different happens entirely above the IR boundary.
A frontend just has to emit correct-looking IR.
It has to emit IR whose meaning under the IR's semantics matches its language's semantics. Attributes such as nsw and noalias are assertions, and an incorrect assertion is a miscompilation the frontend caused.
Go not using LLVM was a mistake.
It was a trade. Go bought compile speed and control of its own backend, and pays by implementing every target itself. Both sides of that trade are visible in the project today.

Go deeper

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

overview

Several unrelated languages compile through the same optimizer and the same code generators, because each of their compilers ends by producing the same instruction set. Everything that makes a language distinctive happens before that point; everything shared happens after it. That single agreement is what lets a new language get a serious optimizer and dozens of target architectures without writing either.

practical

If you are building a language, the decision is whether to emit LLVM IR, emit Cranelift IR, or write a backend. LLVM gives the best generated code and the longest target list, and costs a large dependency and slow optimized builds. Cranelift compiles far faster with somewhat weaker output, which is the right trade for a JIT or a fast development cycle. Writing your own is justified when compile speed is the product or when your language needs something no shared IR can express — and both of those are claims you should be able to state precisely before acting on them.

advanced

The most interesting failure mode of shared infrastructure is asymmetric exposure. Attributes like noalias exist for everyone but are exercised heavily by only some producers, so the optimizer paths they enable are, in practice, tested by whoever emits them most. When a new frontend starts emitting an under-exercised attribute, it finds bugs that have been latent for years — which is exactly what happened when rustc enabled noalias and had to disable it again several times. The lesson generalises beyond compilers: in shared infrastructure, the cost of using a rarely-used feature is not the feature, it is being the one who discovers what it is like to depend on it.

How much this depends on

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

implementationThis describes LLVM in the mid-2020s. The frontend list, the attribute set and the target list all change every release, and LLVM IR is not a stable interface across versions — out-of-tree frontends track releases rather than pinning. GCC has the same architecture internally without the external-frontend property, because its middle-end is not a supported public interface.
typicalThe claim that a frontend inherits every target holds for code generation and not for runtime requirements. A language needing precise garbage collection, structured unwinding or thread-local storage on an unusual target does not get that target working for free just because instruction selection succeeds — the stack maps and unwind tables are per-target obligations of their own, and [[stack-unwinding]] covers the compiler side of them.

If you were asked this in an interview

  • Clang and rustc share an optimizer. What exactly do they share, and what do they definitely not?
  • Rust knows &mut references cannot alias. Trace what happens to that fact between rustc and machine code.
  • What did Go buy by writing its own backend, and what did it pay?

Connections

Domains that do not exist yet
  • DevOps / Production Engineering — Depending on a large upstream project: release cadence, vendoring, patch backports
    Adopting LLVM is a build and release commitment as much as a compiler-design one, and the questions it raises — which release to pin, whether to vendor, how to carry local patches — are that domain's, and are frequently what decides the choice.
  • Testing & Reliability Engineering — Blast radius of shared dependencies, and testing strategies for them
    One optimizer serving six languages is a shared-dependency risk problem before it is a compiler problem, and the reasoning about how much testing shared infrastructure deserves belongs there.