Benchmarking to the Bottom - Iterating Lists in .NET

stphnwlsh

Stephen Walsh

Posted on March 10, 2022

Benchmarking to the Bottom - Iterating Lists in .NET

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.

"Buy Me A Coffee"

💖 💪 🙅 🚩
stphnwlsh
Stephen Walsh

Posted on March 10, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related