Bad LOH allocations when deregistering from IOptionsMonitor

Open
#112,127 12 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
35/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Stale
Tech stack
csharp
Domain
performance

Research direction

Start at IOptionsMonitor.Dispose and the delegate deregistration described in the issue. Run the provided BenchmarkDotNet AddRemove benchmark and compare allocations as the registered delegate count grows. Done means addressing the deregistration allocation problem without changing registration behavior.

Written by the indexing model from the issue text.

Description

area-Extensions-Options tenet-performance
Description

We have an Orleans cluster and in the silo, each grain depends on global configuration. Since that can change during runtime, we are using IOptionsMonitor and when the grain is done, we dispose the handle we got from the OnChange call. That last dispose call is the lone source of some hefty LOH allocations. The grains come and go constantly and at a certain load on the silo (around 2500 grains), LOH allocations start to happen.

As far as I can tell, IOptionsMonitor.Dispose only deregisters from the internal multicast delegate and this deregistration is the main culprit: The multicast delegate allocates a new invocation list for each -=, so memory consumption becomes O(n) while it's amortized O(1) for += due to exponential growth.

This small benchmark shows the problem. The allocations grow with the number of already registered delegates.

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<Benchmark>();

[SimpleJob(iterationCount: 5)]
[MemoryDiagnoser]
public class Benchmark
{
    private Action? _methods;

    [Params(8, 10, 12, 14)]
    public int Power { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        _methods += Method;
        for (var i = 0; i < Power; ++i)
        {
            _methods += _methods;
        }
    }

    [Benchmark]
    public void AddRemove()
    {
        var m = new Action(Method);
        _methods += m;
        _methods -= m;
    }

    private void Method() { }
}
| Method    | Power | Mean       | Error      | StdDev     | Gen0     | Gen1     | Gen2     | Allocated |
|---------- |------ |-----------:|-----------:|-----------:|---------:|---------:|---------:|----------:|
| AddRemove | 8     |   1.512 us |  0.8605 us |  0.2235 us |   0.7629 |   0.0172 |        - |   6.23 KB |
| AddRemove | 10    |   5.376 us |  2.8169 us |  0.4359 us |   2.9602 |   0.1831 |        - |  24.23 KB |
| AddRemove | 12    |  18.840 us |  1.0445 us |  0.1616 us |  11.7493 |   1.4648 |        - |  96.23 KB |
| AddRemove | 14    | 135.817 us | 74.9703 us | 19.4695 us | 124.8779 | 124.8779 | 124.8779 | 384.28 KB |
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from dotnet/runtime

All issues in dotnet/runtime

Similar issues

More C# issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.