μ Field Notes · Implementation May 2026

50× speedup, guided by one benchmark

Field Note #1 · May 2026 · interpreter · optimiser · runtime

μ is a small array language — expressive, dynamically typed, interpreted. Making it fast without sacrificing generality is the core engineering challenge. This note documents the methodology: pick one real program, profile it ruthlessly, and fix only what the numbers justify. No speculative tuning, no benchmark-specific shortcuts — every fix must generalise to any μ program that hits the same pattern.

The program is a Monte Carlo GBM option pricer — a few dozen lines of μ that generate 10,000 price paths and price an Asian call. It is a good probe because it has three measurable phases, a clear C reference implementation to calibrate against, and a Python+NumPy version that sets a realistic target for an interpreted language.

Workload: GBM Monte Carlo, 10,000 paths (5,000 antithetic pairs), 252 daily steps. Asian call (strike 1.05 × S₀). All timings: Apple M-series, clang -O3 -ffast-math -march=native, --quiet.

The benchmark as a probe

The core of the simulation:

bm:         {[n]  sqrt(-2.0*log(1.0-?n)) * cos(2.0*pi*?n)  }
price_path: {[w]  S0 * exp(+\((w*vol_dt)+drift))            }

za:        {(bm nsteps)}' !npairs       // Phase A: BM draws
pos_paths: price_path' za               // Phase B: GBM paths
neg_paths: price_path' {neg x}' za      // Phase B: antithetic
paths:     pos_paths , neg_paths

finals:    {last x}' paths              // Phase C: reduce
payoffs:   0 | (finals - 1.05*S0)
price:     exp(-0.05*T) * (+/ payoffs) % (2*npairs)

Three phases isolate costs cleanly:

PhaseExpressionPython+NumPyμ (current)
A — BM generation{(bm nsteps)}' !npairs~22 ms~18 ms
B — GBM pathsprice_path' za (×2)~25 ms~8 ms
C — payoff{last x}', mean~6 ms~1 ms
Total~53 ms~46 ms

Starting point: three root causes

Before any work, at 1,000 paths: mu takes ~287 ms, C takes ~2.4 ms. A profile immediately shows three causes:

  1. Output printing (~64% of wall-clock). Every assignment x: val calls val_fprint → __dtoa. For a 252-float array, 252 formatted writes per line. --quiet alone drops 287 ms → 102 ms.
  2. Linear string scan per env lookup. env_get("vol_dt") runs strlen + memcmp against every live name in the frame (~16 entries). Called millions of times per run.
  3. Per-element heap boxing. +\ and element-wise * each allocate a fresh value_t node (24 bytes) per output element — 252 allocations per operation per path.

The eleven fixes — 102 ms to 5.5 ms

All measurements at 1,000 paths / 500 pairs, --quiet. The bar tracks the running total; each fix removes one class of overhead.

runtime optimiser kernel fusion
F1
102 msbaseline

Interned environment keys → 51 ms

What
All symbol names are deduplicated into a static bump-pointer buffer via sym_intern(). env_set_iname / env_get_iname compare keys with pointer == instead of strlen + memcmp. The implicit args x, y, z are pre-interned at startup.
Why
The hot path calls eval_expr on every node, resolving names in a ~16-entry frame with string comparison. At 252 steps × 10k paths that is 2.5 million frame scans per run.
Impact
102 ms → 51 ms. The env scan was the single largest cost.
F2
51 ms−50%

Packed-array arithmetic → 23 ms

What
Homogeneous float/long lists use LIST_STORAGE_FLOAT / LIST_STORAGE_LONG backed by a flat double[] / long[]. All arithmetic verbs, +\, and math keywords detect packed storage and operate with a single float_alloc(n) + one tight C loop.
Why
Each previous arithmetic step allocated 252 × 24-byte value_t nodes and a value_t*[] items array. The inner loop for +\w was memory-bound, not compute-bound.
Impact
51 ms → 23 ms. Also a prerequisite for the fused kernels in F7–F11.
F3+F4
23 ms−55%

RNG + each' env reuse → 17 ms

F3 — RNG
verb_rand(n) previously allocated n individual value_t nodes. Replaced with a direct write into float_alloc(n) using xoshiro256+ (period 2²⁵⁶−1, passes PractRand). Eliminates 252k val_alloc calls per path.
F4 — each' env reuse
The handler previously copied the entire 16 KB env_t struct on every iteration. Fix: build the local env once from the captured frame, then per iteration reset local.len to the post-seed depth and rebind only the argument with one env_set_iname.
Impact
23 ms → 17 ms combined.
F5
17 ms−26%

Scratch-pool reset → 13.5 ms

What
The bump-pointer pools are treated as a call stack inside each'. The output slab is pre-allocated before eval_expr; all scratch lands above it. After the call, pool tops reset to just above the output — discarding all intermediates with no memcpy.
Why
Without this, ~9 intermediate float slabs per price_path call accumulated across all 10k iterations. Working set reached ~19 MB at 1k paths and overflowed at larger counts.
Impact
17 ms → 13.5 ms. Working set: 19 MB → 4 MB. Enabled the 10k-path default.
F6
13.5 ms−21%

Scalar accumulation → 13.5 ms overall · Phase C: 32→1 ms

What
When each''s body returns a scalar float, results are written directly into a pre-allocated float_alloc(n) slab and returned as a packed FLOAT[] — no per-element val_alloc.
Why
At 10k paths, {last x}' paths was allocating 10,000 value_t nodes and returning a LIST_STORAGE_GENERAL array, forcing downstream +/ to unbox every element. Making finals a packed FLOAT[] lets all downstream ops hit the fast path.
Impact
Negligible at 1k paths, but Phase C at 10k paths: 32 ms → 1 ms. Critical for making the 10k-path default practical.
F7
13.5 msphase C now <1 ms

Function-body inliner → 11 ms

What
The optimiser stores inlined bodies back into EXPR_FUNC_LITERAL nodes at definition time. When price_path:{[w] S0*exp(sums(incr w))} is defined, incr and sums are substituted and scalar captures (vol_dt, drift, S0) are folded to EXPR_CONSTANT. The stored body becomes S0 * exp(+\((w*vol_dt)+drift)) with all constants embedded.
Why
Without this, every call to price_path(w) resolved incr and sums by name and dispatched through apply_func_once twice — two env lookups and two call levels per path.
Impact
13.5 ms → 11 ms.
F8
11 ms−19%

GBM fused kernel → 9 ms

What
The optimiser pattern-matches S0 * exp(+\((w*C1)+C2)) in function bodies and replaces it with __gbm_path_fused__: a single C loop (prefix-sum log-price + expf), one float_alloc(252), no intermediate arrays. Structurally identical to the C reference.
Why
Even with packed arrays, the AST-walked loop for +\ + * + exp allocates three 252-float slabs per path. The kernel collapses all three into one output slab and lets the CPU pipeline fma + expf.
Impact
Phase B at 10k paths: ~118 ms → ~19 ms. At 1k paths: 11 ms → 9 ms.
F9
9 ms−18%

Normalisation pass → 8 ms

What
A new opt_normalize pass runs before opt_expr: (1) fold all-constant subtrees to EXPR_CONSTANT (2*acos(-1.0) → 6.2832); (2) substitute scalar env names into expression bodies; (3) reassociate CONST*(CONST*expr) → (CONST*CONST)*expr, collapsing 2*pi*?n into one multiplication and saving one float_alloc(252) per BM call.
Impact
9 ms → 8 ms. Also prerequisite for F10's pattern match.
F10
8 ms−11%

BM fused kernel → 7 ms

What
Pattern-matches sqrt(-2*log(1-?n)) * cos(C*?n) and replaces it with __bm_fused__: one loop, two xoshiro256+ draws per element, one float_alloc(n), zero intermediate arrays.
Why
Box-Muller in AST form generates two 252-float RNG arrays then a chain of intermediate slabs for log, sqrt, cos, * — seven operations, seven allocations per path.
Impact
1k paths: 8 ms → 7 ms. Phase A at 10k paths: ~52 ms → ~44 ms.
F11
7 ms−13%

Wrapper inlining + pre-resolved constants → 5.5 ms

F11a — Wrapper inlining
The benchmark calls bm via a closure: {(bm nsteps)}'. After F10, bm's stored body was APPLY(__bm_fused__, [n, 12.5664]). The inliner checked if the captured frame was safe to inline through — it was not, because __bm_fused__ has body=NULL (a bare verb alias), and frame_is_inlinable_for_body treated any body==NULL function as unsafe.

The fix: distinguish fused kernel sentinels (uppercase verb character defined in opt.h) from arbitrary unknown closures. Fused kernels are compile-time constants — they never close over mutable state. Allowing them as inlinable unblocks the substitution, exposing APPLY(__bm_fused__, [252, 12.5664]) directly in the adverb body.
Impact of F11a
Phase A at 10k paths: ~44 ms → ~18 ms. Now matches C's ~18.5 ms — the BM loop in μ and C are structurally identical.
F11b — Pre-resolved fused constants
All make_*_fused_call builders previously emitted EXPR_NAME("__gbm_path_fused__") as the function node. At runtime, eval_expr resolves every EXPR_NAME via a linear env scan — on every iteration of every each' loop.

The fix: emit EXPR_CONSTANT(val_func(NULL, VERB_*_FUSED, ...)) instead. eval_expr on EXPR_CONSTANT returns the embedded value with zero env lookup. A new helper make_fused_fn_node(char verb) centralises the pattern.
Impact of F11b
Phase B at 10k paths: ~19 ms → ~8 ms.

Current state: head-to-head at 10,000 paths

ImplementationTotalPhase APhase Bvs Python
mu — unoptimised~1,000 ms——~19×
mu — after F1–F9~78 ms~52 ms~19 ms~1.5×
mu — after F10~70 ms~44 ms~19 ms~1.3×
mu — after F11 (current)~46 ms~18 ms~8 ms~0.87×
Python + NumPy~53 ms~22 ms~25 ms1×
C (-O3 -ffast-math -march=native)~24 ms~18.5 ms~3.6 ms~0.45×

Current μ at ~46 ms is faster than Python+NumPy (~53 ms) on this workload. Phase A is well ahead (~18 ms vs ~22 ms). Phase B is where the gains are largest: μ's fused GBM kernel (~8 ms) beats NumPy's vectorised path construction (~25 ms) because NumPy must allocate and broadcast full n_paths × n_steps matrices, while μ processes each path independently with a single tight C loop. The remaining overhead vs C is Phase B's per-path float_alloc and function dispatch — architecture-level cost, not algorithmic.

What was not done, and why

Antithetic-path fusion. The benchmark runs price_path on both za and za_neg. A benchmark-specific optimisation could fuse both passes, halving Phase B's iteration count. Rejected: it does not generalise to any other μ program. The general fix (F11b) eliminated the per-call overhead instead.

SIMD / vectorised BM. NEON intrinsics could give 2–4× throughput on the BM kernel. Not done: Phase A is already at C parity. Complexity without proportional gain.

What comes next

μ is now faster than Python+NumPy overall. The interesting remaining question is whether the per-phase advantage can be widened. Phase B (~8 ms) is the main target — NumPy takes ~25 ms here, so there is headroom, but also a structural cost:

  1. Per-path allocation and dispatch. Each of the 10,000 calls to price_path' enters apply_func_once and allocates a float[252] output slab. Batching all paths into a single 2D kernel (n_paths × n_steps) would eliminate this but requires a different adverb evaluation strategy.
  2. The neg pass. {neg x}' za calls eval_expr per element — O(n_paths) AST dispatch. Recognising {neg x}' as a map-negate kernel is a straightforward extension of the existing fusion patterns.

The {neg x}' map-negate pass is the simplest win: recognising it as a built-in map kernel would eliminate O(n_paths) eval_expr dispatch with a trivial pattern addition. Beyond that, closing further vs C requires the 2D batch kernel — a larger architectural change with diminishing returns given the Python comparison is already won.

General lessons

  1. Profile before fixing. F1–F3 alone covered ~80% of the total reduction. Guessing would have produced far worse leverage.
  2. One bottleneck hides the next. String comparison → boxing → rand allocation → env copy → pool pressure — the waterfall shape in the fix table is not coincidence.
  3. Every fix must generalise. Interned env, packed arrays, and fused kernels all benefit any μ program that hits those patterns.
  4. Fused kernels are a force multiplier. F8 + F10 together dropped Phase B from ~118 ms to ~19 ms, not by being clever, but by writing structurally identical code to the C reference.
  5. Guard conditions in the optimiser need care. F11a's conservative safety check in frame_is_inlinable_for_body silently blocked a correct, high-value transformation. The fix was two lines; the diagnosis required understanding the full opt pipeline.

Timings: Apple M-series, clang -O3 -ffast-math -march=native, ./mu --quiet --time. μ commit: main, May 2026. Source: experiments/montecarlo/.