- PVSM.RU - https://www.pvsm.ru -
Релиз .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 (да и основу для самих бенчмарков) я позаимствовал из репозитория перфоманс-тестов [1]. Эти тесты используются мейнтейнерами .NET Core для тестирования предлагаемых оптимизаций.
Цикл 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 [2] |
Ctor | String | 1.00 | 0.90 | 0.92 | Report [3] |
IterateFor | Int | 1.00 | 0.80 | 0.80 | Report [4] |
IterateFor | String | 1.00 | 0.80 | 0.80 | Report [5] |
IterateForEach | Int | 1.00 | 1.00 | 1.00 | Report [6] |
IterateForEach | String | 1.00 | 0.80 | 0.73 | Report [7] |
Add | Int | 1.00 | 0.62 | 0.63 | Report [8] |
Add | String | 1.00 | 0.87 | 0.78 | Report [9] |
Contains | Int | 1.00 | 0.40 | 0.17 | Report [10] |
Contains | String | 1.00 | 0.83 | 0.68 | Report [11] |
IndexOf | Int | 1.00 | 0.99 | 0.43 | Report [12] |
IndexOf | String | 1.00 | 0.95 | 0.95 | Report [13] |
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 [14] |
Ctor | String | 1.00 | 0.75 | 0.84 | Report [15] |
IterateFor | Int | 1.00 | 0.86 | 1.00 | Report [16] |
IterateFor | String | 1.00 | 1.00 | 1.00 | Report [17] |
IterateForEach | Int | 1.00 | 0.84 | 1.00 | Report [18] |
IterateForEach | String | 1.00 | 1.00 | 1.00 | Report [19] |
Здесь можно отметить, что как и прежде, цикл foreach для массива преобразуется в обычный for. Т.е. с точки зрения производительности для итерации массива нет разницы какой из циклов использовать.
Randomized Hash
В .NET Core для расчета хешей строк теперь используется рандомизированный алгоритм (Marvin). Т.е. при каждом запуске приложения хеш одной и той же строки будет разным. Это защита от хеш-атак, в частности "hash flooding" (подробнее [20]). Естественно, этот алгоритм медленнее, чем нерандомизированный. Чтобы производительность 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 [21] |
Ctor | String | 1.00 | 4.06 | 3.84 | Report [22] |
Add | Int | 1.00 | 1.17 | 1.09 | Report [23] |
Add | String | 1.00 | 1.06 | 0.81 | Report [24] |
GetValue | Int | 1.00 | 1.01 | 0.86 | Report [25] |
GetValue | String | 1.00 | 1.00 | 0.75 | Report [26] |
ContainsKey | Int | 1.00 | 0.84 | 0.78 | Report [27] |
ContainsKey | String | 1.00 | 0.99 | 0.73 | Report [28] |
ContainsValue | Int | 1.00 | 0.54 | 0.54 | Report [29] |
ContainsValue | String | 1.00 | 0.86 | 0.90 | Report [30] |
Как и ожидалось, почти все рассмотренные методы на Core 3 работают быстрее. Разница зачастую составляет 20-30%, а то и больше. Для таких базовых коллекций это отличный результат.
Код и детальные результаты всех тестов доступны на GitHub [31].
На сегодня Core практически догнал Framework по возможностям, а по производительности давно оставил его позади. Что касается ASP.NET Core — к третьей версии он вышел в топ самых производительных веб-фреймворков (топ-5 по последним тестам TechEmpower [32]).
Стивен Тауб про оптимизации в .NET Core: Core 2.0 [33], Core 2.1 [34], Core 3.0 [35]
Блоги: Андрей Акиньшин [36], Егор Богатов [37], Adam Sitnik [38], Matt Warren [39]
Материалы по .NET Performance [40]
Тесты веб-фреймворков от TechEmpower [41]
Репозиторий с перфоманс тестами [1]
Репозиторий рантайма Core [42]
Браузер исходного кода .NET Framework [43] и .NET Core [44]
Автор: fixer_m
Источник [45]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/345456
Ссылки в тексте:
[1] репозитория перфоманс-тестов: https://github.com/dotnet/performance
[2] Report: https://git.io/Jv3MV
[3] Report: https://git.io/Jv3Mw
[4] Report: https://git.io/Jv3Mr
[5] Report: https://git.io/Jv3Mo
[6] Report: https://git.io/Jv3MK
[7] Report: https://git.io/Jv3M6
[8] Report: https://git.io/Jv3Mi
[9] Report: https://git.io/Jv3M1
[10] Report: https://git.io/Jv3MD
[11] Report: https://git.io/Jv3My
[12] Report: https://git.io/Jv3M9
[13] Report: https://git.io/Jv3MH
[14] Report: https://git.io/Jv3Ml
[15] Report: https://git.io/Jv3DU
[16] Report: https://git.io/Jv3DT
[17] Report: https://git.io/Jv3Dk
[18] Report: https://git.io/Jv3DI
[19] Report: https://git.io/Jv3Dt
[20] подробнее: https://andrewlock.net/why-is-string-gethashcode-different-each-time-i-run-my-program-in-net-core
[21] Report: https://git.io/Jv3Dm
[22] Report: https://git.io/Jv3D3
[23] Report: https://git.io/Jv3Ds
[24] Report: https://git.io/Jv3DG
[25] Report: https://git.io/Jv3DZ
[26] Report: https://git.io/Jv3Dn
[27] Report: https://git.io/Jv3Dc
[28] Report: https://git.io/Jv3Dz
[29] Report: https://git.io/Jv3D2
[30] Report: https://git.io/Jv3Da
[31] доступны на GitHub: https://github.com/fixer-m/RuntimePerformance
[32] тестам TechEmpower: https://www.techempower.com/benchmarks/#section=test&runid=8721f3a4-7b13-4703-9cd8-91b6779668c2&hw=ph&test=plaintext
[33] Core 2.0: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-core
[34] Core 2.1: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-core-2-1
[35] Core 3.0: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-core-3-0
[36] Андрей Акиньшин: https://aakinshin.net
[37] Егор Богатов: https://egorbo.com/
[38] Adam Sitnik: https://adamsitnik.com
[39] Matt Warren: https://mattwarren.org
[40] .NET Performance: https://github.com/adamsitnik/awesome-dot-net-performance
[41] TechEmpower: https://www.techempower.com/benchmarks
[42] рантайма Core: https://github.com/dotnet/runtime
[43] .NET Framework: https://referencesource.microsoft.com
[44] .NET Core: https://source.dot.net
[45] Источник: https://habr.com/ru/post/481558/?utm_campaign=481558&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.