Choosing an Execution Model
Implementation strategy is not a property of a language, but a language definition can make some strategies expensive and others impossible. This is the lesson about which features write cheques the execution model has to cash.
How does what I put in the language decide what implementations of it are possible?
The definition read as a set of obligations on any implementation. Every dynamic feature — eval, reflection, run-time code loading, monkey-patching, open classes — is an obligation to have translation machinery resident while the program runs. Every static guarantee is permission to do work before it starts. The representation that survives to run time follows from that ledger rather than from a preference.
An implementation may choose any strategy that produces the defined behavior, but a language feature that requires run-time translation cannot be implemented by a strategy that has none. The precondition for a purely ahead-of-time implementation is that the set of reachable code is fixed at build time — a closed world. Any feature that lets the running program construct new reachable code, or reach code by a name computed at run time, violates that precondition and must be either forbidden, configured explicitly, or supported by shipping a translator.
Key points
- A language does not have an execution model; it has a set of obligations that make some models cheap, some expensive and some impossible.
- The decisive question is whether a running program can cause code to exist that did not exist at build time. If yes, a translator ships with every deployment.
- Reflection by computed name, dynamic loading and open classes each break the closed-world assumption that aggressive ahead-of-time compilation depends on.
- Sealed hierarchies, closed modules, monomorphizable generics and defined value layouts are the guarantees that buy back static optimization.
- Build the tree-walking interpreter first regardless of the eventual target; it is the executable reference semantics every later implementation is checked against.
The strategies, and what each demands of the definition
From [[compiler-vs-interpreter]] we have the shapes; the design question is which ones your language will permit. A language definition does not choose a strategy, but it does write a bill that some strategies cannot pay.
| Strategy | Needs the definition to provide | Becomes impossible or expensive if the language has |
|---|---|---|
| Tree-walking interpreter | Almost nothing. Any language can be implemented this way | Nothing — but any performance requirement, since per-node overhead is paid on every execution |
| Bytecode VM | A stable enough semantics to fix an instruction set; a value model the VM can represent uniformly | Native memory layout guarantees, or any promise about pointer identity that boxing would break |
| VM plus JIT | Enough dynamism that observed types are worth exploiting; a way to describe the state at every deoptimization point | Hard real-time requirements — compilation happens on the same machine and pauses are unpredictable |
| Ahead-of-time to native | A closed world: every reachable function known at build time | eval, reflection by computed name, run-time class generation, dynamic method addition |
| Transpile to another language | A target whose semantics can express yours, including its control flow and error model | Tail calls, coroutines or unwinding the target lacks — each becomes an expensive encoding |
| Compile to WebAssemblyimplementation | A memory model expressible as a linear memory or as managed references; no assumption of ambient system access | Direct system calls, threads without the relevant proposal, or a garbage collector you wanted the host to provide |
Features that force a translator to be present
The sharpest version of the design question is: after the program starts, can it cause code to exist that did not exist before? If yes, a translator ships with the program and every ahead-of-time strategy becomes partial.
eval is the obvious case and the least common in practice. The expensive cases are the quiet ones. Reflection by computed name means the reachability graph is not statically known, so dead-code elimination must be conservative and whole-program optimization must be told what to keep. Dynamic class loading means the class hierarchy is open, so devirtualization needs a guard rather than a proof. Monkey-patching means a method's implementation can change after code that called it was compiled, so every call site is a potential invalidation point.
None of these are wrong to include, and each is genuinely valuable to some audience. What matters is that they are decided in the definition and paid for in every implementation, forever. Java's reflection is why GraalVM native-image needs configuration files; Python's open classes are why an ahead-of-time Python is a research project rather than a build flag; JavaScript's ability to construct functions from strings is why every engine contains a full compiler.
Features that make an execution model cheap
The trade runs both ways, and the productive design move is to decide which guarantees to make in exchange for which strategies.
A closed module system with no dynamic loading makes whole-program optimization straightforward. Sealed or final-by-default class hierarchies make devirtualization provable rather than speculative. Monomorphizable generics make polymorphic code compile to specialised machine code with no dispatch — at the cost of code size and compile time, which is the Rust and C++ trade in [[monomorphization]]. Value types with a defined layout make data structures compile to flat memory rather than to pointer chases, which is most of the practical performance difference between a struct-of-values language and an object-of-references one.
WebAssembly is the clearest case of a target designed backwards from a strategy. It has a validated instruction set so that a host can verify a module cheaply and then compile it, a linear memory so that a compiler with its own memory model can target it, and structured control flow rather than arbitrary jumps so that validation and single-pass compilation are possible at all. Every one of those is a restriction adopted to make a specific execution strategy fast and safe — see [[wasm-model]].
Decide the first implementation separately from the language
The practical advice is to keep two decisions apart. The language decision is which obligations to accept. The implementation decision is which strategy to build first, and the answer to the second is almost always a tree-walking interpreter regardless of the first.
Building the interpreter first gets a running language in days, gives you an executable reference semantics to test every later implementation against, and surfaces the design questions from [[language-design-questions]] immediately rather than after a backend exists. The bytecode compiler and the VM come second because they are a strict refinement — the same semantics, a different representation. A native backend or a JIT comes much later, if ever, and by then the language has been shaped enough that the choice is informed. This is the sequence [[atlaslang-overview]] follows for exactly these reasons.
The failure mode of getting this backwards is well documented: a language whose semantics were fixed by what its first backend found easy to emit, and whose subsequent design discussions all begin with "we cannot, because of how it compiles".
How it works
The steps, in the order the compiler takes them.
- Enumerate the language features that let a running program reach code not known at build time.
- For each, decide whether to forbid it, to require explicit declaration of what it may reach, or to accept that a translator is resident at run time.
- Enumerate the guarantees the language will make about hierarchies, modules, generics and layout, and note which static optimizations each one enables.
- Choose the first implementation strategy for development speed rather than for performance, and keep it as the reference semantics.
- Re-derive the strategy space whenever a feature is added, because a single dynamic feature can move a language from the ahead-of-time column to the run-time one.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A language ships with a small dynamic feature used by two libraries, and every ahead-of-time deployment thereafter requires a configuration file listing what those libraries reach.
- A team discovers at deployment time that their language cannot be compiled ahead of time for their platform, because a dependency deep in the tree uses reflection.
- A design discussion is closed with "we cannot do that because of how the compiler works", which means the implementation is now the specification.
- A JIT-based runtime is deployed into a hard-latency environment and the tail latency is dominated by compilation pauses that no amount of tuning removes, only defers.
- A language compiled to another language cannot express deep recursion or coroutines efficiently, and every user hits the resulting stack limit rather than the language's own.
When it helps
- Deciding whether to include a dynamic feature: the question "what does this cost every future implementation" is answerable and usually decisive.
- Evaluating whether a language can meet a deployment constraint — cold start, sandboxing, no runtime on the host — before committing to it.
- Planning the implementation order for a new language, where building the wrong thing first is the most common way small language projects die.
When it hurts
- Refusing all dynamic features to keep the strategy space open. Reflection, dynamic loading and interactive evaluation are what several audiences actually need, and a language that forbids them for the sake of an ahead-of-time backend has optimised for its implementers.
- Assuming the strategy is fixed. Java gained ahead-of-time compilation, Python gained a JIT, and JavaScript gained bytecode caching, all long after their designs were considered settled.
What it costs
Every one of these is paid by something.
- Permitting run-time code construction buys interactive development, plugin architectures and metaprogramming, and costs a resident translator in every deployment plus permanent conservatism in every whole-program analysis.
- Requiring a closed world buys devirtualization, dead-code elimination across the program and a small binary, and costs reflection, dynamic loading and every library that depends on them — a cost paid by users who did not choose it.
- Targeting a portable virtual instruction set buys one artifact for every host and cheap sandboxing, and costs the ability to make guarantees about memory layout and system access that the host may not grant.
- Building the interpreter first costs a component you will eventually mostly replace, and buys a reference semantics, a running language in days, and design feedback before the expensive parts are written.
What else you could do
What a different compiler or language does instead, and when that is better.
- Support both: ship a JIT-based runtime for development and an ahead-of-time compiler for deployment, accepting that the two must be kept behaviorally identical and that they will not be. Several ecosystems do this and the divergence is a permanent source of bugs.
- Restrict dynamism to a declared subset — an explicit list of reflectively reachable entry points — which is what native-image configuration is, generalised into the language rather than bolted on.
- Target WebAssembly and let the host decide the final strategy, buying sandboxing and portability while giving up direct system access —
[[wasm-vs-native]]. - Transpile to an established language and inherit its entire implementation, which is the cheapest possible answer and makes debugging a source-map problem —
[[typescript-pipeline]],[[source-maps]].
See it for yourself
The flag, dump or tool that shows you this directly.
- Find the dynamism in an existing codebase before assuming an ahead-of-time build is possible: grep for reflective APIs,
Class.forName,getattrwith a computed name,eval, and dynamic proxy creation. - For the JVM: try
native-imageon the application and read the failure list. It is the fastest available inventory of what your program does dynamically. - For a language you are designing: write down every feature that can introduce reachable code at run time. If the list is empty, an ahead-of-time strategy is available; if it is not, name what each entry costs.
- Compare the artifacts: build the same program under each candidate strategy and measure start-up with
hyperfine, resident memory with/usr/bin/time -v, and artifact size directly.
Plausible wrong readings
Stated the way a confident engineer states them.
- "We will decide the execution model later." Every dynamic feature added before that decision removes options from it, silently.
- "A JIT is strictly better than an interpreter." A JIT is a compiler running inside your process, consuming memory and producing unpredictable pauses. For short-lived processes and for latency-sensitive ones, that is a cost with no matching benefit.
- "Compiling to WebAssembly makes a language portable." It makes it portable to hosts that implement the proposals you depend on, with the memory and system-access model Wasm defines. That is a specific portability, not a general one.
- "Interpreters are for prototypes." Every production JavaScript and Java runtime contains one, permanently, because starting fast and falling back safely both require it.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A language does not choose how it is executed, but it can make choices that rule options out. Anything that lets a running program create or find code that did not exist at build time means a translator has to ship with the program. Anything that closes the world — sealed hierarchies, no dynamic loading, no reflection by name — lets everything be decided before it starts.
practical
Before adopting a language for a deployment with a hard constraint, inventory the dynamism in the dependency tree rather than in your own code, because that is where it will be. Before adding a dynamic feature to a language, write down which implementations it forecloses. And when starting an implementation, build the interpreter first no matter what the eventual target is: it is the reference semantics, and it exists in a week.
advanced
The most interesting position on this axis is the one that refuses to choose: a language that permits dynamism but requires it to be declared, so the closed world is a checkable property rather than an assumption. Module systems with explicit exports approximate this; native-image configuration is the same idea implemented outside the language and therefore imperfectly. A language designed today with deployment in mind would probably make reachability part of the module system — every dynamically reachable entry point named in the definition — and would in exchange get whole-program optimization, small artifacts and instant start-up without giving up plugins. That nobody mainstream has done it yet is a fair indication of how much the dynamic features are worth to the people who use them.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- Which language features make ahead-of-time compilation impossible, and what do the workarounds actually give up?
- You are designing a language for serverless functions. Which features do you refuse, and why?
- Why build a tree-walking interpreter first even when you know you want a native backend?
Connections
- Programming Languages & Runtime Internals — What a resident runtime costs in memory, pauses and schedulingThis lesson decides whether a runtime must be present and what it must be able to do. Its internal design and its operational behavior are the other domain's subject.