9 minute read

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

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 |