Skip to content

Commit fd1aa2c

Browse files
Marc Gravellcarlossanlop
Marc Gravell
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 741b045 commit fd1aa2c

File tree

3 files changed

+166
-24
lines changed

3 files changed

+166
-24
lines changed

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

+127-17
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
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.Runtime.CompilerServices;
9+
using System.Runtime.InteropServices;
810
using System.Threading;
911
using System.Threading.Tasks;
1012
using Microsoft.Extensions.Internal;
@@ -23,7 +25,8 @@ public class MemoryCache : IMemoryCache
2325
internal readonly ILogger _logger;
2426

2527
private readonly MemoryCacheOptions _options;
26-
private readonly ConcurrentDictionary<object, CacheEntry> _entries;
28+
private readonly ConcurrentDictionary<string, CacheEntry> _stringKeyEntries;
29+
private readonly ConcurrentDictionary<object, CacheEntry> _nonStringKeyEntries;
2730

2831
private long _cacheSize;
2932
private bool _disposed;
@@ -56,7 +59,8 @@ public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor, ILoggerFactory
5659
_options = optionsAccessor.Value;
5760
_logger = loggerFactory.CreateLogger<MemoryCache>();
5861

59-
_entries = new ConcurrentDictionary<object, CacheEntry>();
62+
_stringKeyEntries = new ConcurrentDictionary<string, CacheEntry>(StringKeyComparer.Instance);
63+
_nonStringKeyEntries = new ConcurrentDictionary<object, CacheEntry>();
6064

6165
if (_options.Clock == null)
6266
{
@@ -74,12 +78,14 @@ public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor, ILoggerFactory
7478
/// <summary>
7579
/// Gets the count of the current entries for diagnostic purposes.
7680
/// </summary>
77-
public int Count => _entries.Count;
81+
public int Count => _stringKeyEntries.Count + _nonStringKeyEntries.Count;
7882

7983
// internal for testing
8084
internal long Size { get => Interlocked.Read(ref _cacheSize); }
8185

82-
private ICollection<KeyValuePair<object, CacheEntry>> EntriesCollection => _entries;
86+
private ICollection<KeyValuePair<string, CacheEntry>> StringKeyEntriesCollection => _stringKeyEntries;
87+
88+
private ICollection<KeyValuePair<object, CacheEntry>> NonStringKeyEntriesCollection => _nonStringKeyEntries;
8389

8490
/// <inheritdoc />
8591
public ICacheEntry CreateEntry(object key)
@@ -129,7 +135,16 @@ internal void SetEntry(CacheEntry entry)
129135
// Initialize the last access timestamp at the time the entry is added
130136
entry.LastAccessed = utcNow;
131137

132-
if (_entries.TryGetValue(entry.Key, out CacheEntry priorEntry))
138+
CacheEntry priorEntry = null;
139+
string s = entry.Key as string;
140+
if (s != null)
141+
{
142+
if (_stringKeyEntries.TryGetValue(s, out priorEntry))
143+
{
144+
priorEntry.SetExpired(EvictionReason.Replaced);
145+
}
146+
}
147+
else if (_nonStringKeyEntries.TryGetValue(entry.Key, out priorEntry))
133148
{
134149
priorEntry.SetExpired(EvictionReason.Replaced);
135150
}
@@ -143,12 +158,26 @@ internal void SetEntry(CacheEntry entry)
143158
if (priorEntry == null)
144159
{
145160
// Try to add the new entry if no previous entries exist.
146-
entryAdded = _entries.TryAdd(entry.Key, entry);
161+
if (s != null)
162+
{
163+
entryAdded = _stringKeyEntries.TryAdd(s, entry);
164+
}
165+
else
166+
{
167+
entryAdded = _nonStringKeyEntries.TryAdd(entry.Key, entry);
168+
}
147169
}
148170
else
149171
{
150172
// Try to update with the new entry if a previous entries exist.
151-
entryAdded = _entries.TryUpdate(entry.Key, entry, priorEntry);
173+
if (s != null)
174+
{
175+
entryAdded = _stringKeyEntries.TryUpdate(s, entry, priorEntry);
176+
}
177+
else
178+
{
179+
entryAdded = _nonStringKeyEntries.TryUpdate(entry.Key, entry, priorEntry);
180+
}
152181

153182
if (entryAdded)
154183
{
@@ -163,7 +192,14 @@ internal void SetEntry(CacheEntry entry)
163192
// The update will fail if the previous entry was removed after retrival.
164193
// Adding the new entry will succeed only if no entry has been added since.
165194
// This guarantees removing an old entry does not prevent adding a new entry.
166-
entryAdded = _entries.TryAdd(entry.Key, entry);
195+
if (s != null)
196+
{
197+
entryAdded = _stringKeyEntries.TryAdd(s, entry);
198+
}
199+
else
200+
{
201+
entryAdded = _nonStringKeyEntries.TryAdd(entry.Key, entry);
202+
}
167203
}
168204
}
169205

@@ -223,7 +259,18 @@ public bool TryGetValue(object key, out object result)
223259

224260
DateTimeOffset utcNow = _options.Clock.UtcNow;
225261

226-
if (_entries.TryGetValue(key, out CacheEntry entry))
262+
bool found;
263+
CacheEntry entry;
264+
if (key is string s)
265+
{
266+
found = _stringKeyEntries.TryGetValue(s, out entry);
267+
}
268+
else
269+
{
270+
found = _nonStringKeyEntries.TryGetValue(key, out entry);
271+
}
272+
273+
if (found)
227274
{
228275
// Check if expired due to expiration tokens, timers, etc. and if so, remove it.
229276
// Allow a stale Replaced value to be returned due to concurrent calls to SetExpired during SetEntry.
@@ -262,7 +309,18 @@ public void Remove(object key)
262309
ValidateCacheKey(key);
263310

264311
CheckDisposed();
265-
if (_entries.TryRemove(key, out CacheEntry entry))
312+
bool removed;
313+
CacheEntry entry;
314+
if (key is string s)
315+
{
316+
removed = _stringKeyEntries.TryRemove(s, out entry);
317+
}
318+
else
319+
{
320+
removed = _nonStringKeyEntries.TryRemove(key, out entry);
321+
}
322+
323+
if (removed)
266324
{
267325
if (_options.SizeLimit.HasValue)
268326
{
@@ -278,7 +336,17 @@ public void Remove(object key)
278336

279337
private void RemoveEntry(CacheEntry entry)
280338
{
281-
if (EntriesCollection.Remove(new KeyValuePair<object, CacheEntry>(entry.Key, entry)))
339+
bool removed;
340+
if (entry.Key is string s)
341+
{
342+
removed = StringKeyEntriesCollection.Remove(new KeyValuePair<string, CacheEntry>(s, entry));
343+
}
344+
else
345+
{
346+
removed = NonStringKeyEntriesCollection.Remove(new KeyValuePair<object, CacheEntry>(entry.Key, entry));
347+
}
348+
349+
if (removed)
282350
{
283351
if (_options.SizeLimit.HasValue)
284352
{
@@ -317,10 +385,8 @@ private static void ScanForExpiredItems(MemoryCache cache)
317385
{
318386
DateTimeOffset now = cache._lastExpirationScan = cache._options.Clock.UtcNow;
319387

320-
foreach (KeyValuePair<object, CacheEntry> item in cache._entries)
388+
foreach (CacheEntry entry in cache.GetCacheEntries())
321389
{
322-
CacheEntry entry = item.Value;
323-
324390
if (entry.CheckExpired(now))
325391
{
326392
cache.RemoveEntry(entry);
@@ -388,10 +454,26 @@ private static void OvercapacityCompaction(MemoryCache cache)
388454
/// ?. Larger objects - estimated by object graph size, inaccurate.
389455
public void Compact(double percentage)
390456
{
391-
int removalCountTarget = (int)(_entries.Count * percentage);
457+
int removalCountTarget = (int)(Count * percentage);
392458
Compact(removalCountTarget, _ => 1);
393459
}
394460

461+
private IEnumerable<CacheEntry> GetCacheEntries()
462+
{
463+
// note this mimics the outgoing code in that we don't just access
464+
// .Values, which has additional overheads; this is only used for rare
465+
// calls - compaction, clear, etc - so the additional overhead of a
466+
// generated enumerator is not alarming
467+
foreach (KeyValuePair<string, CacheEntry> item in _stringKeyEntries)
468+
{
469+
yield return item.Value;
470+
}
471+
foreach (KeyValuePair<object, CacheEntry> item in _nonStringKeyEntries)
472+
{
473+
yield return item.Value;
474+
}
475+
}
476+
395477
private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntrySize)
396478
{
397479
var entriesToRemove = new List<CacheEntry>();
@@ -403,9 +485,8 @@ private void Compact(long removalSizeTarget, Func<CacheEntry, long> computeEntry
403485

404486
// Sort items by expired & priority status
405487
DateTimeOffset now = _options.Clock.UtcNow;
406-
foreach (KeyValuePair<object, CacheEntry> item in _entries)
488+
foreach (CacheEntry entry in GetCacheEntries())
407489
{
408-
CacheEntry entry = item.Value;
409490
if (entry.CheckExpired(now))
410491
{
411492
entriesToRemove.Add(entry);
@@ -526,5 +607,34 @@ private static void ValidateCacheKey(object key)
526607

527608
static void Throw() => throw new ArgumentNullException(nameof(key));
528609
}
610+
611+
#if NETCOREAPP
612+
// on .NET Core, the inbuilt comparer has Marvin built in; no need to intercept
613+
private static class StringKeyComparer
614+
{
615+
internal static IEqualityComparer<string> Instance => EqualityComparer<string>.Default;
616+
}
617+
#else
618+
// otherwise, we need a custom comparer that manually implements Marvin
619+
private sealed class StringKeyComparer : IEqualityComparer<string>, IEqualityComparer
620+
{
621+
private StringKeyComparer() { }
622+
623+
internal static readonly IEqualityComparer<string> Instance = new StringKeyComparer();
624+
625+
// special-case string keys and use Marvin hashing
626+
public int GetHashCode(string? s) => s is null ? 0
627+
: Marvin.ComputeHash32(MemoryMarshal.AsBytes(s.AsSpan()), Marvin.DefaultSeed);
628+
629+
public bool Equals(string? x, string? y)
630+
=> string.Equals(x, y);
631+
632+
bool IEqualityComparer.Equals(object x, object y)
633+
=> object.Equals(x, y);
634+
635+
int IEqualityComparer.GetHashCode(object obj)
636+
=> obj is string s ? Marvin.ComputeHash32(MemoryMarshal.AsBytes(s.AsSpan()), Marvin.DefaultSeed) : 0;
637+
}
638+
#endif
529639
}
530640
}

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

+7-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
<TargetFrameworks>netstandard2.0;net461</TargetFrameworks>
55
<EnableDefaultItems>true</EnableDefaultItems>
66
<PackageDescription>In-memory cache implementation of Microsoft.Extensions.Caching.Memory.IMemoryCache.</PackageDescription>
7-
<ServicingVersion>1</ServicingVersion>
7+
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
8+
<ServicingVersion>2</ServicingVersion>
9+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
810
</PropertyGroup>
911

1012
<ItemGroup>
@@ -15,4 +17,8 @@
1517
<ProjectReference Include="$(LibrariesProjectRoot)Microsoft.Extensions.Primitives\src\Microsoft.Extensions.Primitives.csproj" />
1618
</ItemGroup>
1719

20+
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'netstandard2.1'))">
21+
<Compile Include="$(CoreLibSharedDir)System\Marvin.cs" Link="Common\System\Marvin.cs" />
22+
</ItemGroup>
23+
1824
</Project>

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

+32-6
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics;
5-
using System.Numerics;
65
using System.Runtime.CompilerServices;
76
using System.Runtime.InteropServices;
7+
8+
#if SYSTEM_PRIVATE_CORELIB
89
using Internal.Runtime.CompilerServices;
10+
using static System.Numerics.BitOperations;
11+
#else
12+
using System.Security.Cryptography;
13+
#endif
914

1015
namespace System
1116
{
@@ -205,7 +210,7 @@ public static int ComputeHash32(ref byte data, uint count, uint p0, uint p1)
205210
else
206211
{
207212
partialResult |= (uint)Unsafe.ReadUnaligned<ushort>(ref data);
208-
partialResult = BitOperations.RotateLeft(partialResult, 16);
213+
partialResult = RotateLeft(partialResult, 16);
209214
}
210215
}
211216

@@ -222,16 +227,16 @@ private static void Block(ref uint rp0, ref uint rp1)
222227
uint p1 = rp1;
223228

224229
p1 ^= p0;
225-
p0 = BitOperations.RotateLeft(p0, 20);
230+
p0 = RotateLeft(p0, 20);
226231

227232
p0 += p1;
228-
p1 = BitOperations.RotateLeft(p1, 9);
233+
p1 = RotateLeft(p1, 9);
229234

230235
p1 ^= p0;
231-
p0 = BitOperations.RotateLeft(p0, 27);
236+
p0 = RotateLeft(p0, 27);
232237

233238
p0 += p1;
234-
p1 = BitOperations.RotateLeft(p1, 19);
239+
p1 = RotateLeft(p1, 19);
235240

236241
rp0 = p0;
237242
rp1 = p1;
@@ -242,8 +247,29 @@ private static void Block(ref uint rp0, ref uint rp1)
242247
private static unsafe ulong GenerateSeed()
243248
{
244249
ulong seed;
250+
#if SYSTEM_PRIVATE_CORELIB
245251
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
246263
return seed;
247264
}
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
248274
}
249275
}

0 commit comments

Comments
 (0)