В первой части мы сравнивали методы Any()
и Count
для различных коллекций и предложили вариант оптимизаций.
Прошло достаточно времени, чтобы провести повторное сравнение.
Array
Начнем с самого простого типа для коллекции - массива.
Проверим, есть ли какая-то разница между вызовами array.Any()
и array.Length != 0
.
Здесь и далее будет использоваться эта конфигурация:
BenchmarkDotNet v0.14.0, Windows 10 (10.0.19045.5796/22H2/2022Update)
AMD Ryzen 7 7840H with Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.203
[Host] : .NET 9.0.4 (9.0.425.16305), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
.NET 5.0 : .NET 5.0.17 (5.0.1722.21314), X64 RyuJIT AVX2
.NET 6.0 : .NET 6.0.36 (6.0.3624.51421), X64 RyuJIT AVX2
.NET 8.0 : .NET 8.0.15 (8.0.1525.16413), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
.NET 9.0 : .NET 9.0.4 (9.0.425.16305), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
.NET Core 3.1 : .NET Core 3.1.32 (CoreCLR 4.700.22.55902, CoreFX 4.700.22.56512), X64 RyuJIT AVX2
.NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256
Код бенчмарка. Нажмите, чтобы развернуть.
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
namespace AnyVsCount
{
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.Net50)]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
[SimpleJob(RuntimeMoniker.Net48)]
[HideColumns("Job", "Error", "StdDev", "Gen0")]
public class ArrayBenchmark
{
[Params(10, 10000)]
public int N;
private int[] array;
[GlobalSetup]
public void SetUp()
{
array = new int[N];
}
[Benchmark]
public bool ArrayAny()
{
return array.Any();
}
[Benchmark]
public bool ArrayCount()
{
return array.Length != 0;
}
}
}
Результаты
Method | Runtime | N | Mean | Median | Allocated |
---|---|---|---|---|---|
ArrayAny | .NET Framework 4.8 | 10 | 8.7962 ns | 8.8213 ns | 32 B |
ArrayCount | .NET Framework 4.8 | 10 | 0.0073 ns | 0.0028 ns | - |
ArrayAny | .NET Core 3.1 | 10 | 8.0450 ns | 8.0462 ns | 32 B |
ArrayCount | .NET Core 3.1 | 10 | 0.0433 ns | 0.0399 ns | - |
ArrayAny | .NET 5.0 | 10 | 6.2080 ns | 6.1852 ns | - |
ArrayCount | .NET 5.0 | 10 | 0.0030 ns | 0.0000 ns | - |
ArrayAny | .NET 6.0 | 10 | 6.0673 ns | 6.0671 ns | - |
ArrayCount | .NET 6.0 | 10 | 0.0135 ns | 0.0135 ns | - |
ArrayAny | .NET 8.0 | 10 | 5.0980 ns | 5.2591 ns | - |
ArrayCount | .NET 8.0 | 10 | 0.0000 ns | 0.0000 ns | - |
ArrayAny | .NET 9.0 | 10 | 2.1643 ns | 2.1216 ns | - |
ArrayCount | .NET 9.0 | 10 | 0.0030 ns | 0.0034 ns | - |
ArrayAny | .NET Framework 4.8 | 10000 | 9.0093 ns | 9.0043 ns | 32 B |
ArrayCount | .NET Framework 4.8 | 10000 | 0.0123 ns | 0.0137 ns | - |
ArrayAny | .NET Core 3.1 | 10000 | 7.9624 ns | 7.7794 ns | 32 B |
ArrayCount | .NET Core 3.1 | 10000 | 0.0311 ns | 0.0295 ns | - |
ArrayAny | .NET 5.0 | 10000 | 4.8357 ns | 4.8352 ns | - |
ArrayCount | .NET 5.0 | 10000 | 0.0008 ns | 0.0007 ns | - |
ArrayAny | .NET 6.0 | 10000 | 6.0913 ns | 6.0747 ns | - |
ArrayCount | .NET 6.0 | 10000 | 0.0147 ns | 0.0153 ns | - |
ArrayAny | .NET 8.0 | 10000 | 4.7691 ns | 4.7521 ns | - |
ArrayCount | .NET 8.0 | 10000 | 0.0110 ns | 0.0078 ns | - |
ArrayAny | .NET 9.0 | 10000 | 2.2933 ns | 2.2906 ns | - |
ArrayCount | .NET 9.0 | 10000 | 0.0121 ns | 0.0109 ns | - |
Значения близкие к нулю (например, 0.0000 ns) следует считать пренебрежимо малыми — они находятся в пределах погрешности измерений.
Легенда ко всем таблицам:
N : Value of the 'N' parameter
Mean : Arithmetic mean of all measurements
Median : Value separating the higher half of all measurements (50th percentile)
Allocated : Allocated memory per single operation (managed only, inclusive, 1KB = 1024B)
1 ns : 1 Nanosecond (0.000000001 sec)
Можно заметить, что для массива результаты практически не зависят от размера, поэтому дальше будет рассматривать только результаты для N = 10000
.
Дальше мы видим, что прямой вызов свойства Length
у массива минимум на 2 порядка быстрее вызова метода Any()
и кажется, что нет никакого смысла его использовать.
Но зачастую мы работаем не с коллекциями напрямую, а с обобщенным кодом:
public bool IsEmpty<T>(IEnumerable<T> collection)
{
return !collection.Any();
}
Поэтому продолжим наше исследование и внимательнее посмотрим, почему получается такая разница в методе Any()
в разных версиях фреймворка.
Видно, что от .NET 4.8 до .NET 9.0 время выполнения метода Any()
уменьшилось в 4 раза:
Разница между последней версией классического фреймворка и .NET Core незначительная. Давайте посмотрим на реализацию метода Any()
внутри .NET 4.8 и .NET Core 3.1 (они совпадают):
public static bool Any<TSource>(this IEnumerable<TSource> source)
{
if (source == null)
{
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
}
using (IEnumerator<TSource> e = source.GetEnumerator())
{
return e.MoveNext();
}
}
Как видим, чтобы определить, есть ли в последовательности хотя бы один элемент, создаётся итератор по этой последовательности и вызывается метод MoveNext()
.
На это же нам намекали 32 байта выделяемой памяти в бенчмарке - это как раз затраты на создание итератора. Что же изменилось в .NET 5?
Давайте опять посмотрим на код:
public static bool Any<TSource>(this IEnumerable<TSource> source)
{
if (source == null)
{
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
}
if (source is ICollection<TSource> collectionoft)
{
return collectionoft.Count != 0;
}
else if (source is IIListProvider<TSource> listProv)
{
int count = listProv.GetCount(onlyIfCheap: true);
if (count >= 0)
{
return count != 0;
}
}
else if (source is ICollection collection)
{
return collection.Count != 0;
}
using (IEnumerator<TSource> e = source.GetEnumerator())
{
return e.MoveNext();
}
}
Решение подозрительно похоже на то, что мы использовали в первой части.
Вначале проверяется, реализует ли перечисление интерфейс ICollection<T>
, у которого есть свойство Count
. А дальше мы видим использование нового интерфейса IIListProvider<TSource>
(внутренний интерфейс .NET, оптимизирующий операции LINQ за счёт избегания перечисления элементов):
internal interface IIListProvider<TElement> : IEnumerable<TElement>
{
TElement[] ToArray();
List<TElement> ToList();
int GetCount(bool onlyIfCheap);
}
Этот интерфейс - часть масштабной переработки LINQ, которая позволила добиться значительной оптимизации заметной даже на простом методе Any()
.
Любопытно, что в .NET 6 производительность несколько ухудшилась, хотя реализация метода Any()
не изменилась.
В восьмой версии .NET код, который вычисляет размер перечисления (не выполняя то самое перечисление) вынесли в отдельный метод TryGetNonEnumeratedCount()
.
Этот метод работает за константное время, но не всегда может вернуть значение.
В девятой версии .NET концепция использования IIListProvider<TSource>
получила развитие и LINQ-методы были переработаны с использованием нового класса Iterator<TSource>
, что позволило еще улучшить производительность.
Как видим, напрашивающееся решение с проверкой типа перечисления и свойства Count
было реализовано в новых версиях .NET.
Будет ли это эффективно для всех коллекций?
ConcurrentDictionary
Давайте рассмотрим самую распространенную потокобезопасную коллекцию - ConcurrentDictionary<TKey, TValue>
.
Кажется, что с новой реализацией могут быть проблемы.
Код бенчмарка. Нажмите, чтобы развернуть.
using System.Collections.Concurrent;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
namespace AnyVsCount
{
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.Net50)]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
[SimpleJob(RuntimeMoniker.Net48)]
[HideColumns("Job", "Error", "StdDev", "Gen0")]
public class ConcurrentDictionaryBenchmark
{
[Params(10, 10000)]
public int N;
private ConcurrentDictionary<int, string> dictionary;
[GlobalSetup]
public void SetUp()
{
dictionary = new ConcurrentDictionary<int, string>();
for (int i = 0; i < N; i++)
{
dictionary[i] = i.ToString();
}
}
[Benchmark]
public bool DictionaryAny()
{
return dictionary.Any();
}
[Benchmark]
public bool DictionaryCount()
{
return dictionary.Count != 0;
}
}
}
Результаты
Method | Runtime | N | Mean | Median | Allocated |
---|---|---|---|---|---|
DictionaryAny | .NET Framework 4.8 | 10 | 18.48 ns | 18.47 ns | 64 B |
DictionaryCount | .NET Framework 4.8 | 10 | 110.77 ns | 110.75 ns | - |
DictionaryAny | .NET Core 3.1 | 10 | 18.21 ns | 18.17 ns | 64 B |
DictionaryCount | .NET Core 3.1 | 10 | 108.27 ns | 110.12 ns | - |
DictionaryAny | .NET 5.0 | 10 | 92.98 ns | 92.81 ns | - |
DictionaryCount | .NET 5.0 | 10 | 97.02 ns | 96.42 ns | - |
DictionaryAny | .NET 6.0 | 10 | 87.96 ns | 87.79 ns | - |
DictionaryCount | .NET 6.0 | 10 | 88.35 ns | 88.40 ns | - |
DictionaryAny | .NET 8.0 | 10 | 76.10 ns | 76.08 ns | - |
DictionaryCount | .NET 8.0 | 10 | 72.26 ns | 72.27 ns | - |
DictionaryAny | .NET 9.0 | 10 | 83.02 ns | 83.52 ns | - |
DictionaryCount | .NET 9.0 | 10 | 80.43 ns | 79.34 ns | - |
DictionaryAny | .NET Framework 4.8 | 10000 | 18.87 ns | 18.71 ns | 64 B |
DictionaryCount | .NET Framework 4.8 | 10000 | 6,870.38 ns | 6,859.65 ns | - |
DictionaryAny | .NET Core 3.1 | 10000 | 18.04 ns | 18.06 ns | 64 B |
DictionaryCount | .NET Core 3.1 | 10000 | 7,000.55 ns | 7,000.95 ns | - |
DictionaryAny | .NET 5.0 | 10000 | 5,877.30 ns | 5,877.50 ns | - |
DictionaryCount | .NET 5.0 | 10000 | 5,962.47 ns | 5,958.17 ns | - |
DictionaryAny | .NET 6.0 | 10000 | 5,993.15 ns | 5,994.17 ns | - |
DictionaryCount | .NET 6.0 | 10000 | 6,030.51 ns | 6,029.08 ns | - |
DictionaryAny | .NET 8.0 | 10000 | 5,082.70 ns | 5,082.69 ns | - |
DictionaryCount | .NET 8.0 | 10000 | 5,370.34 ns | 5,368.76 ns | - |
DictionaryAny | .NET 9.0 | 10000 | 5,917.83 ns | 5,917.32 ns | - |
DictionaryCount | .NET 9.0 | 10000 | 5,847.50 ns | 5,848.04 ns | - |
Уже видны некоторые особенности:
- Время выполнения зависит от размера коллекции.
- В старых версиях фреймворка метод
Any()
был намного быстрее. - В новых версиях фреймворка метод
Any()
сровнялся по производительности сCount
:
Давайте посмотрим реализацию свойства Count
:
public int Count
{
get
{
int locksAcquired = 0;
try
{
AcquireAllLocks(ref locksAcquired);
return GetCountNoLocks();
}
finally
{
ReleaseLocks(locksAcquired);
}
}
}
private void AcquireAllLocks(ref int locksAcquired)
{
//...
// First, acquire lock 0, then acquire the rest. _tables won't change after acquiring lock 0.
AcquireFirstLock(ref locksAcquired);
AcquirePostFirstLock(_tables, ref locksAcquired);
Debug.Assert(locksAcquired == _tables._locks.Length);
}
Подсчет всех элементов требует получения блокировок на специальный внутренний массив _tables._locks
. Каждый элемент этого массива блокирует часть словаря.
Таким образом, начиная с .NET 5, метод Any()
для ConcurrentDictionary
использует свойство Count
через ICollection<T>
, что приводит к таким же блокировкам, как и при прямом вызове Count
.
Any() и IsEmpty
Давайте восстановим реализацию Any()
с итератором и проверим производительность этого метода:
[Benchmark]
public bool DictionaryEnumerator()
{
using (var enumerator = dictionary.GetEnumerator())
{
return enumerator.MoveNext();
}
}
Method | Runtime | N | Mean | Median | Allocated |
---|---|---|---|---|---|
DictionaryEnumerator | .NET Framework 4.8 | 10 | 15.51 ns | 15.52 ns | 64 B |
DictionaryEnumerator | .NET Core 3.1 | 10 | 14.60 ns | 14.60 ns | 64 B |
DictionaryEnumerator | .NET 5.0 | 10 | 15.16 ns | 15.14 ns | 64 B |
DictionaryEnumerator | .NET 6.0 | 10 | 18.60 ns | 18.67 ns | 64 B |
DictionaryEnumerator | .NET 8.0 | 10 | 12.97 ns | 12.96 ns | 64 B |
DictionaryEnumerator | .NET 9.0 | 10 | 12.80 ns | 12.81 ns | 64 B |
DictionaryEnumerator | .NET Framework 4.8 | 10000 | 15.41 ns | 15.38 ns | 64 B |
DictionaryEnumerator | .NET Core 3.1 | 10000 | 15.03 ns | 14.96 ns | 64 B |
DictionaryEnumerator | .NET 5.0 | 10000 | 15.90 ns | 15.92 ns | 64 B |
DictionaryEnumerator | .NET 6.0 | 10000 | 18.50 ns | 18.50 ns | 64 B |
DictionaryEnumerator | .NET 8.0 | 10000 | 13.38 ns | 12.92 ns | 64 B |
DictionaryEnumerator | .NET 9.0 | 10000 | 12.97 ns | 12.53 ns | 64 B |
Видим, что результаты не зависят от количества элементов и практически не изменились в новых версиях .NET. Но мы расходуем немного памяти на создание итератора каждый раз.
Но можно воспользоваться специализированным свойством коллекции - IsEmpty
:
[Benchmark]
public bool DictionaryIsEmpty()
{
return !dictionary.IsEmpty;
}
Method | Runtime | N | Mean | Median | Allocated |
---|---|---|---|---|---|
DictionaryIsEmpty | .NET Framework 4.8 | 10 | 99.023 ns | 98.709 ns | - |
DictionaryIsEmpty | .NET Core 3.1 | 10 | 2.313 ns | 2.313 ns | - |
DictionaryIsEmpty | .NET 5.0 | 10 | 2.545 ns | 2.545 ns | - |
DictionaryIsEmpty | .NET 6.0 | 10 | 2.247 ns | 2.283 ns | - |
DictionaryIsEmpty | .NET 8.0 | 10 | 2.567 ns | 2.568 ns | - |
DictionaryIsEmpty | .NET 9.0 | 10 | 10.917 ns | 10.902 ns | - |
DictionaryIsEmpty | .NET Framework 4.8 | 10000 | 6,027.991 ns | 6,026.648 ns | - |
DictionaryIsEmpty | .NET Core 3.1 | 10000 | 2.320 ns | 2.315 ns | - |
DictionaryIsEmpty | .NET 5.0 | 10000 | 2.513 ns | 2.512 ns | - |
DictionaryIsEmpty | .NET 6.0 | 10000 | 2.756 ns | 2.759 ns | - |
DictionaryIsEmpty | .NET 8.0 | 10000 | 2.673 ns | 2.673 ns | - |
DictionaryIsEmpty | .NET 9.0 | 10000 | 3.801 ns | 3.804 ns | - |
Видим, что уже начиная с .NET Core 3.1 реализация этого свойства не зависит от размера коллекции и в случае не пустых коллекций вызов этого свойства не является блокирующим.
Для .NET 4.8 мы получили результаты аналогичные вызову свойства Count
- так же используется блокировка на всю коллекцию.
Для .NET 9 результаты получились несколько хуже, но у меня нет быстрого ответа, почему так произошло.
Заключение
Реализация метода Any()
сегодня достаточно оптимизирована, чтобы его использовать в обобщенном коде, но еще есть задел на его использование в потокобезопасных коллекциях.
Рекомендации
- Используйте свойства
Count
для простых коллекций, если вам важна производительность. - Используйте метод
Any()
для обобщенного кода иIEnumerable<T>
. - Используйте свойство
IsEmpty
, если коллекции его поддерживают, начиная с .NET 5+