[performance] Port single-value SKPMColor premultiply/unpremultiply to managed C##4385
[performance] Port single-value SKPMColor premultiply/unpremultiply to managed C##4385github-actions[bot] wants to merge 3 commits into
Conversation
SKPMColor.PreMultiply(SKColor) and UnPreMultiply(SKPMColor) round-tripped every single-colour conversion through a native P/Invoke (sk_color_premultiply / sk_color_unpremultiply) even though the work is a handful of integer ops. Port both bodies to managed integer math that is bit-exact with the native result: - PreMultiply: SkMulDiv255Round per channel (opaque colours pass through). - UnPreMultiply: a once-built 256-entry scale table (mirrors Skia's SkUnPreMultiply::gTable) + ApplyScale, so the managed path is a table lookup rather than a per-call divide. Only method bodies change (ABI-safe); the array overloads stay native (already one P/Invoke for the whole batch) and the operators are unchanged. Proven faster (BenchmarkDotNet New-vs-Old, AMD EPYC 7763, .NET 10, net10.0): PreMultiply ratio 0.75-0.77, UnPreMultiply ratio 0.74-0.81, zero allocations. Proven identical by SKPMColorEquivalenceTest: exhaustive bit-exact sweep of every (alpha x channel) against the native oracle for both conversions, plus round-trip and deliberately-wrong "teeth" guards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Research: is the
|
| Variant | 256 | 4096 | Ratio | Alloc/call |
|---|---|---|---|---|
| V1 – runtime table (this PR) | 384 ns | 6085 ns | 1.00 | 0 |
| V2 – direct divide (no table) | 476 ns | 7832 ns | 1.24–1.29 (slower) | 0 |
V3 – ReadOnlySpan<byte> RVA literal + MemoryMarshal.Cast<byte,uint> |
388 ns | 6131 ns | 1.01 (=) | 0 |
V4 – baked uint[] initializer |
384 ns | 6075 ns | 1.00 (=) | 0 |
Takeaways
- Don't replace the table with a per-call divide — it's ~25% slower. Skia's table choice is correct; this PR is right to keep one.
- Compile-time hardcoding (V3/V4) matches the runtime table per-call (within noise). There's no per-call win available — the hot path is already optimal. (Note: C# has no
const uint[]; the closest real "compile-time table" is aReadOnlySpan<byte>literal baked into the DLL data section, V3.)
Cost of building the table at runtime
- Steady-state: 169 ns + 1.02 KB heap.
- Cold first-call (incl. JIT): ~1.3–1.7 µs.
- It's lazy (
beforefieldinit), so it's only paid on firstUnPreMultiply, and ~1 µs ≈ the cost of ~40 unpremultiply calls.
Pros/cons of hardcoding
| V1 runtime table (current) | V3 ReadOnlySpan<byte> literal |
V4 baked uint[] |
|
|---|---|---|---|
| Per-call speed | baseline | identical | identical |
| One-time build | ~1 µs, lazy | none (mapped from DLL) | ~0 divides, still array copy |
| Permanent heap | 1 KB forever | 0 | 1 KB forever |
| Static cctor | yes (lazy) | none | yes |
| Endianness | safe | needs LE assumption + BE guard | safe |
| Readability | clean 5-line loop, mirrors Skia's round((255<<24)/a) |
MemoryMarshal.Cast + 1 KB of hex |
256 magic numbers |
Recommendation
Leave the table as V1. V3 only reclaims a one-time ~1 µs and a permanent 1 KB allocation, at the cost of MemoryMarshal + endianness handling + an opaque data blob — a footprint/startup micro-optimization, not a speed one. The readable loop that visibly mirrors Skia's algorithm is worth more. If AOT size or a "zero static allocations" goal ever makes the 1 KB matter, V3 is the right tool then.
Tests
The existing suite is sufficient — the exhaustive (alpha × channel) sweep across all three channel positions vs the native oracle (plus round-trip and the two teeth guards) drives the public API, so it would immediately catch a byte-order/endianness bug if the table representation ever changed. No additions needed.
(Variant micro-benchmarks were run in a throwaway project and are not part of this PR; happy to add a committed variant benchmark if useful.)
📦 Try the packages from this PRWarning Do not run these scripts without first reviewing the code in this PR. Step 1 — Download the packages bash / macOS / Linux: curl -fsSL https://raw.githubusercontent.com/mono/SkiaSharp/main/scripts/get-skiasharp-pr.sh | bash -s -- 4385PowerShell / Windows: iex "& { $(irm https://raw.githubusercontent.com/mono/SkiaSharp/main/scripts/get-skiasharp-pr.ps1) } 4385"Step 2 — Add the local NuGet source dotnet nuget add source ~/.skiasharp/hives/pr-4385/packages --name skiasharp-pr-4385More options
Or download manually from Azure Pipelines — look for the Remove the source when you're done: dotnet nuget remove source skiasharp-pr-4385 |
|
📖 Documentation Preview The documentation for this PR has been deployed and is available at: 🔗 View Staging Site This preview will be updated automatically when you push new commits to this PR. This comment is automatically updated by the documentation staging workflow. |
| private static uint[] CreateUnPreMultiplyScaleTable () | ||
| { | ||
| // Mirrors Skia's SkUnPreMultiply::gTable: round((255 << 24) / alpha), computed once. | ||
| // Replaces a per-call 32-bit divide with a table lookup, matching the native path. | ||
| var table = new uint[256]; | ||
| for (uint a = 1; a < 256; a++) | ||
| table[a] = ((255u << 24) + (a >> 1)) / a; | ||
| return table; | ||
| } |
There was a problem hiding this comment.
This allocates a new table on the first call. If it is identical to SkUnPreMultiply::gTable, can you retrieve that one for use in managed code?
Local benchmark — macOS (real hardware)Ran the committed Environment: Apple M3 Pro · macOS 26.5 · .NET 10.0.9 (Arm64 RyuJIT) · BenchmarkDotNet 0.13.5 · PreMultiply
UnPreMultiply
≈ 1.35× faster premultiply, ≈ 1.22–1.35× faster unpremultiply; zero allocation on both paths (the win is removing the per-call native P/Invoke). Side note from reviewing the unpremultiply scale table: I measured the one-time runtime table build at only ~1 µs / ~1 KB, and confirmed a per-call divide (no table) is ~25% slower — so keeping the runtime table is the right call. |
…4476) Add regression-tracking benchmarks for common colour/interop paths (#4476) Derived from: #4370, #4385, #4442, #4453, #4455 CI: Track - Benchmarks run 29608575980 (green, all OS/roles) The permanent regression-tracking suite (SkiaSharp.Benchmarks.Tracking) landed recently alongside a wave of perf/agentic PRs, but it had no coverage for the managed colour/interop paths those PRs actually touch. This adds a small, curated set of trackers so a future regression (or the improvement itself) shows up as a step in the persisted per-OS time/allocation history. Following the suite's scaled-pyramid philosophy (a few small common-path trackers plus one broad composite, not a micro-benchmark per PR), four benchmarks are added under Tracking/Benchmarks/ (auto-linked into the source-mode project, so they also drive the PR column): * ColorMathBenchmark - managed SKColor->SKColorF (#4370) and SKPMColor PreMultiply/UnPreMultiply (#4385). Zero-alloc; the ToColorF/ToColor ratio (the reverse operator stays native) is a managed-regression signal even on noisy shared runners. These two ports are the real, dashboard-visible speed wins - their time lines should drop when the ports merge. * RasterImageLifecycleBenchmark - create+dispose churn of SKImage.Create raster (#4455) and SKData.Create with a managed release proc (#4453). Allocation is the tracked signal for that object-lifecycle area. * RuntimeEffectShaderBenchmark - a per-frame animated SkSL shader: build uniforms (the SKColor uniform pays the #4370 convert), ToShader, draw. Covers the uniforms lifecycle whose SKData leak #4442 touched. * SceneRenderBenchmark - one broad frame (transforms, linear+radial gradients, filled/stroked primitives, a built path, a scaled image draw, a colour-filter layer, clipping, managed colour maths) so many unrelated future optimizations each nudge it - a merge indicator rather than a micro-benchmark. Scope notes: the failure-path leak fixes (#4453, #4455), the native uniforms leak (#4442), and the UAF/double-free fixes (#4372, #4468) are correctness/native-memory changes that a throughput + managed-allocation dashboard largely will not register; they are represented here by area, not as leak assertions (those belong in soak/ leak tests). #4372 and #4468 are intentionally not benchmarked (niche debug canvases; HarfBuzz lives in a separate assembly the tracking project does not reference). All benchmarks use public API only (the project references the nightly NuGet, not internal SkiaApi), are deterministic and machine-independent (fixed seeds, no fonts or external content), and compile against every benchmarked role - verified against the 3.119.4 baseline (prev-major) and 4.151 nightly, with only SKPath/SKPathBuilder behind the existing #if. A short local run and the full CI matrix (Linux/Windows/ macOS, all version roles plus the source-built PR column) emit time and allocation data for every case. Co-authored-by: Matthew Leibowitz <mattleibow@live.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
What
Port the two single-value
SKPMColorconversions from a native P/Invoke to managed integer math:binding/SkiaSharp/SKPMColor.cs—PreMultiply(SKColor)(:24) andUnPreMultiply(SKPMColor)(
:58) no longer callSkiaApi.sk_color_premultiply/sk_color_unpremultiply; they compute theresult in C#.
SkMulDiv255Round(c, a) = (p + (p>>8)) >> 8,p = c*a + 128, per channel;opaque colours (
a == 255) pass through unchanged (MulDiv255Round(c, 255) == c).unpremultiplyScale[a] = round((255<<24)/a)(mirrors Skia's
SkUnPreMultiply::gTable) +ApplyScale(scale, c) = (scale*c + (1<<23)) >> 24,so the managed path is a table lookup, not a per-call divide.
operators are unchanged — they call these two methods, so they benefit automatically.
Only method bodies change → ABI-safe (no public signature or return-type change).
Why it is correct (invariant)
Both conversions are pure, deterministic integer maps parameterised only by alpha. The managed
code replicates Skia's exact integer algorithm, so it is bit-exact with the native result for
every input — including out-of-gamut premultiplied colours where a channel exceeds alpha: the
32-bit multiply wraps identically under
unchecked, and>> 24of a 32-bit value is always ≤ 255 sopacking never bleeds across channels. Integer-only ⇒ deterministic on all runtimes/TFMs (no
x87/float divergence — no
RuntimeInformationfallback required, unlike float ports such as #4241).Proof 1 — faster (BenchmarkDotNet, New = managed vs Old = native P/Invoke baseline)
benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKPMColorConvertBenchmark.cs,[MemoryDiagnoser], XORsink, alpha varied across the batch. AMD EPYC 7763, .NET 10.0.9,
net10.0, in-process toolchain.Two independent runs shown as
run1 / run2:≈ 23–25 % faster (PreMultiply) and ≈ 19–26 % faster (UnPreMultiply), tight bands, zero
allocations on either path.
Proof 2 — identical (equivalence test)
tests/Tests/SkiaSharp/SKPMColorEquivalenceTest.cs(5 tests, all green) drives the shipped publicAPI and compares against the native oracle:
(alpha 0..255) × (channel 0..255)for each of the threechannel positions, for both conversions, vs
SkiaApi.sk_color_premultiply/sk_color_unpremultiply— a complete enumeration of every reachable behaviour, including invalid(out-of-gamut) premultiplied inputs. Distinct sentinels in the non-swept channels also pin the
packing order (catch a swapped/mis-shifted channel).
unpremultiply (R/B swapped) are both detected by the same oracle comparison — proving the sweep
actually has teeth.
Neighbouring suites unaffected:
SKColorTest35/35,SKColorFTest69/69,SKImageInfoTest4/4.Scope note (honest)
verified by the exhaustive test above.
regenerates a project that pulls in an uninstalled Android TFM here); in-process is slightly less
isolated but stable and adequate for this CPU-bound micro-comparison.
Label
perf/interop— the dominant, measured driver is removing the per-call native P/Invoke (theunpremultiply table lookup is the secondary change that lets the interop removal net a win).
Produced by the SkiaSharp performance-fixer agentic workflow using the
performance-fixerskill (automated scan → prove-faster → prove-identical → fix). This PR is opened as a draft for
human review; both proofs are included above.
Fixes #4384