From a207ab373c0cfba039ff4cdd2948ea00181217b2 Mon Sep 17 00:00:00 2001 From: Jan Biehl Date: Sat, 19 Apr 2025 20:23:17 +0200 Subject: [PATCH 1/5] refactored ToId to not allocate any memory --- src/Sqids/SqidsEncoder.cs | 45 +++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/Sqids/SqidsEncoder.cs b/src/Sqids/SqidsEncoder.cs index 98636a1..704d6db 100644 --- a/src/Sqids/SqidsEncoder.cs +++ b/src/Sqids/SqidsEncoder.cs @@ -1,3 +1,4 @@ +using System.Buffers; #if NET7_0_OR_GREATER using System.Numerics; #endif @@ -241,15 +242,24 @@ private string Encode(ReadOnlySpan numbers, int increment = 0) { var number = numbers[i]; var alphabetWithoutSeparator = alphabetTemp[1..]; // NOTE: Excludes the first character — which is the separator - var encodedNumber = ToId(number, alphabetWithoutSeparator); - builder.Append(encodedNumber); + var tempBuffer = ArrayPool.Shared.Rent(256); - if (i >= numbers.Length - 1) // NOTE: If the last one - continue; + try + { + var encodedNumber = ToId(number, alphabetWithoutSeparator, tempBuffer); + builder.Append(encodedNumber); - char separator = alphabetTemp[0]; - builder.Append(separator); - ConsistentShuffle(alphabetTemp); + if (i >= numbers.Length - 1) // NOTE: If the last one + continue; + + char separator = alphabetTemp[0]; + builder.Append(separator); + ConsistentShuffle(alphabetTemp); + } + finally + { + ArrayPool.Shared.Return(tempBuffer); + } } if (builder.Length < _minLength) @@ -393,29 +403,32 @@ private static void ConsistentShuffle(Span chars) } #if NET7_0_OR_GREATER - private static ReadOnlySpan ToId(T num, ReadOnlySpan alphabet) + private static ReadOnlySpan ToId(T num, ReadOnlySpan alphabet, Span buffer) #else - private static ReadOnlySpan ToId(int num, ReadOnlySpan alphabet) + private static ReadOnlySpan ToId(int num, ReadOnlySpan alphabet, Span buffer) #endif { - var id = new StringBuilder(); + var baseLength = alphabet.Length; + var result = num; + var index = buffer.Length; #if NET7_0_OR_GREATER do { - id.Insert(0, alphabet[int.CreateChecked(result % T.CreateChecked(alphabet.Length))]); - result /= T.CreateChecked(alphabet.Length); + index--; + buffer[index] = alphabet[int.CreateChecked(result % T.CreateChecked(baseLength))]; + result /= T.CreateChecked(baseLength); } while (result > T.Zero); #else do { - id.Insert(0, alphabet[result % alphabet.Length]); - result /= alphabet.Length; + index--; + buffer[index] = alphabet[result % baseLength]; + result /= baseLength; } while (result > 0); #endif - - return id.ToString().AsSpan(); // TODO: possibly avoid creating a string + return buffer.Slice(index, buffer.Length - index); } #if NET7_0_OR_GREATER From 270c2337575f1189ed2e540c083450de7302f5d7 Mon Sep 17 00:00:00 2001 From: Jan Biehl Date: Sat, 19 Apr 2025 20:29:45 +0200 Subject: [PATCH 2/5] refactored IsBlockedId to not allocate memory --- src/Sqids/SqidsEncoder.cs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Sqids/SqidsEncoder.cs b/src/Sqids/SqidsEncoder.cs index 704d6db..a425c5d 100644 --- a/src/Sqids/SqidsEncoder.cs +++ b/src/Sqids/SqidsEncoder.cs @@ -30,6 +30,10 @@ public sealed class SqidsEncoder private readonly int _minLength; private readonly string[] _blockList; +#if NET8_0_OR_GREATER + private readonly SearchValues _digitValues = SearchValues.Create("0123456789"); +#endif + #if NET7_0_OR_GREATER /// /// Initializes a new instance of with the default options. @@ -380,7 +384,7 @@ private bool IsBlockedId(ReadOnlySpan id) id.Equals(word.AsSpan(), StringComparison.OrdinalIgnoreCase)) return true; - if (word.Any(char.IsDigit) && + if (ContainsDigit(word.AsSpan()) && (id.StartsWith(word.AsSpan(), StringComparison.OrdinalIgnoreCase) || id.EndsWith(word.AsSpan(), StringComparison.OrdinalIgnoreCase))) return true; @@ -448,4 +452,19 @@ private static int ToNumber(ReadOnlySpan id, ReadOnlySpan alphabet) #endif return result; } + + private bool ContainsDigit(ReadOnlySpan value) + { +#if NET8_0_OR_GREATER + return value.ContainsAny(_digitValues); +#else + for (var i = 0; i < value.Length; i++) + { + if (char.IsDigit(value[i])) + return true; + } + + return false; +#endif + } } From b8329675ff81a637765520b07478a1143e8a0235 Mon Sep 17 00:00:00 2001 From: Jan Biehl Date: Sat, 19 Apr 2025 20:33:48 +0200 Subject: [PATCH 3/5] Use pooled string builder instances --- src/Sqids/SqidsEncoder.cs | 77 +++++++++++++----------- src/Sqids/StringBuilderPool.cs | 104 +++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 35 deletions(-) create mode 100644 src/Sqids/StringBuilderPool.cs diff --git a/src/Sqids/SqidsEncoder.cs b/src/Sqids/SqidsEncoder.cs index a425c5d..f612b0f 100644 --- a/src/Sqids/SqidsEncoder.cs +++ b/src/Sqids/SqidsEncoder.cs @@ -239,52 +239,59 @@ private string Encode(ReadOnlySpan numbers, int increment = 0) char prefix = alphabetTemp[0]; alphabetTemp.Reverse(); - var builder = new StringBuilder(); // TODO: pool a la Hashids.net? - builder.Append(prefix); - - for (int i = 0; i < numbers.Length; i++) + var builder = StringBuilderPool.Instance.Rent(); + try { - var number = numbers[i]; - var alphabetWithoutSeparator = alphabetTemp[1..]; // NOTE: Excludes the first character — which is the separator - var tempBuffer = ArrayPool.Shared.Rent(256); + builder.Append(prefix); - try + for (int i = 0; i < numbers.Length; i++) { - var encodedNumber = ToId(number, alphabetWithoutSeparator, tempBuffer); - builder.Append(encodedNumber); - - if (i >= numbers.Length - 1) // NOTE: If the last one - continue; + var number = numbers[i]; + var alphabetWithoutSeparator = alphabetTemp[1..]; // NOTE: Excludes the first character — which is the separator + var tempBuffer = ArrayPool.Shared.Rent(256); + + try + { + var encodedNumber = ToId(number, alphabetWithoutSeparator, tempBuffer); + builder.Append(encodedNumber); + + if (i >= numbers.Length - 1) // NOTE: If the last one + continue; + + char separator = alphabetTemp[0]; + builder.Append(separator); + ConsistentShuffle(alphabetTemp); + } + finally + { + ArrayPool.Shared.Return(tempBuffer); + } + } + if (builder.Length < _minLength) + { char separator = alphabetTemp[0]; builder.Append(separator); - ConsistentShuffle(alphabetTemp); - } - finally - { - ArrayPool.Shared.Return(tempBuffer); - } - } - if (builder.Length < _minLength) - { - char separator = alphabetTemp[0]; - builder.Append(separator); - - while (builder.Length < _minLength) - { - ConsistentShuffle(alphabetTemp); - int toIndex = Math.Min(_minLength - builder.Length, _alphabet.Length); - builder.Append(alphabetTemp[..toIndex]); + while (builder.Length < _minLength) + { + ConsistentShuffle(alphabetTemp); + int toIndex = Math.Min(_minLength - builder.Length, _alphabet.Length); + builder.Append(alphabetTemp[..toIndex]); + } } - } - string result = builder.ToString(); + string result = builder.ToString(); - if (IsBlockedId(result.AsSpan())) - result = Encode(numbers, increment + 1); + if (IsBlockedId(result.AsSpan())) + result = Encode(numbers, increment + 1); - return result; + return result; + } + finally + { + StringBuilderPool.Instance.Return(builder); + } } /// diff --git a/src/Sqids/StringBuilderPool.cs b/src/Sqids/StringBuilderPool.cs new file mode 100644 index 0000000..8942f80 --- /dev/null +++ b/src/Sqids/StringBuilderPool.cs @@ -0,0 +1,104 @@ +using System.Collections.Concurrent; + +namespace Sqids; + +/// +/// Provides a thread-safe object pool for reusing instances. +/// This helps to reduce memory allocations and garbage collection pressure, particularly +/// in scenarios involving frequent string manipulations. +/// +/// +/// This class implements the Singleton pattern via the property to ensure +/// a single, globally accessible pool. It uses a internally, +/// making the and operations thread-safe. +/// +/// Usage pattern: +/// +/// var sb = StringBuilderPool.Instance.Rent(); +/// try +/// { +/// // Use the StringBuilder instance (sb) +/// sb.Append("example"); +/// // ... other operations ... +/// string result = sb.ToString(); +/// } +/// finally +/// { +/// StringBuilderPool.Instance.Return(sb); +/// } +/// +/// It is crucial to always return the rented instance back to the pool +/// using the method within a finally block to prevent resource leaks +/// and ensure the pool remains effective. Failure to return instances will lead to the pool +/// creating new instances when depleted, negating the benefits of pooling. +/// +public class StringBuilderPool +{ + private readonly ConcurrentBag _pool = []; + private static StringBuilderPool? _instance; + + /// + /// Gets the singleton instance of the . + /// + /// The singleton instance. + public static StringBuilderPool Instance => _instance ??= new StringBuilderPool(); + + /// + /// Initializes a new instance of the class + /// and optionally pre-populates the pool. + /// + /// The initial number of instances to create and add to the pool. Defaults to 16. + /// Thrown if is less than 0. + public StringBuilderPool(int initialCapacity = 16) + { + if (initialCapacity < 0) throw new ArgumentOutOfRangeException(nameof(initialCapacity)); + + // Pre-populate the pool for better initial performance + for (var i = 0; i < initialCapacity; i++) + { + _pool.Add(new StringBuilder()); + } + } + + /// + /// Rents a instance from the pool. + /// + /// + /// A cleared instance obtained from the pool if available; + /// otherwise, a new instance. + /// + /// + /// The returned is cleared before being provided to the caller. + /// If the pool is empty, a new instance is allocated. Remember to return the instance + /// using when finished. + /// + public StringBuilder Rent() + { + if (_pool.TryTake(out var sb)) + { + // Ensure the StringBuilder is clean before reuse + sb.Clear(); + return sb; + } + + // Pool is empty, create a new instance + return new StringBuilder(); + } + + /// + /// Returns a instance to the pool. + /// + /// The instance to return to the pool. + /// + /// The provided instance is cleared before being added back + /// to the pool, preparing it for the next renter. It is crucial to call this method + /// for every instance obtained via to ensure proper pool functioning. + /// Typically called within a `finally` block. + /// + public void Return(StringBuilder sb) + { + // Clear the StringBuilder before putting it back into the pool + sb.Clear(); + _pool.Add(sb); + } +} From 028251acf6f757c705b9e5f4c5053018cb467360 Mon Sep 17 00:00:00 2001 From: Jan Biehl Date: Sat, 19 Apr 2025 20:37:18 +0200 Subject: [PATCH 4/5] improved decoding speed --- src/Sqids/SqidsEncoder.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Sqids/SqidsEncoder.cs b/src/Sqids/SqidsEncoder.cs index f612b0f..b15160f 100644 --- a/src/Sqids/SqidsEncoder.cs +++ b/src/Sqids/SqidsEncoder.cs @@ -32,6 +32,7 @@ public sealed class SqidsEncoder #if NET8_0_OR_GREATER private readonly SearchValues _digitValues = SearchValues.Create("0123456789"); + private readonly SearchValues _alphabetValues; #endif #if NET7_0_OR_GREATER @@ -124,6 +125,9 @@ public SqidsEncoder(SqidsOptions options) _blockList = blockList.ToArray(); // NOTE: Arrays are faster to iterate than HashSets, so we construct an array here. _alphabet = options.Alphabet.ToCharArray(); +#if NET8_0_OR_GREATER + _alphabetValues = SearchValues.Create(_alphabet); +#endif ConsistentShuffle(_alphabet); } @@ -316,12 +320,16 @@ public IReadOnlyList Decode(ReadOnlySpan id) return Array.Empty(); #endif +#if NET8_0_OR_GREATER + foreach (var c in id) + { + if (!_alphabetValues.Contains(c)) + return []; + } +#else foreach (char c in id) if (!_alphabet.Contains(c)) -#if NET7_0_OR_GREATER - return Array.Empty(); -#else - return Array.Empty(); + return []; #endif var alphabetSpan = _alphabet.AsSpan(); From 61b1cce024f10666d6e44763ba8bf79734a1eb11 Mon Sep 17 00:00:00 2001 From: Jan Biehl Date: Sat, 19 Apr 2025 22:11:31 +0200 Subject: [PATCH 5/5] Benchmark project --- Sqids.sln | 7 +++++++ src/Sqids.Benchmarks/Program.cs | 5 +++++ src/Sqids.Benchmarks/SqidIdDecoderBenchmark.cs | 15 +++++++++++++++ src/Sqids.Benchmarks/SqidIdEncoderBenchmark.cs | 15 +++++++++++++++ src/Sqids.Benchmarks/Sqids.Benchmarks.csproj | 18 ++++++++++++++++++ 5 files changed, 60 insertions(+) create mode 100644 src/Sqids.Benchmarks/Program.cs create mode 100644 src/Sqids.Benchmarks/SqidIdDecoderBenchmark.cs create mode 100644 src/Sqids.Benchmarks/SqidIdEncoderBenchmark.cs create mode 100644 src/Sqids.Benchmarks/Sqids.Benchmarks.csproj diff --git a/Sqids.sln b/Sqids.sln index 9de449b..e9cfaac 100644 --- a/Sqids.sln +++ b/Sqids.sln @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{5B69EBB5-A EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sqids.Tests", "test\Sqids.Tests\Sqids.Tests.csproj", "{26D42DEF-5A42-436C-8B80-44AA4917BFC1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sqids.Benchmarks", "src\Sqids.Benchmarks\Sqids.Benchmarks.csproj", "{FA1E422D-46BC-462C-9FB3-695BB177268B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -28,9 +30,14 @@ Global {26D42DEF-5A42-436C-8B80-44AA4917BFC1}.Debug|Any CPU.Build.0 = Debug|Any CPU {26D42DEF-5A42-436C-8B80-44AA4917BFC1}.Release|Any CPU.ActiveCfg = Release|Any CPU {26D42DEF-5A42-436C-8B80-44AA4917BFC1}.Release|Any CPU.Build.0 = Release|Any CPU + {FA1E422D-46BC-462C-9FB3-695BB177268B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA1E422D-46BC-462C-9FB3-695BB177268B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA1E422D-46BC-462C-9FB3-695BB177268B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA1E422D-46BC-462C-9FB3-695BB177268B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {387B307E-04C6-4B8E-BE50-03FF91307070} = {FC64F776-DE51-4BFF-91D5-0ECFEEF5CCAC} {26D42DEF-5A42-436C-8B80-44AA4917BFC1} = {5B69EBB5-A05C-4DCB-9355-D010B0A093AE} + {FA1E422D-46BC-462C-9FB3-695BB177268B} = {FC64F776-DE51-4BFF-91D5-0ECFEEF5CCAC} EndGlobalSection EndGlobal diff --git a/src/Sqids.Benchmarks/Program.cs b/src/Sqids.Benchmarks/Program.cs new file mode 100644 index 0000000..3799c8c --- /dev/null +++ b/src/Sqids.Benchmarks/Program.cs @@ -0,0 +1,5 @@ +using BenchmarkDotNet.Running; + +BenchmarkSwitcher + .FromAssembly(typeof(Program).Assembly) + .RunAllJoined(); diff --git a/src/Sqids.Benchmarks/SqidIdDecoderBenchmark.cs b/src/Sqids.Benchmarks/SqidIdDecoderBenchmark.cs new file mode 100644 index 0000000..1f13329 --- /dev/null +++ b/src/Sqids.Benchmarks/SqidIdDecoderBenchmark.cs @@ -0,0 +1,15 @@ +using BenchmarkDotNet.Attributes; + +namespace Sqids.Benchmarks; + +[MemoryDiagnoser] +public class SqidIdDecoderBenchmark +{ + private readonly SqidsEncoder _sqidsEncoder = new (); + + [Params("Uk", "DzPWXTJADcE", "bMZn4Y5Fq8QTCJoLjxPvGfB9Dh6mlz1Sgcu0KpkMyOEiIdrsHRW2VZtweX3aA7UNbhFm8ZG04y52lzNU6di")] + public string EncodedId { get; set; } = string.Empty; + + [Benchmark] + public IReadOnlyList Decode() => _sqidsEncoder.Decode(EncodedId); +} diff --git a/src/Sqids.Benchmarks/SqidIdEncoderBenchmark.cs b/src/Sqids.Benchmarks/SqidIdEncoderBenchmark.cs new file mode 100644 index 0000000..423f858 --- /dev/null +++ b/src/Sqids.Benchmarks/SqidIdEncoderBenchmark.cs @@ -0,0 +1,15 @@ +using BenchmarkDotNet.Attributes; + +namespace Sqids.Benchmarks; + +[MemoryDiagnoser] +public class SqidIdEncoderBenchmark +{ + private readonly SqidsEncoder _sqidsEncoder = new (); + + [Params(1, 100, 1_000, 1_000_000, 1_000_000_000)] + public int Id { get; set; } + + [Benchmark] + public string Encode() => _sqidsEncoder.Encode(Id); +} diff --git a/src/Sqids.Benchmarks/Sqids.Benchmarks.csproj b/src/Sqids.Benchmarks/Sqids.Benchmarks.csproj new file mode 100644 index 0000000..6314801 --- /dev/null +++ b/src/Sqids.Benchmarks/Sqids.Benchmarks.csproj @@ -0,0 +1,18 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + +