Pipelineimplementation

What Seven Real Languages Actually Do

C++, Python, JavaScript, TypeScript, Java, Rust and Go, each traced through the same three questions — with the version numbers attached, because every one of these answers has changed at least once.

The question

For the languages I actually use, what happens at build time and what happens at run time?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Seven different answers to the same question — what the program is at each of the four moments from [[source-to-behaviour]]. The comparison is only meaningful because the axis is fixed: for each implementation, name the artifact that exists at start-up, name the moment it was produced, and name what is still being translated while the program runs.

What this phase may assume or do

Each implementation may assume exactly the guarantees its language definition provides, which is why the same strategy pays off differently across them. A Go compiler may assume no signed-overflow undefinedness and must therefore prove loop bounds another way; a C++ compiler may assume it and gets induction-variable analysis cheaply; a JavaScript engine may assume almost nothing statically and must therefore observe first and guard afterwards.

Key points

  • The same three questions describe all seven implementations, and every answer carries a version number that has changed recently.
  • TypeScript does a full type check and then erases everything; nothing at run time consults a TypeScript type.
  • javac deliberately optimizes almost nothing, because the JIT can do it better with knowledge of what actually loaded.
  • Rust monomorphizes generics and pays in build time and binary size; Java erases them and pays in what it cannot recover at run time.
  • Every one of these languages has at least one alternative implementation with a different strategy in production use.

Seven implementations, one axis

implementationEvery row is a version claim and several are recent. CPython's adaptive interpreter arrived in 3.11 and its experimental JIT in 3.13; V8 has restructured its tier ladder more than once; Java gained an ahead-of-time class-loading mechanism in 24. A statement from a 2018 article about any of these rows is likely to be wrong now.

The table below is the useful form of the question "is X compiled". Every row names an implementation and a version, because in every one of these rows the answer has changed inside the last decade and will change again.

Read the last column as the source of every warmup effect, every deoptimization and every performance mystery that has no corresponding code change. Where it says "nothing", performance is a property of the build; where it does not, performance is a property of the run.

Seven implementations against the three questionsimplementation
ImplementationAt start-up the program isProduced whenStill translating at run time
C++ / Clang 18, releaseimplementationNative machine code in an executable or shared objectBuild time, per translation unit, then linkedNothing. Templates were instantiated at build time; virtual dispatch is a table lookup, not a translation
Python / CPython 3.12implementationBytecode for a stack machine, cached in __pycache__First import of each moduleBytecode specialisation: the adaptive interpreter rewrites instructions in place once operand types stabilise
JavaScript / V8 12implementationSource text. Nothing was produced ahead of timeNothing at build time; lazily parsed as functions are first calledParsing, bytecode generation, and optimizing compilation of hot functions, with deoptimization when a guard fails
TypeScript / tsc 5implementationJavaScript with every type annotation deletedBuild time, for the checking; the emit is a syntactic transformWhatever the JavaScript engine underneath does. The types are gone and no runtime consults them
Java / HotSpot 21implementationClass files containing JVM bytecodeBuild time by javac, which does very little optimizationInterpretation, then C1, then C2 per hot method, with on-stack replacement for hot loops and deoptimization on failed speculation
Rust / rustc 1.7x, releaseimplementationNative machine code, generics already monomorphizedBuild time; borrow checking on MIR, optimization by LLVMNothing. There is a small runtime, but it does not translate anything
Go / gc toolchain 1.22implementationA statically linked native executable including the scheduler and collectorBuild time, by a toolchain that owns its whole pipelineNothing translational; the scheduler and garbage collector run alongside

The rows that surprise people

Three of these deserve unpacking, because each contradicts a widely held summary.

TypeScript emits nothing that helps at run time. tsc performs a full semantic analysis — resolution, inference, structural type checking, exhaustiveness — and then deletes all of it, emitting JavaScript that is often nearly character-identical to the input minus the annotations. There is no TypeScript runtime, no type information available to reflection, and no way to check a value against an interface at run time. That is a deliberate design choice with a specific payoff and a specific bill; see [[typescript-pipeline]] and [[type-erasure]].

`javac` is not where Java performance comes from. It compiles to bytecode with almost no optimization, deliberately: inlining and escape analysis in javac would be premature, because the JIT can do both with knowledge of the actual class hierarchy that got loaded. The consequence is that reading Java bytecode tells you very little about what will execute — which is the opposite of the relationship between C++ source and its assembly.

Rust generics are compiled twice, or ten times. Monomorphization emits a separate specialised copy of a generic function for every concrete type it is used with, which is why Rust generics have no dispatch cost and why Rust build times and binary sizes are what they are. Java takes the opposite route and erases, which is why Java generics cost nothing at build time and cannot recover the type parameter at run time. Neither is a mistake; they are the two ends of [[monomorphization]] versus [[type-erasure]].

Strategy is not fixed per language

implementationGraalVM native-image requires a closed-world assumption: everything reachable must be known at build time, so reflection, dynamic proxies and service loading need explicit configuration or they fail at run time rather than at build time. That failure mode is the cost of the strategy, not a defect in it.

Every row above has at least one alternative implementation that answers the questions differently, and in several cases the alternative is in production use somewhere.

None of these are exotic. They are the ordinary consequence of the point from [[compiler-vs-interpreter]]: the language definition does not specify the strategy, so anyone with a reason can implement a different one.

  • Python: PyPy is a tracing JIT that compiles hot loops to machine code; Cython compiles annotated Python ahead of time through C; MicroPython targets microcontrollers with a smaller bytecode and no cache directory at all.
  • Java: GraalVM native-image compiles the whole program ahead of time into a native executable with a fixed closed world, trading reflection and dynamic class loading for start-up in milliseconds instead of seconds.
  • JavaScript: engines can snapshot a warmed heap and ship it, and bytecode caches persist across page loads — both are ways to move work backwards across the run-time boundary.
  • C++: Cling interprets it; Emscripten compiles it to WebAssembly, which is then compiled again by the browser engine; a constexpr function is evaluated by the compiler itself at build time, which is a third translation strategy inside the same build. See [[compile-time-evaluation]].
  • Rust: the Cranelift backend exists to make debug builds fast, producing worse code much more quickly, which is a different point on the same trade as [[optimization-levels]].

What this changes about your decisions

The strategy determines which questions about performance are answerable and where. In a build-time strategy, the artifact is the truth: read the assembly, and what you see is what runs. In a run-time strategy, the artifact is a starting point and the truth is a profile of a warmed process — reading the bytecode tells you almost nothing.

It also determines what a deployment costs. Native artifacts are per-target, which means a build matrix; bytecode artifacts are portable, which means one build and a runtime dependency on every host. Container image size, cold-start latency and the ability to reproduce a build all fall out of this single choice.

What the strategy decides for you downstream
DecisionBuild-time strategyRun-time strategy
Where you measure performanceThe artifact: disassembly and static inspection are meaningfulA warmed process: the artifact is not predictive
Start-up latencytypicalLoader time only, typically millisecondsParse, interpret and warm up before steady-state performance appears
Artifacts per releaseOne per target tripleOne, plus a runtime on every host
What a crash report needsSymbols kept separately, and a symbolication stepA stack trace the runtime produces itself, with names intact
What dynamic features costeval and reflection need a translator shipped with the program, or are unavailableNearly free — the machinery is already resident

How it works

The steps, in the order the compiler takes them.

  • Identify the artifact present at process start: source text, bytecode, or native code.
  • Identify when it was produced: build time on another machine, first use on this one, or continuously.
  • Identify what still translates during the run: nothing, bytecode specialisation, or full optimizing compilation with deoptimization.
  • Read the consequences off those three answers: where performance is measurable, what start-up costs, what deployment ships, and whether dynamic features are affordable.

How it breaks

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

  • A microbenchmark of a JVM or JavaScript workload reports numbers that never reproduce in production, because it measured before the optimizing tier ran at all.
  • A TypeScript service accepts malformed JSON from an external API and fails deep inside business logic, because the developer believed an interface annotation was checked at run time. Nothing checks it.
  • A Rust build takes twenty minutes and produces a fifty-megabyte binary, and the cause is a generic function instantiated over dozens of concrete types rather than anything about the algorithm.
  • A Java service moved to native-image starts in twelve milliseconds and then throws ClassNotFoundException on a code path that uses reflection, months after the migration was declared finished.
  • A Python service is profiled, the hot function is rewritten in idiomatic Python and gets slower, because the original version was hitting a specialised bytecode path that the rewrite made polymorphic.

When it helps

  • Choosing a language for a workload with a hard start-up constraint, where the answer follows directly from the third column rather than from any benchmark.
  • Explaining a performance report that makes no sense, which in run-time strategies is usually warmup and in build-time strategies is usually the optimization level.
  • Estimating the deployment cost of a language before committing to it: artifacts per target, runtime dependency, image size and crash-report machinery all fall out of the strategy.

When it hurts

  • Comparing languages by these rows alone. The rows describe translation strategy, and most production performance is decided by allocation behavior, I/O and data layout, which the strategy influences but does not determine.
  • Assuming the row is stable. These answers change between versions often enough that a two-year-old summary is a liability, and several teams have made migration decisions on obsolete ones.

What it costs

Every one of these is paid by something.

  • Build-time strategies buy predictability and start-up latency, and pay a build matrix per target, a symbolication pipeline for crash reports, and the inability to use facts only the run knows.
  • Run-time strategies buy portability, dynamic features and specialisation on real data, and pay warmup latency, memory for the compiler and its output, and performance that is a property of a process rather than of an artifact.
  • Monomorphization buys zero-cost generics and pays in compile time and code size — measurably, on real Rust and C++ builds. Erasure buys compile time and code size and pays by making the type parameter unrecoverable at run time, which forces reflection-based workarounds that then have their own cost.

What else you could do

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

  • For a Python service that is compute-bound: PyPy, or Cython for the hot module, or moving the hot loop into a native extension — three different points on the same trade.
  • For a Java service with a hard cold-start budget: GraalVM native-image or a class-data-sharing archive, both of which move work earlier at the cost of dynamic capability.
  • For a JavaScript workload with a large start-up cost: bytecode caching and heap snapshotting, which do not change the strategy but move work backwards inside it.
  • For any of them: measure first. [[profile-guided-optimization]] moves run-time knowledge into a build-time strategy, which is the only way to have some of both.

See it for yourself

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

  • Python: python -m dis module.py for the bytecode, python -X importtime script.py for what compilation costs at start-up.
  • Java: javap -c Class.class for the bytecode, java -XX:+PrintCompilation for the tiering, -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining for what the JIT actually inlined.
  • JavaScript: node --trace-opt --trace-deopt script.js. A repeated deopt on one function is almost always the cause of a mystery regression.
  • TypeScript: tsc --noEmit type-checks without producing anything, and diffing tsc output against the input shows exactly how little the emit does.
  • Rust and C++: cargo build --release && cargo bloat or nm --size-sort on the binary shows what monomorphization actually produced.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "TypeScript adds type safety at run time." It adds nothing at run time. Every guarantee it provides is a build-time guarantee about code that was checked, and data crossing a boundary from outside was not checked by anything.
  • "Java is slow because it runs on a virtual machine." Steady-state HotSpot performance on scalar code is competitive with ahead-of-time compilation on many workloads; the costs that are real are start-up, memory footprint and allocation behavior, none of which is the dispatch loop.
  • "Go compiles to native code, so it has no runtime." It has a substantial runtime — a scheduler, a garbage collector, growable stacks — statically linked into every binary. Native compilation and having a runtime are independent.
  • "Rust and C++ are the same strategy." Both compile ahead of time, and their generic instantiation, their overflow semantics and their build-time evaluation models differ enough that performance advice does not transfer unexamined.

Misconceptions

The claim, and what is actually true.

Each language has a compilation model.
Each *implementation* has one, and popular languages have several implementations with different models shipping simultaneously.
Bytecode means slow.
Bytecode means portable and compact. Whether it is slow depends entirely on what executes it — a dispatch loop, an adaptive interpreter, or an optimizing JIT that replaces it with machine code.
If a language compiles to native code, its performance is fixed at build time.
Largely true for translation, and false in general: allocation behavior, branch predictability and cache behavior are all properties of the run, and the last two are owned by the hardware, not the compiler.

Go deeper

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

overview

Seven languages, three questions each: what exists when the program starts, when was it made, and what is still being translated while it runs. C++, Rust and Go answer "native code, at build time, nothing". Python and Java answer "bytecode, at build time, and yes". JavaScript answers "source text, never, everything". TypeScript answers "JavaScript with the types deleted".

practical

Use the third column to decide where to measure. If nothing translates at run time, disassemble the artifact and trust it. If something does, benchmark a warmed process, discard the first seconds, and check the deoptimization log before believing any regression is in your code. Use the second column to decide what deployment looks like: build-time strategies need a target matrix and a symbol server, run-time strategies need a runtime on every host.

advanced

The rows converge under pressure and it is worth watching how. Java grew ahead-of-time compilation because cold start became a billing line item; Python grew a specialising interpreter and then a JIT because its ceiling became a constraint; JavaScript engines grew bytecode caches and snapshots because parsing megabytes of source on every page load was the dominant cost. Every one of those moves is the same move — shifting work across the build/run boundary — and every one pays the same price, which is that whatever is decided earlier is decided with less information and must therefore be guarded, configured, or given up.

How much this depends on

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

implementationEvery row names an implementation and a version deliberately. CPython 3.11 through 3.13 changed the last column twice; V8 and HotSpot both restructure tiers between releases. Re-verify any row before relying on it for a decision, using the inspection commands above rather than documentation.
typicalStart-up figures, binary sizes and build times vary by orders of magnitude with program size and dependency count. The ordering between strategies is stable; the magnitudes are not, and quoting a number without the program it came from is how these comparisons become folklore.
specNone of these strategies is required by any of the language definitions. The JVM specification is explicit that interpretation, JIT and ahead-of-time compilation are all conforming; the C++ standard describes an abstract machine and says nothing about how an implementation realises it.

If you were asked this in an interview

  • Take Java and Rust. Where does each spend its optimization budget, and what does that mean for how you measure them?
  • Why does TypeScript not check types at run time, and what does a team have to do about it?
  • A Python function gets slower after a refactor that removed a branch. What is your hypothesis?

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — The runtimes themselves — Go's scheduler, HotSpot's collectors, V8's object model
    These rows describe what each toolchain translates and when. What the resident runtime then does with the result is the other domain's subject, and most of the performance differences between these seven languages in production are decided there rather than here.