diff --git a/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKFourByteTagParseBenchmark.cs b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKFourByteTagParseBenchmark.cs new file mode 100644 index 00000000000..b105139603f --- /dev/null +++ b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKFourByteTagParseBenchmark.cs @@ -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]); + } +} diff --git a/binding/SkiaSharp/SKFourByteTag.cs b/binding/SkiaSharp/SKFourByteTag.cs index c77ad4ea5fd..7905aa3ff37 100644 --- a/binding/SkiaSharp/SKFourByteTag.cs +++ b/binding/SkiaSharp/SKFourByteTag.cs @@ -16,20 +16,24 @@ 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 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. The first character always + // exists here because empty input was handled by the guard above. + var c1 = tag[0]; + var c2 = tag.Length > 1 ? tag[1] : ' '; + var c3 = tag.Length > 2 ? tag[2] : ' '; + var c4 = tag.Length > 3 ? tag[3] : ' '; - return new SKFourByteTag (realTag[0], realTag[1], realTag[2], realTag[3]); + return new SKFourByteTag (c1, c2, c3, c4); } public override string ToString () => diff --git a/tests/Tests/SkiaSharp/SKFourByteTagTest.cs b/tests/Tests/SkiaSharp/SKFourByteTagTest.cs index 08872dd2964..a8cb56fd1ba 100644 --- a/tests/Tests/SkiaSharp/SKFourByteTagTest.cs +++ b/tests/Tests/SkiaSharp/SKFourByteTagTest.cs @@ -118,6 +118,62 @@ public void FourByteTagEqualsObjectWorks () Assert.False (tag.Equals ("wght")); // wrong type } + // Expected values are precomputed constants rather than the output of a runtime reference + // implementation, so the oracle can never silently drift along with the code under test. + // Each expected value is the big-endian packing of the low byte of the first four UTF-16 + // code units, padding any missing trailing slots with spaces (0x20) and treating + // null/empty as zero. + [Theory] + [InlineData (null, 0x00000000u)] + [InlineData ("", 0x00000000u)] + [InlineData (" ", 0x20202020u)] + [InlineData ("a", 0x61202020u)] + [InlineData ("ab", 0x61622020u)] + [InlineData ("abc", 0x61626320u)] + [InlineData ("abcd", 0x61626364u)] + [InlineData ("abcde", 0x61626364u)] + [InlineData ("abcdef", 0x61626364u)] + [InlineData ("wght", 0x77676874u)] + [InlineData ("wdth", 0x77647468u)] + [InlineData ("slnt", 0x736C6E74u)] + [InlineData ("opsz", 0x6F70737Au)] + [InlineData ("ital", 0x6974616Cu)] + [InlineData ("GSUB", 0x47535542u)] + [InlineData ("GPOS", 0x47504F53u)] + [InlineData ("OS/2", 0x4F532F32u)] + [InlineData (" ", 0x20202020u)] + [InlineData ("\0\0\0\0", 0x00000000u)] + [InlineData ("\t\r\n ", 0x090D0A20u)] + // characters above 0xFF: only the low byte is kept + [InlineData ("\u0100\u0141\u2764\uFFFF", 0x004164FFu)] + [InlineData ("\u00e9\u00e9\u00ffz", 0xE9E9FF7Au)] + [InlineData ("\uABCDwgh", 0xCD776768u)] + // surrogate code units are still just char code units to the algorithm + [InlineData ("\uD83D\uDE00xy", 0x3D007879u)] + public void FourByteTagParseProducesExpectedValue (string input, uint expected) + { + // Both the string overload and the span overload must produce the same constant. + Assert.Equal (expected, (uint)SKFourByteTag.Parse (input)); + Assert.Equal (expected, (uint)SKFourByteTag.Parse (input.AsSpan ())); + } + + [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.Empty)); + Assert.Equal (0u, (uint)SKFourByteTag.Parse (default (ReadOnlySpan))); + } + [Fact] public void FourByteTagSurvivesNativeRoundTrip () {