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);
}
}
}