Performance

Coming from Rust / C++? You’re used to controlling allocation and watching it. Hale’s arena model makes most code allocation-bounded by construction — a per-method scratch region absorbs intermediate allocations and frees them at method exit — but a few patterns can still grow a long-running process. This chapter is the shape of that growth and how to keep it flat.

The default is already bounded

Section titled “The default is already bounded”

Inside any locus method, a scratch sub-region opens on entry and is destroyed on return. Transient allocations — string concatenations, JSON parsing, format building — land in scratch and are reclaimed when the method returns. Values you persist (self.field = ...) are deep-copied into the locus’s own arena first, so they outlive the scratch. The net effect: a hot run() loop that allocates transiently doesn’t grow the locus’s lifetime arena. You get this without doing anything.

So the question isn’t “how do I free?” — it’s “which patterns defeat the automatic bounding?”

The pattern that bites: accumulating in a loop

Section titled “The pattern that bites: accumulating in a loop”
fn render(rows: Int) -> String {
let mut out = "";
let mut i = 0;
while i < rows {
out = out + render_row(i); // a fresh String each iteration
i = i + 1;
}
return out;
}

Each out + ... allocates a new string; scratch demand peaks at the total size of every intermediate. For large inputs that crosses a chunk boundary. The fix is an accumulator that grows one buffer in place:

fn render(rows: Int) -> String {
let b = std::bytes::BytesBuilder { };
let mut i = 0;
while i < rows {
b.append(std::bytes::from_string(render_row(i)));
i = i + 1;
}
return std::str::from_bytes(b.finish());
}

BytesBuilder is the canonical accumulator — one extensible buffer instead of N throwaway strings. Use it (or std::json::Builder for JSON output) anywhere you build a result incrementally.

Resolve string keys to ints at boot

Section titled “Resolve string keys to ints at boot”

If a hot path looks something up by string key in another locus, the string gets copied on every call. Resolve the key to an Int index once at startup and pass the index on the hot path:

locus Service {
params { metrics: MetricsRegistry = MetricsRegistry { }; ticks_idx: Int = 0; }
birth() {
self.ticks_idx = self.metrics.register("ticks_total"); // clone once
}
fn dispatch(m: Msg) {
self.metrics.inc(self.ticks_idx); // zero per-call alloc
}
}

Reclaim per-connection state

Section titled “Reclaim per-connection state”

The other place growth hides is a daemon that accepts a child per connection. If those children are residents, their regions live until the (never-dissolving) parent does, and memory climbs with connection count. Make them flows — declare release(c: Conn) on the parent — so each child’s region is reclaimed when its connection ends. If RSS tracks connection count, this is almost always why.

Catching it at compile time

Section titled “Catching it at compile time”

The growth patterns above — a per-message handler that allocates into self, a connection child left resident — have a static shape, and hale check flags them before you ever measure RSS. These are advisory warnings, not build failures:

When a path really is hot — a per-frame handler, a tight decode loop, a 10k/s ingest — certify it and have the compiler hold the line. That’s layered, so the strictness lands only where you ask for it:

@hot on a fn or handler promotes the advisory findings above to hard errors inside that fn, and turns on two stricter perf hints that would nag as defaults: .snapshot() / .finish() in a loop (each call copies the builder’s whole contents — prefer the zero-copy .view() / .text_view()), and a whole-struct self-field replace (reclaimed since v0.11.3, but each store still pays a clone + retire per String field where in-place scalar mutation is allocation-free).

@budget(alloc_per_call = N) on a fn (free or method) is the counted contract on top: the fn allocates at most N times per call, enforced as a hard error (this is the one allocation check that fails the build — because you asked for it). The two stack: @hot @budget(alloc_per_call = 0) fn send(...).

Certifying a hot path

Section titled “Certifying a hot path”

A hot path is worth more than a comment saying it’s hot. Hale lets you state what a function may do — no syscalls, no blocking, no allocation, no clock — and have the compiler prove it over the call graph, so the property survives the next refactor:

@no_block @no_syscall @deterministic @no_recursion @hot
@budget(alloc_per_call = 0)
fn on_tick(a: Int, b: Int) -> Int { ... }

That one line is the full hot-path certificate: nothing that waits, nothing that reaches the kernel, nothing non-deterministic, no unbounded stack, no allocation. Each part is checked, and a violation names the call chain that reaches it.

The whole surface — the effect classes, @budget’s other dimensions, per-lifecycle-phase contracts, and what the compiler infers on its own — is taught in Effects & contracts.

Knobs for when it’s not your code

Section titled “Knobs for when it’s not your code”

The substrate exposes diagnostics and glibc tuning via environment variables — LOTUS_ARENA_RESIDENCY=1 to dump live arena sizes from a heartbeat, LOTUS_ARENA_LOG_CHUNK_ATTACH=N to trace which arena is growing, LOTUS_CHUNK_POOL_STATS=1 for chunk-pool hit rates, and the MALLOC_* family for glibc’s trim/arena behavior. The full table is in spec/memory.md and the keeping memory bounded spec material. The workflow: smaps-diff over a window → if it’s [heap], check 30s deltas → bursty 64KB steps mean chunk-pool overflow (a loop accumulator) → fix with BytesBuilder.

Hot-path I/O primitives

Section titled “Hot-path I/O primitives”

For latency-sensitive sockets, the stdlib exposes the knobs you’d reach for in C, without an FFI shim:

And the run-time complement to the compile-time --warn-unbounded-alloc check: std::diag::heap_alloc_count() and std::diag::syscall_count(name) let a test assert a steady-state region did what you think — read the counter before and after and check the delta is zero (“this loop allocated nothing”, “exactly one recv per poll”).

Build-time tuning

Section titled “Build-time tuning”

hale build already tunes to the machine you build on: native builds compile for the host CPU at O3, so generated code autovectorizes to whatever the host supports (AVX2, AVX-512, …). Two knobs matter when that default isn’t what you want:

Where Hale earns its overhead

Section titled “Where Hale earns its overhead”

Where Hale leads is where the design bet lives: subject-keyed dispatch — the lock-free bus plus static-dispatch devirtualization turned message dispatch from several-times-behind into an outright lead (ahead of Go ~2.4×, and ahead of hand-written C ~1.9× on the same workload) — and contended @form writes (hashmap set ~1.3–1.4× vs a hand-rolled C table). On the fan-out path, dispatch to a where async_io subscriber reuses coroutine stacks from a bounded per-worker pool rather than allocating one per delivery.

The tight loop caught up too: pure arithmetic used to be where the substrate showed through, but native codegen closed it to parity with clang -O3 C (loops, vec reads, hashmap reads all sit in the ±15% band).

And the honest column: the per-op overheads that remain are free-fn call protocol (~2.5× behind the compiled trio), locus instantiation (2–3× — region setup is a cost you pay at spawn, not a place Hale wins), and heap-payload dispatch. The design guidance follows directly: resolve handles at boot and keep hot paths on the bus and in @form storage (styleguide S1/S12), spawn loci at topology changes rather than per message (the compiler warns), and the overheads sit outside the paths that repeat.

Current benchmark numbers and methodology live in hale-lang/bench.

Next: what @form actually compiles to — Forms under the hood.