Утечка памяти в контейнерах DI или BenchmarksDotNet MemoryDiagnoser обеспечивает неточные измерения?

Вступление

Мы пытаемся уловить потенциальные утечки памяти, используя BenchmarksDotNet,

Для простоты примера, вот простой TestClass:

public class TestClass 
{
    private readonly string _eventName;

    public TestClass(string eventName)
    {
        _eventName = eventName;
    }

    public void TestMethod() =>
        Console.Write($@"{_eventName} ");
}

Мы реализуем бенчмаркинг, хотя тесты NUnit в netcoreapp2.0:

[TestFixture]
[MemoryDiagnoser]
public class TestBenchmarks
{
    [Test]
    public void RunTestBenchmarks() =>
        BenchmarkRunner.Run<TestBenchmarks>(new BenchmarksConfig());

    [Benchmark]
    public void TestBenchmark1() =>
        CreateTestClass("Test");

    private void CreateTestClass(string eventName)
    {
        var testClass = new TestClass(eventName);
        testClass.TestMethod();
    }
}

Результат теста содержит следующую сводку:

         Method | Mean | Error | Allocated |
--------------- |-----:|------:|----------:|
 TestBenchmark1 |   NA |    NA |       0 B |

Тестовый вывод также содержит все Console.Write вывод, который доказывает, что 0 B здесь означает, что не было утечки памяти, а не был выполнен код из-за оптимизации компилятора.

проблема

Путаница начинается, когда мы пытаемся решить TestClass с TinyIoC контейнер:

[TestFixture]
[MemoryDiagnoser]
public class TestBenchmarks
{
    private TinyIoCContainer _container;

    [GlobalSetup]
    public void SetUp() =>
        _container = TinyIoCContainer.Current;

    [Test]
    public void RunTestBenchmarks() =>
        BenchmarkRunner.Run<TestBenchmarks>(new BenchmarksConfig());

    [Benchmark]
    public void TestBenchmark1() => 
        ResolveTestClass("Test");

    private void ResolveTestClass(string eventName)
    {
        var testClass = _container.Resolve<TestClass>(
            NamedParameterOverloads.FromIDictionary(
                new Dictionary<string, object> {["eventName"] = eventName}));
        testClass.TestMethod();
    }
}

Резюме указывает, что 1,07 КБ было утечка.

         Method | Mean | Error | Allocated |
--------------- |-----:|------:|----------:|
 TestBenchmark1 |   NA |    NA |   1.07 KB |

Allocated значение увеличивается пропорционально количеству ResolveTestClass звонки из TestBenchmark1Сводка для

[Benchmark]
public void TestBenchmark1() 
{
    ResolveTestClass("Test");
    ResolveTestClass("Test");
}

является

         Method | Mean | Error | Allocated |
--------------- |-----:|------:|----------:|
 TestBenchmark1 |   NA |    NA |   2.14 KB |

Это указывает на то, что либо TinyIoC хранит ссылку на каждый разрешенный объект (что не соответствует действительности в соответствии с исходным кодом) или BenchmarksDotNet измерения включают в себя некоторые дополнительные выделения памяти вне метода, отмеченного [Benchmark] приписывать.

Конфиг используется в обоих случаях:

public class BenchmarksConfig : ManualConfig
{
    public BenchmarksConfig()
    {
        Add(JitOptimizationsValidator.DontFailOnError); 

        Add(DefaultConfig.Instance.GetLoggers().ToArray()); 
        Add(DefaultConfig.Instance.GetColumnProviders().ToArray()); 

        Add(Job.Default
            .WithLaunchCount(1)
            .WithTargetCount(1)
            .WithWarmupCount(1)
            .WithInvocationCount(16));

        Add(MemoryDiagnoser.Default);
    }
}

Кстати, замена TinyIoC с Autofac структура внедрения зависимостей не сильно изменила ситуацию.

Вопросы

Означает ли это, что вся структура DI должна реализовывать своего рода кеш для разрешенных объектов? Значит ли это BenchmarksDotNet неправильно используется в данном примере? Это хорошая идея, чтобы охотиться на утечки памяти с помощью комбинации NUnit а также BenchmarksDotNet на первом месте?

1 ответ

Я человек, который реализовал MemoryDiagnoser для BenchmarkDotNet, и я очень рад ответить на этот вопрос.

Но сначала я опишу, как работает MemoryDiagnoser.

  1. Он получает количество выделенной памяти с помощью доступного API.
  2. Он выполняет одну дополнительную итерацию тестов. В твоем случае это 16 (.WithInvocationCount(16))
  3. Он получает количество выделенной памяти с помощью доступного API.

final result = (totalMemoryAfter - totalMemoryBefore) / invocationCount

Насколько точен результат? Это так же точно, как доступные API, которые мы используем: GC.GetAllocatedBytesForCurrentThread() для.NET Core 1.1+ и AppDomain.MonitoringTotalAllocatedMemorySize для.NET 4.6+.

То, что называется GC Allocation Quantum, определяет размер выделенной памяти. Обычно это 8 Кбайт.

Что это действительно означает: если мы выделим один объект с new object() и GC должен выделить память для него (текущий сегмент заполнен), он собирается выделить 8 КБ памяти. И оба API сообщат о выделении 8 КБ памяти после выделения одного объекта.

Console.WriteLine(AppDomain.MonitoringTotalAllocatedMemorySize);
GC.KeepAlive(new object());
Console.WriteLine(AppDomain.MonitoringTotalAllocatedMemorySize);

может в конечном итоге в отчетности:

x
x + 8000

Как BenchmarkDotNet справляется с этой проблемой? Мы выполняем МНОГО вызовов (обычно миллионы или миллиарды), поэтому минимизируем проблему квантового размера распределения (для нас это никогда не 8 КБ).

Как решить проблему в вашем случае: установите WithInvocationCount на большее число (может быть, 1000).

Чтобы проверить результаты, вы можете рассмотреть возможность использования Memory Profiler. Лично я использовал Visual Studio Memory Profiler, который является частью Visual Studio.

Другой альтернативой является использование JetBrains.DotMemoryUnit. Скорее всего, это лучший инструмент в вашем случае.

Другие вопросы по тегам