diff --git a/Directory.Build.props b/Directory.Build.props index e722ccfbc0..cc459eb3d3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,5 +18,6 @@ MicrosoftNuGet true true + 7.2 diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/AsciiKeyedJumpTable.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/AsciiKeyedJumpTable.cs new file mode 100644 index 0000000000..bce1afa71e --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/AsciiKeyedJumpTable.cs @@ -0,0 +1,172 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + // An optimized jump table that trades a small amount of additional memory for + // hash-table like performance. + // + // The optimization here is to use the first character of the known entries + // as a 'key' in the hash table in the space of A-Z. This gives us a maximum + // of 26 buckets (hence the reduced memory) + internal class AsciiKeyedJumpTable : JumpTable + { + public static bool TryCreate( + int defaultDestination, + int exitDestination, + List<(string text, int destination)> entries, + out JumpTable result) + { + result = null; + + // First we group string by their uppercase letter. If we see a string + // that starts with a non-ASCII letter + var map = new Dictionary>(); + + for (var i = 0; i < entries.Count; i++) + { + if (entries[i].text.Length == 0) + { + return false; + } + + if (!IsAscii(entries[i].text)) + { + return false; + } + + var first = ToUpperAscii(entries[i].text[0]); + if (first < 'A' || first > 'Z') + { + // Not a letter + return false; + } + + if (!map.TryGetValue(first, out var matches)) + { + matches = new List<(string text, int destination)>(); + map.Add(first, matches); + } + + matches.Add(entries[i]); + } + + var next = 0; + var ordered = new(string text, int destination)[entries.Count]; + var indexes = new int[26 * 2]; + for (var i = 0; i < 26; i++) + { + indexes[i * 2] = next; + + var length = 0; + if (map.TryGetValue((char)('A' + i), out var matches)) + { + length += matches.Count; + for (var j = 0; j < matches.Count; j++) + { + ordered[next++] = matches[j]; + } + } + + indexes[i * 2 + 1] = length; + } + + result = new AsciiKeyedJumpTable(defaultDestination, exitDestination, ordered, indexes); + return true; + } + + private readonly int _defaultDestination; + private readonly int _exitDestination; + private readonly (string text, int destination)[] _entries; + private readonly int[] _indexes; + + private AsciiKeyedJumpTable( + int defaultDestination, + int exitDestination, + (string text, int destination)[] entries, + int[] indexes) + { + _defaultDestination = defaultDestination; + _exitDestination = exitDestination; + _entries = entries; + _indexes = indexes; + } + + public override unsafe int GetDestination(string path, PathSegment segment) + { + if (segment.Length == 0) + { + return _exitDestination; + } + + var c = path[segment.Start]; + if (!IsAscii(c)) + { + return _defaultDestination; + } + + c = ToUpperAscii(c); + if (c < 'A' || c > 'Z') + { + // Character is non-ASCII or not a letter. Since we know that all of the entries are ASCII + // and begin with a letter this is not a match. + return _defaultDestination; + } + + var offset = (c - 'A') * 2; + var start = _indexes[offset]; + var length = _indexes[offset + 1]; + + var entries = _entries; + for (var i = start; i < start + length; i++) + { + var text = entries[i].text; + if (segment.Length == text.Length && + string.Compare( + path, + segment.Start, + text, + 0, + segment.Length, + StringComparison.OrdinalIgnoreCase) == 0) + { + return entries[i].destination; + } + } + + return _defaultDestination; + } + + internal static bool IsAscii(char c) + { + // ~0x7F is a bit mask that checks for bits that won't be set in an ASCII character. + // ASCII only uses the lowest 7 bits. + return (c & ~0x7F) == 0; + } + + internal static bool IsAscii(string text) + { + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (!IsAscii(c)) + { + return false; + } + } + + return true; + } + + internal static char ToUpperAscii(char c) + { + // 0x5F can be used to convert a character to uppercase ascii (assuming it's a letter). + // This works because lowercase ASCII chars are exactly 32 less than their uppercase + // counterparts. + return (char)(c & 0x5F); + } + } +} \ No newline at end of file diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/CustomHashTableJumpTable.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/CustomHashTableJumpTable.cs new file mode 100644 index 0000000000..bbffc14a80 --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/CustomHashTableJumpTable.cs @@ -0,0 +1,197 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + internal class CustomHashTableJumpTable : JumpTable + { + // Similar to HashHelpers list of primes, but truncated. We don't expect + // incredibly large numbers to be useful here. + private static readonly int[] Primes = new int[] + { + 3, 7, 11, 17, 23, 29, 37, 47, 59, + 71, 89, 107, 131, 163, 197, 239, 293, + 353, 431, 521, 631, 761, 919, 1103, 1327, + 1597, 1931, 2333, 2801, 3371, 4049, 4861, 5839, + 7013, 8419, 10103, + }; + + private readonly int _defaultDestination; + private readonly int _exitDestination; + + private readonly int _prime; + private readonly int[] _buckets; + private readonly Entry[] _entries; + + public CustomHashTableJumpTable( + int defaultDestination, + int exitDestination, + (string text, int destination)[] entries) + { + _defaultDestination = defaultDestination; + _exitDestination = exitDestination; + + var map = new Dictionary>(); + + for (var i = 0; i < entries.Length; i++) + { + var key = GetKey(entries[i].text, new PathSegment(0, entries[i].text.Length)); + if (!map.TryGetValue(key, out var matches)) + { + matches = new List<(string text, int destination)>(); + map.Add(key, matches); + } + + matches.Add(entries[i]); + } + + _prime = GetPrime(map.Count); + _buckets = new int[_prime + 1]; + _entries = new Entry[map.Sum(kvp => kvp.Value.Count)]; + + var next = 0; + foreach (var group in map.GroupBy(kvp => kvp.Key % _prime).OrderBy(g => g.Key)) + { + _buckets[group.Key] = next; + + foreach (var array in group) + { + for (var i = 0; i < array.Value.Count; i++) + { + _entries[next++] = new Entry(array.Value[i].text, array.Value[i].destination); + } + } + } + + Debug.Assert(next == _entries.Length); + _buckets[_prime] = next; + + var last = 0; + for (var i = 0; i < _buckets.Length; i++) + { + if (_buckets[i] == 0) + { + _buckets[i] = last; + } + else + { + last = _buckets[i]; + } + } + } + + public int Find(int key) + { + return key % _prime; + } + + private static int GetPrime(int capacity) + { + for (int i = 0; i < Primes.Length; i++) + { + int prime = Primes[i]; + if (prime >= capacity) + { + return prime; + } + } + + return Primes[Primes.Length - 1]; + } + + public override unsafe int GetDestination(string path, PathSegment segment) + { + if (segment.Length == 0) + { + return _exitDestination; + } + + var key = GetKey(path, segment); + var index = Find(key); + + var start = _buckets[index]; + var end = _buckets[index + 1]; + + var entries = _entries.AsSpan(start, end - start); + for (var i = 0; i < entries.Length; i++) + { + var text = entries[i].Text; + if (text.Length == segment.Length && + string.Compare( + path, + segment.Start, + text, + 0, + segment.Length, + StringComparison.OrdinalIgnoreCase) == 0) + { + return entries[i].Destination; + } + } + + return _defaultDestination; + } + + // Builds a hashcode from segment (first four characters converted to 8bit ASCII) + private static unsafe int GetKey(string path, PathSegment segment) + { + fixed (char* p = path) + { + switch (path.Length) + { + case 0: + { + return 0; + } + + case 1: + { + return + ((*(p + segment.Start + 0) & 0x5F) << (0 * 8)); + } + + case 2: + { + return + ((*(p + segment.Start + 0) & 0x5F) << (0 * 8)) | + ((*(p + segment.Start + 1) & 0x5F) << (1 * 8)); + } + + case 3: + { + return + ((*(p + segment.Start + 0) & 0x5F) << (0 * 8)) | + ((*(p + segment.Start + 1) & 0x5F) << (1 * 8)) | + ((*(p + segment.Start + 2) & 0x5F) << (2 * 8)); + } + + default: + { + return + ((*(p + segment.Start + 0) & 0x5F) << (0 * 8)) | + ((*(p + segment.Start + 1) & 0x5F) << (1 * 8)) | + ((*(p + segment.Start + 2) & 0x5F) << (2 * 8)) | + ((*(p + segment.Start + 3) & 0x5F) << (3 * 8)); + } + } + } + } + + private readonly struct Entry + { + public readonly string Text; + public readonly int Destination; + + public Entry(string text, int destination) + { + Text = text; + Destination = destination; + } + } + } +} diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/DictionaryLookupJumpTable.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/DictionaryLookupJumpTable.cs new file mode 100644 index 0000000000..3e0f75716b --- /dev/null +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/DictionaryLookupJumpTable.cs @@ -0,0 +1,112 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.Routing.Matchers +{ + internal class DictionaryLookupJumpTable : JumpTable + { + private readonly int _defaultDestination; + private readonly int _exitDestination; + private readonly Dictionary _store; + + public DictionaryLookupJumpTable( + int defaultDestination, + int exitDestination, + (string text, int destination)[] entries) + { + _defaultDestination = defaultDestination; + _exitDestination = exitDestination; + + var map = new Dictionary>(); + + for (var i = 0; i < entries.Length; i++) + { + var key = GetKey(entries[i].text.AsSpan()); + if (!map.TryGetValue(key, out var matches)) + { + matches = new List<(string text, int destination)>(); + map.Add(key, matches); + } + + matches.Add(entries[i]); + } + + _store = map.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray()); + } + + public override int GetDestination(string path, PathSegment segment) + { + if (segment.Length == 0) + { + return _exitDestination; + } + + var key = GetKey(path.AsSpan(segment.Start, segment.Length)); + if (_store.TryGetValue(key, out var entries)) + { + for (var i = 0; i < entries.Length; i++) + { + var text = entries[i].text; + if (text.Length == segment.Length && + string.Compare( + path, + segment.Start, + text, + 0, + segment.Length, + StringComparison.OrdinalIgnoreCase) == 0) + { + return entries[i].destination; + } + } + } + + return _defaultDestination; + } + + private static int GetKey(string path, PathSegment segment) + { + return GetKey(path.AsSpan(segment.Start, segment.Length)); + } + + /// builds a key from the last byte of length + first 3 characters of text (converted to ascii) + private static int GetKey(ReadOnlySpan span) + { + var length = (byte)(span.Length & 0xFF); + + byte c0, c1, c2; + switch (length) + { + case 0: + { + return 0; + } + + case 1: + { + c0 = (byte)(span[0] & 0x5F); + return (length << 24) | (c0 << 16); + } + + case 2: + { + c0 = (byte)(span[0] & 0x5F); + c1 = (byte)(span[1] & 0x5F); + return (length << 24) | (c0 << 16) | (c1 << 8); + } + + default: + { + c0 = (byte)(span[0] & 0x5F); + c1 = (byte)(span[1] & 0x5F); + c2 = (byte)(span[2] & 0x5F); + return (length << 24) | (c0 << 16) | (c1 << 8) | c2; + } + } + } + } +} diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/JumpTableMultipleEntryBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/JumpTableMultipleEntryBenchmark.cs index 87d70ae74d..731334c1c1 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/JumpTableMultipleEntryBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matchers/JumpTableMultipleEntryBenchmark.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using BenchmarkDotNet.Attributes; namespace Microsoft.AspNetCore.Routing.Matchers @@ -14,9 +15,12 @@ namespace Microsoft.AspNetCore.Routing.Matchers private JumpTable _linearSearch; private JumpTable _dictionary; + private JumpTable _ascii; + private JumpTable _dictionaryLookup; + private JumpTable _customHashTable; // All factors of 100 to support sampling - [Params(2, 5, 10, 25, 50, 100)] + [Params(2, 4, 5, 10, 25)] public int Count; [GlobalSetup] @@ -44,6 +48,9 @@ namespace Microsoft.AspNetCore.Routing.Matchers _linearSearch = new LinearSearchJumpTable(0, -1, entries.ToArray()); _dictionary = new DictionaryJumpTable(0, -1, entries.ToArray()); + Debug.Assert(AsciiKeyedJumpTable.TryCreate(0, -1, entries, out _ascii)); + _dictionaryLookup = new DictionaryLookupJumpTable(0, -1, entries.ToArray()); + _customHashTable = new CustomHashTableJumpTable(0, -1, entries.ToArray()); } // This baseline is similar to SingleEntryJumpTable. We just want @@ -104,6 +111,51 @@ namespace Microsoft.AspNetCore.Routing.Matchers return destination; } + [Benchmark(OperationsPerInvoke = 100)] + public int Ascii() + { + var strings = _strings; + var segments = _segments; + + var destination = 0; + for (var i = 0; i < strings.Length; i++) + { + destination = _ascii.GetDestination(strings[i], segments[i]); + } + + return destination; + } + + [Benchmark(OperationsPerInvoke = 100)] + public int DictionaryLookup() + { + var strings = _strings; + var segments = _segments; + + var destination = 0; + for (var i = 0; i < strings.Length; i++) + { + destination = _dictionaryLookup.GetDestination(strings[i], segments[i]); + } + + return destination; + } + + [Benchmark(OperationsPerInvoke = 100)] + public int CustomHashTable() + { + var strings = _strings; + var segments = _segments; + + var destination = 0; + for (var i = 0; i < strings.Length; i++) + { + destination = _customHashTable.GetDestination(strings[i], segments[i]); + } + + return destination; + } + private static string[] GetStrings(int count) { var strings = new string[count];