Browse Source

Rune extensions micro-optimizations (#3910)

* Add benchmarks for potentially optimizable RuneExtensions

* Add new RuneExtensions.DecodeSurrogatePair benchmark implementation

Avoids intermediate heap array allocations which is especially nice when the rune is not surrogate pair because then array heap allocations are completely avoided.

* Enable nullable reference types in RuneExtensions

* Make RuneExtensions.MaxUnicodeCodePoint readonly

Makes sure no one can accidentally change the value. Ideally would be const value.

* Optimize RuneExtensions.DecodeSurrogatePair

* Remove duplicate Rune.GetUnicodeCategory call

* Add new RuneExtensions.IsSurrogatePair benchmark implementation

Avoids intermediate heap allocations by using stack allocated buffer.

* Optimize RuneExtensions.IsSurrogatePair

* Add RuneExtensions.GetEncodingLength tests

* Optimize RuneExtensions.GetEncodingLength

* Optimize RuneExtensions.Encode

* Print encoding name in benchmark results

* Rename variable to better match return description

* Add RuneExtensions.EncodeSurrogatePair benchmark

---------

Co-authored-by: Tig <[email protected]>
Tonttu 5 months ago
parent
commit
e24bd67658

+ 20 - 0
Benchmarks/Benchmarks.csproj

@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net8.0</TargetFramework>
+    <IsPackable>false</IsPackable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <RootNamespace>Terminal.Gui.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\Terminal.Gui\Terminal.Gui.csproj" />
+  </ItemGroup>
+
+</Project>

+ 20 - 0
Benchmarks/Program.cs

@@ -0,0 +1,20 @@
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Running;
+
+namespace Terminal.Gui.Benchmarks;
+
+class Program
+{
+    static void Main (string [] args)
+    {
+        var config = DefaultConfig.Instance;
+
+        // Uncomment for faster but less accurate intermediate iteration.
+        // Final benchmarks should be run with at least the default run length.
+        //config = config.AddJob (BenchmarkDotNet.Jobs.Job.ShortRun);
+
+        BenchmarkSwitcher
+            .FromAssembly (typeof (Program).Assembly)
+            .Run(args, config);
+    }
+}

+ 66 - 0
Benchmarks/Text/RuneExtensions/DecodeSurrogatePair.cs

@@ -0,0 +1,66 @@
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using Tui = Terminal.Gui;
+
+namespace Terminal.Gui.Benchmarks.Text.RuneExtensions;
+
+/// <summary>
+/// Benchmarks for <see cref="Tui.RuneExtensions.DecodeSurrogatePair"/> performance fine-tuning.
+/// </summary>
+[MemoryDiagnoser]
+[BenchmarkCategory (nameof (Tui.RuneExtensions))]
+public class DecodeSurrogatePair
+{
+    /// <summary>
+    /// Benchmark for previous implementation.
+    /// </summary>
+    /// <param name="rune"></param>
+    /// <returns></returns>
+    [Benchmark]
+    [ArgumentsSource (nameof (DataSource))]
+    public char []? Previous (Rune rune)
+    {
+        _ = RuneToStringToCharArray (rune, out char []? chars);
+        return chars;
+    }
+
+    /// <summary>
+    /// Benchmark for current implementation.
+    /// 
+    /// Utilizes Rune methods that take Span argument avoiding intermediate heap array allocation when combined with stack allocated intermediate buffer.
+    /// When rune is not surrogate pair there will be no heap allocation.
+    /// 
+    /// Final surrogate pair array allocation cannot be avoided due to the current method signature design.
+    /// Changing the method signature, or providing an alternative method, to take a destination Span would allow further optimizations by allowing caller to reuse buffer for consecutive calls.
+    /// </summary>
+    [Benchmark (Baseline = true)]
+    [ArgumentsSource (nameof (DataSource))]
+    public char []? Current (Rune rune)
+    {
+        _ = Tui.RuneExtensions.DecodeSurrogatePair (rune, out char []? chars);
+        return chars;
+    }
+
+    /// <summary>
+    /// Previous implementation with intermediate string allocation.
+    /// 
+    /// The IsSurrogatePair implementation at the time had hidden extra string allocation so there were intermediate heap allocations even if rune is not surrogate pair.
+    /// </summary>
+    private static bool RuneToStringToCharArray (Rune rune, out char []? chars)
+    {
+        if (rune.IsSurrogatePair ())
+        {
+            chars = rune.ToString ().ToCharArray ();
+            return true;
+        }
+
+        chars = null;
+        return false;
+    }
+
+    public static IEnumerable<object> DataSource ()
+    {
+        yield return new Rune ('a');
+        yield return "𝔹".EnumerateRunes ().Single ();
+    }
+}

+ 72 - 0
Benchmarks/Text/RuneExtensions/Encode.cs

@@ -0,0 +1,72 @@
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using Tui = Terminal.Gui;
+
+namespace Terminal.Gui.Benchmarks.Text.RuneExtensions;
+
+/// <summary>
+/// Benchmarks for <see cref="Tui.RuneExtensions.Encode"/> performance fine-tuning.
+/// </summary>
+[MemoryDiagnoser]
+[BenchmarkCategory (nameof (Tui.RuneExtensions))]
+public class Encode
+{
+    /// <summary>
+    /// Benchmark for previous implementation.
+    /// </summary>
+    [Benchmark]
+    [ArgumentsSource (nameof (DataSource))]
+    public byte [] Previous (Rune rune, byte [] destination, int start, int count)
+    {
+        _ = StringEncodingGetBytes (rune, destination, start, count);
+        return destination;
+    }
+
+    /// <summary>
+    /// Benchmark for current implementation.
+    /// 
+    /// Avoids intermediate heap allocations with stack allocated intermediate buffer.
+    /// </summary>
+    [Benchmark (Baseline = true)]
+    [ArgumentsSource (nameof (DataSource))]
+    public byte [] Current (Rune rune, byte [] destination, int start, int count)
+    {
+        _ = Tui.RuneExtensions.Encode (rune, destination, start, count);
+        return destination;
+    }
+
+    /// <summary>
+    /// Previous implementation with intermediate byte array and string allocation.
+    /// </summary>
+    private static int StringEncodingGetBytes (Rune rune, byte [] dest, int start = 0, int count = -1)
+    {
+        byte [] bytes = Encoding.UTF8.GetBytes (rune.ToString ());
+        var length = 0;
+
+        for (var i = 0; i < (count == -1 ? bytes.Length : count); i++)
+        {
+            if (bytes [i] == 0)
+            {
+                break;
+            }
+
+            dest [start + i] = bytes [i];
+            length++;
+        }
+
+        return length;
+    }
+
+    public static IEnumerable<object []> DataSource ()
+    {
+        Rune[] runes = [ new Rune ('a'),"𝔞".EnumerateRunes().Single() ];
+
+        foreach (var rune in runes)
+        {
+            yield return new object [] { rune, new byte [16], 0, -1 };
+            yield return new object [] { rune, new byte [16], 8, -1 };
+            // Does not work in original implementation
+            //yield return new object [] { rune, new byte [16], 8, 8 };
+        }
+    }
+}

+ 36 - 0
Benchmarks/Text/RuneExtensions/EncodeSurrogatePair.cs

@@ -0,0 +1,36 @@
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using Tui = Terminal.Gui;
+
+namespace Terminal.Gui.Benchmarks.Text.RuneExtensions;
+
+/// <summary>
+/// Benchmarks for <see cref="Tui.RuneExtensions.EncodeSurrogatePair"/> performance fine-tuning.
+/// </summary>
+[MemoryDiagnoser]
+[BenchmarkCategory (nameof (Tui.RuneExtensions))]
+public class EncodeSurrogatePair
+{
+    /// <summary>
+    /// Benchmark for current implementation.
+    /// </summary>
+    [Benchmark (Baseline = true)]
+    [ArgumentsSource (nameof (DataSource))]
+    public Rune Current (char highSurrogate, char lowSurrogate)
+    {
+        _ = Tui.RuneExtensions.EncodeSurrogatePair (highSurrogate, lowSurrogate, out Rune rune);
+        return rune;
+    }
+
+    public static IEnumerable<object []> DataSource ()
+    {
+        string[] runeStrings = ["🍕", "🧠", "🌹"];
+        foreach (string symbol in runeStrings)
+        {
+            if (symbol is [char high, char low])
+            {
+                yield return [high, low];
+            }
+        }
+    }
+}

+ 74 - 0
Benchmarks/Text/RuneExtensions/GetEncodingLength.cs

@@ -0,0 +1,74 @@
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using Tui = Terminal.Gui;
+
+namespace Terminal.Gui.Benchmarks.Text.RuneExtensions;
+
+/// <summary>
+/// Benchmarks for <see cref="Tui.RuneExtensions.GetEncodingLength"/> performance fine-tuning.
+/// </summary>
+[MemoryDiagnoser]
+[BenchmarkCategory (nameof (Tui.RuneExtensions))]
+public class GetEncodingLength
+{
+    /// <summary>
+    /// Benchmark for previous implementation.
+    /// </summary>
+    [Benchmark]
+    [ArgumentsSource (nameof (DataSource))]
+    public int Previous (Rune rune, PrettyPrintedEncoding encoding)
+    {
+        return WithEncodingGetBytesArray (rune, encoding);
+    }
+
+    /// <summary>
+    /// Benchmark for current implementation.
+    /// </summary>
+    [Benchmark (Baseline = true)]
+    [ArgumentsSource (nameof (DataSource))]
+    public int Current (Rune rune, PrettyPrintedEncoding encoding)
+    {
+        return Tui.RuneExtensions.GetEncodingLength (rune, encoding);
+    }
+
+    /// <summary>
+    /// Previous implementation with intermediate byte array, string, and char array allocation.
+    /// </summary>
+    private static int WithEncodingGetBytesArray (Rune rune, Encoding? encoding = null)
+    {
+        encoding ??= Encoding.UTF8;
+        byte [] bytes = encoding.GetBytes (rune.ToString ().ToCharArray ());
+        var offset = 0;
+
+        if (bytes [^1] == 0)
+        {
+            offset++;
+        }
+
+        return bytes.Length - offset;
+    }
+
+    public static IEnumerable<object []> DataSource ()
+    {
+        PrettyPrintedEncoding[] encodings = [ new(Encoding.UTF8), new(Encoding.Unicode), new(Encoding.UTF32) ];
+        Rune[] runes = [ new Rune ('a'), "𝔹".EnumerateRunes ().Single () ];
+
+        foreach (var encoding in encodings)
+        {
+            foreach (Rune rune in runes)
+            {
+                yield return [rune, encoding];
+            }
+        }
+    }
+
+    /// <summary>
+    /// <see cref="System.Text.Encoding"/> wrapper to display proper encoding name in benchmark results.
+    /// </summary>
+    public record PrettyPrintedEncoding (Encoding Encoding)
+    {
+        public static implicit operator Encoding (PrettyPrintedEncoding ppe) => ppe.Encoding;
+
+        public override string ToString () => Encoding.HeaderName;
+    }
+}

+ 50 - 0
Benchmarks/Text/RuneExtensions/IsSurrogatePair.cs

@@ -0,0 +1,50 @@
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using Tui = Terminal.Gui;
+
+namespace Terminal.Gui.Benchmarks.Text.RuneExtensions;
+
+/// <summary>
+/// Benchmarks for <see cref="Tui.RuneExtensions.IsSurrogatePair"/> performance fine-tuning.
+/// </summary>
+[MemoryDiagnoser]
+[BenchmarkCategory (nameof (Tui.RuneExtensions))]
+public class IsSurrogatePair
+{
+    /// <summary>
+    /// Benchmark for previous implementation.
+    /// </summary>
+    /// <param name="rune"></param>
+    [Benchmark]
+    [ArgumentsSource (nameof (DataSource))]
+    public bool Previous (Rune rune)
+    {
+        return WithToString (rune);
+    }
+
+    /// <summary>
+    /// Benchmark for current implementation.
+    /// 
+    /// Avoids intermediate heap allocations by using stack allocated buffer.
+    /// </summary>
+    [Benchmark (Baseline = true)]
+    [ArgumentsSource (nameof (DataSource))]
+    public bool Current (Rune rune)
+    {
+        return Tui.RuneExtensions.IsSurrogatePair (rune);
+    }
+
+    /// <summary>
+    /// Previous implementation with intermediate string allocation.
+    /// </summary>
+    private static bool WithToString (Rune rune)
+    {
+        return char.IsSurrogatePair (rune.ToString (), 0);
+    }
+
+    public static IEnumerable<object> DataSource ()
+    {
+        yield return new Rune ('a');
+        yield return "𝔹".EnumerateRunes ().Single ();
+    }
+}

+ 57 - 24
Terminal.Gui/Text/RuneExtensions.cs

@@ -1,4 +1,6 @@
-using System.Globalization;
+#nullable enable
+
+using System.Globalization;
 using Wcwidth;
 
 namespace Terminal.Gui;
@@ -7,7 +9,7 @@ namespace Terminal.Gui;
 public static class RuneExtensions
 {
     /// <summary>Maximum Unicode code point.</summary>
-    public static int MaxUnicodeCodePoint = 0x10FFFF;
+    public static readonly int MaxUnicodeCodePoint = 0x10FFFF;
 
     /// <summary>Reports if the provided array of bytes can be encoded as UTF-8.</summary>
     /// <param name="buffer">The byte array to probe.</param>
@@ -32,17 +34,25 @@ public static class RuneExtensions
     /// <param name="rune">The rune to decode.</param>
     /// <param name="chars">The chars if the rune is a surrogate pair. Null otherwise.</param>
     /// <returns><see langword="true"/> if the rune is a valid surrogate pair; <see langword="false"/> otherwise.</returns>
-    public static bool DecodeSurrogatePair (this Rune rune, out char [] chars)
+    public static bool DecodeSurrogatePair (this Rune rune, out char []? chars)
     {
-        if (rune.IsSurrogatePair ())
+        bool isSingleUtf16CodeUnit = rune.IsBmp;
+        if (isSingleUtf16CodeUnit)
         {
-            chars = rune.ToString ().ToCharArray ();
+            chars = null;
+            return false;
+        }
 
+        const int maxCharsPerRune = 2;
+        Span<char> charBuffer = stackalloc char[maxCharsPerRune];
+        int charsWritten = rune.EncodeToUtf16 (charBuffer);
+        if (charsWritten >= 2 && char.IsSurrogatePair (charBuffer [0], charBuffer [1]))
+        {
+            chars = charBuffer [..charsWritten].ToArray ();
             return true;
         }
 
         chars = null;
-
         return false;
     }
 
@@ -55,21 +65,24 @@ public static class RuneExtensions
     /// <returns>he number of bytes written into the destination buffer.</returns>
     public static int Encode (this Rune rune, byte [] dest, int start = 0, int count = -1)
     {
-        byte [] bytes = Encoding.UTF8.GetBytes (rune.ToString ());
-        var length = 0;
-
-        for (var i = 0; i < (count == -1 ? bytes.Length : count); i++)
+        const int maxUtf8BytesPerRune = 4;
+        Span<byte> bytes = stackalloc byte[maxUtf8BytesPerRune];
+        int writtenBytes = rune.EncodeToUtf8 (bytes);
+
+        int bytesToCopy = count == -1
+            ? writtenBytes
+            : Math.Min (count, writtenBytes);
+        int bytesWritten = 0;
+        for (int i = 0; i < bytesToCopy; i++)
         {
-            if (bytes [i] == 0)
+            if (bytes [i] == '\0')
             {
                 break;
             }
-
             dest [start + i] = bytes [i];
-            length++;
+            bytesWritten++;
         }
-
-        return length;
+        return bytesWritten;
     }
 
     /// <summary>Attempts to encode (as UTF-16) a surrogate pair.</summary>
@@ -105,18 +118,26 @@ public static class RuneExtensions
     /// <param name="rune">The rune to probe.</param>
     /// <param name="encoding">The encoding used; the default is UTF8.</param>
     /// <returns>The number of bytes required.</returns>
-    public static int GetEncodingLength (this Rune rune, Encoding encoding = null)
+    public static int GetEncodingLength (this Rune rune, Encoding? encoding = null)
     {
         encoding ??= Encoding.UTF8;
-        byte [] bytes = encoding.GetBytes (rune.ToString ().ToCharArray ());
-        var offset = 0;
 
-        if (bytes [^1] == 0)
+        const int maxCharsPerRune = 2;
+        // Get characters with UTF16 to keep that part independent of selected encoding.
+        Span<char> charBuffer = stackalloc char[maxCharsPerRune];
+        int charsWritten = rune.EncodeToUtf16(charBuffer);
+        Span<char> chars = charBuffer[..charsWritten];
+
+        int maxEncodedLength = encoding.GetMaxByteCount (charsWritten);
+        Span<byte> byteBuffer = stackalloc byte[maxEncodedLength];
+        int bytesEncoded = encoding.GetBytes (chars, byteBuffer);
+        ReadOnlySpan<byte> encodedBytes = byteBuffer[..bytesEncoded];
+
+        if (encodedBytes [^1] == '\0')
         {
-            offset++;
+            return encodedBytes.Length - 1;
         }
-
-        return bytes.Length - offset;
+        return encodedBytes.Length;
     }
 
     /// <summary>Returns <see langword="true"/> if the rune is a combining character.</summary>
@@ -127,7 +148,7 @@ public static class RuneExtensions
     {
         UnicodeCategory category = Rune.GetUnicodeCategory (rune);
 
-        return Rune.GetUnicodeCategory (rune) == UnicodeCategory.NonSpacingMark
+        return category == UnicodeCategory.NonSpacingMark
                || category == UnicodeCategory.SpacingCombiningMark
                || category == UnicodeCategory.EnclosingMark;
     }
@@ -136,7 +157,19 @@ public static class RuneExtensions
     /// <remarks>This is a Terminal.Gui extension method to <see cref="System.Text.Rune"/> to support TUI text manipulation.</remarks>
     /// <param name="rune">The rune to probe.</param>
     /// <returns><see langword="true"/> if the rune is a surrogate code point; <see langword="false"/> otherwise.</returns>
-    public static bool IsSurrogatePair (this Rune rune) { return char.IsSurrogatePair (rune.ToString (), 0); }
+    public static bool IsSurrogatePair (this Rune rune)
+    {
+        bool isSingleUtf16CodeUnit = rune.IsBmp;
+        if (isSingleUtf16CodeUnit)
+        {
+            return false;
+        }
+
+        const int maxCharsPerRune = 2;
+        Span<char> charBuffer = stackalloc char[maxCharsPerRune];
+        int charsWritten = rune.EncodeToUtf16 (charBuffer);
+        return charsWritten >= 2 && char.IsSurrogatePair (charBuffer [0], charBuffer [1]);
+    }
 
     /// <summary>
     ///     Ensures the rune is not a control character and can be displayed by translating characters below 0x20 to

+ 6 - 0
Terminal.sln

@@ -48,6 +48,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SelfContained", "SelfContai
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NativeAot", "NativeAot\NativeAot.csproj", "{E6D716C6-AC94-4150-B10A-44AE13F79344}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarks", "Benchmarks\Benchmarks.csproj", "{242FBD3E-2EC6-4274-BD40-8E62AF9327B2}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -86,6 +88,10 @@ Global
 		{E6D716C6-AC94-4150-B10A-44AE13F79344}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{E6D716C6-AC94-4150-B10A-44AE13F79344}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{E6D716C6-AC94-4150-B10A-44AE13F79344}.Release|Any CPU.Build.0 = Release|Any CPU
+		{242FBD3E-2EC6-4274-BD40-8E62AF9327B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{242FBD3E-2EC6-4274-BD40-8E62AF9327B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{242FBD3E-2EC6-4274-BD40-8E62AF9327B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{242FBD3E-2EC6-4274-BD40-8E62AF9327B2}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE

+ 17 - 0
UnitTests/Text/RuneTests.cs

@@ -902,6 +902,23 @@ public class RuneTests
         Assert.Equal (3, splitOnComma.Length);
     }
 
+    [Theory]
+    [InlineData ("a", "utf-8", 1)]
+    [InlineData ("a", "utf-16", 1)]
+    [InlineData ("a", "utf-32", 3)]
+    [InlineData ("𝔹", "utf-8", 4)]
+    [InlineData ("𝔹", "utf-16", 4)]
+    [InlineData ("𝔹", "utf-32", 3)]
+    public void GetEncodingLength_ReturnsLengthBasedOnSelectedEncoding (string runeStr, string encodingName, int expectedLength)
+    {
+        Rune rune = runeStr.EnumerateRunes ().Single ();
+        var encoding = Encoding.GetEncoding (encodingName);
+
+        int actualLength = rune.GetEncodingLength (encoding);
+
+        Assert.Equal (expectedLength, actualLength);
+    }
+
     private int CountLettersInString (string s)
     {
         var letterCount = 0;