Conversation
Add a buffer-based serialization path to reduce allocations on the L2 (distributed cache) hot path: - New IBufferFusionCacheSerializer interface with IBufferWriter<byte> / ReadOnlySequence<byte> overloads, implemented by all bundled serializers. - BufferWriterStream and ReadOnlySequenceStream adapters for serializers that only expose Stream-based APIs. - ArrayPoolBufferWriter improvements to back the new path. - DistributedCacheAccessor automatically uses IBufferDistributedCache when both the underlying L2 and the serializer support it, falling back to the byte[] path otherwise. - New FusionCacheOptions.DistributedCacheBufferPool to control the ArrayPool used by that path (defaults to ArrayPool<byte>.Shared). - Tests and benchmarks covering the buffer path end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage so far was serializer-level only: nothing exercised the path selection in DistributedCacheAccessor, the fallbacks, or the buffer lifetime through the real cache pipeline. BufferedL2Tests drives two FusionCache instances over one shared L2 (so reads cannot be served by L1) across 6 serializers, sync and async: - buffered path used only when BOTH the cache and the serializer support buffers, with zero classic calls - classic fallback when the serializer does not support buffers - classic fallback when the cache does not support buffers - buffered <-> classic wire compatibility over the same storage, both directions (mixed fleets, rollbacks) - misses and null values - every sequence handed to Set is single-segment, so consumers like Redis never have to linearize it Call counts are tracked per key because FusionCache also reads and writes its own internal marker entries through the same accessor. Also implement IBufferFusionCacheSerializer on NullSerializer, matching its classic path exactly (writes nothing, reads back nothing). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- ArrayPoolBufferWriter.Advance: use a subtraction-based bounds check so `_bytesWritten + count` cannot overflow and wrap the writer state negative. - ArrayPoolBufferWriter.GetMemory: do the capacity/growth math in long, cap at the max array length and throw instead of silently returning a buffer smaller than the requested sizeHint. - Tests: guard ToMultiSegmentSequence against a non-positive segmentSize, which would otherwise loop forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Hi @slang25 , first of all thanks!
Spoiler: this looks really good.
I just updated @Inok here.
That's a great approach, and fwiw it's the same design I was thinking about: an additional opt-in interface that derives from
I need to take a better look at this.
Great!
At first sight, not sure about exposing such an "internal detail", but I guess it can probably make sense.
This sounds honestly glorious.
Not sure it would necessarily need it, thoughts?
That was not planned, care to elaborate about this?
I'll take a look at the code, but of course in general we should try to avoid leaks as much as possible.
Definitely the right path. Again, fwiw the approach is the same I was thinking about, although not yet in such a thought out way. During this weekend I'll try to take a good deep look at it, then I'll let (both of) you know. Thanks! PS: one of the very first task on my backlog after finally releasing my course was a (potentially) great performance boost I am thinking about for some time now, but... that would require a bit of internal refactoring, and that in turn would probably not play so well with PRs, merges & friends so I'll wait a bit before doing it, I have no rush for that. |
Draft, mostly to get some feedback on the direction before I take it any further. Related to #371 and #528, and there's already a prototype in #593 from @Inok that I leaned on for ideas 👍
The idea is to skip the intermediate
byte[]on the L2 path when both sides can do better:IBufferFusionCacheSerializer : IFusionCacheSerializer, withSerialize<T>(T, IBufferWriter<byte>)andDeserialize<T>(in ReadOnlySequence<byte>). Sync-only, sincenetstandard2.0rules out default interface methods (same callHybridCachemade).DistributedCacheAccessorpicks the buffered path at runtime only when the L2 is anIBufferDistributedCacheand the serializer implements the new interface, otherwise it falls back to the existingbyte[]path.Streamadapters (BufferWriterStream/ReadOnlySequenceStream).FusionCacheOptions.DistributedCacheBufferPoolcontrols the pool, defaulting toArrayPool<byte>.Shared.On my machine (M2 Pro, .NET 10, STJ) a buffered
Setdrops essentially all of the payload allocation (103KB → 1.35KB, and 4.3MB → 1.38KB), runs 5-10% faster and has no Gen2, while a bufferedGetloses the payload-sized array (−27%). Both paths produce byte-identical output, so mixed fleets and rollbacks are safe, and there are tests covering that in both directions.Outstanding actions:
Chaospackage has no buffer-aware wrappers yetIHybridCacheSerializer<T>bridgingSo it's not bulletproof yet. Is this a direction you'd welcome, or is there a better shape for it?