-
Notifications
You must be signed in to change notification settings - Fork 5k
[API Proposal]: M.E.C.M. MemoryCache - add ReadOnlySpan<char> get API #110504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
Random scratch ideas for how var scratch = new Writer<TWriter>(ref writer);
scratch.WriteString("this ");
scratch.WriteSpanFormattable(key.id, "000", provider);
scratch.WriteString(" is ");
scratch.WriteString(key.region);
scratch.WriteString(" magic");
scratch.Flush(); (but everything still works if that magic isn't available) |
Why not use an interpolated string handler? |
@stephentoub because I didn't know that was a thing; if that is a good fit here, great! Huge thanks! Linking for the long tail (and to check we're on the same page): https://learn.microsoft.com/dotnet/csharp/advanced-topics/performance/interpolated-string-handler |
Yup. I only briefly skimmed what you wrote, but on quick perusal it looks like a good fit. |
@stephentoub yes, looks like an ideal fit; in fact, wait one moment for a follow-up API suggestion... because right now I can get everything I want via: var key = (id: 42, region: "abc");
var cache = new Cache();
cache.Get<int>($"abc {key.id:000} in {key.region}");
...
class Cache
{
public T Get<T>(ref DefaultInterpolatedStringHandler key) // real code has "miss" callback etc
{
// this is just to show it is working; obvs we don't actually ToString usually
Console.WriteLine($"Fetching: {GetText(ref handler).ToString()}");
Clear(ref handler);
return default!; // result doesn't matter here
}
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "Clear")]
private static extern void Clear(ref DefaultInterpolatedStringHandler value);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_Text")]
private static extern ReadOnlySpan<char> GetText(ref DefaultInterpolatedStringHandler value);
} |
Tagging subscribers to this area: @dotnet/area-extensions-caching |
MemoryCache builds for netstandard and netfx as well, and DefaultInterpolatedStringHandler isn't available there. Are you proposing these new APIs only for .NET Core builds? |
I am trying to envision what the tuple/record/etc. case look like to avoid the case of the handler as well we initially were thinking here - does this approach mean we would need to figure this our per cache type, or do we want to think through this in a more holistic way given the underlying split of string/everything-else split that's happened beneath in several places now? I guess the other argument to be made for usability is records being a good fit - perhaps it's worth seeing what is the ideal key scenario across all these cache types with usability vs. hashing and imagine what that might look like equally on say MemoryCache and HybridCache as well? I have been thinking of this in terms of how we can access caches directly today with more efficient lookups, but not most of the caching APIs, for example: void Main()
{
Util.AutoScrollResults = true;
BenchmarkRunner.Run<LookupBenchmarks>();
}
[MemoryDiagnoser]
public class LookupBenchmarks
{
private ConcurrentDictionary<(UserType UT, RequestType RT, string RequestID), object> _tupleCache = new();
private ConcurrentDictionary<RequestKey, object> _recordCache = new();
[Benchmark]
public object TupleLookup() => _tupleCache.TryGetValue((UserType.A, RequestType.B, "myKey"), out var val) ? val : default;
[Benchmark]
public object RecordLookup() => _recordCache.TryGetValue(new(UserType.A, RequestType.B, "myKey"), out var val) ? val : default;
}
public enum UserType { A, B }
public enum RequestType { A, B, C }
public readonly record struct RequestKey(UserType UT, RequestType RT, string RequestID);
...but I realize everything today is a string on the common caching APIs, so maybe that's the only viable path without API explosion. If they all could take things like a readonly record struct though...possibilities. |
@NickCraver The interpolated string handler suggested replaces the need to even worry about that - and has the advantage of working with the L1 we have today (if we make the change proposed here) rather than some speculative L1 we might wish to have in the future (RCache etc with TKey generic key usage). So your usage just becomes regular HybridCache:
That work? Note this would be based on the alt-lookup if CD<string,object> which means: .NET 9+ if I hack in unsafe-accessor, and .NET 10+ to use the new API if we get it. |
What is the comparable performance like for that case? And is the proposed API overload in that case only an interpolated string arg? (I'm not sure how that would be the case, I imagine this has to be a priority underload?) My worry here is that the existing APIs are strings, so what prevents a user from doing the wrong thing, constructing the string earlier, passing it in method calls, etc. and not actually getting the interpolated string overload by the time they get here? One goal, I think, should be the inability to hold it wrong, and I'm not seeing how we'd accomplish that here - thoughts? |
Great question. For same-method usage we could potentially add an analyzer that spots |
Gotcha. So overall: I would likely not use this interpolated-based API if available, as it just as easily leads devs down a lot of the same bad roads we have today. A big win, in my mind with the above options is the stronger typing of the key and obviousness of the code. For example, if someone does I think we have a larger opportunity here to make cache keys be effectively typed, in ways that eliminate these types of errors while being more efficient as well. But to do that, we have to not be tied to all the old string overloads as the only funnel. If we're constrained to that, of course I don't object to optimizing it some, but it seems like we're leaving a lot of the pit-of-success (in both correctness and performance) on the table, and I'd be disappointed if we left it there. If we zoom out, this non-interpolated path also works for things like collections of keys from cache, which matters more so in remote fetch scenarios where we're batching. Maybe there's a clean way to do batch APIs with interpolated, but I'm having trouble picturing it. And admittedly we can solve some of what I'm talking about for instance by banning the string-based overload as an API, but I'm not sure that's a friendly or practical solution to force optimal behavior because we just as often have at least one |
Your points there re key validity do actually make it very tempting to wrap the default impl in a |
@mgravell is this something that is blocking HybridCache v1 ATM or is it a nice-to-have, that could be implemented after its release? I'm assuming the latter but was curious. |
Definitely the latter; the initial release will be |
Here's a fully worked example of how two proposed tweaks combine to allow complex cache scenarios to use alloc-free code paths, including retroactively when a string API already exists: https://gist.github.com/mgravell/750bed134f36f417f97c217297552a88 |
@mgravell I got the impression that if this is approved, you would implement it. With that assumption, I've put it into the .NET 10 milestone. |
namespace Microsoft.Extensions.Caching.Memory
{
public class MemoryCache : IMemoryCache
{
[OverloadResolutionPriority(1)]
public bool TryGetValue(ReadOnlySpan<char> key, out object? value);
[OverloadResolutionPriority(1)]
public bool TryGetValue<TItem>(ReadOnlySpan<char> key, out TItem? value);
}
} |
Open questions (will make more sense after reading):
DefaultInterpolatedStringHandler
or something bespoke (for example a new[InterpolatedStringHandler] public ref struct KeyBuilder { ... }
)?DefaultInterpolatedStringHandler
?api-approved
HybridCache
?(note: a lot of the discussion in comments below has already been incorporated into this top post down to end-of 2024)
Relevant runtimes / targets
Short version: .NET 10+ (omit new APIs in down-level multi-target builds)
Longer version: M.E.C.M. is an OOB NuGet multi-targeting package; the proposed API additions would by default be limited to .NET 10+; it may be technically possible to backport for .NET 9, but since .NET 9 is not LTS I'm not sure this is hugely advantageous, and would presumably require a few hacks such as
[InternalsVisibleTo]
or[UnsafeAccessor]
, which probably isn't ideal. It is not useful to consider backport lower than .NET 9, due to requiringTryGetAlternateLookup
(a .NET 9 feature) to be useful.Background and motivation
The existing
MemoryCache
API takesobject
keys, however: in many cases, the key is actually astring
.There is a set of existing APIs of the form:
The impact of this is that when using
string
keys (the most common use-case), it is required to pay the cost of allocating thestring
key, which is usually string concatenation of some constants with contextual tokens, for example$"/foos/{region}/{id}"
.Proposal: add a new API to facility allocation-free fetch:
Observations:
string
-like keys; however, this is a huge majority of keys used in cachingMemoryCache
; no change toIMemoryCache
is proposed; this is not an issue, and has been the approach used for.Count
,.Keys
, etc; consumers can type-test as if necessary; since it is specific toMemoryCache
, no change toCacheExtensions
is proposedTryGetAlternateLookup
feature, that allows dictionaries to be accessed in such waysMemoryCache
to use a separate dictionary forstring
-based objects; this already exists (it was merged via "internal" for .NET 6, .NET 8 and .NET 9, and explicitly for .NET 10)new
astring
and use that instead.[OverloadResolutionPriority(1)]
here is to avoid ambiguity withcache.TryGetValue($"/foos/{region}/{id}", out var value)
(CS0121), however; note that this still uses astring
; you can see this here - in particular note the value passed is(ReadOnlySpan<char>)defaultInterpolatedStringHandler.ToStringAndClear()
- i.e. "create astring
, and shim it to the span"; if we want to avoid that issue so that$"/foos/{region}/{id}"
uses the allocation-free approach (without Roslyn changes), we would need to use an interpolated string handler parameter; for exampleNote that the compiler automatically prefers interpolated string handlers over
string
in relevant scenarios (except for pure constant values, although the problematic scenarios from this link do not apply in this scenario); for a fully worked version see here. A further complication is that we would want the interpolated string handler to expose some additional methods, to allow efficient access to the span contents - although since this is "runtime", IVTA may be sufficient here (untested). It may be simpler, however, to consider these two proposals inherently linked.A secondary win here is that pre-existing inline usage of interpolated strings, i.e.
cache.TryGetValue($"/foos/{region}/{id}", ...)
will, when recompiled, pick up the lower-allocation API without any code changes. However, the real intended "win" here is via secondary APIs such asHybridCache
(below).Question: if using interpolated string handlers should we use
DefaultInterpolatedStringHandler
? or a bespokeKeyBuilder
that encapsulates aDefaultInterpolatedStringHandler
? (there are some complications in the latter due to the compiler considering exposing the span that far as unsafe).Use-cases:
We want allocation-free fetch from complex APIs like
HybridCache
, which useIMemoryCache
(and usually, although we'd need to type-test) therefore:MemoryCache
. By adding this API, along with some "format" APIs in newHybridCache
APIs, we allow the scenario where composite keys can be passed as (for example) value-tuples, with a formatter that writes not astring
but aSpan<char>
, used for reads. The impact here is reduced allocations for the key in the hot "cache hit" path. Obviously cache misses still need to pay the cost, but we can't have everything.Usage example:
HybridCache
currently has aGetOrCreateAsync<T>
API that takes astring key
parameter.We would add a
GetOrCreateAsync<T>
overload that takes an alloc-based char-based key (with the same considerations as above), passing the key along, allowing local-cache "hit" scenarios (which should be a common event) to be allocation free end-to-end. I presume this would be a separate API proposal, but will have very similar considerations to the above. Likely conclusion:i.e. a new
virtual
method that takesref DefaultInterpolatedStringHandler key
, that concrete implementations shouldoverride
to perform allocation-free local-cache lookups, otherwise defaulting to existingstring
usage (if they do notoverride
).Question: is the above sufficient to consider part of this API proposal, or should a separate API process be followed for the
HybridCache
addition?API Proposal
covered above
API Usage
covered above
Alternative Designs
span vs interpolated string handler covered above
Risks
No response
The text was updated successfully, but these errors were encountered: