Skip to content

Reduce false sharing in MemoryCache - #131696

Open
EgorBo wants to merge 5 commits into
dotnet:mainfrom
EgorBo:false-sharing-audit
Open

Reduce false sharing in MemoryCache#131696
EgorBo wants to merge 5 commits into
dotnet:mainfrom
EgorBo:false-sharing-audit

Conversation

@EgorBo

@EgorBo EgorBo commented Aug 1, 2026

Copy link
Copy Markdown
Member

Follow-up to #131470, which fixed the same class of problem in System.Runtime.Caching.Counters.

CoherentState._cacheSize is Interlocked-updated on every Set/Remove when SizeLimit is configured, and sat immediately after _stringEntries/_nonStringEntries, which every TryGetValue loads. Moved onto its own cache line.

Results

Benchmark: #131696 (comment)

@EgorBot, main vs PR, mixed 7:1 read/write against a SizeLimit-configured cache. Ratio > 1 means the PR is faster:

Threads linux-x64 (Turin) linux-arm64 (Cobalt 100) windows-arm64 (Cobalt 100)
1 1.00 1.00 1.00
4 1.53 1.06 1.26
8 2.27 1.64 1.58
16 2.43 1.52 1.28
32 2.20 1.09 1.57

Neutral single-threaded and improving under contention on all three targets. The Unsized control arm - same workload with no SizeLimit, so nothing writes _cacheSize - stays within 0.99-1.02 everywhere, confirming the gain is the padding rather than an unrelated effect.

Note

This section was generated with GitHub Copilot.

Follow-up to dotnet#131470.

MemoryCache: CoherentState._cacheSize is Interlocked-updated on every
Set/Remove when SizeLimit is configured, while _stringEntries and
_nonStringEntries are read on every TryGetValue. Move the counter onto its
own cache line so the write-hot atomic stops invalidating the line holding
those read-mostly references.

CounterAggregator: move PaddedDouble.Value to the end of its padding. At
offset 0, element 0's CAS target shared a cache line with the array's Length
field, which the bounds check in Update loads on every call from every
thread - the same hazard dotnet#131470 fixed in Counters. The struct stays 64
bytes, so this costs no extra memory.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3cea9f94-d161-471d-9fd3-543f6148300d
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-caching
See info in area-owners.md if you want to be subscribed.

@EgorBo

This comment was marked as outdated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to reduce false sharing in two hot, contended library paths by adjusting field layout/padding so contended atomics don’t share cache lines with frequently-read data.

Changes:

  • CounterAggregator: move PaddedDouble.Value to the end of the 64-byte struct to avoid colliding with array metadata cache-line traffic.
  • MemoryCache: move the size-limit accounting counter off the same cache line as the hot dictionary references by introducing padded storage and accessing it via a ref return.
  • Microsoft.Extensions.Caching.Memory.csproj: include the shared Internal/Padding.cs so PaddingHelpers.CACHE_LINE_SIZE is available.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Metrics/CounterAggregator.cs Adjusts per-slot padding layout so Value is placed at the end of the cache-line-sized struct.
src/libraries/Microsoft.Extensions.Caching.Memory/src/Microsoft.Extensions.Caching.Memory.csproj Links in shared Internal/Padding.cs to provide Internal.PaddingHelpers.CACHE_LINE_SIZE.
src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs Introduces a padded wrapper for the cache-size counter and updates all atomic reads/writes to target it.

Comment thread src/libraries/Microsoft.Extensions.Caching.Memory/src/MemoryCache.cs Outdated
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3cea9f94-d161-471d-9fd3-543f6148300d
Copilot AI review requested due to automatic review settings August 1, 2026 11:42
CACHE_LINE_SIZE is 128 unconditionally for out-of-CoreLib libraries, which
made the struct 256 bytes per MemoryCache on every platform. Match
CounterAggregator and use 64 directly, which also drops the Padding.cs
dependency.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3cea9f94-d161-471d-9fd3-543f6148300d

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Metrics/CounterAggregator.cs:79

  • The comment hard-codes the array Length field offset as 8 bytes. That’s true on 64-bit, but it’s pointer-size dependent (e.g., 4 bytes on 32-bit) and may mislead future readers. Consider removing the specific offset and referring to the array header/Length field more generally.
        // 64 bytes is the size of a cache line on many systems; larger ones may see a little more
        // false sharing. Value sits at the end rather than at offset 0 so element 0 doesn't share a
        // line with the array's Length field at offset 8: the bounds check in Update loads Length on
        // every call from every thread, and a plain load of a line that is also a contended atomic's
        // target defeats far-atomic handling on Arm64.

Copilot AI review requested due to automatic review settings August 1, 2026 11:48
The specific offsets are 64-bit CoreCLR layout; the reason Value sits at the
end holds regardless.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3cea9f94-d161-471d-9fd3-543f6148300d

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

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

  • The comment explains 64-byte cache lines, but the padding struct is sized to 128 bytes; it’s not obvious to a future reader why 128 is chosen or what guarantee it provides. Consider explicitly stating that the struct is 128 bytes so the Value at +64 is at least one 64B cache line away from adjacent fields on both sides (even if the runtime reorders class fields).
            // _stringEntries/_nonStringEntries are read on every TryGetValue; the padding keeps the
            // write-hot atomic off the line holding those read-mostly references. It has to live in
            // a struct -- layout attributes on a class only affect marshaling, and the runtime
            // reorders class fields freely. 64 is the size of a cache line on many systems; larger
            // ones may see a little more false sharing.

Benchmarks show it trades one false-sharing hazard for another rather than
removing it. Moving Value to the end of the 64-byte element leaves the last
slot flush against the array object's end (16 + 7*64 + 56 + 8 = 528 = object
size), so it shares a line with whatever the GC places next. Which hazard
bites depends on heap layout: 3.12x faster on linux-arm64 Cobalt 100, 3.75x
slower on windows-arm64 on the same CPU.

Keeping only the MemoryCache change, which is a consistent win on all three
targets with a flat control arm.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3cea9f94-d161-471d-9fd3-543f6148300d
@EgorBo EgorBo changed the title Reduce false sharing in MemoryCache and CounterAggregator Reduce false sharing in MemoryCache Aug 1, 2026
Copilot AI review requested due to automatic review settings August 1, 2026 12:54
@EgorBo

EgorBo commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

@EgorBot -windows_arm -linux_arm -linux_x64

using BenchmarkDotNet.Attributes;
using Microsoft.Extensions.Caching.Memory;
using System.Diagnostics.Metrics;
using System.Diagnostics.Tracing;

[MemoryDiagnoser(false)]
public class MemoryCacheContention
{
    private const int TotalOps = 1 << 20;
    private const int Keys = 1024;

    private MemoryCache _sized;
    private MemoryCache _unsized;
    private string[] _keys;

    [Params(1, 4, 8, 16, 32)]
    public int Threads;

    [GlobalSetup]
    public void Setup()
    {
        _keys = new string[Keys];
        for (int i = 0; i < Keys; i++)
            _keys[i] = "key" + i;
        _sized = new MemoryCache(new MemoryCacheOptions { SizeLimit = 1 << 20 });
        _unsized = new MemoryCache(new MemoryCacheOptions());
        for (int i = 0; i < Keys; i++)
        {
            _sized.Set(_keys[i], i, new MemoryCacheEntryOptions { Size = 1 });
            _unsized.Set(_keys[i], i);
        }
    }

    [GlobalCleanup]
    public void Cleanup()
    {
        _sized.Dispose();
        _unsized.Dispose();
    }

    // SizeLimit set => every Set does Interlocked.Add on CoherentState._cacheSize, which before
    // this change shared a cache line with the dictionary references every TryGetValue loads.
    [Benchmark(OperationsPerInvoke = TotalOps)]
    public void Sized() => Mixed(_sized, sized: true);

    // Control: no SizeLimit, so _cacheSize is never written. Should be flat across PR and main.
    [Benchmark(OperationsPerInvoke = TotalOps)]
    public void Unsized() => Mixed(_unsized, sized: false);

    // 7 reads per write: readers load _stringEntries while a writer bumps _cacheSize.
    private void Mixed(MemoryCache cache, bool sized)
    {
        int perThread = TotalOps / Threads;
        var barrier = new Barrier(Threads);
        var workers = new Thread[Threads];
        for (int t = 0; t < Threads; t++)
        {
            int id = t;
            workers[t] = new Thread(() =>
            {
                barrier.SignalAndWait();
                int k = id * 31;
                for (int i = 0; i < perThread; i++)
                {
                    k = (k + 1) & (Keys - 1);
                    if ((i & 7) == 0)
                        if (sized)
                            cache.Set(_keys[k], i, new MemoryCacheEntryOptions { Size = 1 });
                        else
                            cache.Set(_keys[k], i);
                    else
                        cache.TryGetValue(_keys[k], out _);
                }
            });
            workers[t].Start();
        }
        for (int t = 0; t < Threads; t++)
            workers[t].Join();
    }
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Suppressed comments (1)

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

  • The cache-line padding uses hard-coded 64/128-byte constants. In this repo, other false-sharing mitigations typically use Internal.PaddingHelpers.CACHE_LINE_SIZE (e.g., System.Runtime.Caching/Counters.cs) to cover larger cache lines; with the current values, the separation is only guaranteed for 64-byte cache lines and may be ineffective on 128-byte systems.

Consider linking $(CommonPath)Internal\Padding.cs into this project (as other libs do) and basing CacheSizePadded on PaddingHelpers.CACHE_LINE_SIZE (e.g., Size = 2 * CacheLineSize and [FieldOffset(CacheLineSize)]).

            // reorders class fields freely. 64 is the size of a cache line on many systems; larger
            // ones may see a little more false sharing.
            private CacheSizePadded _cacheSizePadded;

            [StructLayout(LayoutKind.Explicit, Size = 128)]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants