Skip to content

Commit d90c2e5

Browse files
authored
M.E.C.M. performance (#111502)
- split string and non-string keys into separate collections, to allow re-hashing of string keys - use Marvin explicitly on down-level TFMs (it is implicit on up-level)
1 parent 85506c3 commit d90c2e5

File tree

4 files changed

+154
-32
lines changed

4 files changed

+154
-32
lines changed

src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs

+102-27
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.Collections;
56
using System.Collections.Concurrent;
67
using System.Collections.Generic;
78
using System.Diagnostics;
89
using System.Diagnostics.CodeAnalysis;
910
using System.Runtime.CompilerServices;
11+
using System.Runtime.InteropServices;
1012
using System.Threading;
1113
using System.Threading.Tasks;
1214
using Microsoft.Extensions.Logging;
@@ -81,15 +83,7 @@ public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor, ILoggerFactory
8183
/// Gets an enumerable of the all the keys in the <see cref="MemoryCache"/>.
8284
/// </summary>
8385
public IEnumerable<object> Keys
84-
{
85-
get
86-
{
87-
foreach (KeyValuePair<object, CacheEntry> pairs in _coherentState._entries)
88-
{
89-
yield return pairs.Key;
90-
}
91-
}
92-
}
86+
=> _coherentState.GetAllKeys();
9387

9488
/// <summary>
9589
/// Internal accessor for Size for testing only.
@@ -141,7 +135,7 @@ internal void SetEntry(CacheEntry entry)
141135
entry.LastAccessed = utcNow;
142136

143137
CoherentState coherentState = _coherentState; // Clear() can update the reference in the meantime
144-
if (coherentState._entries.TryGetValue(entry.Key, out CacheEntry? priorEntry))
138+
if (coherentState.TryGetValue(entry.Key, out CacheEntry? priorEntry))
145139
{
146140
priorEntry.SetExpired(EvictionReason.Replaced);
147141
}
@@ -160,19 +154,19 @@ internal void SetEntry(CacheEntry entry)
160154
if (priorEntry == null)
161155
{
162156
// Try to add the new entry if no previous entries exist.
163-
entryAdded = coherentState._entries.TryAdd(entry.Key, entry);
157+
entryAdded = coherentState.TryAdd(entry.Key, entry);
164158
}
165159
else
166160
{
167161
// Try to update with the new entry if a previous entries exist.
168-
entryAdded = coherentState._entries.TryUpdate(entry.Key, entry, priorEntry);
162+
entryAdded = coherentState.TryUpdate(entry.Key, entry, priorEntry);
169163

170164
if (!entryAdded)
171165
{
172166
// The update will fail if the previous entry was removed after retrieval.
173167
// Adding the new entry will succeed only if no entry has been added since.
174168
// This guarantees removing an old entry does not prevent adding a new entry.
175-
entryAdded = coherentState._entries.TryAdd(entry.Key, entry);
169+
entryAdded = coherentState.TryAdd(entry.Key, entry);
176170
}
177171
}
178172

@@ -217,7 +211,7 @@ public bool TryGetValue(object key, out object? result)
217211
DateTime utcNow = UtcNow;
218212

219213
CoherentState coherentState = _coherentState; // Clear() can update the reference in the meantime
220-
if (coherentState._entries.TryGetValue(key, out CacheEntry? tmp))
214+
if (coherentState.TryGetValue(key, out CacheEntry? tmp))
221215
{
222216
CacheEntry entry = tmp;
223217
// Check if expired due to expiration tokens, timers, etc. and if so, remove it.
@@ -276,7 +270,8 @@ public void Remove(object key)
276270
CheckDisposed();
277271

278272
CoherentState coherentState = _coherentState; // Clear() can update the reference in the meantime
279-
if (coherentState._entries.TryRemove(key, out CacheEntry? entry))
273+
274+
if (coherentState.TryRemove(key, out CacheEntry? entry))
280275
{
281276
if (_options.HasSizeLimit)
282277
{
@@ -298,10 +293,10 @@ public void Clear()
298293
CheckDisposed();
299294

300295
CoherentState oldState = Interlocked.Exchange(ref _coherentState, new CoherentState());
301-
foreach (KeyValuePair<object, CacheEntry> entry in oldState._entries)
296+
foreach (CacheEntry entry in oldState.GetAllValues())
302297
{
303-
entry.Value.SetExpired(EvictionReason.Removed);
304-
entry.Value.InvokeEvictionCallbacks();
298+
entry.SetExpired(EvictionReason.Removed);
299+
entry.InvokeEvictionCallbacks();
305300
}
306301
}
307302

@@ -422,10 +417,9 @@ private void ScanForExpiredItems()
422417
DateTime utcNow = _lastExpirationScan = UtcNow;
423418

424419
CoherentState coherentState = _coherentState; // Clear() can update the reference in the meantime
425-
foreach (KeyValuePair<object, CacheEntry> item in coherentState._entries)
426-
{
427-
CacheEntry entry = item.Value;
428420

421+
foreach (CacheEntry entry in coherentState.GetAllValues())
422+
{
429423
if (entry.CheckExpired(utcNow))
430424
{
431425
coherentState.RemoveEntry(entry, _options);
@@ -547,9 +541,8 @@ private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntry
547541

548542
// Sort items by expired & priority status
549543
DateTime utcNow = UtcNow;
550-
foreach (KeyValuePair<object, CacheEntry> item in coherentState._entries)
544+
foreach (CacheEntry entry in coherentState.GetAllValues())
551545
{
552-
CacheEntry entry = item.Value;
553546
if (entry.CheckExpired(utcNow))
554547
{
555548
entriesToRemove.Add(entry);
@@ -676,18 +669,71 @@ private static void ValidateCacheKey(object key)
676669
/// </summary>
677670
private sealed class CoherentState
678671
{
679-
internal ConcurrentDictionary<object, CacheEntry> _entries = new ConcurrentDictionary<object, CacheEntry>();
672+
private readonly ConcurrentDictionary<string, CacheEntry> _stringEntries = new ConcurrentDictionary<string, CacheEntry>(StringKeyComparer.Instance);
673+
private readonly ConcurrentDictionary<object, CacheEntry> _nonStringEntries = new ConcurrentDictionary<object, CacheEntry>();
680674
internal long _cacheSize;
681675

682-
private ICollection<KeyValuePair<object, CacheEntry>> EntriesCollection => _entries;
676+
internal bool TryGetValue(object key, [NotNullWhen(true)] out CacheEntry? entry)
677+
=> key is string s ? _stringEntries.TryGetValue(s, out entry) : _nonStringEntries.TryGetValue(key, out entry);
678+
679+
internal bool TryRemove(object key, [NotNullWhen(true)] out CacheEntry? entry)
680+
=> key is string s ? _stringEntries.TryRemove(s, out entry) : _nonStringEntries.TryRemove(key, out entry);
681+
682+
internal bool TryAdd(object key, CacheEntry entry)
683+
=> key is string s ? _stringEntries.TryAdd(s, entry) : _nonStringEntries.TryAdd(key, entry);
684+
685+
internal bool TryUpdate(object key, CacheEntry entry, CacheEntry comparison)
686+
=> key is string s ? _stringEntries.TryUpdate(s, entry, comparison) : _nonStringEntries.TryUpdate(key, entry, comparison);
687+
688+
public IEnumerable<CacheEntry> GetAllValues()
689+
{
690+
// note this mimics the outgoing code in that we don't just access
691+
// .Values, which has additional overheads; this is only used for rare
692+
// calls - compaction, clear, etc - so the additional overhead of a
693+
// generated enumerator is not alarming
694+
foreach (KeyValuePair<string, CacheEntry> entry in _stringEntries)
695+
{
696+
yield return entry.Value;
697+
}
698+
foreach (KeyValuePair<object, CacheEntry> entry in _nonStringEntries)
699+
{
700+
yield return entry.Value;
701+
}
702+
}
703+
704+
public IEnumerable<object> GetAllKeys()
705+
{
706+
foreach (KeyValuePair<string, CacheEntry> pairs in _stringEntries)
707+
{
708+
yield return pairs.Key;
709+
}
710+
foreach (KeyValuePair<object, CacheEntry> pairs in _nonStringEntries)
711+
{
712+
yield return pairs.Key;
713+
}
714+
}
715+
716+
private ICollection<KeyValuePair<string, CacheEntry>> StringEntriesCollection => _stringEntries;
717+
private ICollection<KeyValuePair<object, CacheEntry>> NonStringEntriesCollection => _nonStringEntries;
683718

684-
internal int Count => _entries.Count;
719+
internal int Count => _stringEntries.Count + _nonStringEntries.Count;
685720

686721
internal long Size => Volatile.Read(ref _cacheSize);
687722

688723
internal void RemoveEntry(CacheEntry entry, MemoryCacheOptions options)
689724
{
690-
if (EntriesCollection.Remove(new KeyValuePair<object, CacheEntry>(entry.Key, entry)))
725+
if (entry.Key is string s)
726+
{
727+
if (StringEntriesCollection.Remove(new KeyValuePair<string, CacheEntry>(s, entry)))
728+
{
729+
if (options.SizeLimit.HasValue)
730+
{
731+
Interlocked.Add(ref _cacheSize, -entry.Size);
732+
}
733+
entry.InvokeEvictionCallbacks();
734+
}
735+
}
736+
else if (NonStringEntriesCollection.Remove(new KeyValuePair<object, CacheEntry>(entry.Key, entry)))
691737
{
692738
if (options.SizeLimit.HasValue)
693739
{
@@ -696,6 +742,35 @@ internal void RemoveEntry(CacheEntry entry, MemoryCacheOptions options)
696742
entry.InvokeEvictionCallbacks();
697743
}
698744
}
745+
746+
#if NETCOREAPP
747+
// on .NET Core, the inbuilt comparer has Marvin built in; no need to intercept
748+
private static class StringKeyComparer
749+
{
750+
internal static IEqualityComparer<string> Instance => EqualityComparer<string>.Default;
751+
}
752+
#else
753+
// otherwise, we need a custom comparer that manually implements Marvin
754+
private sealed class StringKeyComparer : IEqualityComparer<string>, IEqualityComparer
755+
{
756+
private StringKeyComparer() { }
757+
758+
internal static readonly IEqualityComparer<string> Instance = new StringKeyComparer();
759+
760+
// special-case string keys and use Marvin hashing
761+
public int GetHashCode(string? s) => s is null ? 0
762+
: Marvin.ComputeHash32(MemoryMarshal.AsBytes(s.AsSpan()), Marvin.DefaultSeed);
763+
764+
public bool Equals(string? x, string? y)
765+
=> string.Equals(x, y);
766+
767+
bool IEqualityComparer.Equals(object x, object y)
768+
=> object.Equals(x, y);
769+
770+
int IEqualityComparer.GetHashCode(object obj)
771+
=> obj is string s ? GetHashCode(s) : 0;
772+
}
773+
#endif
699774
}
700775
}
701776
}

src/libraries/Microsoft.Extensions.Caching.Memory/src/Microsoft.Extensions.Caching.Memory.csproj

+5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<EnableDefaultItems>true</EnableDefaultItems>
66
<IsPackable>true</IsPackable>
77
<PackageDescription>In-memory cache implementation of Microsoft.Extensions.Caching.Memory.IMemoryCache.</PackageDescription>
8+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
89
</PropertyGroup>
910

1011
<ItemGroup>
@@ -21,4 +22,8 @@
2122
<PackageReference Include="System.ValueTuple" Version="$(SystemValueTupleVersion)" />
2223
</ItemGroup>
2324

25+
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'netstandard2.1'))">
26+
<Compile Include="$(CoreLibSharedDir)System\Marvin.cs" Link="Common\System\Marvin.cs" />
27+
</ItemGroup>
28+
2429
</Project>

src/libraries/Microsoft.Extensions.Caching.Memory/tests/MemoryCacheSetAndRemoveTests.cs

+15
Original file line numberDiff line numberDiff line change
@@ -850,6 +850,21 @@ public async Task GetOrCreateAsyncWithCacheEntryOptions()
850850
Assert.False(cache.TryGetValue(cacheKey, out _));
851851
}
852852

853+
[Fact]
854+
public void MixedKeysUsage()
855+
{
856+
// keys are split internally into 2 separate chunks
857+
var cache = CreateCache();
858+
var typed = Assert.IsType<MemoryCache>(cache);
859+
object key0 = 123.45M, key1 = "123.45";
860+
cache.Set(key0, "string value");
861+
cache.Set(key1, "decimal value");
862+
863+
Assert.Equal(2, typed.Count);
864+
Assert.Equal("string value", cache.Get(key0));
865+
Assert.Equal("decimal value", cache.Get(key1));
866+
}
867+
853868
private class TestKey
854869
{
855870
public override bool Equals(object obj) => true;

src/libraries/System.Private.CoreLib/src/System/Marvin.cs

+32-5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
using System.Runtime.CompilerServices;
77
using System.Runtime.InteropServices;
88

9+
#if SYSTEM_PRIVATE_CORELIB
10+
using static System.Numerics.BitOperations;
11+
#else
12+
using System.Security.Cryptography;
13+
#endif
14+
915
namespace System
1016
{
1117
internal static partial class Marvin
@@ -204,7 +210,7 @@ public static int ComputeHash32(ref byte data, uint count, uint p0, uint p1)
204210
else
205211
{
206212
partialResult |= (uint)Unsafe.ReadUnaligned<ushort>(ref data);
207-
partialResult = BitOperations.RotateLeft(partialResult, 16);
213+
partialResult = RotateLeft(partialResult, 16);
208214
}
209215
}
210216

@@ -221,16 +227,16 @@ private static void Block(ref uint rp0, ref uint rp1)
221227
uint p1 = rp1;
222228

223229
p1 ^= p0;
224-
p0 = BitOperations.RotateLeft(p0, 20);
230+
p0 = RotateLeft(p0, 20);
225231

226232
p0 += p1;
227-
p1 = BitOperations.RotateLeft(p1, 9);
233+
p1 = RotateLeft(p1, 9);
228234

229235
p1 ^= p0;
230-
p0 = BitOperations.RotateLeft(p0, 27);
236+
p0 = RotateLeft(p0, 27);
231237

232238
p0 += p1;
233-
p1 = BitOperations.RotateLeft(p1, 19);
239+
p1 = RotateLeft(p1, 19);
234240

235241
rp0 = p0;
236242
rp1 = p1;
@@ -241,8 +247,29 @@ private static void Block(ref uint rp0, ref uint rp1)
241247
private static unsafe ulong GenerateSeed()
242248
{
243249
ulong seed;
250+
#if SYSTEM_PRIVATE_CORELIB
244251
Interop.GetRandomBytes((byte*)&seed, sizeof(ulong));
252+
#else
253+
byte[] seedBytes = new byte[sizeof(ulong)];
254+
using (RandomNumberGenerator rng = RandomNumberGenerator.Create())
255+
{
256+
rng.GetBytes(seedBytes);
257+
fixed (byte* b = seedBytes)
258+
{
259+
seed = *(ulong*)b;
260+
}
261+
}
262+
#endif
245263
return seed;
246264
}
265+
266+
#if !SYSTEM_PRIVATE_CORELIB
267+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
268+
private static uint RotateLeft(uint value, int shift)
269+
{
270+
// This is expected to be optimized into a single rol (or ror with negated shift value) instruction
271+
return (value << shift) | (value >> (32 - shift));
272+
}
273+
#endif
247274
}
248275
}

0 commit comments

Comments
 (0)