Skip to content

[performance] Make HarfBuzzSharp.Buffer.AddUtf8(string) allocation-free (pooled UTF-8 encode)#4376

Merged
mattleibow merged 2 commits into
mainfrom
dev/perf-harfbuzz-addutf8-bfaee32600c8abfa
Jul 13, 2026
Merged

[performance] Make HarfBuzzSharp.Buffer.AddUtf8(string) allocation-free (pooled UTF-8 encode)#4376
mattleibow merged 2 commits into
mainfrom
dev/perf-harfbuzz-addutf8-bfaee32600c8abfa

Conversation

@github-actions

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

Copy link
Copy Markdown
Contributor

What & why

HarfBuzzSharp.Buffer.AddUtf8(string) (binding/HarfBuzzSharp/Buffer.cs) allocated a throwaway UTF-8 byte[] via Encoding.UTF8.GetBytes on every call, even though the sibling AddUtf16(string) is already allocation-free. It is on the canonical shaping hot path — SKShaper.Shape(string)buffer.AddUtf8(text)SKCanvas.DrawShapedText — so the allocation is paid once per shaped run / per frame for complex-script text.

This change encodes the UTF-8 into a pooled ArrayPool<byte> buffer, pins it, and passes the pointer + exact byte count to the existing native overload AddUtf8(IntPtr, int, int, int).

The invariant that keeps it correct

hb_buffer_add_utf8 consumes UTF-8 bytes. Both the old and new code use the framework's own Encoding.UTF8, so the same bytes and the same byte count reach the native call — the shaping result (GlyphInfos, clusters, positions) is bit-identical. Only the method body changed; the public signature is unchanged (ABI-safe). Null/empty inputs keep the exact original behaviour (null → ArgumentNullException, empty → adds nothing). The unsafe Encoding pointer overloads and ArrayPool<byte> exist on all TFMs (verified building netstandard2.0 and net462), so there is no #if split.

Proof 1 — faster (benchmark)

benchmarks/SkiaSharp.Benchmarks/Benchmarks/HarfBuzzAddUtf8Benchmark.csNew (shipped pooled encode) vs Old ([Benchmark(Baseline=true)], verbatim previous impl: Encoding.UTF8.GetBytes + AddUtf8(span, 0, -1)), [MemoryDiagnoser], one reused buffer reset with ClearContents() between adds.

Run (in-process avoids the default toolchain's child build in this environment):

dotnet run -c Release --project benchmarks/SkiaSharp.Benchmarks \
  -p:AllTargetFrameworks=net10.0 -p:BasicTargetFrameworks=net10.0 \
  -- --filter '*HarfBuzzAddUtf8Benchmark*' --inProcess

Results (in-process, net10.0, Linux x64), 2 consistent runs:

Input New Mean Old Mean Ratio New Alloc Old Alloc
"shaping" (7 ch) ~52–54 ns ~57–59 ns 0.89–0.94 0 B 32 B
ASCII sentence (44 ch) ~104–105 ns ~119–121 ns 0.87 0 B 72 B
CJK (14 ch, 3-byte) ~106–108 ns ~110 ns 0.96–0.98 0 B 72 B
mixed paragraph (72 ch) ~199–212 ns ~220–229 ns 0.90–0.93 0 B 120 B

Dominant, measured driver = the eliminated allocation (byte[]0 B); the modest time win comes from GetMaxByteCount + a single GetBytes scan instead of encoding twice. Never slower on any input.

Proof 2 — identical (equivalence test)

tests/Tests/HarfBuzzSharp/HBBufferAddUtf8Test.cs — 32 cases. For each input it compares the new AddUtf8(string) against the verbatim original path (AddUtf8(Encoding.UTF8.GetBytes(text), 0, -1)) on both:

  • pre-shape buffer GlyphInfos (Codepoint + Cluster), and
  • post-shape glyphs + clusters + X/Y advances (after GuessSegmentProperties + Font.Shape).

Edge coverage: ASCII, whitespace/control, 2-byte (café, Ærøskøbing), 3-byte CJK, decomposed combining marks, 4-byte emoji/surrogate pairs, mixed scripts, a 5000-char string and a long mixed paragraph (exercise larger pooled buffers), plus empty (adds nothing) and null (throws ArgumentNullException, matching the original).

Teeth: temporarily replacing the encode with a deliberately-wrong off-by-one byte truncation turned the suite red (30 of 32 failed; only the empty + null cases don't exercise truncation), confirming the test detects divergence. With the real fix it is green (32/32), as are the existing HBBufferTest (17), all HarfBuzzSharp.Tests (232), and SKShaperTest (7). The full suite shows no new failures (27 failed = the pre-existing environmental baseline, unchanged).

Scope / attribution

  • Managed-C# only — change confined to binding/HarfBuzzSharp/Buffer.cs; no *.generated.cs, no externals/skia/**.
  • ABI-safe (body-only). No behaviour change — SkiaSharp shapes identically before and after.
  • Numbers are empirically measured (BenchmarkDotNet, in-process, net10.0, Linux x64), not statically reasoned. Absolute ns will differ on other hardware/TFM; the 0-allocation result and the direction of the ratio are the durable claims.
  • Label perf/allocations: the primary measured driver is the removed per-call byte[] allocation.

Produced automatically by the performance-fixer agentic workflow (using the performance-fixer skill).

Fixes #4375

Generated by Fixer - Performance · ● 67.6M ·

HarfBuzzSharp.Buffer.AddUtf8(string) allocated a throwaway UTF-8 byte[]
via Encoding.UTF8.GetBytes on every call, even though the sibling
AddUtf16(string) is already zero-alloc. It sits on the canonical shaping
hot path (SKShaper.Shape(string) -> buffer.AddUtf8(text) ->
SKCanvas.DrawShapedText), so the allocation is paid once per shaped run
/ per frame for complex-script text.

Encode into a pooled ArrayPool<byte> buffer instead, pin it, and pass the
pointer plus the exact byte count to the existing native overload. The
identical UTF-8 bytes reach hb_buffer_add_utf8, so shaping output is
bit-identical. Using GetMaxByteCount + a single GetBytes scan (instead of
GetByteCount + GetBytes) also avoids double-scanning the string.

Null/empty inputs keep the exact original behaviour (null throws
ArgumentNullException; empty adds nothing). Only the method body changes;
the public signature is unchanged (ABI-safe). The unsafe Encoding
pointer overloads and ArrayPool<byte> are available on all target
frameworks (verified netstandard2.0 + net462), so no per-TFM split.

Benchmark (BenchmarkDotNet, in-process, net10.0, Linux x64), New vs Old
baseline: allocation eliminated entirely (byte[] -> 0 B) and Mean ratio
0.87-0.98 (never slower) across a short word, an ASCII sentence, CJK, and
a mixed paragraph.

Adds a New-vs-Old benchmark and a 32-case equivalence test (pre-shape
codepoints/clusters and post-shape glyphs/advances across ASCII,
multibyte, CJK, combining marks, emoji surrogate pairs, empty, null, and
long inputs); the test was confirmed to catch a deliberately-wrong
implementation.

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 8, 2026
@mattleibow mattleibow added the partner/agentic-workflows Issues and PRs created by SkiaSharp agentic workflows. label Jul 11, 2026
Replace the string.IsNullOrEmpty fallback block in HarfBuzzSharp.Buffer
.AddUtf8(string) with an explicit ArgumentNullException guard for null,
and let an empty string flow through the existing pooled UTF-8 encode
path as a verified no-op (GetMaxByteCount(0) rents a small buffer,
GetBytes writes 0 bytes, AddUtf8(ptr, 0, ...) adds nothing) — mirroring
how AddUtf16(string) already handles empty in the same file.

This removes the fallback Encoding.UTF8.GetBytes allocation while
preserving main's observable behaviour: null throws ArgumentNullException
and empty adds nothing. The pooled fast path is unchanged and still
passes the exact byteCount (GetBytes return value) to the native
AddUtf8(IntPtr, int, int, int) overload, since the rented buffer is
oversized.

Tests are grounded in main's semantics: null asserts
ArgumentNullException, empty asserts Length == 0 (matching a fresh
buffer), plus a teeth guard so the equivalence/shaping tests are not
vacuous.

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

PowerShell / Windows:

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

Step 2 — Add the local NuGet source

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

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

@mattleibow
mattleibow marked this pull request as ready for review July 13, 2026 16:00
@mattleibow
mattleibow merged commit 9e55413 into main Jul 13, 2026
85 checks passed
@mattleibow
mattleibow deleted the dev/perf-harfbuzz-addutf8-bfaee32600c8abfa branch July 13, 2026 16:00
@mattleibow

Copy link
Copy Markdown
Contributor

Local benchmark — macOS (real hardware)

Ran the committed HarfBuzzAddUtf8Benchmark locally on Apple Silicon.

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

Method Text Mean Ratio Allocated Alloc Ratio
Old "shaping" (7) 41.98 ns 1.00 32 B 1.00
New "shaping" (7) 36.98 ns 0.88 0 B 0.00
Old ASCII sentence (44) 148.28 ns 1.00 72 B 1.00
New ASCII sentence (44) 131.69 ns 0.89 0 B 0.00
Old CJK (14, 3-byte) 97.27 ns 1.00 72 B 1.00
New CJK (14, 3-byte) 92.57 ns 0.95 0 B 0.00
Old mixed paragraph (72) 280.40 ns 1.00 120 B 1.00
New mixed paragraph (72) 242.72 ns 0.87 0 B 0.00

0 bytes allocated on every input (was 32–120 B/call) and ≈ 1.05–1.15× faster — the headline win is the eliminated per-call byte[]. The allocation removal holds on all TFMs (the encoded array genuinely escapes on the old path).

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] HarfBuzzSharp.Buffer.AddUtf8(string) allocates a byte[] on every shaped run — make it allocation-free

1 participant