Hazel performance analysis · ^report(4) · 2026-10-11

The witch and her woods, ported to Hazel

M's witch went into Messed Up Dress Up first (^report(3)). Now she is a slide in the Livelit Playground, on branch #2635 (livelits × modular editors), kept in step on #2597 (user-defined livelits). Her woods and three more of the game's backgrounds followed, as a Scenes slide. This report covers what the two slides do, two layout bugs the port turned up, and what a redraw costs, with a first look at why the woods cost so much.

The Witch slide's picture five ways: the default purple witch with green skin and a hooked nose; with a tuxedo cat on her shoulder and a broom in her hand; her nose pointing straight out at twice its length; in black with white webs, purple trim, red boots and a ginger cat; and with the cat at 400% and the broom at 300%.
The Witch slide, by its own livelits: the default; the cat and the broom turned on; the nose pointing out, at 200; other colors; the cat at 400% and the broom at 300%.
2
new slides, Witch and Scenes, with 24 new controls between them; and 2 layout bugs fixed on the way
2.2–4.5 s
the Witch slide redrawing after one change, with the three fixes below (it was 5.0–7.5 s)
2.6–6.4 s
a redraw in the witch's woods, each picture new, after three fixes below: 2.6–3.5 s moving the figure, 5.2–6.4 s changing the woods. It was 10–21 s
1–3 ms
the game drawing the same witch, or a scene

Update, 2026-10-12: the picture's view, made cheaper, and a correction. The woods' remaining cost was not per shape, as said below, but per point, and in copies. Three changes since: the frame now sets the picture's box; the points strings are cheaper; and a typed value is no longer copied at every boundary it crosses. Moving the figure in the woods now redraws in a median of 1.41 s, down from 2.20 s. The details.

The Witch slide

The slide starts from Full Body's figure: the same hair, head, eyes, mouth, arms and hands. It adds the witch's own controls and pieces. As in the game:

Every piece is a polygon or a path, because those are the only shapes the slides' Drawing library has. Each piece is placed by the figure's own anchors: hips, feet, neck, the hand at the end of the arm. Two passes over the finished picture recolor it. One turns the shirt's blue into her dress color. The other turns the usual skin and its outline into the chosen skin. The pieces stack in the game's order, so a raised arm still crosses in front of her face and the broom hand goes on last.

The game's witch and the slide's witch side by side, each with a tuxedo cat and a broom. The game's dress is covered in a repeating web pattern; the slide's has five separate webs.
The game, and the slide. The game tiles its web over the whole dress, using an SVG pattern. The Drawing library has no patterns, so the slide draws five webs, each with eight threads and three rings.

Try it: the Witch slide. Switch the outline to 🖼️ to see only the GUIs.

Skin, as swatches

The game's skin colors include five that mean something, each marked with a face: 😀 smiley yellow, 😡 mad red, 🤢 sick green, 👽 alien grey and 🧙 witch green. The Full Body slide gained a skin choice earlier today, one button per skin, as a ^choice.

Ten skins were too many for it. A ^choice's width is a number in its definition, sized for about seven short buttons, and that number cannot depend on the choices passed in. The row of ten spilled past both ends of its box, and the code drawn beside it covered the first button. Pressing "peach" pressed the code instead, and the picture never changed. The redraw script found this. Every other press redrew in 5–7 s, and "peach" never did, even after 60 s.

The skin is now its own small livelit, ^skin. It shows one swatch per skin, in that skin's color, as the game does, in a box wide enough for all ten. The faces became words: smiley, mad, sick, alien, witch.

The skin_colors line in the editor: ten small buttons, each in its own skin color, labelled peach, smiley, mad, sick, alien, witch, pale, tan, brown and dark; witch is outlined.
The swatch row in the editor, witch green pressed.
The witch in peach, mad red and witch green skin.
Peach, mad red, witch green: one press each, 6.2 s, 6.5 s and 5.0 s.

Labels that fit

The witch's choices first had two-word labels: "green web", "orange trim", "nose points left". Each ^choice is one line tall. The labels wrapped to two lines, and the box cut off the second, so "nose points left" read as "nose points". The labels are now one word each. The name on each line (web_color, trim_color, nose_dir) already says what the choice is for.

The witch's controls before: buttons such as green web and nose points left wrapped onto two lines and cut off.
Before: two-word labels, wrapped and cut off.
The witch's controls after: every button a single word on one line, and the cat and broom toggles and size sliders below.
After.

Both bugs come from the same place. A livelit's box is a fixed width, one line tall, and nothing reports when its view overflows the box. A script now checks every button on these two slides: each sits inside its box, and nothing covers it. Full Body still has five shoe labels that wrap, such as "alien 4 toes". That fix is next.

The Scenes slide

Four of the game's backgrounds, with all their sliders: the forest; under the sea, with the sharks at their picnic table, forks in their fins; the beach; and the witch's woods. A paper doll in the game's usual look stands in for the figure. The slides before this one draw the real figure, and drawing it here would make every redraw pay for both. Three sliders size the doll about its feet and move it across and up or down the stage.

The paper doll in each of four scenes: a sunny forest with pines, sunshafts, flowers and round trees; under the sea with a shipwreck, seaweed, fish and two sharks at a picnic table; a beach with palms, umbrellas, chairs, blankets and sand castles; and the witch's woods at dusk, with dead trees, a skull-topped fence, skeletons and a bubbling cauldron.
The four scenes, each as the slide first draws it.

The woods have the game's newest controls (^report(3)):

The woods three ways: the hut on chicken legs behind the fence; the legs at 200% and the hut as near as it comes, its top flattened against the top of the stage, the skulls' eyes bigger; and the doll at half size, moved left and up.
Chicken legs on; legs at 200%, the hut nearest and the eyes at 100%; the figure at 50%, moved.

Some things are approximated, as agreed before the port:

Try it: the Scenes slide.

The slide's webs, back in the game

I liked the slide's few webs better than the game's web all over the dress. So the game now offers both for the witch:

The game's witch four ways: her web all over the dress; a few webs; a few white webs on a black dress; a few webs on a wider dress, the webs grown to fit.
In the game: all over; a few webs; a few, on black; a few, on a wider dress.

What it costs

The Witch slide

ChangeWitch slideFull Body slideGame draw()
Load, to the first picture14.5 s14.7 s
Skin: mad red; peach; witch green6.5 s; 6.2 s; 5.0 s4.1 s; 3.3 s; 3.4 s~1 ms, the witch with her cat and broom (3 ms on the beach)
Cat on; broom on6.9 s; 6.6 s
Nose out; nose length 100 → 2006.2 s; 6.2 s
Dress, web, trim, boot and fur colors6.2–7.3 s
Cat size 100 → 400; broom size 100 → 3006.1 s; 7.5 s

Every change to the witch costs about the same, 5–7.5 s, whatever it changes. A skin press costs about 2–3 s more on the Witch slide than on Full Body, though the Witch slide is shorter (2,189 lines against 2,896). So the extra time comes from what the witch adds, not from the slide's length. Which part of it is not known yet: this slide hasn't been profiled.

Since the three fixes below (the worker's slices, reuse and the view's memo), the same changes take about half as long, single runs again:

These are single runs, one per change, so read them as sizes rather than precise numbers. Earlier reports saw a few tenths of a second between runs of the same change.

The Scenes slide

SceneShapesPointsSwitching to itMoving the figure (size 100 → 90 → 100 → 90 → 100)
Forest1641,2426.3 s5.5, 3.5, 3.6, 3.7 s
Under the sea1491,0255.4 s
Beach1818955.0 s
Witch's woods570 (614 with legs)2,558 (2,702)12.0 s15.5, 10.0, 12.0, 10.9 s

In the woods, each slider took 16.5–21.2 s, one run each: chicken legs on, leg size, hut nearer, eye size, and the figure's size and place. The slide takes 23.5–26.3 s to load. The game draws a scene in about 3 ms.

The woods cost about three times as much as the other scenes, even when only the figure moves. Resizing the figure in the woods takes 10–15.5 s; in the forest it takes 3.5–5.5 s. So a redraw costs what the whole picture costs, not what changed. The woods have 3.7 times the forest's shapes and 2.2 times its points. In every series, the first change after loading is the slowest.

Three experiments on the woods

VariantShapesPointsMoving the figure, four runs
As first built: the scene and the figure kept on the stage together5702,55818.0, 10.3, 13.2, 11.2 s
The scene and the figure kept on the stage apart (kept)5702,55815.5, 10.0, 12.0, 10.9 s
Each dead tree drawn as one line per fork level, retracing its limbs3723,41618.3, 12.0, 14.4, 14.0 s
The view's call memo keeping 1,024 calls per helper, not 8 (an engine change, not kept)5702,55817.8, 11.5, 10.2, 10.6 s

Where a woods redraw goes

A profile of two figure-size changes in the woods, on the first build. Each change splits about evenly between the eval worker and the page:

Profiled times run longer than real ones: these two changes took 14.8 and 19.7 s under the profiler, against 10–15.5 s without it. Read the split as shares, not times.

The worker's timeline, and a fix

A message timeline of the woods followed, as in ^report(1). It covered the page's posts to the eval worker and the worker's replies, with their sizes and times. The worker logged, per request, how its time went and what it reused; that logging was temporary. Two figure-size changes, two eye-size changes, then the same in the forest:

Fixed: the worker now runs slices back to back for up to 30 ms before it yields (#2635 25e515536d, #2597 74f8056646). A new request still gets in within those 30 ms.

Moving the figure, four runsBeforeWith the fix
Witch's woods15.5, 10.0, 12.0, 10.9 s9.4, 3.6, 4.2, 5.0 s
Forest5.5, 3.5, 3.6, 3.7 s3.6, 1.6, 2.4, 2.0 s

Kids' Choice, ^report(1)'s slider, is unchanged: release to face has a median of 138 ms over 12 releases. On that slide the worker is not on the face's path.

On #2597, which lacks #2469's speedups, the Scenes slide takes about 5 minutes to draw its first picture, with or without the fix (297 and 301 s). The page is too busy even to answer a script. The Witch slide takes 172 s there. That is the main thread, not the worker.

Later on 2026-10-12: still true, measured again cold (Scenes 287.5 s, Witch 173.1 s), and now explained in ^report(7). On #2597 the page runs a full update for every partial result the worker streams, re-projecting the whole program each time, and the worker streams after every slice. One commit from #2469, c07bdacead, brings Scenes to about 17 s and Witch to about 19 s in a copy of #2597.

Why nothing was reused, and a fix

The next question was which variable broke reuse. The worker logged, again only for this, every top-level binding it evaluated again, and why its definition's reuse check failed. Followed back from the woods, the chain started at the top of the slide:

The cause is in statics, which works out each expression's free variables, the ones the reuse check compares. A recursive function is compiled into a fixpoint that binds its own name. Statics already dropped that name from the fixpoint's free variables, with a comment saying that leaving it in breaks reuse. But the let around the fixpoint still took its definition's free variables whole, so the name leaked into every expression around it. A slide's library is such an expression, and every slide has one.

Fixed (#2635 4b964e6660, #2597 a1a148278e): a recursive let's free variables drop the names its fixpoint binds.

Woods, each change a new picture, four runsWithout the fixWith it
Figure size (the woods unchanged)8.4, 9.9, 9.0, 10.7 s6.5, 6.1, 7.3, 6.2 s
Skull eye size (the woods changed)8.3, 10.2, 8.8, 10.3 s8.3, 10.2, 9.2, 10.7 s

Moving the figure saves about 3 s. Changing the woods saves nothing, as it should: the woods must be evaluated again. Going back to a picture the page has drawn before now takes about 1 s: 0.95–1.07 s in the woods, 0.64–0.78 s in the forest. What is left for a new picture is mostly the page drawing it.

The view's memo cost more than it saved, and a fix

With the worker reusing the woods, a profile of two new-picture changes put almost all of the time on the page, in the ^picture's view. A third of the view was the call memo's own work: computing each call's key, which puts every value the function reads in canonical form and hashes it, and looking it up.

The memo keeps 8 calls per function, which suits Kids' Choice's few helpers. The picture's view calls its shape helper once per shape, about 600 times. So it missed on every call and still paid for every key.

Woods, figure size, each picture new, four runsRelease to redrawKids' Choice, median of 12
The memo as it was6.5, 6.1, 7.3, 6.2 s125 ms
Keeping 1,024 calls per function, not 810.3, 9.0, 9.7, 12.2 s
The memo off2.4, 2.4, 2.6, 2.9 s167.5 ms
The memo giving up on a function that keeps missing (kept)3.5, 3.2, 2.6, 3.3 s130 ms

A bigger memo was slower, since a lookup scans the stored entries. With the memo off, the woods were fast but Kids' Choice lost the 40 ms the memo was made to save.

Fixed (#2635 126f595ce9, #2597 498cf299ea): once a function has missed 24 times for each time it hit, it is no longer looked up or recorded. A function that hits stays memoized.

Tried after, and not kept: letting the memo keep each shape's call.

Where the view's remaining second or so goes is the next question. In a profile, computing keys was about a sixth of the page's time and evaluating about a third.

The view's casts, and a fix

A new profile of the picture's view showed type work that has nothing to do with drawing.

Fixed (#2635 efaaee244f, #2597 abadface30): unrolling is remembered per type, and the check is cached by hash like its neighbour's.

The view's remaining time is not its per-point work. With coords writing only each shape's first point, about 600 points instead of 2,700, the times did not move: 2.9, 1.7, 2.4 and 2.5 s. What is left is per shape: building each shape's Html node and its attributes, for all ~600 shapes on every change.

Update, 2026-10-12: not so. The Html nodes are about 3% of the view's evaluation steps. Most of the rest was per point, in two places. The view's box walked every point, all of them even in this experiment, which is why the times did not move; and so did the points strings. The view also copied the picture each time it was passed to a typed function. Below.

Earlier lessons, checked against this slide

Update, 2026-10-12: the picture's view, made cheaper

Counted under node, by kind of step and by part of the view, then timed on the page. The woods' picture has 570 shapes and about 2,560 points. Its view took 238,781 evaluation steps:

Part of the viewStepsShare
The box (view_box): every point, to find the bounds134,32056%
The points strings (coords)92,70339%
The Html nodes and their attributesabout 6,6003%

Three changes followed, each measured before and after. They are on #2597 and #2635, and the third is also a dev PR, #2679:

  1. A frame sets the box. Most playground pictures already had a Drawing.frame, there so the box holds still. Now the box is the frame's, with the same margin, and the view no longer walks every point. Anything drawn outside the frame is cut off. The Witch's and Full Body's frames were raised to take in the hat and the hair as the slides open, so every slide opens with the box it had before. The view went from 238,781 to 99,621 steps. The redraw of a figure move went from a median of 2.20 s to 1.52 s, 8 changes each, with the same pictures.
  2. Cheaper points strings. to_fixed called directly, and a space after every point. Steps 99,621 → 74,055, but the view's own time only about 6% less, measured on one build: 26% fewer steps, 6% less time. Steps are not equal; see ^report(6)'s update.
  3. No copy at a typed boundary. Giving a value a type, as a typed parameter does, rebuilt the value, every list cell and tuple field, even when nothing inside changed. The picture was copied whole, 15,500 nodes, at view_box(pic : Picture) and again elsewhere. The copies cost time and garbage, and, being new objects, they made the view's call memo redo its keys: about a quarter of the view on every redraw. Now a value that already has the type comes back as itself. The view on a move to a new size went from about 725–830 ms to 520–650 ms. Revisiting a size the view has drawn still takes about 210 ms, and Kids' Choice is unchanged. The whole redraw, in the same session, went from a median of 1.62 s to 1.41 s.

What is left of the view is the evaluator's own work, about 500 ms: variable lookups, pattern matching, fresh ids. That is the next target.

What next

Since then: the shoe labels fit. A ^choice now wraps its buttons into rows and is as many lines tall as they take (#2635 af826fcb46, #2597 fc9cadab07). The page's side of a redraw, and how it compares with diffing the view in the worker, is ^report(5).

How these were measured