Files
DarkRoom/docs/technical-debt.md
T
dtourolleandClaude Opus 5 2e6825ded0 Record what the measurement found, where the next person will look for it
Three places, because the finding has three audiences.

The display spec's §1 table said FR-DSP-3 was unmeasured and FR-DSP-5 untagged.
Both are now false, and §2's decision rule has fired. The body of §2 is left as
written with the verdict quoted above it: a plan overtaken by its own evidence
reads better in order than quietly edited into agreement with the outcome.

TD-4 is the stage that misses the budget. Clarity's kernel is a fraction of the
frame, so it reaches a 52-pixel radius at 4K and costs 34 ms — seven times the
entire fused chain, for one slider. It is debt rather than a bug because the
detail stage cannot yet write a target smaller than it reads, which
`local_contrast`'s own documentation has said since it was written. The entry
says plainly that tiles are the wrong tool for it, since that is exactly the
conclusion a reader arriving from ARCH §5.3 would otherwise draw.

TD-5 is the one nobody was looking for: composing the fused shader costs
2.8–5.2 ms of CPU per frame on a full chain, on the UI thread, which at
1920x1200 is more than the dispatch it precedes. The source depends only on the
graph's structure — what `structure_hash` already identifies and what does not
move during a drag — so the fix is the cache `AdjustPass` already keeps for
compiled pipelines, one level up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:06:19 +02:00

248 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# DarkRoom — Technical debt
**Status:** Living document · first written 2026-08-26
**Companion to:** [architecture.md](architecture.md)
Deliberate compromises: things the code does knowing they are wrong, because the alternative was
worse at the time. Each entry says what the debt is, what it cost to take on, what it would take to
pay off, and how you would know it had been paid.
Not a bug list. A bug is something nobody chose. Everything here was chosen, and the point of
writing it down is that the reasoning outlives whoever chose it — so the next person can tell a
constraint from an accident, and does not "fix" something load-bearing or preserve something that
has quietly stopped being necessary.
---
## TD-1 — The Android develop view reads pixels back through the CPU
**Breaks:** [architecture.md §12 / 6.1](architecture.md) — GPU results never round-trip through the
CPU — and AC-8, on Android only. Desktop is unaffected and keeps the zero-copy path.
### What it does
`DevelopSession::render` on Android runs the compute passes on the GPU as usual, then calls
`AdjustPass::export_pixels` and hands the frame to Slint as a `SharedPixelBuffer`. That is exactly
the GPU→CPU→GPU transfer §6.1 exists to forbid, and it is on the frame path.
### Why
Zero-copy needs Slint to draw with wgpu. On Android that means wgpu's Vulkan swapchain, which
hardcodes `preTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR`
([gfx-rs/wgpu#3345](https://github.com/gfx-rs/wgpu/issues/3345)) — wgpu-hal says so in a comment
beside the line.
On a tablet whose panel is mounted landscape, a portrait window then hands Android an unrotated
buffer, every present returns `VK_SUBOPTIMAL_KHR`, and frames arrive torn. Measured on the device,
same build, only the tablet rotated:
| orientation | `bufferTransform` | composition | result |
|---|---|---|---|
| landscape | `ROT_180` | `DEVICE (2)` | clean |
| portrait | `ROT_270` | `CLIENT (1)` | torn |
Setting `preTransform` is not a fix available to us: the field is a *promise* that the content is
already rotated, so honouring it needs the renderer to rotate what it draws, which wgpu cannot do
on Skia's behalf.
So the choice was never fast-develop against slow-develop. It was a develop view that costs a
readback against a grid that tears in the orientation a tablet is mostly held in.
### What it costs
Less than §6.1's headline numbers, because `render` fits the pass to the canvas before it runs — the
readback is at viewport resolution, not sensor resolution. The 7.43 ms at 4K in §12 is the ceiling,
not the bill. **It has not been measured on the device**, which is the first thing to do if the
develop view feels heavy on the tablet; do not assume this is the cause without a number.
### Paying it off
Any one of these removes it:
- wgpu implements pre-rotation (#3345), and Android goes back on `unstable-wgpu-29`.
- Slint's Skia Vulkan surface handles `preTransform` and Android uses that instead of OpenGL.
- Skia over OpenGL grows a way to sample an external texture that wgpu can write.
**Done when:** `ui/dr-ui/Cargo.toml` no longer scopes `renderer-femtovg-wgpu` and
`unstable-wgpu-29` to non-Android, the `#[cfg(target_os = "android")]` arm of
`DevelopSession::render` is gone, and the tablet is clean in portrait.
---
## TD-2 — Thumbnails are fetched one at a time
**Where:** `library::spawn_thumbnails` — the `for req in to_fetch` loop.
### What it does
The interactive thumbnail batch fetches serially: one image at a time, and two HTTP round trips
each (a header read, then the preview's byte range). A window of a few hundred cells is that many
sequential round trips against the server.
### Why it is debt rather than a bug
It is correct, and it was fast enough when a window was one screenful. It is the *ordering* that
kept it survivable: since `fetch_rank`, on-screen cells are requested first, so the cells a person
is looking at arrive first even though the queue as a whole is slow.
Portrait makes it worse by construction — a narrow window means smaller cells, more rows, and two
to three times as many cells on screen at once, all of them ahead of the ones below in a queue that
never runs more than one request.
### Paying it off
`spawn_thumbnail_sweep` already has the pattern: `SWEEP_LANES` disjoint lanes over a chunk, joined,
with the store written on the one thread that owns it. Striping a *priority-ordered* chunk across
lanes keeps `fetch_rank`'s ordering while running several requests at once.
Not done yet because it multiplies concurrent requests against the user's Nextcloud during a
scroll, and that is a behaviour change worth deciding on deliberately rather than inheriting from a
performance fix.
**Done when:** the interactive batch runs on more than one lane, priority order is preserved
across the lanes, and a slow server still cannot stall the visible cells behind offscreen ones.
---
## TD-3 — The thumbnail drain applies an unbounded batch on the UI thread
**Where:** `library_ui::drain_thumbnails` — the `loop` inside the timer callback.
### What it does
Every message queued when the timer fires is applied in that one callback, with no ceiling. On a
library whose thumbnails are already in the store, the worker delivers a whole window at once, so a
single callback can do hundreds of `to_slint_image` calls back to back — each an allocation and a
full RGBA copy — while the grid is mid-flick.
The copy cannot move off the UI thread: `slint::SharedPixelBuffer` is not `Send`, so decoded bytes
can only become an `Image` on the thread that draws. Only the *amount done per wake* is ours to
choose, and right now it is "all of it".
### Cost
Measured with a temporary probe, **debug build**, so treat the shape rather than the size:
| class | per thumbnail | × a 280-cell window |
|---|---|---|
| grid, 256 px | 1.93 ms | 539 ms |
| large, 512 px | 7.78 ms | 2.18 s |
A release measurement was started and never completed — do not quote these as release figures.
### Paying it off
A time budget per wake and a shorter interval: apply for a few milliseconds, return without
stopping the timer, and finish on the next tick. A batch then lands in frame-sized slices rather
than one lump between two frames. Draft written and discarded during the investigation; it is a
small change.
**Done when:** one wake of the drain cannot exceed a frame, and a fully-cached window still fills
in well under a second.
---
## TD-4 — The local-contrast base is computed at full render resolution
**Where:** `dr_pipeline::ops::local_contrast::LocalContrast::passes` — the `base` and `combine`
passes, and the stage that dispatches them, `dr_pipeline::detail`.
Breaks **FR-DSP-3** at large viewports. Measured, and the numbers are in
[frame-budget.md](frame-budget.md) §M3.
### What it does
Clarity's Gaussian σ is 1.2% of the frame's shorter edge, truncated at 2σ, so its kernel radius is
a property of the *viewport*: 29 px at 1920 × 1200, 38 px at 2560 × 1600, **52 px at 4K**. The two
separable passes therefore run 105 taps each over 8.3 M pixels at 4K, which is 1.7 billion texture
reads for one control.
| viewport | radius | clarity alone, p99 |
|---|---:|---:|
| 1920 × 1200 | 29 | 5.99 ms |
| 2560 × 1600 | 38 | 12.44 ms |
| 3840 × 2160 | 52 | **33.89 ms** |
RTX 3050 laptop, `examples/frame_budget`, fused dispatch reused so this is the convolutions alone.
Clarity is 97% of the cost of all four neighbourhood operations together at every size.
For scale: the entire fused chain — every point operation active, film stock included — costs
4.5 ms at the same 4K viewport. **A single slider is seven times the rest of the pipeline.**
### Why
Because the stage cannot do otherwise yet. `dr_pipeline::detail` dispatches every pass at the
render size; there is no way to express "read this target and write a smaller one". The module's own
documentation has said so since it was written:
> The right optimisation is a base computed at reduced resolution, which needs a detail stage that
> can write a smaller target than it reads; that is a change to `crate::detail`, not to this file.
It was the right call to ship the correct answer slowly rather than a fast approximation nobody had
checked — the halo behaviour is the hard part of this operation and it is tested.
### Not a tiling problem
Worth saying because ARCH §5.3 offers a tile cache and this is the stage that looks like it wants
one. It does not: a tiled convolution reads a halo per tile, so at a 52-pixel radius, 256-pixel
tiles would read (256 + 104)² instead of 256² — very nearly **twice** the taps.
[display-and-extension.md](display-and-extension.md) §2's decision rule was resolved on this
evidence; see [frame-budget.md](frame-budget.md).
### Paying it off
A detail pass that declares an output scale, so the base can be computed at a quarter resolution and
sampled back up in `combine`. A quarter-resolution base is 1/16 the pixels at 1/4 the radius —
about **1/64 of the work** — and is visually identical, because a base at σ = 26 px holds no content
above the quarter-resolution Nyquist to lose. Texture's σ is a decade finer and must stay at full
resolution; the scale therefore belongs on the `DetailPass`, not on the stage.
**Done when:** clarity at 100% is inside the frame budget at 3840 × 2160, the halo tests in
`tests/local_contrast.rs` still pass unchanged, and `examples/frame_budget`'s M3 table in
[frame-budget.md](frame-budget.md) has been rerun and committed.
---
## TD-5 — The fused shader is reassembled from strings on every frame
**Where:** `dr_pipeline::operation::compose_full`, called from `DevelopSession::render`.
### What it does
`EditGraph::compose` walks the active operations and formats a WGSL source string, per frame, on
the UI thread. On a full chain that is **2.8–5.2 ms** — at 1920 × 1200 it is larger than the entire
fused dispatch it precedes, and on the chains that also carry a detail stage it is a third of what
is left of the 16 ms budget after the GPU has taken its share. It does not vary with resolution,
because it is not pixel work.
### Why
Because it was free until the chain got long. Composition was written when an edit was two or three
operations, and the cost is roughly linear in generated source: the colour mixer emits twelve hue bands
and the tone curve emits a spline evaluator, so a full chain is a large string built from scratch
sixty times a second.
### Paying it off
The generated source depends only on the *structure* of the graph — which is precisely what
`ComposedShader::structure_hash` already identifies, and precisely what does not change while a
slider is being dragged. `AdjustPass` relies on that already: it caches compiled pipelines against
that hash and does not recompile during a drag. Caching the source string against the same hash and
rebuilding only the uniforms — a handful of floats per operation — takes this to approximately
nothing on the path that needs it most.
The care needed is in what the hash covers. It deliberately excludes parameter *magnitudes*, so a
cache keyed on it is sound for the source and would be wrong for anything else in `ComposedShader`.
**Done when:** the `shader` column of [frame-budget.md](frame-budget.md)'s M1 table is under a
millisecond for the `point` and `all` chains, and the codegen tests still pass byte for byte.
---
## Related, and deliberately not here
The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed*
rather than deferred — see the commits around `6d6ef8d`. They are mentioned only so that a reader
looking for "why was the grid slow" finds the answer in the code and its comments rather than
assuming it is still outstanding.