.NET Core vs Framework. Производительность коллекций
Мне стало интересно, какой прирост производительности можно ожидать от Core в самых базовых классах, которые максимально часто используются в коде. Например, коллекции List, Array и Dictionary.
Если вам тоже интересно, как и почему изменилась производительность основных коллекций в Core 3 — прошу под кат!
Бенчмарки
Также я прогонял все тесты на двух дополнительных машинах (на Haswell и Sky Lake), чтобы убедиться, что результаты тестов стабильны и воспроизводятся на другом железе.
Цикл for
| 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 стало быстрее на
Цикл foreach
| 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), примерно также как и в полном фреймворке.
Чтобы протестировать метод без ресайза внутреннего массива в тесте используется конструктор List с заданной ёмкостью (capacity).
| 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 |
Contains
Давайте возьмём негативный сценарий для метода Contains: будем искать элементы, которых нет в коллекции.
| 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 |
Кстати, раньше я всегда думал, что метод 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
| 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
| 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.
.NET Core vs Framework. Производительность коллекций
Мне стало интересно, какой прирост производительности можно ожидать от Core в самых базовых классах, которые максимально часто используются в коде. Например, коллекции List, Array и Dictionary.
Если вам тоже интересно, как и почему изменилась производительность основных коллекций в Core 3 — прошу под кат!
Бенчмарки
Также я прогонял все тесты на двух дополнительных машинах (на Haswell и Sky Lake), чтобы убедиться, что результаты тестов стабильны и воспроизводятся на другом железе.
Цикл for
| 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 стало быстрее на
Цикл foreach
| 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), примерно также как и в полном фреймворке.
Чтобы протестировать метод без ресайза внутреннего массива в тесте используется конструктор List с заданной ёмкостью (capacity).
| 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 |
Contains
Давайте возьмём негативный сценарий для метода Contains: будем искать элементы, которых нет в коллекции.
| 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 |
Кстати, раньше я всегда думал, что метод 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
| 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
| 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).

