diff --git a/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKShaperGlyphExtractionBenchmark.cs b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKShaperGlyphExtractionBenchmark.cs new file mode 100644 index 00000000000..355532cd7b1 --- /dev/null +++ b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKShaperGlyphExtractionBenchmark.cs @@ -0,0 +1,177 @@ +using System; +using System.Reflection; +using System.Text; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Toolchains.InProcess.Emit; +using HarfBuzzSharp; +using SkiaSharp.HarfBuzz; +using Buffer = HarfBuzzSharp.Buffer; + +namespace SkiaSharp.Benchmarks; + +// SKShaper.Shape(...) is the per-shaped-run text path behind SKCanvas.DrawShapedText. After native +// HarfBuzz shaping it extracts the results into the SKShaper.Result arrays. The shipped code reads +// the shaping output through buffer.GlyphInfos and buffer.GlyphPositions — and each of those getters +// does a .ToArray() copy of the native glyph buffer (Buffer.cs: GetGlyphInfoSpan().ToArray()) — then +// iterates each array exactly once. Those two copies are pure per-shape waste: the zero-copy +// buffer.GetGlyphInfoSpan()/GetGlyphPositionSpan() expose the very same data with no allocation. +// +// This benchmark isolates the result-extraction step (the only part the fix changes) against an +// already-shaped buffer, so native shaping is held constant and the delta is exactly the two removed +// glyph arrays. Old = the shipped array-getter extraction copied verbatim; New = the same loop over +// the zero-copy spans. Both still allocate the three real Result arrays (points/clusters/codepoints), +// because the fix does not — and must not — remove those. +[Config(typeof(Config))] +[MemoryDiagnoser] +public class SKShaperGlyphExtractionBenchmark +{ + // Runs in-process so BenchmarkDotNet does not spawn a generated project that rebuilds the + // multi-targeted binding projects (their mobile TFMs need SDK workloads that are not installed + // on this desktop/CI host). MemoryDiagnoser and the New-vs-Old timing are fully supported + // in-process; this only changes where the benchmark executes, not what is measured. + private class Config : ManualConfig + { + public Config() => + AddJob(Job.Default.WithToolchain(InProcessEmitToolchain.Instance)); + } + + // Mirrors SKShaper.FONT_SIZE_SCALE (internal to SkiaSharp.HarfBuzz). + private const int FontSizeScale = 512; + + // Approximate shaped-glyph counts: a word, a short line, a paragraph-ish run. + [Params(3, 24, 96)] + public int Glyphs { get; set; } + + private SKTypeface typeface; + private Blob blob; + private Face face; + private Font hbFont; + private Buffer buffer; + + private int len; + private float textSizeX; + private float textSizeY; + + [GlobalSetup] + public void Setup() + { + var fontBytes = LoadFont(); + using (var data = SKData.CreateCopy(fontBytes)) + typeface = SKTypeface.FromData(data); + + // Build the HarfBuzz font exactly as SKShaper's constructor does. + blob = typeface.OpenStream(out var index).ToHarfBuzzBlob(); + face = new Face(blob, index) + { + Index = index, + UnitsPerEm = typeface.UnitsPerEm, + }; + hbFont = new Font(face); + hbFont.SetScale(FontSizeScale, FontSizeScale); + hbFont.SetFunctionsOpenType(); + + // A realistic complex-script (Arabic) run of roughly `Glyphs` glyphs. + var reps = Math.Max(1, Glyphs / 3); + var sb = new StringBuilder(reps * 4); + for (var i = 0; i < reps; i++) + { + if (i > 0) + sb.Append(' '); + sb.Append("متن"); + } + + buffer = new Buffer(); + buffer.AddUtf8(sb.ToString()); + buffer.GuessSegmentProperties(); + hbFont.Shape(buffer); + + len = buffer.Length; + + // SKShaper computes these from font.Size / font.ScaleX (default ScaleX == 1). + const float fontSize = 64f; + textSizeY = fontSize / FontSizeScale; + textSizeX = textSizeY * 1f; + } + + [GlobalCleanup] + public void Cleanup() + { + buffer?.Dispose(); + hbFont?.Dispose(); + face?.Dispose(); + blob?.Dispose(); + typeface?.Dispose(); + } + + // Old: the shipped SKShaper.Shape extraction — buffer.GlyphInfos/GlyphPositions each allocate a + // GlyphInfo[]/GlyphPosition[] copy (via ToArray) that is read once and discarded. + [Benchmark(Baseline = true)] + public int Old() + { + var info = buffer.GlyphInfos; + var pos = buffer.GlyphPositions; + + var points = new SKPoint[len]; + var clusters = new uint[len]; + var codepoints = new uint[len]; + + float xOffset = 0, yOffset = 0; + for (var i = 0; i < len; i++) + { + codepoints[i] = info[i].Codepoint; + clusters[i] = info[i].Cluster; + points[i] = new SKPoint( + xOffset + pos[i].XOffset * textSizeX, + yOffset - pos[i].YOffset * textSizeY); + xOffset += pos[i].XAdvance * textSizeX; + yOffset += pos[i].YAdvance * textSizeY; + } + + return points.Length + clusters.Length + codepoints.Length; + } + + // New: identical extraction over the zero-copy spans — no GlyphInfo[]/GlyphPosition[] copy. + [Benchmark] + public int New() + { + var info = buffer.GetGlyphInfoSpan(); + var pos = buffer.GetGlyphPositionSpan(); + + var points = new SKPoint[len]; + var clusters = new uint[len]; + var codepoints = new uint[len]; + + float xOffset = 0, yOffset = 0; + for (var i = 0; i < len; i++) + { + codepoints[i] = info[i].Codepoint; + clusters[i] = info[i].Cluster; + points[i] = new SKPoint( + xOffset + pos[i].XOffset * textSizeX, + yOffset - pos[i].YOffset * textSizeY); + xOffset += pos[i].XAdvance * textSizeX; + yOffset += pos[i].YAdvance * textSizeY; + } + + return points.Length + clusters.Length + codepoints.Length; + } + + private static byte[] LoadFont() + { + var asm = typeof(SKShaperGlyphExtractionBenchmark).Assembly; + using var stream = asm.GetManifestResourceStream("content-font.ttf") + ?? throw new InvalidOperationException("Embedded font 'content-font.ttf' was not found."); + var bytes = new byte[stream.Length]; + var read = 0; + while (read < bytes.Length) + { + var n = stream.Read(bytes, read, bytes.Length - read); + if (n == 0) + break; + read += n; + } + return bytes; + } +} diff --git a/benchmarks/SkiaSharp.Benchmarks/SkiaSharp.Benchmarks.csproj b/benchmarks/SkiaSharp.Benchmarks/SkiaSharp.Benchmarks.csproj index c71f6dfa9f4..587651d884b 100644 --- a/benchmarks/SkiaSharp.Benchmarks/SkiaSharp.Benchmarks.csproj +++ b/benchmarks/SkiaSharp.Benchmarks/SkiaSharp.Benchmarks.csproj @@ -28,6 +28,13 @@ + + + + + diff --git a/source/SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz/SKShaper.cs b/source/SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz/SKShaper.cs index 05cb568c5c8..b428d6984d7 100644 --- a/source/SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz/SKShaper.cs +++ b/source/SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz/SKShaper.cs @@ -68,8 +68,8 @@ public Result Shape(Buffer buffer, float xOffset, float yOffset, SKFont font) // get the shaping results var len = buffer.Length; - var info = buffer.GlyphInfos; - var pos = buffer.GlyphPositions; + var info = buffer.GetGlyphInfoSpan(); + var pos = buffer.GetGlyphPositionSpan(); // get the sizes float textSizeY = font.Size / FONT_SIZE_SCALE; diff --git a/tests/Tests/SkiaSharp/SKShaperGlyphExtractionTests.cs b/tests/Tests/SkiaSharp/SKShaperGlyphExtractionTests.cs new file mode 100644 index 00000000000..f87fd8d4e46 --- /dev/null +++ b/tests/Tests/SkiaSharp/SKShaperGlyphExtractionTests.cs @@ -0,0 +1,121 @@ +using System.IO; +using HarfBuzzSharp; +using SkiaSharp.Tests; +using Xunit; +using Buffer = HarfBuzzSharp.Buffer; + +namespace SkiaSharp.HarfBuzz.Tests +{ + // Guards the SKShaper.Shape glyph-extraction optimization: reading the shaping results through the + // zero-copy buffer.GetGlyphInfoSpan()/GetGlyphPositionSpan() instead of the allocating + // buffer.GlyphInfos/GlyphPositions array getters must produce a byte-for-byte identical + // SKShaper.Result. These tests pin that equivalence so the two accessors can never silently drift. + public class SKShaperGlyphExtractionTests : SKTest + { + // The zero-copy span accessors must expose exactly the same glyph data as the array getters — + // this is the invariant the SKShaper.Shape substitution relies on. + [Fact] + public void GlyphSpanAccessorsMatchArrayAccessors() + { + using var tf = SKTypeface.FromFile(Path.Combine(PathToFonts, "content-font.ttf")); + using var shaper = new SKShaper(tf); + using var font = new SKFont { Size = 64, Typeface = tf }; + using var buffer = new Buffer(); + + buffer.AddUtf8("متن متن متن"); + buffer.GuessSegmentProperties(); + + // Shape in place using the shaper's own font (this is what SKShaper.Shape does internally). + shaper.Shape(buffer, font); + + var infoArray = buffer.GlyphInfos; + var infoSpan = buffer.GetGlyphInfoSpan(); + var posArray = buffer.GlyphPositions; + var posSpan = buffer.GetGlyphPositionSpan(); + + Assert.True(infoArray.Length > 0); + Assert.Equal(infoArray.Length, infoSpan.Length); + Assert.Equal(posArray.Length, posSpan.Length); + + for (var i = 0; i < infoArray.Length; i++) + { + Assert.Equal(infoArray[i], infoSpan[i]); + Assert.Equal(posArray[i], posSpan[i]); + } + } + + // The shipped SKShaper.Shape(Buffer, ...) result must equal, bit-for-bit, an independent oracle + // computed from the array getters (the pre-optimization data source). Passes trivially before the + // change (array vs array), must still pass after it (span vs array — proving the data is identical), + // and turns red if the extraction ever diverges. + [Theory] + [InlineData("content-font.ttf", "متن", 0f, 0f)] + [InlineData("content-font.ttf", "متن", 100f, 200f)] + [InlineData("content-font.ttf", "متن متن متن", 12.5f, -7.25f)] + [InlineData("content-font.ttf", " متن ", 3f, 3f)] + [InlineData("segoeui.ttf", "SkiaSharp", 0f, 0f)] + [InlineData("segoeui.ttf", "SkiaSharp shaping!", 300f, 100f)] + public void ShapeResultMatchesArrayGetterOracle(string fontFile, string text, float xOffset, float yOffset) + { + using var tf = SKTypeface.FromFile(Path.Combine(PathToFonts, fontFile)); + using var shaper = new SKShaper(tf); + using var font = new SKFont { Size = 64, Typeface = tf }; + using var buffer = new Buffer(); + + buffer.AddUtf8(text); + buffer.GuessSegmentProperties(); + + // Subject: the shipped extraction (reads the glyph spans after the optimization). + var result = shaper.Shape(buffer, xOffset, yOffset, font); + + // Oracle: recompute from the SAME now-shaped buffer via the array getters, mirroring the exact + // arithmetic in SKShaper.Shape so any behavioural divergence is caught. + var info = buffer.GlyphInfos; + var pos = buffer.GlyphPositions; + var len = buffer.Length; + + const int fontSizeScale = 512; // SKShaper.FONT_SIZE_SCALE (internal) + var textSizeY = font.Size / fontSizeScale; + var textSizeX = textSizeY * font.ScaleX; + + var expectedCodepoints = new uint[len]; + var expectedClusters = new uint[len]; + var expectedPoints = new SKPoint[len]; + var x = xOffset; + var y = yOffset; + for (var i = 0; i < len; i++) + { + expectedCodepoints[i] = info[i].Codepoint; + expectedClusters[i] = info[i].Cluster; + expectedPoints[i] = new SKPoint( + x + pos[i].XOffset * textSizeX, + y - pos[i].YOffset * textSizeY); + x += pos[i].XAdvance * textSizeX; + y += pos[i].YAdvance * textSizeY; + } + var expectedWidth = x - xOffset; + + Assert.True(len > 0); + Assert.Equal(expectedCodepoints, result.Codepoints); + Assert.Equal(expectedClusters, result.Clusters); + Assert.Equal(expectedPoints, result.Points); + Assert.Equal(expectedWidth, result.Width); + } + + // The empty-input boundary must remain an empty result (unchanged by the extraction change). + [Fact] + public void EmptyTextReturnsEmptyResult() + { + using var tf = SKTypeface.FromFile(Path.Combine(PathToFonts, "content-font.ttf")); + using var shaper = new SKShaper(tf); + using var font = new SKFont { Size = 64, Typeface = tf }; + + var result = shaper.Shape(string.Empty, font); + + Assert.Empty(result.Codepoints); + Assert.Empty(result.Clusters); + Assert.Empty(result.Points); + Assert.Equal(0f, result.Width); + } + } +}