Hazel performance analysis · ^report(10) · 2026-10-13
What a slider change touches
When a slider moves, which parts of the picture does it actually change, and which does it leave alone? And can Hazel tell the two apart? ^report(9)'s Figure 5 found that evaluating a slide incrementally after a slider edit is no faster than starting over: the edit's dependents are evaluated again whatever they compute. This report measures what is inside them, on the Livelit Playground's slides, with an oracle that names each call the way Incremental Computation with Names (OOPSLA 2015) names allocations in its interpreter: by where it is.
Short answers. Preliminary: counts of evaluation steps, not times, from an oracle that watches and reuses nothing.
- A slider changes a small part of the picture: Full Body's smile edit changes 8 of its picture's 49 shapes, Witch's 16 of 133, and Scenes' eye-size edit 26 of 570 (Figure 1). Everything else in the picture is the same, value for value.
- Today Hazel evaluates the whole picture again. Its reuse is per top-level binding, and the picture is one binding that depends on the slider. Of the steps a from-scratch evaluation takes, today's incremental run skips 8 to 14%, and
^report(9)found it no faster in time. - Named by position, the calls tell the parts apart. Giving each call a name from its caller's name, its call site and its count there, and comparing it with the call of the same name before the edit: 75 to 84% of the steps today's run takes are in calls whose inputs did not change (Figure 2). A memo keyed by those names could skip them, if checking cost nothing.
- What would still run is more than the shapes that changed: Full Body, 16% of the shapes changed and 25% of the picture's evaluation would run; Witch, 12% of the shapes changed and 23% of the picture's evaluation would run; Scenes, 5% of the shapes changed and 13% of the picture's evaluation would run (Figure 3). The rest is the walks over the picture's lists, which run again down to the last changed shape, and the functions that read the slider.
- Names or content, the same reuse: keyed by a call's body and inputs instead of its name, a memo finds the same calls, to within 0.1% of the steps. Names by position are stable under a slider edit: after the edit, 43 of Full Body's 3,712 calls, 53 of Witch's 6,639 calls, 10 of Scenes's 31,613 calls have a name the run before did not. The difference between them is what a lookup costs, which this oracle does not measure. Added later on 2026-10-13: on slider edits that change only values. On the 6 slider edits that change how many shapes there are, and so which calls are made, an update keyed by position costs 1.3 to 4.9 points of a run from scratch more than one keyed by content, the estimated trace distance, and one keyed by operand 0.0 to 1.3 (
^report(11)). - The oracle's control is clean: the same program evaluated twice gives every call the same name and the same inputs. Getting there meant erasing the ids that evaluation mints inside types and module values, which an id-free comparison had kept (below).
Terms, added later on 2026-10-13: ^report(11) names what this report measures. What it calls the shapes the edit changed is the picture diff, a diff of the result. The steps an actual update evaluates again, given its cache and how it names what it keeps, are its update cost: this report's "left to run" and "what would still run" are the update cost of a memo keyed by position. The fewest steps any update could evaluate again, under the best matching of the two runs, is the trace distance, which keying by content estimates from above: a diff of the computation, which need not follow the picture diff. Both as a share of a run from scratch.
At a glance
Full Body: 8 of 49 shapes changed
Witch: 16 of 133 shapes changed
Scenes: 26 of 570 shapes changed
The question
Moving a slider changes one literal in the program, a livelit's model, and keeps its id. Hazel's incremental evaluator reuses the cached result of each top-level binding whose code and free variables are unchanged (^report(9)), so after the edit it evaluates again the binding the slider is in and every binding that uses it, and the picture uses it. Inside the picture, nothing is reused: a call made again is a call evaluated again. ^report(9) measured the result: incremental evaluation no faster than starting over.
That says nothing about how much of the picture the slider actually changes. If it changes most of it, there is nothing to save; if it changes a little, an evaluator that could recognize the rest would save most of the work. Two measurements answer it: the picture compared shape by shape, before and after the edit, and every call in the evaluation compared with its counterpart in the run before.
The picture, shape by shape
Each slide was cut at its picture, as ^report(9) cut the woods: the program up to the line that hands the picture to Drawing.^picture, ending with the picture's expression. It was evaluated before and after the edit, and each shape of the resulting list compared with the shape in the same place by a hash of its id-free form (below). A shape is a Polygon or a Path: a name, points, a stroke and a fill.
| Slide (edit) | The picture | Shapes | Changed | Changed, by name |
|---|---|---|---|---|
| Full Body (smile 85 → 120) | skinned(outfit) | 49 | 8 | long, head, eye (4), mouth, long top |
| Witch (smile 85 → 120) | skinned(dressed(outfit)) | 133 | 16 | long, head, eye (4), mouth, nose, wart (3), long top, hat (4) |
| Scenes (eye size 70 → 90) | Drawing.frame((x0, y0, x1, y1)) @ backdrop @ figure | 570 | 26 | red eye (26) |
Every shape the edit did not change is equal, value for value, to the one before, in the same place. The lists have the same length and order, so "the same in place" and "the same anywhere in the list" agree.
An oracle that names calls
A local build of hazelgrove/hazel#2635 (livelits × modular editors) at c4e64060f1, with a module that watches the evaluator. At each call of a function (the step from f(arg) to f's body), it records:
- a name: the caller's name, the call site's id, and how many times that site has been called in that caller. A call made at the top level takes the id of the top-level expression it is in instead of a caller's name. This is the paper's naming for its interpreter, the program position and the loop count, made from the call stack Hazel's probes already keep;
- its inputs: the values of the variables its body reads, the argument among them, and the body itself, each as a hash of an id-free form: the form the views' call memo uses (
CallMemo), in which every id is the same and a function is its code and the values of the variables its code reads; - its result, the same way, and the steps it took, its callees' included.
Two evaluations, before and after an edit, are then compared call by call. A call is reusable by name when the evaluation before had a call of the same name with the same body and the same inputs. Reusing a call skips its callees too, so what a memo could save is the steps of the outermost reusable calls. For comparison, a call is reusable by content when its body and inputs occurred anywhere before, under any name: the call memo's kind of key, with no limit on how many it keeps. Nothing is reused: the oracle only watches, and it cannot see what checking would cost.
The control
The same program evaluated twice, with no edit, should give every call the same name and the same inputs. At first it did not: about 1,200 calls on Scenes, and a dozen or so on Full Body and Witch, looked changed. Walking the two forms of a differing input side by side found an id where they parted: evaluation mints ids for the items of a module value (ModVal), here the Drawing library's, and the id-free form erased ids only in expressions, patterns and rules. Erasing them in module items too (and in types, which made no difference), the control is clean on every slide: Full Body 3,712 calls, every one reusable, Witch 6,639 calls, every one reusable, Scenes 31,613 calls, every one reusable. The views' call memo does not erase them either, so a call there whose inputs hold a module value made afresh would not be recognized; whether that happens in the views is not measured here. No input or result in any run was a value the hash could not take (a function, or a lazy value not yet forced); had one been, it would have counted as changed.
What a memo by name could skip
The whole slide, evaluated from scratch after the edit, and incrementally against the run before it, as the eval worker does. Steps are the evaluator's own steps, counted as they are taken: today's incremental run adds a reused binding's recorded steps to its count without taking them, so they are counted here directly.
| Slide | Run | Steps | In calls | Calls | Reusable by name | Steps a memo by name skips | By content | Left to run |
|---|---|---|---|---|---|---|---|---|
| Full Body | from scratch | 128,393 | 113,230 | 3,712 | 3,043 | 93,224 (73%) | 93,351 | 35,169 |
| Full Body | incremental, today | 110,597 | 105,470 | 3,357 | 2,780 | 87,125 (79%) | 87,125 | 23,472 |
| Witch | from scratch | 205,665 | 190,512 | 6,639 | 5,442 | 152,290 (74%) | 152,446 | 53,375 |
| Witch | incremental, today | 189,832 | 179,267 | 6,125 | 5,037 | 143,104 (75%) | 143,104 | 46,728 |
| Scenes | from scratch | 556,688 | 529,904 | 31,613 | 28,055 | 460,743 (83%) | 460,779 | 95,945 |
| Scenes | incremental, today | 514,822 | 488,987 | 28,981 | 26,124 | 433,901 (84%) | 433,913 | 80,921 |
- Most of the work is in calls, 88 to 95% of the steps. The rest is the top level, binding after binding, which a memo inside calls does not touch.
- The saving is in the bigger calls. 34 to 53% of the calls take fewer than 40 steps, the call memo's threshold; skipping only the others keeps 94 to 98% of the saving.
- Names by position barely move. After the edit, Full Body: 43 new names and as many gone, of 3,712 calls, and 36 names now calling a different function; Witch: 53 new names and as many gone, of 6,639 calls, and 47 names now calling a different function; Scenes: 10 new names and as many gone, of 31,613 calls, and 9 names now calling a different function. A slider edit changes values, not which calls are made; an edit that inserts an element in a list would shift every later name, which is why the paper derives names from a list cell's own name instead.
What would still run
The steps not in a reusable call, after today's reuse, by the function they are in (its own steps, not its callees'):
| Slide | Functions that run again, with calls and own steps |
|---|---|
| Full Body | shift_points 202 calls, 6,644 steps; clear_of_eyes 42 calls, 2,882 steps; along_lip 42 calls, 2,388 steps; skinned 26 calls, 1,183 steps; lip_y 39 calls, 1,014 steps; on_head 28 calls, 531 steps |
| Witch | shift_points 409 calls, 13,490 steps; dressed 110 calls, 5,030 steps; skinned 110 calls, 4,699 steps; clear_of_eyes 42 calls, 2,882 steps; along_lip 42 calls, 2,388 steps; anchor 97 calls, 2,032 steps |
| Scenes | (anonymous functions) 686 calls, 21,787 steps; map 755 calls, 11,794 steps; init_helper 269 calls, 5,002 steps; verts 234 calls, 3,900 steps; skull 9 calls, 2,367 steps; fmin 313 calls, 1,878 steps |
- The changed shapes' own work: drawing the mouth (
along_lip,lip_y), and moving the points of the shapes that moved (shift_points, a Drawing helper that moves every point of a shape), on the witch her hat and warts among them. - Calls whose answer did not change. 123 calls on Full Body, 139 on Witch and 126 on Scenes ran with changed inputs and returned what they returned before. The largest is
clear_of_eyes, which works out how far the mouth must drop for its top curve to clear the eyes, recursing over 41 points of the lip: a call of 35,879 steps, its callees' included, that comes to the same answer. A memo by name runs it again, since its inputs changed, though most of its callees are skipped (its own steps are in the table above); the callers that read only its answer then find their own inputs unchanged. That is the paper's early cutoff, here for free, because inputs are compared by value. - The walks over the picture's lists. On Scenes, the largest calls that run again walk the picture's list of shapes to move it (
moved, andmapunder it), and what differs in their inputs is the list. Each call takes the rest of the list, and a rest that holds a changed eye is itself changed, so the walk runs again from the head of the list down to the last changed eye, while the calls on each unchanged shape are skipped. This is the cascade the paper begins from (its Section 2.2), for a list's spine; its remedy is a name for each cell, forked from the cell before. - The functions that read the slider's value run again, though most of their callees are skipped: on Scenes,
woods, which reads it asglow.
In the paper's terms
The oracle is the paper's inner layer with two simplifications. Its names come from position, as in the paper's interpreter, not from the names of the inputs. And it has no graph of reads: a call's dependencies are the values of every variable its body mentions, where the paper records each read with what it saw. A function that mentions the slider's value anywhere in its body depends on it, even on a path that does not read it, which can only cost a reuse. Comparing inputs by value is what makes the early cutoff free here. A memo that keeps the graph would not compare inputs: it would mark dirty what read the slider, and re-check only that.
What next
- Time a memo by name. Added later on 2026-10-13:
^report(11)suggests which name: the call site and the argument, a call named for its operand. Build it at the call boundary, where the views' call memo hooks in: look a call up by its name, compare its inputs by hash, reuse its result. Checking costs a hash of each call's inputs; with^report(9)'s Merkle names kept on the values, a value's hash is already there, for 11 to 31% more evaluation time to keep them. Measure real time against today's incremental run and against a run from scratch, on the three slides and in the browser. - Replay what a reused call recorded. A top-level binding's cache entry replays its probe samples and tests; a reused call would have to as well, or the probes inside it would go blank.
- Names for list cells, so that a walk over a list stops at the changed cells instead of running again down its spine: the paper's remedy, and the largest part of what is left on Scenes. Corrected later on 2026-10-13: not the largest part; the table above already shows the walk's own steps are a fraction of what is left.
^report(11)measures lists as named cells on every Scenes slider: they lower the update cost by 0.0 to 1.5 points of a run from scratch. - Reads instead of mentions. Record what each call actually reads, so that a function that mentions the slider on a path it does not take is not counted as reading it: the paper's graph of what was demanded.
How this was put together
- Build: #2635 at
c4e64060f1, with measuring code on a local branch, not pushed: the oracle (CallNames), its hook at the evaluator's function call, a count of the steps taken, and^report(9)'s experiments, switched off. - Programs: the Livelit Playground's Full Body, Witch and Scenes slides, each parsed once (
FastParse,MakeTerm) and analyzed withStatics.mk_unmemoized; the edit replaces one integer literal, the slider's model, keeping its id. The whole-slide runs evaluate the slide withEvaluator.evaluate: once before the edit, again unedited (the control), from scratch after the edit, and incrementally after it against the first run's cache. The picture runs evaluate the slide cut at its picture withEvaluator.evaluate_open, before and after. - Counts: node, the test build. Steps are counted by the oracle as the evaluator takes them; they are deterministic, the same in every run, so one run of each suffices, and the control is the noise floor: zero.
- Drawing: Figure 1 is drawn from the shapes the evaluation produced, exported as JSON: points, strokes and fills as the Drawing library would hand them to SVG.
- 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 in010/data/.