Skip to content

Optimize SKColor hex parsing and add ReadOnlySpan<char> overloads#4345

Merged
mattleibow merged 1 commit into
mainfrom
mattleibow-optimize-skcolor-parse
Jul 6, 2026
Merged

Optimize SKColor hex parsing and add ReadOnlySpan<char> overloads#4345
mattleibow merged 1 commit into
mainfrom
mattleibow-optimize-skcolor-parse

Conversation

@mattleibow

@mattleibow mattleibow commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

What

Rewrites SKColor.TryParse to parse hex color strings with a manual, allocation-free hex parser instead of byte.TryParse/uint.TryParse + NumberStyles.HexNumber, and adds public ReadOnlySpan<char> overloads for Parse/TryParse.

Why

SKColor.Parse/TryParse sit on hot paths for anyone hydrating colors from XAML/CSS/JSON/theme files. The old implementation:

  • called byte.TryParse per channel and uint.TryParse for the 6/8-digit forms (culture + NumberStyles overhead), and
  • had a #if split where the net4x/netstandard2.0 path allocated a new string(char, 2) per channel.

Changes

  • Unified, allocation-free parser on ReadOnlySpan<char> (Trim().TrimStart('#') + a length switch, with small case-insensitive nibble helpers). Removes the #if split — one implementation for all TFMs.
  • New public overloads (ABI-safe additions; the string overloads delegate via .AsSpan(), matching the existing pattern in SKFont/SKTextBlob/etc.):
    public static SKColor Parse (ReadOnlySpan<char> hexString);
    public static bool TryParse (ReadOnlySpan<char> hexString, out SKColor color);
    These let callers parse a color out of a larger buffer without allocating a substring.
  • Tests: whitespace trimming, case-insensitivity (ff/FF/fF), null/empty/#, named-color rejection, span/string agreement, slice parsing, invalid/empty span rejection. 35 SKColorTest cases pass, 0 failed.
  • Benchmarks (benchmarks/) documenting the work: a strategy shootout (if-chain vs lookup table vs branchless vs switch), the span-vs-string comparison, and the public New-vs-Old numbers.

Interop / native surface

None. This is a pure-managed change with no P/Invoke involved anywhere:

  • SKColor is a readonly struct wrapping a single uint — it has no native handle and no SKObject/IntPtr backing.
  • Parse/TryParse and the private TryParseNibble/TryParseByte helpers are 100% managed C#: no SkiaApi.*/sk_* calls, no DllImport/LibraryImport, no Marshal, no stackalloc/fixed, no native memory.
  • Parsing a color string therefore never crosses into native Skia — the input goes char-by-char straight into the uint.
  • No C API (externals/skia/**) or generated bindings (*.generated.cs) are touched, so externals-download is sufficient for local builds and there is no native rebuild requirement.

Behavior

Preserved for every input the old code accepted, with one intentional tightening: whitespace immediately after # in the 6/8-digit forms (e.g. "# 12345") was previously accepted as a side effect of NumberStyles.HexNumber's AllowLeadingWhite, and is now rejected — matching the stricter behavior the 3/4-digit path already had. Parsing stays case-insensitive.

Performance

net10.0, Apple M3 Pro, BenchmarkDotNet — zero allocations, ~1.4–2.3× faster across all shapes:

Input New Old Speedup
#FFF 3.8 ns 8.4 ns 2.2×
#FFFF 4.0 ns 9.4 ns 2.3×
#FFFFFF 4.8 ns 7.0 ns 1.4×
#FFFFFFFF 6.0 ns 8.9 ns 1.5×
#A7A8A9A0 (varying) 5.1 ns 9.3 ns 1.8×
Red (invalid) 1.7 ns 3.7 ns 2.2×

Notes

  • Builds clean on all TFMs (net462/netstandard2.0 included). The old #if guard existed only because that path used the byte/uint.TryParse span overloads (netstandard2.1+); this trimming-only span code compiles everywhere.
  • The new public overloads will need XML/mdoc documentation entries — left for the docs generation (CI/api-docs).

Rewrite SKColor.TryParse to parse hex color strings ("#RGB", "#ARGB",
"#RRGGBB", "#AARRGGBB") with a manual, allocation-free hex parser instead
of byte.TryParse/uint.TryParse + NumberStyles.HexNumber. This removes the
per-channel parse overhead and the #if split between the span path and the
allocating net4x/netstandard2.0 path (which built a `new string(char, 2)`
per channel), unifying behavior across all target frameworks.

The core now runs on ReadOnlySpan<char> (Trim().TrimStart('#') + a length
switch), and new public overloads are exposed:

  SKColor.Parse(ReadOnlySpan<char>)
  SKColor.TryParse(ReadOnlySpan<char>, out SKColor)

The string overloads delegate to the span ones via AsSpan(), matching the
pattern already used elsewhere in the binding (SKFont, SKTextBlob, ...).
These are pure additions, so the change is ABI-safe. Re-basing to a span
also lets the JIT drop bounds checks in the fixed-length cases.

Behavior is preserved for every input the old code accepted, with one
intentional tightening: whitespace directly after '#' in the 6/8-digit
forms (e.g. "# 12345") was previously accepted as a side effect of
NumberStyles.HexNumber's AllowLeadingWhite and is now rejected, matching
the stricter behavior the 3/4-digit path already had. Parsing remains
case-insensitive.

Measured on net10.0 (Apple M3 Pro): ~1.4-2.3x faster across all shapes
with zero allocations (e.g. "#FFF" 8.4ns -> 3.8ns, "#FFFFFFFF" ~9ns ->
6ns, invalid fast-reject 3.7ns -> 1.7ns).

Adds SKColorTest coverage (whitespace, case-insensitivity, null/empty,
named-color rejection, span overloads, slice parsing) and benchmark
projects documenting the strategy shootout, the span-vs-string
comparison, and the public New-vs-Old numbers.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

📦 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 -- 4345

PowerShell / Windows:

iex "& { $(irm https://raw.githubusercontent.com/mono/SkiaSharp/main/scripts/get-skiasharp-pr.ps1) } 4345"

Step 2 — Add the local NuGet source

dotnet nuget add source ~/.skiasharp/hives/pr-4345/packages --name skiasharp-pr-4345
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-4345

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

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

@mattleibow
mattleibow merged commit d167198 into main Jul 6, 2026
6 checks passed
@mattleibow
mattleibow deleted the mattleibow-optimize-skcolor-parse branch July 6, 2026 22:48
mattleibow added a commit that referenced this pull request Jul 8, 2026
…ready (#4361)

[skills] Add performance-fixer skill + make the benchmark harness AI-ready (#4361)

Context: dotnet/aspnetcore optimize-aspnetcore-performance (reference architecture)
Related: #4241 (SKMatrix native→managed, up to 24×), #4345 (SKColor alloc-free parse),
         #4247 (SKSurface.Canvas caching), #4362 (perf/* label taxonomy), #4330 (memory-leak-fixer)

SkiaSharp is a thin managed wrapper over native Skia, so its recurring, high-impact
performance family is the tax the C# layer adds between the caller and Skia — a P/Invoke
transition paid for math that is a few float ops, an allocation on a hot parse/convert
path, a native lookup redone on every getter, or per-element marshalling in a loop. We
have merged several such wins by hand (#4241, #4345, #4247); this adds an autonomous
scanner so more of them surface as code lands, without a human hunting for them.

Add a `performance-fixer` skill (sibling to `memory-leak-fixer`) plus a scheduled gh-aw
workflow that scans the managed binding for one hot-path opportunity, proves it, fixes it
(managed-only, ABI-safe), and files a linked issue + draft PR.

The defining discipline is two proofs, which a generic perf pass lacks: every fix must be
shown FASTER (a BenchmarkDotNet New-vs-Old measurement) AND behaviour-IDENTICAL (an
equivalence test — bit-exact vs SkiaApi.sk_* for numeric ports, across edge inputs, and
mutation-checked to prove it catches divergence). A faster answer that differs from Skia is
a rendering regression, not a win, so it is rejected. The skill also encodes the traps we
learned the hard way: the x86/x87 float-determinism native fallback (#4241), the ARM64
Vector256 regression, statistical benchmark rigor, and full behaviour parity including
exceptions, ownership/GC.KeepAlive lifetime, and rendered pixels.

~~ Reference structure ~~
References are split by area (mirroring the aspnetcore skill): references/hot-paths/*
(SkiaSharp code areas — geometry-math, color, handles-and-collections, text-and-fonts,
pixels-and-images) and references/bcl-patterns/* (the .NET techniques), plus
decision-framework.md (impact×complexity rubric + the two-proof gate), measuring.md,
signals.md (detection routing), and repo-helpers.md. A lean SKILL.md routes between them.

~~ Benchmark harness readiness ~~
So a scan never wastes time repairing infra: replace TheBenchmark.cs — a trap whose
[SimpleJob(Net48/Net70/Net80)] attributes silently skip off-Windows and whose empty bodies
taught none of the conventions — with a working TemplateBenchmark.cs (New-vs-Old +
[MemoryDiagnoser] + [Params] + return-sink that runs out of the box), and add
benchmarks/README.md documenting the add/run commands, --list flat / --job short for fast
iteration, the single-TFM rule, and the InternalsVisibleTo native-oracle pattern.

~~ Labels ~~
Findings are labelled tenet/performance plus one agent-chosen perf/* sub-type from the
shared taxonomy in .agents/skills/issue-triage/references/labels.md (#4362), matching the
memory-leak-fixer convention; the sub-type is decided by the win's dominant measured driver
(a removed P/Invoke → perf/interop, removed allocations → perf/allocations, ...).

~~ Validated end to end ~~
The workflow self-tests on any PR touching the skill via a forced dry-run. Those runs
correctly rejected a non-finding (SKFourByteTag's char[4] is already stack-allocated by
.NET 9/10 escape analysis) and found real, fully-proven wins (SKShaper glyph-array
extraction → perf/allocations; SKColor→SKColorF operator → perf/interop). A real dispatch
run created a correctly-labelled issue (#4369) and draft PR (#4370), confirming the
tenet/performance + perf/* labels land on both.

No binding/runtime code changes: a new skill, a new workflow, benchmark docs/template, and
two rows in AGENTS.md.

Co-authored-by: Matthew Leibowitz <mattleibow@live.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mattleibow mattleibow added this to the 4.151.0-preview.2 milestone Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant