Optimize AsyncLocal maps with many entries - #132658
Open
thomhurst wants to merge 4 commits into
Open
Conversation
Reuse immutable lookup arrays when updating existing values instead of cloning Dictionary storage on every write.
|
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. |
Contributor
|
Tagging subscribers to this area: @JulieLeeMSFT, @VSadov |
jkotas
reviewed
Aug 22, 2026
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; |
Contributor
There was a problem hiding this comment.
Is this necessary? Isn't the reference-based hash code already uniformly distributed?
Author
There was a problem hiding this comment.
You are right. The reference hash codes are already well distributed, so I removed the extra mixing in dbaf3f2.
jkotas
reviewed
Aug 23, 2026
|
|
||
| int capacity = 4; | ||
| long minimumCapacity = keyValues.Length + ((long)keyValues.Length >> 1); | ||
| while (capacity < minimumCapacity && capacity < 1 << 30) |
Author
There was a problem hiding this comment.
Updated to use BitOperations.RoundUpToPowerOf2 in 758982f.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replace the
Dictionary<IAsyncLocal, object?>used byManyElementAsyncLocalValueMapwith 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.11source 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:
Read last inserted value:
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.AsyncLocalTestsexercise 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.