Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
using System;
using System.Reflection;
using System.Text;
Comment on lines +1 to +3
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;
Comment on lines +166 to +175
}
}
7 changes: 7 additions & 0 deletions benchmarks/SkiaSharp.Benchmarks/SkiaSharp.Benchmarks.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
<None Remove="BenchmarkDotNet.Artifacts/**" />
</ItemGroup>

<ItemGroup>
<!-- Real font used by SKShaperGlyphExtractionBenchmark to shape text. Linked from the test
content (not duplicated into the repo) and embedded so it loads regardless of the working
directory BenchmarkDotNet spawns the process from. -->
<EmbeddedResource Include="..\..\tests\Content\fonts\content-font.ttf" Link="content-font.ttf" LogicalName="content-font.ttf" />
</ItemGroup>

<Import Project="..\..\binding\IncludeNativeAssets.SkiaSharp.targets" />
<Import Project="..\..\binding\IncludeNativeAssets.HarfBuzzSharp.targets" />

Expand Down
4 changes: 2 additions & 2 deletions source/SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz/SKShaper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
121 changes: 121 additions & 0 deletions tests/Tests/SkiaSharp/SKShaperGlyphExtractionTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading