Skip to content

Optimize AsyncLocal maps with many entries - #132658

Open
thomhurst wants to merge 4 commits into
dotnet:mainfrom
thomhurst:perf/asynclocal-many-map
Open

Optimize AsyncLocal maps with many entries#132658
thomhurst wants to merge 4 commits into
dotnet:mainfrom
thomhurst:perf/asynclocal-many-map

Conversation

@thomhurst

Copy link
Copy Markdown

Summary

Replace the Dictionary<IAsyncLocal, object?> used by ManyElementAsyncLocalValueMap with immutable key/value, bucket, and collision-chain arrays.

Updating an existing value now clones only the key/value array and shares the immutable lookup arrays. Adding or removing a key rebuilds the lookup. Maps containing 16 or fewer entries keep their existing implementations.

Benchmark results

BenchmarkDotNet 0.15.8 on Windows 11, Intel Core i7-12700K, x64 RyuJIT, and ReadyToRun disabled. Baseline and candidate CoreLib assemblies were built from the same v10.0.11 source checkout with the same toolchain. Each result uses one launch, five warmup iterations, and ten measured iterations. Benchmark setup verifies the expected CoreLib MVID so a job cannot silently load the wrong assembly.

Existing-value write:

AsyncLocals Baseline Candidate Ratio Allocated before Allocated after
1 15.22 ns 15.51 ns 1.02 72 B 72 B
4 24.00 ns 24.45 ns 1.02 120 B 120 B
16 35.92 ns 33.64 ns 0.94 344 B 344 B
17 164.11 ns 41.95 ns 0.26 648 B 376 B
64 460.82 ns 78.27 ns 0.17 2,160 B 1,128 B
256 1,793.85 ns 208.47 ns 0.12 8,376 B 4,200 B

Read last inserted value:

AsyncLocals Baseline Candidate Ratio
1 3.599 ns 3.261 ns 0.91
4 3.539 ns 3.511 ns 0.99
16 10.076 ns 8.890 ns 0.88
17 6.256 ns 4.951 ns 0.79
64 6.276 ns 4.853 ns 0.77
256 6.246 ns 4.858 ns 0.78

The changed 17+ entry path improves existing-value writes by 3.9x to 8.6x, reduces write allocation by 42% to 50%, and improves reads by 21% to 23%. The implementations used for 1 through 16 entries are unchanged.

Testing

  • build.cmd clr.corelib -rc release -c release — passed with zero warnings and errors.
  • Custom validation using stock and patched CoreLib assemblies at 1, 4, 16, 17, 18, 40, 64, 256, and 1,024 entries — add, reverse update, remove, re-add, notification, task flow, and context isolation checks passed.
  • Existing AsyncLocalTests exercise every count from 1 through 40, including the 16/17 representation boundary.

A full native runtime/test build was not available on this machine because Visual Studio C++ tools are not installed.

Risk

The lookup arrays are immutable and are shared only when an existing key is updated. Key additions and removals build new arrays. Collision chains still compare keys by reference, matching the existing small-map behavior. The 16-to-17 upgrade and 17-to-16 downgrade are covered by validation.

Reuse immutable lookup arrays when updating existing values instead of cloning Dictionary storage on every write.
Copilot AI lite review requested due to automatic review settings August 22, 2026 15:27

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label Aug 22, 2026
@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: @JulieLeeMSFT, @VSadov
See info in area-owners.md if you want to be subscribed.

Comment thread src/libraries/System.Private.CoreLib/src/System/Threading/AsyncLocal.cs Outdated
Copilot AI review requested due to automatic review settings August 22, 2026 16:39

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@thomhurst

Copy link
Copy Markdown
Author

@EgorBot -amd -windows_x64 -osx_arm64

using System;
using System.Threading;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(AsyncLocalManyMapBenchmarks).Assembly).Run(args);

[MemoryDiagnoser]
public class AsyncLocalManyMapBenchmarks
{
    private static readonly object s_value1 = new();
    private static readonly object s_value2 = new();

    private AsyncLocal<object?>[] _locals = null!;
    private AsyncLocal<object?> _extra = null!;
    private bool[] _useSecondValue = null!;
    private int _readIndex;
    private int _updateIndex;

    [Params(16, 17, 32, 128)]
    public int Count { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        _locals = new AsyncLocal<object?>[Count];
        _useSecondValue = new bool[Count];

        for (int i = 0; i < Count; i++)
        {
            _locals[i] = new AsyncLocal<object?>();
            _locals[i].Value = s_value1;
        }

        _extra = new AsyncLocal<object?>();
        _readIndex = -1;
        _updateIndex = -1;
    }

    [GlobalCleanup]
    public void Cleanup()
    {
        _extra.Value = null;

        foreach (AsyncLocal<object?> local in _locals)
        {
            local.Value = null;
        }
    }

    [Benchmark]
    public object? ReadExisting()
    {
        int index = _readIndex + 1;
        if (index == Count)
        {
            index = 0;
        }

        _readIndex = index;
        return _locals[index].Value;
    }

    [Benchmark]
    public object UpdateExisting()
    {
        int index = _updateIndex + 1;
        if (index == Count)
        {
            index = 0;
        }

        _updateIndex = index;

        bool useSecond = _useSecondValue[index] = !_useSecondValue[index];
        object value = useSecond ? s_value2 : s_value1;
        _locals[index].Value = value;
        return value;
    }

    [Benchmark]
    public object? AddThenRemove()
    {
        _extra.Value = s_value1;
        object? value = _extra.Value;
        _extra.Value = null;
        return value;
    }
}

Note

This benchmark request was AI-generated.

Comment on lines +591 to +593
hashCode ^= hashCode >> 16;
hashCode *= 0x7FEB352Du;
hashCode ^= hashCode >> 15;

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.

Is this necessary? Isn't the reference-based hash code already uniformly distributed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You are right. The reference hash codes are already well distributed, so I removed the extra mixing in dbaf3f2.

Copilot AI review requested due to automatic review settings August 22, 2026 23:31

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.


int capacity = 4;
long minimumCapacity = keyValues.Length + ((long)keyValues.Length >> 1);
while (capacity < minimumCapacity && capacity < 1 << 30)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This can use RoundUpToPowerOf2

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated to use BitOperations.RoundUpToPowerOf2 in 758982f.

Copilot AI review requested due to automatic review settings August 23, 2026 03:19

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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

Labels

area-System.Threading community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants