Skip to content

[performance] Port single-value SKPMColor premultiply/unpremultiply to managed C##4385

Open
github-actions[bot] wants to merge 3 commits into
mainfrom
dev/perf-skpmcolor-managed-8920b4f8bb1ce155
Open

[performance] Port single-value SKPMColor premultiply/unpremultiply to managed C##4385
github-actions[bot] wants to merge 3 commits into
mainfrom
dev/perf-skpmcolor-managed-8920b4f8bb1ce155

Conversation

@github-actions

@github-actions github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What

Port the two single-value SKPMColor conversions from a native P/Invoke to managed integer math:

  • binding/SkiaSharp/SKPMColor.csPreMultiply(SKColor) (:24) and UnPreMultiply(SKPMColor)
    (:58) no longer call SkiaApi.sk_color_premultiply / sk_color_unpremultiply; they compute the
    result in C#.
    • PreMultiply: SkMulDiv255Round(c, a) = (p + (p>>8)) >> 8, p = c*a + 128, per channel;
      opaque colours (a == 255) pass through unchanged (MulDiv255Round(c, 255) == c).
    • UnPreMultiply: a once-built 256-entry scale table 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.
  • The array overloads stay native (already one P/Invoke for the whole batch) and the cast
    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 >> 24 of a 32-bit value is always ≤ 255 so
packing never bleeds across channels. Integer-only ⇒ deterministic on all runtimes/TFMs (no
x87/float divergence — no RuntimeInformation fallback 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], XOR
sink, alpha varied across the batch. AMD EPYC 7763, .NET 10.0.9, net10.0, in-process toolchain.
Two independent runs shown as run1 / run2:

Operation N New (managed) Old (native) Ratio Alloc
PreMultiply 256 813.7 / 814.6 ns 1074.1 / 1062.8 ns 0.76 / 0.77 none
PreMultiply 4096 12.91 / 12.91 μs 17.30 / 16.98 μs 0.75 / 0.76 none
UnPreMultiply 256 720.2 / 762.1 ns 977.0 / 945.2 ns 0.74 / 0.81 none
UnPreMultiply 4096 11.43 / 12.04 μs 14.77 / 15.85 μs 0.77 / 0.76 none

23–25 % faster (PreMultiply) and ≈ 19–26 % faster (UnPreMultiply), tight bands, zero
allocations
on either path.

dotnet run -c Release --project benchmarks/SkiaSharp.Benchmarks -- \
  --filter '*SKPMColor*Benchmark*' --inProcess

Proof 2 — identical (equivalence test)

tests/Tests/SkiaSharp/SKPMColorEquivalenceTest.cs (5 tests, all green) drives the shipped public
API
and compares against the native oracle:

  • Exhaustive, bit-exact sweep of every (alpha 0..255) × (channel 0..255) for each of the three
    channel 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).
  • Round-trip: premultiply → unpremultiply recovers the original opaque colour.
  • Teeth guards: a deliberately-wrong premultiply (rounding bias dropped) and a deliberately-wrong
    unpremultiply (R/B swapped) are both detected by the same oracle comparison — proving the sweep
    actually has teeth.

Neighbouring suites unaffected: SKColorTest 35/35, SKColorFTest 69/69, SKImageInfoTest 4/4.

Scope note (honest)

  • Numbers are empirically measured (BenchmarkDotNet, 2 runs); bit-exactness is empirically
    verified by the exhaustive test above.
  • Benchmark caveat: in-process toolchain (BenchmarkDotNet's default out-of-process build
    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.
  • Determinism: integer-only ⇒ deterministic on all runtimes/TFMs.
  • ABI: bodies only; operators and array overloads unchanged.

Label

perf/interop — the dominant, measured driver is removing the per-call native P/Invoke (the
unpremultiply 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-fixer
skill (automated scan → prove-faster → prove-identical → fix). This PR is opened as a draft for
human review; both proofs are included above.

Fixes #4384

Generated by Fixer - Performance · ● 71.6M ·

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>
@github-actions github-actions Bot added perf/interop P/Invoke marshalling / native interop overhead. Implies tenet/performance. tenet/performance Performance related issues labels Jul 9, 2026
@github-actions github-actions Bot added the perf/interop P/Invoke marshalling / native interop overhead. Implies tenet/performance. label Jul 9, 2026
@mattleibow mattleibow added the partner/agentic-workflows Issues and PRs created by SkiaSharp agentic workflows. label Jul 11, 2026
@mattleibow

Copy link
Copy Markdown
Contributor

Research: is the unpremultiplyScale table worth hardcoding at compile time?

Prompted by the idea of replacing CreateUnPreMultiplyScaleTable with a compile-time/const table. I benchmarked the alternatives and measured the table-build cost. TL;DR: keep the runtime table as-is — the build cost is ~1 µs once, and the one obvious "simplification" (drop the table, divide per call) is actually slower.

All variants verified bit-exact for every (alpha 0..255) × (channel 0..255). Apple M3 Pro, .NET 10, net10.0, in-process, XOR sink over 256/4096-value batches.

Per-call UnPreMultiply

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

  1. 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.
  2. 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 a ReadOnlySpan<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 first UnPreMultiply, 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.)

@mattleibow
mattleibow marked this pull request as ready for review July 12, 2026 05:43
@github-actions

Copy link
Copy Markdown
Contributor Author

📦 Try the packages from this PR

Warning

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 -- 4385

PowerShell / 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-4385
More options
Option Description
--successful-only / -SuccessfulOnly Only use successful builds
--force / -Force Overwrite previously downloaded packages
--list / -List List available artifacts without downloading
--build-id ID / -BuildId ID Download from a specific build

Or download manually from Azure Pipelines — look for the nuget artifact on the build for this PR.

Remove the source when you're done:

dotnet nuget remove source skiasharp-pr-4385

@github-actions

Copy link
Copy Markdown
Contributor Author

📖 Documentation Preview

The documentation for this PR has been deployed and is available at:

🔗 View Staging Site
🔗 View Staging Docs
🔗 View Staging Gallery (Blazor)
🔗 View Staging Gallery (Uno Platform)
🔗 View Staging SkiaFiddle

This preview will be updated automatically when you push new commits to this PR.


This comment is automatically updated by the documentation staging workflow.

Comment on lines +98 to +106
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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@mattleibow

Copy link
Copy Markdown
Contributor

Local benchmark — macOS (real hardware)

Ran the committed SKPMColorPreMultiplyBenchmark / SKPMColorUnPreMultiplyBenchmark locally on Apple Silicon.

Environment: Apple M3 Pro · macOS 26.5 · .NET 10.0.9 (Arm64 RyuJIT) · BenchmarkDotNet 0.13.5 · InProcessEmitToolchain

PreMultiply

Method N Mean Error StdDev Ratio Allocated
New 256 469.2 ns 3.54 ns 2.76 ns 0.74 -
Old 256 634.8 ns 3.43 ns 2.86 ns 1.00 -
New 4096 7,443.8 ns 3.45 ns 2.88 ns 0.74 -
Old 4096 9,997.0 ns 1.38 ns 1.08 ns 1.00 -

UnPreMultiply

Method N Mean Error StdDev Ratio Allocated
New 256 440.2 ns 3.08 ns 2.57 ns 0.74 -
Old 256 595.9 ns 0.30 ns 0.25 ns 1.00 -
New 4096 7,754.6 ns 6.98 ns 6.18 ns 0.82 -
Old 4096 9,423.6 ns 2.56 ns 2.13 ns 1.00 -

≈ 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.

mattleibow added a commit that referenced this pull request Jul 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

partner/agentic-workflows Issues and PRs created by SkiaSharp agentic workflows. perf/interop P/Invoke marshalling / native interop overhead. Implies tenet/performance. tenet/performance Performance related issues

Projects

Status: Approved

Development

Successfully merging this pull request may close these issues.

[performance] Port single-value SKPMColor premultiply/unpremultiply to managed C#

2 participants