Hazel performance analysis · ^report(8) · 2026-10-12

One slider, three ways: what modular editing saves

A question about the two livelits branches. #2597 has no modular editing, and #2635 has it, from #2469 (modular editors). How long does one slider change take on a complex picture, the Livelit Playground's Full Body slide? The hypothesis: #2635 is much faster, especially in its UX outline mode, showing only the slider and the picture, because it doesn't reprocess the unrelated code. A third set-up separates the two halves of that hypothesis.

Short answers.

  1. Modular editing makes the change about 7× faster. From releasing the slider to the redrawn figure took a median of 8.6 s on #2597, and 1.2 s on #2635 with the whole program on screen (12 changes each). On #2597 the page analyzes the whole program again for every change: 5.4 s of statics per change, profiled. On #2635 it analyzes only the changed item, in 38 ms.
  2. Showing only the GUI, as it's built today, makes the change about 3× slower again: 3.5 s. In UX mode, the outline opens the slider and the picture as cells, and that divides the program into cells and the rest. The divided program costs more per change:
    • the worker's reuse check takes about 2 s instead of a few milliseconds, though it reuses exactly as much as with the program whole;
    • the page's autosave lands inside the change, turning the whole program into text and storing it (0.55 s);
    • the cells' livelit views run without their memo (0.46 s).
  3. So the hypothesis holds for analysis, not yet for display. Not reprocessing unrelated code is the big win, and statics per item already does it. Hiding the unrelated code saves nothing yet: with the whole program on screen, #2635 spends only 9 ms per change on the code view, because it is cached. And dividing the program costs more than hiding saves. With the fix in 5, the two about cancel: B takes as long as C.
  4. On #2597 the figure is rebuilt rather than patched: its elements are new after every change. That costs the page more, and it fooled the first measurements here (below).
  5. One fix in the worker's reuse check makes B as fast as C: 1.26 s against 1.23 s (Update, later on 2026-10-14: not on today's head, where the autosave makes B 0.6–0.7 s slower again; a fix for that brings them level again: below.) (medians of 12). It is on #2635 as cbdab791f5. The check now compares lexemes in the same memoized walk as structure, so the 2 s becomes 1–4 ms, with exactly the same reuse decisions (below).
  6. The worker's 2 s has one cause: the open picture cell's item depends on the slider, and in the divided program it is elaborated again after every change, with fresh ids. About 120 enclosing parts of the program then differ only by ids, and each takes the reuse check's slow path. With only the slider's cell open, those slow checks fall from 122 to 4 per change. Keeping a re-analyzed cell's ids stable would fix it. A GUI-only view that keeps the program whole would avoid all three costs.

Update, later on 2026-10-14: do B and C come out the same once both have the same fixes? Yes, with one more fix.

Release to redraw, each session of 12 changes: the median, and the range0 s1 s2 s3 s4 s5 s 10-12: C 10-12: B 10-12: B, reuse check fixedtoday's head: Ctoday's head: Bwith the print fixed: Cwith the print fixed: B
Every session, release to redraw: the dot is the median of twelve changes, the line their range. The first three rows are this report's, from 10-12. "With the print fixed" is #2635's head with ec9f125acd (five sessions each).
8.6 s
A: #2597, no modular editing
1.2 s
C: #2635, the whole program on screen
3.5 s
B: #2635, only the slider and the picture
1.26 s
B with the reuse check fixed

The three set-ups

The Full Body slide draws a whole figure from about forty livelits: polygon editors for the shirt, pants, head, hair and shoes, choices and sliders for everything else, and a final ^picture of it all. Its text is the same on both branches. The change measured is one drag of the "arm length" slider, to a length not drawn before, so no change could be served from a cache of earlier pictures.

BranchOn screen
A#2597, no modular editingthe whole program, with its livelits
C#2635, modular editingthe whole program, with its livelits (the outline's 🏗️ dev mode)
B#2635, modular editingonly two cells: the slider and the picture (the outline's 🖼️ UX mode, with every other GUI row switched off)

A against C is what modular editing does. C against B is what showing only the GUI does on top of it.

How long a change takes

From the slider's release to the redrawn figure, twelve changes in each set-up, after two warm-up changes:

MedianRangeAgainst C
A: #25978.6 s7.4–10.5 s7.2×
C: #2635, whole program1.2 s0.9–1.8 s1
B: #2635, slider and picture only3.5 s2.9–5.0 s2.9×

The ranges don't overlap, so the order is not noise. A has c07bdacead (^report(7)), so its worker's stream messages are throttled as on #2635.

Where the time goes

Three changes in each set-up were profiled, the page's main thread and the eval worker together, and six more in B with the reuse check's fix (below). Times are milliseconds per change, profiled, so they run longer than the clock (the windows were 13.3, 1.8, 4.2 and 2.2 s). Rows overlap: a function's time includes what it calls.

Per change, profiledACBB, fixed
Release to redraw (the profiled window)13,2891,7684,1502,167
Page: the whole update12,7199492,2641,457
— statics5,4053843
— printing code (of_segment): the code view in A and C, the autosave's text in B2,29394806
— the program turned into text (segment_to_string)1,522—4722
— the autosave (save_current)——547—
— livelit views without their memo (live_html)840—463449
— livelit views through their memo (memo_view)—163——
Worker: the reuse check22512,2106
Worker: evaluating, including the reuse check9751,0562,174884

The worker checks for reuse during the evaluation, so its evaluating row includes most of the check. B's extra worker time is the check, not more evaluation (below).

A: the whole program, analyzed again

On #2597, a slider change edits the program's text, since the livelit's model lives in it, and the page then runs statics on the whole program from scratch. That is 5.4 s of a 13.3 s profiled change. The page also rebuilds the whole code view (2.3 s) and turns the whole program into text (1.5 s). The worker's part, about a second, is the same as on #2635. This is the cost ^report(1) found on the much smaller Kids' Choice slide, where it was 3.5 s of statics against #2635's per-item 16 ms.

B: what dividing the program costs

The UX outline mode shows a GUI by opening its row as a cell: a small editor of its own. With cells open, the program is divided: the cells hold their items, and the rest stays in a base, and on every change the whole document is assembled again from them. Three costs follow, and none of them is in C:

So B is slower not because it shows less, but because of how it shows less. Its picture and slider are as cheap to draw as C's, but they come from a divided program.

The reuse check, counted

To see what the worker's check was doing, a temporary build of #2635 counted its outcomes and printed them per request: is there a cached entry, does the hash match, and if not, are the two elaborations equal anyway? The build is on a local branch and was not pushed. Per slider change, steady state:

Per changeC: wholeB: slider and picture openB: only the slider open
Checks1,4541,4761,476
— the hash matches (one lookup)1,1281,0031,127
— the hash differs, but the parts are equal (the slow path)11224
— time on the slow path0–3 ms1,450–2,500 ms350–375 ms
Reused in the end338337339

The hash covers ids as well as structure, so it differs when a part is rebuilt with new ids. The slow path then compares structure, which is cheap, since it is memoized across checks (0–5 ms in all). It also compares the two parts' lexemes, by building a trace of every node in each, and that is the 2 s. It is quadratic: the program is a chain of lets, each holding the rest of the program, and each of the 122 enclosing lets builds its trace of a subterm of about 6,400 nodes.

The parts that change ids are the ones that contain the picture's item, drawn. Its own text doesn't change, but with its cell open, the item is elaborated again after every change, and the nodes the elaboration adds get fresh ids (for example, a let renumbered 62e92200… → 2ed82400…). Close the picture's cell and the slow path falls to 4 checks. With the program whole, the item keeps its ids.

So there are two fixes, and either would remove most of the 2 s:

A fix for the check, tried

The check's structural comparison is cheap because it is memoized across checks, and two subterms whose hashes match are equal without a walk. The lexeme comparison had neither. So Equality gets a ~lexemes option: every walker, for each sort of node, also requires the two nodes' lexemes to be equal, inside the same hash shortcut and memo. The reuse check calls Exp.fast_equal_hashed_with_lexemes instead of the structural comparison followed by two traces. Other callers of Equality are unchanged.

Per changeB beforeB with the fixC with the fix
Release to redraw (median of 12)3.5 s1.26 s1.23 s
The reuse check's slow path1,450–2,500 ms1–4 ms0–1 ms
Slow matches, and parts reused122, 337122, 3371, 338

Update, later on 2026-10-14: on today's head the save falls inside B's change again, and its print takes about 1 s (below).

The decisions are identical; only their cost changed. With the check cheap, a change in B also finishes before the autosave fires, so the save stopped landing in the middle of it. B's range was 1.06–1.9 s, with one change at 3.8 s.

Profiled again with the fix (six changes, the last column of the table), the worker's part of a change in B is now smaller than in C (884 ms against 1,056), and the autosave and the text conversion are gone from the change. What B still spends beyond C is on the page: the cells' livelit views without their memo (449 ms against C's 163) and collecting the stream's state (340 against 204). Profiled, B's window is still 1.2× C's, though on the clock the two are within 3%: the profiler slows some code more than other code (as in ^report(4)), so these rows say where the time goes, and the clock says how much.

The fix is on #2635 as cbdab791f5. It adds a test that the one-walk check agrees with the trace check (the same term with new ids: equal; a lexeme-only edit: not; a real change: not). The existing test that a lexeme-only edit invalidates reuse still passes, as do the Evaluator (524), Evaluator.Incremental (46), IncrSlides (13), CallMemo (6) and Equality (8) groups.

#2597 has no lexeme comparison in its check: it predates 7de3875cd8, which added the comparison to dev after an in-place edit of @@ to @@@ showed the old operator's result. So #2597 has neither this cost nor that protection. Corrected on 2026-10-13: #2597 doesn't need that protection. Its terms carry no lexemes at all (they came to dev with 500c466b44, which #2597 lacks), and it turns an unknown operator such as @@ into a hole of its operands alone. So @@ and @@@ make the same term and the same result, and nothing can go stale. Its check is already the cheap one: the hash lookup, then the memoized structural walk. dev compares lexemes by trace on every check, and without the hash shortcut, so the same fix would suit it, together with the shortcut.

Also in these profiles: the page indexes the whole program

C's profile showed another cost that grows with the program. When the worker's partial results arrive, the page combines them with an index of the elaboration: where each expression starts and ends. That index is built again for each new evaluation, so once per slider change, and it took 204 ms of C's 949 ms page update, profiled. Only expressions get an entry, but the walk also rebuilt every pattern and type. 8717564a5f, also on #2635, stops the walk there: 115 ms per change, profiled over six changes, with no change in what the index holds (a new test checks that the page never falls back to its slower full walk). The clock can't see it: twelve changes in C took a median of 1.37 s, against 1.23 s before, inside the noise of a check that runs every 200 ms.

Measuring it: two things that went wrong first

Update, later on 2026-10-14: B and C with the same fixes

B and C, measured again the same way on #2635's head, 9fb2bdf067: the Full Body slide, the "arm length" slider, two warm-up changes and then twelve, each a real pointer drag to a length not drawn before, in headless Firefox 157.0 with a fresh profile per session. The harness of 10-12 did not survive, so it was rebuilt from this report's method (below). The picture's row now reads probe(drawn) while it is shown, so B keeps that row and arm_length's.

BuildC, the whole programB, the slider and the picture
#2635's head1.33, 1.44 s2.05, 2.04 s
with the print fixed (ec9f125acd)1.76, 1.30, 1.23, 1.41, 1.30 s1.26, 1.43, 1.51, 1.54, 1.56 s

Each number is one session's median of twelve changes. The ranges are in the figure and in the data.

Where B's extra time went. Profiled (four changes in B on the head), the program's conversion to text (segment_to_string) was in 49% of the page's samples, about 1 s a change, inside the window. In C the same save comes just after the redraw. With the window held open 2.5 s past the redraw to catch it, save_current was 1,821 samples over three changes, about 600 ms each, and 239 (about 80 ms) with the fix.

Why the print was slow. To print a program with probes, the page wraps each probed term in its ^^probe(...) syntax (Triggers.refractor_seg_to_seg_with). A slide's top level is one long let ... in chain. At each node of it, the wrap sliced the list from its head (ListUtil.sublist) and took each child's range by walking down the chain (Skel.range): quadratic in the top-level items. And Segment.to_string calls it again on every nested segment while a probe is still unplaced, building each segment's skeleton. The fix slices an array, takes one end of a range at a time, and passes over any segment none of whose own pieces carries a probe. The output is the same: the 120 doc slides printed with their probes and parsed back pass, as do the divided-program, segmented-parser and FastParse tests.

What is left in B is the cells' livelit views, run on the page (live_html, about 340 ms of profile a change). That is the gap between the two rows' medians, where there is one.

A cross-check, on 2026-10-15. ^report(15), which sets Dress Up beside its livelits, measured the same slide and slider on #2635's newer head, 032fe3aca6, with the print's fix and the undo history's (^report(14)'s update). Its drag is not this report's: one real drag of 1.5 seconds (15 pointer moves of 100 ms), and release to redraw checked every 50 ms, eight drags after two warm-ups, in one session per set-up. Medians: C 1,091 ms (1,014–1,212), B 1,197 ms (1,056–2,206). The two again within the drift this update found between sessions; the numbers are in ^report(15)'s data.

What next

  1. Bring the reuse check's fix to #2597, whose check has no lexeme comparison at all (above), and to dev, together with the hash shortcut. Corrected on 2026-10-13: there is nothing to bring to #2597 (above); dev remains.
  2. Keep an open cell's ids stable when it is analyzed again without its text changing, or don't analyze it again. With the fix the check is cheap, but those parts still miss the hash lookup on every change; stable ids would put them back on it, on any slide with cells open (above).
  3. Keep the autosave out of a change: save when the page is idle, not on a timer that can fall in the middle of an update. (Update, later on 2026-10-14: the save's print was also quadratic with probes, and is fixed: above. Saving when idle would still keep it out of a change.)
  4. Give the cells' livelit views the view memo the whole program's editor uses.
  5. Or a GUI-only view that keeps the program whole, drawing just the chosen livelits from the whole program's editor instead of opening cells. That would avoid all three costs at once.
  6. A way to open a slide in UX mode from its address, for example ?mode=ux&show=arm_length,drawn. It would make these measurements, and demos, quicker to set up than clicking 41 rows off.

How this was put together