Skip to content

[performance] Remove per-call char[4] allocation in SKFourByteTag.Parse#4396

Merged
mattleibow merged 3 commits into
mainfrom
dev/perf-fourbytetag-parse-b954714d6dc4fe14
Jul 11, 2026
Merged

[performance] Remove per-call char[4] allocation in SKFourByteTag.Parse#4396
mattleibow merged 3 commits into
mainfrom
dev/perf-fourbytetag-parse-b954714d6dc4fe14

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

What this does

SKFourByteTag.Parse(string?) allocated a char[4] scratch buffer on every call to pad/truncate an OpenType tag before packing it into a uint. This PR rewrites the body to read the (up to) four characters directly with space padding — no scratch array — and adds an ABI-safe additive Parse(ReadOnlySpan<char>) overload that the string overload delegates to.

public static SKFourByteTag Parse (string? tag) =>
    Parse (tag.AsSpan ());

public static SKFourByteTag Parse (ReadOnlySpan<char> tag)
{
    if (tag.IsEmpty)
        return new SKFourByteTag (0);

    var c1 = tag.Length > 0 ? tag[0] : ' ';
    var c2 = tag.Length > 1 ? tag[1] : ' ';
    var c3 = tag.Length > 2 ? tag[2] : ' ';
    var c4 = tag.Length > 3 ? tag[3] : ' ';

    return new SKFourByteTag (c1, c2, c3, c4);
}

Invariant that keeps it correct: the original packed (byte)c of each of the first four chars, padding missing trailing slots with ' ' and truncating extra chars; IsNullOrEmpty returned tag 0. The new code preserves all of that exactly — null/empty → empty span → 0, the same byte-truncation happens in the existing SKFourByteTag(char,char,char,char) constructor, and space padding is identical.

No public signature changed — one method body changed plus one additive overload. ABI-safe.

Proof it is faster

Standalone New-vs-Old harness (the two exact algorithm bodies; Parse is pure managed, no native call). net10.0, AMD EPYC 7763, .NET 10.0.9 X64 RyuJIT AVX2, 110M calls over a representative tag batch, 3 runs:

Run Old ns/call New ns/call Ratio Old alloc New alloc
1 7.54 5.43 0.72 117.7 MB (32 B/call) 0 B
2 5.89 4.11 0.70 0 B* 0 B
3 5.90 3.96 0.67 0 B* 0 B

~30% faster, repeatable. *On modern .NET the JIT escape-analyses the old char[4] and stack-allocates it in the tight steady-state loop (0 B in runs 2-3), but the allocation is real on cold/tier-0 code (run 1 = 32 B/call) and on TFMs without escape analysis (.NET Framework, netstandard2.0 consumers), where the old path allocates 32 B on every call. The new path never allocates on any TFM.

The committed benchmark is benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKFourByteTagParseBenchmark.cs (standard [MemoryDiagnoser] New-vs-Old form). BenchmarkDotNet's default child-process toolchain cannot build the multi-TFM binding in this CI-less environment (same limitation noted in #4369), so the numbers above were taken with an equivalent in-process harness; run it in CI with:

dotnet run -c Release --project benchmarks/SkiaSharp.Benchmarks -- --filter '*SKFourByteTagParseBenchmark*'

Proof it is identical

tests/Tests/SkiaSharp/SKFourByteTagTest.cs gains a bit-exact equivalence suite: a [Theory] comparing the new Parse against a verbatim copy of the original char[4] algorithm across null / empty / 1–6 chars / all-spaces / NUL / chars above 0xFF (low-byte truncation) / surrogate code units, plus tests that the new ReadOnlySpan<char> overload agrees with the string overload (including a span sliced out of a larger string) and that empty/default spans return 0.

  • 64/64 SKFourByteTagTest tests pass (net10.0).
  • The equivalence test catches a deliberately-wrong result: mutating one padding char to '_' turns the suite red (2 failures), confirming it is a real guardrail.
  • The rest of the suite is unchanged.

Label

perf/allocations — the dominant, measured driver is eliminating the per-call char[4] scratch allocation (there is no P/Invoke on this path).

Fixes #4395


Produced automatically by the SkiaSharp agentic performance-fixer workflow (via the performance-fixer skill). Scope: managed C# only. Numbers are empirically measured on the named hardware/TFM (not statically reasoned); absolute ns are environment-dependent. ABI impact: none.

Generated by Fixer - Performance · ● 35.9M ·

Rewrite SKFourByteTag.Parse to read the tag characters directly with space
padding instead of allocating a char[4] scratch buffer on every call, and add
an ABI-safe Parse(ReadOnlySpan<char>) overload that the string overload
delegates to.

The result is bit-for-bit identical to the original algorithm (proven by a new
equivalence test suite) and ~30% faster, with the char[4] allocation removed
entirely (32 B/call on TFMs without escape analysis).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added perf/allocations Excessive managed allocations or per-frame GC/heap churn. Implies tenet/performance. tenet/performance Performance related issues labels Jul 10, 2026
mattleibow
mattleibow previously approved these changes Jul 10, 2026
@mattleibow
mattleibow marked this pull request as ready for review July 10, 2026 10:12
@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.

…ourByteTag tests

The equivalence theory previously computed expected values at runtime via a
copy of the original char[4] algorithm (ReferenceParse), and one input used the
range operator ("..."[..4]). On the .NET Framework test target the range
operator pulls in System.Index/System.Range, which are declared in multiple
referenced assemblies (HarfBuzzSharp + mscorlib), producing CS8356 and failing
compilation of the whole Tests (.NET Framework) job.

Bake the expected packed-uint values in as [InlineData] constants (input +
expected) so the oracle cannot drift at runtime, drop ReferenceParse and the
MemberData source, and remove the range operator. The string and span overloads
are both asserted against the constant, which is strictly stronger than the old
span-vs-string comparison.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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 -- 4396

PowerShell / Windows:

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

Step 2 — Add the local NuGet source

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR optimizes SKFourByteTag.Parse by removing the per-call char[4] scratch allocation and introducing an ABI-safe Parse(ReadOnlySpan<char>) overload that the string? overload delegates to, keeping the packed-tag semantics unchanged.

Changes:

  • Rewrote SKFourByteTag.Parse(string?) to delegate to a new Parse(ReadOnlySpan<char>) overload, avoiding scratch-buffer allocation.
  • Added a comprehensive test suite covering null/empty, padding/truncation, low-byte truncation, and surrogate code-unit behavior.
  • Added a BenchmarkDotNet New-vs-Old benchmark for SKFourByteTag.Parse.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
binding/SkiaSharp/SKFourByteTag.cs Adds Parse(ReadOnlySpan<char>) and rewrites Parse(string?) to delegate, eliminating scratch allocation.
tests/Tests/SkiaSharp/SKFourByteTagTest.cs Adds theory/fact coverage to validate exact packed results and span/string overload parity.
benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKFourByteTagParseBenchmark.cs Adds a baseline-vs-new benchmark comparing the original algorithm to the updated implementation.

Comment thread binding/SkiaSharp/SKFourByteTag.cs Outdated
After the empty-span guard the length is always >= 1, so the 'tag.Length > 0'
check on the first character was dead. Read tag[0] directly to drop the
redundant branch; behaviour is unchanged (all parity tests still pass).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mattleibow
mattleibow merged commit 411052d into main Jul 11, 2026
86 checks passed
@mattleibow
mattleibow deleted the dev/perf-fourbytetag-parse-b954714d6dc4fe14 branch July 11, 2026 07:26
@github-project-automation github-project-automation Bot moved this from Approved to Done in SkiaSharp Backlog Jul 11, 2026
@mattleibow mattleibow added the partner/agentic-workflows Issues and PRs created by SkiaSharp agentic workflows. label Jul 11, 2026
mattleibow pushed a commit that referenced this pull request Jul 12, 2026
….Parse (#4408)

[performance] Remove per-call char[4] allocation in HarfBuzzSharp.Tag.Parse (#4408)

Fixes: #4407

HarfBuzzSharp.Tag.Parse(string) packed a tag by copying up to four chars into a
freshly allocated char[4] scratch buffer, space-padding the tail, then folding
the low byte of each slot into the uint. That scratch array is allocated on
every call, and tag parsing is hot during OpenType hydration — feature tags
("liga", "kern"), variation-axis tags ("wght", "wdth", "slnt"), and table tags
are often parsed in batches when configuring fonts and shaping features.

Rewrite Parse to read the first four chars directly and pad missing trailing
slots with spaces inline, with no scratch buffer. Packing semantics are
unchanged: null/empty -> Tag.None, short input space-padded, over-long input
truncated to four, and only the low byte of each UTF-16 code unit is kept.

Add an ABI-safe Parse(ReadOnlySpan<char>) overload and have the string overload
delegate to it via tag.AsSpan(), so callers can parse a slice of a larger string
without a substring allocation. The existing string signature is untouched.

This mirrors the already-merged SkiaSharp.SKFourByteTag.Parse optimization
(#4396); Tag is the HarfBuzzSharp sibling that still carried the old char[4]
path. Change is body + additive overload only, so it is ABI-safe.

Measured (BenchmarkDotNet, MemoryDiagnoser, net10.0) on a 22-tag batch: faster
and allocation-free, repeatable across runs. On .NET 10 both rows report zero
managed allocation because the JIT stack-allocates the non-escaping char[4], so
the win there is codegen (dropping the loops, bounds checks, and array
indexing); on net462 / netstandard2.0, which lack object stack allocation, the
old path additionally heap-allocated the char[4] per call, so those TFMs also
lose that allocation. No allocation regression on any TFM.

Correctness is pinned by precomputed [InlineData] constants (input + expected
packed uint) asserted against both the string and span overloads, plus a
verbatim copy of the original char[4] algorithm as a cross-check oracle, a
sliced-span case, null/empty -> None, a ToString round trip, and a teeth guard
proving the equivalence assertions discriminate a wrong value.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mattleibow added a commit that referenced this pull request Jul 13, 2026
The Polish agent decides whether a page needs new prose. When a page's
data.json changed, it read the existing prose.json, judged it 'compatible',
and skipped it — silently dropping brand-new product PRs. Concretely, a
scheduled run (PR #4413) added three performance PRs (#4408, #4388, #4396) to
4.151.0-unreleased.data.json but left the page's prose untouched, so the
rendered page never mentioned them and the PR shipped data.json with a stale
.md. (An earlier run on the same facts did update the prose — the behaviour is
non-deterministic.)

Remove the soft 'is the old prose still matching?' judgment entirely:

- release-notes-data.py: when a page's data.json genuinely changes, DELETE its
  _sources/<v>.prose.json so the agent must re-author the page from scratch
  against the fresh facts. Scoped to a real change (not a bare --force
  re-render). The human-owned <v>.notes.md sidecar is preserved; the .md is not
  deleted because render overwrites it wholesale from the new prose.
- release-notes-render.py: a data.json with no matching prose.json is now a HARD
  ERROR in --all (was: warn and keep the stale committed page). After Prepare,
  unchanged pages keep their prose and changed pages have theirs deleted, so a
  missing prose.json can only mean the agent failed to author a changed/new
  page — the run fails instead of shipping stale.

Verified: all 71 committed pages have prose (hard-fail is safe); render --all
passes with prose present and fails (exit 1, names the page) when a page's prose
is missing. SKILL.md and the spec (§4.6) updated to match.

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

Copy link
Copy Markdown
Contributor

Local benchmark — macOS (real hardware)

Ran the committed SKFourByteTagParseBenchmark locally on Apple Silicon to add a macOS data point alongside the CI/Linux numbers.

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

Method Mean Error StdDev Ratio Allocated
Old 206.80 ns 2.697 ns 2.522 ns 1.00 -
New 34.06 ns 0.340 ns 0.284 ns 0.16 -

≈ 6.1× faster (206.8 ns → 34.1 ns) for the 22-tag batch.

Note on allocation: on .NET 10 the JIT stack-allocates the non-escaping char[4], so both rows show - (0 B) here — on this runtime the win is codegen. The heap-allocation removal still applies on runtimes without object stack allocation (.NET Framework / Mono / WASM / AOT).

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/allocations Excessive managed allocations or per-frame GC/heap churn. Implies tenet/performance. tenet/performance Performance related issues

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[performance] Port SKFourByteTag.Parse off its per-call char[4] scratch allocation

2 participants