|
|
Donner SVG 0.8.0-pre
SVG editor and embeddable C++20 engine.
|
Status: Draft Author: Claude Fable 5 Drafted by: Claude Opus 5 Created: 2026-08-18
Donner renders on one thread. That is the right default and this design does not change it. But a filtered document is not shape-bound, it is filter-bound: on a reference multi-core x86-64 Linux host at -c opt, a single 900x900 CPU filter node costs 31 to 36 ms, and a two-node blur-plus-diffuse-lighting graph on the same buffer costs 112 ms. One node of a filtered frame therefore costs about as much as the entire Ghostscript Tiger frame that 0060 is built to make cheap. No amount of caching helps here, because the work is not repeated, it is genuinely new pixel math over a large buffer.
This design adds optional, config-gated multithreading to the CPU filter path, and nothing else. The single unit of concurrency is a row band of one filter node's raster: a large filter primitive's output rows are split across workers, each worker computes the same per-output-pixel function it computes today, and the filter graph's node order and semantics are untouched. A working prototype of this decomposition over the unmodified convolveMatrix primitive measured a 4.41x speedup at 8 threads with zero bit-differing floats against the whole-buffer result (see "Measurements").
Two supporting decisions make the feature safe to own:
Two things this design explicitly does not build, with the reasoning recorded so it is not re-litigated: Geode command-encoding parallelism, and render-tree branch parallelism. Both are in "Recorded Do-Not-Build Decisions".
RendererTinySkia builds a filter layer, hands the layer's pixmap to ApplyFilterGraphToPixmap (donner/svg/renderer/FilterGraphExecutor.cc), which converts Donner's FilterGraph into the pixel-space graph the CPU filter library executes, and then calls tiny_skia::filter::executeFilterGraph. That function walks graph.nodes in order. Each node allocates a full-size FloatPixmap, runs its primitive over the whole buffer, applies subregion clipping, and publishes the result as the next node's input.
The important structural fact is that every node's cost is proportional to the full buffer area, not to the node's subregion. createTransparentFloat(w, h) sizes every intermediate at the layer's full extent, and the primitives loop for (int y = 0; y < h; ++y) for (int x = 0; x < w; ++x). A filtered document at editor resolution therefore pays several full-buffer passes per filter, and those passes are the frame.
Measurements on a reference multi-core x86-64 Linux host at -c opt, median of nine, 900x900 float buffers, taken with a scratch harness built against the in-tree filter library:
| Operation | Time |
|---|---|
| gaussianBlur sigma 2.0 | 33.0 ms |
| gaussianBlur sigma 8.0 | 31.8 ms |
| gaussianBlur sigma 24.0 | 31.4 ms |
| diffuseLighting, point light | 34.8 ms |
| convolveMatrix 3x3 | 36.1 ms |
| morphology dilate radius 6 | 34.6 ms |
| executeFilterGraph, feDropShadow | 94.4 ms |
| executeFilterGraph, blur + diffuse light | 112.2 ms |
Three conclusions follow directly, and each one shapes the design.
Blur cost is independent of sigma. 33.0, 31.8, and 31.4 ms across a 12x sigma range. The running- sum box blur is O(1) per pixel per pass, so blur cost is a function of pixel count alone. Filter cost scales with area, which is exactly the quantity a row split divides.
One node is the frame. A single node at 31 to 36 ms is comparable to the whole Ghostscript Tiger frame 0060 measures at about 41 ms. Making the second frame of an unchanged filtered document cheap does not help, because a filter node's output is not reusable across a zoom or a parameter change; the work is new each time. This is why the centerpiece is intra-node parallelism and not another cache.
Node kernels are the majority but not all of the graph. The two-node chain costs 112.2 ms while its two kernels cost about 67 ms. The residual is buffer allocation, the uint8-to-float conversion, the sRGB-to-linear entry conversion and the linear-to-sRGB exit conversion, and subregion clipping. Those are all per-pixel passes over the same buffers, so they tile under exactly the same rules, and the design includes them rather than leaving 40 percent of the graph serial.
Parallelism could in principle be extracted at three levels: across filter graphs, across nodes within a graph, or within a node.
Across graphs is out of scope: filter layers are pushed and popped inside a single ordered draw traversal, and lifting them out is a rendering-order change, not a threading change.
Across nodes is nearly always unavailable. executeFilterGraph maintains previousOutput and a namedBuffers map; the overwhelmingly common graph is a chain where node N+1 consumes node N. Independent branches do exist (a feMerge of two chains, an feComposite of two inputs), but they are the minority, the branches are usually unequal in cost so the join wastes a worker, and the win is capped at the branch factor. Intra-node tiling, by contrast, applies to every node of every graph and its parallelism is bounded by buffer height, not by graph shape.
Within a node is therefore the seam, and it is also the safest one: it changes no data flow at all. The node's inputs are the same buffers, its output is the same buffer, and the only thing that changes is which thread computes which output rows.
Every CPU filter primitive today has the shape f(const Src& src, Dst& dst, params) and writes every row of dst. The change is to add an explicit output row range [yBegin, yEnd) while still passing the whole source buffer:
Passing the whole source and restricting only the output is the load-bearing decision, and it is what makes the apron a non-problem:
The prototype in "Measurements" deliberately used the harder variant, staging each band into its own buffer with an apron, precisely to prove the apron reasoning is sound; that variant still produced zero bit differences, and its band-copy overhead (a 12 percent regression at one thread) is the cost the row-range API removes.
Bands are always horizontal, spanning the full buffer width. The split axis is chosen per primitive so that it is an axis along which the algorithm carries no state. This is the only per-primitive judgement the design requires.
| Class | Primitives | Split axis | Source rows a band reads | Carried state |
|---|---|---|---|---|
| Per-pixel pure | flood, colorMatrix, componentTransfer, composite, blend, merge, offset, tile, turbulence, subregion clipping, srgbToLinear / linearToSrgb on FloatPixmap | output rows | the same rows (offset and tile read a fixed translation of them) | none |
| Bounded gather | convolveMatrix, diffuseLighting, specularLighting | output rows | [yBegin - up, yEnd + down); convolve uses up = targetY, down = orderY - 1 - targetY; lighting uses 1 and 1 for its 3x3 normal | none |
| Displacement-bounded gather | displacementMap | output rows | the band's rows extended by half the displacement scale on each side, clamped to the buffer | none |
| Separable multi-pass | gaussianBlur, morphology | rows of the pass currently executing | the same rows; every pass reads and writes one row at a time | a running sum or a van Herk block accumulator along x, within one row |
| Reduction | computeNonTransparentBounds | output rows | the same rows | a per-band min/max, combined in fixed band order |
The separable multi-pass row deserves the most care, because it is the one place a naive tiling would be wrong. boxBlurHorizontalFloat carries a Vec4f32 sum across the x loop, adding the entering pixel and subtracting the leaving one. Restarting that accumulator part-way along a row would produce a different sequence of float additions and therefore, legitimately, different bytes. So a row is never split. Each band owns whole rows, and the accumulator is re-initialized per row exactly as it is today. The vertical pass is implemented as transpose, horizontal pass, transpose back, so banding the transposed pass by transposed rows is banding the original buffer by columns, which is again an axis with no carried state. The transposes themselves are a blocked copy with disjoint reads and writes and band trivially.
morphology's van Herk pass allocates its fwd / bwd scratch once outside the row loop and reuses it per row. That scratch becomes per band. Blur's buffer and scratch are whole-buffer and are written disjointly by rows, so they stay shared.
Passes are separated by a join, not by a lock. Pass N+1 reads the whole output of pass N, so the pool joins all of pass N's bands before submitting pass N+1. That is the dependency edge; there is no other synchronization inside a primitive.
The determinism argument has four parts and each one is checkable in the tree:
The band grid is a pure function of the buffer height and a fixed band height, not of the worker count. Worker assignment is dynamic, so a busy machine and an idle machine produce different schedules and identical pixels. Fixing the grid independently of worker count also keeps a debugger session reproducible and makes per-band scratch sizing predictable.
The color-space conversions are the largest non-kernel cost in the graph, and they are already structured for this. ColorSpace.cpp builds its four transfer tables in function-local statics (srgbToLinearFloatLut() and friends) and the file already documents that C++11 function-local static initialization is thread-safe, so a concurrent first caller is fine. After initialization the tables are const and are only read. lookupUnit clamps its input before indexing, including mapping NaN to zero, so there is no input-dependent control flow that could differ per thread.
The conversions therefore need no change beyond the row range: they are per-pixel pure over a read-only table. Concretely, srgbToLinear(FloatPixmap&) and linearToSrgb(FloatPixmap&) gain a row range and are driven by the same band loop as any other pass.
One rule is worth writing down because it is easy to break later: any future quantized ramp, cached conversion, or memoized table must be immutable after construction, or built before the first band is submitted. A lazily-populated per-call cache mutated from a worker would be both a race and a determinism hazard, and it would not necessarily fail a single-threaded test.
SimdVec.h selects NEON, Wasm SIMD128, SSE2, or a scalar fallback at compile time, and Vec4u32 / Vec4f32 are value types over local registers with no global or thread-local state. Banding therefore does not interact with vectorization at all: each band runs the same instruction mix on its own rows.
Two consequences are worth stating:
Identity is asserted within one build configuration, never across ISA branches. Cross-ISA byte differences already exist by design (FloatPixmap::fromPixmap documents one such deliberate NEON/scalar divergence) and are outside this design's scope; what this design owns is that a given configuration produces the same bytes at every worker count.
The pool is a renderer-scoped service with one owner, following the structured-concurrency contract 0060 sets out for its prepare stage:
Failure behaviour is fail-safe rather than fail-closed: if the pool cannot be created (no thread support, a platform limit, a configuration error), the renderer runs the zero-worker inline path and renders correctly, more slowly. A rendering feature must not refuse to render because a performance optimization is unavailable.
A bool_flag plus config_setting in donner/svg/renderer/BUILD.bazel, following the existing :filters / :text / :renderer_backend pattern, for example --//donner/svg/renderer:render_worker_pool. With the flag off, which is the default, the pool compiles to the inline path and no threading header is included and no threading primitive is linked.
Worker count is a runtime setting on the renderer's options, defaulting to zero. Compile-time gating and runtime count are separate on purpose: the flag decides whether the machinery exists, the count decides whether it is used, and CI can build the flag on while most targets still run at zero workers.
The plain wasm configuration does not pass -pthread, and the design keeps the worker pool compiled out there: a browser build without cross-origin isolation must keep working, and the CPU filter path must not become a second reason to require it. The editor-wasm configuration does pass -pthread, so it can host the pool, but the runtime worker count still defaults to zero and the renderer must probe for usable concurrency rather than assume it: a build with pthread support served without cross-origin isolation cannot actually spawn workers, and that case must land on the inline path, not on a failed render.
The cooperative structure (joinable bands, idempotent joins, inline submission) compiles and runs unchanged in every Wasm configuration, because the zero-worker mode is the same code path.
ApplyFilterGraphToPixmap gains an optional pool pointer; a null pool is the current behaviour. The primitives gain their RowRange parameter. Nothing in the public SVG API changes.
All numbers are -c opt on a reference multi-core x86-64 Linux host, median of nine repetitions, taken with a scratch harness linked against the in-tree filter library. They are reproduced here as the motivation and the acceptance baseline; the benchmark target in Milestone 4 makes them re-runnable.
Concurrency headroom, running the same primitive on N disjoint 900x900 buffers from N threads. This bounds what banding one buffer can reach on this machine, because it is the same instruction mix against the same memory system with no shared state:
| Workers | diffuseLighting wall | per buffer | gaussianBlur wall | per buffer |
|---|---|---|---|---|
| 1 | 36.8 ms | 36.8 ms | 22.5 ms | 22.5 ms |
| 2 | 37.2 ms | 18.6 ms | 23.9 ms | 12.0 ms |
| 4 | 37.4 ms | 9.3 ms | 27.9 ms | 7.0 ms |
| 8 | 37.8 ms | 4.7 ms | 43.6 ms | 5.5 ms |
Lighting is compute-bound and holds 7.8x throughput at eight threads. Blur is memory-bandwidth-bound (streaming loads and stores plus two transposes) and reaches about 4.1x. That asymmetry is expected and it is the honest ceiling: blur will scale worse than the gather primitives, and the goal targets the gather-shaped ones.
End-to-end banding prototype, convolveMatrix 3x3 over a 900x900 float buffer, bands staged with an apron and executed on plain threads, compared byte-for-byte against the whole-buffer result:
| Configuration | Time | Speedup | Bit-differing floats |
|---|---|---|---|
| serial, whole buffer | 35.0 ms | 1.00x | reference |
| banded, 1 thread | 39.9 ms | 0.88x | 0 |
| banded, 2 threads | 21.6 ms | 1.62x | 0 |
| banded, 4 threads | 12.3 ms | 2.85x | 0 |
| banded, 8 threads | 7.9 ms | 4.41x | 0 |
| banded, 16 threads | 6.4 ms | 5.43x | 0 |
Zero bit differences at every thread count is the result that matters. The 0.88x at one thread is the prototype's band staging and copy-back, which the row-range API removes, so the shipped speedups should be modestly better than the table.
SVG input is untrusted, and filter parameters are attacker-influenced (sigma, kernel order, radius, buffer extent). This design adds no parsing surface and no new allocation that scales with input, but it does add a thread pool, so the boundaries are:
Every invariant below names the CI target that fails when it breaks, per this directory's rule.
The per-layer compositor compose fix is a named precondition for any compositor-layer parallelism, and it is not met. CompositorController::composeLayers walks segments and layers in paint order and blits each payload through one renderer whose paint state is carried across draws. drawPayload has to call setPaint(PaintParams{}) before every tile blit specifically because a preceding direct-render can leave a group's opacity on the renderer, which then dims an already-composited tile; that is a real bug the reset exists to prevent. Per-layer opacity and blend mode are not parameters of the compose blit at all today: subtrees that need them are either kept out of promotion or direct-rendered.
Two layers composed concurrently would therefore share mutable renderer paint state, and the sequential ordering that makes the reset correct would no longer exist. The fix is to consolidate compose into a single explicit blit operation carrying its own payload, transform, opacity, and blend mode, with no dependence on carried renderer state. Until that lands, compositor-layer parallelism is not on the table, and this design does not touch the compositor.
Compositor tiling is deferred to a follow-up design doc. Splitting the composed frame into screen tiles interacts with layer promotion, damage tracking, the split background/foreground fast path, and the ordering rules above. It is a larger problem than intra-node filter tiling and it should not ride along on this doc's determinism argument, which is specific to pure per-pixel functions over immutable inputs.
Scope of this doc: CPU filter tiling only. That is the whole of it.
These are conclusions, recorded with their reasoning so they do not have to be rediscovered.
Decision: no. The encoder is not the bottleneck, and cross-thread encoding would break a documented correctness invariant.
Not the bottleneck. Since path residency and cross-entity batching landed, a steady-state Geode frame is served from resident state rather than from encoding. GeodeCounters documents the targets directly: pathEncodes is "`== 0` on an unchanged-geometry frame (the `GeodePathCacheComponent` serves all paths)", bufferCreates is "`== 0` on an unchanged-geometry frame", textureCreates is "`== 0` on repeat-render at the same size", and submits is "`== 1` per frame regardless of layer/filter/mask push depth". GeodePerf_tests.cc asserts against those ceilings. Parallelizing a stage whose steady-state work is already near zero buys nothing, and the first frame that does encode is dominated by upload and pipeline creation rather than by CPU encoding.
It would break the write-after-record invariant. Geode publishes per-draw parameters by writing into buffers and then recording draws into one command encoder submitted once per frame. Buffer writes are queue-ordered ahead of every draw in that submit, so the last write to a slot wins for every draw in the frame, regardless of where the draw was recorded. The encoder states this explicitly at the point where the solo resident path decides whether to write a slot's uniform: a scene-form write issued after a recorded solo draw "would retroactively change that draw's uniform (last write wins at submit time)". The solo path avoids the hazard by binding a shared identity record and never writing a slot a same-frame repeat could overwrite; the batch path avoids it by giving each instance its own record slot.
That invariant is a property of a single, ordered encode stream. Two threads encoding into the same frame would interleave writes and draws with no defined order between them, and the failure mode is not a crash or a race a sanitizer reports: it is a draw silently rendering with another draw's parameters. On top of that, the supporting structures are documented single-threaded by design (GeodeBufferPool: "Not thread-safe; all use is on the renderer's thread"; GeodeResidentSlab: "Not thread-safe: allocate/free/beginFrame mutate the free list and bump cursors without locking. The renderer serializes one frame per device at a time, so a document's slab is only touched from one thread"). Making them thread-safe would add synchronization to the exact path the residency work made cheap.
Decision: no. The ECS registry does not tolerate structural mutation concurrent with view iteration, which is a hard constraint, and the task shape did not pay for itself on the corpus, which is recorded below as a design assumption rather than as a re-runnable measurement.
The precise constraint. Component storage is paged, so inserting a component never relocates existing ones. Erasing one is swap-and-pop: the storage moves its last element into the freed slot. The renderer relies on this today, and the tree documents why it is dangerous. PathShape is a borrowed view, and RendererInterface.h records the consequence: a pointer into a component erased while borrowed "does not dangle into freed memory, it silently starts naming a different entity's geometry. That renders the wrong shape with no crash, and neither ASAN nor a sanitizer build will flag it." The stated precondition is that no component of that type is erased between the borrow and its last use, and it holds only because every removal site runs outside draw traversal.
Draw traversal is not free of structural mutation either. RendererDriver snapshots the entity slice into a std::vector<Entity> before traversing precisely because preRenderFeImageFragments emplaces into and sorts RenderingInstanceComponent storage, "which would invalidate a live-iterating view". So the current single-threaded design already has to sequence structural mutation against iteration by hand.
Branch-level tasks would put concurrent iteration and concurrent structural mutation into the same registry, against exactly this storage model. The failure mode is the worst kind: correct-looking pixels from the wrong entity, invisible to ThreadSanitizer because the swap-and-pop is a well-synchronized write to memory the reader is legitimately allowed to read. The tree already carries the conclusion this leads to: a --config=tsan build exists and is run against the document concurrency and render-snapshot tests, and what 0033 shipped in response to this class of problem was scoped read/write access guards plus immutable render snapshots, not finer-grained locking inside the registry. The ECS boundary is held by construction because instrumentation cannot see the failure.
And the win was not there. Branch-level task shapes produced no measurable improvement on the corpus, because per-branch cost is small relative to task submission and join overhead: a typical subtree is tens of microseconds of shape drawing, against a filter node's tens of milliseconds. The parallelism worth having in a Donner frame is concentrated inside a few very large leaves, which is what this design targets, and not spread across many small branches. This last point is a measurement whose harness does not exist in-tree; it is recorded here as a design assumption and the Milestone 4 benchmark is the place to falsify it cheaply if anyone wants to revisit.
Stated as assumptions rather than facts, and each names where it gets settled: