Concepts

Architecture

Rayzor is a native code generator, not a source-to-source backend: every backend emits machine code, WASM bytecode, or C used as an assembler. What it adds over the official compiler is a tiered runtime and a single optimization pipeline shared by every backend — code starts executing immediately, and only what proves hot pays compilation cost.

Language transpilation is an explicit non-goal. There is no JavaScript target and none planned.

The pipeline

01 Parser
Preprocessor and conditional compilation, then a recursive-descent parser — with a legacy parser for error recovery
02 AST
Source syntax verbatim, then macro expansion (interpreter, reification, @:build)
03 TAST
Types, symbol table and type table resolved. Type checking is folded into lowering, not a separate phase
04 HIR
Desugared but still structured — ForIn, TryCatch, Switch, lambdas. Ownership analysis and diagnostics read here
05 MIR
SSA over basic blocks with real phi nodes. Monomorphization runs here, after lowering and the stdlib merge
06 Backends
One optimized MIR consumed by the interpreter, Cranelift, LLVM and WASM alike

HIR still knows what the user wrote, so diagnostics and ownership analysis read naturally there. MIR is flat and typed, so one optimization pipeline serves every backend — that is the whole reason for the split. Type checking is not a separate phase; it is folded into AST lowering.

$ rayzor compile main.hx --stage ast|tast|hir|mir|native

MIR

SSA over basic blocks with real phi nodes, type metadata carried down rather than erased. Every collection is a BTreeMap — explicitly so iteration order is deterministic and codegen is reproducible.

IrModule functions · globals · types · string_pool · extern_functions
IrFunction signature · cfg · locals · register_types
IrBasicBlock instructions · terminator · phi_nodes · predecessors

MIR prints in an LLVM-like textual form — registers $N, blocks bbN — sorted by id so dumps diff cleanly. RAYZOR_DUMP_MIR=1 fires before the passes; use rayzor dump --diff for what the backend actually sees.

Optimization levels

The default is O2. InsertFree is added before the level match at every level — it is a correctness pass, not an optimization.

O0 Inlining(15), DCE, UnreachableBlockElim, SRA, CopyProp, DCE
O1 Inlining, DCE, Devirtualization, ConstantFolding, CopyProp, UnreachableBlockElim — the only level without SRA
O2 O1 plus SRA, GlobalLoadCaching, BCE, GVN, CSE, LICM, LoopUnrolling, ControlFlowSimplify, DCE (default)
O3 O2 plus LoopVectorization and TailCallOpt

O0 is not "no optimization." It still forces inlining, because Haxe inline is a language guarantee rather than a hint — and because inlining small constructors is what exposes the Alloc+GEP shape SRA needs. Without it, per-iteration constructor allocations are never scalarised and loops leak.

Ordering is load-bearing: inlining exposes Alloc+GEP for SRA, GlobalLoadCaching dedups metadata loads for BCE, BCE emits the invariant load LICM hoists, and LICM's clean loop bodies are what LoopUnrolling and LoopVectorization need.

Tiered execution

The interpreter is tier 0, the next three rungs are Cranelift, and Maximum is LLVM. Promotion compares tier ordinals, so a counter clearing several thresholds at once skips rungs.

T0 Interpreted MIR interpreter at O0 — register-based, since MIR is already SSA. Instant startup
T1 Baseline Cranelift, no opt, O0. Compiled inline on the main thread
T2 Standard Cranelift speed, O1. Routed to a background broker thread
T3 Optimized Cranelift speed, O2. Background, one adapter and bead registry per tier
T4 Maximum LLVM at O3, queued and drained on the main thread — add_global_mapping requires it

The promotion barrier is the safety-critical part. Function pointers cannot be swapped while JIT code is running, so a safepoint gates the swap: the promoter requests promotion, waits for the in-flight execution counter to drain to zero, swaps under a write lock, and returns to idle. Installs are monotonic — a pointer for a function already at a higher tier is dropped.

SIMD

Vectors are language-level types, not intrinsics you reach for through externs. The rayzor.SIMD* family are @:coreType abstracts whose representation follows from their identity — each lowers straight to a MIR vector type, in 128- and 256-bit widths, across float and integer lanes.

SIMD4f
4 × f32
128-bit
SIMD4i32
4 × i32
128-bit
SIMD16i8
16 × i8
128-bit
SIMD8i32
8 × i32
256-bit
SIMD32i8
32 × i8
256-bit
import rayzor.SIMD4f; var a:SIMD4f = (1.0, 2.0, 3.0, 4.0); // tuple literal var b = SIMD4f.splat(2.0); // broadcast to 4 lanes var c = a * b + a; // @:op overloads var d = a.dot(b); // dot, sum, normalize, magnitude, lerp
Representation Decided from the type's identity ahead of the underlying type, so a vector is never truncated to a 64-bit param at a call boundary
Construction Tuple literal, array literal via @:from, SIMD4f.make(x, y, z, w), or SIMD4f.splat(v) to broadcast one scalar across all four lanes
Operations Arithmetic through real operators, lane read/write via array access, and a full float surface — dot, sum, sqrt, abs, min, max, rounding, normalize, magnitude, lerp
Quantized kernels The integer types carry widening dot-accumulate — dot, dotI8I7, dotI8U8 — which lower to a single VNNI instruction on x86-64
Native paths Vectorized f32 CPU paths for NEON and SSE2, with a scalar fallback where neither is available
WASM SIMD128 The WebAssembly backend carries the same vector work through SIMD128
Tier policy The interpreter executes vector types, and functions that use SIMD are promoted to Baseline on first call so vector work runs compiled from the start
Vectorization LoopVectorization runs at O3, on the clean loop bodies LICM leaves behind

The 256-bit types are not portable. SIMD8i32 and SIMD32i8 are LLVM-only — wasm's v128 and Cranelift have no 256-bit vector type and refuse them rather than narrowing. Gate behind #if llvm with a 128-bit fallback. The TCC linker path on Linux also lacks SIMD in its final tier.

The rayzor package

Beyond the Haxe standard library, Rayzor ships a systems layer: memory primitives that map to real machine types, a native vector monomorphized per element type, and a concurrency surface the compiler validates through Send and Sync.

Memory & systems

Pointer-sized abstracts that carry machine addresses and are never truncated.

Ptr Ref Box Usize Mem Bytes Slice CString Double Atomic
Collections

Vec<T> is monomorphized per element type — VecI32, VecF64, packed VecBool, VecPtr — so primitives are contiguous and unboxed.

Vec<T> Result<T,E>
Concurrency

Message passing and shared state, validated at compile time through Send and Sync.

Thread Channel<T> Select Mutex Arc Future WorkerPool SpinPool Parker CpuTopology
Concurrency guide →
Platform

Windowing, compile-time feature detection, and a built-in spec runner.

Window Key EventType WindowStyle CC Spec
var pool = WorkerPool.global(); pool.parallelFor(1000000, (idx, node) -> { ... }); // one worker per NUMA node var f = Future.create(() -> heavy()); // lazy — nothing runs yet f.then(v -> trace(v)); // or f.await() to block

Parallelism adapts to the machine. On multi-NUMA-node systems the pool pins one worker per node so allocations land first-touch on that node's controller; on UMA hardware and wasm it runs inline on the calling thread unless you force fanout.

Backends

interpreter Register-based MIR interpreter. Instant startup, tier 0
cranelift JIT tiers 1–3. Fast compile; fuses fmul into fma within a block
llvm Default AOT path, top JIT tier, and a whole-module upgrade
wasm MIR → core WASM → WASI P2 component. Linear memory, SIMD128
wgsl @:shader classes → WGSL at compile time. Not a general target
c99 Experimental — MIR → C99 → gcc -O2. Not an official backend

AOT defaults to LLVM; without the llvm-backend feature rayzor aot errors rather than silently degrading.

Memory & object layout

Cleanup is decided at compile time. The HIR-level drop-point analyzer computes last use per variable, tracking loop position, reassignment, block depth, and two escape sets — general escapes and lambda captures, since a captured variable is owned by the closure.

slot 0 __type_id : i64 ← stable name-hash class id slot 1 first user field ... every slot is 8 bytes

There is no vtable pointer in the header — dispatch resolves the vtable from the class id in slot 0, and interface values are fat pointers wrapped at the new site. Allocation size takes the maximum over the whole extends chain at the allocation site, because an imported parent's fields may not have been visible when the subclass was registered.

AutoDrop Compiler emits Free — user classes allocated with new
AutoDropWithDtor Run the user's drop(), then Free — @:derive(Drop)
ManualDrop @:manualDrop; never auto-freed
RuntimeManaged Runtime owns the lifetime — Thread, Channel, Arc, Mutex
NoDrop Primitives, arrays, Dynamic

On-disk formats

.blade BLAD

One MIR module plus metadata and cached maps.

.bsym BSYM

Pre-resolved stdlib symbols, for fast startup.

.rzb RZBF

All modules, module table, entry point, build info.

Cache invalidation uses three independent keys: a source content hash, the compiler semver, and a content-derived compiler cache ABI id — the last exists because parser or MIR-shape changes do not bump the semver. Cached maps are keyed by name, never by symbol or type id, because ids are reassigned per compilation.

The full architecture doc

Pass internals, the runtime ABI, closure conventions and the format specs.

Read it on GitHub ↗