Контейнеры 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");
}

is

         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?


person foxanna    schedule 26.02.2018    source источник


Ответы (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 определяет размер выделенной памяти. Обычно это 8k байт.

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

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

может закончиться отчетом:

x
x + 8000

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

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

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

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

person Adam Sitnik    schedule 27.02.2018
comment
Дорогой Адам, я очень рад прочитать ответ от MemoryDiagnoser создателя! Спасибо большое за вашу работу, ребята, то, что вы делаете, просто супер! - person foxanna; 28.02.2018
comment
Я попытался заменить 16 на 1024 в BenchmarksConfig, и результаты теста показали те же числа для эксперимента с ResolveTestClass, но также сообщили 64 B для эксперимента с CreateTestClass. Я также попытался вообще удалить строку Add(Job.Default .WithLaunchCount(1) .WithTargetCount(1) .WithWarmupCount(1) .WithInvocationCount(16));, чтобы позволить BenchmarksDotNet самому решать количество итераций, и получил те же результаты, что и с 1024 счетчиком вызовов: 64 B при создании TestClass с ctor и 1.07 KB при разрешении его из контейнера. - person foxanna; 28.02.2018
comment
@foxanna, тогда вам нужно проверить результат с помощью некоторого профилировщика памяти. Возможно, 1.07 KB верно - person Adam Sitnik; 28.02.2018