diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.TagHelperSet.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.TagHelperSet.cs new file mode 100644 index 00000000000..c162bd1908d --- /dev/null +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.TagHelperSet.cs @@ -0,0 +1,152 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; + +namespace Microsoft.AspNetCore.Razor.Language; + +internal sealed partial class TagHelperBinder +{ + /// + /// Similar to , but optimized to store either a single value or an array of values. + /// + [DebuggerDisplay("{GetDebuggerDisplay(),nq}")] + [DebuggerTypeProxy(typeof(DebuggerProxy))] + private readonly struct TagHelperSet + { + public static readonly TagHelperSet Empty = default!; + + private readonly object _valueOrArray; + + public TagHelperSet(TagHelperDescriptor value) + { + _valueOrArray = value; + } + + public TagHelperSet(TagHelperDescriptor[] array) + { + _valueOrArray = array; + } + + public TagHelperDescriptor this[int index] + { + get + { + return _valueOrArray switch + { + TagHelperDescriptor[] array => array[index], + not null when index == 0 => (TagHelperDescriptor)_valueOrArray, + _ => throw new IndexOutOfRangeException(), + }; + } + } + + public int Count + => _valueOrArray switch + { + TagHelperDescriptor[] array => array.Length, + null => 0, + + // _valueOrArray can be an array, a single value, or null. + // So, we can avoid a type check for the single value case. + _ => 1 + }; + + public Enumerator GetEnumerator() + => new(this); + + public struct Enumerator + { + private readonly TagHelperSet _tagHelperSet; + private int _index; + + internal Enumerator(TagHelperSet tagHelperSet) + { + _tagHelperSet = tagHelperSet; + _index = -1; + } + + public bool MoveNext() + { + _index++; + return _index < _tagHelperSet.Count; + } + + public readonly TagHelperDescriptor Current + => _tagHelperSet[_index]; + } + + private sealed class DebuggerProxy(TagHelperSet instance) + { + private readonly TagHelperSet _instance = instance; + + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public TagHelperDescriptor[] Items + => _instance._valueOrArray switch + { + TagHelperDescriptor[] array => array, + TagHelperDescriptor value => [value], + _ => [] + }; + } + + private string GetDebuggerDisplay() + => "Count " + Count; + + /// + /// This is a mutable builder for . However, it works differently from + /// a typical builder. First, you must call to set the number of items. + /// Once you've done that for each item to be added, you can call + /// exactly that many times. This ensures that space allocated is exactly what's needed to + /// produce the resulting . + /// + public struct Builder + { + private object? _valueOrArray; + private int _index; + private int _size; + + public void IncreaseSize() + { + Debug.Assert(_valueOrArray is null, "Cannot increase size once items have been added."); + _size++; + } + + public void Add(TagHelperDescriptor item) + { + Debug.Assert(_index < _size, "Cannot add more items."); + + if (_size == 1) + { + // We only need to store a single value. + _valueOrArray = item; + _index = 1; + return; + } + + Debug.Assert(_valueOrArray is null or TagHelperDescriptor[]); + + if (_valueOrArray is not TagHelperDescriptor[] array) + { + array = new TagHelperDescriptor[_size]; + _valueOrArray = array; + } + + array[_index++] = item; + } + + public readonly TagHelperSet ToSet() + { + Debug.Assert(_index == _size, "Must have added all items."); + + return _size switch + { + 0 => Empty, + 1 => new((TagHelperDescriptor)_valueOrArray!), + _ => new((TagHelperDescriptor[])_valueOrArray!) + }; + } + } + } +} diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs index b7e818560cb..c85bcd90d97 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/TagHelperBinder.cs @@ -13,10 +13,10 @@ namespace Microsoft.AspNetCore.Razor.Language; /// /// Enables retrieval of 's. /// -internal sealed class TagHelperBinder +internal sealed partial class TagHelperBinder { - private readonly ImmutableArray _catchAllDescriptors; - private readonly ReadOnlyDictionary> _tagNameToDescriptorsMap; + private readonly TagHelperSet _catchAllDescriptors; + private readonly ReadOnlyDictionary _tagNameToDescriptorsMap; public string? TagNamePrefix { get; } public ImmutableArray Descriptors { get; } @@ -38,52 +38,88 @@ public TagHelperBinder(string? tagNamePrefix, ImmutableArray descriptors, string? tagNamePrefix, - out ReadOnlyDictionary> tagNameToDescriptorsMap, - out ImmutableArray catchAllDescriptors) + out ReadOnlyDictionary tagNameToDescriptorsMap, + out TagHelperSet catchAllDescriptors) { - using var catchAllBuilder = new PooledArrayBuilder(); - using var pooledMap = StringDictionaryPool.Builder>.OrdinalIgnoreCase.GetPooledObject(out var mapBuilder); - using var pooledSet = HashSetPool.GetPooledObject(out var distinctSet); + // Initialize a MemoryBuilder of TagHelperSet.Builders. We need a builder for each unique tag name. + using var builders = new MemoryBuilder(initialCapacity: 32, clearArray: true); + + // Keep track of what needs to be added in the second pass. + // There will be an entry for every tag matching rule. + // Each entry consists of an index to identify a builder and the TagHelperDescriptor to add to it. + using var toAdd = new MemoryBuilder<(int, TagHelperDescriptor)>(initialCapacity: descriptors.Length * 4, clearArray: true); + + // Use a special TagHelperSet.Builder to track catch-all tag helpers. + var catchAllBuilder = new TagHelperSet.Builder(); + + // At most, there should only be one catch-all tag helper per descriptor. + using var catchAllToAdd = new MemoryBuilder(initialCapacity: descriptors.Length, clearArray: true); - // Build a map of tag name -> tag helpers. - foreach (var descriptor in descriptors) + // The builders are indexed using a map of "tag name" to the index of the builder in the array. + using var _1 = StringDictionaryPool.OrdinalIgnoreCase.GetPooledObject(out var tagNameToBuilderIndexMap); + using var _2 = HashSetPool.GetPooledObject(out var tagHelperSet); + +#if NET + tagHelperSet.EnsureCapacity(descriptors.Length); +#endif + + foreach (var tagHelper in descriptors) { - if (!distinctSet.Add(descriptor)) + if (!tagHelperSet.Add(tagHelper)) { - // We're already seen this descriptor, skip it. + // We've already seen this tag helper. Skip. continue; } - foreach (var rule in descriptor.TagMatchingRules) + foreach (var rule in tagHelper.TagMatchingRules) { - if (rule.TagName == TagHelperMatchingConventions.ElementCatchAllName) + var tagName = rule.TagName; + + if (tagName == TagHelperMatchingConventions.ElementCatchAllName) { - // This is a catch-all descriptor, we can keep track of it separately. - catchAllBuilder.Add(descriptor); + catchAllBuilder.IncreaseSize(); + catchAllToAdd.Append(tagHelper); + continue; } - else + + if (!tagNameToBuilderIndexMap.TryGetValue(tagName, out var builderIndex)) { - // This is a specific tag name, we need to add it to the map. - var tagName = tagNamePrefix + rule.TagName; - var builder = mapBuilder.GetOrAdd(tagName, _ => ImmutableArray.CreateBuilder()); + builderIndex = builders.Length; + builders.Append(default(TagHelperSet.Builder)); - builder.Add(descriptor); + tagNameToBuilderIndexMap.Add(tagName, builderIndex); } + + builders[builderIndex].IncreaseSize(); + toAdd.Append((builderIndex, tagHelper)); } } - // Build the final dictionary with immutable arrays. - var map = new Dictionary>(capacity: mapBuilder.Count, StringComparer.OrdinalIgnoreCase); + // Next, we walk through toAdd and add each descriptor to the appropriate builder. + // Because we counted first, we know that each builder will allocate exactly the + // space needed for the final result. + foreach (var (builderIndex, tagHelper) in toAdd.AsMemory().Span) + { + builders[builderIndex].Add(tagHelper); + } + + foreach (var tagHelper in catchAllToAdd.AsMemory().Span) + { + catchAllBuilder.Add(tagHelper); + } + + // Build the final dictionary. + var map = new Dictionary(capacity: tagNameToBuilderIndexMap.Count, StringComparer.OrdinalIgnoreCase); - foreach (var (key, value) in mapBuilder) + foreach (var (tagName, builderIndex) in tagNameToBuilderIndexMap) { - map.Add(key, value.ToImmutableAndClear()); + map.Add(tagNamePrefix + tagName, builders[builderIndex].ToSet()); } - tagNameToDescriptorsMap = new ReadOnlyDictionary>(map); + tagNameToDescriptorsMap = new ReadOnlyDictionary(map); - // Build the catch all descriptors array. - catchAllDescriptors = catchAllBuilder.ToImmutableAndClear(); + // Build the "catch all" tag helpers set. + catchAllDescriptors = catchAllBuilder.ToSet(); } /// @@ -150,7 +186,7 @@ private static void ProcessDescriptors( : null; static void CollectBoundRulesInfo( - ImmutableArray descriptors, + TagHelperSet descriptors, ReadOnlySpan tagName, ReadOnlySpan parentTagName, ImmutableArray> attributes, diff --git a/src/Compiler/perf/Microbenchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks.Compiler.csproj b/src/Compiler/perf/Microbenchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks.Compiler.csproj index f0b9e4dbc40..bf093e40c21 100644 --- a/src/Compiler/perf/Microbenchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks.Compiler.csproj +++ b/src/Compiler/perf/Microbenchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks.Compiler.csproj @@ -21,6 +21,13 @@ + + + + + + + diff --git a/src/Compiler/perf/Microbenchmarks/Program.cs b/src/Compiler/perf/Microbenchmarks/Program.cs index ab85aa5f05b..8026ee02204 100644 --- a/src/Compiler/perf/Microbenchmarks/Program.cs +++ b/src/Compiler/perf/Microbenchmarks/Program.cs @@ -4,7 +4,13 @@ using System; using System.Diagnostics; using System.Linq; +using BenchmarkDotNet.Columns; using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Exporters.Json; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Reports; using BenchmarkDotNet.Running; namespace Microsoft.AspNetCore.BenchmarkDotNet.Runner; @@ -13,9 +19,7 @@ partial class Program { private static int Main(string[] args) { - IConfig config = Debugger.IsAttached - ? new DebugInProcessConfig() - : ManualConfig.CreateEmpty(); + var config = GetConfig(); var summaries = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly) .Run(args, config); @@ -54,4 +58,36 @@ private static int Fail(object o, string message) Console.Error.WriteLine("'{0}' failed, reason: '{1}'", o, message); return 1; } + + private static IConfig GetConfig() + { + if (Debugger.IsAttached) + { + return new DebugInProcessConfig(); + } + + return ManualConfig.CreateEmpty() + .WithBuildTimeout(TimeSpan.FromMinutes(15)) // for slow machines + .AddLogger(ConsoleLogger.Default) // log output to console + .AddValidator(DefaultConfig.Instance.GetValidators().ToArray()) // copy default validators + .AddAnalyser(DefaultConfig.Instance.GetAnalysers().ToArray()) // copy default analysers + .AddExporter(MarkdownExporter.GitHub) // export to GitHub markdown + .AddColumnProvider(DefaultColumnProviders.Instance) // display default columns (method name, args etc) + .AddDiagnoser(MemoryDiagnoser.Default) + .AddExporter(JsonExporter.Full) + .AddColumn(StatisticColumn.Median, StatisticColumn.Min, StatisticColumn.Max) + .WithSummaryStyle(SummaryStyle.Default.WithMaxParameterColumnWidth(36)) // the default is 20 and trims too aggressively some benchmark results + .AddDiagnoser(CreateDisassembler()); + } + + private static DisassemblyDiagnoser CreateDisassembler() + => new(new DisassemblyDiagnoserConfig( + maxDepth: 1, // TODO: is depth == 1 enough? + syntax: DisassemblySyntax.Masm, // TODO: enable diffable format + printSource: false, // we are not interested in getting C# + printInstructionAddresses: false, // would make the diffing hard, however could be useful to determine alignment + exportGithubMarkdown: false, + exportHtml: false, + exportCombinedDisassemblyReport: false, + exportDiff: false)); } diff --git a/src/Compiler/perf/Microbenchmarks/TagHelperBinderBenchmark.cs b/src/Compiler/perf/Microbenchmarks/TagHelperBinderBenchmark.cs new file mode 100644 index 00000000000..c9f60b1f19c --- /dev/null +++ b/src/Compiler/perf/Microbenchmarks/TagHelperBinderBenchmark.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Immutable; +using BenchmarkDotNet.Attributes; +using Microsoft.AspNetCore.Razor.Language; + +namespace Microsoft.AspNetCore.Razor.Microbenchmarks; + +public class TagHelperBinderBenchmark +{ + // We create a number of binders to get a measurable time. + private const int Count = 2500; + + private readonly TagHelperBinder[] _binders = new TagHelperBinder[Count]; + private ImmutableArray _tagHelpers; + + [ParamsAllValues] + public TagHelpers TagHelpers { get; set; } + + [IterationSetup] + public void IterationSetup() + { + _tagHelpers = TagHelpers switch + { + TagHelpers.BlazorServerApp => TagHelperResources.BlazorServerApp, + TagHelpers.TelerikMvc => TagHelperResources.TelerikMvc, + _ => Assumed.Unreachable>() + }; + } + + [IterationCleanup] + public void IterationCleanUp() + { + Array.Clear(_binders); + } + + [Benchmark(Description = "Construct TagHelperBinders")] + public void ConstructTagHelperBinders() + { + for (var i = 0; i < Count; i++) + { + _binders[i] = new TagHelperBinder(tagNamePrefix: null, _tagHelpers); + } + } + + [Benchmark(Description = "Construct TagHelperBinders (with prefix)")] + public void ConstructTagHelperBinderWithPrefix() + { + for (var i = 0; i < Count; i++) + { + _binders[i] = new TagHelperBinder(tagNamePrefix: "abc", _tagHelpers); + } + } +} diff --git a/src/Compiler/perf/Microbenchmarks/TagHelperResources.cs b/src/Compiler/perf/Microbenchmarks/TagHelperResources.cs new file mode 100644 index 00000000000..48e9d762297 --- /dev/null +++ b/src/Compiler/perf/Microbenchmarks/TagHelperResources.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Immutable; +using System.Reflection; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Serialization.Json; + +namespace Microsoft.AspNetCore.Razor.Microbenchmarks; + +internal static class TagHelperResources +{ + private const string ResourceNameBase = "Microsoft.AspNetCore.Razor.Microbenchmarks.Compiler.Resources"; + private const string BlazorServerAppResourceName = $"{ResourceNameBase}.BlazorServerApp.TagHelpers.json"; + private const string TelerikMvcResourceName = $"{ResourceNameBase}.Telerik.Kendo.Mvc.Examples.taghelpers.json"; + + private static readonly Lazy> s_lazyBlazorServerApp = new(() => ReadTagHelpersFromResource(BlazorServerAppResourceName)); + private static readonly Lazy> s_lazyTelerikMvc = new(() => ReadTagHelpersFromResource(TelerikMvcResourceName)); + + private static ImmutableArray ReadTagHelpersFromResource(string resourceName) + { + using var resourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName); + Assumed.NotNull(resourceStream); + + var length = (int)resourceStream.Length; + var bytes = new byte[length]; + resourceStream.ReadExactly(bytes.AsSpan(0, length)); + + return JsonDataConvert.DeserializeTagHelperArray(bytes); + } + + public static ImmutableArray BlazorServerApp => s_lazyBlazorServerApp.Value; + public static ImmutableArray TelerikMvc => s_lazyTelerikMvc.Value; +} diff --git a/src/Compiler/perf/Microbenchmarks/TagHelpers.cs b/src/Compiler/perf/Microbenchmarks/TagHelpers.cs new file mode 100644 index 00000000000..05a78af6567 --- /dev/null +++ b/src/Compiler/perf/Microbenchmarks/TagHelpers.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Razor.Microbenchmarks; + +public enum TagHelpers +{ + BlazorServerApp, + TelerikMvc +} diff --git a/src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/MemoryBuilder`1.cs b/src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/MemoryBuilder`1.cs index 76053f6920d..e2058e8f2a2 100644 --- a/src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/MemoryBuilder`1.cs +++ b/src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/MemoryBuilder`1.cs @@ -18,8 +18,9 @@ internal ref struct MemoryBuilder private Memory _memory; private T[]? _arrayFromPool; private int _length; + private bool _clearArray; - public MemoryBuilder(int initialCapacity = 0) + public MemoryBuilder(int initialCapacity = 0, bool clearArray = false) { ArgHelper.ThrowIfNegative(initialCapacity); @@ -28,6 +29,8 @@ public MemoryBuilder(int initialCapacity = 0) _arrayFromPool = ArrayPool.Shared.Rent(initialCapacity); _memory = _arrayFromPool; } + + _clearArray = clearArray; } public void Dispose() @@ -35,15 +38,18 @@ public void Dispose() var toReturn = _arrayFromPool; if (toReturn is not null) { + ArrayPool.Shared.Return(toReturn, _clearArray); + _memory = default; _arrayFromPool = null; - ArrayPool.Shared.Return(toReturn); + _length = 0; + _clearArray = false; } } public int Length { - get => _length; + readonly get => _length; set { Debug.Assert(value >= 0); @@ -53,6 +59,16 @@ public int Length } } + public ref T this[int index] + { + get + { + Debug.Assert(index >= 0 && index < _length); + + return ref _memory.Span[index]; + } + } + public readonly ReadOnlyMemory AsMemory() => _memory[.._length]; @@ -148,7 +164,7 @@ private void Grow(int additionalCapacityRequired = 1) if (toReturn != null) { - ArrayPool.Shared.Return(toReturn); + ArrayPool.Shared.Return(toReturn, _clearArray); } } }