Skip to content

Commit 36ba546

Browse files
authored
Reduce costs in ComponentDirectiveVisitor.VisitRazorDirective (#11881)
* Reduce costs in ComponentDirectiveVisitor.VisitRazorDirective Customer feedback ticket shows ~40% of CPU costs are in this method. Of the 32 seconds of CPU time in this method, 21 seconds are in it's calls to GetTypeNamespace and GetSpanWithoutGlobalPrefix. VisitRazorDirective is called many times (we'll call that number 'l'), depending on the number of directives in the page. Each call costs O(m x n) where m is the number of import statements in the page and n is the number of directives that aren't fully qualified. Evidently, for this customer on this page, this O(l * m * n) is too much. 1) GetTypeNamespace -- Reduced to O(n) calls by storing it's result in _nonFullyQualifiedComponents 2) GetSpanWithoutGlobalPrefix -- Reduced to O(l * m) by moving outside the innnermost loop 3) Reduced innermost loop to only walk taghelpers in the same namespace Fixes: https://developercommunity.visualstudio.com/t/Razor-Development:-30-Second-CPU-Churn-A/10904811
1 parent 54c21d0 commit 36ba546

File tree

3 files changed

+111
-75
lines changed

3 files changed

+111
-75
lines changed

src/Compiler/Microsoft.AspNetCore.Razor.Language/test/DefaultRazorTagHelperBinderPhaseTest.cs

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,26 +1245,6 @@ @using static SomeProject.SomeOtherFolder.Foo
12451245
Assert.Same(componentDescriptor, result);
12461246
}
12471247

1248-
[Theory]
1249-
[InlineData("", "", true)]
1250-
[InlineData("Foo", "Project", true)]
1251-
[InlineData("Project.Foo", "Project", true)]
1252-
[InlineData("Project.Foo", "global::Project", true)]
1253-
[InlineData("Project.Bar.Foo", "Project.Bar", true)]
1254-
[InlineData("Project.Foo", "Project.Bar", false)]
1255-
[InlineData("Project.Foo", "global::Project.Bar", false)]
1256-
[InlineData("Project.Bar.Foo", "Project", false)]
1257-
[InlineData("Bar.Foo", "Project", false)]
1258-
public void IsTypeInNamespace_WorksAsExpected(string typeName, string @namespace, bool expected)
1259-
{
1260-
// Arrange & Act
1261-
var descriptor = CreateComponentDescriptor(typeName, typeName, "Test.dll");
1262-
var result = DefaultRazorTagHelperContextDiscoveryPhase.ComponentDirectiveVisitor.IsTypeInNamespace(descriptor, @namespace);
1263-
1264-
// Assert
1265-
Assert.Equal(expected, result);
1266-
}
1267-
12681248
[Theory]
12691249
[InlineData("", "", true)]
12701250
[InlineData("Foo", "Project", true)]
@@ -1273,11 +1253,13 @@ public void IsTypeInNamespace_WorksAsExpected(string typeName, string @namespace
12731253
[InlineData("Project.Foo", "Project.Bar", true)]
12741254
[InlineData("Project.Bar.Foo", "Project", false)]
12751255
[InlineData("Bar.Foo", "Project", false)]
1276-
public void IsTypeInScope_WorksAsExpected(string typeName, string currentNamespace, bool expected)
1256+
public void IsTypeNamespaceInScope_WorksAsExpected(string typeName, string currentNamespace, bool expected)
12771257
{
12781258
// Arrange & Act
12791259
var descriptor = CreateComponentDescriptor(typeName, typeName, "Test.dll");
1280-
var result = DefaultRazorTagHelperContextDiscoveryPhase.ComponentDirectiveVisitor.IsTypeInScope(descriptor, currentNamespace);
1260+
var tagHelperTypeNamespace = descriptor.GetTypeNamespace();
1261+
1262+
var result = DefaultRazorTagHelperContextDiscoveryPhase.ComponentDirectiveVisitor.IsTypeNamespaceInScope(tagHelperTypeNamespace, currentNamespace);
12811263

12821264
// Assert
12831265
Assert.Equal(expected, result);

src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorTagHelperContextDiscoveryPhase.cs

Lines changed: 65 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,18 @@ protected override void ExecuteCore(RazorCodeDocument codeDocument, Cancellation
5858
codeDocument.SetPreTagHelperSyntaxTree(syntaxTree);
5959
}
6060

61-
private static ReadOnlySpan<char> GetSpanWithoutGlobalPrefix(string s)
61+
internal static ReadOnlyMemory<char> GetMemoryWithoutGlobalPrefix(string s)
6262
{
6363
const string globalPrefix = "global::";
6464

65-
var span = s.AsSpan();
65+
var mem = s.AsMemory();
6666

67-
if (span.StartsWith(globalPrefix.AsSpan(), StringComparison.Ordinal))
67+
if (mem.Span.StartsWith(globalPrefix.AsSpan(), StringComparison.Ordinal))
6868
{
69-
return span[globalPrefix.Length..];
69+
return mem[globalPrefix.Length..];
7070
}
7171

72-
return span;
72+
return mem;
7373
}
7474

7575
internal abstract class DirectiveVisitor : SyntaxWalker
@@ -241,7 +241,7 @@ public override void VisitRazorDirective(RazorDirectiveSyntax node)
241241
continue;
242242
}
243243

244-
switch (GetSpanWithoutGlobalPrefix(addTagHelper.TypePattern))
244+
switch (GetMemoryWithoutGlobalPrefix(addTagHelper.TypePattern).Span)
245245
{
246246
case ['*']:
247247
AddMatches(nonComponentTagHelpers);
@@ -287,7 +287,7 @@ public override void VisitRazorDirective(RazorDirectiveSyntax node)
287287
continue;
288288
}
289289

290-
switch (GetSpanWithoutGlobalPrefix(removeTagHelper.TypePattern))
290+
switch (GetMemoryWithoutGlobalPrefix(removeTagHelper.TypePattern).Span)
291291
{
292292
case ['*']:
293293
RemoveMatches(nonComponentTagHelpers);
@@ -334,7 +334,9 @@ public override void VisitRazorDirective(RazorDirectiveSyntax node)
334334

335335
internal sealed class ComponentDirectiveVisitor : DirectiveVisitor
336336
{
337-
private readonly List<TagHelperDescriptor> _nonFullyQualifiedComponents = [];
337+
// The list values in this dictionary are pooled
338+
private readonly Dictionary<ReadOnlyMemory<char>, List<TagHelperDescriptor>> _typeNamespaceToNonFullyQualifiedComponents = new Dictionary<ReadOnlyMemory<char>, List<TagHelperDescriptor>>(ReadOnlyMemoryOfCharComparer.Instance);
339+
private List<TagHelperDescriptor>? _nonFullyQualifiedComponentsWithEmptyTypeNamespace;
338340

339341
private string? _filePath;
340342
private RazorSourceDocument? _source;
@@ -347,6 +349,7 @@ public void Initialize(string filePath, IReadOnlyList<TagHelperDescriptor> tagHe
347349
Debug.Assert(!IsInitialized);
348350

349351
_filePath = filePath;
352+
_nonFullyQualifiedComponentsWithEmptyTypeNamespace = ListPool<TagHelperDescriptor>.Default.Get();
350353

351354
foreach (var tagHelper in tagHelpers.AsEnumerable())
352355
{
@@ -363,25 +366,31 @@ public void Initialize(string filePath, IReadOnlyList<TagHelperDescriptor> tagHe
363366
continue;
364367
}
365368

366-
_nonFullyQualifiedComponents.Add(tagHelper);
369+
var tagHelperTypeNamespace = tagHelper.GetTypeNamespace().AsMemory();
367370

368-
if (currentNamespace is null)
371+
if (tagHelperTypeNamespace.IsEmpty)
369372
{
370-
continue;
373+
_nonFullyQualifiedComponentsWithEmptyTypeNamespace.Add(tagHelper);
371374
}
372-
373-
if (tagHelper.IsChildContentTagHelper)
375+
else
374376
{
375-
// If this is a child content tag helper, we want to add it if it's original type is in scope.
376-
// E.g, if the type name is `Test.MyComponent.ChildContent`, we want to add it if `Test.MyComponent` is in scope.
377-
if (IsTypeInScope(tagHelper, currentNamespace))
377+
if (!_typeNamespaceToNonFullyQualifiedComponents.TryGetValue(tagHelperTypeNamespace, out var tagHelpersList))
378378
{
379-
AddMatch(tagHelper);
379+
tagHelpersList = ListPool<TagHelperDescriptor>.Default.Get();
380+
_typeNamespaceToNonFullyQualifiedComponents.Add(tagHelperTypeNamespace, tagHelpersList);
380381
}
382+
383+
tagHelpersList.Add(tagHelper);
381384
}
382-
else if (IsTypeInScope(tagHelper, currentNamespace))
385+
386+
if (currentNamespace is null)
383387
{
384-
// Also, if the type is already in scope of the document's namespace, using isn't necessary.
388+
continue;
389+
}
390+
391+
if (IsTypeNamespaceInScope(tagHelperTypeNamespace.Span, currentNamespace))
392+
{
393+
// If the type is already in scope of the document's namespace, using isn't necessary.
385394
AddMatch(tagHelper);
386395
}
387396
}
@@ -391,7 +400,18 @@ public void Initialize(string filePath, IReadOnlyList<TagHelperDescriptor> tagHe
391400

392401
public override void Reset()
393402
{
394-
_nonFullyQualifiedComponents.Clear();
403+
if (_nonFullyQualifiedComponentsWithEmptyTypeNamespace != null)
404+
{
405+
ListPool<TagHelperDescriptor>.Default.Return(_nonFullyQualifiedComponentsWithEmptyTypeNamespace);
406+
_nonFullyQualifiedComponentsWithEmptyTypeNamespace = null;
407+
}
408+
409+
foreach (var (_, tagHelpers) in _typeNamespaceToNonFullyQualifiedComponents)
410+
{
411+
ListPool<TagHelperDescriptor>.Default.Return(tagHelpers);
412+
}
413+
414+
_typeNamespaceToNonFullyQualifiedComponents.Clear();
395415
_filePath = null;
396416
_source = null;
397417

@@ -407,6 +427,8 @@ public override void Visit(RazorSyntaxTree tree)
407427

408428
public override void VisitRazorDirective(RazorDirectiveSyntax node)
409429
{
430+
var componentsWithEmptyTypeNamespace = _nonFullyQualifiedComponentsWithEmptyTypeNamespace.AssumeNotNull();
431+
410432
var descendantLiterals = node.DescendantNodes();
411433
foreach (var child in descendantLiterals)
412434
{
@@ -455,22 +477,30 @@ public override void VisitRazorDirective(RazorDirectiveSyntax node)
455477
continue;
456478
}
457479

458-
foreach (var tagHelper in _nonFullyQualifiedComponents)
480+
if (_typeNamespaceToNonFullyQualifiedComponents.Count == 0 && componentsWithEmptyTypeNamespace.Count == 0)
481+
{
482+
// There aren't any non qualified components to add
483+
continue;
484+
}
485+
486+
// Add all tag helpers that have an empty type namespace
487+
foreach (var tagHelper in componentsWithEmptyTypeNamespace)
459488
{
460489
Debug.Assert(!tagHelper.IsComponentFullyQualifiedNameMatch, "We've already processed these.");
461490

462-
if (tagHelper.IsChildContentTagHelper)
463-
{
464-
// If this is a child content tag helper, we want to add it if it's original type is in scope of the given namespace.
465-
// E.g, if the type name is `Test.MyComponent.ChildContent`, we want to add it if `Test.MyComponent` is in this namespace.
466-
if (IsTypeInNamespace(tagHelper, @namespace))
467-
{
468-
AddMatch(tagHelper);
469-
}
470-
}
471-
else if (IsTypeInNamespace(tagHelper, @namespace))
491+
AddMatch(tagHelper);
492+
}
493+
494+
// Remove global:: prefix from namespace.
495+
var normalizedNamespace = GetMemoryWithoutGlobalPrefix(@namespace);
496+
497+
// Add all tag helpers with a matching namespace
498+
if (_typeNamespaceToNonFullyQualifiedComponents.TryGetValue(normalizedNamespace, out var tagHelpers))
499+
{
500+
foreach (var tagHelper in tagHelpers)
472501
{
473-
// If the type is at the top-level or if the type's namespace matches the using's namespace, add it.
502+
Debug.Assert(!tagHelper.IsComponentFullyQualifiedNameMatch, "We've already processed these.");
503+
474504
AddMatch(tagHelper);
475505
}
476506
}
@@ -480,32 +510,14 @@ public override void VisitRazorDirective(RazorDirectiveSyntax node)
480510
}
481511
}
482512

483-
internal static bool IsTypeInNamespace(TagHelperDescriptor tagHelper, string @namespace)
484-
{
485-
var typeNamespace = tagHelper.GetTypeNamespace();
486-
487-
if (typeNamespace.IsNullOrEmpty())
488-
{
489-
// Either the typeName is not the full type name or this type is at the top level.
490-
return true;
491-
}
492-
493-
// Remove global:: prefix from namespace.
494-
var normalizedNamespaceSpan = GetSpanWithoutGlobalPrefix(@namespace);
495-
496-
return normalizedNamespaceSpan.Equals(typeNamespace.AsSpan(), StringComparison.Ordinal);
497-
}
498-
499-
// Check if the given type is already in scope given the namespace of the current document.
513+
// Check if a type's namespace is already in scope given the namespace of the current document.
500514
// E.g,
501515
// If the namespace of the document is `MyComponents.Components.Shared`,
502516
// then the types `MyComponents.FooComponent`, `MyComponents.Components.BarComponent`, `MyComponents.Components.Shared.BazComponent` are all in scope.
503517
// Whereas `MyComponents.SomethingElse.OtherComponent` is not in scope.
504-
internal static bool IsTypeInScope(TagHelperDescriptor tagHelper, string @namespace)
518+
internal static bool IsTypeNamespaceInScope(ReadOnlySpan<char> typeNamespace, string @namespace)
505519
{
506-
var typeNamespace = tagHelper.GetTypeNamespace();
507-
508-
if (typeNamespace.IsNullOrEmpty())
520+
if (typeNamespace.IsEmpty)
509521
{
510522
// Either the typeName is not the full type name or this type is at the top level.
511523
return true;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using Microsoft.Extensions.Internal;
8+
9+
namespace Microsoft.AspNetCore.Razor.Language;
10+
11+
internal sealed class ReadOnlyMemoryOfCharComparer : IEqualityComparer<ReadOnlyMemory<char>>
12+
{
13+
public static readonly ReadOnlyMemoryOfCharComparer Instance = new ReadOnlyMemoryOfCharComparer();
14+
15+
private ReadOnlyMemoryOfCharComparer()
16+
{
17+
}
18+
19+
public static bool Equals(ReadOnlySpan<char> x, ReadOnlyMemory<char> y)
20+
=> x.SequenceEqual(y.Span);
21+
22+
public bool Equals(ReadOnlyMemory<char> x, ReadOnlyMemory<char> y)
23+
=> x.Span.SequenceEqual(y.Span);
24+
25+
public int GetHashCode(ReadOnlyMemory<char> memory)
26+
{
27+
#if NET
28+
return string.GetHashCode(memory.Span);
29+
#else
30+
// We don't rely on ReadOnlyMemory<char>.GetHashCode() because it includes
31+
// the index and length, but we just want a hash based on the characters.
32+
var hashCombiner = HashCodeCombiner.Start();
33+
34+
foreach (var ch in memory.Span)
35+
{
36+
hashCombiner.Add(ch);
37+
}
38+
39+
return hashCombiner.CombinedHash;
40+
#endif
41+
}
42+
}

0 commit comments

Comments
 (0)