Repository navigation
Design undo shares unchanged
The owner chose this as the second step in shrinking an open document's memory, after the overall limit for pictures (Q221). This note covers three things: what an undo step holds today, how a step can hold only the drawings that changed, and what that costs.
There are two kinds of step (DocumentEditor):
- Delta steps carry an apply and revert pair and hold almost nothing. A stroke commit is one.
-
Snapshot steps (
Perform, 146 call sites) hold a whole copy of the document (Doc.Clone). Every point of every stroke is copied. Undo and redo swap that copy with the live document.
These figures were measured on the lab's owner-shaped fixture: 64 drawings, 1,261
strokes and 189,004 points, run with a throwaway console against Doc.Clone.
| per structural edit | |
|---|---|
Doc.Clone |
13.4 ms, 14.1 MB |
| a compare of every point against a copy | 13.2 ms, 0 bytes |
The lab's memory session saw the same: about 14.5 MB more per structural edit. The stack keeps 64 steps, so a session of layer, timeline and palette edits can hold about 900 MB of document copies. Most of those copies are identical drawings, because adding a layer or renaming one changes no drawing at all.
The obvious saving is to let a step reuse the live document's unchanged frames. That is wrong, because the live document is mutated in place:
- A stroke commit appends to
frame.Strokes. - A transform moves points.
- An undo of a delta reverts in place.
A shared frame would change under the step that holds it. Sharing with the copy in the previous step fails the same way: on undo, a snapshot's document becomes the live one and is then edited in place.
So anything shared must be frozen: a copy that is never handed out to be edited.
- A step holds a frozen document. That is a skeleton (the document and scene without their drawings) plus one frozen copy per drawing, held by id.
-
Frozen drawings are reused. The editor keeps the latest frozen copy of each
drawing. When a snapshot is taken, each live drawing is compared with that copy:
- If it is the same, the step references the existing frozen copy.
- If it is not, the drawing is frozen anew, and that one copy is the only cost. A structural edit that changes no drawing stores a skeleton and nothing more.
- Undo and redo thaw. Thawing builds a fresh live document from the frozen one, cloning each drawing so that nothing live aliases anything frozen. The document being left is frozen in the same way, with the same reuse. Frame ids are carried, so caches keyed by id stay valid, as they do today (B202).
A compare that misses a field would treat a changed drawing as unchanged. Undo would then restore the wrong drawing, silently. That is the B379 shape, where a hand-listed clone missed a deep field. So the compare is complete by construction: it walks every property the serializer writes, by reflection compiled once per type, rather than a list someone has to keep up to date.
- Points take a fast path: a span loop comparing the bits of every field.
-
Doubles are compared bit for bit.
Hash01seeds every dab from those bits, so two values that are equal as numbers but differ in bits are different drawings. - Errs strict. Anything the walker cannot classify counts as changed. A wrong "changed" costs one copy; a wrong "same" costs a drawing.
The sensitivity review blocked this change once, and two of its notes changed code as well as text.
- Redo no longer returns the objects that were live before the undo. On main, redo handed back the very document instance. Now it thaws a new one. 19 delta steps wrote to objects they had captured (guides, reference strips, weight painting), so redoing them after a redone structural edit would change nothing on screen. That was already reachable on main in the other order, and is fixed separately as B413, which this change needs first.
-
Shared samples were mutable.
BakedSampleandStrokeCheckpointare shared by every copy of a stroke, so a change in place would rewrite every undo state at once, and the compare could never notice, because the two sides are the same object. Both are now init-only. Making them so found one writer, the nudge to spacing (B414). - A type with nothing to compare fails closed. A type with no public property would otherwise compare equal for any two instances.
- Cels sharing one frame object are split into two frames with the same id on redo as well as on undo. Save and load split them the same way, so this matches the file.
- Structural edit: the compare replaces most of the clone, at about the same time (13 ms against 13.4 ms on the fixture). Allocation drops from 14 MB to the drawings that changed, which is usually none or one.
- Structural undo and redo: these are a swap today, so free. They become a thaw of about one clone, roughly 13 ms on the fixture, before the re-render that already follows them. This is the one thing that gets slower.
- Delta steps: unchanged.
These numbers were taken on the owner-shaped fixture, with a throwaway console
driving DocumentEditor, against main. The pattern was 23 renames, then 20 undos,
run twice; the figures agreed to within a millisecond.
| main | this change | |
|---|---|---|
| what a rename stores | 14.4 MB | 17 KB |
| rename, min / median | 13 / 48 ms | 18 / 19.5 ms |
| undo, min / median | 0 / 0 ms | 10 / 27 ms |
| managed memory held afterwards | 352 MB | 60 MB |
- The rename median improved. On main it was mostly garbage collection paying for the copies. The minimum rose by the compare's cost.
- Undo is the one thing that got slower. It now pays for a thaw before the re-render that already follows it.
-
The first build of the compare cost 46 ms, twice the clone. Most of that was
one getter:
BrushSettings.MediumOnDiskdecides "untouched" by serializing the medium, and the compare read it twice per stroke. It is now compared throughMedium, which gives the same answer (ContentEquality.ComparedThrough). That brought the compare of 64 drawings to 22.7 ms, allocating 6 KB.
-
UndoSnapshotFidelityTestsstill holds. It compares whole serialized documents across the round trip and does not care how the copy was made, as it did across B142's two fixes. -
A reflection guard. For every property the walker sees on
Frame,Strokeand the types beneath them, perturbing that property alone must make the compare say "changed". A property added later is covered without anyone remembering to. - Sharing is real: a structural edit that changes one drawing allocates about one drawing.
- Nothing aliases: after any undo, mutating the live document leaves every frozen copy unchanged.
-
Measured: the lab's
memory-sessionruns again, A/B.
- Getting started
- Documents and projects
- Tools and strokes
- Brushes
- Colour
- Layers, selections and guides
- The timeline
- Onion skin, references and the camera
- Symbols
- Saving and recovery
- Exporting to a game engine
- AI assistance
- Keyboard, performance and what is planned
- Bones and rigging
- Effects — fire, smoke and water
- A simplified 3D space to draw in: planes, parallax first, orbit second
- Brush performance beyond 4k: what the GPU can and cannot buy us
- The add-on boundary
- Making the AI trustworthy
- What an AI request actually costs
- Bones and spline deformers for 2D — design
- Brush tips — a generated library, not a live calculation
- Cloud and SaaS: what the architecture already fits, and what it does not
- Colour mixing — paint that picks up what it is laid on
- DESIGN: Effects
- DESIGN: Fluid effects — fire, smoke and water as drawn line and fill
- Fluid and viscous media — notes for the next brush-engine pass
- Compositing on the GPU
- Decomposing the two big files: which pieces come out, and with which tool
- One memory limit for pictures
- .NET 10: what is safe, what is unverified, and what it narrows
- One picture cache: the paused canvas on tiles
- The paint path: one mark, made once, under the pen
- Pen dynamics: tilt and speed
- A pencil is graphite on the paper's tooth
- Performance: the ratchet and the map
- Playback's first loop
- Pressure curves — a shape, not an exponent
- Importing Procreate brushes
- A project as a game's asset set
- One hierarchy: folders scope everything
- Puppet pins — design and plan
- The raster checkpoint: making a painting cheap to reopen
- Scoped resources — palettes, references and everything else shared
- Sensitive topics, and the three-track flow
- Design: pose-assisted frame-by-frame animation (rigid bones + AI gap filling)
- Stroke groups: why a merge should never turn strokes into pixels
- The project window: what you do between drawings
- Reading the subject before drawing on it
- The ceiling of a swept soft mark (B349)
- Q171 — a symbol owns a layer stack
- Pillar 3 — reusable assets, as live symbols
- What a template is
- Text, and where fonts sit
- The tiled compositor, and getting it off the CPU
- Design tokens
- Transform tool design (M14)
- Why a brush stamps twice, and when it does not
- Migrating to the designed UI
- Undo stores only what changed
- Vector tooling: making the lines you already drew editable
- The smudge/blur family, and aligning brush settings with Photoshop
Generated from the repository. Edit the files under docs/, not these pages.