A honeybee colony that showcases the OTP actor stack on the Gleam native target, and benchmarks it against the BEAM — one project, one set of (polyglot) dependencies, two targets.
The showcase (gleam run) walks through:
- a crew of named actor foragers under a one-for-one supervisor
- parallel work fanned out to the crew and collected
sys.get_stateintrospection of a running actor- a crash (a wasp!) and the supervisor's transparent replacement — the same named subject keeps working, state reset to zero
- scouts started on demand by a factory supervisor
- the waggle dance: a message relayed around the crew
Run it on either side:
make run # native
make run-beam # BEAM (crash reports appear inline — that's OTP's logger)make benchMeasures three phases (10 passes each over the supervised crew, fastest
of 3 runs, noop-baseline subtracted) on the BEAM, the native JIT, and
ahead-of-time compiled native executables (one per benchmark module, via
gleam export native -m <module>):
- forage — Collatz work fanned out to the crew: compute-dominated
- calls — synchronous request/reply round trips: park + wake per call
- waggle — a message relayed around the crew: pure send/switch cost
Every configuration must print byte-identical output for every benchmark module, and the AOT executable must reproduce the JIT's showcase output exactly, or the script fails.
make profile # forage phase
make profile PHASE=waggle
make profile PHASE=callsExports the phase's profiling harness ahead-of-time (so every Gleam
function keeps its gleam$module$function symbol), samples it under
macOS's sample, and prints per-function self-time; the full call tree
lands in /tmp/hive-profile-<phase>.txt. Every phase's story was
written by this loop — fix the top item, re-profile, repeat:
- forage is ~100% inside
forager.flight— the Collatz loop itself, with the runtime invisible (<0.5%). Profiling drove three code generator improvements: inline fast paths for integer/and%, direct self-tail-recursion compiled as intra-function loops, and finally evenness fusion —case n % 2 { 0 -> .. _ -> .. }needs only whether the remainder is zero, which for a tagged small integer is one bit test on the dividend (sign-independent), so the whole remainder computation and the released temporary vanish. Disassembly drove that last one: the%block was 13 instructions, now 4. Together they took the phase from ~93 ms to ~12 ms per pass — past the BEAM. - waggle spends its active time on the per-hop message lifecycle.
Profiling removed two costs — the actor now caches its per-message
selector instead of rebuilding it (which sped the BEAM up even more
than native), and the process/name tables use read-write locks — and
then drove move-on-send: when a send's message argument is the
binding's last use, the code generator hands ownership to the runtime,
which moves every exclusively-owned node straight into the receiver's
mailbox and deep-copies only shared subtrees. Every waggle hop moves
its root. The last copies were the route's subjects, shared with the
still-live incoming message — so subjects became atomically counted
shared leaves: their count word carries a flag that routes the inline
count fast path's cold branch to an atomic instruction, letting sends
share the same subject box across process boundaries with no copy and
no leak. The finale is receiver-side spine reuse: a clause now
frees the deconstructed message container the moment its fields are
bound (instead of after the clause body), so the received route's
cells reach the handler's
list.appenduniquely owned — and the nativeappendsplices a uniquely-owned left list onto the right in place. Each hop's spine circulates between the processes like a ring buffer: 250,000 of 250,010 appends per run splice with zero allocation, and the phase's remaining active samples are the ownership walk, two small frees, and the scheduler's park/wake — the same relay cost the BEAM pays.
On an Apple Silicon Mac (macOS 14, release-built compiler, Erlang/OTP 29), 2026-08-27:
| configuration | noop | forage/pass | calls/pass | waggle/pass | combined/pass | peak RSS |
|---|---|---|---|---|---|---|
| erlang (BEAM) | 0.26 s | 13.5 ms | 13.8 ms | 12.6 ms | 41.9 ms | 84 MB |
| native (JIT) | 0.28 s | 12.0 ms | 12.6 ms | 12.7 ms | 40.2 ms | 33 MB |
| native (AOT) | 0.01 s | 12.7 ms | 12.3 ms | 12.4 ms | 40.5 ms | 5 MB |
What the numbers say:
- Native now wins the combined run — 40.2 ms against the BEAM's 41.9, with every phase at parity or ahead.
- Forage flipped to a native win (12.0 vs 13.5 ms) after starting the project 7x behind: div/rem fast paths, self-tail-call loops, and evenness fusion, each found by profiling and the last by reading the loop's disassembly.
- Synchronous calls stay a native win — 12.3 ms against the BEAM's 13.8 per pass (~1.2 µs per round trip).
- The waggle relay sits at parity — 12.4 ms AOT against the BEAM's
12.6 (once 1.9x behind at ~25 ms): selector caching, move-on-send,
atomically shared subjects, and early container release plus in-place
list.appendeliminated every per-hop copy and spine allocation; what remains on both targets is the relay's intrinsic park/wake. - Memory and startup are the native target's headline: the ahead-of-time binaries run the full actor workload in 5 MB of peak memory with a 0.01 s startup, against the BEAM's 82 MB and 0.27 s — actors, supervisors, mailboxes and all. The JIT sits between at 32 MB.