Hazel performance analysis · ^report(9) · 2026-10-13

Names and reuse: what fresh ids cost Hazel

How do fresh ids relate to lack of reuse in Hazel? An incremental system recognizes work it can reuse by identity, and identity is only as stable as the ids it hands out. Incremental Computation with Names (Hammer, Dunfield, Headley, Labich, Foster, Hicks and Van Horn, OOPSLA 2015), which Matthew co-wrote, asks this of Adapton: allocate with a fresh name and a re-run never matches the last one; allocate with a name derived from where you are, and it can. This report measures what Hazel can tell us about it today, on the Livelit Playground's slides.

Short answers.

  1. Hazel has both kinds of id. Id.mk is a gensym: a counter and random bytes, unique every time. Id.derive, Id.next and IdTagged.fresh_deterministic make names: an id computed from a parent's, the same each time. The editor's tiles give the program's syntax names that last across edits. Statics and the evaluator mint almost everything else with the gensym (Figure 1).
  2. Analyzing the same program again renames what statics adds: 667 of Full Body's 111,462 nodes, and through the program's chain of lets, that changes the hash of 692 expressions though their structure is the same. No syntax id ever changes (below).
  3. Two sites in statics make most of them, and naming them as Adapton would, by where they are, cuts the renames from 667 to 85 and the changed hashes from 692 to 247 (below).
  4. But names do not change what is reused, only how. After a slider edit, Full Body reuses 336 parts with fresh ids or with names. Hazel's reuse check falls back to comparing structure, so a renamed part is still found the same, at a higher cost; naming moves 59 checks from that fallback to a hash lookup (below).
  5. And on these edits, incremental evaluation is no faster than starting over: Full Body 499 ms against 464 ms from scratch, Witch 754 against 701, Scenes 1,416 against 1,453 (node, medians of five, each slide in its own process). The edit's dependents are recomputed whatever the ids; the reuse a slider change could use is inside them.
  6. The evaluator mints by far the most: 461,422 ids to build the woods' picture of 33,847 nodes. The same picture evaluated twice is equal in structure but never by hash; the only ids the two share are the program's own (below).
  7. Between the page and the eval worker, fresh ids cost 11% of what results send, and requests twice what they could be, on eight woods figure moves. Results stay small because the worker's reuse hands back the very same objects: identity survives there through reuse, not through names (below).

Update, later on 2026-10-13: the paper, and Hazel's caching in its terms. Four sections are added below, at Matthew's request: the paper's main ideas (below), Hazel's cache and how it is invalidated, in the paper's terms (below), an experiment that derives names everywhere in statics and the evaluator (below), and what following the paper would take (below). In short:

Update, later still on 2026-10-13: Merkle names, kept on the values. Matthew asked whether the slowdown of derived names was something non-linear that merklizing would fix. It was (below). Hashing a value is linear: each object is hashed once, and every evaluation of the woods hashes the same 1,033,746 objects. But the hashes were kept in a side table, a JavaScript WeakMap, and with identical work each evaluation in a session took longer: 2.3, 5.5, 6.3 s. Kept on each value instead, as a Merkle name computed when the value is built, the time holds still, 2.2, 2.5, 2.2 s, and derived names cost 11 to 31% over fresh ids, not 23 times. Two claims in the section on the experiment are corrected there.

Update, later still on 2026-10-13: one set of timings. The times in this report came from three runs, in processes with different histories, and did not agree: Full Body from scratch with fresh ids was 451, 489 or 511 ms, depending on the run. Every time in it now comes from one protocol: each configuration (fresh ids, the two statics sites named, derived names) and each slide in its own node process, five timed runs, the median shown with the fastest and slowest. The conclusions stand: on these edits incremental evaluation is no faster than starting over (Figure 5), and derived names, kept on the values, cost 11 to 31% (Figures 10 and 12).

At a glance

The editor each tile keeps its id across edits: names Statics keeps the syntax's ids; mints ids for what it adds mostly gensyms The evaluator a fresh id for every node it builds gensyms 22,611 syntax nodes (Full Body) 667 renamed per analysis 461,422 minted (the woods) Where Hazel's ids come from
Figure 1. Where Hazel's ids come from: the editor names the syntax; statics and the evaluator mint most of the rest (below).
Nodes renamed when the same program is analyzed againFull Body66785Witch79885Scenes21085Shirt and Pants11479Polygons140fresh ids, as todaynamed, the experiment
Figure 2. Each slide parsed once and analyzed twice, as if every item were analyzed again. Amber: as Hazel is today. Green: with the two sites of the experiment named.
Expressions whose hash then changes (structure equal)Full Body692247Witch844308Scenes237136Shirt and Pants8258Polygons2313fresh ids, as todaynamed, the experiment
Figure 3. Each renamed node changes the hash of every expression that holds it, here the chain of lets above it.
After a slider edit: how the reuse check recognized each partFull Body, fresh idssame by hash (names held): 943943same by structure (renamed): 105different: 139reused 336Full Body, namedsame by hash (names held): 1,0021,002same by structure (renamed): 46different: 140reused 336Witch, fresh idssame by hash (names held): 2,2152,215same by structure (renamed): 172different: 152reused 663Witch, namedsame by hash (names held): 2,2932,293same by structure (renamed): 94different: 153reused 663Scenes, fresh idssame by hash (names held): 110same by structure (renamed): 106different: 31reused 114Scenes, namedsame by hash (names held): 130same by structure (renamed): 86different: 33reused 114same by hash (names held)same by structure (renamed)different
Figure 4. The eval worker's reuse check on the second run, after a slider edit. Names move parts from the structural fallback (amber) to the hash (green); what is reused does not change.
After the edit: incremental vs from scratch (medians of 5, lines: fastest to slowest)Full Body499 ms464 msWitch754 ms701 msScenes1,416 ms1,453 msincremental, against the first run's cachefrom scratch
Figure 5. Evaluating the edited program against the first run's cache, and without it, in node. Incremental is not faster on these edits.
The woods picture, one evaluationids minted461,422nodes in the picture33,847distinct ids in it7,650nodes with program ids26,266
Figure 6. One evaluation of the Scenes slide up to its woods picture: the ids the evaluator minted, against the picture it built. Green: the picture's nodes whose ids are the program's own, the only ids two evaluations share.
Eight woods figure moves: slots sentworker → page387,625347,920page → worker43,24821,728sent, as todayif equal values had equal ids
Figure 7. What crossed between the page and the worker on eight woods figure moves, in slots of the values sent, against what would have crossed if parts equal in structure to ones already sent went as references.

Where Hazel's ids come from

An id in Hazel is a 16-byte UUID. Id.mk makes one from a counter in its leading bytes and random bytes after it: a gensym, new at every call, which is what an incremental system cannot match from one run to the next. Id.derive(~salt, x), Id.next(x) and Id.mk_str(s) make version-5 UUIDs from a parent id or a string: names, the same every time they are computed from the same place. IdTagged.fresh_deterministic(prev_id, term) builds a node with Id.next(prev_id).

The program's syntax is named by the editor: each tile keeps its id across edits, so an item that did not change carries the same ids into the next analysis. Statics keeps those ids in the elaboration and mints ids for the nodes it adds. A few sites name them (function sugar, one definition site in Statics, module roots, padding in the pretty printer); most use IdTagged.fresh, which calls Id.mk, often through IdTagged.FreshGrammar. The evaluator gives every node it builds a fresh id.

Added later on 2026-10-13: not quite every one. A step's result keeps the id of the expression it came from (rewrap, about 116 uses in Transition, and fast_copy): an input name passed straight through, with no part saying which instance it is, so one piece of syntax evaluated in many calls gives many values with one id. And a variable's value is named, for reuse, by the binding it came from and the path into it (below). Sampling who mints the rest: variable lookups 38% (each copies the value under a new id), environment frames 20%, ascriptions pushed through values about 30%.

Statics, run again: what gets renamed

Each slide was parsed once, so its syntax has one set of ids, and analyzed twice with Statics.mk_unmemoized, as the editor's per-item statics does for an item it analyzes again. (The memoized Statics.mk hands back the very same elaboration for the very same term, and so renames nothing.) This is the worst case: in the editor, only the edited item is analyzed again, and, in UX mode, the open cells (^report(8)). The two elaborations have the same structure, node for node; walking them side by side:

SlideNodesRenamed, fresh idsRenamed, namedHash changed, fresh idsHash changed, named
Full Body111,46266785692247
Witch107,72879885844308
Scenes101,40321085237136
Shirt and Pants87,215114798258
Polygons62,6281402313

Every renamed node is one statics minted; no syntax id changes. On Full Body they are, by kind: 42 case expressions and 42 holes, 84 constructor patterns and 84 constructor applications in patterns, 86 variable bindings, 59 ascriptions, 52 variable references, 47 field accesses, 45 applications, and 62 type nodes: about one set for each of the slide's livelit uses, and the types. They come from two places:

A renamed node changes the Merkle hash (Util.ValueHash, ids included) of every expression that holds it. A slide is a chain of lets, each holding the rest of the program, so one renamed node late in the chain changes the hash of every let above it.

Naming them: the experiment

Both sites can be named by where they are, as Adapton names an allocation:

The renames fall from 667 to 85 on Full Body, and to 85 on every slide that uses the Drawing library: those 85 (62 of them type nodes) are the same on each of those slides, which points at the library's own analysis. Polygons has none left. The changed hashes fall from 692 to 247 (Figures 2 and 3). The experiment is on a local branch and was not committed; it is a measurement, not a proposal.

What the eval worker reuses, and what it costs

The worker evaluates incrementally: it checks each part of the new elaboration against its cache of the last run, by id, and reuses a part found the same with the same inputs. "The same" is the check from ^report(8): one hash lookup, ids included, and if that fails, a walk comparing structure and lexemes, memoized. Here, each slide was evaluated, a slider's model was edited in place with every id kept (as the slider's commit keeps them), the edited program analyzed again, and evaluated against the first run's cache:

Slide (edit)IdsSame by hashSame by structureDifferentReused
Full Body (smile 85 → 120)fresh ids943105139336
Full Body (smile 85 → 120)named100246140336
Witch (smile 85 → 120)fresh ids2215172152663
Witch (smile 85 → 120)named229394153663
Scenes (eye size 70 → 90)fresh ids11010631114
Scenes (eye size 70 → 90)named1308633114

The parts reused are the same with fresh ids and with names: the structural fallback finds a renamed part the same, as a hash lookup finds a named one. Names only change the cost of finding it, which the one-walk check of ^report(8) already made small. So in Hazel today, fresh ids do not cause lack of reuse; they cost the check. Added later on 2026-10-13: at the granularity Hazel caches, a top-level binding. Inside a binding's evaluation nothing is cached, so there is no reuse there for fresh ids to lose; what names there could find is the subject of ^report(10) and ^report(11).

What the reuse saves is another question. Timed, five runs each, alternating the incremental run and one from scratch, each slide and configuration in its own process:

SlideIncremental, msMedianFrom scratch, msMedianNamed: incremental / scratch
Full Body621 476 494 500 499499448 457 464 484 469464552 / 508
Witch796 707 754 830 718754675 701 755 923 650701727 / 692
Scenes1522 1357 1416 1412 145514161453 1421 1444 1462 146414531416 / 1432

The first incremental run in each process is usually its slowest (the JavaScript engine warming up); the medians are what Figure 5 shows. Incremental evaluation is no faster here, with fresh ids or named. A slider's value flows into the drawing, so the parts that depend on it, the costly ones, are evaluated again; what is reused is mostly definitions, which are cheap. The reuse that would pay is inside those parts: the shapes of a picture that the slider did not move. Recognizing that needs the values computed inside an evaluation to carry identities from one run to the next, which is where the evaluator's ids come in. Added later on 2026-10-13: ^report(10) measures what is inside those parts: a slider edit changes 5 to 16% of a picture's shapes, and 75 to 84% of the steps evaluated after it are in calls whose inputs did not change.

This is node, with the whole program analyzed again. In the browser, the worker's request shares unchanged values with the last one and per-item statics keeps unchanged items, so more checks succeed by identity; whether the worker's incremental run beats a fresh one there is the next measurement.

The evaluator: an id for every node it builds

Counting Id.mk calls while the Scenes slide is evaluated up to its woods picture: 461,422 ids, the same number every run, for a picture of 33,847 nodes with 7,650 distinct ids (copies of a value keep its id). Nearly all of them belong to intermediate terms the evaluator builds and throws away; a fresh id is a 16-byte string, made at every step that builds a node: part of what each step of evaluation allocates.

Evaluated twice, the two pictures are equal in structure and differ by hash. Of the second picture's nodes, 26,266 have an id that is also in the first, and every one of those is an id of the program itself, a literal carried into the picture. The rest are new each time. So a cache keyed by a value's hash can never recognize the same value computed again. That is why the views' call memo erases ids from its keys before comparing, and why the worker's results share well only where its reuse handed back the very same object.

Between the page and the worker

Requests and results cross between the page and the worker with their already-sent values replaced by references, by hash, ids included (Util.ShareCodec). A build that counted, for each message, the slots of the values it sent, and the slots it would have sent if every part equal in structure to one already sent had also gone as a reference, moved the figure in the woods eight times:

DirectionMessagesSlots sentIf equal values had equal idsRatio
worker → page (results and streams)32387,625347,9201.11×
page → worker (requests)843,24821,7281.99×

About 2,900 parts a move went to the page as references. They are the parts the worker reused: the same objects as last time, so the same hashes. What a result sends beyond them is mostly new: the moved figure and the picture's list built around it. A request is twice what it could be, from a small base (about 5,400 slots a move, against 749,480 when the slide loads): the edited item, analyzed again, with what statics adds renamed (above).

The paper's main ideas added later on 2026-10-13

An incremental system saves work by recognizing, in a new run, work it did in the last one. Incremental Computation with Names is about how it recognizes it.

Caching in Hazel, in the paper's terms added later on 2026-10-13

Hazel todayIncremental Computation with Nameslet shapes = …let colors = …let smile = 120let face = … smile …let pic = … face …let woods = …smile (input)facemouthpicwoodsre-run from the top;what uses smile runs againre-check only what read smile;face came out the same, so pic staysreusededitedevaluated again
Figure 8. Two ways to find what an edit invalidates, on a made-up program. Hazel re-runs the program from the top and reuses a top-level part whose code is the same and whose free variables came from parts that were reused, so everything after the edit that uses it runs again, whatever it computes. The paper re-checks only what read the changed input, along recorded edges, and stops where a result is unchanged: if face comes out the same, pic is not run again.

The input names. The editor makes a tile's id when the tile is made, keeps it while the tile is edited, and drops it when the tile goes: the gensym happens in the outer layer, as in the paper. A slider's model is a literal in the program, so moving a slider changes that literal and keeps its id.

What is cached. One entry per top-level node of the program, keyed by its id: its elaboration, the provenance of its free variables, its probe targets, its value, and what the evaluation recorded (probe samples and tests, replayed when the entry is reused). Nothing is cached inside a function call: entries are recorded and reused only at the top of the program.

Names Hazel derives today. Statics derives a few (function sugar, one definition site, module roots, livelits' context entries) and mints the rest. The evaluator passes input names through: a step's result keeps its redex's id. And the incremental evaluator names each variable's value by its provenance: the id of the binding that produced it and the path into that value (a tuple's field, a list's cell, a constructor's argument, an ascription), with a flag, clean or dirty. That pair is a derived name for a value, though it says where the value came from, not what it is.

How invalidation works. The evaluator runs the program from the top. At each top-level node with an entry, it reuses the entry if the node's elaboration is the same (by hash, or by structure: ^report(8)), its free variables' provenances are what they were (the same binding, the same path, still clean), and its probe targets are the same. A binding whose right-hand side was reused makes its variables clean; one that was evaluated again makes them dirty, whatever value it produced. So:

Hazel todayIncremental Computation with Names
What is cachedTop-level nodes of the programAny thunk, at any depth
IdentityThe editor's ids; a value's provenance (binding, path)Names derived from the input names
DependenciesStatic: the free variables statics findsDynamic: what each thunk forced, in order, with what it saw
What invalidatesA dependency that was evaluated againA dependency whose result changed
Early cutoffNoneYes: the observed result is compared
Who decidesA re-run from the top, reusing what checks outDemand: only what is dirty and asked for
CorrectnessIncremental agrees with fresh (the IncrSlides tests)Proved: from-scratch consistency

Hazel does the paper's outer layer, and does it well: unchanged code keeps its names across edits. It has not tried the inner layer. That explains the timings above: a slider's value flows into the drawing, the binding it reaches is evaluated again, every binding after it is dirty whatever it computes, and nothing inside the calls that draw can be reused. It also explains why the inner layer cannot simply be added: with fresh ids inside, nothing computed again is recognizable by name.

Derived names everywhere: an experiment added later on 2026-10-13

A switch, in a local build, under which statics' and the evaluator's ids are computed instead of minted: at IdTagged.fresh, at Exp.replace_all_ids, at a variable lookup, and at Environment.extend, a node's id is a hash of its content (its constructor, its children's ids, its literals), computed with ValueHash. Names then flow up from the editor's input names. This is the structural way to derive names, close to hash-consing, not the paper's positional fork. Corrected later on 2026-10-13: the paper's fork is not positional: it derives names from a name already given, an input's, or, in its interpreter, a position's. ^report(11) sets these choices out side by side, with Fumola's merge sort as a worked example. Counting the ids still minted (Id.mk calls), the same three slider edits as above:

Fresh ids minted by one incremental evaluation after a slider editFull Body84,5284,425Witch147,0816,842Scenes423,36213,578fresh ids, as todayderived names, the experiment
Figure 9. Fresh ids minted by the incremental evaluation after one slider edit, as Hazel is and with derived names.
Slider editStatics, todayStatics, derivedIncremental evaluation, todayIncremental evaluation, derived
Full Body (smile 85 → 120)15,6244,39884,5284,425
Witch (smile 85 → 120)18,6374,995147,0816,842
Scenes (eye size 70 → 90)6,0551,852423,36213,578
Time (ms), medians of 5, names kept on the valueswoods, one evaluation1,508 ms1,667 msNames kept from the first run (of 33,847 nodes)nodes named as before26,26633,846fresh ids, as todayderived names, the experiment
Figure 10. What derived names bought and cost, on the woods: names that last across runs, at 1.11 times the time. Medians of five runs, each mode in its own process; the lines run from the fastest to the slowest. Updated later still on 2026-10-13: the figure first showed the run with the hashes in a side table, late in a long session: 1,912 ms against 44,197 ms, a cost of that table, not of the names (Figure 11).

Merkle names, kept on the values added later still on 2026-10-13

Was the slowdown something non-linear, and would merklizing help? Counting, for the woods' evaluation with derived names on, three times in one process:

The woods, with derived names, evaluated again and againevaluation 32,319 ms2,205 msevaluation 65,463 ms2,484 msevaluation 96,296 ms2,221 mshashes in a side table (WeakMap)hashes kept on the values (Merkle)
Figure 11. The woods evaluated nine times in one process with derived names, the last of each three timed: with the hashes in a side table, and kept on the values. The work is the same in every one of them. Added later still on 2026-10-13: these runs keep each round's first two pictures alive while the third is timed, with a list of one's nodes and a table of the other's ids: a larger live heap than Figure 12's runs have. Their times are higher, and not comparable with Figure 12's; what this figure shows is the growth across a session, and its absence.

Timed in the protocol of the update at the top, each configuration and workload in its own process, medians of five (fastest to slowest in brackets):

Fresh ids, as todayDerived names, kept on the valuesCost
The woods, one evaluation1,508 ms (1,489–1,780)1,667 ms (1,629–1,785)1.11×
Full Body, from scratch464 ms (448–484)524 ms (503–535)1.13×
Witch, from scratch701 ms (650–923)852 ms (787–1,087)1.22×
Scenes, from scratch1,453 ms (1,421–1,464)1,897 ms (1,714–2,060)1.31×
The whole derived-names experiment, both modes, in one process180 s, against 1,043 s with the side table
Fresh ids against derived names kept on the values (medians of 5)the woods, one evaluation1,508 ms1,667 msFull Body, from scratch464 ms524 msWitch, from scratch701 ms852 msScenes, from scratch1,453 ms1,897 msfresh ids, as todayderived names, kept on the values
Figure 12. What derived names cost, once kept on the values: 11 to 31% over fresh ids. Medians of five runs, each in its own process; the lines run from the fastest to the slowest. The ids they save (Figure 9) and the names they keep (33,846 of the picture's 33,847) are as before.

That cost is still hashing a value's content, done once per value. The paper's names cost less: derived from a parent's name, not from the content. In statics, where FreshGrammar chooses an annotation before the term exists, that is the only way to name what it adds (the earlier experiment). In the evaluator, content names are what make two runs' values comparable by name, for an early cutoff; kept on the values, they are now affordable. The experiment is on a local branch and was not committed.

Following the paper: what it would take added later on 2026-10-13

  1. Cached units below the top level, with names. Each call (and each let's right-hand side) a unit, named from its call site's id, the name of the unit it runs in, and its argument's name. Hazel's probes already key a sample by (syntax id, call-stack instance), which is this name made by position; the paper's lesson is to derive it from the argument's name instead, so that an inserted element does not shift every later name.
  2. Record what each unit reads. In the evaluator, a variable lookup and a function application are the reads. Record each with the name of the result it saw; the provenance map already records where a variable came from, not what it held.
  3. An early cutoff. A dirty dependency whose new result has its old name stops the invalidation there. This needs equal values to have equal names: the experiment above, at the paper's cost, not content's.
  4. Inputs as named references. A slider's model is a literal with a tile id, so moving it is setting that reference: dirty what read it, ask for the result again, run again only what is dirty.
  5. What Hazel can build on: the views' call memo is already a cache per call, by structure (classic Adapton's kind); entries already replay their probe samples and tests, which units inside calls would also have to do; probes already name call instances.
  6. What is hard: replaying observations (probes, the stepper) for units deep in recursion; the memory of an edge for every read; and boundaries for units in an evaluator that steps a term on a trampoline (the call memo's boundary, at an application, is the place to start).

What next

  1. Name what statics adds. The two sites measured here, the livelit use's dispatch and the inserted ascriptions, then the 85 left from the library. It removes most renames of an item analyzed again, so its unchanged parts pass the reuse check by hash; whether it closes the gap measured in requests is to be measured. A small change, with a test that two analyses give the same ids and two uses different ones.
  2. Mint fewer ids in the evaluator. 461,422 for one picture, most for terms thrown away: minting an id only when a node is kept, or naming it by its provenance, is part of the per-step allocation the evaluator still has.
  3. Names inside evaluation, for the reuse that would pay. Reusing the shapes a slider did not move, inside a part that is evaluated again, needs values that keep their identity from run to run: names for what evaluation allocates, in the sense of the 2015 paper. That is a design question, for a note with Cyrus before any code. Added later on 2026-10-13: the sections on Hazel's caching and on following the paper lay out that question, and ^report(10) takes its first step: calls named by position, and what a memo keyed by those names could skip.
  4. Measure the worker's incremental run in the browser, against a fresh one, with per-item statics and shared requests as they are there.

How this was put together