[performance] Make HarfBuzzSharp.Buffer.AddUtf8(string) allocation-free (pooled UTF-8 encode)#4376
Conversation
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>
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>
📦 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 -- 4376PowerShell / 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-4376More options
Or download manually from Azure Pipelines — look for the Remove the source when you're done: dotnet nuget remove source skiasharp-pr-4376 |
|
📖 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. |
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 ·
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 |
What & why
HarfBuzzSharp.Buffer.AddUtf8(string)(binding/HarfBuzzSharp/Buffer.cs) allocated a throwaway UTF-8byte[]viaEncoding.UTF8.GetByteson every call, even though the siblingAddUtf16(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 overloadAddUtf8(IntPtr, int, int, int).The invariant that keeps it correct
hb_buffer_add_utf8consumes UTF-8 bytes. Both the old and new code use the framework's ownEncoding.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 unsafeEncodingpointer overloads andArrayPool<byte>exist on all TFMs (verified buildingnetstandard2.0andnet462), so there is no#ifsplit.Proof 1 — faster (benchmark)
benchmarks/SkiaSharp.Benchmarks/Benchmarks/HarfBuzzAddUtf8Benchmark.cs—New(shipped pooled encode) vsOld([Benchmark(Baseline=true)], verbatim previous impl:Encoding.UTF8.GetBytes+AddUtf8(span, 0, -1)),[MemoryDiagnoser], one reused buffer reset withClearContents()between adds.Run (in-process avoids the default toolchain's child build in this environment):
Results (in-process,
net10.0, Linux x64), 2 consistent runs:"shaping"(7 ch)Dominant, measured driver = the eliminated allocation (
byte[]→ 0 B); the modest time win comes fromGetMaxByteCount+ a singleGetBytesscan 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 newAddUtf8(string)against the verbatim original path (AddUtf8(Encoding.UTF8.GetBytes(text), 0, -1)) on both:GlyphInfos(Codepoint + Cluster), andGuessSegmentProperties+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 (throwsArgumentNullException, 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), allHarfBuzzSharp.Tests(232), andSKShaperTest(7). The full suite shows no new failures (27 failed = the pre-existing environmental baseline, unchanged).Scope / attribution
binding/HarfBuzzSharp/Buffer.cs; no*.generated.cs, noexternals/skia/**.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.perf/allocations: the primary measured driver is the removed per-callbyte[]allocation.Produced automatically by the
performance-fixeragentic workflow (using theperformance-fixerskill).Fixes #4375