Skip to content

Commit 378b27e

Browse files
Marc Gravellcarlossanlop
authored andcommitted
Microsoft.Extensions.Caching.Memory: use Marvin for keys on down-level TFMs
Note that Microsoft.Extensions.Caching.Memory is deployed OOB via NuGet, and multi-targets including support for netfx and ns2.0 Marvin is the string hashing used in modern .NET (https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Marvin.cs); this change extends this behaviour to `string` keys when used with `MemoryCache` - noting that in many scenarios we *expect* the key to be a `string` (even though other types are allowed) To do this we: - ~~add a custom key equality comparer for use with `ConcurrentDictionary<object, CacheEntry>` (we do this on all TFMs, replacing the need for the dynamic lookup via `EqualityComparer<TKey>.Default`)~~ - split the internal dictionary into 2 - one for `string`, one for everything else - in down-level TFMs only, provide a custom `string` key comparer with `MarvinHash` enabled (local snapshot since not available) This gives us Marvin everywhere, and Marvin+rehash on netcore TFMs (rehash requires `TKey` === `string`)
1 parent 7dc1560 commit 378b27e

File tree

4 files changed

+136
-26
lines changed

4 files changed

+136
-26
lines changed

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

Lines changed: 89 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
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.CodeAnalysis;
89
using System.Runtime.CompilerServices;
10+
using System.Runtime.InteropServices;
911
using System.Threading;
1012
using System.Threading.Tasks;
1113
using Microsoft.Extensions.Logging;
@@ -126,7 +128,7 @@ internal void SetEntry(CacheEntry entry)
126128
entry.LastAccessed = utcNow;
127129

128130
CoherentState coherentState = _coherentState; // Clear() can update the reference in the meantime
129-
if (coherentState._entries.TryGetValue(entry.Key, out CacheEntry? priorEntry))
131+
if (coherentState.TryGetValue(entry.Key, out CacheEntry? priorEntry))
130132
{
131133
priorEntry.SetExpired(EvictionReason.Replaced);
132134
}
@@ -145,12 +147,12 @@ internal void SetEntry(CacheEntry entry)
145147
if (priorEntry == null)
146148
{
147149
// Try to add the new entry if no previous entries exist.
148-
entryAdded = coherentState._entries.TryAdd(entry.Key, entry);
150+
entryAdded = coherentState.TryAdd(entry.Key, entry);
149151
}
150152
else
151153
{
152154
// Try to update with the new entry if a previous entries exist.
153-
entryAdded = coherentState._entries.TryUpdate(entry.Key, entry, priorEntry);
155+
entryAdded = coherentState.TryUpdate(entry.Key, entry, priorEntry);
154156

155157
if (entryAdded)
156158
{
@@ -165,7 +167,7 @@ internal void SetEntry(CacheEntry entry)
165167
// The update will fail if the previous entry was removed after retrieval.
166168
// Adding the new entry will succeed only if no entry has been added since.
167169
// This guarantees removing an old entry does not prevent adding a new entry.
168-
entryAdded = coherentState._entries.TryAdd(entry.Key, entry);
170+
entryAdded = coherentState.TryAdd(entry.Key, entry);
169171
}
170172
}
171173

@@ -210,7 +212,7 @@ public bool TryGetValue(object key, out object? result)
210212
DateTime utcNow = UtcNow;
211213

212214
CoherentState coherentState = _coherentState; // Clear() can update the reference in the meantime
213-
if (coherentState._entries.TryGetValue(key, out CacheEntry? tmp))
215+
if (coherentState.TryGetValue(key, out CacheEntry? tmp))
214216
{
215217
CacheEntry entry = tmp;
216218
// Check if expired due to expiration tokens, timers, etc. and if so, remove it.
@@ -269,7 +271,8 @@ public void Remove(object key)
269271
CheckDisposed();
270272

271273
CoherentState coherentState = _coherentState; // Clear() can update the reference in the meantime
272-
if (coherentState._entries.TryRemove(key, out CacheEntry? entry))
274+
275+
if (coherentState.TryRemove(key, out CacheEntry? entry))
273276
{
274277
if (_options.HasSizeLimit)
275278
{
@@ -291,10 +294,10 @@ public void Clear()
291294
CheckDisposed();
292295

293296
CoherentState oldState = Interlocked.Exchange(ref _coherentState, new CoherentState());
294-
foreach (KeyValuePair<object, CacheEntry> entry in oldState._entries)
297+
foreach (CacheEntry entry in oldState.GetAllValues())
295298
{
296-
entry.Value.SetExpired(EvictionReason.Removed);
297-
entry.Value.InvokeEvictionCallbacks();
299+
entry.SetExpired(EvictionReason.Removed);
300+
entry.InvokeEvictionCallbacks();
298301
}
299302
}
300303

@@ -415,10 +418,9 @@ private void ScanForExpiredItems()
415418
DateTime utcNow = _lastExpirationScan = UtcNow;
416419

417420
CoherentState coherentState = _coherentState; // Clear() can update the reference in the meantime
418-
foreach (KeyValuePair<object, CacheEntry> item in coherentState._entries)
419-
{
420-
CacheEntry entry = item.Value;
421421

422+
foreach (CacheEntry entry in coherentState.GetAllValues())
423+
{
422424
if (entry.CheckExpired(utcNow))
423425
{
424426
coherentState.RemoveEntry(entry, _options);
@@ -516,9 +518,8 @@ private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntry
516518

517519
// Sort items by expired & priority status
518520
DateTime utcNow = UtcNow;
519-
foreach (KeyValuePair<object, CacheEntry> item in coherentState._entries)
521+
foreach (CacheEntry entry in coherentState.GetAllValues())
520522
{
521-
CacheEntry entry = item.Value;
522523
if (entry.CheckExpired(utcNow))
523524
{
524525
entriesToRemove.Add(entry);
@@ -645,18 +646,59 @@ private static void ValidateCacheKey(object key)
645646
/// </summary>
646647
private sealed class CoherentState
647648
{
648-
internal ConcurrentDictionary<object, CacheEntry> _entries = new ConcurrentDictionary<object, CacheEntry>();
649+
private readonly ConcurrentDictionary<string, CacheEntry> _stringEntries = new ConcurrentDictionary<string, CacheEntry>(StringKeyComparer.Instance);
650+
private readonly ConcurrentDictionary<object, CacheEntry> _nonStringEntries = new ConcurrentDictionary<object, CacheEntry>();
649651
internal long _cacheSize;
650652

651-
private ICollection<KeyValuePair<object, CacheEntry>> EntriesCollection => _entries;
653+
internal bool TryGetValue(object key, [NotNullWhen(true)] out CacheEntry? entry)
654+
=> key is string s ? _stringEntries.TryGetValue(s, out entry) : _nonStringEntries.TryGetValue(key, out entry);
655+
656+
internal bool TryRemove(object key, [NotNullWhen(true)] out CacheEntry? entry)
657+
=> key is string s ? _stringEntries.TryRemove(s, out entry) : _nonStringEntries.TryRemove(key, out entry);
658+
659+
internal bool TryAdd(object key, CacheEntry entry)
660+
=> key is string s ? _stringEntries.TryAdd(s, entry) : _nonStringEntries.TryAdd(key, entry);
661+
662+
internal bool TryUpdate(object key, CacheEntry entry, CacheEntry comparison)
663+
=> key is string s ? _stringEntries.TryUpdate(s, entry, comparison) : _nonStringEntries.TryUpdate(key, entry, comparison);
664+
665+
public IEnumerable<CacheEntry> GetAllValues()
666+
{
667+
// note this mimics the outgoing code in that we don't just access
668+
// .Values, which has additional overheads; this is only used for rare
669+
// calls - compaction, clear, etc - so the additional overhead of a
670+
// generated enumerator is not alarming
671+
foreach (KeyValuePair<string, CacheEntry> entry in _stringEntries)
672+
{
673+
yield return entry.Value;
674+
}
675+
foreach (KeyValuePair<object, CacheEntry> entry in _nonStringEntries)
676+
{
677+
yield return entry.Value;
678+
}
679+
}
680+
681+
private ICollection<KeyValuePair<string, CacheEntry>> StringEntriesCollection => _stringEntries;
682+
private ICollection<KeyValuePair<object, CacheEntry>> NonStringEntriesCollection => _nonStringEntries;
652683

653-
internal int Count => _entries.Count;
684+
internal int Count => _stringEntries.Count + _nonStringEntries.Count;
654685

655686
internal long Size => Volatile.Read(ref _cacheSize);
656687

657688
internal void RemoveEntry(CacheEntry entry, MemoryCacheOptions options)
658689
{
659-
if (EntriesCollection.Remove(new KeyValuePair<object, CacheEntry>(entry.Key, entry)))
690+
if (entry.Key is string s)
691+
{
692+
if (StringEntriesCollection.Remove(new KeyValuePair<string, CacheEntry>(s, entry)))
693+
{
694+
if (options.SizeLimit.HasValue)
695+
{
696+
Interlocked.Add(ref _cacheSize, -entry.Size);
697+
}
698+
entry.InvokeEvictionCallbacks();
699+
}
700+
}
701+
else if (NonStringEntriesCollection.Remove(new KeyValuePair<object, CacheEntry>(entry.Key, entry)))
660702
{
661703
if (options.SizeLimit.HasValue)
662704
{
@@ -665,6 +707,35 @@ internal void RemoveEntry(CacheEntry entry, MemoryCacheOptions options)
665707
entry.InvokeEvictionCallbacks();
666708
}
667709
}
710+
711+
#if NETCOREAPP
712+
// on .NET Core, the inbuilt comparer has Marvin built in; no need to intercept
713+
private static class StringKeyComparer
714+
{
715+
internal static IEqualityComparer<string> Instance => EqualityComparer<string>.Default;
716+
}
717+
#else
718+
// otherwise, we need a custom comparer that manually implements Marvin
719+
private sealed class StringKeyComparer : IEqualityComparer<string>, IEqualityComparer
720+
{
721+
private StringKeyComparer() { }
722+
723+
internal static readonly IEqualityComparer<string> Instance = new StringKeyComparer();
724+
725+
// special-case string keys and use Marvin hashing
726+
public int GetHashCode(string? s) => s is null ? 0
727+
: Marvin.ComputeHash32(MemoryMarshal.AsBytes(s.AsSpan()), Marvin.DefaultSeed);
728+
729+
public bool Equals(string? x, string? y)
730+
=> string.Equals(x, y);
731+
732+
bool IEqualityComparer.Equals(object x, object y)
733+
=> object.Equals(x, y);
734+
735+
int IEqualityComparer.GetHashCode(object obj)
736+
=> obj is string s ? GetHashCode(s) : 0;
737+
}
738+
#endif
668739
}
669740
}
670741
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
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>
9+
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
10+
<ServicingVersion>1</ServicingVersion>
811
</PropertyGroup>
912

1013
<ItemGroup>
@@ -21,4 +24,8 @@
2124
<PackageReference Include="System.ValueTuple" Version="$(SystemValueTupleVersion)" />
2225
</ItemGroup>
2326

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

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,21 @@ public async Task GetOrCreateAsyncFromCacheWithNullKeyThrows()
794794
await Assert.ThrowsAsync<ArgumentNullException>(async () => await cache.GetOrCreateAsync<object>(null, null));
795795
}
796796

797+
[Fact]
798+
public void MixedKeysUsage()
799+
{
800+
// keys are split internally into 2 separate chunks
801+
var cache = CreateCache();
802+
var typed = Assert.IsType<MemoryCache>(cache);
803+
object key0 = 123.45M, key1 = "123.45";
804+
cache.Set(key0, "string value");
805+
cache.Set(key1, "decimal value");
806+
807+
Assert.Equal(2, typed.Count);
808+
Assert.Equal("string value", cache.Get(key0));
809+
Assert.Equal("decimal value", cache.Get(key1));
810+
}
811+
797812
private class TestKey
798813
{
799814
public override bool Equals(object obj) => true;

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
using System.Runtime.CompilerServices;
66
using System.Runtime.InteropServices;
77

8+
#if SYSTEM_PRIVATE_CORELIB
9+
using static System.Numerics.BitOperations;
10+
#else
11+
using System.Security.Cryptography;
12+
#endif
13+
814
namespace System
915
{
1016
internal static partial class Marvin
@@ -235,23 +241,34 @@ private static void Block(ref uint rp0, ref uint rp1)
235241
rp1 = p1;
236242
}
237243

238-
#if SYSTEM_PRIVATE_CORELIB
239-
[MethodImpl(MethodImplOptions.AggressiveInlining)]
240-
private static uint RotateLeft(uint value, int offset) =>
241-
System.Numerics.BitOperations.RotateLeft(value, offset);
242-
243244
public static ulong DefaultSeed { get; } = GenerateSeed();
244245

245246
private static unsafe ulong GenerateSeed()
246247
{
247248
ulong seed;
249+
#if SYSTEM_PRIVATE_CORELIB
248250
Interop.GetRandomBytes((byte*)&seed, sizeof(ulong));
251+
#else
252+
byte[] seedBytes = new byte[sizeof(ulong)];
253+
using (RandomNumberGenerator rng = RandomNumberGenerator.Create())
254+
{
255+
rng.GetBytes(seedBytes);
256+
fixed (byte* b = seedBytes)
257+
{
258+
seed = *(ulong*)b;
259+
}
260+
}
261+
#endif
249262
return seed;
250263
}
251-
#else
264+
265+
#if !SYSTEM_PRIVATE_CORELIB
252266
[MethodImpl(MethodImplOptions.AggressiveInlining)]
253-
private static uint RotateLeft(uint value, int offset) =>
254-
(value << offset) | (value >> (32 - offset));
267+
private static uint RotateLeft(uint value, int shift)
268+
{
269+
// This is expected to be optimized into a single rol (or ror with negated shift value) instruction
270+
return (value << shift) | (value >> (32 - shift));
271+
}
255272
#endif
256273
}
257274
}

0 commit comments

Comments
 (0)