Any() vs Count: часть 2

Published on Tuesday 6 May 2025

В первой части мы сравнивали методы 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 раза:

Method Any performance

Разница между последней версией классического фреймворка и .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:

ConcurrentDictionary Any performance

Давайте посмотрим реализацию свойства 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+