Benchmarking to the Bottom - Iterating Lists in .NET
Stephen Walsh
Posted on March 10, 2022
Finding the fastest way to iterate lists while accessing their value in .NET using BenchmarkDotNet validate performance at scale.
I have spent some time recently looking through an older system, wondering how I can make it perform better. There are many avenues to make a system like this faster and more performant, but I wanted to avoid large scale refactoring to keep the risk to a minimum. Specifically I have focused on small items that can add up to make a bigger difference overall. The most obvious is a whole system upgrade but when that’s not an option, these small changes might be all an engineer has to make a difference.
Doing this work has peaked my interest as to which is the best way of doing these every day small tasks in the first place. I’m slowly building out a small series of tasks. It might be consuming different libraries to complete a task vs the native features, or it might straight up comparisons of every day tasks. Either way lessons will be learned and fun will be had.
Iterating and Accessing Lists
Today I decided to look at many different ways that you can iterate over a list. The application I have been working on has many instances of long lists that are iterated for various reasons. It made me wonder what’s the fastest way to work through all the items in a list.
On my initial run through, I neglected to assign the items being iterated to a variable. Purely iterating the list without accessing the item lead to skewed results. Both sets of results will be listed in the outcomes.
Below are the methods being used and tested via BenchmarkDotNet.
Theory
I have read lately about how the for
loop is actually faster than the foreach
one. I'm anticipating that what I would find is for that to be slightly more performant on a List
. To cover multiple different ways to iterate through a list I have included LINQ and for
as other options. I would anticipate LINQ to be slightly slower and I have never used Span
before so I have no idea what to expect.
Setup
Using BenchmarkDotNet to create a console application makes running this benchmarking test very simple. To ensure older systems are being covered these tests will be run in both .NET Framework 4.8 and .NET 6.0 as these are the current Long Term Support versions of .NET. One for the older world and one for the new world.
The premise is simple. Create a List
of strings 100 and 10,000 deep. Then run the test to iterate through each item and do this on repeat until BenchmarkDotNet is happy to give us a result. This should give us a reasonable assumption of the fastest way to do this going forwards.
Methods
Next up is creating a method for each type of List
iteration that I want to test. Each method does one thing to make it an even benchmark. Each method will iterate through each item in the List
, access the item in the list and assign it to a variable. Once the all the items have been process it will finish and record the time taken to complete the task.
Results
Thankfully for us, BenchmarkDotNet includes a Rank column to make it clear which run of tests is the winner. As you read these results you can use the Mean column to compare the difference in runtime between each test. For some of these there is a clear winner and others the difference is negligible.
.NET Framework 4.8
Rank | Method | N | Mean |
---|---|---|---|
1 | For | 100 | 344.8 ns |
2 | ForEachLinq | 100 | 559.9 ns |
3 | ForEach | 100 | 659.4 ns |
3 | GetEnumerator | 100 | 659.5 ns |
4 | For | 10000 | 33,562.8 ns |
5 | ForEachLinq | 10000 | 53,895.5 ns |
6 | GetEnumerator | 10000 | 63,317.0 ns |
6 | ForEach | 10000 | 63,356.6 ns |
.NET 6.0
Rank | Method | N | Mean |
---|---|---|---|
1 | SpanForEach | 100 | 144.3 ns |
2 | SpanFor | 100 | 176.3 ns |
3 | ForEach | 100 | 251.0 ns |
3 | GetEnumerator | 100 | 251.0 ns |
3 | For | 100 | 251.4 ns |
4 | ForEachLinq | 100 | 352.1 ns |
5 | SpanForEach | 10000 | 12,260.4 ns |
6 | SpanFor | 10000 | 16,088.1 ns |
7 | ForEach | 10000 | 24,109.4 ns |
7 | GetEnumerator | 10000 | 24,111.0 ns |
7 | For | 10000 | 24,113.8 ns |
8 | ForEachLinq | 10000 | 34,713.9 ns |
Results are formatted for clean and clear display, for the full output you peruse the output of the actions on GitHub.
Thoughts
.NET Framework 4.8
After running the benchmark tests, the huge surprise to me here is how bad foreach
performs. It’s on par as the worst with GetEnumerator
and gets outperformed by LINQ foreach
. I will definitely not be using this in any older system any time soon. Would have been nice to have some Span
results to compare but that feature is not available for .NET Framework. The clear choice here is to refactor all List
iterations into for
loops and live a happy life.
.NET 6.0
The .NET results are quite surprising to me. I started running this not knowing much about Span
, and now I’m ready to do a bit of research to understand more. When accessing the item, it appears that the Span
has an edge over the for
and foreach
and it looks like it's worth refactoring your entire codebase for that performance gain. The key outcome here is that any iteration of a List
should look to convert that list to a Span
. Outside of that there is some difference between for
and foreach
when iterating a Span
but I'm not sure that it's worth changing all those over.
STOP USING LINQ FOREACH STATEMENTS
They are a performance killer in both .NET 6.0 and .NET Framework 4.8 and far slower than the alternatives. Refactor those immediately. It's incredibly poor for something that does get used by a lot of people.
Code
All of this code is open sourced. You can find my benchmarking work on GitHub. The results are the output of a GitHub Action to run the benchmark tests, you should be able to find detailed output on that repository.
Support
If you like this, or want to checkout my other work, please connect with me on LinkedIn, Twitter or GitHub, and consider supporting me at Buy Me a Coffee.
Posted on March 10, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.