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];