Skip to content
C.W.K.
Stream
Lesson 03 of 05 · published

The Autosave That Copied Everything

~14 min · code-review, autosave, performance, copy-on-write, main-thread

Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Three Hours Old

Ariandel's repository was born on the evening of 2026-10-01. Before that night was over, it had an engine with layers, history and a file format, an app with its first TestFlight builds, and a green test suite. Then Dad asked for two things, in this order: "1. comprehensive code review 2. add missing essential features."

The review read the whole repository: engine, C bridge, renderer, shaders, app and tests. Its first finding, in the words of the coop history row that records it (#5658):

"Autosave copied every painted tile on the main thread (ArtworkArchive copied Data per tile): hundreds of MB per save on a large painting."

The Code Was Honest, and Still Wrong

Look at the old version in the first code block. Nothing was hidden. Its docstring said exactly what it did: "Taking an archive copies tile bytes on the artwork's thread; encoding, compressing and writing can then run anywhere." The design was half right. Encoding already ran off the main thread, in a detached task. And the copy was not carelessness, it was the price of safety: without some way to promise that a buffer will not change, handing a live tile to a background thread means the next dab can rewrite the pixels while the compressor reads them. Copying was the only promise the engine knew how to make.

The cost hid in the word copies. Each tile's .data was a fresh 32 KiB Data, for every painted tile of every layer, on the thread that also receives Apple Pencil input. And autosave was a debounce: four seconds after the artwork last reported a change, a save fired. So every pause in painting long enough to lift the pen and look triggered a full copy of the painting. Run the first lesson's arithmetic: a fully painted 4096×4096 layer is 128 MiB, so a few such layers is hundreds of megabytes, exactly the review's figure.

Why the Tests Were Green

The engine suite passed before the review and after it. That is not a flaw in the suite; it is what pass-or-fail tests measure. A test saves a small canvas, reads it back, and compares pixels: correct. A copy of a few dozen tiles takes no time worth noticing. The defect was never a wrong answer. It was a cost that grows with the size of the painting, on the one thread where time is the product. The question that finds it is the one a reader asks of each line: what does this cost when the painting is big, and which thread pays?

The Fix: Freeze, Don't Copy

Commit 6479f1f landed the copy-on-write tiles from the previous lesson, and the archive became their first beneficiary. LayerArchive.tiles changed from [(key, bytes: Data)] to [(key, tile: FrozenTile)], and the archive line changed from copying each tile's data to calling frozenTiles(). In the commit's own words: "A stroke now costs one tile copy instead of two, an undo step's after-state shares the live tile, duplicating a layer copies nothing, and ArtworkArchive freezes instead of copying, so autosave no longer copies every painted tile on the main thread and saves and exports can encode off it."

Today's save (second code block) freezes on the main thread, which costs little more than setting a flag per tile, then compresses and writes on an actor, while painting continues. The next stroke pays one copy for each tile it touches (one per batch if it is held to a selection or laid through a grain), and only those. Be precise about what is still on the main thread: the two flatten calls for the gallery preview (2048 px) and thumbnail (512 px). Each one uploads any dirty tiles into the GPU mirror, composites the whole canvas at full size, scales it down, and makes the main thread wait for the GPU to finish before it reads the small result back. So their cost grows with the canvas, not with the preview size. What they no longer do is copy every painted tile's bytes, and that copy was the review's finding.

The Same Review's Other Finds

The autosave was the headline, not the whole list. The same review fixed, among smaller things:

  • Merge down went through the 8-bit display mirror, erasing the color of faint paint in the lower layer (the faint orange from lesson one). It now uploads both layers at 16 bits and composites in 32-bit float.
  • The memory-pressure handling the architecture named did not exist. History.shed now answers the system's memory warning, oldest undo steps first, layers never.
  • A background save could be suspended mid-write. Saves now ask iOS for background time.
  • Canvas size allowed 16384×16384 regardless of device memory, where the GPU mirror alone would exceed RAM. CanvasSize.maximumPixels now follows the device.
  • The display had no mipmaps, so the canvas shimmered at zoom-out.

Track 6 takes most of these apart. Notice the shape of the second one: a document described a safety mechanism and the code did not have it. No test fails for a function nobody wrote.

The verification on the record: on the simulator, the engine suite ran 57 green (25 new), the hosted app tests 20, the UI tests 2. The record also names what was not exercised: Pencil pressure, tilt and hover feel, QuickShape timing with a real Pencil, and voice. A review is not a device test, and it said so.

A review that reads the code finds what a happy-path test never will. Tests answer "is the result right?" on the inputs someone thought of. Reading asks "what does this cost, on which thread, at what size, and does the thing the document promises exist?" Those are different questions, and a project needs both.

Code

Commit 6479f1f's change to ArtworkArchive (git show, trimmed to the docstring, the tile field and the line that takes the snapshot)·text
@@ -1,15 +1,29 @@
 import Foundation
 
-/// A frozen, thread-safe copy of an artwork: what a save writes and a load reads.
+/// A frozen, thread-safe picture of an artwork: what a save writes, an
+/// export encodes, and a load reads.
 ///
-/// Taking an archive copies tile bytes on the artwork's thread; encoding,
-/// compressing and writing can then run anywhere.
+/// Taking an archive freezes the live tiles instead of copying them (see
+/// `TileBuffer`), so it is cheap on the artwork's thread however large the
+/// painting; encoding, compressing and writing then run anywhere while
+/// painting goes on, and the next stroke copies only the tiles it touches.
 public struct ArtworkArchive: Sendable {
     public struct LayerArchive: Sendable {
         public var id: UUID
         public var properties: LayerProperties
-        /// Native-format tile bytes (`Tile.byteCount` each).
-        public var tiles: [(key: TileKey, bytes: Data)]
+        /// Every painted tile, frozen, in key order.
+        public var tiles: [(key: TileKey, tile: FrozenTile)]
@@ … @@
         layers = artwork.layers.map { layer in
             LayerArchive(id: layer.id, properties: layer.properties,
-                         tiles: layer.tiles.tiles.keys.sorted().map { ($0, layer.tiles.tiles[$0]!.data) })
+                         tiles: layer.tiles.frozenTiles().map { ($0.key, FrozenTile($0.tile)) })
         }
The main-thread half of today's save (AriandelMobile, Canvas/CanvasModel.swift, verbatim)·swift
    /// Saves the artwork off the main thread. Leaving the canvas or the app
    /// first commits a transform or adjustment in progress; an autosave waits
    /// for it instead. Returns the save in flight, if one started.
    @discardableResult
    func save(reason: SaveReason) -> Task<Void, Never>? {
        if reason == .closing || reason == .leavingForeground {
            if painter.isStroking { painter.endStroke() }
            finishSessions()
            timelapse?.finish()
        } else if painter.isStroking || hasSession {
            scheduleAutosave()
            return nil
        }
        guard reason == .created || reason == .explicit || artwork.revision != savedRevision else { return nil }
        // Freezing the tiles is cheap; encoding and writing happen off the main thread.
        let archive = ArtworkArchive(artwork)
        let revision = artwork.revision
        let preview = try? renderer.flatten(artwork, maxEdge: 2048)
        let thumbnail = try? renderer.flatten(artwork, maxEdge: 512)
        isSaving = true
        // Leaving the foreground: ask for time to finish, or iOS may suspend
        // the app with the package half written (the old one stays intact).
        let background = UIApplication.shared.beginBackgroundTask(withName: "Save artwork")

External links

Exercise

Pick the save, autosave or sync path in an app you maintain and read it line by line with one question per line: how does this line's cost grow with the size of the user's document, and which thread pays it? Write down every line whose cost is proportional to the whole document rather than to the latest change. Then check your architecture notes for one safety mechanism they describe, and find the code that implements it.
Hint
Copies hide in innocent spellings: a value-type property read that materializes a buffer, a .data accessor, a map over every element to build a snapshot, a JSON encode of the whole model. If the architecture note names a mechanism and grep finds no caller, you have reproduced the review's second kind of finding.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.