Skip to content

Commit a8adf78

Browse files
Speed up multiple attributes overwrite detection. Fixes #24467
1 parent bb96535 commit a8adf78

File tree

3 files changed

+333
-32
lines changed

3 files changed

+333
-32
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Diagnostics;
6+
7+
namespace Microsoft.AspNetCore.Components.Rendering
8+
{
9+
/// <summary>
10+
/// A specialized alternative dictionary tuned entirely towards speeding up RenderTreeBuilder's
11+
/// ProcessDuplicateAttributes method.
12+
/// </summary>
13+
/// <remarks>
14+
/// It's faster than a normal Dictionary[string, int] because:
15+
///
16+
/// 1. It uses a hash function optimized for the "no match" case. If we expect most lookups to
17+
/// result in no match, which we do for attribute writing, then it's sufficient to consider
18+
/// just a small fraction of the string when building a hash.
19+
/// 2. It has an API designed around the add-or-return-existing semantics needed for this
20+
/// process, with a lot less abstraction and extensibility than Dictionary[string, int]
21+
///
22+
/// In total, this improves the ComplexTable benchmark by 6-7%.
23+
///
24+
/// This dictionary shouldn't be used in other situations because it may perform much worse than
25+
/// a Dictionary[string, int] if most of the lookups/insertions match existing entries.
26+
/// </remarks>
27+
internal class MultipleAttributesDictionary
28+
{
29+
public const int InitialCapacity = 79;
30+
31+
private string[] _keys = new string[InitialCapacity];
32+
private int[] _values = new int[InitialCapacity];
33+
private int _capacity = InitialCapacity;
34+
35+
public void Clear()
36+
{
37+
Array.Clear(_keys, 0, _keys.Length);
38+
Array.Clear(_values, 0, _values.Length);
39+
}
40+
41+
public bool TryAdd(string key, int value, out int existingValue)
42+
{
43+
if (TryFindIndex(key, out var index))
44+
{
45+
existingValue = _values[index];
46+
return false;
47+
}
48+
else
49+
{
50+
if (index < 0) // Indicates that storage is full
51+
{
52+
ExpandStorage();
53+
TryFindIndex(key, out index);
54+
Debug.Assert(index >= 0);
55+
}
56+
57+
_keys[index] = key;
58+
_values[index] = value;
59+
existingValue = default;
60+
return true;
61+
}
62+
}
63+
64+
public void Replace(string key, int value)
65+
{
66+
if (TryFindIndex(key, out var index))
67+
{
68+
_values[index] = value;
69+
}
70+
else
71+
{
72+
throw new InvalidOperationException($"Key not found: '{key}'");
73+
}
74+
}
75+
76+
private bool TryFindIndex(string key, out int existingIndexOrInsertionPosition)
77+
{
78+
var hashCode = GetSimpleHashCode(key);
79+
var startIndex = hashCode % _capacity;
80+
if (startIndex < 0)
81+
{
82+
startIndex += _capacity;
83+
}
84+
var candidateIndex = startIndex;
85+
86+
do
87+
{
88+
var candidateKey = _keys[candidateIndex];
89+
if (candidateKey == null)
90+
{
91+
existingIndexOrInsertionPosition = candidateIndex;
92+
return false;
93+
}
94+
95+
if (string.Equals(candidateKey, key, StringComparison.OrdinalIgnoreCase))
96+
{
97+
existingIndexOrInsertionPosition = candidateIndex;
98+
return true;
99+
}
100+
101+
if (++candidateIndex >= _capacity)
102+
{
103+
candidateIndex = 0;
104+
}
105+
}
106+
while (candidateIndex != startIndex);
107+
108+
// We didn't find the key, and there's no empty slot in which we could insert it.
109+
// Storage is full.
110+
existingIndexOrInsertionPosition = -1;
111+
return false;
112+
}
113+
114+
private void ExpandStorage()
115+
{
116+
var oldKeys = _keys;
117+
var oldValues = _values;
118+
_capacity = _capacity * 2;
119+
_keys = new string[_capacity];
120+
_values = new int[_capacity];
121+
122+
for (var i = 0; i < oldKeys.Length; i++)
123+
{
124+
var key = oldKeys[i];
125+
if (!(key is null))
126+
{
127+
var value = oldValues[i];
128+
var didInsert = TryAdd(key, value, out _);
129+
Debug.Assert(didInsert);
130+
}
131+
}
132+
}
133+
134+
private static int GetSimpleHashCode(string key)
135+
{
136+
var keyLength = key.Length;
137+
if (keyLength > 0)
138+
{
139+
// Consider just the first and middle characters.
140+
// This will produce a distinct result for a sufficiently large
141+
// proportion of attribute names.
142+
return unchecked(
143+
char.ToLowerInvariant(key[0])
144+
+ 31 * char.ToLowerInvariant(key[keyLength / 2]));
145+
}
146+
else
147+
{
148+
return default;
149+
}
150+
}
151+
}
152+
}

src/Components/Components/src/Rendering/RenderTreeBuilder.cs

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public sealed class RenderTreeBuilder : IDisposable
2727
private readonly Stack<int> _openElementIndices = new Stack<int>();
2828
private RenderTreeFrameType? _lastNonAttributeFrameType;
2929
private bool _hasSeenAddMultipleAttributes;
30-
private Dictionary<string, int>? _seenAttributeNames;
30+
private MultipleAttributesDictionary? _seenAttributeNames;
3131

3232
/// <summary>
3333
/// The reserved parameter name used for supplying child content.
@@ -707,41 +707,39 @@ internal void ProcessDuplicateAttributes(int first)
707707
}
708708

709709
// Now that we've found the last attribute, we can iterate backwards and process duplicates.
710-
var seenAttributeNames = (_seenAttributeNames ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase));
710+
var seenAttributeNames = (_seenAttributeNames ??= new MultipleAttributesDictionary());
711711
for (var i = last; i >= first; i--)
712712
{
713713
ref var frame = ref buffer[i];
714714
Debug.Assert(frame.FrameTypeField == RenderTreeFrameType.Attribute, $"Frame type is {frame.FrameTypeField} at {i}");
715715

716-
if (!seenAttributeNames.TryGetValue(frame.AttributeNameField, out var index))
716+
if (!seenAttributeNames.TryAdd(frame.AttributeNameField, i, out var index))
717717
{
718-
// This is the first time seeing this attribute name. Add to the dictionary and move on.
719-
seenAttributeNames.Add(frame.AttributeNameField, i);
720-
}
721-
else if (index < i)
722-
{
723-
// This attribute is overriding a "silent frame" where we didn't create a frame for an AddAttribute call.
724-
// This is the case for a null event handler, or bool false value.
725-
//
726-
// We need to update our tracking, in case the attribute appeared 3 or more times.
727-
seenAttributeNames[frame.AttributeNameField] = i;
728-
}
729-
else if (index > i)
730-
{
731-
// This attribute has been overridden. For now, blank out its name to *mark* it. We'll do a pass
732-
// later to wipe it out.
733-
frame = default;
734-
}
735-
else
736-
{
737-
// OK so index == i. How is that possible? Well it's possible for a "silent frame" immediately
738-
// followed by setting the same attribute. Think of it this way, when we create a "silent frame"
739-
// we have to track that attribute name with *some* index.
740-
//
741-
// The only index value we can safely use is _entries.Count (next available). This is fine because
742-
// we never use these indexes to look stuff up, only for comparison.
743-
//
744-
// That gets you here, and there's no action to take.
718+
if (index < i)
719+
{
720+
// This attribute is overriding a "silent frame" where we didn't create a frame for an AddAttribute call.
721+
// This is the case for a null event handler, or bool false value.
722+
//
723+
// We need to update our tracking, in case the attribute appeared 3 or more times.
724+
seenAttributeNames.Replace(frame.AttributeNameField, i);
725+
}
726+
else if (index > i)
727+
{
728+
// This attribute has been overridden. For now, blank out its name to *mark* it. We'll do a pass
729+
// later to wipe it out.
730+
frame = default;
731+
}
732+
else
733+
{
734+
// OK so index == i. How is that possible? Well it's possible for a "silent frame" immediately
735+
// followed by setting the same attribute. Think of it this way, when we create a "silent frame"
736+
// we have to track that attribute name with *some* index.
737+
//
738+
// The only index value we can safely use is _entries.Count (next available). This is fine because
739+
// we never use these indexes to look stuff up, only for comparison.
740+
//
741+
// That gets you here, and there's no action to take.
742+
}
745743
}
746744
}
747745

@@ -780,8 +778,8 @@ internal void TrackAttributeName(string name)
780778
return;
781779
}
782780

783-
var seenAttributeNames = (_seenAttributeNames ??= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase));
784-
seenAttributeNames[name] = _entries.Count; // See comment in ProcessAttributes for why this is OK.
781+
var seenAttributeNames = (_seenAttributeNames ??= new MultipleAttributesDictionary());
782+
seenAttributeNames.TryAdd(name, _entries.Count, out _); // See comment in ProcessAttributes for why this is OK.
785783
}
786784

787785
void IDisposable.Dispose()
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using Xunit;
6+
7+
namespace Microsoft.AspNetCore.Components.Rendering
8+
{
9+
public class MultipleAttributesDictionaryTest
10+
{
11+
[Fact]
12+
public void CanStoreAndRetrieveValues()
13+
{
14+
var instance = new MultipleAttributesDictionary();
15+
16+
// Add key1
17+
Assert.True(instance.TryAdd("key1", 123, out var existingValue1));
18+
Assert.Equal(default, existingValue1);
19+
20+
// Add key2
21+
Assert.True(instance.TryAdd("key2", 456, out var existingValue2));
22+
Assert.Equal(default, existingValue2);
23+
24+
// Can't add key1 again. Instead we retrieve the existing value.
25+
Assert.False(instance.TryAdd("key1", 1000, out var existingValue3));
26+
Assert.Equal(123, existingValue3);
27+
28+
// Same for KEY1, showing the keys are case-insensitive, and we didn't overwrite last time.
29+
Assert.False(instance.TryAdd("KEY1", 2000, out var existingValue4));
30+
Assert.Equal(123, existingValue4);
31+
}
32+
33+
[Fact]
34+
public void HandlesKeysThatVaryOnlyByOneChar()
35+
{
36+
// Arrange
37+
// This test case is intended to produce clashing hashes
38+
var instance = new MultipleAttributesDictionary();
39+
instance.TryAdd("SomeLongKey", 123, out _);
40+
41+
// Act
42+
var didAddSecondEntry = instance.TryAdd("SXmeLongKey", 456, out _);
43+
44+
// Assert
45+
Assert.True(didAddSecondEntry);
46+
Assert.False(instance.TryAdd("SomeLongKey", 0, out var existingValue1));
47+
Assert.False(instance.TryAdd("SXmeLongKey", 0, out var existingValue2));
48+
Assert.Equal(123, existingValue1);
49+
Assert.Equal(456, existingValue2);
50+
}
51+
52+
[Fact]
53+
public void CanClear()
54+
{
55+
// Arrange
56+
var instance = new MultipleAttributesDictionary();
57+
instance.TryAdd("X", 123, out _);
58+
59+
// Act
60+
instance.Clear();
61+
62+
// Assert
63+
Assert.True(instance.TryAdd("X", 456, out var existingValue));
64+
Assert.Equal(default, existingValue);
65+
}
66+
67+
[Fact]
68+
public void AllowsEmptyStringKey()
69+
{
70+
// Arrange
71+
var instance = new MultipleAttributesDictionary();
72+
73+
// Act
74+
instance.TryAdd(string.Empty, 1, out _);
75+
76+
// Assert
77+
Assert.False(instance.TryAdd(string.Empty, 0, out var storedValue));
78+
Assert.Equal(1, storedValue);
79+
}
80+
81+
[Fact]
82+
public void CanReplaceExistingValues()
83+
{
84+
// Arrange
85+
var instance = new MultipleAttributesDictionary();
86+
instance.TryAdd("somekey", 123, out _);
87+
88+
// Act
89+
instance.Replace("SomeKey", 456);
90+
instance.Replace("SomeKey", 789);
91+
92+
// Assert
93+
Assert.False(instance.TryAdd("SOMEKEY", 0, out var storedValue));
94+
Assert.Equal(789, storedValue);
95+
}
96+
97+
[Fact]
98+
public void CannotReplaceNonExistingValues()
99+
{
100+
// Arrange
101+
var instance = new MultipleAttributesDictionary();
102+
instance.TryAdd("somekey", 123, out _);
103+
104+
// Act
105+
Assert.Throws<InvalidOperationException>(() =>
106+
{
107+
instance.Replace("otherkey", 456);
108+
});
109+
}
110+
111+
[Fact]
112+
public void CanExpandStorage()
113+
{
114+
// Arrange
115+
var instance = new MultipleAttributesDictionary();
116+
int index;
117+
for (index = 0; index < MultipleAttributesDictionary.InitialCapacity; index++)
118+
{
119+
Assert.True(instance.TryAdd($"key{index}", index, out _));
120+
}
121+
122+
// Act 1: Store the same amount again
123+
var doubledCapacity = 2 * MultipleAttributesDictionary.InitialCapacity;
124+
for (; index < doubledCapacity; index++)
125+
{
126+
Assert.True(instance.TryAdd($"key{index}", index, out _));
127+
}
128+
129+
// Assert: Verify contents
130+
for (var i = 0; i < doubledCapacity; i++)
131+
{
132+
Assert.False(instance.TryAdd($"key{i}", 0, out var storedValue));
133+
Assert.Equal(i, storedValue);
134+
}
135+
136+
// Act 2: Store a lot more
137+
var largeCapacity = 100 * MultipleAttributesDictionary.InitialCapacity;
138+
for (; index < largeCapacity; index++)
139+
{
140+
Assert.True(instance.TryAdd($"key{index}", index, out _));
141+
}
142+
143+
// Assert: Verify contents
144+
for (var i = 0; i < largeCapacity; i++)
145+
{
146+
Assert.False(instance.TryAdd($"key{i}", 0, out var storedValue));
147+
Assert.Equal(i, storedValue);
148+
}
149+
}
150+
}
151+
}

0 commit comments

Comments
 (0)