Релиз .NET Core 3.1 — хороший повод мигрировать свой проект с Framework на Core. Во-первых, это отполированная версия с долгосрочной поддержкой (LTS), т.е. её можно смело использовать в продакшене. Во-вторых, в третьей версии добавили поддержку WPF и WinForms, так что теперь появилась возможность мигрировать и десктопные приложения.
Мне стало интересно, какой прирост производительности можно ожидать от Core в самых базовых классах, которые максимально часто используются в коде. Например, коллекции List, Array и Dictionary.
Если вам тоже интересно, как и почему изменилась производительность основных коллекций в Core 3 — прошу под кат!
Бенчмарки
Для сравнительных тестов я взял три актуальных рантайма: .NET Framework 4.8, .NET Core 3.1 и .NET Core 2.1. Все замеры производились на следующей конфигурации:
BenchmarkDotNet=v0.12.0, OS=Windows 10.0.18363
Intel Core i7-7700K CPU 4.20GHz (Kaby Lake), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=3.1.101
[Host] : .NET Core 3.1.1 (CoreCLR 4.700.19.60701, CoreFX 4.700.19.60801), X64 RyuJIT
Job-1 : .NET Framework 4.8 (4.8.4075.0), X64 RyuJIT
Job-2 : .NET Core 2.1.15 (CoreCLR 4.6.28325.01, CoreFX 4.6.28327.02), X64 RyuJIT
Job-3 : .NET Core 3.1.1 (CoreCLR 4.700.19.60701, CoreFX 4.700.19.60801), X64 RyuJIT
Также я прогонял все тесты на двух дополнительных машинах (на Haswell и Sky Lake), чтобы убедиться, что результаты тестов стабильны и воспроизводятся на другом железе.
Класс ValuesGenerator (да и основу для самих бенчмарков) я позаимствовал из репозитория перфоманс-тестов. Эти тесты используются мейнтейнерами .NET Core для тестирования предлагаемых оптимизаций.
List
Цикл for
[GenericTypeArguments(typeof(int))]
[GenericTypeArguments(typeof(string))]
public class ListIterationFor<T>
{
[Params(100, 1_000, 10_000)]
public int Size;
private List<T> _list;
[GlobalSetup]
public void Setup() => _list = new List<T>(ValuesGenerator.ArrayOfUniqueValues<T>(Size));
[Benchmark]
public T List_IterateFor()
{
T result = default;
List<T> collection = _list;
for (int i = 0; i < collection.Count; i++)
result = collection[i];
return result;
}
}
Method | Runtime | Size | Mean | Error | StdDev | Ratio |
---|---|---|---|---|---|---|
IterateFor_Int | .NET 4.8 | 1000 | 565.09 ns | 0.191 ns | 0.127 ns | 1.00 |
IterateFor_Int | .NET Core 2.1 | 1000 | 451.14 ns | 0.236 ns | 0.156 ns | 0.80 |
IterateFor_Int | .NET Core 3.1 | 1000 | 451.08 ns | 0.143 ns | 0.085 ns | 0.80 |
IterateFor_String | .NET 4.8 | 1000 | 574.80 ns | 6.795 ns | 4.494 ns | 1.00 |
IterateFor_String | .NET Core 2.1 | 1000 | 460.86 ns | 3.771 ns | 2.494 ns | 0.80 |
IterateFor_String | .NET Core 3.1 | 1000 | 460.35 ns | 0.681 ns | 0.405 ns | 0.80 |
В Core JIT генерирует более эффективный код, чтение элементов из List в цикле for стало быстрее на ~20%.
Цикл foreach
[GenericTypeArguments(typeof(int))]
[GenericTypeArguments(typeof(string))]
public class ListIterationForEach<T>
{
[Params(100, 1_000, 10_000)]
public int Size;
private List<T> _list;
[GlobalSetup]
public void Setup() => _list = new List<T>(ValuesGenerator.ArrayOfUniqueValues<T>(Size));
[Benchmark]
public T List_IterateForEach()
{
T result = default;
List<T> collection = _list;
foreach (var item in collection)
result = item;
return result;
}
}
Method | Runtime | Size | Mean | Error | StdDev | Ratio |
---|---|---|---|---|---|---|
IterateForEach_Int | .NET 4.8 | 1000 | 1,574.5 ns | 2.73 ns | 1.81 ns | 1.00 |
IterateForEach_Int | .NET Core 2.1 | 1000 | 1,575.8 ns | 3.82 ns | 2.27 ns | 1.00 |
IterateForEach_Int | .NET Core 3.1 | 1000 | 1,568.1 ns | 0.61 ns | 0.40 ns | 1.00 |
IterateForEach_String | .NET 4.8 | 1000 | 8,046.3 ns | 36.51 ns | 24.15 ns | 1.00 |
IterateForEach_String | .NET Core 2.1 | 1000 | 6,465.0 ns | 15.26 ns | 10.09 ns | 0.80 |
IterateForEach_String | .NET Core 3.1 | 1000 | 5,886.3 ns | 14.65 ns | 9.69 ns | 0.73 |
Итерирование List с ссылочными типами через foreach стало быстрее на 27%, но для значимых типов ничего не поменялось. Здесь можно оценить, насколько foreach медленнее, чем for. Разница в их эффективности на Core составляет 3.5x (value types) и 12x (reference types), примерно также как и в полном фреймворке.
Add
Чтобы протестировать метод без ресайза внутреннего массива в тесте используется конструктор List с заданной ёмкостью (capacity).
[GenericTypeArguments(typeof(int))]
[GenericTypeArguments(typeof(string))]
public class ListAdd<T>
{
private T[] _uniqueValues;
[Params(100, 1_000, 10_000)]
public int Size;
[GlobalSetup]
public void Setup() => _uniqueValues = ValuesGenerator.ArrayOfUniqueValues<T>(Size);
[Benchmark]
public List<T> List_Add()
{
List<T> collection = new List<T>(Size);
T[] uniqueValues = _uniqueValues;
for (int i = 0; i < uniqueValues.Length; i++)
collection.Add(uniqueValues[i]);
return collection;
}
}
Method | Runtime | Size | Mean | Error | StdDev | Ratio |
---|---|---|---|---|---|---|
Add_Int | .NET 4.8 | 1000 | 2,006.5 ns | 11.65 ns | 6.93 ns | 1.00 |
Add_Int | .NET Core 2.1 | 1000 | 1,249.0 ns | 1.00 ns | 0.60 ns | 0.62 |
Add_Int | .NET Core 3.1 | 1000 | 1,260.9 ns | 5.88 ns | 3.89 ns | 0.63 |
Add_String | .NET 4.8 | 1000 | 3,250.8 ns | 53.13 ns | 35.14 ns | 1.00 |
Add_String | .NET Core 2.1 | 1000 | 2,816.8 ns | 37.26 ns | 22.18 ns | 0.87 |
Add_String | .NET Core 3.1 | 1000 | 2,538.2 ns | 30.55 ns | 20.21 ns | 0.78 |
На Core 3 добавление работает быстрее на 22% (reference types) и 37% (value types). Что изменилось в коде метода? Добавление без ресайза, т.е. самый частый вариант выделен в отдельный метод с атрибутом [AggressiveInlining]
, т.е. он теперь инлайнится. Из мелких оптимизаций: убраны две лишние проверки выхода за границы и значение поля size
теперь кешируется в локальную переменную.
Contains
Давайте возьмём негативный сценарий для метода Contains: будем искать элементы, которых нет в коллекции.
[GenericTypeArguments(typeof(int))]
[GenericTypeArguments(typeof(string))]
public class ListContains<T> where T : IEquatable<T>
{
[Params(100, 1_000, 10_000)]
public int Size;
private List<T> _list;
private T[] _lookupValues;
[GlobalSetup]
public void Setup()
{
var uniqueValues = ValuesGenerator.ArrayOfUniqueValues<T>(Size * 2);
_list = uniqueValues.Take(Size).ToList();
_lookupValues = uniqueValues.Skip(Size).ToArray();
}
[Benchmark]
public int List_Contains()
{
int count = 0;
List<T> collection = _list;
T[] array = _lookupValues;
for (int i = 0; i < array.Length; i++)
{
if (collection.Contains(array[i]))
count++;
}
return count;
}
}
Method | Runtime | Size | Mean | Error | StdDev | Ratio |
---|---|---|---|---|---|---|
Contains_Int | .NET 4.8 | 1000 | 1,128.975 us | 5.4951 us | 3.6347 us | 1.00 |
Contains_Int | .NET Core 2.1 | 1000 | 456.040 us | 0.1437 us | 0.0950 us | 0.40 |
Contains_Int | .NET Core 3.1 | 1000 | 188.002 us | 0.1619 us | 0.0964 us | 0.17 |
Contains_String | .NET 4.8 | 1000 | 4,027.20 us | 9.479 us | 5.641 us | 1.00 |
Contains_String | .NET Core 2.1 | 1000 | 3,332.93 us | 2.156 us | 1.128 us | 0.83 |
Contains_String | .NET Core 3.1 | 1000 | 2,723.48 us | 2.460 us | 1.464 us | 0.68 |
На Core 3 поиск Int в List стал примерно в 6 раз быстрее, а поиск строк — в 1.4 раза. В Core JIT научился в некоторых ситуациях девирутализировать виртуальные методы, т.е. они вызываются напрямую. Более того, такие методы могут быть заинлайнены. В данном случае девиртуализируется метод EqualityComparer.Default.Equals
, который используется для сравнения элементов. В случае с Int всё сводится к вызову Int32.Equals
, который к тому же инлайнится. В итоге получившийся код по эффективности близок к прямому сравнению двух Int.
Кстати, раньше я всегда думал, что метод Contains внутри вызывает IndexOf, но оказалось, что это верно только для Core. В полном фреймворке это разные методы, и работают они с разной скоростью.
List Methods Summary
Сводная таблица относительной производительности (ratio) основных методов List при N = 1000.
List Method | Type | .NET 4.8 | Core 2.1 | Core 3.1 | Details |
---|---|---|---|---|---|
Ctor | Int | 1.00 | 0.82 | 0.47 | Report |
Ctor | String | 1.00 | 0.90 | 0.92 | Report |
IterateFor | Int | 1.00 | 0.80 | 0.80 | Report |
IterateFor | String | 1.00 | 0.80 | 0.80 | Report |
IterateForEach | Int | 1.00 | 1.00 | 1.00 | Report |
IterateForEach | String | 1.00 | 0.80 | 0.73 | Report |
Add | Int | 1.00 | 0.62 | 0.63 | Report |
Add | String | 1.00 | 0.87 | 0.78 | Report |
Contains | Int | 1.00 | 0.40 | 0.17 | Report |
Contains | String | 1.00 | 0.83 | 0.68 | Report |
IndexOf | Int | 1.00 | 0.99 | 0.43 | Report |
IndexOf | String | 1.00 | 0.95 | 0.95 | Report |
Array Methods Summary
Подробно останавливаться на методах массива я не буду, поскольку List — это обертка над массивом.
Так что здесь я приведу таблицу относительной производительности Array при N = 1000.
Array Method | Type | .NET 4.8 | Core 2.1 | Core 3.1 | Details |
---|---|---|---|---|---|
Ctor | Int | 1.00 | 0.73 | 0.88 | Report |
Ctor | String | 1.00 | 0.75 | 0.84 | Report |
IterateFor | Int | 1.00 | 0.86 | 1.00 | Report |
IterateFor | String | 1.00 | 1.00 | 1.00 | Report |
IterateForEach | Int | 1.00 | 0.84 | 1.00 | Report |
IterateForEach | String | 1.00 | 1.00 | 1.00 | Report |
Здесь можно отметить, что как и прежде, цикл foreach для массива преобразуется в обычный for. Т.е. с точки зрения производительности для итерации массива нет разницы какой из циклов использовать.
Dictionary
Randomized Hash
В .NET Core для расчета хешей строк теперь используется рандомизированный алгоритм (Marvin). Т.е. при каждом запуске приложения хеш одной и той же строки будет разным. Это защита от хеш-атак, в частности "hash flooding" (подробнее). Естественно, этот алгоритм медленнее, чем нерандомизированный. Чтобы производительность Dictionary со строковым ключом не просела, внутри него рандомизированный хеш включается только при достижении определённого количества коллизий (сейчас HashCollisionThreshold = 100
).
Add
[GenericTypeArguments(typeof(int))]
[GenericTypeArguments(typeof(string))]
public class DictionaryAdd<T>
{
private T[] _uniqueValues;
[Params(100, 1_000, 10_000)]
public int Size;
[GlobalSetup]
public void Setup() => _uniqueValues = ValuesGenerator.ArrayOfUniqueValues<T>(Size);
[Benchmark]
public Dictionary<T, T> Dictionary_Add()
{
var collection = new Dictionary<T, T>(Size);
var uniqueValues = _uniqueValues;
for (int i = 0; i < uniqueValues.Length; i++)
collection.Add(uniqueValues[i], uniqueValues[i]);
return collection;
}
}
Method | Runtime | Size | Mean | Error | StdDev | Ratio |
---|---|---|---|---|---|---|
Add_IntKey | .NET 4.8 | 1000 | 10.449 us | 0.0690 us | 0.0456 us | 1.00 |
Add_IntKey | .NET Core 2.1 | 1000 | 12.270 us | 0.0492 us | 0.0325 us | 1.17 |
Add_IntKey | .NET Core 3.1 | 1000 | 11.355 us | 0.0723 us | 0.0478 us | 1.09 |
Add_StringKey | .NET 4.8 | 1000 | 33.229 us | 0.0331 us | 0.0219 us | 1.00 |
Add_StringKey | .NET Core 2.1 | 1000 | 35.303 us | 0.1821 us | 0.1084 us | 1.06 |
Add_StringKey | .NET Core 3.1 | 1000 | 26.976 us | 0.1248 us | 0.0825 us | 0.81 |
Добавление в Dictionary с ключом String стало быстрее на 19%. В случае с Int ключом результат (ratio) зависит от размера: на 100 — 0.95, на 1'000 — 1.09, на 10'000 — 0.93. Отклонения небольшие, возможно, это просто "шум". На других машинах отклонения ещё меньше. Будем считать, что с ключом типа Int добавление элемента происходит примерно с той же скоростью.
GetValue
[GenericTypeArguments(typeof(int))]
[GenericTypeArguments(typeof(string))]
public class DictionaryGetValue<T>
{
private Dictionary<T, T> _dictionary;
private T[] _values;
[Params(100, 1_000, 10_000)]
public int Size;
[GlobalSetup]
public void Setup()
{
_values = ValuesGenerator.ArrayOfUniqueValues<T>(Size);
_dictionary = _values.ToDictionary(i => i);
}
[Benchmark]
public T Dictionary_GetValue()
{
Dictionary<T, T> collection = _dictionary;
T[] values = _values;
T result = default;
for (int i = 0; i < values.Length; i++)
result = collection[values[i]];
return result;
}
}
Method | Runtime | Size | Mean | Error | StdDev | Ratio |
---|---|---|---|---|---|---|
GetValue_IntKey | .NET 4.8 | 1000 | 10.916 us | 0.019 us | 0.013 us | 1.00 |
GetValue_IntKey | .NET Core 2.1 | 1000 | 10.985 us | 0.135 us | 0.089 us | 1.01 |
GetValue_IntKey | .NET Core 3.1 | 1000 | 9.424 us | 0.086 us | 0.056 us | 0.86 |
GetValue_StringKey | .NET 4.8 | 1000 | 31.622 us | 0.294 us | 0.175 us | 1.00 |
GetValue_StringKey | .NET Core 2.1 | 1000 | 31.787 us | 0.090 us | 0.047 us | 1.00 |
GetValue_StringKey | .NET Core 3.1 | 1000 | 23.572 us | 0.098 us | 0.058 us | 0.75 |
Получение элемента по строковому ключу стало быстрее на 25%, по Int ключу — на 14%. Однако, здесь есть зависимость от размера Dictionary. Чем меньше размер — тем больше Framework отстает от Core 3 и наоборот. На маленьких размерах Core 3 работает в 1.5 раза быстрей. При достижении размера в 10'000 производительность Core 3 падает до уровня Framework и даже чуть ниже (см. отчеты ниже).
В коде класса Dictionary слишком много изменений, чтобы однозначно сказать, какие из них больше всего повлияли на производительность.
Dictionary Methods Summary
Сводная таблица относительной производительности основных методов Dictionary при N = 1000.
Dictionary Method | Type | .NET 4.8 | Core 2.1 | Core 3.1 | Details |
---|---|---|---|---|---|
Ctor | Int | 1.00 | 0.95 | 0.62 | Report |
Ctor | String | 1.00 | 4.06 | 3.84 | Report |
Add | Int | 1.00 | 1.17 | 1.09 | Report |
Add | String | 1.00 | 1.06 | 0.81 | Report |
GetValue | Int | 1.00 | 1.01 | 0.86 | Report |
GetValue | String | 1.00 | 1.00 | 0.75 | Report |
ContainsKey | Int | 1.00 | 0.84 | 0.78 | Report |
ContainsKey | String | 1.00 | 0.99 | 0.73 | Report |
ContainsValue | Int | 1.00 | 0.54 | 0.54 | Report |
ContainsValue | String | 1.00 | 0.86 | 0.90 | Report |
Результаты
Как и ожидалось, почти все рассмотренные методы на Core 3 работают быстрее. Разница зачастую составляет 20-30%, а то и больше. Для таких базовых коллекций это отличный результат.
Код и детальные результаты всех тестов доступны на GitHub.
На сегодня Core практически догнал Framework по возможностям, а по производительности давно оставил его позади. Что касается ASP.NET Core — к третьей версии он вышел в топ самых производительных веб-фреймворков (топ-5 по последним тестам TechEmpower).
Материалы по теме
Стивен Тауб про оптимизации в .NET Core: Core 2.0, Core 2.1, Core 3.0
Блоги: Андрей Акиньшин, Егор Богатов, Adam Sitnik, Matt Warren
Материалы по .NET Performance
Тесты веб-фреймворков от TechEmpower
Репозиторий с перфоманс тестами
Репозиторий рантайма Core
Браузер исходного кода .NET Framework и .NET Core
questor
Посмотрел ТОП, действительно пятое место, допустим у nginx на сях — 63, но… увидел надпись "kestrel" и сразу захотелось уточнить, что майкрософт не рекомендует выставлять kestrel в интернет. И сразу мне как-то показалось, что за такой высокий результат пришлось расплатиться именно порезанной безопасностью.
Таки у меня вопрос: за прошедшие пару лет что-то поменялось в безопасности kestrel или что в 2.1, что в 3.1 разницы особой нет?
dimaaan
Поменялось