diff --git a/BitFaster.Caching.UnitTests/BitFaster.Caching.UnitTests.csproj b/BitFaster.Caching.UnitTests/BitFaster.Caching.UnitTests.csproj index b5aef909..401c9617 100644 --- a/BitFaster.Caching.UnitTests/BitFaster.Caching.UnitTests.csproj +++ b/BitFaster.Caching.UnitTests/BitFaster.Caching.UnitTests.csproj @@ -2,7 +2,8 @@ net48;netcoreapp3.1;net6.0;net9.0 - 10.0 + 13.0 + 10.0 @@ -30,4 +31,4 @@ - \ No newline at end of file + diff --git a/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateLookupTests.cs b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateLookupTests.cs new file mode 100644 index 00000000..c274cd17 --- /dev/null +++ b/BitFaster.Caching.UnitTests/Lru/ConcurrentLruAlternateLookupTests.cs @@ -0,0 +1,76 @@ +#if NET9_0_OR_GREATER +using System; +using BitFaster.Caching.Lru; +using FluentAssertions; +using Xunit; + +namespace BitFaster.Caching.UnitTests.Lru +{ + public class ConcurrentLruAlternateLookupTests + { + [Fact] + public void TryGetAlternateLookupReturnsLookupForCompatibleComparer() + { + var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); + cache.GetOrAdd("42", _ => "value"); + ReadOnlySpan key = "42"; + + cache.TryGetAlternateLookup>(out var alternate).Should().BeTrue(); + alternate.TryGet(key, out var value).Should().BeTrue(); + value.Should().Be("value"); + } + + [Fact] + public void GetAlternateLookupThrowsForIncompatibleComparer() + { + var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); + + Action act = () => cache.GetAlternateLookup(); + + act.Should().Throw().WithMessage("Incompatible comparer"); + cache.TryGetAlternateLookup(out var alternate).Should().BeFalse(); + alternate.Should().BeNull(); + } + + [Fact] + public void AlternateLookupTryRemoveReturnsActualKeyAndValue() + { + var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); + cache.GetOrAdd("42", _ => "value"); + var alternate = cache.GetAlternateLookup>(); + ReadOnlySpan key = "42"; + + alternate.TryRemove(key, out var actualKey, out var value).Should().BeTrue(); + + actualKey.Should().Be("42"); + value.Should().Be("value"); + cache.TryGet("42", out _).Should().BeFalse(); + } + + [Fact] + public void AlternateLookupGetOrAddUsesAlternateKeyOnMissAndHit() + { + var cache = new ConcurrentLru(1, 3, StringComparer.Ordinal); + var alternate = cache.GetAlternateLookup>(); + var factoryCalls = 0; + ReadOnlySpan key = "42"; + + alternate.GetOrAdd(key, key => + { + factoryCalls++; + return $"value-{key.ToString()}"; + }).Should().Be("value-42"); + + alternate.GetOrAdd(key, (_, prefix) => + { + factoryCalls++; + return prefix; + }, "unused").Should().Be("value-42"); + + factoryCalls.Should().Be(1); + cache.TryGet("42", out var value).Should().BeTrue(); + value.Should().Be("value-42"); + } + } +} +#endif diff --git a/BitFaster.Caching/CollectionExtensions.cs b/BitFaster.Caching/CollectionExtensions.cs new file mode 100644 index 00000000..808b5479 --- /dev/null +++ b/BitFaster.Caching/CollectionExtensions.cs @@ -0,0 +1,29 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace BitFaster.Caching +{ + internal static class CollectionExtensions + { +#if NET9_0_OR_GREATER + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsCompatibleKey(this ConcurrentDictionary dictionary) + where TAlternateKey : notnull, allows ref struct + where TKey : notnull + { + return dictionary.Comparer is IAlternateEqualityComparer; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static IAlternateEqualityComparer GetAlternateComparer(this ConcurrentDictionary dictionary) + where TAlternateKey : notnull, allows ref struct + where TKey : notnull + { + Debug.Assert(dictionary.IsCompatibleKey()); + return Unsafe.As>(dictionary.Comparer!); + } +#endif + } +} diff --git a/BitFaster.Caching/IAlternateLookup.cs b/BitFaster.Caching/IAlternateLookup.cs new file mode 100644 index 00000000..47ca7dc2 --- /dev/null +++ b/BitFaster.Caching/IAlternateLookup.cs @@ -0,0 +1,53 @@ +#if NET9_0_OR_GREATER +using System; +using System.Diagnostics.CodeAnalysis; + +namespace BitFaster.Caching +{ + /// + /// Provides an alternate-key lookup over a cache. + /// + /// The alternate key type. + /// The cache key type. + /// The cache value type. + public interface IAlternateLookup + where TAlternateKey : notnull, allows ref struct + where TKey : notnull + { + /// + /// Attempts to get a value using an alternate key. + /// + /// The alternate key. + /// The cached value when found. + /// when the key is found; otherwise, . + bool TryGet(TAlternateKey key, [MaybeNullWhen(false)] out TValue value); + + /// + /// Attempts to remove a value using an alternate key. + /// + /// The alternate key. + /// The removed cache key. + /// The removed value. + /// when the key is found; otherwise, . + bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out TKey actualKey, [MaybeNullWhen(false)] out TValue value); + + /// + /// Gets an existing value or adds a new value using an alternate key. + /// + /// The alternate key. + /// The value factory. + /// The cached value. + TValue GetOrAdd(TAlternateKey key, Func valueFactory); + + /// + /// Gets an existing value or adds a new value using an alternate key and factory argument. + /// + /// The factory argument type. + /// The alternate key. + /// The value factory. + /// The factory argument. + /// The cached value. + TValue GetOrAdd(TAlternateKey key, Func valueFactory, TArg factoryArgument); + } +} +#endif diff --git a/BitFaster.Caching/Lru/ConcurrentLruCore.cs b/BitFaster.Caching/Lru/ConcurrentLruCore.cs index 76f979f6..22e6954b 100644 --- a/BitFaster.Caching/Lru/ConcurrentLruCore.cs +++ b/BitFaster.Caching/Lru/ConcurrentLruCore.cs @@ -882,21 +882,139 @@ private static Optional CreateMetrics(ConcurrentLruCore> CreateEvents(ConcurrentLruCore lru) - { - if (typeof(T) == typeof(NoTelemetryPolicy)) - { - return Optional>.None(); - } - - return new(new Proxy(lru)); - } - - // To get JIT optimizations, policies must be structs. - // If the structs are returned directly via properties, they will be copied. Since - // telemetryPolicy is a mutable struct, copy is bad. One workaround is to store the - // state within the struct in an object. Since the struct points to the same object - // it becomes immutable. However, this object is then somewhere else on the + private static Optional> CreateEvents(ConcurrentLruCore lru) + { + if (typeof(T) == typeof(NoTelemetryPolicy)) + { + return Optional>.None(); + } + + return new(new Proxy(lru)); + } + +#if NET9_0_OR_GREATER + /// + /// Gets an alternate lookup that can use an alternate key type with the configured comparer. + /// + /// The alternate key type. + /// An alternate lookup. + /// The configured comparer does not support . + public IAlternateLookup GetAlternateLookup() + where TAlternateKey : notnull, allows ref struct + { + if (!this.dictionary.IsCompatibleKey()) + { + Throw.IncompatibleComparer(); + } + + return new AlternateLookup(this); + } + + /// + /// Attempts to get an alternate lookup that can use an alternate key type with the configured comparer. + /// + /// The alternate key type. + /// The alternate lookup when available. + /// when the configured comparer supports ; otherwise, . + public bool TryGetAlternateLookup([MaybeNullWhen(false)] out IAlternateLookup lookup) + where TAlternateKey : notnull, allows ref struct + { + if (this.dictionary.IsCompatibleKey()) + { + lookup = new AlternateLookup(this); + return true; + } + + lookup = default; + return false; + } + + internal readonly struct AlternateLookup : IAlternateLookup + where TAlternateKey : notnull, allows ref struct + { + internal AlternateLookup(ConcurrentLruCore lru) + { + Debug.Assert(lru is not null); + Debug.Assert(lru.dictionary.IsCompatibleKey()); + this.Lru = lru; + this.Alternate = lru.dictionary.GetAlternateLookup(); + } + + internal ConcurrentLruCore Lru { get; } + + internal ConcurrentDictionary.AlternateLookup Alternate { get; } + + public bool TryGet(TAlternateKey key, [MaybeNullWhen(false)] out V value) + { + if (this.Alternate.TryGetValue(key, out var item)) + { + return this.Lru.GetOrDiscard(item, out value); + } + + value = default; + this.Lru.telemetryPolicy.IncrementMiss(); + return false; + } + + public bool TryRemove(TAlternateKey key, [MaybeNullWhen(false)] out K actualKey, [MaybeNullWhen(false)] out V value) + { + if (this.Alternate.TryRemove(key, out actualKey, out var item)) + { + this.Lru.OnRemove(actualKey, item, ItemRemovedReason.Removed); + value = item.Value; + return true; + } + + actualKey = default; + value = default; + return false; + } + + public V GetOrAdd(TAlternateKey key, Func valueFactory) + { + while (true) + { + if (this.TryGet(key, out var value)) + { + return value; + } + + K actualKey = this.Lru.dictionary.GetAlternateComparer().Create(key); + + value = valueFactory(key); + if (this.Lru.TryAdd(actualKey, value)) + { + return value; + } + } + } + + public V GetOrAdd(TAlternateKey key, Func valueFactory, TArg factoryArgument) + { + while (true) + { + if (this.TryGet(key, out var value)) + { + return value; + } + + K actualKey = this.Lru.dictionary.GetAlternateComparer().Create(key); + + value = valueFactory(key, factoryArgument); + if (this.Lru.TryAdd(actualKey, value)) + { + return value; + } + } + } + } +#endif + + // To get JIT optimizations, policies must be structs. + // If the structs are returned directly via properties, they will be copied. Since + // telemetryPolicy is a mutable struct, copy is bad. One workaround is to store the + // state within the struct in an object. Since the struct points to the same object + // it becomes immutable. However, this object is then somewhere else on the // heap, which slows down the policies with hit counter logic in benchmarks. Likely // this approach keeps the structs data members in the same CPU cache line as the LRU. // backcompat: remove conditional compile diff --git a/BitFaster.Caching/Throw.cs b/BitFaster.Caching/Throw.cs index 99485767..3e1de3ed 100644 --- a/BitFaster.Caching/Throw.cs +++ b/BitFaster.Caching/Throw.cs @@ -23,11 +23,14 @@ internal static class Throw [DoesNotReturn] public static void ScopedRetryFailure() => throw CreateScopedRetryFailure(); - [DoesNotReturn] - public static void Disposed() => throw CreateObjectDisposedException(); - - [MethodImpl(MethodImplOptions.NoInlining)] - private static ArgumentNullException CreateArgumentNullException(ExceptionArgument arg) => new ArgumentNullException(GetArgumentString(arg)); + [DoesNotReturn] + public static void Disposed() => throw CreateObjectDisposedException(); + + [DoesNotReturn] + public static void IncompatibleComparer() => throw new InvalidOperationException("Incompatible comparer"); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static ArgumentNullException CreateArgumentNullException(ExceptionArgument arg) => new ArgumentNullException(GetArgumentString(arg)); [MethodImpl(MethodImplOptions.NoInlining)] private static ArgumentOutOfRangeException CreateArgumentOutOfRangeException(string paramName) => new ArgumentOutOfRangeException(paramName);