How Code Runs: Compilation, Interpretation, and Execution Models
Hook #
You have typed ruby script.rb thousands of times. What actually happens between pressing enter and seeing output? Most engineers have a vague answer — "Ruby runs it" — and that vagueness is exactly what this course dissolves. The truth is that every language, before it does anything, translates your source text through a pipeline of well-defined stages — scan, parse, analyze, and either interpret or compile — and the differences between "compiled" and "interpreted" languages are differences in where and when that translation happens, not differences in kind. Ruby compiles your code to bytecode every time you run it; C compiles to machine code once, ahead of time; the JVM and BEAM sit in between; and modern Ruby (with YJIT) even compiles hot code to machine code while running. This first lesson draws the map — the pipeline every language shares and the spectrum of execution models it can sit on — so that everything that follows (lexing, parsing, code generation, garbage collection) has a place to hang. The payoff is the end of magic: once you can see the pipeline, no language is a black box again.
What you'll be able to do by the end of this lesson #
- Describe the compilation pipeline every language shares: source text → tokens (scanning) → syntax tree (parsing) → analyzed/typed tree → intermediate representation → either direct interpretation or code generation.
- Explain the compilation-vs-interpretation spectrum as a question of when translation to executable form happens (ahead-of-time, at load, or at runtime) rather than a binary category.
- Place the four languages of this curriculum on that spectrum: C/Zig (ahead-of-time to machine code), Ruby (compiled to YARV bytecode, run on a VM), Elixir (compiled to BEAM bytecode), and where JIT (YJIT) fits.
- Explain why real languages compile to bytecode and run it on a virtual machine rather than walking the syntax tree directly — the performance argument that shapes every production language runtime.
A quick try before we start #
Is Ruby "compiled" or "interpreted"? The reflexive answer is "interpreted" — and it's wrong, or at least badly incomplete. When you run a Ruby file, the interpreter compiles your source into YARV bytecode (you can see it: RubyVM::InstructionSequence.compile("1 + 2").disasm prints the bytecode), and then the YARV virtual machine executes that bytecode. So Ruby is compiled to bytecode, then interpreted by a VM — the same basic model as Java, Python, and C#. The word "interpreted" is doing more harm than good here, because it hides the compilation step that explains so much (why syntax errors happen before any code runs, why some code is slow, how YJIT can speed things up). Noticing that "compiled vs. interpreted" is a false binary — that nearly every real language compiles to something and then runs that something — is the single most clarifying idea in this lesson.
Why this matters here #
This lesson opens the Compilers and Interpreters course by drawing the map that the rest of the course fills in. Every subsequent lesson — lexing and parsing (the front end), semantic analysis and IRs (the middle), code generation and garbage collection (the back end), and the CRuby deep-dive — is a stage or a concern in the pipeline this lesson lays out. Without the map, those lessons are disconnected mechanics; with it, they're the parts of a machine you can see whole. This course is also where the whole systems-and-languages arc of the curriculum converges: the theory of computation (Quarter 8) told you which languages a finite automaton or a pushdown automaton can recognize — and here you'll see that a lexer is a finite automaton and a parser is a pushdown automaton, theory made into working code. The compilation pipeline from the systems course (C's preprocess → compile → assemble → link) is the ahead-of-time end of the spectrum this lesson defines.
For a working engineer, understanding execution models is what turns a language from a magic box into a comprehensible tool. It explains the why behind everyday observations: why a syntax error fires before any of your code runs (parsing happens up front, before execution), why Ruby is slower than C for tight numeric loops (bytecode dispatch overhead vs. native instructions), why YJIT can close some of that gap (compiling hot bytecode to machine code at runtime), and why the JVM and BEAM start slowly but run fast (they compile and warm up). This understanding is also what lets you read a language's source, file a precise bug report, understand a changelog's performance claims, and — the deepest payoff — never again be intimidated by a new language, because you know it's just another arrangement of the same pipeline.
The engineer's lens #
The foundational reframe is that "compiled" and "interpreted" are not two kinds of language but two ends of a spectrum defined by when translation to executable form happens — and almost every real language does both, compiling to some intermediate form and then executing it. The naive picture is a clean binary: C is "compiled" (translated to machine code by gcc, then the CPU runs it directly), and Ruby/Python are "interpreted" (some program reads your source and does what it says, line by line). But the clean binary describes almost nothing accurately. Ruby compiles your source to YARV bytecode before executing a single instruction; Python compiles to .pyc bytecode; Java compiles to JVM bytecode; C# to CIL. All of these then interpret (or JIT-compile) that bytecode on a virtual machine. So the real variable isn't "compiled vs. interpreted" — it's when the translation to executable form happens and how far it goes toward native machine code: ahead-of-time (C/Zig: fully to machine code before you run, so startup is instant and execution is fast, but you must recompile per platform), at load/run time to bytecode (Ruby/Python/JVM/BEAM: compile to a portable intermediate form, then execute it on a VM — portable and flexible, with some dispatch overhead), and just-in-time (YJIT, the JVM's HotSpot, V8: start by interpreting bytecode, notice which parts run hot, and compile those to native machine code while the program runs — combining portable startup with native-speed hot paths). Seeing this spectrum dissolves a dozen confusions at once, and it reframes the whole course: you're not learning "how compilers work" as opposed to "how interpreters work" — you're learning the shared pipeline and the choices about when to run each stage that produce the whole zoo of language runtimes.
The second lens is the pipeline itself as a universal structure — the same phases in the same order, whether the output is a native binary or a running interpreter. Nystrom's image is a mountain you climb up (analysis) and down (synthesis): scanning turns the flat stream of source characters into tokens (the words); parsing turns tokens into a tree that captures grammatical structure (the sentences); semantic analysis resolves names, checks types, and works out what the tree means; then either you interpret the tree/IR directly, or you generate code — lowering the meaning to an intermediate representation, optimizing it, and emitting bytecode or machine instructions. Every language walks this path; they differ only in how far down the synthesis side they go before stopping. A tree-walk interpreter (the simplest kind) stops early — it just walks the analyzed tree and does what each node says, which is easy to build but slow. A bytecode compiler goes further — it flattens the tree into a linear sequence of simple instructions for a virtual machine, which is more work to build but much faster to run. A native compiler goes all the way to machine code. The crucial insight for the rest of the course is that these are the same front end (scanning and parsing are identical whether you're building gcc or a toy interpreter) with different back ends — which is exactly why real compiler infrastructure (LLVM, and CRuby's own structure) separates a language-specific front end from a shared, reusable back end. When you understand that the front end is universal and the back end is where the "compiled vs. interpreted" choice lives, the architecture of every language toolchain snaps into focus.
The third lens is why bytecode-on-a-VM won — the performance-and-portability argument that explains the Ruby/Python/Java execution model you use every day. If a tree-walk interpreter is the easiest thing to build, why don't production languages just do that? Because walking a tree is slow: every operation means chasing pointers around a scattered tree structure, dispatching on each node's type, and re-deciding what to do at each visit — cache-unfriendly and branch-heavy. Compiling the tree once into a flat array of simple bytecode instructions fixes this: the instructions are compact, contiguous (cache-friendly), and each one is a tiny, fast operation the VM dispatches in a tight loop. You pay a one-time compilation cost to get a much faster execution loop — a good trade for any code that runs more than once. And bytecode is portable: the same YARV bytecode runs on any platform CRuby is built for, so you ship source (or bytecode) and let each machine's VM execute it, rather than shipping a different native binary per platform (C's model). This is the sweet spot that made bytecode VMs the dominant execution model for high-level languages: more portable and flexible than ahead-of-time native compilation, much faster than tree-walking. JIT compilation (the third stage) then recovers the remaining speed gap for hot code by compiling those bytecode instructions to native machine code at runtime — which is why YJIT can make a Ruby web app meaningfully faster without changing a line of your code. For a Rails engineer this is the concrete, load-bearing payoff: it explains YARV, explains why RubyVM::InstructionSequence exists, explains what YJIT is actually doing under the hood, and explains the performance character of the runtime your entire career runs on top of.
What to focus on in the resources #
- Crafting Interpreters, 'A Map of the Territory' — primary. Read this one chapter for the whole-pipeline map (scanning → parsing → analysis → IR → optimization → code gen → VM) and Nystrom's mountain diagram. It's free, short, and the mental model for the entire course. Don't start coding yet — just get the map.
- Ruby Under a Microscope, early chapters. Read for the concrete Ruby pipeline: tokenize → parse → compile to YARV → execute. This grounds the abstract map in the language you actually use and previews the CRuby deep-dive (lesson 5).
- Crafting Interpreters Part III intro. Read for why bytecode-on-a-VM beats tree-walking — the performance-and-portability argument. This is the choice that shapes every production runtime.
- Skip on first pass: actually building an interpreter (that's the doing-it work the resources lead you through later), JIT implementation details, and the full history of language runtimes. Get the pipeline map, the when-does-translation-happen spectrum, and the bytecode-VM rationale.
Explain it back #
Explain to a colleague why "Ruby is an interpreted language" is misleading, and what actually happens when you run a Ruby file. A strong answer: "compiled vs. interpreted" is a false binary — the real question is when your source gets translated to executable form and how far toward native machine code it goes. Running a Ruby file compiles it to YARV bytecode first (you can disassemble it with RubyVM::InstructionSequence), then a virtual machine executes that bytecode — the same model as Java and Python. Every language shares one pipeline (scan → parse → analyze → IR → interpret or generate code); they differ only in how far down they go before running: C/Zig go all the way to machine code ahead of time (fast, but per-platform), Ruby/Python/BEAM stop at portable bytecode run on a VM, and JIT (YJIT) compiles hot bytecode to machine code at runtime to recover speed. Bytecode-on-a-VM won because it's far faster than walking the tree (flat, cache-friendly instructions in a tight dispatch loop) and more portable than native binaries. Bonus: this is why syntax errors fire before any code runs (parsing is up front) and why YJIT speeds Rails up without code changes.
Where this connects #
Backward: The ahead-of-time end of the spectrum is the C compilation pipeline (preprocess → compile → assemble → link) from the systems-programming course. The theory-of-computation course sits directly under this course: a lexer is a finite automaton and a parser is a pushdown automaton, so this is that theory made executable. And the "why study programming languages" question from the Programming Languages course is answered concretely here — you study them by seeing how they're built.
Forward: The next lesson dives into the front end — lexing and parsing — the universal first stages this map lays out. Lesson 3 covers the middle (semantic analysis and IR), lesson 4 the back end (code generation and garbage collection), and lesson 5 makes it all concrete in CRuby (parse.y, YARV, YJIT) and has you build a small interpreter. The bytecode-VM and JIT ideas introduced here as concepts become mechanisms you can trace through Ruby's actual runtime by the end of the course.
That's the free preview. Sign in to continue this course.
Sign in to continueNew here? Make a desk →