Repository navigation
Design performance
Two artefacts, two jobs. Conflating them is the mistake this document exists to prevent.
| The ratchet | The map | |
|---|---|---|
| What |
Category=Performance tests |
tools/Lightbox.Bench |
| Runs | every commit, inside dotnet test
|
deliberately, minutes |
| Asks | did this diff break a path we know about | where are the cliffs on paths nobody has walked |
| Shape | fixed scenario, pass/fail | a curve over a dimension that grows |
| Statistic | fastest of N | p50, p95, max |
| Output | green | a ranked, attributable table |
The ratchet came first and it works. What it cannot do is answer "what should we fix next", because a pass/fail on a fixed workload says nothing about where that workload stops being viable — and the unit of work here is a sequence, which no existing budget grows.
What grows, and how fast — the exponent. Every sweep fits cost ∝ n^k on
the log-log points and reports k. This is the durable result and the
milliseconds are not: every budget in CHARTER.md carries a "measured on the
slow dev container, 4× headroom" caveat because a time is a property of the
machine. A slope is a property of the code. Linear here is linear on a desktop,
and a jump from 1.0 to 2.0 is a defect on any hardware — which also means a
regression can be detected without a reference machine.
When it stops being usable — the cliff. The first swept value whose p95 misses the budget. "Onion skin holds under 16 ms to 6 ghosts a side at 1080p; at 8 it doesn't" is the sentence an artist can act on, and no pass/fail can produce it.
What to fix first — the pressure. p95 ÷ budget, ranked.
The budgets take the fastest of several runs, deliberately, and CHARTER.md
argues that case well: a median measures the scheduler as much as the code, and
for catching an order-of-magnitude regression the floor moves as surely as the
middle. It also names what that gives up — "blind to a path that is usually
fast and sometimes terrible".
For a cliff that blind spot is the whole subject. A repaint that is 8 ms nineteen times and 40 ms once is a visible hitch while drawing, and the fastest run cannot see it. So the map takes p95 and the ratchet keeps the minimum. They are measuring different things on purpose.
A pointer event gets 16 ms because it happens sixty times a second. An export
gets two seconds because it happens once. So ranking by p95 ÷ budget is
already a cost-times-frequency ranking, and a separate frequency weight would
say the same thing twice while giving two numbers to argue about.
This matters because the naive ranking is actively misleading. The simulated media are the largest raw numbers in the codebase — a watercolour commit is ~30 ms against 4 ms for a flat brush — and they are also paid once per stroke, on a brush the picker badges as expensive. Ranked by milliseconds they come first. Ranked by pressure they come nowhere near a 12 ms repaint that fires on every pointer event, which is the correct answer.
| Cadence | Budget | Why |
|---|---|---|
| Every pointer event | 16 ms | one frame at 60 Hz |
| Every frame of playback | 83 ms | one period at 12 fps |
| Once per stroke or edit | 100 ms | felt as weight, not as stutter |
| Once per session | 2000 ms | open, save, export |
Two traps, both hit for real in this codebase, both now designed into the harness rather than left to whoever writes the next sweep.
Two benchmarks in one process are not two measurements. An A/B on the fluid lattice read as a 25% regression and was entirely an earlier scenario having grown a shared buffer; run in isolation the same change was an 11% win. The harness tears down and collects between every value, and scenarios hold their state in closures that die with them.
A deterministic workload, or two reports are incomparable. The synthetic drawings are placed by a hash of the index, never an RNG. A benchmark whose input differs run to run cannot detect a regression smaller than its own variance.
Calibration, not absolute times. Each report records a fixed arithmetic loop. Two reports from different machines compare by the ratio of that figure; their raw milliseconds do not compare at all.
A per-dab optimisation measured in Debug is not measured. Debug inflates exactly what an inner-loop change removes — unrolled float arithmetic, and a P/Invoke per Skia call — so it flatters the change roughly twofold and does it silently. B292 was built, reported as a 27% win end to end, and then withdrawn: in Release the same pair read 1.876 against 1.861 ms per pointer event, which is inside the noise of three repeats. The isolated stamp genuinely was 1.65x faster in both configurations; what Debug hid was that the stamp is a small share of the event, so making it faster changed almost nothing.
Two habits follow, and the second one is the cheaper of the two:
-
Release for anything below the frame, because a local
dotnet testis Debug and CI is Release — so a Debug-only conclusion is a conclusion the build that ships never had. B298 is the same seam pointed the other way: a performance test that passes in Debug and measures a negative cost in Release, failing only on CI where it reads as somebody else's flake. - Price the whole operation before optimising a part of it. A 1.65x win on something that is a tenth of the cost is a 6% win, and finding that out after building it costs a branch. B292 and B299 are the same stroke measured twice: the outline looked like the bottleneck and the fill was.
The list is data, not code, so the vector work adds rows rather than a rewrite.
| Area | Dimensions |
|---|---|
| Animation (swept) | layers, onion depth, strokes per frame, frames per scene, playback, scrubbing |
| Drawing / painting (swept) | canvas area, brush diameter, dabs per stroke, medium flow steps |
| Drawing / painting (later) | tip resolution, tip-set blending, smudge sample radius |
| Document | strokes per document, undo depth, clip regions, symbol placements |
| Vector (later) | control points per path, paths per frame, boolean ops, hit-testing |
Animation went first because it had no coverage at all: every existing budget measures one stroke on one frame, which is drawing. The application's unit of work is a sequence, and nothing had ever grown one.
Group findings by cause, not by symptom. The first map came back with six red rows and they were three problems. A full recomposite costing ~20 ms a 1080p layer shows up as four separate failures — scrubbing, playback, onion depth, layer count — and filing four bugs would have put four people in the same blend loop. The ranked table is by symptom because that is what an artist feels; the ledger is by cause because that is what somebody fixes.
The harness will be wrong before the code is. Four measurements in the first sitting were confidently wrong, and each looked like a finding:
| What it said | What it was |
|---|---|
| compositing one cached layer misses 16 ms | a fresh 1080p surface allocated inside the timed region |
| a full recomposite blows the frame budget | true, but scored against a budget the app never pays — ComposeRing repaints the dirty region |
| onion skin costs 8000% of budget | its scene sat exactly on the cache ceiling; the curve belonged to eviction |
| a cliff moved from 8 to 4 | the same measurement, one rung apart on a geometric ladder |
The pattern is the same each time: the number was real and the attribution
was not. So the order of questions when a sweep reports something dramatic is
what else is in this measurement, and only then what is wrong with the code.
The first three are now designed out; the fourth is why check needs a factor
of two before it reports a cliff.
It becomes a bug in BUGS.md, with the two measurements as its evidence.
Not a gate, and not a report-only note. A gate would put a minutes-long sweep in the commit loop and produce false failures on a noisy runner; report-only gets read once and then ignored. Filing into the same ledger as everything else means a regressed cliff is prioritised against real work, which is the only mechanism here that has ever changed what gets built.
Absolute cross-machine comparison beyond the calibration ratio; profiling individual functions, which is a profiler's job and not a harness's; and anything that requires a UI toolkit — the harness drives the rendering and document layers directly, so it needs no display and no Avalonia lifetime.
- 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.