In Part 1, we compared Any()
and Count
methods for different collections and proposed optimization approaches.
Array
Let's start with the simplest collection type - arrays.
We'll check if there's any difference between array.Any()
and array.Length != 0
.
The following configuration is used throughout:
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
Benchmark code. Click to expand.
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;
}
}
}
Results
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 | - |
Values close to zero (e.g., 0.0000 ns) should be considered negligible - they are within measurement error.
Legend for all tables:
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)
For arrays, results are nearly independent of size, so we'll focus on N = 10000
.
Directly accessing the Length
property is at least 2 orders of magnitude faster than Any()
.
However, in generic code working with IEnumerable<T>
, we often use:
public bool IsEmpty<T>(IEnumerable<T> collection)
{
return !collection.Any();
}
Let's analyze why Any()
performance varies across .NET versions.
From .NET 4.8 to 9.0, Any()
execution time decreased 4x:
In .NET 4.8 and Core 3.1, Any()
creates an iterator and checks MoveNext()
, allocating 32 bytes:
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();
}
}
Starting with .NET 5, optimizations were introduced:
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();
}
}
This solution is similar to the one we used in Part 1.
Firstly it checks interface ICollection<T>
, that has property Count
. And then we see usage of a new interface IIListProvider<TSource>
(internal interface of .NET to optimize LINQ methods):
internal interface IIListProvider<TElement> : IEnumerable<TElement>
{
TElement[] ToArray();
List<TElement> ToList();
int GetCount(bool onlyIfCheap);
}
The interface IIListProvider<TSource>
is part of a major rework of LINQ that has resulted in significant optimizations noticeable even in the simple Any()
method.
Interestingly, in .NET 6 the performance has degraded somewhat, although the implementation of the Any()
method has not changed.
In the eighth version of .NET, the code that calculates the size of an enumeration (without performing the enumeration) was moved to a separate method TryGetNonEnumeratedCount()
.
This method runs in constant time, but may not always return a value.
In .NET 9, the concept of using IIListProvider<TSource>
was further developed and the LINQ methods were refactored to use the new Iterator<TSource>
class, further improving performance.
As we can see, the obvious solution with checking the enumeration type and the Count
property has been implemented in new versions of .NET.
Will this be effective for all collections?
ConcurrentDictionary
Let's look at the most common thread-safe collection - ConcurrentDictionary<TKey, TValue>
. It seems that there may be problems with the new implementation.
Benchmark code. Click to expand.
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;
}
}
}
Results
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 | - |
Some features are already visible:
- Execution time depends on the size of the collection.
- In older versions of the framework, the
Any()
method was much faster. - In newer versions of the framework, the
Any()
method is on par withCount
in performance:
Let's look at the implementation of the Count
property:
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);
}
Counting all elements requires acquiring locks on a special internal array _tables._locks
. Each element of this array locks a part of the dictionary.
Thus, starting with .NET 5, the Any()
method of ConcurrentDictionary
uses the Count
property through ICollection<T>
, which results in the same locks as calling Count
directly.
Any() и IsEmpty
Let's reconstruct the implementation of Any()
with an iterator and check the performance of this method:
[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 |
We see that the results do not depend on the number of elements and have remained virtually unchanged in newer versions of .NET. But we spend a little memory creating the iterator each time.
But you can use a specialized collection property - 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 | - |
We see that starting with .NET Core 3.1, the implementation of this property does not depend on the size of the collection, and in the case of non-empty collections, calling this property is not blocking.
For .NET 4.8, we got results similar to calling the Count
property - a lock on the entire collection is also used.
For .NET 9, the results were slightly worse, but I don't have a quick answer as to why this happened.
Conclusion
The implementation of the Any()
method is now optimized enough to be used in generic code, but there is still some room for its use in thread-safe collections.
Recommendations
- Use
Count
properties for simple collections if you care about performance. - Use
Any()
method for generic code andIEnumerable<T>
. - Use
IsEmpty
property if collections support it, starting with .NET 5+