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

CPU Truth, GPU Cache

~15 min · metal, gpu, cache, architecture, invariants

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

The Invariant, Read Against the Code

Ariandel's architecture states its second invariant in one line: "CPU tiles are ground truth; GPU textures are a cache." Every layer is a sparse map of fix15 tiles in memory the CPU owns; Metal textures mirror the tiles that changed, for display and compositing. The same paragraph goes on to say that saving, exporting and showing Pippa the canvas "read tiles, never the GPU." Hold that second sentence up to the code and it is too strong. The project's house rules phrase it precisely, and that phrasing is the one to learn: "whatever reads back from Metal composites tiles uploaded for that read." Tiles are the source. The GPU is an instrument, synchronized first.

What the GPU Holds, and Why It Cannot Be the Truth

The renderer keeps every layer, and every mask's paint, as one slice of a single texture2DArray in .rgba8Unorm, premultiplied, with .private storage. The composite it draws to the screen is rgba16Float and mipmapped. Two facts already rule the mirror out as a source. It is 8-bit premultiplied, so it cannot carry the faint colors fix15 carries (the 1% orange from the first lesson comes back from it as a different color). And private storage means the CPU cannot read it at all without a GPU pass that copies it somewhere shared. The mirror is built to be looked at, not to be believed.

How the Cache Stays Honest

encodeSynchronization (first code block) runs before every frame and at the start of every flatten:

  1. Slots follow the stack. A layer or mask that left gives its slice back. A new one gets a cleared slice, and its store is told markAllDirty(), so its first upload is complete.
  2. Only dirty tiles travel. For each mirrored layer it drains collectDirty(), the set that writableTile, setTile and libmypaint's writable releases fill (lesson two). Each dirty tile is converted with ard_fix15_to_rgba8_premultiplied into a shared staging buffer and blitted into its slice; a tile that is now absent uploads as zeros.
  3. Only the changed region recomposites, unless the stack itself changed (order, visibility, opacity, blend, clipping, background), in which case the whole canvas does.

Pixels flow one way: tiles to textures. The engine never writes mirror pixels back into a layer's tiles. Where the app uses the composite internally, it uses it as a reference: ColorDrop and the magic wand read it to decide where to reach, then paint or select with values of their own. (Copy All puts the picture on the clipboard like any other image; pasting it back is an import.)

Who Reads What

ReaderWhat it reads
The .ariandel packageFrozen fix15 tiles, LZFSE-compressed. No GPU.
PSD layer channels, per-layer PNG exportTiles, converted on the CPU. No GPU.
Gallery preview and thumbnailflatten at 2048 and 512 px.
Pippa's canvas lookflatten, at most 1568 px on the long edge, as JPEG.
PNG, JPEG, TIFF, PDF export, PSD compositeflatten.
Merge downIts own 16-bit arrays and a 32-bit float target. Never the 8-bit mirror.

flatten (second code block) is the important row. Its docstring says it "synchronizes from tiles first, so it never shows a stale mirror": it runs the same synchronization, composites the whole canvas into a fresh rgba16Float texture, packs that to straight 8-bit and reads it back. So Pippa does see a GPU composite, but a composite of tiles uploaded for that very read. Notice what it composites from: the same 8-bit slices the display uses. For a picture that leaves the app as 8 bits anyway, the loss is small but real: a straight-alpha PNG could have kept faint colors the premultiplied mirror already lost (Track 6, lesson 4 shows one). For anything that writes pixels back into a layer, that loss is not acceptable, which is exactly why merge down builds its own 16-bit arrays. That is the honest version of "never the GPU," and it is the one to keep in your head when you add a reader.

Why the Split Survives Trouble

A memory warning. When iOS warns, the app sheds undo history down to a quarter of its bytes, oldest undo steps first and redo steps only after those, and if an undo step went, a short notice tells Dad. Redo steps go silently: the notice watches only the undo stack. Layers are never touched, because layers are the truth; history is the one thing that can be let go without losing the picture. Track 6 has the details.

A lost cache. Because the mirror holds nothing the tiles cannot regenerate, any renderer can be thrown away and rebuilt. The production code already does this whenever a renderer meets a layer it has never seen: on a canvas resize it drops every texture, and each layer is re-uploaded whole from its tiles. The engine suite proves the property directly (third code block): after strokes, a new layer and an undo, a brand-new CanvasRenderer with an empty mirror flattens to byte-identical pixels. And the save treats the GPU as optional: both preview flattens are try?, so a failed composite costs the gallery a picture, never the painting.

Be exact about what is not claimed. Ariandel has no dedicated handler for a GPU reset, and nobody has forced one on Dad's iPad. What the split guarantees is the shape of recovery: if the textures ever vanish, recovery is a rebuild from tiles, not a loss. That guarantee is designed and tested in the suite; it has not been demonstrated on hardware.

Keep the truth where you can read it, and let the fast copy be disposable. A cache that can always be rebuilt from the source can also be wrong, stale or gone without costing anything but time, and every reader that leaves the app either reads the source or rebuilds from it first.

Code

Bringing the GPU mirror up to date from dirty tiles (AriandelEngine/CanvasRenderer.swift, verbatim, through the upload)·swift
    func encodeSynchronization(of artwork: Artwork, into commandBuffer: MTLCommandBuffer) throws {
        try prepareTextures(for: artwork, commandBuffer: commandBuffer)
        var region = pendingRegion
        pendingRegion = nil

        // Slots: layers and masks that left give theirs back; new ones get a cleared slice.
        let mirrored = Self.mirrored(artwork)
        let present = Set(mirrored.map(\.id))
        for (id, slot) in slots where !present.contains(id) {
            slots.removeValue(forKey: id)
            freeSlots.append(slot)
        }
        for layer in mirrored where slots[layer.id] == nil {
            let slot = try allocateSlot(for: artwork, commandBuffer: commandBuffer)
            slots[layer.id] = slot
            clear(slice: slot, commandBuffer: commandBuffer)
            layer.tiles.markAllDirty()
        }

        // Pixels: upload every dirty tile into its layer's slice.
        let size = artwork.size
        var uploads: [(slot: Int, key: TileKey, tile: TileBuffer?)] = []
        for layer in mirrored {
            guard let slot = slots[layer.id] else { continue }
            for key in layer.tiles.collectDirty() {
                guard let rect = PixelRect(tile: key).clipped(to: size) else { continue }
                uploads.append((slot, key, layer.tiles.tile(at: key)))
                region = region.map { $0.union(rect) } ?? rect
            }
        }
        if !uploads.isEmpty {
            try encodeUploads(uploads, canvas: size, commandBuffer: commandBuffer)
        }
Every read-back synchronizes first (AriandelEngine/CanvasRenderer.swift, the head of flatten, verbatim)·swift
    // MARK: Flattening (export, previews, Pippa's look, fills)

    /// The whole picture flattened to straight-alpha 8-bit RGBA, optionally
    /// scaled so its longer edge is at most `maxEdge`; with `visibility`, only
    /// the layers it names (an animation frame). Synchronizes from tiles
    /// first, so it never shows a stale mirror.
    public func flatten(_ artwork: Artwork, maxEdge: Int? = nil, background: RGBAColor?? = .none,
                        visibility: [UUID: LayerDisplay]? = nil) throws -> FlatImage {
        guard let commandBuffer = queue.makeCommandBuffer() else { throw RendererError.readbackFailed }
        try encodeSynchronization(of: artwork, into: commandBuffer)
        guard let array = layerArray else { throw RendererError.readbackFailed }
The cache is disposable: a fresh renderer agrees byte for byte (Tests/AriandelEngineTests/RendererTests.swift, verbatim)·swift
    @Test func incrementalSynchronizationMatchesAFreshFlatten() throws {
        let renderer = try CanvasRenderer()
        let artwork = Artwork(size: CanvasSize(width: 300, height: 300))
        let painter = try makePainter(artwork)
        try renderer.synchronize(with: artwork)
        drawStroke(painter, from: (20, 20), to: (280, 280))
        try renderer.synchronize(with: artwork)
        artwork.addLayer()
        drawStroke(painter, from: (280, 20), to: (20, 280))
        artwork.undo()
        try renderer.synchronize(with: artwork)
        let incremental = try renderer.flatten(artwork)
        let fresh = try CanvasRenderer().flatten(artwork)
        #expect(incremental.rgba == fresh.rgba, "dirty-region updates converge on the same picture")
    }

External links

Exercise

Pick a cache in a system you work on: a GPU texture, a search index, a materialized view, a memoized result. Write down its source of truth, then list every reader that leaves the system (exports, API responses, saves). For each reader, decide whether it reads the source, rebuilds from it first, or trusts the cache. Then write the test Ariandel has: build the cache incrementally through a realistic sequence, build it again from scratch, and assert the two are identical.
Hint
Include an undo or a delete in the sequence. Incremental caches usually go wrong on removal, not on addition, because removal is where a dirty set forgets to mark something. If you cannot build the cache from scratch at all, you have found the real problem: it is not a cache, it is a second source of truth.

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.