diff --git a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matching/JumpTableSingleEntryBenchmark.cs b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matching/JumpTableSingleEntryBenchmark.cs index 1f676ce5ad..99f774620c 100644 --- a/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matching/JumpTableSingleEntryBenchmark.cs +++ b/benchmarks/Microsoft.AspNetCore.Routing.Performance/Matching/JumpTableSingleEntryBenchmark.cs @@ -10,10 +10,10 @@ namespace Microsoft.AspNetCore.Routing.Matching { public class JumpTableSingleEntryBenchmark { - private JumpTable _implementation; - private JumpTable _prototype; + private JumpTable _default; private JumpTable _trie; private JumpTable _vectorTrie; + private JumpTable _ascii; private string[] _strings; private PathSegment[] _segments; @@ -21,10 +21,10 @@ namespace Microsoft.AspNetCore.Routing.Matching [GlobalSetup] public void Setup() { - _implementation = new SingleEntryJumpTable(0, -1, "hello-world", 1); - _prototype = new SingleEntryAsciiVectorizedJumpTable(0, -2, "hello-world", 1); - _trie = new ILEmitTrieJumpTable(0, -1, new[] { ("hello-world", 1), }, vectorize: false, _implementation); - _vectorTrie = new ILEmitTrieJumpTable(0, -1, new[] { ("hello-world", 1), }, vectorize: true, _implementation); + _default = new SingleEntryJumpTable(0, -1, "hello-world", 1); + _trie = new ILEmitTrieJumpTable(0, -1, new[] { ("hello-world", 1), }, vectorize: false, _default); + _vectorTrie = new ILEmitTrieJumpTable(0, -1, new[] { ("hello-world", 1), }, vectorize: true, _default); + _ascii = new SingleEntryAsciiJumpTable(0, -1, "hello-world", 1); _strings = new string[] { @@ -80,7 +80,7 @@ namespace Microsoft.AspNetCore.Routing.Matching } [Benchmark(OperationsPerInvoke = 5)] - public int Implementation() + public int Default() { var strings = _strings; var segments = _segments; @@ -88,14 +88,14 @@ namespace Microsoft.AspNetCore.Routing.Matching var destination = 0; for (var i = 0; i < strings.Length; i++) { - destination = _implementation.GetDestination(strings[i], segments[i]); + destination = _default.GetDestination(strings[i], segments[i]); } return destination; } [Benchmark(OperationsPerInvoke = 5)] - public int Prototype() + public int Ascii() { var strings = _strings; var segments = _segments; @@ -103,7 +103,7 @@ namespace Microsoft.AspNetCore.Routing.Matching var destination = 0; for (var i = 0; i < strings.Length; i++) { - destination = _prototype.GetDestination(strings[i], segments[i]); + destination = _ascii.GetDestination(strings[i], segments[i]); } return destination; @@ -138,181 +138,5 @@ namespace Microsoft.AspNetCore.Routing.Matching return destination; } - - private class SingleEntryAsciiVectorizedJumpTable : JumpTable - { - private readonly int _defaultDestination; - private readonly int _exitDestination; - private readonly string _text; - private readonly int _destination; - - private readonly ulong[] _values; - private readonly int _residue0Lower; - private readonly int _residue0Upper; - private readonly int _residue1Lower; - private readonly int _residue1Upper; - private readonly int _residue2Lower; - private readonly int _residue2Upper; - - public SingleEntryAsciiVectorizedJumpTable( - int defaultDestination, - int exitDestination, - string text, - int destination) - { - _defaultDestination = defaultDestination; - _exitDestination = exitDestination; - _text = text; - _destination = destination; - - var length = text.Length; - var span = text.ToLowerInvariant().AsSpan(); - ref var p = ref Unsafe.As(ref MemoryMarshal.GetReference(span)); - - _values = new ulong[length / 4]; - for (var i = 0; i < length / 4; i++) - { - _values[i] = Unsafe.ReadUnaligned(ref p); - p = Unsafe.Add(ref p, 64); - } - switch (length % 4) - { - case 1: - { - var c = Unsafe.ReadUnaligned(ref p); - _residue0Lower = char.ToLowerInvariant(c); - _residue0Upper = char.ToUpperInvariant(c); - - break; - } - - case 2: - { - var c = Unsafe.ReadUnaligned(ref p); - _residue0Lower = char.ToLowerInvariant(c); - _residue0Upper = char.ToUpperInvariant(c); - - p = Unsafe.Add(ref p, 2); - c = Unsafe.ReadUnaligned(ref p); - _residue1Lower = char.ToLowerInvariant(c); - _residue1Upper = char.ToUpperInvariant(c); - - break; - } - - case 3: - { - var c = Unsafe.ReadUnaligned(ref p); - _residue0Lower = char.ToLowerInvariant(c); - _residue0Upper = char.ToUpperInvariant(c); - - p = Unsafe.Add(ref p, 2); - c = Unsafe.ReadUnaligned(ref p); - _residue1Lower = char.ToLowerInvariant(c); - _residue1Upper = char.ToUpperInvariant(c); - - p = Unsafe.Add(ref p, 2); - c = Unsafe.ReadUnaligned(ref p); - _residue2Lower = char.ToLowerInvariant(c); - _residue2Upper = char.ToUpperInvariant(c); - - break; - } - } - } - - public override int GetDestination(string path, PathSegment segment) - { - var length = segment.Length; - var span = path.AsSpan(segment.Start, length); - ref var p = ref Unsafe.As(ref MemoryMarshal.GetReference(span)); - - var i = 0; - while (length > 3) - { - var value = Unsafe.ReadUnaligned(ref p); - - if ((value & ~0x007F007F007F007FUL) == 0) - { - return _defaultDestination; - } - - var ulongLowerIndicator = value + (0x0080008000800080UL - 0x0041004100410041UL); - var ulongUpperIndicator = value + (0x0080008000800080UL - 0x005B005B005B005BUL); - var ulongCombinedIndicator = (ulongLowerIndicator ^ ulongUpperIndicator) & 0x0080008000800080UL; - var mask = (ulongCombinedIndicator) >> 2; - - value ^= mask; - - if (value != _values[i]) - { - return _defaultDestination; - } - - i++; - length -= 4; - p = ref Unsafe.Add(ref p, 64); - } - - switch (length) - { - case 1: - { - var c = Unsafe.ReadUnaligned(ref p); - if (c != _residue0Lower && c != _residue0Upper) - { - return _defaultDestination; - } - - break; - } - - case 2: - { - var c = Unsafe.ReadUnaligned(ref p); - if (c != _residue0Lower && c != _residue0Upper) - { - return _defaultDestination; - } - - p = ref Unsafe.Add(ref p, 2); - c = Unsafe.ReadUnaligned(ref p); - if (c != _residue1Lower && c != _residue1Upper) - { - return _defaultDestination; - } - - break; - } - - case 3: - { - var c = Unsafe.ReadUnaligned(ref p); - if (c != _residue0Lower && c != _residue0Upper) - { - return _defaultDestination; - } - - p = ref Unsafe.Add(ref p, 2); - c = Unsafe.ReadUnaligned(ref p); - if (c != _residue1Lower && c != _residue1Upper) - { - return _defaultDestination; - } - - p = ref Unsafe.Add(ref p, 2); - c = Unsafe.ReadUnaligned(ref p); - if (c != _residue2Lower && c != _residue2Upper) - { - return _defaultDestination; - } - - break; - } - } - - return _destination; - } - } } } diff --git a/src/Microsoft.AspNetCore.Routing/Matching/Ascii.cs b/src/Microsoft.AspNetCore.Routing/Matching/Ascii.cs new file mode 100644 index 0000000000..6ff7db28ff --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matching/Ascii.cs @@ -0,0 +1,75 @@ +// 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.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Routing.Matching +{ + internal static class Ascii + { + // case-sensitive equality comparison when we KNOW that 'a' is in the ASCII range + // and we know that the spans are the same length. + // + // Similar to https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Globalization/CompareInfo.cs#L549 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool AsciiIgnoreCaseEquals(ReadOnlySpan a, ReadOnlySpan b, int length) + { + // The caller should have checked the length. We enforce that here by THROWING if the + // lengths are unequal. + if (a.Length < length || b.Length < length) + { + // This should never happen, but we don't want to have undefined + // behavior if it does. + ThrowArgumentExceptionForLength(); + } + + ref var charA = ref MemoryMarshal.GetReference(a); + ref var charB = ref MemoryMarshal.GetReference(b); + + // Iterates each span for the provided length and compares each character + // case-insensitively. This looks funky because we're using unsafe operations + // to elide bounds-checks. + while (length > 0 && AsciiIgnoreCaseEquals(charA, charB)) + { + charA = ref Unsafe.Add(ref charA, 1); + charB = ref Unsafe.Add(ref charB, 1); + length--; + } + + return length == 0; + } + + // case-insensitive equality comparison for characters in the ASCII range + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool AsciiIgnoreCaseEquals(char charA, char charB) + { + const uint AsciiToLower = 0x20; + return + // Equal when chars are exactly equal + charA == charB || + + // Equal when converted to-lower AND they are letters + ((charA | AsciiToLower) == (charB | AsciiToLower) && (uint)((charA | AsciiToLower) - 'a') <= (uint)('z' - 'a')); + } + + public static bool IsAscii(string text) + { + for (var i = 0; i < text.Length; i++) + { + if (text[i] > (char)0x7F) + { + return false; + } + } + + return true; + } + + private static void ThrowArgumentExceptionForLength() + { + throw new ArgumentException("length"); + } + } +} diff --git a/src/Microsoft.AspNetCore.Routing/Matching/JumpTableBuilder.cs b/src/Microsoft.AspNetCore.Routing/Matching/JumpTableBuilder.cs index 69939ffeeb..efee38f0f1 100644 --- a/src/Microsoft.AspNetCore.Routing/Matching/JumpTableBuilder.cs +++ b/src/Microsoft.AspNetCore.Routing/Matching/JumpTableBuilder.cs @@ -39,7 +39,15 @@ namespace Microsoft.AspNetCore.Routing.Matching return new ZeroEntryJumpTable(defaultDestination, exitDestination); } - // The IL Emit jump table is not faster for a single entry + // The IL Emit jump table is not faster for a single entry - but we have an optimized version when all text + // is ASCII + if (pathEntries.Length == 1 && Ascii.IsAscii(pathEntries[0].text)) + { + var entry = pathEntries[0]; + return new SingleEntryAsciiJumpTable(defaultDestination, exitDestination, entry.text, entry.destination); + } + + // We have a fallback that works for non-ASCII if (pathEntries.Length == 1) { var entry = pathEntries[0]; diff --git a/src/Microsoft.AspNetCore.Routing/Matching/SingleEntryAsciiJumpTable.cs b/src/Microsoft.AspNetCore.Routing/Matching/SingleEntryAsciiJumpTable.cs new file mode 100644 index 0000000000..5ca0bab566 --- /dev/null +++ b/src/Microsoft.AspNetCore.Routing/Matching/SingleEntryAsciiJumpTable.cs @@ -0,0 +1,55 @@ +// 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.Runtime.CompilerServices; + +namespace Microsoft.AspNetCore.Routing.Matching +{ + // Optimized implementation for cases where we know that we're + // comparing to ASCII. + internal class SingleEntryAsciiJumpTable : JumpTable + { + private readonly int _defaultDestination; + private readonly int _exitDestination; + private readonly string _text; + private readonly int _destination; + + public SingleEntryAsciiJumpTable( + int defaultDestination, + int exitDestination, + string text, + int destination) + { + _defaultDestination = defaultDestination; + _exitDestination = exitDestination; + _text = text; + _destination = destination; + } + + public unsafe override int GetDestination(string path, PathSegment segment) + { + var length = segment.Length; + if (length == 0) + { + return _exitDestination; + } + + var text = _text; + if (length != text.Length) + { + return _defaultDestination; + } + + var a = path.AsSpan(segment.Start, length); + var b = text.AsSpan(); + + return Ascii.AsciiIgnoreCaseEquals(a, b, length) ? _destination : _defaultDestination; + } + + public override string DebuggerToString() + { + return $"{{ {_text}: {_destination}, $+: {_defaultDestination}, $0: {_exitDestination} }}"; + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matching/AsciiTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matching/AsciiTest.cs new file mode 100644 index 0000000000..c6266183a8 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matching/AsciiTest.cs @@ -0,0 +1,114 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.Routing.Matching +{ + // Note that while we don't intend for this code to be used with non-ASCII test, + // we still call into these methods with some non-ASCII characters so that + // we are sure of how it behaves. + public class AsciiTest + { + [Fact] + public void IsAscii_ReturnsTrueForAscii() + { + // Arrange + var text = "abcd\u007F"; + + // Act + var result = Ascii.IsAscii(text); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsAscii_ReturnsFalseForNonAscii() + { + // Arrange + var text = "abcd\u0080"; + + // Act + var result = Ascii.IsAscii(text); + + // Assert + Assert.False(result); + } + + [Theory] + + // Identity + [InlineData('c', 'c')] + [InlineData('C', 'C')] + [InlineData('#', '#')] + [InlineData('\u0080', '\u0080')] + + // Case-insensitive + [InlineData('c', 'C')] + public void AsciiIgnoreCaseEquals_ReturnsTrue(char x, char y) + { + // Arrange + + // Act + var result = Ascii.AsciiIgnoreCaseEquals(x, y); + + // Assert + Assert.True(result); + } + + [Theory] + + // Different letter + [InlineData('c', 'd')] + [InlineData('C', 'D')] + + // Non-letter + casing difference - 'a' and 'A' are 32 bits apart and so are ' ' and '@' + [InlineData(' ', '@')] + [InlineData('\u0080', '\u0080' + 32)] // Outside of ASCII range + public void AsciiIgnoreCaseEquals_ReturnsFalse(char x, char y) + { + // Arrange + + // Act + var result = Ascii.AsciiIgnoreCaseEquals(x, y); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("", "", 0)] + [InlineData("abCD", "abcF", 3)] + [InlineData("ab#\u0080-$%", "Ab#\u0080-$%", 7)] + public void UnsafeAsciiIgnoreCaseEquals_ReturnsTrue(string x, string y, int length) + { + // Arrange + var spanX = x.AsSpan(); + var spanY = y.AsSpan(); + + // Act + var result = Ascii.AsciiIgnoreCaseEquals(spanX, spanY, length); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("abcD", "abCE", 4)] + [InlineData("ab#\u0080-$%", "Ab#\u0081-$%", 7)] + public void UnsafeAsciiIgnoreCaseEquals_ReturnsFalse(string x, string y, int length) + { + // Arrange + var spanX = x.AsSpan(); + var spanY = y.AsSpan(); + + // Act + var result = Ascii.AsciiIgnoreCaseEquals(spanX, spanY, length); + + // Assert + Assert.False(result); + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matching/SingleEntryAsciiJumpTableTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matching/SingleEntryAsciiJumpTableTest.cs new file mode 100644 index 0000000000..bd068178e9 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matching/SingleEntryAsciiJumpTableTest.cs @@ -0,0 +1,13 @@ +// 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. + +namespace Microsoft.AspNetCore.Routing.Matching +{ + public class SingleEntryAsciiJumpTableTest : SingleEntryJumpTableTestBase + { + private protected override JumpTable CreateJumpTable(int defaultDestination, int exitDestination, string text, int destination) + { + return new SingleEntryAsciiJumpTable(defaultDestination, exitDestination, text, destination); + } + } +} diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matching/SingleEntryJumpTableTest.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matching/SingleEntryJumpTableTest.cs index e21e8918cd..77c3366f70 100644 --- a/test/Microsoft.AspNetCore.Routing.Tests/Matching/SingleEntryJumpTableTest.cs +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matching/SingleEntryJumpTableTest.cs @@ -1,62 +1,13 @@ // 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 Xunit; - namespace Microsoft.AspNetCore.Routing.Matching { - public class SingleEntryJumpTableTest + public class SingleEntryJumpTableTest : SingleEntryJumpTableTestBase { - [Fact] - public void GetDestination_ZeroLengthSegment_JumpsToExit() + private protected override JumpTable CreateJumpTable(int defaultDestination, int exitDestination, string text, int destination) { - // Arrange - var table = new SingleEntryJumpTable(0, 1, "text", 2); - - // Act - var result = table.GetDestination("ignored", new PathSegment(0, 0)); - - // Assert - Assert.Equal(1, result); - } - - [Fact] - public void GetDestination_NonMatchingSegment_JumpsToDefault() - { - // Arrange - var table = new SingleEntryJumpTable(0, 1, "text", 2); - - // Act - var result = table.GetDestination("text", new PathSegment(1, 2)); - - // Assert - Assert.Equal(0, result); - } - - [Fact] - public void GetDestination_SegmentMatchingText_JumpsToDestination() - { - // Arrange - var table = new SingleEntryJumpTable(0, 1, "text", 2); - - // Act - var result = table.GetDestination("some-text", new PathSegment(5, 4)); - - // Assert - Assert.Equal(2, result); - } - - [Fact] - public void GetDestination_SegmentMatchingTextIgnoreCase_JumpsToDestination() - { - // Arrange - var table = new SingleEntryJumpTable(0, 1, "text", 2); - - // Act - var result = table.GetDestination("some-tExt", new PathSegment(5, 4)); - - // Assert - Assert.Equal(2, result); + return new SingleEntryJumpTable(defaultDestination, exitDestination, text, destination); } } } diff --git a/test/Microsoft.AspNetCore.Routing.Tests/Matching/SingleEntryJumpTableTestBase.cs b/test/Microsoft.AspNetCore.Routing.Tests/Matching/SingleEntryJumpTableTestBase.cs new file mode 100644 index 0000000000..dd3c5953a6 --- /dev/null +++ b/test/Microsoft.AspNetCore.Routing.Tests/Matching/SingleEntryJumpTableTestBase.cs @@ -0,0 +1,68 @@ +// 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 Xunit; + +namespace Microsoft.AspNetCore.Routing.Matching +{ + public abstract class SingleEntryJumpTableTestBase + { + private protected abstract JumpTable CreateJumpTable( + int defaultDestination, + int exitDestination, + string text, + int destination); + + [Fact] + public void GetDestination_ZeroLengthSegment_JumpsToExit() + { + // Arrange + var table = CreateJumpTable(0, 1, "text", 2); + + // Act + var result = table.GetDestination("ignored", new PathSegment(0, 0)); + + // Assert + Assert.Equal(1, result); + } + + [Fact] + public void GetDestination_NonMatchingSegment_JumpsToDefault() + { + // Arrange + var table = CreateJumpTable(0, 1, "text", 2); + + // Act + var result = table.GetDestination("text", new PathSegment(1, 2)); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetDestination_SegmentMatchingText_JumpsToDestination() + { + // Arrange + var table = CreateJumpTable(0, 1, "text", 2); + + // Act + var result = table.GetDestination("some-text", new PathSegment(5, 4)); + + // Assert + Assert.Equal(2, result); + } + + [Fact] + public void GetDestination_SegmentMatchingTextIgnoreCase_JumpsToDestination() + { + // Arrange + var table = CreateJumpTable(0, 1, "text", 2); + + // Act + var result = table.GetDestination("some-tExt", new PathSegment(5, 4)); + + // Assert + Assert.Equal(2, result); + } + } +}