English
A compiler is a program that translates programs. The source language is designed for human reasoning — it has named variables, structured control flow, abstract types, and high-level operations. The target is designed for machine execution — it has registers, memory addresses, arithmetic on bits, and unconditional branches. The translation must be semantics-preserving: the compiled program must do what the source program specifies, despite the two representations having almost nothing in common structurally. The compiler’s task is to bridge this representational gap, and it turns out to require substantially more than straightforward translation.
A language runtime is the infrastructure that executes a compiled program. For low-level languages like C and Rust, the runtime is thin: the operating system loader, the C standard library, and possibly a minimal startup sequence. For languages with managed memory, the runtime includes a garbage collector that tracks and reclaims unreachable objects. For dynamically typed languages like Python and JavaScript, the runtime includes type dispatch logic and may include a just-in-time compiler that recompiles hot code based on observed runtime behavior. For concurrent languages, the runtime includes a thread scheduler or an event loop. The runtime is not an afterthought; it is the execution context that determines what programs in the language can do and how efficiently they do it.
These two subjects appear together because the boundary between them has eroded. Modern optimizing compilers generate code that is only correct in concert with specific runtime support — garbage collection, virtual dispatch tables, exception handling infrastructure. JIT compilers are simultaneously compiler and runtime, translating code during execution and deoptimizing when speculative assumptions prove wrong. Profile-guided optimization feeds runtime measurements back into the static compiler, blurring the batch-process model of traditional compilation. The toolchain-and-runtime system as a whole is the meaningful unit, not either component separately.
Prerequisites: Programming language theory (§3.4) — semantics, type systems, and the formal concepts that define what compilers must preserve. Computer organization and architecture (§4.1) — the target side of compilation; what registers, pipelines, and caches mean for code generation. Operating systems (§4.2) — the environment in which runtimes operate; memory management, process model, dynamic linking.
From Translation to Systems: How Compiler Engineering Matured
Compilers are nearly as old as programmable computers. The first high-level language compiler was Grace Hopper’s A-0 system in 1952, which translated mathematical notation into machine instructions. FORTRAN, developed at IBM by John Backus and colleagues between 1954 and 1957, was the first production optimizing compiler — it had to be, because the programming community was skeptical that machine-generated code could match hand-written assembly. Backus’s team produced a compiler that, for many workloads, matched or exceeded hand-written code. FORTRAN’s success established that automatic code generation was not only possible but practical, and the compiler became a central artifact of software infrastructure.
The theoretical foundations arrived alongside the early compilers. Noam Chomsky’s 1956-1959 work on formal grammars established the hierarchy of language classes — regular, context-free, context-sensitive, recursively enumerable — that determined which parsing algorithms were applicable to which languages. Context-free grammars, the Chomsky hierarchy’s second tier, turned out to match the structure of most programming language syntax closely enough to be practically useful. The parsing problem for context-free languages was solved by a succession of algorithms — top-down recursive descent, Earley’s algorithm (1968), the SLR/LALR family that underlies most practical parser generators, and later the GLL and GLR algorithms for ambiguous grammars. By the 1970s, parsing was largely a solved problem: given a grammar, the front-end could be generated automatically. Lex and Yacc, the Unix lexer and parser generator tools, made this practical; modern descendants (ANTLR, tree-sitter) are more powerful and more widely used.
Garbage collection is the most important runtime innovation, and it arrived early. John McCarthy invented mark-and-sweep garbage collection for Lisp in 1960 — the first system to automatically reclaim unreachable memory. The alternative, manual memory management, placed responsibility for tracking object lifetimes on the programmer, at substantial cost in programmer effort and program reliability. The debate between automatic and manual memory management has continued for sixty years, with each approach refining over time: garbage collection through generational schemes (Ungar and Jackson, 1984), concurrent and incremental collectors that reduce pause time, and recent low-pause collectors (Shenandoah at Red Hat, ZGC at Oracle, C4 at Azul) that approach pause times of milliseconds on terabyte heaps. Manual memory management through C++’s RAII (resource acquisition is initialization), smart pointers, and ultimately Rust’s ownership type system, which enforces memory safety statically. The ownership model, formalized in the type theory of linear types (§3.5), demonstrates that static analysis can provide memory safety without runtime garbage collection — a result whose practical demonstration in Rust has been one of the most significant developments in language runtime engineering of the past decade.
The optimization problem — transforming programs to execute faster while preserving semantics — developed as a largely independent thread. Early compilers did little optimization; it was assumed that programmers or assemblers would handle performance. As compiler output quality became respectable, the question shifted to how to do more. The 1960s and 1970s produced the foundational analysis framework: dataflow analysis, which propagates information forward or backward through a program’s control flow graph to determine what is true at each program point; the use-define chains that track the relationship between where a value is produced and where it is used; loop optimizations including loop invariant code motion (moving loop-invariant computations outside the loop), strength reduction (replacing expensive operations with cheaper equivalents), and loop unrolling. The control flow graph, the basic block, and the concept of dominance — which nodes in the control flow graph must be executed before reaching a given node — are the conceptual vocabulary of optimizer design that has remained stable for fifty years.
Static Single Assignment (SSA) form, introduced by Cytron, Ferrante, Rosen, Wegman, and Zadeck in 1991, was the most productive single advance in intermediate representation design. In SSA, every variable is assigned exactly once; phi functions at join points in the control flow graph select among incoming values. SSA simplifies many analyses that are expensive or imprecise on general code: constant propagation becomes trivial (trace each value to its unique definition), dead code elimination is clean (a definition with no uses is dead), and many optimization interactions that require iteration on non-SSA form can be resolved in a single pass. SSA is now the standard intermediate form in all major optimizing compilers, including GCC, LLVM, V8, and HotSpot. Its adoption was one of the events that separated the modern era of compiler engineering from the classical era.
The transformation of compiler infrastructure came with LLVM, begun by Chris Lattner as a research project at the University of Illinois in 2000 and released as open source in 2003. LLVM’s design was unusual for its time: a compiler infrastructure designed explicitly for reuse, with a well-defined intermediate representation (LLVM IR) that any front-end could produce and any back-end could consume, and a library-based architecture that allowed tools to use only the components they needed. Before LLVM, most new languages required writing their own code generation and register allocation from scratch — years of infrastructure work before the language could produce competitive performance. After LLVM, a new language needed only a front-end that produced LLVM IR; the optimization, code generation, and register allocation came for free. Swift, Rust, Julia, Zig, Mojo, and dozens of other languages have been built on LLVM. The consolidation it enabled has shifted the intellectual center of new language development from back-end engineering to language design and front-end engineering.
Just-in-time (JIT) compilation arrived as a response to the performance problems of interpreted languages. The JVM, designed by Sun Microsystems for Java in 1995, initially interpreted bytecode — which was slow. HotSpot, developed at Sun and open-sourced with the JVM, introduced adaptive compilation: identify the methods that execute most frequently, compile them to native code, and profile the compiled code to guide further optimization. A HotSpot JIT observes which virtual call sites have been seen to dispatch to a single target and inlines the call speculatively; if the assumption is later violated, it deoptimizes — reverts the compiled code to interpreted execution — and recompiles with a more conservative treatment. V8, Google’s JavaScript engine introduced in 2008, brought the same adaptive compilation approach to JavaScript and demonstrated that a dynamically typed language with no static type information could be compiled to code competitive with statically typed languages on typical workloads. The success of V8 established JIT compilation as standard technique for high-performance dynamic language implementation and drove a generation of language runtime development — LuaJIT, PyPy, the SpiderMonkey improvements in Firefox, and the continued development of HotSpot.
The most recent major development is ML compiler engineering. The training and inference of machine learning models requires operations — matrix multiplications, convolutions, attention, scatter/gather — that traditional compilers were not designed for. Mapping these operations efficiently onto heterogeneous hardware (GPUs, TPUs, specialized accelerators) requires compiler-style transformations: operator fusion, memory layout optimization, tiling for cache efficiency, quantization. TVM (2018), XLA (Google’s compiler for TPUs), and Halide (for image processing pipelines) are specialized compilers for these domains. MLIR, developed by Lattner and colleagues at Google and contributed to the LLVM project in 2019, provides extensible compiler infrastructure through a hierarchy of dialects — each dialect represents a different level of abstraction — allowing compilation to be progressive: high-level tensor operations are lowered through increasingly concrete dialects until reaching LLVM IR or hardware-specific representations. MLIR has become the infrastructure for a generation of ML compilers and has fed back into general-purpose compiler design through the generality of its dialect system.
Translation, Optimization, and Runtime Adaptation
The Intermediate Representation as Engineering Decision
A compiler does not translate source to machine code in a single step; it passes through a sequence of intermediate representations chosen to make specific analyses tractable. The front-end produces an abstract syntax tree (AST) that reflects the source language’s structure. The middle-end translates the AST into one or more IRs optimized for analysis; LLVM IR is a typed, SSA-form, three-address code that makes most optimizations straightforward to implement and verify. The back-end translates the optimized IR into target-specific machine code, performing register allocation (deciding which values live in registers versus memory) and instruction selection (choosing machine instructions to implement IR operations).
The choice of IR at each level determines what information is available for analysis and what optimizations are possible. LLVM IR is expressive enough to represent most source-language constructs but abstract enough that machine-independent optimizations remain clean. It has types, but only machine types — integers and floating-point values, pointers, vectors — not source-language types. It has no loops — only branches and phi nodes — making control flow explicit. And it is in SSA form, making data flow explicit. These properties together make LLVM IR the right representation for the optimization passes that constitute the bulk of modern compiler engineering.
MLIR’s contribution is the observation that compilation for different domains requires different IRs. Tensor programs have structure — shapes, data types, layouts, contraction patterns — that LLVM IR cannot represent without losing the information that enables high-level optimizations like operator fusion. MLIR’s dialect system allows domain-specific operations to be represented in a domain-appropriate form and progressively lowered through a sequence of increasingly concrete dialects. The same compilation infrastructure handles both high-level domain-specific operations and low-level machine code generation, connected by a chain of lowering passes.
Optimization as Semantics-Preserving Transformation
Compiler optimization is constrained by a requirement that is both simple and demanding: every transformation must preserve the observable behavior of the program. This is the soundness requirement. An optimization that produces faster code at the cost of changing the program’s output is not an optimization but a miscompilation.
Constant propagation replaces uses of a variable with its value when that value can be determined statically: if a variable is assigned 5 and used before any other assignment, the use can be replaced by 5. Dead code elimination removes code that cannot affect observable output: assignments to variables that are never subsequently read are dead. Inlining replaces a function call with a copy of the function body, enabling the optimizer to reason about the call’s context — constant arguments, unused return values — and to eliminate call overhead. Loop invariant code motion moves computations out of loops when their values do not change across loop iterations. Each of these is semantics-preserving in the sense that the transformed program has the same observable behavior as the original.
The soundness proof can be formal — CompCert, a verified C compiler, provides Coq proofs that each optimization pass preserves semantics — or informal, relying on careful analysis and testing. Most production compilers use informal verification supported by extensive test suites. Compiler bugs that violate soundness — miscompilations — are rare in mature compilers but do occur, and they are among the most difficult bugs to diagnose because the program appears to have no bug while its compiled form behaves incorrectly.
Profile-guided optimization (PGO) extends static optimization with empirical data. An instrumented build collects data on which branches are taken, which functions are called how often, and what values appear at call sites. A subsequent compilation uses this data to guide decisions: which call sites to inline, which branches to predict as likely, how to lay out code for cache efficiency. PGO typically yields 10-30% performance improvements on workloads similar to the profiling data, at the cost of the two-build process and the requirement that profiling data reflects production workloads.
Runtime Systems: Garbage Collection and Concurrency
The runtime is not simply a support library; it is an active participant in program execution. A garbage collector continuously identifies and reclaims unreachable memory. A JIT compiler observes hot code and recompiles it. A coroutine scheduler multiplexes many logical threads over fewer OS threads. Understanding the runtime is necessary for reasoning about program performance and for diagnosing production problems.
Tracing garbage collectors (mark-and-sweep, mark-and-compact) identify live objects by traversing the object graph from roots (stack references, global variables) and marking all reachable objects; unreachable objects are swept or compacted away. Generational collectors exploit the empirical observation that most objects die young: most allocation occurs in a young generation that is collected frequently with low cost, while long-lived objects are promoted to older generations collected less frequently. Concurrent and incremental collectors perform garbage collection work alongside the application, reducing pause times at the cost of more complex synchronization. Shenandoah and ZGC, the low-pause collectors in the JVM, can collect terabyte heaps with pauses under a millisecond by performing most collection work concurrently with the application.
Reference counting, the alternative to tracing collection, deallocates objects immediately when their reference count drops to zero. It provides deterministic deallocation timing and is appropriate for systems where memory management latency matters — embedded systems, real-time systems — but requires handling cycles explicitly (objects that reference each other cannot be collected by reference counting alone without additional cycle detection). CPython uses reference counting with a cycle detector; Rust’s ownership model statically prevents cycles in owned data, making reference counting (the Rc and Arc types) safe without a cycle detector.
The Go runtime’s goroutine scheduler and the Rust async executor represent the two major approaches to concurrency in modern runtimes. Go provides language-level goroutines — lightweight threads multiplexed by the Go runtime’s M:N scheduler over OS threads — with a preemptive scheduler that can interrupt goroutines at safe points. Rust’s async/await system provides zero-cost abstractions over futures — computations that can pause and resume — executed by a user-space executor. Both approaches allow much higher concurrency than OS threads, but they have different performance characteristics, different debugging experiences, and different interactions with blocking operations.
What Studying This Changes
Compilers and runtimes change how practitioners understand and diagnose the gap between source code and execution.
The first change is fluency reading compiled output. The practitioner who has studied compilers can look at assembly output — from godbolt.org, from a disassembler, from a JIT’s output — and understand what the compiler did and why. Is this loop vectorized? Why was this function not inlined? Is this branch predicted as likely? The answers are visible in the assembly if you can read it, and they are invisible if you cannot. This fluency enables the practitioner to predict how source-level changes will affect compiled output, to verify that the compiler is doing what they expect, and to diagnose performance anomalies that are invisible at the source level.
The second change is the ability to reason about garbage collection as engineering. Every language runtime with garbage collection makes specific tradeoffs: throughput versus pause time, memory overhead versus collection frequency, simplicity versus concurrent collection. A practitioner who understands GC can diagnose latency spikes as GC pauses, memory bloat as retention of objects the collector cannot reclaim, and throughput degradation as excessive collection overhead. They can tune GC parameters with understanding of what effect each tuning will have, rather than cargo-culting settings from Stack Overflow. They can recognize code patterns that interact poorly with the collector — object churn, large object allocation, weak reference abuse — and avoid them.
The third change is the disposition for designing small languages. Most engineers eventually encounter a situation where a configuration language, a query language, or a domain-specific language would simplify a problem. The engineer who has studied compilers approaches this design more carefully: they understand what parsing complexity follows from language design decisions, what semantic choices will require complex runtime support, and what the maintenance cost of different implementation strategies will be. The disposition shows up even when no formal compiler is written — in the design of embedded DSLs, in the design of APIs that approach language-like expressiveness, in the design of configuration formats with non-trivial semantics.
Resources
Books and Texts
Nystrom’s Crafting Interpreters (free at craftinginterpreters.com) is the most accessible entry to compiler and interpreter implementation. It walks through building two implementations of a small language: a tree-walking interpreter in Java and a bytecode virtual machine in C. The prose is unusually clear, the code is careful, and the progression from simple to sophisticated is well-paced. A learner who works through both implementations has built something real and understood it in a way that reading alone cannot provide. The limitation is scope: Crafting Interpreters covers the front-end and a simple runtime but does not address optimization, SSA form, or code generation for real hardware. It is the right first book; it needs follow-up.
Cooper and Torczon’s Engineering a Compiler (3rd ed., Elsevier, 2022) is the current standard primary text for compiler courses. Its distinctive emphasis on the middle-end — SSA construction, dataflow analysis, the major optimization algorithms, register allocation, and instruction scheduling — reflects what production compiler engineering actually involves. The treatment is engineering-oriented rather than theory-heavy, with enough formalism to be precise and enough working detail to be applicable. A learner who completes it has a solid foundation for working with LLVM, for contributing to production compilers, or for building a new language’s front-end on top of compiler infrastructure.
Appel’s Modern Compiler Implementation (in ML, Java, or C, Cambridge, 1998) is the alternative classic, organized around a complete working compiler project. The ML version is pedagogically clearest. Where Cooper-Torczon covers more breadth in optimization, Appel covers the full pipeline from scanning through code generation with a single coherent project. Both are suitable primary texts; learners who prefer building before reading will prefer Appel.
Aho, Lam, Sethi, and Ullman’s Compilers: Principles, Techniques, and Tools (the Dragon Book, 2nd ed., 2006) was the standard text for decades. Its comprehensive treatment of parsing theory remains unmatched. For optimization and code generation, Cooper-Torczon is now preferable. The Dragon Book is the right reference for parsing questions; it is not the right primary text for modern compiler study.
Jones, Hosking, and Moss’s The Garbage Collection Handbook (2nd ed., CRC Press, 2016) is the standard reference for garbage collection algorithms. It covers tracing collection, generational collection, incremental and concurrent collection, reference counting, and the tradeoffs among them with a comprehensiveness that no other source matches. For anyone working on language runtimes, memory management in systems software, or the performance analysis of GC-managed applications, it is the reference.
Muchnick’s Advanced Compiler Design and Implementation (Morgan Kaufmann, 1997) is dated but authoritative on optimization. Its coverage of the major optimization techniques — value numbering, global code motion, alias analysis, loop transformations — is deeper than any more recent text. Use it as a reference for specific optimizations rather than as a front-to-back read.
| Book | Role | Type |
|---|---|---|
| Nystrom, Crafting Interpreters (free) | Accessible entry with two working implementations | Practice |
| Cooper & Torczon, Engineering a Compiler (3rd ed.) | Current standard primary text | Entry |
| Appel, Modern Compiler Implementation (ML/Java/C) | Complete-project alternative primary text | Practice |
| Aho, Lam, Sethi & Ullman, Compilers (Dragon Book) | Parsing reference; not primary text | Reference |
| Jones, Hosking & Moss, The Garbage Collection Handbook (2nd ed.) | GC algorithms reference | Reference |
| Muchnick, Advanced Compiler Design and Implementation | Optimization techniques at depth | Depth |
Courses and Lectures
Stanford CS 143 (Compilers, materials freely available) provides a comprehensive compiler course with project assignments building a compiler for the COOL (Classroom Object-Oriented Language) language. The assignments progress from lexer through parser to semantic analysis to code generation, providing the complete pipeline experience.
Cornell CS 6120 (Advanced Compilers, Adrian Sampson, free at cs.cornell.edu/courses/cs6120) is an advanced course specifically designed around LLVM and modern compiler infrastructure. The course covers SSA form, data flow analysis, optimization passes, and the research frontier of compiler engineering. The assignments involve implementing optimization passes in LLVM, providing direct experience with production compiler infrastructure.
Nand to Tetris (§2.1) provides the full hardware-and-software stack perspective: building a virtual machine and compiler for the Jack language, producing Hack assembly. The breadth compensates for the depth: after Nand to Tetris, Crafting Interpreters provides the depth in interpreter implementation.
| Course | Platform | Type |
|---|---|---|
| Stanford CS 143 Compilers (free) | Stanford / course site | Entry |
| Cornell CS 6120 Advanced Compilers (free) | cs.cornell.edu/courses/cs6120 | Depth |
Practice, Tools, and Current Sources
Compiler Explorer (godbolt.org, free) is the essential interactive tool. It compiles source code in dozens of languages with dozens of compilers and shows the assembly output, with source and assembly lines linked. Use it from the beginning of compiler study: compile a simple loop, observe the assembly, enable optimization (-O2), compare the output. Compile the same code in C, Rust, and Go and observe the differences. When studying a specific optimization, write code that the optimizer should apply it to and verify that it does. Production engineers use Compiler Explorer routinely; learners should adopt it immediately.
The LLVM Kaleidoscope tutorial (free at llvm.org/docs/tutorial) walks through building a compiler for a simple functional language using LLVM as back-end. It is the standard introduction to LLVM-based compiler development, covering how to produce LLVM IR from a front-end and how to invoke the LLVM infrastructure for optimization and code generation.
Reading the source code of production runtimes provides understanding that documentation cannot. OpenJDK’s HotSpot source code, with its JIT compiler and generational garbage collector, is large but well-documented. The Go runtime source, substantially smaller, is the clearest example of an M:N thread scheduler and garbage collector in a production language. V8’s source, with its Ignition interpreter and TurboFan JIT, demonstrates the adaptive compilation techniques that make JavaScript fast. Any of these is a serious learning project; the effort reveals engineering choices that documentation describes abstractly but code makes concrete.
| Resource | Platform | Type |
|---|---|---|
| Compiler Explorer (free) | godbolt.org | Practice |
| LLVM Kaleidoscope tutorial (free) | llvm.org/docs/tutorial | Practice |
| OpenJDK HotSpot source | github.com/openjdk/jdk | Reference |
| V8 source and blog | github.com/v8/v8, v8.dev/blog | Reference |
| Go runtime source | github.com/golang/go/tree/master/src/runtime | Reference |
Traps
| Trap | Why it misleads | Better response |
|---|---|---|
| The Dragon Book as the primary text | The Dragon Book’s emphasis falls on parsing theory and front-end concerns. Parsing is largely a solved problem — parser combinators and recursive descent handle most practical cases cleanly. The optimization, code generation, and runtime concerns that dominate contemporary compiler engineering receive limited treatment in the Dragon Book. A learner who completes it as a primary text has studied the minority of compiler engineering that is least active. | Use the Dragon Book as a parsing reference. Use Cooper-Torczon as the primary text; its emphasis on SSA form, dataflow analysis, register allocation, and instruction scheduling reflects what production compiler engineering actually involves. |
| Treating compilers as front-end work | Many compiler courses emphasize lexers, parsers, and type checkers because this material fits tidily into a single project and is conceptually accessible. The intellectual center of production compiler engineering is the middle-end: IR design, SSA form, dataflow analysis, optimization passes, register allocation. Front-end engineering is largely automated (parser generators) and largely solved; optimization and code generation are where genuine engineering judgment is required. | After any introductory course that emphasizes front-end work, deliberately engage with middle-end material. Read the SSA construction algorithm. Implement a dataflow analysis. Work through the Kaleidoscope tutorial to build an optimizing compiler using LLVM infrastructure. Cornell CS 6120 provides the advanced course that most introductory courses omit. |
| Treating the runtime as a black box | Applications in GC-managed languages — Java, C#, Go, Python, JavaScript — run within runtime systems that actively participate in execution. A GC pause is not a bug in the application; it is the runtime fulfilling its contract. A goroutine that blocks is not stuck; it is suspended by the scheduler. Engineers who treat runtimes as opaque reach a ceiling when production problems trace to GC behavior, scheduler starvation, or memory pressure. The runtime is not implementation detail — it is an active participant in application behavior. | Engage with the runtime of any language you use seriously. Read GC logs. Understand what a GC pause histogram is and how to produce one. Know the major GC algorithms and what tradeoffs they make. For Go, read the goroutine scheduler documentation. For JavaScript, understand V8’s generational heap and what object allocation patterns trigger promotion. |
| Overconfidence after one course | Compiler construction courses cover enough material to produce competence in a toy compiler, which some learners mistake for competence in production compiler engineering. The gap between completing an introductory compiler course and understanding what GHC, V8, or LLVM actually do is enormous. LLVM has millions of lines of code; its optimization infrastructure alone represents decades of engineering effort. | Recognize the gap explicitly. After an introductory course, engage with production compiler material: read parts of the LLVM source code; study the internals documentation of a runtime you use; implement optimization passes in LLVM using the Cornell CS 6120 framework. The discipline as practiced in production differs substantially from the discipline as taught in introductory courses. |
| JIT as universally superior to ahead-of-time compilation | JIT compilation provides genuine capabilities — recompilation based on observed behavior, speculative inlining, deoptimization — but at real costs: startup latency while code is interpreted before compilation, the runtime overhead of the JIT compiler itself, code cache pressure, and difficulty profiling code that changes representation during execution. For command-line tools, startup time matters. For embedded and safety-critical systems, unpredictable compilation pauses are unacceptable. | Treat JIT and AOT compilation as engineering choices with different tradeoffs. Understand which workloads each serves well: JIT favors long-running server applications with warm traffic; AOT favors startup-sensitive tools, embedded systems, and contexts where predictability matters more than peak throughput. The choice is real engineering, not a clear winner. |
| Ignoring the ML compiler frontier | Machine learning compilation — efficiently mapping tensor operations onto heterogeneous hardware — is the most active current subdiscipline of compiler engineering. TVM, XLA, MLIR, and Triton are reshaping what compiler engineers work on. An engineer who only knows traditional compilation has a narrowing skill set in a field where the most active development is in ML compilation. | At minimum, read the MLIR paper to understand the problem it addresses and how its dialect system differs from traditional IR design. Work through the TVM tutorial if ML compiler work is a career interest. The techniques of traditional compilation (dataflow analysis, optimization, code generation) transfer directly; what changes is the domain and the hardware targets. |
中文
编译器是一个翻译程序的程序。源语言是为人类推理而设计的——它有命名变量、结构化控制流、抽象类型和高级操作。目标语言则是为机器执行而设计的——它有寄存器、内存地址、bit 上的算术,以及无条件跳转。这种翻译必须保持语义:编译后的程序必须执行源程序所规定的行为,尽管这两种表示在结构上几乎没有共同点。编译器的任务就是弥合这种表示鸿沟,而事实证明,这远远不只是直接翻译。
语言运行时是执行已编译程序的基础设施。对于 C 和 Rust 这样的低层语言,运行时很薄:操作系统 loader、C standard library,以及可能存在的最小启动序列。对于带有托管内存的语言,运行时包含垃圾回收器,用来追踪并回收不可达对象。对于 Python 和 JavaScript 这样的动态类型语言,运行时包含类型 dispatch 逻辑,并可能包含 just-in-time compiler,它会根据观察到的运行时行为重新编译 hot code。对于并发语言,运行时包含线程调度器或 event loop。运行时不是事后补充;它是执行语境,决定了该语言中的程序能做什么,以及能以多高效率做到。
这两个主题之所以放在一起,是因为二者之间的边界已经被侵蚀。现代 optimizing compilers 生成的代码,只有与特定运行时支持——garbage collection、virtual dispatch tables、exception handling infrastructure——配合时才是正确的。JIT compilers 同时是编译器和运行时:它们在执行期间翻译代码,并在 speculative assumptions 被证明错误时 deoptimize。Profile-guided optimization 会把运行时测量反馈给静态编译器,模糊了传统编译作为批处理过程的模型。真正有意义的单位,是 toolchain-and-runtime system 整体,而不是其中任一组件单独存在。
前置知识:编程语言理论(§3.4)——语义、类型系统,以及定义编译器必须保持什么的形式概念。计算机组成与体系结构(§4.1)——编译目标侧;寄存器、流水线和缓存对代码生成意味着什么。操作系统(§4.2)——运行时运行其中的环境;内存管理、进程模型、动态链接。
从翻译到系统:编译器工程如何成熟
编译器几乎与可编程计算机一样古老。第一个高级语言编译器是 Grace Hopper 1952 年的 A-0 system,它把数学记法翻译成机器指令。FORTRAN 由 John Backus 及其同事于 1954 到 1957 年间在 IBM 开发,是第一个生产级 optimizing compiler——它必须如此,因为当时编程共同体怀疑机器生成代码能否匹配手写汇编。Backus 的团队做出了一个在许多工作负载上能够匹配甚至超过手写代码的编译器。FORTRAN 的成功确立了自动代码生成不仅可能,而且实用;编译器也由此成为软件基础设施的核心产物。
理论基础与早期编译器几乎同时到来。Noam Chomsky 在 1956–1959 年间关于 formal grammars 的工作,建立了语言类别层级——regular、context-free、context-sensitive、recursively enumerable——这决定了哪些 parsing algorithms 适用于哪些语言。Context-free grammars 是 Chomsky hierarchy 的第二层,它被证明足够贴近多数编程语言语法结构,因此具有实践价值。Context-free languages 的 parsing 问题由一系列算法逐步解决:top-down recursive descent、Earley 算法(1968)、支撑多数实用 parser generators 的 SLR/LALR 家族,以及后来用于 ambiguous grammars 的 GLL 和 GLR 算法。到 1970 年代,parsing 基本上已经是一个解决了的问题:给定一份 grammar,front-end 可以自动生成。Lex 和 Yacc 这两个 Unix lexer 与 parser generator 工具使这件事变得实用;现代后继者(ANTLR、tree-sitter)更强大,也使用更广泛。
垃圾回收是最重要的运行时创新,而且很早就出现了。John McCarthy 于 1960 年为 Lisp 发明 mark-and-sweep garbage collection——这是第一个自动回收不可达内存的系统。替代方案 manual memory management 会把追踪对象生命周期的责任交给程序员,代价是大量程序员精力和程序可靠性。自动内存管理与手动内存管理之间的争论已经持续六十年,双方都不断改进:garbage collection 发展出 generational schemes(Ungar 和 Jackson,1984)、减少 pause time 的 concurrent 和 incremental collectors,以及近年来低暂停 collectors(Red Hat 的 Shenandoah、Oracle 的 ZGC、Azul 的 C4),它们在 TB 级 heaps 上可以把 pause time 接近毫秒级。Manual memory management 则通过 C++ 的 RAII(resource acquisition is initialization)、smart pointers,最终通过 Rust 的 ownership type system 演化;Rust 在静态层面强制内存安全。Ownership model 在 linear types(§3.5)的类型理论中被形式化,它证明静态分析可以在没有运行时垃圾回收的情况下提供内存安全——而 Rust 对这一点的实践证明,是过去十年语言运行时工程中最重要的发展之一。
优化问题——在保持语义的同时转换程序,使其执行更快——基本上沿着一条独立线索发展。早期编译器几乎不做优化;人们假定程序员或汇编器会处理性能。当编译器输出质量变得可接受后,问题转为如何做得更多。1960 和 1970 年代产生了基础分析框架:dataflow analysis,它沿程序 control flow graph 向前或向后传播信息,以确定每个程序点上什么为真;use-define chains 追踪值在哪里产生、在哪里使用之间的关系;loop optimizations 包括 loop invariant code motion(把循环不变计算移出循环)、strength reduction(用更便宜的等价操作替换昂贵操作),以及 loop unrolling。Control flow graph、basic block,以及 dominance 概念——在 control flow graph 中,到达某个节点之前必须先执行哪些节点——构成了 optimizer 设计的概念词汇,而且五十年来保持稳定。
Static Single Assignment(SSA)form 由 Cytron、Ferrante、Rosen、Wegman 和 Zadeck 于 1991 年引入,是 intermediate representation 设计中最有生产力的单项进步。在 SSA 中,每个变量只被赋值一次;control flow graph 的 join points 上使用 phi functions 在进入值之间做选择。SSA 简化了许多在一般代码上昂贵或不精确的分析:constant propagation 变得直接(把每个值追踪到其唯一 definition),dead code elimination 变得清晰(没有 uses 的 definition 就是 dead),而许多在非 SSA form 上需要迭代处理的优化交互,可以在单次 pass 中解决。SSA 现在是所有主要 optimizing compilers 的标准中间形式,包括 GCC、LLVM、V8 和 HotSpot。它的采用,是编译器工程从古典时代进入现代时代的标志性事件之一。
编译器基础设施的转型随 LLVM 到来。LLVM 由 Chris Lattner 于 2000 年在 University of Illinois 作为研究项目启动,并于 2003 年开源。LLVM 的设计在当时并不寻常:它是一套明确为复用而设计的 compiler infrastructure,拥有定义良好的 intermediate representation(LLVM IR),任何 front-end 都可以生成它,任何 back-end 都可以消费它;同时采用 library-based architecture,使工具只使用自己需要的组件。LLVM 之前,大多数新语言都必须从零编写自己的 code generation 和 register allocation——也就是在语言能产生有竞争力性能之前,先做数年的基础设施工作。LLVM 之后,一门新语言只需要一个能生成 LLVM IR 的 front-end;optimization、code generation 和 register allocation 都可以直接获得。Swift、Rust、Julia、Zig、Mojo 以及许多其他语言都建立在 LLVM 之上。它所实现的整合,把新语言开发的智识中心从 back-end engineering 转移到了语言设计与 front-end engineering。
Just-in-time(JIT)compilation 是对解释型语言性能问题的回应。Sun Microsystems 于 1995 年为 Java 设计的 JVM 最初解释执行 bytecode——这很慢。HotSpot 由 Sun 开发,并随 JVM 开源,它引入 adaptive compilation:识别执行最频繁的方法,把它们编译为 native code,并 profile 已编译代码以指导进一步优化。HotSpot JIT 会观察哪些 virtual call sites 一直 dispatch 到同一个 target,并推测性地 inline 该调用;如果这个假设后来被违反,它会 deoptimize——把已编译代码恢复到解释执行——并用更保守处理重新编译。Google 于 2008 年推出的 JavaScript 引擎 V8,把同样的 adaptive compilation 方法带到 JavaScript,并证明一种没有静态类型信息的动态类型语言,在典型工作负载上也可以被编译到能与静态类型语言竞争的代码。V8 的成功确立了 JIT compilation 作为高性能动态语言实现的标准技术,并推动了一代语言运行时发展——LuaJIT、PyPy、Firefox 中 SpiderMonkey 的改进,以及 HotSpot 的持续发展。
最近的重大进展是 ML compiler engineering。机器学习模型的训练和推理需要一些传统编译器并非为之设计的操作——matrix multiplications、convolutions、attention、scatter/gather。要把这些操作高效映射到异构硬件(GPUs、TPUs、specialized accelerators)上,需要编译器式转换:operator fusion、memory layout optimization、tiling for cache efficiency、quantization。TVM(2018)、XLA(Google 用于 TPUs 的编译器)以及 Halide(用于图像处理 pipelines)都是面向这些领域的专用编译器。MLIR 由 Lattner 及其 Google 同事开发,并于 2019 年贡献给 LLVM 项目,它通过一套 dialects 层级提供可扩展编译器基础设施——每个 dialect 表示一个不同抽象层级——使编译可以渐进进行:高层 tensor operations 经过逐步 lowering,穿过越来越具体的 dialects,直到抵达 LLVM IR 或硬件特定表示。MLIR 已经成为一代 ML compilers 的基础设施,并且通过其 dialect system 的通用性,反过来影响了通用编译器设计。
翻译、优化与运行时适应
Intermediate Representation 作为工程决策
编译器不会一步把源代码翻译成机器码;它会经过一系列 intermediate representations,而这些表示被选择出来,是为了让特定分析变得可处理。Front-end 生成 abstract syntax tree(AST),反映源语言结构。Middle-end 把 AST 翻译成一个或多个适合分析的 IRs;LLVM IR 是一种带类型、SSA-form、three-address code,使大多数优化都容易实现和验证。Back-end 把优化后的 IR 翻译成 target-specific machine code,执行 register allocation(决定哪些值放在寄存器中,哪些放在内存中)和 instruction selection(选择机器指令来实现 IR operations)。
每一层 IR 的选择,决定了分析中有哪些信息可用,以及哪些优化可行。LLVM IR 足够有表达力,可以表示多数源语言构造;同时又足够抽象,使 machine-independent optimizations 保持清晰。它有类型,但只有 machine types——integers 和 floating-point values、pointers、vectors——而不是源语言类型。它没有 loops——只有 branches 和 phi nodes——从而使 control flow 显式化。它采用 SSA form,使 data flow 显式化。这些性质合在一起,使 LLVM IR 成为实现现代编译器工程主体的 optimization passes 的合适表示。
MLIR 的贡献在于指出:不同领域的编译需要不同 IRs。Tensor programs 有结构——shapes、data types、layouts、contraction patterns——这些结构如果被放进 LLVM IR,就会丢失使 operator fusion 等高层优化成为可能的信息。MLIR 的 dialect system 允许 domain-specific operations 以适合该领域的形式表示,并通过一系列越来越具体的 dialects 逐步 lowering。同一套编译基础设施既能处理高层 domain-specific operations,也能处理低层 machine code generation,中间由一条 lowering passes 链连接。
优化作为保持语义的转换
编译器优化受到一个简单但严格的要求约束:每个转换都必须保持程序的 observable behavior。这就是 soundness requirement。一个以改变程序输出为代价产生更快代码的转换,不是优化,而是 miscompilation。
Constant propagation 会在变量值可以静态确定时,把变量使用替换为该值:如果一个变量被赋值为 5,并且在任何其他赋值前被使用,那么该使用可以替换为 5。Dead code elimination 会移除无法影响 observable output 的代码:赋给变量、但变量之后从未被读取的 assignments 是 dead。Inlining 会把一次函数调用替换为函数体副本,使 optimizer 可以根据调用语境进行推理——constant arguments、unused return values——并消除调用开销。Loop invariant code motion 会把循环迭代之间值不变的计算移出循环。每一种转换都是 semantics-preserving 的:转换后程序与原程序具有相同 observable behavior。
Soundness proof 可以是形式化的——CompCert 这个经过验证的 C compiler 提供 Coq 证明,证明每个 optimization pass 都保持语义——也可以是非形式化的,依赖谨慎分析和测试。多数生产编译器使用由大量测试套件支持的非形式化验证。违反 soundness 的 compiler bugs——miscompilations——在成熟编译器中很少见,但确实会发生,而且它们是最难诊断的 bug 之一,因为程序看起来没有 bug,而其编译形式却表现错误。
Profile-guided optimization(PGO)用经验数据扩展静态优化。一个 instrumented build 会收集哪些 branches 被采用、哪些 functions 被调用多少次、call sites 处出现了什么 values 等数据。后续编译会使用这些数据来指导决策:哪些 call sites 应该 inline,哪些 branches 应预测为 likely,如何布置代码以提升 cache efficiency。PGO 通常可以在与 profiling data 相似的工作负载上带来 10–30% 的性能改进,代价是 two-build process,以及 profiling data 必须反映生产工作负载。
Runtime Systems:垃圾回收与并发
运行时不只是 support library;它是程序执行中的主动参与者。垃圾回收器持续识别并回收不可达内存。JIT compiler 观察 hot code 并重新编译它。Coroutine scheduler 把许多 logical threads multiplex 到较少 OS threads 上。理解运行时,对于推理程序性能和诊断生产问题都是必要的。
Tracing garbage collectors(mark-and-sweep、mark-and-compact)通过从 roots(stack references、global variables)遍历 object graph 来识别 live objects,并标记所有 reachable objects;unreachable objects 随后被 sweep 或 compact 掉。Generational collectors 利用经验观察:多数对象很快死亡。大多数 allocation 发生在 young generation 中,它会以低成本频繁回收;长寿对象则被 promoted 到 old generations 中,较少回收。Concurrent 和 incremental collectors 会在应用运行同时执行垃圾回收工作,从而降低 pause times,代价是更复杂的 synchronization。JVM 中的 low-pause collectors Shenandoah 和 ZGC 可以在 TB 级 heaps 上把 pauses 控制在一毫秒以内,因为它们的大部分 collection work 都与应用并发执行。
Reference counting 是 tracing collection 的替代方案。当对象 reference count 降到零时,它会立即 deallocate 对象。它提供确定性的 deallocation timing,适合内存管理延迟重要的系统——embedded systems、real-time systems——但需要显式处理 cycles(相互引用的对象仅靠 reference counting 无法被回收,除非加入额外 cycle detection)。CPython 使用 reference counting,并配有 cycle detector;Rust 的 ownership model 在静态层面防止 owned data 中的 cycles,使 reference counting(Rc 和 Arc 类型)即使没有 cycle detector 也能安全使用。
Go runtime 的 goroutine scheduler 和 Rust async executor,代表了现代运行时并发的两种主要路径。Go 提供语言层面的 goroutines——轻量 threads,由 Go runtime 的 M:N scheduler multiplex 到 OS threads 之上——并配有可以在 safe points 中断 goroutines 的 preemptive scheduler。Rust 的 async/await system 提供基于 futures 的 zero-cost abstractions——也就是可以暂停和恢复的 computations——并由 user-space executor 执行。两种方法都能提供远高于 OS threads 的并发能力,但它们有不同性能特征、不同调试体验,也与 blocking operations 产生不同交互。
学习这一部分会改变什么
编译器与运行时会改变实践者理解和诊断源代码与执行之间鸿沟的方式。
第一个变化,是能够熟练阅读编译输出。学习过编译器的实践者,可以查看汇编输出——来自 godbolt.org、disassembler 或 JIT 输出——并理解编译器做了什么以及为什么这样做。这个循环有没有被 vectorized?这个函数为什么没有 inline?这个 branch 是否被预测为 likely?如果你能读汇编,答案就在汇编中可见;如果不能,它们就是不可见的。这种熟练度使实践者能够预测源代码层面的修改会如何影响编译输出,验证编译器是否按预期工作,并诊断在源代码层面不可见的性能异常。
第二个变化,是把垃圾回收当成工程问题来推理的能力。每种带 garbage collection 的语言运行时,都在做特定权衡:throughput versus pause time、memory overhead versus collection frequency、simplicity versus concurrent collection。理解 GC 的实践者可以把 latency spikes 诊断为 GC pauses,把 memory bloat 诊断为 collector 无法回收对象的 retention,把 throughput degradation 诊断为过高 collection overhead。他们可以理解每个 GC 参数调优会产生什么效果,而不是从 Stack Overflow 照搬设置。他们也能识别那些与 collector 互动不良的代码模式——object churn、large object allocation、weak reference abuse——并避免它们。
第三个变化,是设计小语言的倾向。多数工程师最终都会遇到这种场景:一个 configuration language、query language 或 domain-specific language 可以简化某个问题。学习过编译器的工程师会更谨慎地处理这种设计:他们理解语言设计决策会带来什么 parsing complexity,哪些语义选择会要求复杂运行时支持,以及不同实现策略会带来什么维护成本。即使没有真正写 formal compiler,这种倾向也会表现出来——嵌入式 DSLs 的设计、接近语言表达力的 APIs 设计、具有非平凡语义的配置格式设计,都体现这一点。
资源
书籍与文本
Nystrom 的 Crafting Interpreters(可在 craftinginterpreters.com 免费获取)是编译器和解释器实现最易读的入口。它带领读者为一门小语言构建两个实现:一个用 Java 写的 tree-walking interpreter,以及一个用 C 写的 bytecode virtual machine。行文异常清晰,代码谨慎,从简单到复杂的推进节奏也很好。完成两个实现的学习者,会真正构建出东西,并以单纯阅读无法达到的方式理解它。其限制在于范围:Crafting Interpreters 覆盖 front-end 和一个简单 runtime,但不处理 optimization、SSA form,也不处理真实硬件上的 code generation。它是正确的第一本书;但需要后续补充。
Cooper 和 Torczon 的 Engineering a Compiler(第 3 版,Elsevier,2022)是当前编译器课程的标准主教材。它鲜明强调 middle-end——SSA construction、dataflow analysis、主要 optimization algorithms、register allocation 和 instruction scheduling——这反映了生产编译器工程实际包含的内容。它是工程导向而不是重理论导向;形式化足以精确,工作细节也足以应用。完成这本书的学习者,会拥有使用 LLVM、参与生产编译器,或在 compiler infrastructure 上构建新语言 front-end 的坚实基础。
Appel 的 Modern Compiler Implementation(ML、Java 或 C 版,Cambridge,1998)是另一部经典,它围绕一个完整 working compiler project 组织。ML 版在教学上最清楚。Cooper-Torczon 在优化上覆盖更广,而 Appel 通过单个连贯项目覆盖从 scanning 到 code generation 的完整 pipeline。二者都适合作为主教材;偏好先构建再阅读的学习者会更喜欢 Appel。
Aho、Lam、Sethi 和 Ullman 的 Compilers: Principles, Techniques, and Tools(Dragon Book,第 2 版,2006)曾是几十年的标准教材。它对 parsing theory 的综合处理至今无可匹敌。对于 optimization 和 code generation,Cooper-Torczon 如今更合适。Dragon Book 是 parsing 问题的正确参考;但不是现代编译器学习的正确主教材。
Jones、Hosking 和 Moss 的 The Garbage Collection Handbook(第 2 版,CRC Press,2016)是垃圾回收算法的标准参考。它覆盖 tracing collection、generational collection、incremental 和 concurrent collection、reference counting,以及它们之间的权衡,其综合程度没有其他来源可比。对于任何从事 language runtimes、系统软件内存管理,或 GC-managed applications 性能分析的人来说,它都是参考书。
Muchnick 的 Advanced Compiler Design and Implementation(Morgan Kaufmann,1997)有些年头,但在优化方面具有权威性。它对主要优化技术——value numbering、global code motion、alias analysis、loop transformations——的覆盖深于任何较新的文本。适合把它作为特定优化的参考,而不是从头读到尾。
| 书籍 | 作用 | 类型 |
|---|---|---|
| Nystrom, Crafting Interpreters(免费) | 易读入口,包含两个工作实现 | 实践 |
| Cooper & Torczon, Engineering a Compiler(第 3 版) | 当前标准主教材 | 入门 |
| Appel, Modern Compiler Implementation(ML/Java/C) | 完整项目式替代主教材 | 实践 |
| Aho, Lam, Sethi & Ullman, Compilers(Dragon Book) | Parsing 参考;不适合作主教材 | 参考 |
| Jones, Hosking & Moss, The Garbage Collection Handbook(第 2 版) | GC 算法参考 | 参考 |
| Muchnick, Advanced Compiler Design and Implementation | 深入优化技术 | 深入 |
课程与讲座
Stanford CS 143(Compilers,材料免费可得)提供一门综合编译器课程,其项目任务是为 COOL(Classroom Object-Oriented Language)语言构建编译器。作业从 lexer 到 parser,再到 semantic analysis 和 code generation,提供完整 pipeline 体验。
Cornell CS 6120(Advanced Compilers,Adrian Sampson,可在 cs.cornell.edu/courses/cs6120 免费获取)是一门专门围绕 LLVM 和现代编译器基础设施设计的高级课程。课程覆盖 SSA form、data flow analysis、optimization passes,以及编译器工程研究前沿。作业要求在 LLVM 中实现 optimization passes,从而提供直接使用生产编译器基础设施的经验。
Nand to Tetris(§2.1)提供完整硬件—软件栈视角:构建一台 virtual machine 和 Jack 语言 compiler,生成 Hack assembly。它以广度弥补深度;完成 Nand to Tetris 后,Crafting Interpreters 可以提供解释器实现方面的深度。
| 课程 | 平台 | 类型 |
|---|---|---|
| Stanford CS 143 Compilers(免费) | Stanford / course site | 入门 |
| Cornell CS 6120 Advanced Compilers(免费) | cs.cornell.edu/courses/cs6120 | 深入 |
实践、工具与当前资料
Compiler Explorer(godbolt.org,免费)是必要交互工具。它可以用数十种编译器编译数十种语言的源代码,并展示汇编输出,同时把源代码行与汇编行对应起来。从编译器学习一开始就使用它:编译一个简单循环,观察汇编;开启优化(-O2),比较输出。在 C、Rust 和 Go 中编译同一段代码,观察差异。学习某个具体优化时,写一段 optimizer 应当应用该优化的代码,并验证它确实这样做。生产工程师常规使用 Compiler Explorer;学习者应立即采用它。
LLVM Kaleidoscope tutorial(可在 llvm.org/docs/tutorial 免费获取)带领读者用 LLVM 作为 back-end,为一门简单函数式语言构建 compiler。它是 LLVM-based compiler development 的标准入门,覆盖如何从 front-end 生成 LLVM IR,以及如何调用 LLVM 基础设施完成 optimization 和 code generation。
阅读生产运行时源码可以提供文档无法提供的理解。OpenJDK 的 HotSpot 源码包含 JIT compiler 和 generational garbage collector,规模很大,但文档良好。Go runtime 源码小得多,是生产语言中 M:N thread scheduler 和 garbage collector 最清楚的例子。V8 源码包含 Ignition interpreter 和 TurboFan JIT,展示了使 JavaScript 变快的 adaptive compilation 技术。任何一个都是严肃学习项目;这种投入会揭示工程选择,而这些选择在文档中只是抽象描述,在代码中才具体呈现。
| 资源 | 平台 | 类型 |
|---|---|---|
| Compiler Explorer(免费) | godbolt.org | 实践 |
| LLVM Kaleidoscope tutorial(免费) | llvm.org/docs/tutorial | 实践 |
| OpenJDK HotSpot source | github.com/openjdk/jdk | 参考 |
| V8 source and blog | github.com/v8/v8, v8.dev/blog | 参考 |
| Go runtime source | github.com/golang/go/tree/master/src/runtime | 参考 |
陷阱
| 陷阱 | 为什么会误导 | 更好的回应 |
|---|---|---|
| 把 Dragon Book 当作主教材 | Dragon Book 的重点落在 parsing theory 和 front-end 问题上。Parsing 在很大程度上已经是解决了的问题——parser combinators 和 recursive descent 能干净处理多数实践情况。当代编译器工程中的 optimization、code generation 和 runtime 问题,在 Dragon Book 中处理有限。把它作为主教材学完的学习者,学习的是编译器工程中较不活跃的一小部分。 | 把 Dragon Book 当作 parsing 参考。把 Cooper-Torczon 作为主教材;它对 SSA form、dataflow analysis、register allocation 和 instruction scheduling 的强调,更符合生产编译器工程实际做的事情。 |
| 把编译器当成 front-end 工作 | 许多编译器课程强调 lexers、parsers 和 type checkers,因为这些材料容易塞进单个项目,概念上也更易接近。生产编译器工程的智识中心是 middle-end:IR design、SSA form、dataflow analysis、optimization passes、register allocation。Front-end engineering 很大程度上已经自动化(parser generators),也很大程度上已经解决;optimization 和 code generation 才是真正需要工程判断的地方。 | 完成任何强调 front-end 的入门课程后,都要有意识地接触 middle-end 材料。阅读 SSA construction algorithm。实现一个 dataflow analysis。完成 Kaleidoscope tutorial,使用 LLVM infrastructure 构建一个 optimizing compiler。Cornell CS 6120 提供了多数入门课程缺失的高级课程。 |
| 把 runtime 当成黑箱 | Java、C#、Go、Python、JavaScript 等 GC-managed languages 中的应用,都运行在会主动参与执行的 runtime systems 内。GC pause 不是应用 bug;它是 runtime 在履行自己的契约。一个 goroutine blocked 并不是卡死;它是被 scheduler 挂起。把 runtimes 当作不透明对象的工程师,一旦生产问题追溯到 GC behavior、scheduler starvation 或 memory pressure,就会遇到上限。Runtime 不是实现细节——它是应用行为的主动参与者。 | 认真使用一门语言,就要接触它的 runtime。阅读 GC logs。理解什么是 GC pause histogram,以及如何生成它。知道主要 GC algorithms 及其权衡。对 Go,阅读 goroutine scheduler 文档。对 JavaScript,理解 V8 的 generational heap,以及哪些 object allocation patterns 会触发 promotion。 |
| 一门课之后过度自信 | Compiler construction courses 会覆盖足够材料,使人能够写一个 toy compiler,有些学习者会把这种能力误认为生产编译器工程能力。完成一门入门编译器课程,与理解 GHC、V8 或 LLVM 实际在做什么之间差距巨大。LLVM 有数百万行代码;仅其 optimization infrastructure 就代表了几十年的工程积累。 | 明确承认这个差距。入门课程之后,接触生产编译器材料:阅读 LLVM 源码的一部分;学习你所使用 runtime 的 internals documentation;使用 Cornell CS 6120 框架实现 LLVM optimization passes。生产中的这门学科,与入门课程中教授的版本差异很大。 |
| 把 JIT 当成普遍优于 ahead-of-time compilation | JIT compilation 确实提供真正能力——基于观察行为重新编译、speculative inlining、deoptimization——但它也有真实成本:代码被编译前先解释执行带来的 startup latency,JIT compiler 本身的运行时开销,code cache pressure,以及代码在执行期间改变表示所导致的 profiling 困难。对于 command-line tools,启动时间很重要。对于 embedded 和 safety-critical systems,不可预测的 compilation pauses 不可接受。 | 把 JIT 和 AOT compilation 当作具有不同权衡的工程选择。理解每种方式适合什么工作负载:JIT 偏向长期运行、有 warm traffic 的 server applications;AOT 偏向 startup-sensitive tools、embedded systems,以及可预测性比 peak throughput 更重要的语境。这里有真实工程选择,而不是明显胜者。 |
| 忽视 ML compiler 前沿 | 机器学习编译——把 tensor operations 高效映射到异构硬件——是当前编译器工程最活跃的子领域。TVM、XLA、MLIR 和 Triton 正在重塑编译器工程师的工作内容。一个只懂传统编译的工程师,在这个最活跃发展方向转向 ML compilation 的领域中,技能集会逐渐变窄。 | 至少阅读 MLIR 论文,理解它处理的问题,以及它的 dialect system 与传统 IR design 有何不同。如果 ML compiler 工作是职业兴趣,就完成 TVM tutorial。传统编译技术(dataflow analysis、optimization、code generation)可以直接迁移;变化的是领域和硬件目标。 |