Benchmark Your Microsoft .NET Code Like dotNetDave!

Benchmarking isn’t optional—it’s how you prove your code can actually perform under pressure.

At its core, benchmarking establishes a performance baseline for your code. It helps you uncover bottlenecks, compare competing algorithms that solve the same problem, and choose the most efficient approach in terms of execution time and memory usage. In .NET, there are countless ways to implement the same functionality, which raises a critical question:

Which one is actually faster?

In .NET, there are countless ways to solve the same problem—but not all of them scale. Poor choices don’t just slow your app down. They increase memory usage, reduce throughput, and drive up cloud costs.

That’s where most developers go wrong.

A poll I ran showed that many developers don’t benchmark their code—or don’t realize how critical it is. That’s a serious gap, especially in modern, cloud-driven systems where inefficient code directly impacts your bottom line.

This article is here to fix that.

Benchmarking requires an upfront investment, but the payoff is massive. I’ve uncovered issues during benchmark runs that never appeared in unit tests. Unit tests run once. Benchmarks run thousands—or millions—of times.

That’s where the truth shows up.

If you’re not benchmarking your code, you’re guessing.

And guessing is how performance problems ship to production.

Benchmarking Code Before Release

Benchmarking should be a required step before releasing any code. In cloud environments, performance problems don’t just slow things down—they cost money. When you’re billed based on execution time and resource usage, inefficient code hits your budget directly.

For years, I’ve relied on BenchmarkDotNet in my professional work, my open-source projects, and my book Rock Your Code: Code & App Performance for Microsoft .NET. It’s rock-solid, battle-tested, and trusted—even Microsoft uses it to benchmark .NET itself.

Everything in this article is drawn from the DotNetTips.Spargine.Benchmarking assembly and NuGet package.

Source code and packages:

GitHub: Benchmarking Projects

NuGet: http://bit.ly/dotNetDaveNuGet

When to Benchmark

Benchmark your code when:

  • You’re comparing multiple implementations
  • You’re optimizing performance-critical paths
  • You’re preparing code for production release
  • You’re working in high-scale or cloud environments

If performance matters—even a little—benchmark it.

To make benchmarking practical—not painful, I built tooling into Spargine that removes the usual setup overhead.

Overview of Benchmarking with Spargine

The DotNetTips.Spargine.Benchmarking assembly removes the friction from setting up BenchmarkDotNet. It provides preconfigured reporting, realistic test data, and reusable base classes so you can focus on measuring performance—not wiring things together.

Here’s a simple example benchmarking Spargine’s BytesToString() method in FastStringBuilder:

[Benchmark(Description = nameof(FastStringBuilder.BytesToString) + ": Array")]
[BenchmarkCategory(Categories.Array, Categories.Strings)]
public void ArrayBytesToString()
{
    var result = FastStringBuilder.BytesToString(ref this._byteArray);
    this.Consume(result);
}

The [Benchmark] attribute is the backbone of every test. I always include a description because it shows up directly in the reports. I also frequently use [BenchmarkCategory] so I can group and execute related tests together.

A diagram displaying the structure and hierarchy of classes related to benchmarking, including abstract classes, properties, and methods for various collection benchmarks.

All report columns and diagnostics are configured centrally in the Spargine Benchmark base class.

At the core of this system is the Benchmark base class, which standardizes how tests are configured and executed.

The Benchmark Base Class

The Benchmark abstract class is the foundation for executing benchmark tests. It centralizes configuration, reporting, and helper functionality so benchmarks remain clean, accurate, and repeatable.

All data used by this class is either defined as constants or loaded once during initialization. This guarantees that test results aren’t skewed by setup costs. Configuration is separated from execution, keeping measurements honest and noise-free.

Constants

The following constants are used throughout benchmarks to standardize test inputs and improve readability:

  • FailedText
    Value: “failed”
  • LowerCaseString
    Value: “john doe”
  • PhoneNumberUpdate
    Value: “555-867-5309”
  • ProperCaseString
    Value: “John Doe”
  • String10Characters01
    Value: “2ds9JiOtNF”
  • String10Characters02
    Value: “ndA5nJSHnU”
  • String15Characters01
    Value: “C8IIVjaUi0owZh6”
  • String15Characters02
    Value: “Q7sXguwS9vZpOo6”
  • SuccessText
    Value: “success”
  • TestEmailLowerCase
    Value: “fake@fakelive.com”
  • TestEmailUpperCase
    Value: “Fake@FakeLive.com”
  • UpperCaseString
    Value: “JOHN DOE”

Benchmark Properties

These properties are preloaded during initialization, so benchmarks don’t pay setup costs during execution:

  • Base64String
    Base64-encoded test string
  • Consumer
    Gets the consumer used for consuming objects in benchmark operations.
  • CoordinateRef01 / CoordinateRef02
    Reference-type coordinate objects
  • CoordinateVal01 / CoordinateVal02
    Value-type coordinate objects
  • JsonTestDataPerson
    JSON test data for a Person object
  • JsonTestDataPersonRecord
    JSON test data for a PersonRecord
  • LaunchDebugger
    Indicates whether a debugger should launch at startup
  • LongTestString
    969-character string for realistic text processing tests
  • PersonRecord01 / PersonRecord02
    Randomly generated records
  • PersonRef01 / PersonRef02
    Reference-type Person objects
  • PersonVal01 / PersonVal02
    Value-type Person objects
  • PersonXml
    XML test data for IPerson
  • StringToTrim
    Whitespace-padded string
  • TestGuid
    Startup-generated Guid

Benchmark Methods

These helper methods exist for one reason: to keep your benchmarks accurate, repeatable, and free from measurement noise. Instead of polluting your benchmark code with setup, logging, or defensive logic, these methods centralize that responsibility.

Execution & Lifecycle Methods

  • CleanUp() / CleanUpAsync()
    Performs cleanup after benchmark execution.
  • ClearDataCaches()
    Releases all cached byte and string fixture references. Use during setup or cleanup, not concurrently with fixture generation. Does not force garbage collection, reset the seed, or modify arrays already returned. Seeded data can be regenerated.
  • GlobalCleanup()
    Runs once after all benchmarks complete. Does not invoke asynchronous overrides. BenchmarkDotNet uses GlobalCleanupAsync(). Tracked directory deletion is attempted even when the cleanup hook fails. Deletion failures propagate, and unsuccessful paths remain tracked for a subsequent cleanup attempt.
  • GlobalCleanupAsync()
    Awaits the cleanup hook and then removes this instance’s tracked temporary directories. BenchmarkDotNet invokes this entry point. Override CleanupAsync() or Cleanup() instead. Directory cleanup is attempted even if the hook fails.
  • GlobalSetup()
    Performs synchronous setup for manual callers, optionally launching the debugger. Does not invoke asynchronous overrides. BenchmarkDotNet uses GlobalSetupAsync().
  • GlobalSetupAsync()
    Optionally launches the debugger and dispatches asynchronous benchmark initialization.
  • Setup() / SetupAsync()
    Override to perform custom initialization before each benchmark run.

Consumption Methods (Prevent Optimization)

  • Consume<T>(T obj)
    Consumes a value to prevent compiler optimization.
  • ConsumeAsync<T>(T obj)
    Async version of Consume.
  • ConsumeAsyncEnumerableAsync<T>(IAsyncEnumerable<T> source, CancellationToken cancellationToken)
    Enumerates and consumes every element of an asynchronous sequence. Enumeration, consumption, and disposal are part of the measured workload when called from a benchmark. Source and disposal exceptions propagate. Cancellation during a pending move requires cooperation from the source; this method does not abandon enumeration in the background.
  • ConsumeCollection(IReadOnlyList<T> collection)
    Consumes each item in the specified IReadOnlyList by index, avoiding enumerator allocation.
  • ConsumeDictionary<TKey, TValue>(IDictionary<TKey, TValue> collection)
    Iterates through the dictionary and consumes each value. This method uses a foreach loop to traverse the dictionary and calls Consume() for each value. It is designed to introduce deterministic work when benchmarking dictionary-based data structures without allocations.
  • ConsumeEnumerable<T>(IEnumerable<T> collection)
    Iterates through a sequence and consumes each item. This method uses a foreach loop to traverse the sequence and calls Consume() for each element. It is designed to introduce deterministic work when benchmarking enumerable data structures without allocations.
  • ConsumeReadOnlySpan<T>(ReadOnlySpan<T> span)
    Consumes each item in the specified ReadOnlySpan using the Consume() method.
  • ConsumeSpan<T>(Span<T> span)
    Iterates through a span and consumes each item. This method uses a foreach loop to traverse the span and calls Consume() for each element. It is designed to introduce deterministic work when benchmarking span-based data structures without allocations.
  • SimulateWork(object item)
    Simulates work by computing the hash code of the provided item object. This method uses RuntimeHelpers.GetHashCode(), which provides a stable hash code for an object during the lifetime of the process, making it suitable for benchmarking operations that need to perform real work without being eliminated by compiler optimizations.

Data Generation & Test Support

These methods provide consistent, reusable test data to eliminate setup overhead and ensure repeatable results.

  • CopyByteArrayTo(Span<byte> destination)
    Restores the entire destination from a private byte fixture of the same length. Use outside measured code unless copying is the workload. The first call creates cached arrays; subsequent copies reuse them. Mutations to arrays returned by getters do not affect this source.
  • CopyStringArrayTo(Span<string> destination, int wordMinLength = 10, int wordMaxLength = 15)
    Restores the entire destination from a private string fixture of the same count. Use outside measured code. Copies references to immutable strings, not the strings themselves.
  • CreateTemporaryDirectory()
    Creates a unique temporary directory owned by this benchmark instance. Opt in by calling during setup, not concurrently with cleanup. Global cleanup recursively deletes the created paths and their contents; do not place data that must be preserved there or move these directories. Merely constructing a benchmark performs no filesystem operations.
  • int DataSeed
    Gets or sets the optional seed used by byte and string fixture helpers. Changing the seed clears the fixture caches. Configure it during setup, not concurrently with fixture access. The same seed and arguments reproduce data on the same runtime independently of call order. Other fixtures, including people, are unaffected. Reproducibility across runtime versions is not guaranteed. Previously returned arrays are not changed.
  • GetByteArray(int count)
    Returns cached random byte arrays. Retrieves a cached mutable byte array with an exact length, including zero.
  • GetByteArrayByLength(int byteCount)
    Retrieves a cached mutable byte array with an exact length, including zero. Shares fixtures with GetByteArray(). Generate fixtures outside measured code.
  • GetStringArray(int count, int minLength, int maxLength)
    Generates and caches random string arrays. Call during setup. A private array snapshot preserves the original string references for CopyStringArrayTo() the immutable strings themselves are shared, not duplicated. Changing returned elements does not change this private snapshot.
  • SimulateWork(object item) / SimulateWorkAsync(object item)
    Simulates work by computing the hash code of the provided object.
  • Update(Person)
    Updates phone number data for test scenarios. This helper is intended for benchmarking scenarios to apply a deterministic mutation to a Person instance. Person.CellPhone() to the constant test value stored in PhoneNumberUpdate().
  • Update<T>(T coordinate)
    Updates coordinate values. This helper applies a deterministic mutation for benchmarking scenarios and validates input.

Logging & Diagnostics

These methods provide structured logging that integrates directly with benchmark output.

  • LogError(string message)
    Logs an error-level message by forwarding it to the benchmark console logger. This helper standardizes error logging for benchmark setup, execution, and cleanup diagnostics.
  • LogInfo(string message)
    Logs an informational message by forwarding it to the benchmark console logger.
  • LogMessage(LogKind logKind, string message)
     This method centralizes logging within benchmarks by forwarding messages to ConsoleLogger.Default(). Messages are written using ConsoleLogger.WriteLine(LogKind, string) and will appear in BenchmarkDotNet console output and artifacts, which is useful for setup/teardown diagnostics and informative traces during benchmark execution.
  • LogWarning(string message)
    Logs a warning-level message by forwarding it to the benchmark console logger.
  • MeasureAction(Action action, string description = “Action”)
    Measures the elapsed time of the specified action and logs the result. This helper is intended for quick ad-hoc timing during setup or cleanup and is not a substitute for BenchmarkDotNet measurements. Exceptions thrown by action are not intercepted and are propagated to the caller.

Logs messages to BenchmarkDotNet output for visibility during execution.

Benchmark Report Configuration

All report formats and diagnostics are configured in the Benchmark base class, including HTML, GitHub Markdown, JSON, and CSV output. Columns include Min, Max, Rank, Memory, Disassembly, Threading, and more.

Configured attributes include:

[AllStatisticsColumn]
[BaselineColumn]
[CategoriesColumn]
[ConfidenceIntervalErrorColumn]
[CsvExporter]
[DisassemblyDiagnoser(printSource: true, exportGithubMarkdown: true, exportCombinedDisassemblyReport: true, exportDiff: true, exportHtml: true)]
[EvaluateOverhead]
[ExceptionDiagnoser]
[GcServer(true)]
[InliningDiagnoser(logFailuresOnly: true, filterByNamespace: true)]
[IterationsColumn]
[JsonExporter(indentJson: true)]
[Orderer(SummaryOrderPolicy.Method, methodOrderPolicy: MethodOrderPolicy.Alphabetical)]
[StopOnFirstError(true)]

CollectionBenchmark

CollectionBenchmark manages realistic collection data used during benchmarks. Collections are generated once, stored in memory, and cloned as needed. This eliminates setup overhead and keeps results clean.

Constructor

  • CollectionBenchmark(int maxCount)
    maxCount is the maximum count for the collections used in the benchmark.

Properties

  • int HalfCount
    Gets the half count.
  • int MaxCount
    Gets the maximum count for the collections used in the benchmark.
  • string PersonEmailHalf
    Gets the email address of a person in the middle of the collection.
  • string PersonEmailLast
    Gets the email address of the last person in the collection.
  • string PersonFirstNameHalf
    Gets the first name of a person in the middle of the collection.
  • string PersonFirstNameLast
    Gets the first name of the last person in the collection.
  • string PersonLastNameHalf
    Gets the last name of a person in the middle of the collection.
  • string PersonLastNameLast
    Gets the last name of the last person in the collection.
  • string PersonRecordLookupHalf
    Gets the person record for lookup from the middle of the collection.
  • string PersonRecordLookupHalf
    Gets the person record for lookup from the middle of the collection.
  • string PersonRecordLookupLast
    Gets the person record for lookup of the last item in the collection.
  • string PersonRefLookupHalf
    Gets the reference type person for lookup from the middle of the collection.
  • string PersonRefLookupLast
    Gets the reference type person for lookup of the last item in the collection.
  • Person PersonValLookupHalf
    Gets the value type person for lookup from the middle of the collection.
  • Person PersonValLookupLast
    Gets the value type person for lookup of the last item in the collection.

Methods

  • ClearCollectionCaches()
    Clears all in-memory collection caches held by this benchmark instance. After calling this method, the internal collections will be empty and lookup properties will be reset to defaults.
  • ClearPersonCaches()
    Clears the in-memory person collection caches and replaces them with empty lists. Useful for tests that need to reset state between runs.
  • Collection<Coordinate> GetCoordinateValCollection()
    Gets a clone of the Coordinate list as a Collection<T>. Similar to GetCoordinateValArray(), this method provides a fresh copy of the data for benchmark tests.
  • Coordinate GetCoordinateRefCollection()
    Gets a clone of the Coordinate list as a Collection<T. Similar to GetCoordinateValArray(), this method provides a fresh copy of the data for benchmark tests.
  • Coordinate[] GetCoordinateRefArray()
     Gets a clone of the Coordinate array. This method ensures that benchmarks operate on a fresh copy of the data, preventing modifications from affecting subsequent benchmark runs.
  • Coordinate[] GetCoordinateValArray()
    Gets a clone of the Coordinate array. This method ensures that benchmarks operate on a fresh copy of the data, preventing modifications from affecting subsequent benchmark runs.
  • CopyCoordinateRefTo(Span<Tester.Models.RefTypes.Coordinate> destination)
    Copies the internal reference-type coordinate buffer into the provided destination span. Throws if the destination is smaller than the source.
  • CopyCoordinateValTo(Span<Coordinate> destination)
    Copies the internal value-type coordinate buffer into the provided destination span. Throws if the destination is smaller than the source.
  • Dictionary<string, Person> GetPersonValDictionary()
    Gets clone of person value dictionary.
  • Dictionary<string, PersonRecord> GetPersonRecordDictionary()
    Gets a cloned dictionary for PersonRecord.
  • GetPersonRecordCollectionToInsert()
    Gets a collection of PersonRecord objects for insertion into collections.
  • GetPersonRefCollectionToInsert()
    Gets a collection of Person reference objects for insertion into collections.
  • GetPersonValCollectionToInsert()
    Gets a collection of Person value objects for insertion into collections.
  • LoadCoordinateCollections()
    Loads the coordinate collections into memory. This includes both a list and an array of Coordinate objects, populated to the maximum count specified for the benchmark. This method is called to prepare data for benchmark tests.
  • LoadPersonCollections()
    Loads the person collections into memory, including arrays, lists, and dictionaries for PersonRecord, Person reference types, and Person value types.
  • ReadOnlySpan<Coordinate> GetCoordinateRefReadOnlySpan()
     Returns a ReadOnlySpan over the internal reference-type coordinate array. The caller MUST NOT mutate the underlying collection; this provides allocation-free read access for hot-path benchmarks.
  • ReadOnlySpan<Coordinate> GetCoordinateValReadOnlySpan()
    Returns a ReadOnlySpan over the internal value-type coordinate array. The caller MUST NOT mutate the underlying collection; this provides allocation-free read access for hot-path benchmarks.
  • ReadOnlySpan<Person> GetPersonRefReadOnlySpan()
    Returns a read-only span over the internal Person reference-type list. This is allocation-free and intended for high-performance read-only access. The caller MUST NOT mutate the underlying list.
  • ReadOnlySpan<PersonRecord> GetPersonRecordReadOnlySpan()
    Returns a read-only span over the internal PersonRecord list. This is allocation-free and intended for high-performance read-only access. The caller MUST NOT mutate the underlying list.
  • ReadOnlySpan<Tester.Models.ValueTypes.Person> GetPersonValReadOnlySpan()
    Returns a read-only span over the internal Person value-type list. This is allocation-free and intended for high-performance read-only access. The caller MUST NOT mutate the underlying list.
  • ReloadCollections()
    Reloads the coordinate and person collections and refreshes the lookup values. This is useful for tests that need to repopulate the internal caches deterministically.
  • Setup()
    Sets up the benchmark instance. This method is called before the benchmark runs and is responsible for initializing the collections and loading the data.
  • TryFindPersonRefById(string id, out Person person)
    Attempts to find a Person reference with the specified id in the internal list. This is a convenience O(n) lookup. For large collections prefer building a dictionary via GetPersonRefDictionary().

All collection-producing methods return clones to prevent cross-test contamination.

Large, Small, and Tiny Collection Benchmarks

  • LargeCollectionBenchmark
    Benchmarks sizes from 64 to 8192 elements
  • SmallCounterBenchmark
    Benchmarks sizes from 16 to 2048 elements
  • TinyCollectionBenchmark
    Benchmarks sizes from 2 to 256 elements
    Ideal for string and micro-operation testing

Creating Benchmark Classes

For non-collection benchmarks:

public class GeneralBenchmark : Benchmark

Override setup and cleanup as needed:

public override void Setup()
{
    base.Setup();
    this._personRefList = this.GetPersonRefArray().ToList();
}

If benchmarking collections, inherit from one of the collection benchmark base classes and let Spargine handle the heavy lifting.

Running Benchmarks with BenchmarkHelper

BenchmarkHelper provides streamlined utility methods for running BenchmarkDotNet benchmarks with minimal boilerplate. It simplifies setup, execution, and reporting so you can focus on performance results—not plumbing.

Available Methods

  • PlayErrorBeep()
    Plays an error beep sequence using system beep tones. Produces two beeps to indicate an error, failure, or problematic benchmark result. The second beep uses a high frequency for an extended duration to create an attention-grabbing alert tone. The high frequency and long duration make this beep sequence distinctly different from the success beep, making it immediately recognizable as an error notification. Note: This method blocks the calling thread during beep playback and may not work in all environments (e.g., containerized applications or headless systems without audio hardware).
  • PlaySuccessBeep()
    Plays a success beep sequence using system beep tones. Produces four consecutive beeps with increasing frequency and duration to indicate successful completion of a benchmark or operation. The total duration of the sequence is approximately 6 seconds plus the default beep duration. Note: This method blocks the calling thread during beep playback and may not work in all environments (e.g., containerized applications or headless systems without audio hardware).
  • RunAllBenchmarks(IConfig config)
    Executes all benchmark classes discovered in the calling assembly using the specified configuration. This method is intended to be called from a benchmark project’s Program.cs file to execute every benchmark that derives from Benchmark in the calling assembly. It uses Assembly.GetCallingAssembly() to discover all non-abstract classes assignable to Benchmark and then delegates execution to the private Run(IConfig,bool,Type[],Assembly,string) helper, which handles validation, timing, logging, and optional result persistence.
  • RunBenchmarks(IConfig config, bool saveResults, params Type[] benchmarks)
    Executes only the specified benchmark classes using the provided configuration. Optionally saves a human-readable timing summary to the BenchmarkDotNet artifacts directory. This is intended for selectively running a subset of benchmarks from a given assembly. It should typically be called from a benchmark project’s Program.cs.

Insight into Benchmark Test Runs

Both RunAllBenchmarks() and RunBenchmarks() automatically filter and execute only classes that inherit from Benchmark. This ensures consistency and produces additional diagnostics for each run, including:

  • Execution time per benchmark class
  • Total number of benchmark tests executed
  • High-level run diagnostics for traceability

By leveraging the PerformanceStopwatch in Spargine, you also get a consolidated timing report similar to the following:

Performance Report
===============================
Total Time: 14 minutes 55 seconds

Laps:
Lap 0: 11 minutes 41 seconds
Lap 1: 14 minutes 55 seconds

Diagnostics:
2026-03-29 13:15:26Z: Found 2 benchmark classes to run.
2026-03-29 13:15:27Z: Starting benchmark: CharacterExtensionsBenchmark - Test Count: 12
2026-03-29 13:27:07Z: Benchmark completed: CharacterExtensionsBenchmark - Runtime: 00:11:40
2026-03-29 13:27:07Z: Starting benchmark: ExceptionExtensionsBenchmark - Test Count: 3
2026-03-29 13:30:21Z: Benchmark completed: ExceptionExtensionsBenchmark - Runtime: 00:03:13
2026-03-29 13:30:21Z: Total benchmark tests across all classes: 15

Saving and Using Results

  • The report is always saved when using RunAllBenchmarks().
  • When using RunBenchmarks(), saving the report is optional via the saveResults parameter.

These reports are more than just logs—they’re actionable data. I use them to:

  • Update benchmark summaries in class headers
  • Maintain accurate performance metrics in the Spargine README

Track performance changes over time

SummaryBenchmarking separates guesswork from truth.

If you care about performance, scalability, and cost—especially in cloud environments—benchmarking isn’t optional. It’s a requirement.

By combining BenchmarkDotNet with the DotNetTips.Spargine.Benchmarking assembly, you eliminate setup friction and gain real insight into how your code behaves under pressure.

Stop assuming your code is fast.
Measure it.

Stop guessing which approach is better.
Prove it.

Invest the time. Measure the truth. Ship faster, leaner, more scalable code.

Benchmark like dotNetDave—and take control of your .NET performance.

If you’d like to contribute, submit a pull request or open an issue.

If your team needs help with benchmarking or performance tuning, I’m available for contract work.

Email: dotnetdave@live.com

Happy benchmarking. Rock on.

Pick up any books by David McCarter by going to Amazon.com: http://bit.ly/RockYourCodeBooks

If you liked this article, please buy David a cup of Coffee by going here: https://www.buymeacoffee.com/dotnetdave

© The information in this article is copywritten and cannot be reproduced in any way without express permission from David McCarter.


Discover more from dotNetTips.com

Subscribe to get the latest posts sent to your email.