← Back to DevLog

June 17, 2026 · By Pedro Jesus, Language Designer · 10 min read

Bind Once: Why Dryad's AOT Compiler Chooses Compile-Time Type Specialization Over JIT Deoptimization

Every dynamically-typed language that wants performance eventually faces the same question: how do you generate fast native code when you don't know the types at compile time?

The dominant answer in the industry — used by V8 (JavaScript), SpiderMonkey (JavaScript), HotSpot (Java), and LuaJIT — is adaptive optimization: run in an interpreter, profile the types, JIT-compile based on those profiles, then deoptimize and recompile when the assumptions break. This feedback loop produces excellent peak performance, but at significant cost: warm-up time, memory for profiling data, runtime stutter during deoptimization, and enormous compiler engineering complexity.

Dryad takes a different path. Our AOT compiler uses a strategy we call 'Bind Once': type specialization is performed once, at compile time, and the binding is irreversible. There is no deoptimization. There is no reoptimization. There is no warm-up phase. The program reaches peak performance on the very first instruction.

This post explains the architectural decisions behind Bind Once, the techniques that make it viable, and the trade-offs we accept.


The Core Insight: Not All Dynamism Is Genuine

When a developer writes function add(a, b) { return a + b; }, the types of a and b are unknown at the function declaration level. But in practice, across the entire call graph of a program, most variables receive only one or two types. The truly polymorphic variable — one that genuinely receives number on some paths and string on others — is the exception, not the rule.

JIT compilers exploit this by observing actual execution, then betting on the common case. Dryad's AOT exploits the same property, but through static analysis instead of observation. Our gradient inference engine traverses the Control Flow Graph (CFG) and propagates type constraints forward. If variable x is used as number on every reachable path, the compiler promotes it to native f64. If all paths agree on string, it becomes a native string pointer. Only when the analysis detects genuine merging of different types does the compiler fall back to the Variant Enum.

The key differentiator is that Dryad performs this analysis once, at compile time, and the binding is final. There is no speculation, no guard insertion, no bailout handling.


The Three-Level Gradient

The inference engine operates at three levels, each covering cases the level above cannot resolve:

Level 1 — Static CFG Inference (Automatic): The compiler propagates types through the entire call graph. If a function parameter is always called with number arguments across all call sites, the parameter is promoted to f64. Local variables are promoted when all assignments produce the same type. The promotion is per-variable and per-block — a variable might be promoted to native inside a hot loop while retaining the Variant Enum outside it.

Level 2 — Annotation-Guided (Opt-In): User-provided type annotations (e.g., let x: i32, function add(a: number, b: number): number) are treated as binding directives. They override inference and tell the compiler 'I guarantee this type is correct; generate the native code without checks.' This is how "use strict types" works at the module level — it's equivalent to annotating every binding.

Level 3 — Dynamic Fallback (Safe): When the compiler cannot determine a unique type (e.g., a variable receives number on one branch and string on another), it preserves the Variant Enum for that specific binding only. The rest of the function compiles to native code. The fallback is finely scoped — a single variable in a single block, not the entire function or module.

This gradient design is crucial: it means that adding type annotations is never required for correctness, but every annotation you add improves the resulting binary's performance. The compiler never guesses, never speculates, and never needs to recover from a wrong assumption.


Type-Specialized Stubs: Monomorphization in Practice

Once the gradient inference determines types, the compiler generates specialized function variants — one per unique type signature encountered. Consider:


function scale(arr, factor) {

  let result = [];

  for (let i = 0; i < arr.len(); i++) {

    result.push(arr[i] * factor);

  }

  return result;

}



let a = scale([1, 2, 3], 2);     // f64[] * f64

let b = scale([0.5, 1.5], 0.5);  // f64[] * f64 (same stub)

The compiler sees that arr is always f64[] and factor is always f64. It generates a single monomorphized stub scale_f64_arr(f64[], f64) -> f64[]. The assembly for the loop body becomes: load d0 from array, fmul d0, d0, d1 (native multiply), store result. Zero type checks, zero Variant Enum dispatches.

If a different call site used scale(['a', 'b'], '!'), the compiler would generate a second stub scale_str_arr(string[], string) -> string[] with string concatenation logic. Each stub is independently optimized. The call site selects the correct stub at compile time through the linker symbol.


Shapes: Object Properties as Memory Offsets

Object property access is a notorious bottleneck in dynamic languages. A naive implementation uses a HashMap lookup per property access — O(1) amortized, but with hashing overhead, dynamic allocation, and cache misses.

Dryad's AOT compiler assigns a Shape (also called Hidden Class or Map) to every object construction site. When the compiler sees:


let point = { x: 10, y: 20 };

It creates a Shape encoding: property x at offset 0, property y at offset 8. The access point.y becomes load [obj + 8] — identical to a C++ struct member access. Objects constructed at different sites may share the same Shape if their property sets match, enabling inline caching for polymorphic sites.

When properties are added or removed dynamically, the Shape transitions. The compiler generates fast-path inline cache checks for the expected Shape and a slow-path fallback that performs a dictionary lookup. In practice, most objects have a stable Shape after construction, so the fast path dominates.


Escape Analysis: Free Stack Allocation

Objects that don't outlive their allocating function can be placed on the stack instead of the heap. Dryad's AOT performs escape analysis on every allocation site: if the object is never stored to a global, never returned, and never captured by a closure, it is stack-allocated.

This eliminates three sources of overhead at once: (1) the malloc/free call for heap allocation; (2) reference counting operations (stack lifetime is deterministic — the object dies when the frame dies); and (3) GC pressure. Combined with Shapes for property access, a non-escaping object produces zero runtime overhead for allocation, access, and deallocation — indistinguishable from a C++ struct on the stack.


Why Not JIT?

The obvious question: why go through all this effort when JIT compilers with deoptimization already work well?

The answer lies in Dryad's design axioms, particularly Zero Surprises and Clarity over Performance. Adaptive optimization systems inherently violate Zero Surprises: the same code runs at different speeds depending on whether the JIT has warmed up, whether type profiles fit in memory, and whether deoptimization triggers at an unfortunate moment. Debugging performance issues in JIT-compiled languages often requires understanding the compiler's internal heuristics — a skill orthogonal to writing correct programs.

Bind Once accepts a different trade-off: peak performance may be slightly lower than a well-tuned JIT for pathological cases (code with genuinely dynamic types that a profile-guided JIT could optimize speculatively). In exchange, it provides:

  • Deterministic performance: the binary runs at the same speed on every execution, on every machine.

  • Zero warm-up: the first invocation is as fast as the millionth.

  • Simpler toolchain: no need to embed an interpreter or profiling infrastructure in the compiled binary.

  • Smaller binaries: no JIT compiler code, no profiling data structures, no code cache.

  • Predictable memory usage: no hidden allocations for type feedback vectors or optimized code cache.

For Dryad's target domains — scripting, automation, prototyping, and small-to-medium applications — these characteristics matter more than extracting the last 5-10% of peak throughput from deeply polymorphic hot paths.


Summary

Dryad's Bind Once strategy is a deliberate architectural choice: replace the complex adaptive optimization loop (interpret → profile → JIT → deopt → re-JIT) with a single, irreversible compile-time type binding. Gradient inference, type-specialized stubs, Shapes, and escape analysis make this viable for the vast majority of code. The small fraction of genuinely polymorphic code falls back to the Variant Enum, maintaining correctness without compromising the optimization of the rest.

This approach aligns with Dryad's philosophy: clarity and predictability over speculative peak performance. The compiler tells you exactly what it will generate. The binary tells you exactly how fast it will run. No surprises.

AOT·Compilers·Type Systems·Performance·Architecture