В этой заметке мне хотелось бы поделиться информацией о небольшом, но, на мой взгляд, весьма и весьма полезном проекте, в котором Stefan Jokull Sigur?arson добавляет все известные ему IoC контейнеры, которые мигрировали на .NET Core, и с использованием BenchmarkDotNet проводит замеры instance resolving performance. Не упустил возможности поучаствовать в этом соревновании и я со своим маленьким проектом FsContainer.


image


1.2.0


После миграции проекта на .NET Core (хочу заметить, что это оказалось совершенно не сложно) сказать что я не пал духом, значит ничего не сказать и связано это было с тем, что один из трех замеров мой контейнер не проходил. В прямом значении этого слова- замер просто-напросто длился свыше 20 минут и не завершался.


Причина оказалась в этом участке кода:


public object Resolve(Type type)
{
    var instance = _bindingResolver.Resolve(this, GetBindings(), type);

    if (!_disposeManager.Contains(instance))
    {
        _disposeManager.Add(instance);
    }

    return instance;
}

Если задуматься, основной принцип работы benchmark'ов- измерение количества выполняемых операций за еденицу времени (опционально потребляемую память), а значит, метод Resolve запускается максимально возможное количество раз. Вы можете заметить, что после resolve полученный instance добавляется в _disposeManager для дальнейшего его уничтожения в случае container.Dispose(). Т.к. внутри реализации находится List<object>, экземпляры в который добавляются посредством проверки на Contains, то можно догадаться, что налицо сразу 2 side-effect'a:


  1. Каждый новый созданный экземпляр, используя проверку Contains, будет вычислять GetHashCode и искать среди ранее добавленных дубликат;
  2. Т.к. каждый новый созданный экземпляр всегда будет являться уникальным (тестировался resolve с TransientLifetimeManager), то и размер List<object> будет постоянно увеличиваться посредством выделения нового, в 2 раза большего участка памяти и копирования в него ранее добавленных элементов (для добавления миллиона экземпляров операции выделения памяти и копирования будут вызваны минимум 20 раз);

Признаться, я не уверен какое решение является наиболее корректным в данном случае, ведь в реальной жизни мне сложно представить, когда один контейнер будет держать у себя миллионы ссылок на ранее созданные экземпляры, поэтому я решил лишь половину проблемы, добавив (вполне логичное) ограничение на добавление в _disposeManager лишь тех объектов, которые реализуют IDisposable.


if (instance is IDisposable && !_disposeManager.Contains(instance))
{
    _disposeManager.Add(instance);
}

Как итог, замер завершился за вполне приемлимое время и выдал следующие результаты:


Method Mean Error StdDev Scaled ScaledSD Gen 0 Gen 1 Allocated
Direct 13.77 ns 0.3559 ns 0.3655 ns 1.00 0.00 0.0178 - 56 B
LightInject 36.95 ns 0.1081 ns 0.0902 ns 2.69 0.07 0.0178 - 56 B
SimpleInjector 46.17 ns 0.2746 ns 0.2434 ns 3.35 0.09 0.0178 - 56 B
AspNetCore 71.09 ns 0.4592 ns 0.4296 ns 5.17 0.14 0.0178 - 56 B
Autofac 1,600.67 ns 14.4742 ns 12.8310 ns 116.32 3.10 0.5741 - 1803 B
StructureMap 1,815.87 ns 18.2271 ns 16.1578 ns 131.95 3.55 0.6294 - 1978 B
FsContainer 2,819.01 ns 6.0161 ns 5.3331 ns 204.85 5.24 0.4845 - 1524 B
Ninject 12,812.70 ns 255.5191 ns 447.5211 ns 931.06 39.95 1.7853 0.4425 5767 B

Доволен ими я конечно же не стал и приступил к поиску дальнейших способов оптимизации.


1.2.1


В текущей версии контейнера определение необходимого конструктора и требуемых для него аргументов является неизменным, следовательно, эту информацию можно закешировать и впредь не тратить процессорное время. Результатом этой оптимизации стало добавление ConcurrentDictionary, ключём которого является запрашиваемый тип (Resolve<T>), а значениями- конструктор и аргументы, которые будут использоваться для создания экземпляра непосредственно.


private readonly IDictionary<Type, Tuple<ConstructorInfo, ParameterInfo[]>> _ctorCache = 
    new ConcurrentDictionary<Type, Tuple<ConstructorInfo, ParameterInfo[]>>();

Судя по проведённым замерам, такая нехитрая операция увеличила производительность более чем на 30%:


Method Mean Error StdDev Scaled ScaledSD Gen 0 Gen 1 Gen 2 Allocated
Direct 13.50 ns 0.2240 ns 0.1986 ns 1.00 0.00 0.0178 - - 56 B
LightInject 36.94 ns 0.0999 ns 0.0886 ns 2.74 0.04 0.0178 - - 56 B
SimpleInjector 46.40 ns 0.3409 ns 0.3189 ns 3.44 0.05 0.0178 - - 56 B
AspNetCore 70.26 ns 0.4897 ns 0.4581 ns 5.21 0.08 0.0178 - - 56 B
Autofac 1,634.89 ns 15.3160 ns 14.3266 ns 121.14 2.01 0.5741 - - 1803 B
FsContainer 1,779.12 ns 18.9507 ns 17.7265 ns 131.83 2.27 0.2441 - - 774 B
StructureMap 1,830.01 ns 5.4174 ns 4.8024 ns 135.60 1.97 0.6294 - - 1978 B
Ninject 12,558.59 ns 268.1920 ns 490.4042 ns 930.58 38.29 1.7858 0.4423 0.0005 5662 B

1.2.2


Проводя замеры, BenchmarkDotNet уведомляет пользователя о том, что та или иная сборка может быть не оптимизирована (собрана в конфигурации Debug). Я долго не мог понять, почему это сообщение высвечивалось в проекте, где контейнер подключался посредством nuget package и, какого же было моё удивление, когда я увидел возможный список параметров для nuget pack:


nuget pack MyProject.csproj -properties Configuration=Release

Оказывается, всё это время я собирал package в конфигурации Debug, что судя по обновленным результатам замеров замедляло производительность ещё аж на 25%.


Method Mean Error StdDev Scaled ScaledSD Gen 0 Gen 1 Gen 2 Allocated
Direct 13.38 ns 0.2216 ns 0.2073 ns 1.00 0.00 0.0178 - - 56 B
LightInject 36.85 ns 0.0577 ns 0.0511 ns 2.75 0.04 0.0178 - - 56 B
SimpleInjector 46.56 ns 0.5329 ns 0.4724 ns 3.48 0.06 0.0178 - - 56 B
AspNetCore 70.17 ns 0.1403 ns 0.1312 ns 5.25 0.08 0.0178 - - 56 B
FsContainer 1,271.81 ns 4.0828 ns 3.8190 ns 95.09 1.44 0.2460 - - 774 B
Autofac 1,648.52 ns 2.3197 ns 2.0563 ns 123.26 1.84 0.5741 - - 1803 B
StructureMap 1,829.05 ns 17.8238 ns 16.6724 ns 136.75 2.37 0.6294 - - 1978 B
Ninject 12,520.08 ns 248.2530 ns 534.3907 ns 936.10 41.98 1.7860 0.4423 0.0008 5662 B

1.2.3


Ещё одной оптимизацией стало кеширование функции активатора, которая компилируется с использованием Expression:


private readonly IDictionary<Type, Func<object[], object>> _activatorCache =
    new ConcurrentDictionary<Type, Func<object[], object>>();

Универсальная функция принимает в качестве аргументов ConstructorInfo и массив аргументов ParameterInfo[], а в качестве результата возвращает строго типизированную lambda:


private Func<object[], object> GetActivator(ConstructorInfo ctor, ParameterInfo[] parameters) {
    var p = Expression.Parameter(typeof(object[]), "args");
    var args = new Expression[parameters.Length];

    for (var i = 0; i < parameters.Length; i++)
    {
        var a = Expression.ArrayAccess(p, Expression.Constant(i));
        args[i] = Expression.Convert(a, parameters[i].ParameterType);
    }

    var b = Expression.New(ctor, args);
    var l = Expression.Lambda<Func<object[], object>>(b, p);

    return l.Compile();
}

Соглашусь, что логичным продолжением этого решения должно стать компилирование всей функции Resolve, а не только Activator, но даже в текущей реализации это привнесло 10% ускорение, тем самым позволив занять уверенное 5-е место:


Method Mean Error StdDev Scaled ScaledSD Gen 0 Gen 1 Gen 2 Allocated
Direct 13.24 ns 0.0836 ns 0.0698 ns 1.00 0.00 0.0178 - - 56 B
LightInject 37.39 ns 0.0570 ns 0.0533 ns 2.82 0.01 0.0178 - - 56 B
SimpleInjector 46.22 ns 0.2327 ns 0.2063 ns 3.49 0.02 0.0178 - - 56 B
AspNetCore 70.53 ns 0.2885 ns 0.2698 ns 5.33 0.03 0.0178 - - 56 B
FsContainer 1,038.13 ns 17.1037 ns 15.9988 ns 78.41 1.23 0.2327 - - 734 B
Autofac 1,551.33 ns 3.6293 ns 3.2173 ns 117.17 0.64 0.5741 - - 1803 B
StructureMap 1,944.35 ns 1.8665 ns 1.7459 ns 146.85 0.76 0.6294 - - 1978 B
Ninject 13,139.70 ns 260.8754 ns 508.8174 ns 992.43 38.35 1.7857 0.4425 0.0004 5682 B

1.2.4


Уже после публикации статьи @turbanoff заметил, что в случае с ConcurrentDictionary производительность метода GetOrAdd выше, чем у ContainsKey/Add, за что ему отдельное спасибо. Результаты замеров представлены ниже:


До:


if (!_activatorCache.ContainsKey(concrete)) {
    _activatorCache[concrete] = GetActivator(ctor, parameters);
}

Method Mean Error StdDev Median Gen 0 Allocated
ResolveSingleton 299.0 ns 7.239 ns 19.45 ns 295.7 ns 0.1268 199 B
ResolveTransient 686.3 ns 32.333 ns 86.30 ns 668.7 ns 0.2079 327 B
ResolveCombined 1,487.4 ns 101.057 ns 273.21 ns 1,388.7 ns 0.4673 734 B

После:


var activator = _activatorCache.GetOrAdd(concrete, x => GetActivator(ctor, parameters));

Method Mean Error StdDev Gen 0 Allocated
ResolveSingleton 266.6 ns 4.955 ns 4.393 ns 0.1268 199 B
ResolveTransient 512.0 ns 16.974 ns 16.671 ns 0.3252 511 B
ResolveCombined 1,119.2 ns 18.218 ns 15.213 ns 0.6943 1101 B

P.S.


В качестве эксперимента я решил произвести замеры времени создания объектов используя разные конструкции. Сам проект доступен на Github, а результаты вы можете видеть ниже. Для полноты картины не хватает только способа активации посредством генерации IL инструкций максимально приближенных к методу Direct- именно этот способ используют контейнеры из топ 4, что и позволяет им добиваться таких впечатляющих результатов.


Method Mean Error StdDev Gen 0 Allocated
Direct 4.031 ns 0.1588 ns 0.1890 ns 0.0076 24 B
CompiledInvoke 85.541 ns 0.5319 ns 0.4715 ns 0.0178 56 B
ConstructorInfoInvoke 316.088 ns 1.8337 ns 1.6256 ns 0.0277 88 B
ActivatorCreateInstance 727.547 ns 2.9228 ns 2.5910 ns 0.1316 416 B
DynamicInvoke 974.699 ns 5.5867 ns 5.2258 ns 0.0515 168 B
Поделиться с друзьями
-->

Комментарии (8)


  1. dmitry_dvm
    25.06.2017 15:59
    +1

    А почему решили писать свой контейнер? Чем не подошли существующие?


    1. fsou11
      25.06.2017 16:13
      +1

      Долгое время для меня всё, что происходит внутри контейнеров оставалось загадкой, поэтому решил разобраться с этим попытавшись написать своё собственное решение. Это побудило меня к длительному изучению внутреннего устройства существующих контейнеров (Unity, Ninject, StructureMap) и развеяло всё мистическое :)


  1. MikailBag
    25.06.2017 17:23

    Я правильно понимаю, что автор берет массив длины около миллиона, а потом примерно миллион раз ищет в нем элемент?


  1. impwx
    25.06.2017 18:37
    +3

    Глубоко код не смотрел, но прямо навскидку попалась пара подозрительных вещей:

    1. BindingResolver: вы используете ConcurrentDictionary, но обращаетесь к нему всегда изнутри locked-блока — в чем смысл?
    2. DisposeManager: почему List, а не HashSet или Dictionary? На большом количестве объектов стоимость вставки и поиска гораздо ниже.

    Если я все правильно понял и если в остальном коде это продолжается, то понятно, почему разница с тем же LightInject больше, чем в 30 раз. Стоит более детально разобраться в структурах данных.


    1. fsou11
      26.06.2017 00:56

      Да, вы правы. Что-то подсмотрел у других контейнеров, что-то сам не совсем оптимально реализовал. Буду разбираться.


    1. fsou11
      26.06.2017 01:13

      1. Замена ConcurrentDictionary на обычный Dictionary привнесла порядка 5% ускорения;
      2. Возможно, но в данном случае роли не играет, т.к. объекты не IDisposable;


    1. fsou11
      26.06.2017 01:17

      Если я не ошибаюсь, в LightInject функция activator'a (или даже весь Resolve) собирается посредством emit'a IL инструкций. На мой взгляд, это первостепенная причина такого разрыва.


      1. MoreBeauty
        26.06.2017 03:46
        +1

        Если я не ошибаюсь, корректно построенный и скомпилированный Expression не будет давать просадку, ведь это тот же скомпилированный кусок кода.