Skip to content

Commit fd50164

Browse files
Speed up multiple attributes overwrite detection. Fixes #24467
1 parent 1be16fb commit fd50164

File tree

3 files changed

+315
-32
lines changed

3 files changed

+315
-32
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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 instead of hashing the entire key
15+
/// (considering every character), it performs a much cheaper hash that only considers a few
16+
/// characters. This detects non-matches much faster, which is ideal for attribute splatting,
17+
/// because the vast majority of attributes aren't overridden.
18+
///
19+
/// When tweaking the ComplexTable benchmark to pass 3 extra splatted parameters per cell, this
20+
/// optimization improves the timings by 17%. It's also about 4% faster than using
21+
/// Dictionary[string, int] with custom comparer to achieve the simplified hash, as the API is
22+
/// a more precise match to the "add-or-return-existing" semantics needed.
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, middle, and last characters
140+
// This will produce a distinct result for a sufficiently large
141+
// proportion of attribute names
142+
return unchecked(17
143+
+ 31 * char.ToLowerInvariant(key[0])
144+
+ 961 * char.ToLowerInvariant(key[keyLength / 2])
145+
+ 29791 * char.ToLowerInvariant(key[keyLength - 1]));
146+
}
147+
else
148+
{
149+
return default;
150+
}
151+
}
152+
}
153+
}

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: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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 CanClear()
35+
{
36+
// Arrange
37+
var instance = new MultipleAttributesDictionary();
38+
instance.TryAdd("X", 123, out _);
39+
40+
// Act
41+
instance.Clear();
42+
43+
// Assert
44+
Assert.True(instance.TryAdd("X", 456, out var existingValue));
45+
Assert.Equal(default, existingValue);
46+
}
47+
48+
[Fact]
49+
public void AllowsEmptyStringKey()
50+
{
51+
// Arrange
52+
var instance = new MultipleAttributesDictionary();
53+
54+
// Act
55+
instance.TryAdd(string.Empty, 1, out _);
56+
57+
// Assert
58+
Assert.False(instance.TryAdd(string.Empty, 0, out var storedValue));
59+
Assert.Equal(1, storedValue);
60+
}
61+
62+
[Fact]
63+
public void CanReplaceExistingValues()
64+
{
65+
// Arrange
66+
var instance = new MultipleAttributesDictionary();
67+
instance.TryAdd("somekey", 123, out _);
68+
69+
// Act
70+
instance.Replace("SomeKey", 456);
71+
instance.Replace("SomeKey", 789);
72+
73+
// Assert
74+
Assert.False(instance.TryAdd("SOMEKEY", 0, out var storedValue));
75+
Assert.Equal(789, storedValue);
76+
}
77+
78+
[Fact]
79+
public void CannotReplaceNonExistingValues()
80+
{
81+
// Arrange
82+
var instance = new MultipleAttributesDictionary();
83+
instance.TryAdd("somekey", 123, out _);
84+
85+
// Act
86+
Assert.Throws<InvalidOperationException>(() =>
87+
{
88+
instance.Replace("otherkey", 456);
89+
});
90+
}
91+
92+
[Fact]
93+
public void CanExpandStorage()
94+
{
95+
// Arrange
96+
var instance = new MultipleAttributesDictionary();
97+
int index;
98+
for (index = 0; index < MultipleAttributesDictionary.InitialCapacity; index++)
99+
{
100+
Assert.True(instance.TryAdd($"key{index}", index, out _));
101+
}
102+
103+
// Act 1: Store the same amount again
104+
var doubledCapacity = 2 * MultipleAttributesDictionary.InitialCapacity;
105+
for (; index < doubledCapacity; index++)
106+
{
107+
Assert.True(instance.TryAdd($"key{index}", index, out _));
108+
}
109+
110+
// Assert: Verify contents
111+
for (var i = 0; i < doubledCapacity; i++)
112+
{
113+
Assert.False(instance.TryAdd($"key{i}", 0, out var storedValue));
114+
Assert.Equal(i, storedValue);
115+
}
116+
117+
// Act 2: Store a lot more
118+
var largeCapacity = 100 * MultipleAttributesDictionary.InitialCapacity;
119+
for (; index < largeCapacity; index++)
120+
{
121+
Assert.True(instance.TryAdd($"key{index}", index, out _));
122+
}
123+
124+
// Assert: Verify contents
125+
for (var i = 0; i < largeCapacity; i++)
126+
{
127+
Assert.False(instance.TryAdd($"key{i}", 0, out var storedValue));
128+
Assert.Equal(i, storedValue);
129+
}
130+
}
131+
}
132+
}

0 commit comments

Comments
 (0)