diff --git a/.gitignore b/.gitignore index 940794e..09f0f9e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ x86/ bld/ [Bb]in/ [Oo]bj/ +[Bb]enchmark[Dd]ot[Nn]et.Artifacts/ [Ll]og/ # Visual Studio 2015 cache/options directory diff --git a/Base62.Benchmark/Base62.Benchmark.csproj b/Base62.Benchmark/Base62.Benchmark.csproj new file mode 100644 index 0000000..653f9f1 --- /dev/null +++ b/Base62.Benchmark/Base62.Benchmark.csproj @@ -0,0 +1,30 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + + + + validation_data.txt + PreserveNewest + false + + + + diff --git a/Base62.Benchmark/Base62Benchmark.cs b/Base62.Benchmark/Base62Benchmark.cs new file mode 100644 index 0000000..0712f50 --- /dev/null +++ b/Base62.Benchmark/Base62Benchmark.cs @@ -0,0 +1,98 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Order; + +namespace Base62.Benchmark; + +[Orderer(SummaryOrderPolicy.Method)] +public class Base62Benchmark +{ + private readonly Base62Converter _converter = new(); + private readonly Base62Converter _invertedConverter = new(Base62Converter.CharacterSet.INVERTED); + private string[] _inputs = []; + private string[] _encodedValues = []; + private byte[] _bytesWithLeadingZeros = []; + + [GlobalSetup] + public void GlobalSetup() + { + var validationPath = Path.Combine(AppContext.BaseDirectory, "validation_data.txt"); + var inputs = new List(); + var encoded = new List(); + + foreach (var line in File.ReadLines(validationPath)) + { + var parts = line.Split('\t'); + if (parts.Length < 2) + continue; + + inputs.Add(parts[0]); + encoded.Add(parts[1]); + } + + _inputs = inputs.ToArray(); + _encodedValues = encoded.ToArray(); + _bytesWithLeadingZeros = [0, 0, 1, 2, 0, 0]; + } + + [Benchmark] + public void EncodeString_AllValidationSamples() + { + foreach (var input in _inputs) + { + _ = _converter.Encode(input); + } + } + + [Benchmark] + public void DecodeString_AllValidationSamples() + { + foreach (var value in _encodedValues) + { + _ = _converter.Decode(value); + } + } + + [Benchmark] + public void EncodeDecode_RoundTrip_AllValidationSamples() + { + foreach (var input in _inputs) + { + var encoded = _converter.Encode(input); + _ = _converter.Decode(encoded); + } + } + + [Benchmark] + public void EncodeBytes_WithLeadingZeros() + { + _ = _converter.Encode(_bytesWithLeadingZeros); + } + + [Benchmark] + public void DecodeBytes_WithLeadingZeros() + { + var encoded = _converter.Encode(_bytesWithLeadingZeros); + _ = _converter.Decode(encoded); + } + + [Benchmark] + public void EncodeString_InvertedCharacterSet() + { + foreach (var input in _inputs) + { + _ = _invertedConverter.Encode(input); + } + } + + [Benchmark] + public void EncodeString_ShortAscii() + { + _ = _converter.Encode("120"); + } + + [Benchmark] + public void EncodeString_Unicode() + { + _ = _converter.Encode("love爱"); + } +} diff --git a/Base62.Benchmark/BenchmarkConfig.cs b/Base62.Benchmark/BenchmarkConfig.cs new file mode 100644 index 0000000..cf1439d --- /dev/null +++ b/Base62.Benchmark/BenchmarkConfig.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Environments; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Exporters.Csv; +using BenchmarkDotNet.Exporters.Json; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; + +namespace Base62.Benchmark; + +public class BenchmarkConfig : ManualConfig +{ + public BenchmarkConfig(IEnumerable latestPackageVersionNumbers) + { + var framework = CoreRuntime.Core10_0; + + AddExporter(MarkdownExporter.GitHub); + AddExporter(CsvExporter.Default); + AddExporter(JsonExporter.Default); + + AddLogger(ConsoleLogger.Default); + + AddColumnProvider(DefaultColumnProviders.Instance); + + AddDiagnoser(MemoryDiagnoser.Default); + + foreach (var versionNumber in latestPackageVersionNumbers) + { + var nugetPackageJob = Job.ShortRun + .WithRuntime(framework) + .WithMsBuildArguments($"/p:NugetPackageVersion={versionNumber}") + .WithId($"v{versionNumber}"); + + AddJob(nugetPackageJob); + } + + var currentProjectJob = Job.ShortRun + .WithRuntime(framework) + .WithId("current"); + + AddJob(currentProjectJob); + } +} diff --git a/Base62.Benchmark/Nuget/NugetPackage.cs b/Base62.Benchmark/Nuget/NugetPackage.cs new file mode 100644 index 0000000..ee56ed3 --- /dev/null +++ b/Base62.Benchmark/Nuget/NugetPackage.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Base62.Benchmark.Nuget; + +public class NugetPackage +{ + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("version")] + public required string Version { get; set; } +} diff --git a/Base62.Benchmark/Nuget/NugetSearchResultRoot.cs b/Base62.Benchmark/Nuget/NugetSearchResultRoot.cs new file mode 100644 index 0000000..b640f32 --- /dev/null +++ b/Base62.Benchmark/Nuget/NugetSearchResultRoot.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Base62.Benchmark.Nuget; + +public class NugetSearchResultRoot +{ + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("problems")] + public List Problems { get; set; } = []; + + [JsonPropertyName("searchResult")] + public List SearchResult { get; set; } = []; +} diff --git a/Base62.Benchmark/Nuget/NugetSourceResult.cs b/Base62.Benchmark/Nuget/NugetSourceResult.cs new file mode 100644 index 0000000..5de9b3b --- /dev/null +++ b/Base62.Benchmark/Nuget/NugetSourceResult.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Base62.Benchmark.Nuget; + +public class NugetSourceResult +{ + [JsonPropertyName("sourceName")] + public required string SourceName { get; set; } + + [JsonPropertyName("packages")] + public List Packages { get; set; } = []; +} diff --git a/Base62.Benchmark/Program.cs b/Base62.Benchmark/Program.cs new file mode 100644 index 0000000..99ec082 --- /dev/null +++ b/Base62.Benchmark/Program.cs @@ -0,0 +1,47 @@ +using System.Diagnostics; +using System.Text.Json; +using Base62.Benchmark.Nuget; +using BenchmarkDotNet.Running; + +namespace Base62.Benchmark; + +public static class Program +{ + public static async Task Main(string[] args) + { + var latestVersions = await GetLatestPackageVersionNumbers("Base62", topN: 1); + + _ = BenchmarkRunner.Run(new BenchmarkConfig(latestVersions)); + } + + private static async Task> GetLatestPackageVersionNumbers( + string packageName, + int topN = 1, + CancellationToken cancellationToken = default) + { + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"package search {packageName} --format json --exact-match", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start dotnet process"); + + await process.WaitForExitAsync(cancellationToken); + + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); + + var packages = JsonSerializer.Deserialize(output); + + var latestPackages = packages?.SearchResult + .SelectMany(sr => sr.Packages) + .OrderByDescending(p => Version.Parse(p.Version)) + .Take(topN); + + return latestPackages?.Select(p => p.Version).ToList() ?? []; + } +} diff --git a/Base62.Tests/Base62Tests.cs b/Base62.Tests/Base62Tests.cs index 966c6fb..5b7efab 100644 --- a/Base62.Tests/Base62Tests.cs +++ b/Base62.Tests/Base62Tests.cs @@ -66,6 +66,106 @@ public void FirstZeroBytesAreConvertedCorrectly() Assert.Equal(sourceBytes, decoded); } + [Fact] + public void EmptyString_EncodesAndDecodesToEmpty() + { + var converter = new Base62Converter(); + + var encoded = converter.Encode(string.Empty); + var decoded = converter.Decode(encoded); + + Assert.Equal(string.Empty, encoded); + Assert.Equal(string.Empty, decoded); + } + + [Fact] + public void EmptyByteArray_EncodesAndDecodesToEmpty() + { + var converter = new Base62Converter(); + var empty = Array.Empty(); + + var encoded = converter.Encode(empty); + var decoded = converter.Decode(encoded); + + Assert.Empty(encoded); + Assert.Empty(decoded); + } + + [Fact] + public void AllZeroBytes_ArePreservedOnRoundTrip() + { + var sourceBytes = new byte[] { 0, 0, 0 }; + var converter = new Base62Converter(); + var encodedBytes = converter.Encode(sourceBytes); + var decoded = converter.Decode(encodedBytes); + + Assert.Equal(sourceBytes, decoded); + Assert.Equal("000", converter.Encode("\0\0\0")); + } + + [Fact] + public void StringWithLeadingNullBytes_RoundTrips() + { + var input = "\0\0\0"; + var converter = new Base62Converter(); + var encoded = converter.Encode(input); + var decoded = converter.Decode(encoded); + + Assert.Equal(input, decoded); + Assert.StartsWith("0", encoded); + } + + [Fact] + public void SingleZeroByte_RoundTrips() + { + var sourceBytes = new byte[] { 0 }; + var converter = new Base62Converter(); + + Assert.Equal(sourceBytes, converter.Decode(converter.Encode(sourceBytes))); + } + + [Fact] + public void Decode_WithCharacterOutsideCharset_DoesNotThrow() + { + var converter = new Base62Converter(); + var validEncoded = converter.Encode("120"); + + var decoded = converter.Decode(validEncoded + "@"); + + Assert.NotEqual("120", decoded); + } + + [Fact] + public void Decode_WithCharacterAboveAsciiRange_DoesNotThrow() + { + var converter = new Base62Converter(); + var validEncoded = converter.Encode("120"); + + var decoded = converter.Decode(validEncoded + "\u0080"); + + Assert.NotEqual("120", decoded); + } + + [Fact] + public void InvertedCharacterSet_AllZeroBytes_RoundTrip() + { + var sourceBytes = new byte[] { 0, 0 }; + var converter = new Base62Converter(Base62Converter.CharacterSet.INVERTED); + + Assert.Equal(sourceBytes, converter.Decode(converter.Encode(sourceBytes))); + } + + [Fact] + public void DefaultConstructor_MatchesExplicitDefaultCharacterSet() + { + var input = "120"; + var implicitConverter = new Base62Converter(); + var explicitConverter = new Base62Converter(Base62Converter.CharacterSet.DEFAULT); + + Assert.Equal(explicitConverter.Encode(input), implicitConverter.Encode(input)); + Assert.Equal(explicitConverter.Decode("DWjo"), implicitConverter.Decode("DWjo")); + } + public static IEnumerable GetData() { var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "validation_data.txt"); diff --git a/Base62.sln b/Base62.sln index 44a445d..e1fc451 100644 --- a/Base62.sln +++ b/Base62.sln @@ -15,20 +15,54 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Base62.Benchmark", "Base62.Benchmark\Base62.Benchmark.csproj", "{782CA29C-A056-41D0-BD8C-C061483FA067}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Debug|x64.Build.0 = Debug|Any CPU + {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Debug|x86.Build.0 = Debug|Any CPU {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Release|Any CPU.Build.0 = Release|Any CPU + {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Release|x64.ActiveCfg = Release|Any CPU + {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Release|x64.Build.0 = Release|Any CPU + {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Release|x86.ActiveCfg = Release|Any CPU + {AE1B5455-F004-4948-AE22-AC59C03C53BC}.Release|x86.Build.0 = Release|Any CPU {11235960-3E83-46D3-A66B-F493894471C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {11235960-3E83-46D3-A66B-F493894471C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11235960-3E83-46D3-A66B-F493894471C5}.Debug|x64.ActiveCfg = Debug|Any CPU + {11235960-3E83-46D3-A66B-F493894471C5}.Debug|x64.Build.0 = Debug|Any CPU + {11235960-3E83-46D3-A66B-F493894471C5}.Debug|x86.ActiveCfg = Debug|Any CPU + {11235960-3E83-46D3-A66B-F493894471C5}.Debug|x86.Build.0 = Debug|Any CPU {11235960-3E83-46D3-A66B-F493894471C5}.Release|Any CPU.ActiveCfg = Release|Any CPU {11235960-3E83-46D3-A66B-F493894471C5}.Release|Any CPU.Build.0 = Release|Any CPU + {11235960-3E83-46D3-A66B-F493894471C5}.Release|x64.ActiveCfg = Release|Any CPU + {11235960-3E83-46D3-A66B-F493894471C5}.Release|x64.Build.0 = Release|Any CPU + {11235960-3E83-46D3-A66B-F493894471C5}.Release|x86.ActiveCfg = Release|Any CPU + {11235960-3E83-46D3-A66B-F493894471C5}.Release|x86.Build.0 = Release|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Debug|Any CPU.Build.0 = Debug|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Debug|x64.ActiveCfg = Debug|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Debug|x64.Build.0 = Debug|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Debug|x86.ActiveCfg = Debug|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Debug|x86.Build.0 = Debug|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Release|Any CPU.ActiveCfg = Release|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Release|Any CPU.Build.0 = Release|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Release|x64.ActiveCfg = Release|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Release|x64.Build.0 = Release|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Release|x86.ActiveCfg = Release|Any CPU + {782CA29C-A056-41D0-BD8C-C061483FA067}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Base62/Base62Converter.cs b/Base62/Base62Converter.cs index de06a7e..253b2f7 100644 --- a/Base62/Base62Converter.cs +++ b/Base62/Base62Converter.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Text; namespace Base62 @@ -11,7 +10,9 @@ public class Base62Converter { private const string DEFAULT_CHARACTER_SET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; private const string INVERTED_CHARACTER_SET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private const byte InvalidCharacterValue = byte.MaxValue; private readonly string characterSet; + private readonly byte[] decodeMap; /// /// Initializes a new instance of . @@ -19,6 +20,7 @@ public class Base62Converter public Base62Converter() { characterSet = DEFAULT_CHARACTER_SET; + decodeMap = CreateDecodeMap(characterSet); } /// @@ -31,6 +33,8 @@ public Base62Converter(CharacterSet charset) characterSet = DEFAULT_CHARACTER_SET; else characterSet = INVERTED_CHARACTER_SET; + + decodeMap = CreateDecodeMap(characterSet); } /// @@ -42,12 +46,12 @@ public string Encode(string value) { var arr = Encoding.UTF8.GetBytes(value); var converted = Encode(arr); - var builder = new StringBuilder(); - foreach (var c in converted) + var output = new char[converted.Length]; + for (var i = 0; i < converted.Length; i++) { - builder.Append(characterSet[c]); + output[i] = characterSet[converted[i]]; } - return builder.ToString(); + return new string(output); } /// @@ -60,7 +64,8 @@ public string Decode(string value) var arr = new byte[value.Length]; for (var i = 0; i < arr.Length; i++) { - arr[i] = (byte)characterSet.IndexOf(value[i]); + var c = value[i]; + arr[i] = c < decodeMap.Length ? decodeMap[c] : InvalidCharacterValue; } var converted = Decode(arr); @@ -102,54 +107,80 @@ private static byte[] BaseConvert(byte[] source, int sourceBase, int targetBase) if (sourceBase < 2 || sourceBase > 256) throw new ArgumentOutOfRangeException(nameof(sourceBase), sourceBase, "Value must be between 2 & 256 (inclusive)"); - // Set initial capacity estimate if the size is small. - var startCapacity = source.Length < 1028 - ? (int)(source.Length * 1.5) - : source.Length; + if (source.Length == 0) + return Array.Empty(); - var result = new List(startCapacity); - var quotient = new List((int)(source.Length * 0.5)); - int count; - int initialStartOffset = 0; + var sourceBuffer = new byte[source.Length]; + Buffer.BlockCopy(source, 0, sourceBuffer, 0, source.Length); - // This is a bug fix for the following issue: - // https://github.com/ghost1face/base62/issues/4 - while (source[initialStartOffset] == 0) + var leadingZeroCount = 0; + while (leadingZeroCount < sourceBuffer.Length && sourceBuffer[leadingZeroCount] == 0) { - result.Add(0); - initialStartOffset++; + leadingZeroCount++; } - int startOffset = initialStartOffset; + if (leadingZeroCount == sourceBuffer.Length) + return new byte[sourceBuffer.Length]; + + var maxResultDigitCount = sourceBase >= targetBase + ? (int)Math.Ceiling(source.Length * Math.Log(sourceBase) / Math.Log(targetBase)) + 1 + : source.Length + 1; - while ((count = source.Length) > 0) + var quotientBuffer = new byte[source.Length]; + var resultDigits = new byte[maxResultDigitCount]; + var resultDigitCount = 0; + var sourceLength = sourceBuffer.Length; + var sourceStartOffset = leadingZeroCount; + + while (sourceLength > 0) { - quotient.Clear(); int remainder = 0; - for (var i = initialStartOffset; i != count; i++) + var quotientLength = 0; + for (var i = sourceStartOffset; i < sourceLength; i++) { - int accumulator = source[i] + remainder * sourceBase; - byte digit = (byte)((accumulator - (accumulator % targetBase)) / targetBase); + var accumulator = sourceBuffer[i] + remainder * sourceBase; + var digit = (byte)(accumulator / targetBase); remainder = accumulator % targetBase; - if (quotient.Count > 0 || digit != 0) + if (quotientLength > 0 || digit != 0) { - quotient.Add(digit); + quotientBuffer[quotientLength++] = digit; } } - result.Insert(startOffset, remainder); - source = quotient.ToArray(); - initialStartOffset = 0; - } + resultDigits[resultDigitCount++] = (byte)remainder; + if (quotientLength == 0) + break; - var output = new byte[result.Count]; + (sourceBuffer, quotientBuffer) = (quotientBuffer, sourceBuffer); + sourceLength = quotientLength; + sourceStartOffset = 0; + } - for (int i = 0; i < result.Count; i++) - output[i] = (byte)result[i]; + var output = new byte[leadingZeroCount + resultDigitCount]; + for (var i = 0; i < resultDigitCount; i++) + { + output[leadingZeroCount + i] = resultDigits[resultDigitCount - 1 - i]; + } return output; } + private static byte[] CreateDecodeMap(string characterSet) + { + var map = new byte[128]; + for (var i = 0; i < map.Length; i++) + { + map[i] = InvalidCharacterValue; + } + + for (byte i = 0; i < characterSet.Length; i++) + { + map[characterSet[i]] = i; + } + + return map; + } + /// /// Character set to use for encoding/decoding. /// diff --git a/benchmark.sh b/benchmark.sh new file mode 100755 index 0000000..240e896 --- /dev/null +++ b/benchmark.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +dotnet run --project Base62.Benchmark --configuration Release