Measuring performance using BenchmarkDotNet - Part 2

tonycknight

Tony Knight

Posted on April 1, 2021

Measuring performance using BenchmarkDotNet - Part 2

Introduction

Previously we discussed what BenchmarkDotNet gives us and how to write simple benchmarks. As a quick reminder:

  • We use benchmarks to find code performance
  • BenchmarkDotNet is a nuget package
  • We use console apps to host and run benchmarks

So what's next to do? We need to run it and get benchmarks as easily and frequently as we can.


Running Benchmarks locally

We have a sample .Net core console application coded up and ready to go in Github:

GitHub logo NewDayTechnology / benchmarking-performance-part-2

A simple demonstration of BenchmarkDotNet

Build and run

Once you've cloned the repo, just run a dotnet publish from the local repository's root folder:

dotnet publish -c Release -o publish
Enter fullscreen mode Exit fullscreen mode

If you're unfamiliar with dotnet's CLI, dotnet publish will build and integrate the application, pushing the complete distributable application to the ./publish directory. You can read more here.

At this point, you've got a benchmarking console application in ./publish that's ready to use. Because I like my command line clean, I'm going to change the working folder:

cd publish
Enter fullscreen mode Exit fullscreen mode

...and we're almost ready to start.


Before you run, prepare your machine

Whenever you're measuring CPU performance you've got to be mindful of what else is running on your machine. Even with a 64 core beast your OS may interrupt the benchmark execution and skew results. That skew is not easy to measure or counter: it's best to assume that the interrupts and switches always happen.

Whenever you run final benchmarks make sure the absolute minimum software and applications are running. Before you start, close down all other applications before running your benchmarks. Browsers, chat, video, everything!

For now, don't close down everything: we're just exploring BenchmarkDotNet here and you need a browser open to read. But, when capturing real results always remember to run on idle machines.


And now to get some benchmarks

To run them all we need to:

dotnet ./benchmarkdotnetdemo.dll -f *
Enter fullscreen mode Exit fullscreen mode

-f * is a BenchmarkDotNet argument to selectively run benchmarks by their fully qualified namespace type. We've elected to select all of them with the wildcard *; if we want to run only selected benchmarks, I'd have to use -f benchmarkdotnetdemo.<pattern> as all these benchmarks fall in the benchmarkdotnetdemo namespace. For instance, -f benchmarkdotnetdemo.Simple* will run all the "Simple" benchmarks.

Each console application with BenchmarkDotNet has help automatically integrated. Just use --help as the arguments, and you will get a very comprehensive set of switches.

So now all we have to do is wait, and eventually your console will give you the good news:

// ***** BenchmarkRunner: End *****
// ** Remained 0 benchmark(s) to run **
Run time: 00:03:44 (224.56 sec), executed benchmarks: 3

Global total time: 00:08:03 (483.58 sec), executed benchmarks: 15
// * Artifacts cleanup *
Enter fullscreen mode Exit fullscreen mode

All good! The result files will have been pushed to the BenchmarkDotNet.Artifacts folder:

    Directory: C:\...\benchmarking-performance-part-2\publish\BenchmarkDotNet.Artifacts


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d-----          4/1/2021  11:50 AM                results
-a----          4/1/2021  11:50 AM         128042 BenchmarkRun-20210401-114253.log
Enter fullscreen mode Exit fullscreen mode

The .log file is simply the benchmark console echoed to file.

Within the /results directory you'll find the actual reports:

    Directory: C:\...\benchmarking-performance-part-2\publish\BenchmarkDotNet.Artifacts\results


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----          4/1/2021  11:47 AM         109014 benchmarkdotnetdemo.FibonacciBenchmark-measurements.csv
-a----          4/1/2021  11:47 AM         103104 benchmarkdotnetdemo.FibonacciBenchmark-report-full.json
-a----          4/1/2021  11:47 AM           3930 benchmarkdotnetdemo.FibonacciBenchmark-report-github.md
-a----          4/1/2021  11:47 AM           6632 benchmarkdotnetdemo.FibonacciBenchmark-report.csv
-a----          4/1/2021  11:47 AM           4484 benchmarkdotnetdemo.FibonacciBenchmark-report.html
-a----          4/1/2021  11:50 AM          83537 benchmarkdotnetdemo.SimpleBenchmark-measurements.csv
-a----          4/1/2021  11:50 AM          53879 benchmarkdotnetdemo.SimpleBenchmark-report-full.json
-a----          4/1/2021  11:50 AM           1215 benchmarkdotnetdemo.SimpleBenchmark-report-github.md
-a----          4/1/2021  11:50 AM           2119 benchmarkdotnetdemo.SimpleBenchmark-report.csv
-a----          4/1/2021  11:50 AM           1881 benchmarkdotnetdemo.SimpleBenchmark-report.html
Enter fullscreen mode Exit fullscreen mode

As you can see, it's a mix of CSV, HTML, markdown and pure JSON ready for publication and reading.

These formats are determined by either the benchmark code or the runtime arguments. I've included them all in the demo repo to give a feel of what's on offer.


Interpreting the results

We've previously discussed the various reports' contents. But suffice to say BenchmarkDotNet runs & reports benchmarks but does not evaluate them.

Evaluating these benchmarks and acting on them is a fairly complex problem: what analysis method to use? How do we run and capture results? Can we use benchmarks as a PR gateway? This will be the subject of a future post.

But before we run, we'd would like benchmarks running on all git pushes, right?


Running benchmarks in CI

Let's implement the simplest possible approach:

  • build benchmarks
  • run them
  • capture the report files
  • present for manual inspection

In short, benchmarks are built, run, and the results published as workflow artifacts. Anyone with access can download these artifacts.

Because our repo is in Github, and we want to show this in-the-flesh we'll be using Github Actions.

One day, Github Actions will support deep artifact linking and one-click reports, just like Jenkins and TeamCity have provided for years. But until that day dawns the tedium of download-extract-search is our lot :(

Here's a super-simple Github Actions workflow:

If you're unfamiliar with Action workflows, one of the best hands-on introductions is from Pete King.

This workflow file is in the sample Github repository, under ./.github/workflows/dotnet.yml.

Looking at the workflow, let's skip past the job's build steps as they're self explanatory.


Publish

- name: Publish
      run: dotnet publish -c Release --verbosity normal -o ./publish/
Enter fullscreen mode Exit fullscreen mode

Here we prepare a fully publishable .Net core application.
We always need to build with Release configuration: BenchmarkDotNet will not adequately run without normal compiler optimisations. The application with its dependencies, including the code-under-test, is pushed to a ./publish/ directory within the job.

One glorious day, both Windows and linux will finally and completely converge on a single standard for directory path separators. Until that time, please be careful if you're writing these workflows on Windows!


Archive

- name: Archive 
      uses: actions/upload-artifact@v2
      with:
        name: benchmarkdotnetdemo
        path: ./publish/*
Enter fullscreen mode Exit fullscreen mode

We're just arching the binaries here, in case we want to distribute and run locally.


Run Benchmarks

- name: Run Benchmarks    
      run: dotnet "./publish/benchmarkdotnetdemo.dll" -f "benchmarkdotnetdemo.*"
Enter fullscreen mode Exit fullscreen mode

This is where we run the benchmarks.

As of now there are no Github Actions to support benchmark running, so all we do here is run the console application itself within the Github Actions job.

We're running all benchmarks in the benchmarkdotnetdemo namespace, and we expect the results to be pushed to the same working folder.

Note the double quotes! In windows you won't need to quote these arguments, but you will need to for Github Actions. If you don't, you'll see strange command line parsing errors.

Previously I remarked that you should only run benchmarks on an idle machine. Here we'll be running these on virtualised hardware, where OS interrupts are an absolutely unavoidable fact of life. Clearly we're trading precision for convenience here, and the code-under-test is simple enough not to worry too much about single-tick precision metrics.


Upload benchmark results

- name: Upload benchmark results
      uses: actions/upload-artifact@v2
      with:
        name: Benchmark_Results
        path: ./BenchmarkDotNet.Artifacts/results/*
Enter fullscreen mode Exit fullscreen mode

This is where we present the results for inspection.

We just zip up the benchmark result files into a single artifact called Benchmark_Results.


And lastly...

That's it! Every time you push changes to this solution, benchmarks will be run. Performance degradations won't fail the build as we're not analysing the results, and we're certainly not applying quality gates in this solution. But you've got the minimum useful visibility, albeit very simply:

GHA-build-results


What have we learned?

Running benchmarks is very simple on face value, but there are considerations when doing so: you don't want to run while you're rendering videos!

Incorporating benchmark reporting into a CI pipeline is straight forward, although the lack of build reporting in Github Actions is a disappointment.

We've yet to act on those benchmarks' results. For instance, we don't yet fail the build if our code-under-test is underperforming.


Up next

How to fail the build if your code's underperforming.


Further Reading

💖 💪 🙅 🚩
tonycknight
Tony Knight

Posted on April 1, 2021

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

Sign up to receive the latest update from our blog.

Related