mirror of
https://github.com/bancika/diy-layout-creator
synced 2026-11-13 02:32:09 +10:00
a8d8a9b409
* Caching and culling improvements * doc * Update canvas-rendering-cache-and-culling.md * caching for wires * cache invalidation improvements * caching improvements * twisted wire performance update * Improved grid rendering on screen and when exporting; PDF export of the grid is now vector-based and does not get blurry on zooming in * area append improvement * bug fix for subtracting trace cuts * Twisted wire stroke render improvement * various performance improvements * lazy area merging * regression update * Update update.xml * code cleanup * Twisted wire border thickness fix
237 lines
14 KiB
Markdown
237 lines
14 KiB
Markdown
# Drawing Engine Performance
|
||
|
||
The remaining repaint costs in the canvas drawing pipeline, ranked by expected payoff, and what
|
||
each fix needs. The image cache and off-screen culling that already exist are described in
|
||
[`../adr/canvas-rendering-cache-and-culling.md`](../adr/canvas-rendering-cache-and-culling.md),
|
||
which also lists their own open issues. This plan covers everything else.
|
||
|
||
How a repaint flows: `CanvasPanel.paint` → `Presenter.draw` → `DrawingManager.drawProject`. That
|
||
calls `DrawingCache.bulkPrepare` (parallel), then runs the per-component loop through
|
||
`DrawingCache.draw` / `G2DWrapper` → `component.draw()`.
|
||
|
||
## 0. Measurements
|
||
|
||
**How to measure.**
|
||
|
||
- **Recording.** Add `-XX:StartFlightRecording=duration=60s,filename=drag.jfr` to the run
|
||
configuration, then drag, scroll, zoom and edit a complex layout.
|
||
- **Reading it.** `jfr view hot-methods drag.jfr`, `jfr view gc drag.jfr` and
|
||
`jfr view allocation-by-site drag.jfr` give the overview. For DIYLC call sites, export the samples
|
||
with `jfr print --json --events jdk.ExecutionSample --stack-depth 96` (or
|
||
`jdk.ObjectAllocationSample`) and group them by the innermost `org.diylc` frame.
|
||
- **Before and after a change,** compare the `DrawingManager` "Page stats" average at the same zoom
|
||
and viewport, plus the `DrawingCache` lines (see the ADR, §5). Compare total time, not per-type
|
||
counts, because culled components are not counted.
|
||
|
||
**Profile of 2026-10-14.** Complex layout, wires cached, culling and invalidation-based caching in
|
||
place. 750 CPU samples over 60 s, so treat the shares as approximate.
|
||
|
||
| Share | Where | Item |
|
||
| --- | --- | --- |
|
||
| ~52% of CPU samples | `TwistedWire.recalculate` and the `Area` operations under it, mostly on the `bulkPrepare` workers (fixed, see §7) | §7 |
|
||
| ~7% of CPU samples | `G2DWrapper.appendShape` recording `TwistedWire`'s outline (`currentArea.add`) | §2a |
|
||
| 16% of CPU samples | Marlin rasterisation (`Renderer._endRendering`): the drawing itself | — |
|
||
| ~19% of EDT samples | `Presenter.findAllGroupedComponents`, from `Presenter.draw` and `mouseMoved` | §6 |
|
||
| 958 MB / 60 s | pixel buffers for cache renders | — |
|
||
| 426 MB / 60 s | grid image rebuilt on zoom changes (fixed, see §3) | §3 |
|
||
| 386 MB / 60 s | canvas back buffer re-created on every scroll step (fixed, see §5) | §5 |
|
||
|
||
GC pauses totalled 348 ms. Most were under 40 ms, with one young-generation pause of 115 ms during
|
||
heavy allocation.
|
||
|
||
**Profile of 2026-10-14, later (P4),** mostly dragging parts with twisted wires attached: 707 CPU
|
||
samples, 383 of them `TwistedWire` geometry on the `bulkPrepare` workers, and 4.75 GB of cached
|
||
images allocated in 60 s. Wires stuck to a dragged part kept the `NORMAL` state, so every frame
|
||
re-rendered them into a new cache image. Moved-along components now bypass the cache (ADR §2).
|
||
The geometry itself still runs on every frame, because the wire's shape really changes; see §7.
|
||
Grouping fell to 12 samples after §6, and the per-component overhead (now under "Not worth
|
||
optimizing now") showed 1 sample.
|
||
|
||
**P5 (stroke-drawn twisted wires, moved components bypass the cache):** 248 CPU samples against
|
||
707 in P4 for a similar drag session; `TwistedWire` geometry 35 samples (was 383), workers 70
|
||
(was 537), total allocation 2.3 GB (was 7.4 GB), cached images 0.98 GB (was 4.75 GB), longest GC
|
||
pause 42 ms (was 71 ms). Recording areas was then the largest cost, about 37% of samples, mostly
|
||
for the moved wires on every frame; §2a addresses it.
|
||
|
||
**P6 (lazy area merging):** 174 CPU samples for a similar drag session; area recording 0 (was
|
||
92), on-demand merges from clicks and hovers 6, total allocation 1.45 GB, longest GC pause 13 ms.
|
||
The cursor check briefly cost 17 samples recomputing outline bounds on every mouse move, fixed by
|
||
caching them in `ComponentArea`. Rasterising (Marlin) is now the largest cost at about a third of
|
||
the samples, so the remaining gains come from drawing less: §2b and §5. Image copies measured 3
|
||
samples, which puts §4 low.
|
||
|
||
**Baseline before culling,** from the same layout: 327 repaints averaging 26 ms; `HookupWire`
|
||
(then uncached) about 9 ms per repaint; `TwistedWire` about 96 ms per render; `TubeSocket` 572 ms
|
||
in `DrawingManager` against 25 ms of image pasting.
|
||
|
||
## 1. Reflection on every edit
|
||
|
||
**Done:** the per-repaint reflective comparison is gone. `DrawingCache` now relies on `Presenter`
|
||
invalidating changed components; see the ADR, §2.
|
||
|
||
**What is left.** Reflection now runs once per edit instead of on every repaint. Each edit:
|
||
|
||
- clones the whole project (`AbstractComponent.clone()` walks the fields with reflection);
|
||
- runs `Project.equals`;
|
||
- runs `Presenter.invalidateChangedComponents`, which compares every component with its snapshot.
|
||
|
||
All three use the same `getDeclaredFields()` / `setAccessible` / `Field.get` walk. It is felt
|
||
mostly on repeated edits, such as holding an arrow key to nudge a large project.
|
||
|
||
**Fix.** Cache each class's comparable fields once, with `setAccessible` already applied, in a
|
||
`ClassValue`, and use it in both `equalsTo` and `clone`. Behaviour stays identical. Unit-test the
|
||
new `equalsTo` against the current one on a few real components from `diylc-library`.
|
||
|
||
**Watch.** Missed invalidation paths are now a correctness risk. Run with
|
||
`-Dorg.diylc.verifyDrawingCache=true` while testing new edit operations or plugins that change
|
||
components.
|
||
|
||
## 2. Dragging redraws everything
|
||
|
||
Each drag step invalidates the moved components, and the next frame redraws the whole visible
|
||
project.
|
||
|
||
**2a. Area recording during drags: done.** Recording areas used to merge every tracked shape into
|
||
an `Area` as it was drawn, which cost the most for the components moved on every frame. Recording
|
||
now keeps copies of the shapes, and `ComponentArea` merges them pairwise the first time an area is
|
||
asked for (ADR §1). `debugAreaPerformance` keeps an eagerly merged outline of its own for its check,
|
||
and `Snapshot`, the test recorder, now stores shapes rather than areas in its `ComponentArea`s.
|
||
|
||
**2b. Draw the drag over a frozen background.**
|
||
|
||
- At drag start, render everything that is not moving into a background image once. Each frame
|
||
then pastes that image and draws only the moving components, the control points and the
|
||
selection.
|
||
- Re-render the background when the viewport scrolls or the drag ends.
|
||
- **Stacking order:** a moving part that sits under other parts would draw on top of them while
|
||
dragging. Either accept that for the length of the drag, or split the background into a layer
|
||
below the lowest moving component and a layer above it, and draw the moving parts between them.
|
||
- Components that move along with the dragged ones without being selected (stuck components) must
|
||
count as moving. `Presenter` knows which these are from the control-point map.
|
||
- This is the biggest structural win for dragging, and the most invasive change in this plan. It
|
||
touches `CanvasPanel`, `Presenter.draw` and `DrawingManager.drawProject`.
|
||
|
||
## 3. Grid: done
|
||
|
||
`DrawingManager.drawGrid` used to cache the grid as one image the size of the whole canvas at the
|
||
current zoom. That was up to ~190 MB at high zoom, outside the cache budget, and 426–1,041 MB of
|
||
grid images were allocated per profiled minute of zooming. It was rendered at 1× and stretched on
|
||
HiDPI, and its rebuild check ignored grid spacing, dot spacing and theme.
|
||
|
||
It now lives in `GridRenderer` (tested by `GridRendererTest`), which draws the lines and dots
|
||
directly, and only those inside the visible rect:
|
||
|
||
- **Antialiasing off for the grid.** The lines are axis-aligned, so each one snaps to crisp whole
|
||
pixels instead of being smeared across two. The thickness formula is unchanged: 1 px at zoom
|
||
≥ 1, and `zoom` px below that.
|
||
- **Dense grids are thinned.** When lines would be closer than `GridRenderer.MIN_GRID_LINE_SPACING` (3 canvas
|
||
pixels at the current zoom), only every 5th line is drawn, then every 25th, and so on. Snapping
|
||
still uses the real spacing. A 0.5 mm grid shows every line at 100% and every 5th line from
|
||
75% down.
|
||
- **Exports and printing** pass no visible rect and get the whole grid as vector lines, instead of
|
||
a raster image embedded in the PDF or SVG.
|
||
|
||
One viewport of a 0.5 mm grid draws in under a millisecond. There is no cached state any more, so
|
||
the grid cannot go stale.
|
||
|
||
## 4. Image formats force slow compositing
|
||
|
||
- **The canvas back buffer** (`CanvasPanel.createBufferImage`) is `TYPE_INT_ARGB`, though the
|
||
canvas is always filled with an opaque background. Use an opaque compatible image,
|
||
`getGraphicsConfiguration().createCompatibleImage(w, h, Transparency.OPAQUE)`, kept HiDPI-scaled
|
||
as `ScaledBufferedImage` does now, so that copying it to the screen takes the fast path.
|
||
- **Cached component images** (`DrawingCache.renderAndCache`) are `TYPE_INT_ARGB`, so every paste
|
||
blends each pixel without premultiplied alpha. `TYPE_INT_ARGB_PRE` blends faster. Confirm that
|
||
`ScaledBufferedImage` and anything else reading the pixels is fine with the format.
|
||
|
||
Each is a one-line change, but Java2D's speed varies by pipeline (Metal on macOS), so keep each
|
||
change only if the page-time comparison shows a gain.
|
||
|
||
## 5. Every paint redraws the whole visible area
|
||
|
||
**Done:** the back buffer used to be thrown away on every scroll-bar event. `CanvasPlugin` called
|
||
`invalidateCache()` from both scroll listeners, which allocated 386 MB in the profiled minute.
|
||
`CanvasPanel.paint` now re-creates the buffer only when its pixel size changes, which includes a
|
||
change of HiDPI scale.
|
||
|
||
**Problem.** `CanvasPanel.paint` ignores the clip that Swing passes in. It always redraws the full
|
||
visible rect into a back buffer sized and positioned to the viewport. Scrolling, which only
|
||
exposes a thin strip, and partial repaints after a tooltip or balloon closes, both pay for a full
|
||
redraw.
|
||
|
||
**Fix.** Redraw only `g.getClipBounds()` (intersected with
|
||
the visible rect), passing that as the visible rect so that culling and the cache limit the work.
|
||
The buffer has to survive scrolling: either shift its contents and redraw the exposed strip, or
|
||
make it larger than the viewport. Moderate work. Do it after item 3, since the grid currently
|
||
assumes it draws the whole canvas.
|
||
|
||
## 6. Grouping and locking bookkeeping: done
|
||
|
||
`Presenter.draw` used to rebuild group membership and the locked-component set on every repaint,
|
||
and `mouseMoved` looked groups up on every move: about a fifth of the EDT's samples in all three
|
||
profiles. Both now come from `GroupAndLockIndex` (tested by `GroupAndLockIndexTest`), which works
|
||
them out once per project state:
|
||
|
||
- group membership is indexed by component id, so a component outside the project, such as one
|
||
being placed, resolves the same way as before; the sets it hands out are read-only;
|
||
- `Presenter` calls `invalidate()` from `notifyProjectModifiedIfNeeded`, `loadProject`, paste
|
||
(which copies the clipboard's groups straight in), `ungroupComponents`, `setLayerLocked` (which
|
||
updates the selection before notifying) and `lockComponent`;
|
||
- asking about a different project object rebuilds the index without an explicit invalidation.
|
||
|
||
**Watch.** Code that changes the current project's groups, locks or component list without going
|
||
through `Presenter` would leave the index stale until the next edit. Nothing does today; plugins
|
||
change the project through `IPlugInPort`, and `LoadlineEditorFrame` builds its own project.
|
||
|
||
## 7. Individual components
|
||
|
||
**`TwistedWire`: done in two steps.**
|
||
|
||
1. `recalculate` used to build and cut each lead by growing one area segment by segment and
|
||
cutting every crossing out of the whole lead, so its cost grew with the square of the wire's
|
||
length. The segment areas are now merged pairwise and each kind of crossing is cut in one
|
||
operation. That also removed a data race (the parallel crossing loop read one lead while
|
||
another thread subtracted from it) and a bug that left the last crossings near the end of many
|
||
wires uncut.
|
||
2. P4 still showed the geometry on every frame for wires whose ends move with a drag. Opaque
|
||
wires are now drawn with strokes and no `Area` operations: per lead, a dark stroke one pixel
|
||
wider than the lead and a coloured one a pixel narrower give the outline, the stripe is a
|
||
dashed stroke on top, and redrawing the first lead clipped to its own crossing squares puts it
|
||
on top there. `buildLeadGeometry` produces the lead paths and squares once per shape change and
|
||
is cached in `leadGeometry`. The first stroke of each lead is the one recorded as the
|
||
component's area.
|
||
|
||
Translucent wires (alpha below 100%) keep the area path, because strokes paint some pixels twice
|
||
and that would show through; its output is pixel-identical to before. On eight test renders the
|
||
stroke path is about 8x faster (97 ms against 772 ms). Opaque output matches apart from
|
||
antialiasing along the edges and a slightly finer outline, and selected wires lose faint seams the
|
||
area cuts used to leave. Stroking a lead as one path whose segments are separate sub-paths is fine:
|
||
stroke outlines are filled with the non-zero rule, so overlapping segments merge.
|
||
|
||
**`HookupWire`** is now cached and no longer shows up in the profile.
|
||
|
||
## Not worth optimizing now
|
||
|
||
- **Per-component overhead in the drawing loop** (formerly §7): the stats key built with
|
||
`getCanonicalName().replace(...)`, the `G2DWrapper` allocations on draws that record nothing, and
|
||
the `LOG.warn("Empty outline area detected ...")` in `DrawingCache.renderAndCache`. P3 and P4
|
||
show it at 1 sample each. The warning still breaks the no-logging-on-hot-paths rule and can go
|
||
whenever that file is next touched.
|
||
|
||
- **Mouse-move handling.** `Presenter.mouseMoved` walks every control point twice per move, but it
|
||
does not repaint. It only becomes worth indexing at much larger project sizes.
|
||
- **Type lookups and locking.** The `ComponentProcessor.extractComponentTypeFrom` lookups (three
|
||
per component per repaint) and the uncontended `synchronized (this)` around each component in
|
||
the drawing loop are cheap.
|
||
|
||
## Order of work
|
||
|
||
| Step | Item | Size | Risk | Needs |
|
||
| --- | --- | --- | --- | --- |
|
||
| 1 | §4 image formats | small | low | page-time comparison on macOS |
|
||
| 2 | §2b frozen drag background | large | medium | manual drag testing incl. stacking order |
|
||
| 3 | §5 redraw only the damaged area | medium | medium | scroll and tooltip testing |
|
||
| 4 | §1 cached reflection metadata for `equalsTo` / `clone` | small | low | unit test for `equalsTo` |
|
||
|
||
Re-profile next: the ranking of the remaining steps still comes from reading the code.
|