Skip to content

Performance improvements for Encoding & Decoding #41

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
7 changes: 7 additions & 0 deletions Sqids.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
5 changes: 5 additions & 0 deletions src/Sqids.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using BenchmarkDotNet.Running;

BenchmarkSwitcher
.FromAssembly(typeof(Program).Assembly)
.RunAllJoined();
15 changes: 15 additions & 0 deletions src/Sqids.Benchmarks/SqidIdDecoderBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using BenchmarkDotNet.Attributes;

namespace Sqids.Benchmarks;

[MemoryDiagnoser]
public class SqidIdDecoderBenchmark
{
private readonly SqidsEncoder<long> _sqidsEncoder = new ();

[Params("Uk", "DzPWXTJADcE", "bMZn4Y5Fq8QTCJoLjxPvGfB9Dh6mlz1Sgcu0KpkMyOEiIdrsHRW2VZtweX3aA7UNbhFm8ZG04y52lzNU6di")]
public string EncodedId { get; set; } = string.Empty;

[Benchmark]
public IReadOnlyList<long> Decode() => _sqidsEncoder.Decode(EncodedId);
}
15 changes: 15 additions & 0 deletions src/Sqids.Benchmarks/SqidIdEncoderBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using BenchmarkDotNet.Attributes;

namespace Sqids.Benchmarks;

[MemoryDiagnoser]
public class SqidIdEncoderBenchmark
{
private readonly SqidsEncoder<long> _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);
}
18 changes: 18 additions & 0 deletions src/Sqids.Benchmarks/Sqids.Benchmarks.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Sqids\Sqids.csproj" />
</ItemGroup>

</Project>
133 changes: 90 additions & 43 deletions src/Sqids/SqidsEncoder.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Buffers;
#if NET7_0_OR_GREATER
using System.Numerics;
#endif
Expand Down Expand Up @@ -29,6 +30,11 @@ public sealed class SqidsEncoder
private readonly int _minLength;
private readonly string[] _blockList;

#if NET8_0_OR_GREATER
private readonly SearchValues<char> _digitValues = SearchValues.Create("0123456789");
private readonly SearchValues<char> _alphabetValues;
#endif

#if NET7_0_OR_GREATER
/// <summary>
/// Initializes a new instance of <see cref="SqidsEncoder{T}" /> with the default options.
Expand Down Expand Up @@ -119,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);
}

Expand Down Expand Up @@ -234,43 +243,59 @@ private string Encode(ReadOnlySpan<int> 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 encodedNumber = ToId(number, alphabetWithoutSeparator);
builder.Append(encodedNumber);

if (i >= numbers.Length - 1) // NOTE: If the last one
continue;
builder.Append(prefix);

char separator = alphabetTemp[0];
builder.Append(separator);
ConsistentShuffle(alphabetTemp);
}

if (builder.Length < _minLength)
{
char separator = alphabetTemp[0];
builder.Append(separator);
for (int i = 0; i < numbers.Length; i++)
{
var number = numbers[i];
var alphabetWithoutSeparator = alphabetTemp[1..]; // NOTE: Excludes the first character — which is the separator
var tempBuffer = ArrayPool<char>.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<char>.Shared.Return(tempBuffer);
}
}

while (builder.Length < _minLength)
if (builder.Length < _minLength)
{
ConsistentShuffle(alphabetTemp);
int toIndex = Math.Min(_minLength - builder.Length, _alphabet.Length);
builder.Append(alphabetTemp[..toIndex]);
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]);
}
}
}

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);
}
}

/// <summary>
Expand All @@ -295,12 +320,16 @@ public IReadOnlyList<int> Decode(ReadOnlySpan<char> id)
return Array.Empty<int>();
#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<T>();
#else
return Array.Empty<int>();
return [];
#endif

var alphabetSpan = _alphabet.AsSpan();
Expand Down Expand Up @@ -370,7 +399,7 @@ private bool IsBlockedId(ReadOnlySpan<char> 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;
Expand All @@ -393,29 +422,32 @@ private static void ConsistentShuffle(Span<char> chars)
}

#if NET7_0_OR_GREATER
private static ReadOnlySpan<char> ToId(T num, ReadOnlySpan<char> alphabet)
private static ReadOnlySpan<char> ToId(T num, ReadOnlySpan<char> alphabet, Span<char> buffer)
#else
private static ReadOnlySpan<char> ToId(int num, ReadOnlySpan<char> alphabet)
private static ReadOnlySpan<char> ToId(int num, ReadOnlySpan<char> alphabet, Span<char> 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
Expand All @@ -435,4 +467,19 @@ private static int ToNumber(ReadOnlySpan<char> id, ReadOnlySpan<char> alphabet)
#endif
return result;
}

private bool ContainsDigit(ReadOnlySpan<char> 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
}
}
104 changes: 104 additions & 0 deletions src/Sqids/StringBuilderPool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using System.Collections.Concurrent;

namespace Sqids;

/// <summary>
/// Provides a thread-safe object pool for reusing <see cref="StringBuilder"/> instances.
/// This helps to reduce memory allocations and garbage collection pressure, particularly
/// in scenarios involving frequent string manipulations.
/// </summary>
/// <remarks>
/// This class implements the Singleton pattern via the <see cref="Instance"/> property to ensure
/// a single, globally accessible pool. It uses a <see cref="ConcurrentBag{T}"/> internally,
/// making the <see cref="Rent"/> and <see cref="Return"/> operations thread-safe.
///
/// Usage pattern:
/// <code>
/// 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);
/// }
/// </code>
/// It is crucial to always return the rented <see cref="StringBuilder"/> instance back to the pool
/// using the <see cref="Return"/> 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.
/// </remarks>
public class StringBuilderPool
{
private readonly ConcurrentBag<StringBuilder> _pool = [];
private static StringBuilderPool? _instance;

/// <summary>
/// Gets the singleton instance of the <see cref="StringBuilderPool"/>.
/// </summary>
/// <value>The singleton <see cref="StringBuilderPool"/> instance.</value>
public static StringBuilderPool Instance => _instance ??= new StringBuilderPool();

/// <summary>
/// Initializes a new instance of the <see cref="StringBuilderPool"/> class
/// and optionally pre-populates the pool.
/// </summary>
/// <param name="initialCapacity">The initial number of <see cref="StringBuilder"/> instances to create and add to the pool. Defaults to 16.</param>
/// <exception cref="ArgumentOutOfRangeException">Thrown if <paramref name="initialCapacity"/> is less than 0.</exception>
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());
}
}

/// <summary>
/// Rents a <see cref="StringBuilder"/> instance from the pool.
/// </summary>
/// <returns>
/// A cleared <see cref="StringBuilder"/> instance obtained from the pool if available;
/// otherwise, a new <see cref="StringBuilder"/> instance.
/// </returns>
/// <remarks>
/// The returned <see cref="StringBuilder"/> is cleared before being provided to the caller.
/// If the pool is empty, a new instance is allocated. Remember to return the instance
/// using <see cref="Return"/> when finished.
/// </remarks>
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();
}

/// <summary>
/// Returns a <see cref="StringBuilder"/> instance to the pool.
/// </summary>
/// <param name="sb">The <see cref="StringBuilder"/> instance to return to the pool.</param>
/// <remarks>
/// The provided <see cref="StringBuilder"/> 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 <see cref="Rent"/> to ensure proper pool functioning.
/// Typically called within a `finally` block.
/// </remarks>
public void Return(StringBuilder sb)
{
// Clear the StringBuilder before putting it back into the pool
sb.Clear();
_pool.Add(sb);
}
}