Hazel reports · ^report(27) · performance · started 2026-10-22 · work in progress · about 7 minutes to read
The World Rule in Hazel and in Fumola
Matthew, after ^report(25): do the same game logic that we've exported to Hazel in
This is the World room's rule (a step to the square beside), written in Fumola, and timed against Hazel with every performance change we have, at map sizes from 3x3 to 64x64 (^report(25), but now do a Hazel (best PRs for performance) against Fumola in Wasm, doing the same logic ... I especially want to compare the map-based version in Fumola (store world data in maps, not lists) --- so there's a chance at O(log N).^ticket(542)). The syntax of the two side by side is ^report(28).
Short answers. Node on the Ubuntu laptop, one process at a time, the median call, the mean of two rounds; every answer checked against the game's.
- Fumola with the world held in a HashMap is flat: about 0.54 ms and 450 steps a call at 64x64, the same as at 3x3. The map is built once, when the world changes (295 ms at 64x64), and each move passes four numbers and a direction and looks up one square. Fumola's
HashMapis a persistent hash trie, O(log₃₂ N), constant at these sizes. That meets the goal set in^report(25)'s next steps: O(1) or O(log N) in the world's size. - Hazel has no map to do the same. Its best here walks a list: 254 ms at 64x64 with the world held in a closure, 346 ms passed in. Against Fumola's held map, that is 471x and 641x.
- Like for like, Fumola is faster on every version that grows. At 64x64, passing the whole map: 145 ms against Hazel's 346 (2.4x). Holding it as a list and walking it: 26.3 ms against 254 (9.7x).
- With only the neighbours passed, the two are the same: about 1 ms, at every size (1.35 and 1.27 ms at 64x64). There the call boundary is all there is.
- The boundaries differ. Hazel's library converts JS values into Hazel's. Fumola's wasm build takes only source text, so a passed map is written out and parsed every call: 281,427 characters at 64x64, most of its 195,144 steps. Holding the world in the instance avoids that, in either language.
^report(25)). Colour is how the world reaches the rule: passed in (red), held as a list (teal), held in a HashMap (violet), only the neighbours (green). Values from data/.What was measured#
- Hazel at its best: its JS library built by GitHub from
trial/r25-best(bce9b4e5d1): all 14 performance PRs, #2681's memo limited to depth 1 (proposed), and the library binding a call's argument in the environment (^ticket(539)). Timed with^report(25)'s driver and rules: passed is the shipped rule, held the map held in a closure, neighbours the neighbours-only rule. - Fumola: its wasm build (
fumola_wasm_bg.wasm, from667a34d), in node throughinitSync, one instance per size. The rules are below; each call isfumola_eval_topof a call written as text, its answer read back from JSON and checked.
The Fumola rules#
The World rule, as the game's Hazel one reads (both, side by side): a step in a direction, the square there found, and a GoSquare if it has a way back. step stays in Nat: it checks an edge before stepping off it, since Fumola keeps Nat and Int apart (^report(28)'s traps).
func no_way(kind, d) { kind == "waterfall" and d == "N" }; func member(s, xs) { var found = false; for (x in xs.vals()) { if (x == s) { found := true } }; found }; func opp(d) { if (d == "N") { "S" } else if (d == "NE") { "SW" } else if (d == "E") { "W" } else if (d == "SE") { "NW" } else if (d == "S") { "N" } else if (d == "SW") { "NE" } else if (d == "W") { "E" } else if (d == "NW") { "SE" } else { "" } }; func step(c, r, d, cols, rows) { let up = d == "N" or d == "NE" or d == "NW"; let down = d == "S" or d == "SE" or d == "SW"; let left = d == "W" or d == "NW" or d == "SW"; let right = d == "E" or d == "NE" or d == "SE"; if ((up and r == 0) or (left and c == 0)) { null } else { let c2 = if (left) { c - 1 } else if (right) { c + 1 } else { c }; let r2 = if (up) { r - 1 } else if (down) { r + 1 } else { r }; if (c2 < cols and r2 < rows) { ?(c2, r2) } else { null } } }; func decide(x, back, c, r) { if (member(back, x.ways) and (no_way(x.kind, back) == false)) { #GoSquare((c, r, back, #Done)) } else { #Done } }; func find_at(cs, i, n, c, r) { if (i == n) { null } else { let x = cs[i]; if (x.col == c and x.row == r) { ?x } else { find_at(cs, i + 1, n, c, r) } } }; func world_list(g, e) { switch (e) { case (#Way(d)) { switch (step(g.at_col, g.at_row, d, g.cols, g.rows)) { case (?(c, r)) { switch (find_at(g.cells, 0, g.cells.size(), c, r)) { case (?x) { decide(x, opp(d), c, r) }; case null { #Done } } }; case null { #Done } } }; case _ { #Done } } }
The held versions: the world bound once in the instance, as a list (wl) and as a HashMap keyed by (col, row) (wm). A move passes four numbers and a direction.
let wl = [ ...every cell, as written for a passed call... ]; func world_held_list(ac, ar, cols, rows, d) { switch (step(ac, ar, d, cols, rows)) { case (?(c, r)) { switch (find_at(wl, 0, wl.size(), c, r)) { case (?x) { decide(x, opp(d), c, r) }; case null { #Done } } }; case null { #Done } } }; var wm0 = HashMap.new(); for (x in wl.vals()) { let (m2, _) = HashMap.put(wm0, (x.col, x.row), x); wm0 := m2 }; let wm = wm0; func world_held_map(ac, ar, cols, rows, d) { switch (step(ac, ar, d, cols, rows)) { case (?(c, r)) { switch (HashMap.get(wm, (c, r))) { case (?x) { decide(x, opp(d), c, r) }; case null { #Done } } }; case null { #Done } } }
The numbers#
| map (cells) | Hazel before, passed | Hazel best, passed (shipped) | Hazel best, passed (unannotated) | Hazel best, held | Hazel best, neighbours | Fumola, passed | Fumola, held list | Fumola, held map | Fumola, neighbours | Fumola, held map: steps |
|---|---|---|---|---|---|---|---|---|---|---|
| 3x3 (9) | 1.62 | 1.57 | 1.14 | 1.1 | 1.03 | 1.76 | 0.812 | 0.717 | 1.3 | 450 |
| 4x4 (16) | 2.78 | 1.97 | 1.7 | 1.59 | 0.99 | 1.43 | 0.731 | 0.605 | 1.02 | 450 |
| 6x6 (36) | 6.42 | 3.06 | 2.5 | 2.25 | 0.887 | 2.32 | 0.758 | 0.588 | 1.1 | 450 |
| 8x8 (64) | 16.3 | 5.18 | 4.5 | 3.59 | 0.942 | 2.84 | 0.94 | 0.539 | 1.03 | 450 |
| 12x12 (144) | 50.3 | 11.2 | 9.16 | 8.99 | 1.05 | 5.8 | 1.52 | 0.489 | 1 | 450 |
| 16x16 (256) | 144 | 21.1 | 18.8 | 14.8 | 1.04 | 9.52 | 1.89 | 0.512 | 0.976 | 450 |
| 24x24 (576) | 678 | 46.9 | 41 | 35.7 | 1.12 | 20 | 4.4 | 0.521 | 0.865 | 450 |
| 32x32 (1024) | – | 85.4 | 74.4 | 62.3 | 1.06 | 35.3 | 6.38 | 0.517 | 0.863 | 450 |
| 48x48 (2304) | – | 197 | 170 | 143 | 1.19 | 80.3 | 14.3 | 0.557 | 0.933 | 450 |
| 64x64 (4096) | – | 346 | 295 | 254 | 1.27 | 145 | 26.3 | 0.54 | 1.35 | 450 |
ms per call, the median of each round, the mean of 2 rounds (Hazel) and 2 (Fumola). Growth exponents from 64 to 4,096 cells (1 is linear, 0 flat): Hazel best passed 1.01, held 1.02, neighbours 0.07; Fumola passed 0.95, held list 0.80, held map 0.00, neighbours 0.06.
What Hazel would need#
For the world's size to leave a move's cost alone, the rule must find a square without walking to it. Hazel's rules have lists and records, and no map. Three ways there:
- Pass only what the rule reads: the neighbours. Flat today, in both languages (answer 4), but the game must know what each rule reads.
- A map in Hazel, written in Hazel: a balanced tree keyed by
(col, row), held as the world is held now. O(log N) evaluator steps a lookup. An estimate, not measured: a 64x64 world is twelve levels deep, and at a few dozen steps a level and about 3 µs a step (^report(25)), a lookup would cost about a millisecond, at any size near this one. - A map built in, as Fumola's
HashMapis a primitive over a persistent hash trie: one step a lookup.
That is ^ticket(540)'s question, every handler in O(1) or O(log N), now with a measured target.
What next#
- Look at the game playing under Fumola: every room's rules are now in Fumola in matthewhammer/kids-choice-games#4 (a draft on top of #2), with
?logic=fumola(the world held in a HashMap, rebuilt when its cells change) and?logic=fumola-list(the map passed in). A node check compares its answers with the Hazel rules' on 20,653 cases, and GitHub's browser checks pass (run 39823022799, its only failures the same nine as its base's). One run on GitHub's computer, for the order of things only: a rule call, median, 2.9 ms in Hazel, 1.1 ms in Fumola with the map held and 1.4 ms with it passed in; Fumola defines its rules in 4 ms, where Hazel compiles in 514 ms. Fumola's modes are not in the speed probe yet. - A map for Hazel's rules (above), and the World rule timed with it.
- Fumola's boundary without source text: a JSON value in as a Fumola value, so a passed map is not parsed every call.
- The same comparison in the game, in GitHub's browsers.
How this was put together#
Node v22 on the Ubuntu laptop (Intel i7), one process at a time, pinned to two threads and niced. Fumola: drivers/measure.mjs (world.fum, held.fum.js), per size a fresh instance, three warm-up calls, then up to 60 timed (stopping after 2.5 s and at least 9), the median; its answer checked against the game's (GoSquare(c + 1, r, "W", Done)). Hazel: 025/drivers/r25.mjs on trial/r25-best's library, the same sizes. Two rounds each. The target cell is mid-map, moving east, as in ^report(25).
The data#
- The numbers, for programs too: every table in this report, and what its numbers measure, is also in this page as JSON, in its
<script type="application/json" id="report-data">element, so a reader or an agent that fetches the page alone has the dataset. The same, with any raw files that survive, is in027/data/.