diff --git a/Sqids.sln b/Sqids.sln index 9de449b..9e2b4e5 100644 --- a/Sqids.sln +++ b/Sqids.sln @@ -11,6 +11,10 @@ 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("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmark", "benchmark", "{F165CB60-0269-4F53-8F6C-F61EF172947C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sqids.Benchmarks", "benchmark\Sqids.Benchmarks\Sqids.Benchmarks.csproj", "{7892A604-1098-4088-A806-01787BE78521}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -28,9 +32,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 + {7892A604-1098-4088-A806-01787BE78521}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7892A604-1098-4088-A806-01787BE78521}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7892A604-1098-4088-A806-01787BE78521}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7892A604-1098-4088-A806-01787BE78521}.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} + {7892A604-1098-4088-A806-01787BE78521} = {F165CB60-0269-4F53-8F6C-F61EF172947C} EndGlobalSection EndGlobal diff --git a/benchmark/Sqids.Benchmarks/EncodeBenchmark.cs b/benchmark/Sqids.Benchmarks/EncodeBenchmark.cs new file mode 100644 index 0000000..8bbf10d --- /dev/null +++ b/benchmark/Sqids.Benchmarks/EncodeBenchmark.cs @@ -0,0 +1,31 @@ +using BenchmarkDotNet.Attributes; + +namespace Sqids.Benchmarks; + +[MemoryDiagnoser] +public class EncodeBenchmark +{ +#if NET7_0_OR_GREATER + private SqidsEncoder _encoder = new SqidsEncoder(); +#else + private SqidsEncoder _encoder = new SqidsEncoder(); +#endif + + [Benchmark] + public string EncodeSmall() => _encoder.Encode(42); + + [Benchmark] + public string EncodeBig() => _encoder.Encode(int.MaxValue); + + [Benchmark] + public string EncodeMany() => _encoder.Encode(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + + [Benchmark] + public IReadOnlyList DecodeSmall() => _encoder.Decode("Jg"); + + [Benchmark] + public IReadOnlyList DecodeBig() => _encoder.Decode("UKrsQ1F"); + + [Benchmark] + public IReadOnlyList DecodeMany() => _encoder.Decode("hwB5vcCxfAyBnVKMtAaV"); +} diff --git a/benchmark/Sqids.Benchmarks/Program.cs b/benchmark/Sqids.Benchmarks/Program.cs new file mode 100644 index 0000000..43759a5 --- /dev/null +++ b/benchmark/Sqids.Benchmarks/Program.cs @@ -0,0 +1,20 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Environments; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Running; + +namespace Sqids.Benchmarks; + +public static class Program +{ + public static void Main(string[] args) + { + IConfig config = ManualConfig.CreateMinimumViable() + .AddJob(Job.Default.WithRuntime(ClrRuntime.Net472)) + .AddJob(Job.Default.WithRuntime(CoreRuntime.Core60)) + .AddJob(Job.Default.WithRuntime(CoreRuntime.Core70)) + .AddJob(Job.Default.WithRuntime(CoreRuntime.Core80)); + + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); + } +} diff --git a/benchmark/Sqids.Benchmarks/Sqids.Benchmarks.csproj b/benchmark/Sqids.Benchmarks/Sqids.Benchmarks.csproj new file mode 100644 index 0000000..f667236 --- /dev/null +++ b/benchmark/Sqids.Benchmarks/Sqids.Benchmarks.csproj @@ -0,0 +1,20 @@ + + + + + net472;net6.0;net7.0;net8.0 + 12.0 + enable + enable + exe + + + + + + + + + + + diff --git a/src/Sqids/SqidsEncoder.cs b/src/Sqids/SqidsEncoder.cs index 6a8e199..74d0b92 100644 --- a/src/Sqids/SqidsEncoder.cs +++ b/src/Sqids/SqidsEncoder.cs @@ -1,5 +1,7 @@ +using System.Runtime.CompilerServices; #if NET7_0_OR_GREATER using System.Numerics; +using System.Runtime.CompilerServices; #endif namespace Sqids; @@ -71,13 +73,13 @@ public SqidsEncoder(SqidsOptions options) if (options.Alphabet.Distinct().Count() != options.Alphabet.Length) throw new ArgumentOutOfRangeException( - nameof(options.MinLength), + nameof(options.Alphabet), "The alphabet must not contain duplicate characters." ); if (Encoding.UTF8.GetByteCount(options.Alphabet) != options.Alphabet.Length) throw new ArgumentOutOfRangeException( - nameof(options.MinLength), + nameof(options.Alphabet), "The alphabet must not contain multi-byte characters." ); @@ -96,21 +98,28 @@ public SqidsEncoder(SqidsOptions options) _minLength = options.MinLength; // NOTE: Cleanup the blocklist: - options.BlockList = new HashSet( - options.BlockList, + HashSet blockList = new HashSet( StringComparer.OrdinalIgnoreCase // NOTE: Effectively removes items that differ only in casing — leaves one version of each word casing-wise which will then be compared against the generated IDs case-insensitively ); - options.BlockList.RemoveWhere(w => - // NOTE: Removes words that are less than 3 characters long - w.Length < 3 || - // NOTE: Removes words that contain characters not found in the alphabet + + foreach (string w in options.BlockList) + { + if ( + // NOTE: Removes words that are less than 3 characters long + w.Length < 3 || + // NOTE: Removes words that contain characters not found in the alphabet #if NETSTANDARD2_0 - w.Any(c => options.Alphabet.IndexOf(c.ToString(), StringComparison.OrdinalIgnoreCase) == -1) // NOTE: A `string.Contains` overload with `StringComparison` didn't exist prior to .NET Standard 2.1, so we have to resort to `IndexOf` — see https://stackoverflow.com/a/52791476 + w.Any(c => options.Alphabet.IndexOf(c.ToString(), StringComparison.OrdinalIgnoreCase) == -1) // NOTE: A `string.Contains` overload with `StringComparison` didn't exist prior to .NET Standard 2.1, so we have to resort to `IndexOf` — see https://stackoverflow.com/a/52791476 #else - w.Any(c => !options.Alphabet.Contains(c, StringComparison.OrdinalIgnoreCase)) + w.Any(c => !options.Alphabet.Contains(c, StringComparison.OrdinalIgnoreCase)) #endif - ); - _blockList = [.. options.BlockList]; // NOTE: Arrays are faster to iterate than HashSets, so we construct an array here. + ) + continue; + + blockList.Add(w); + } + + _blockList = blockList.ToArray(); // NOTE: Arrays are faster to iterate than HashSets, so we construct an array here. _alphabet = options.Alphabet.ToCharArray(); ConsistentShuffle(_alphabet); @@ -231,12 +240,14 @@ private string Encode(ReadOnlySpan numbers, int increment = 0) var builder = new StringBuilder(); // TODO: pool a la Hashids.net? builder.Append(prefix); + Span idBuffer = stackalloc char[64]; //NOTE: The smallest alphabet (3 chars) with the largest value (ulong.MaxValue) needs 64 bytes + 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 encodedNumber = ToId(number, alphabetWithoutSeparator); - builder.Append(encodedNumber); + int written = ToId(number, alphabetWithoutSeparator, idBuffer); + builder.Append(idBuffer[^written..]); // NOTE: The buffer is written from the end towards the beginning if (i >= numbers.Length - 1) // NOTE: If the last one continue; @@ -387,29 +398,34 @@ private static void ConsistentShuffle(Span chars) } #if NET7_0_OR_GREATER - private static ReadOnlySpan ToId(T num, ReadOnlySpan alphabet) + private static int ToId(T num, ReadOnlySpan alphabet, Span buffer) #else - private static ReadOnlySpan ToId(int num, ReadOnlySpan alphabet) + private static int ToId(int num, ReadOnlySpan alphabet, Span buffer) #endif { - var id = new StringBuilder(); - var result = num; + int offset = buffer.Length - 1; // NOTE: Start at the end of the buffer + +#if NET7_0_OR_GREATER + T alphaLen = T.CreateChecked(alphabet.Length); +#else + int alphaLen = alphabet.Length; +#endif #if NET7_0_OR_GREATER do { - id.Insert(0, alphabet[int.CreateChecked(result % T.CreateChecked(alphabet.Length))]); - result /= T.CreateChecked(alphabet.Length); - } while (result > T.Zero); + buffer[offset--] = alphabet[int.CreateChecked(num % alphaLen)]; + num /= alphaLen; + } while (num > T.Zero); #else do { - id.Insert(0, alphabet[result % alphabet.Length]); - result /= alphabet.Length; - } while (result > 0); + buffer[offset--] = alphabet[num % alphaLen]; + num /= alphaLen; + } while (num > 0); #endif - - return id.ToString().AsSpan(); // TODO: possibly avoid creating a string + // NOTE: Return how much we have written back to the caller + return buffer.Length - offset - 1; } #if NET7_0_OR_GREATER