Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,67 @@
using System;
using BenchmarkDotNet.Attributes;

namespace SkiaSharp.Benchmarks;

// New vs Old for SKFourByteTag.Parse(string).
//
// Old = the current shipped implementation (allocates a char[4] scratch buffer per call).
// New = the proposed managed fast path (no allocation; reads chars directly).
//
// The real caller shape is font/OpenType tag hydration: feature tags ("liga", "kern"),
// variation-axis tags ("wght", "wdth", "slnt"), and table tags parsed from strings, often a
// batch of many at once when configuring a font. This benchmark parses a representative batch
// of such tags (plus null/empty/short/over-long edges) so both the happy path and the padding
// branches are exercised.
[MemoryDiagnoser]
public class SKFourByteTagParseBenchmark
{
private string[] tags;

[GlobalSetup]
public void GlobalSetup()
{
// A realistic mix of 4-char tags, plus edge shapes that hit the padding/truncation code.
tags = new[]
{
"liga", "kern", "wght", "wdth", "slnt", "ital", "opsz", "GSUB",
"GPOS", "cmap", "head", "name", "OS/2", "post", "glyf", "loca",
"a", "bc", "def", "toolong", "", null,
};
}

[Benchmark(Baseline = true)]
public uint Old()
{
uint sink = 0;
foreach (var t in tags)
sink ^= OldParse(t);
return sink;
}

[Benchmark]
public uint New()
{
uint sink = 0;
foreach (var t in tags)
sink ^= (uint)SKFourByteTag.Parse(t);
return sink;
}

// Verbatim copy of the ORIGINAL shipped Parse(string) body, so the ratio is honest.
private static uint OldParse(string tag)
{
if (string.IsNullOrEmpty(tag))
return 0;

var realTag = new char[4];
var len = Math.Min(4, tag.Length);
var i = 0;
for (; i < len; i++)
realTag[i] = tag[i];
for (; i < 4; i++)
realTag[i] = ' ';

return (uint)(((byte)realTag[0] << 24) | ((byte)realTag[1] << 16) | ((byte)realTag[2] << 8) | (byte)realTag[3]);
}
}
23 changes: 13 additions & 10 deletions binding/SkiaSharp/SKFourByteTag.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,23 @@ public SKFourByteTag (char c1, char c2, char c3, char c4)
value = (uint)(((byte)c1 << 24) | ((byte)c2 << 16) | ((byte)c3 << 8) | (byte)c4);
}

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

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

var realTag = new char[4];
var len = Math.Min (4, tag!.Length);
var i = 0;
for (; i < len; i++)
realTag[i] = tag[i];
for (; i < 4; i++)
realTag[i] = ' ';
// Take up to the first four characters, padding any missing trailing
// slots with spaces — matching the original char[4]-scratch behaviour
// without allocating the scratch array.
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] : ' ';
Comment thread
mattleibow marked this conversation as resolved.
Outdated

return new SKFourByteTag (realTag[0], realTag[1], realTag[2], realTag[3]);
return new SKFourByteTag (c1, c2, c3, c4);
}

public override string ToString () =>
Expand Down
70 changes: 70 additions & 0 deletions tests/Tests/SkiaSharp/SKFourByteTagTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using Xunit;

Expand Down Expand Up @@ -118,6 +119,75 @@ public void FourByteTagEqualsObjectWorks ()
Assert.False (tag.Equals ("wght")); // wrong type
}

// Reference implementation: the ORIGINAL char[4]-scratch algorithm, kept verbatim as the
// equivalence oracle so the managed rewrite is proven bit-for-bit identical.
private static uint ReferenceParse (string tag)
{
if (string.IsNullOrEmpty (tag))
return 0;

var realTag = new char[4];
var len = Math.Min (4, tag.Length);
var i = 0;
for (; i < len; i++)
realTag[i] = tag[i];
for (; i < 4; i++)
realTag[i] = ' ';

return (uint)(((byte)realTag[0] << 24) | ((byte)realTag[1] << 16) | ((byte)realTag[2] << 8) | (byte)realTag[3]);
}

public static IEnumerable<object[]> EquivalenceInputs ()
{
var inputs = new[]
{
null, "", " ", "a", "ab", "abc", "abcd", "abcde", "abcdef",
"wght", "wdth", "slnt", "opsz", "ital", "GSUB", "GPOS", "OS/2",
" ", "\0\0\0\0", "\t\r\n ",
// characters above 0xFF: only the low byte is kept, exactly like the old code
"\u0100\u0141\u2764\uFFFF", "é\u00e9\u00ffz", "\uABCDwght"[..4],
// surrogate-ish high code units (still just char code units to the algorithm)
"\uD83D\uDE00xy",
};
foreach (var s in inputs)
yield return new object[] { s };
}

[Theory]
[MemberData (nameof (EquivalenceInputs))]
public void FourByteTagParseMatchesReference (string input)
{
var expected = ReferenceParse (input);
var actual = (uint)SKFourByteTag.Parse (input);
Assert.Equal (expected, actual);
}

[Theory]
[MemberData (nameof (EquivalenceInputs))]
public void FourByteTagParseSpanOverloadMatchesStringOverload (string input)
{
var fromString = (uint)SKFourByteTag.Parse (input);
var fromSpan = (uint)SKFourByteTag.Parse (input.AsSpan ());
Assert.Equal (fromString, fromSpan);
}

[Fact]
public void FourByteTagParseSpanFromSlicedStringMatches ()
{
// A span carved out of a larger string (no substring allocation) must parse the same
// as the equivalent standalone string.
const string source = "xxwghtyy";
var slice = source.AsSpan (2, 4);
Assert.Equal ((uint)SKFourByteTag.Parse ("wght"), (uint)SKFourByteTag.Parse (slice));
}

[Fact]
public void FourByteTagParseEmptySpanReturnsZero ()
{
Assert.Equal (0u, (uint)SKFourByteTag.Parse (ReadOnlySpan<char>.Empty));
Assert.Equal (0u, (uint)SKFourByteTag.Parse (default (ReadOnlySpan<char>)));
}

[Fact]
public void FourByteTagSurvivesNativeRoundTrip ()
{
Expand Down
Loading