<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>NBenchmark Blog</title>
    <link>https://nbenchmark.net/blog</link>
    <description>Deep dives into NBenchmark - adaptive measurement, honest statistics, outlier handling, and the traps that make benchmarking .NET harder than it looks.</description>
    <language>en-us</language>
    <lastBuildDate>Tue, 04 Aug 2026 00:00:00 GMT</lastBuildDate>
    <atom:link href="https://nbenchmark.net/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>The engine, opened up</title>
      <link>https://nbenchmark.net/blog/the-engine-opened-up</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/the-engine-opened-up</guid>
      <pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate>
      <description>A deep dive into the NBenchmark measurement engine, from the worker protocol and clock probing to optional-stopping correction.</description>
      <category>Under the hood</category>
      <content:encoded><![CDATA[<p>We&#39;ve spent the last twenty-one posts learning how to use NBenchmark to get honest numbers. We&#39;ve discussed isolation, sampling, and environmental noise. But for a number to be truly defensible, it cannot be a &quot;black box.&quot; It must be based on a transparent, verifiable process.</p>
<p>In this final post, we&#39;re opening the hood. We&#39;ll explore the mechanics of the worker protocol and the mathematical corrections the engine applies to turn a raw stopwatch reading into a statistical fact.</p>
<h2>The Worker Protocol: Orchestrating Isolation</h2>
<p>As we established in post 18, the only way to ensure a clean measurement is to move the work to a separate process. But a separate process is, by definition, a separate memory space. The &quot;Worker Protocol&quot; is the bridge that makes this possible.</p>
<h3>Discovery and Addressing</h3>
<p>When you call <code>Run()</code>, the harness doesn&#39;t just start a process; it establishes a high-speed communication channel. The harness acts as the orchestrator, while the worker acts as the executor. The protocol handles the discovery of the worker&#39;s address and the negotiation of the measurement budget.</p>
<h3>The Wire Format and State Transfer</h3>
<p>To minimize the impact of the communication itself, the protocol uses a compact binary wire format. When the engine moves a <code>prepare</code> block or a <code>[BenchmarkPlan]</code> to the worker, it doesn&#39;t just send code; it transfers the captured state. </p>
<p>The protocol enforces a strict <code>SampleReservoir</code> cap. The worker collects thousands of samples, but it only sends the statistically significant subset back to the harness. This prevents the communication channel from becoming a bottleneck and ensures that the harness doesn&#39;t run out of memory when analyzing massive suites.</p>
<h2>The Clock: Probing for Truth</h2>
<p>Every system clock is a liar. Some have high resolution but drift over time; others are stable but &quot;quantized,&quot; meaning they only tick in large jumps.</p>
<p>NBenchmark does not assume the clock is perfect. Before a single sample is taken, the engine performs <strong>clock probing</strong>. It runs a series of empty loops to determine the minimum detectable increment of the system timer.</p>
<p>If the probe reveals that the clock is too coarse for the operation being measured, the engine automatically switches strategies. This is where the &quot;Ops-per-Sample&quot; (K) calibration from post 10 comes from. The engine calculates the exact K-factor needed to push the measurement window above the clock&#39;s resolution floor, ensuring that the resulting number is a measurement, not a guess.</p>
<h2>Jitter Calibration and the Detector Auto-Switch</h2>
<p>Beyond resolution is the problem of jitter - the tiny, high-frequency fluctuations in timing that happen even on a perfectly stable machine.</p>
<p>The engine uses a jitter calibration phase to establish the &quot;noise floor&quot; of the current environment. It then employs a detector auto-switch. Depending on the distribution of the jitter, the engine chooses between different outlier detection algorithms. </p>
<p>If the jitter is Gaussian (normal), the engine uses standard deviation-based trimming. If the jitter is skewed or heavy-tailed, it switches to the IQR (Interquartile Range) fence. This ensures that the engine isn&#39;t accidentally trimming real performance data or leaving in systemic noise.</p>
<h2>The Host Drift Canary</h2>
<p>Even a perfectly calibrated run can be ruined by a &quot;drift event&quot; - a sudden change in the host&#39;s environment, such as a CPU frequency shift or a background system task.</p>
<p>To detect this, NBenchmark runs a <strong>drift canary</strong>. This is a hidden, ultra-stable benchmark that runs periodically alongside your actual code. The canary&#39;s timing is known and constant. If the canary&#39;s result shifts beyond the <code>--drift-tolerance</code>, the engine knows that the host is no longer stable. </p>
<p>Rather than reporting a corrupted number, the engine triggers a restart. It flushes the current samples and starts the run over. It is better to take an extra ten seconds to restart than to report a &quot;confident&quot; number that was skewed by a background process.</p>
<h2>Optional Stopping and the Unbiasing Correction</h2>
<p>One of the most subtle challenges in statistics is the &quot;Optional Stopping&quot; problem. </p>
<p>In NBenchmark, we sample adaptively: we keep taking samples until the confidence interval is tight enough to stop. However, in statistics, stopping a test precisely when it hits a target can introduce a subtle bias. You are more likely to stop a run when a &quot;lucky&quot; string of fast samples narrows the interval, potentially underestimating the true mean.</p>
<p>To solve this, NBenchmark applies an <strong>unbiasing correction</strong>. It uses a mathematical adjustment based on the total number of samples and the achieved interval width to remove this bias. This ensures that the reported mean is an unbiased estimate of the population, not just the result of a lucky stopping point.</p>
<h2>Validation: Pinned against the Giants</h2>
<p>Math is only useful if it&#39;s correct. To ensure that NBenchmark&#39;s statistical implementations - from the Mann-Whitney U test to the IQR fence - are accurate, they are not developed in a vacuum.</p>
<p>Every core numerical function in the engine is pinned against <strong>SciPy 1.17.1</strong> and <strong>NumPy 2.4.6</strong>. We run the same raw sample sets through both NBenchmark and the industry-standard Python libraries. If the results diverge by more than a specified tolerance (typically $10^{-9}$), the build fails.</p>
<p>When you see a <code>Sig</code> checkmark or a <code>Median</code> in an NBenchmark report, you aren&#39;t trusting a custom implementation - you are trusting math that has been verified against the same libraries used by data scientists worldwide.</p>
<h2>The Final Word</h2>
<p>Benchmarking is a battle against noise. From the process boundary and the clock probe to the drift canary and the unbiasing correction, every feature in NBenchmark exists to remove one specific way that a number can lie to you.</p>
<p>By combining rigorous isolation with verified statistics, NBenchmark turns the &quot;vibe&quot; of performance into an engineering discipline. You no longer have to guess if your code is fast; you can prove it.</p>
<hr>
<p><strong>Go deeper:</strong> You&#39;ve reached the end of the series. If you want to dive even deeper into the internals, the full technical specifications for the worker protocol and the measurement engine are available in the <a href="#">Deep Dives section of the docs</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>When the built-ins are not enough</title>
      <link>https://nbenchmark.net/blog/when-the-built-ins-are-not-enough</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/when-the-built-ins-are-not-enough</guid>
      <pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate>
      <description>Extending NBenchmark with custom reporters, outlier detectors, and OpenTelemetry integration for advanced observability.</description>
      <category>Under the hood</category>
      <content:encoded><![CDATA[<p>For most engineers, a Markdown table in a PR is the end of the road. But as your performance needs grow, you&#39;ll hit a wall where the built-in tools aren&#39;t enough. </p>
<p>Maybe you need to stream your benchmark results into a corporate Grafana dashboard. Maybe you&#39;re working with specialized hardware that produces a very specific type of timing outlier that the IQR fence doesn&#39;t catch. Or maybe you want to correlate your benchmark samples with BCL instrumentation to see exactly which internal method is stealing your cycles.</p>
<p>NBenchmark is designed as a platform. Through a set of extension points, you can customize how results are reported, how outliers are detected, and how samples are observed in real-time.</p>
<h2>Custom Reporters</h2>
<p>If the standard formats don&#39;t fit your needs, you can implement your own <code>IReporter</code>. A custom reporter is responsible for taking the <code>BenchmarkResult</code> and turning it into your desired output.</p>
<p>The key to building a great reporter is the <code>BenchmarkTable.Build</code> utility. You don&#39;t need to re-derive the complex logic for ratios and significance tests; <code>BenchmarkTable</code> does that for you. Your reporter simply decides how to render that table.</p>
<p>To make your reporter discoverable via the <code>--reporter</code> CLI flag, you register it with the <code>ReporterRegistry</code> using a <code>[ModuleInitializer]</code>.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">class</span><span class="tok"> </span><span class="tok-type">MyCorporateReporter</span><span class="tok"> </span><span class="tok-punct">:</span><span class="tok"> </span><span class="tok-type">IReporter</span>
</span><span class="md-code-line"><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">Report</span><span class="tok-punct">(</span><span class="tok-type">BenchmarkResult</span><span class="tok"> result</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-type">ReportDetailLevel</span><span class="tok"> detail</span><span class="tok-punct">)</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-keyword">var</span><span class="tok"> table </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-type">BenchmarkTable</span><span class="tok-punct">.</span><span class="tok-method">Build</span><span class="tok-punct">(</span><span class="tok">result</span><span class="tok-punct">);</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-comment">// Send table to corporate API...</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">}</span>
</span><span class="md-code-line"><span class="tok-punct">}</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">static</span><span class="tok"> </span><span class="tok-keyword">class</span><span class="tok"> </span><span class="tok-type">ReporterExtensions</span>
</span><span class="md-code-line"><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[ModuleInitializer</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">static</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">Initialize</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-type">ReporterRegistry</span><span class="tok-punct">.</span><span class="tok-method">Register</span><span class="tok-punct">(</span><span class="tok-string">&quot;corp&quot;</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">MyCorporateReporter</span><span class="tok-punct">());</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">}</span>
</span><span class="md-code-line"><span class="tok-punct">}</span>
</span></code></pre><h2>Specialized Outlier Detection</h2>
<p>The default IQR (Interquartile Range) fence is a great general-purpose tool, but it isn&#39;t perfect. If you&#39;re dealing with bimodal distributions or hardware-specific jitters, you can implement a custom <code>IOutlierDetector</code>.</p>
<p>By using <code>WithOutlierDetector</code>, you can define a rule that examines the entire sample set and decides exactly which timings are &quot;noise&quot; and which are &quot;signal.&quot; This is essential for tail-latency analysis, where a single unexpected spike might be a critical bug rather than an OS interrupt.</p>
<h2>Real-time Observability with Observers</h2>
<p>Sometimes you need to know what&#39;s happening <em>during</em> the run, not just after it. <code>IMeasurementObserver</code> allows you to hook into the engine&#39;s lifecycle.</p>
<p>Observers provide events for:</p>
<ul>
<li><strong>OnPhase:</strong> When the engine moves from warmup to sampling.</li>
<li><strong>OnSample:</strong> Every time a new K-batch is completed.</li>
<li><strong>OnDetector:</strong> When the outlier detector trims a sample.</li>
<li><strong>OnResult:</strong> When the final statistics are calculated.</li>
</ul>
<p>There is one strict rule for observers: <strong>they must be invisible.</strong> They should return immediately, never allocate on the hot path, and never throw. If an observer adds 10ns of overhead to a 50ns operation, it has just invalidated the benchmark.</p>
<h2>BCL Instrumentation and OpenTelemetry</h2>
<p>The most powerful way to observe a benchmark is to use the BCL&#39;s own instrumentation. NBenchmark provides a set of <code>nbenchmark.*</code> instruments and a span hierarchy that allow you to track execution without adding manual timing code to your body.</p>
<p>For those who need enterprise-grade observability, the <code>NBenchmark.Exporters.OpenTelemetry</code> package allows you to stream every single sample as an OTLP signal. </p>
<p>By using <code>WithOpenTelemetry</code>, your benchmarks stop being static reports and start being live telemetry. You can see your samples move in real-time in Jaeger or Honeycomb, allowing you to correlate a timing spike with a specific internal event or resource contention.</p>
<hr>
<p><strong>Go deeper:</strong> We&#39;ve covered the extensions, the hardware, and the runtimes. To close the series, we&#39;re going to open the engine itself and look at the math and the protocols that make an honest number possible.</p>
<p><a href="https://nbenchmark.net/blog/the-engine-opened-up">The engine, opened up →</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>One codebase, three runtimes</title>
      <link>https://nbenchmark.net/blog/one-codebase-three-runtimes</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/one-codebase-three-runtimes</guid>
      <pubDate>Wed, 29 Jul 2026 00:00:00 GMT</pubDate>
      <description>Comparing performance across .NET versions to make data-driven upgrade decisions.</description>
      <category>Under the hood</category>
      <content:encoded><![CDATA[<p>&quot;Will upgrading to .NET 10 actually make our code faster?&quot;</p>
<p>For most teams, the answer to this question is a guess based on a blog post or a release note. But for a performance-critical application, &quot;it should be faster&quot; is not a technical justification for an upgrade. You need an empirical answer.</p>
<p>Multi-runtime support allows you to run the exact same benchmark suite across multiple target frameworks - such as <code>.net8.0</code>, <code>.net9.0</code>, and <code>.net10.0</code> - and compare the results side-by-side.</p>
<h2>Setting up a multi-runtime project</h2>
<p>To compare runtimes, you first need a project that can target all of them. In your <code>.csproj</code> file, replace the single <code>&lt;TargetFramework&gt;</code> tag with <code>&lt;TargetFrameworks&gt;</code>, listing the versions you want to compare:</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-punct">&lt;</span><span class="tok-keyword">PropertyGroup</span><span class="tok-punct">&gt;</span>
</span><span class="md-code-line"><span class="tok">  </span><span class="tok-punct">&lt;</span><span class="tok-keyword">TargetFrameworks</span><span class="tok-punct">&gt;</span><span class="tok">net8.0;net9.0;net10.0</span><span class="tok-punct">&lt;/</span><span class="tok-keyword">TargetFrameworks</span><span class="tok-punct">&gt;</span>
</span><span class="md-code-line"><span class="tok-punct">&lt;/</span><span class="tok-keyword">PropertyGroup</span><span class="tok-punct">&gt;</span>
</span></code></pre><p>Once your project is multi-targeted, NBenchmark can orchestrate the execution across the different installed runtimes on your machine.</p>
<h2>Configuring the runtime sweep</h2>
<p>Depending on your usage mode, there are different ways to trigger a runtime comparison.</p>
<h3>In Harness mode</h3>
<p>The simplest way is via the CLI. You can specify the runtimes you want to target using the <code>--runtimes</code> flag:</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-method">dotnet</span><span class="tok"> benchmark </span><span class="tok-attr">--runtimes</span><span class="tok"> net8</span><span class="tok-punct">,</span><span class="tok">net9</span><span class="tok-punct">,</span><span class="tok">net10</span>
</span></code></pre><p>Alternatively, you can use the <code>[Runtimes]</code> attribute on your benchmark class to pin the target versions directly in the code.</p>
<h3>In Suite mode</h3>
<p>When building a suite programmatically, you use the <code>WithRuntimes(...)</code> method. However, because each runtime is its own process by definition, you cannot use a simple inline suite. Instead, you must use a <code>[BenchmarkPlan]</code> factory and <code>RunPlanAsync</code>.</p>
<p>This ensures that the engine can spawn a worker process for each specified runtime, apply the correct <code>RuntimeProfile</code>, and collect the results in a unified report.</p>
<h2>Reading the cross-runtime report</h2>
<p>The output of a multi-runtime run is grouped by runtime. You will see a separate results table for <code>.net8.0</code>, <code>.net9.0</code>, and <code>.net10.0</code>.</p>
<p>The most important rule of cross-runtime reporting is this: <strong>significance and ratios are only calculated within a single runtime.</strong></p>
<p>You will see a <code>Sig</code> column for the comparisons within <code>.net9.0</code>, but you will never see a significance test that spans from <code>.net8.0</code> to <code>.net9.0</code>. Why? Because the baseline shifted. The hardware might be the same, but the JIT compiler, the GC implementation, and the BCL internals have changed. Comparing a median from one runtime to another is like comparing temperatures in Celsius and Fahrenheit - the numbers are different, but they are measuring different things.</p>
<p>To make a decision, you look at the <strong>Relative Magnitude</strong>. If Implementation A is 20% faster than B in <code>.net8.0</code>, but only 5% faster in <code>.net10.0</code>, the new runtime has effectively erased your optimization.</p>
<h2>A data-driven upgrade workflow</h2>
<p>The most effective way to use multi-runtime support is as a three-step workflow:</p>
<ol>
<li><strong>Pin the Suite:</strong> Select a representative set of benchmarks that cover your most critical hot paths.</li>
<li><strong>Sweep Runtimes:</strong> Run the suite across your current version and the target upgrade version.</li>
<li><strong>Analyze the Delta:</strong> Use the <code>Sig</code> and <code>Magnitude</code> columns to identify where the new runtime provides a win and where it introduces a regression.</li>
</ol>
<p>This turns the upgrade conversation from &quot;should we?&quot; to &quot;here is exactly how much faster we will be.&quot;</p>
<hr>
<p><strong>Go deeper:</strong> Most of the time, the built-in reporters and statistical tests are enough. But what if you need to stream your results to OpenTelemetry or implement a custom outlier detector for a specific hardware glitch?</p>
<p><a href="https://nbenchmark.net/blog/when-the-built-ins-are-not-enough">When the built-ins are not enough →</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>State that survives between benchmarks</title>
      <link>https://nbenchmark.net/blog/state-that-survives-between-benchmarks</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/state-that-survives-between-benchmarks</guid>
      <pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate>
      <description>Managing instance lifetimes and shared state to prevent contamination and ensure statistically valid results.</description>
      <category>Under the hood</category>
      <content:encoded><![CDATA[<p>The most dangerous bug in a benchmark is not a crash; it&#39;s a cache.</p>
<p>Imagine you have two benchmarks: one that populates a static cache and another that reads from it. If you run them in that order, the second benchmark will report a blazing fast execution time. If you run them in reverse, it will be significantly slower. </p>
<p>This is <strong>state contamination</strong>. When one benchmark alters the environment in a way that benefits the next, your results are no longer independent. Your significance tests, which rely on the assumption that every sample is an independent event, are now lying to you. They will report &quot;statistically significant&quot; differences that are actually just the side effects of a shared cache.</p>
<h2>Understanding instance lifetimes</h2>
<p>NBenchmark gives you precise control over how your benchmark classes are instantiated. This allows you to balance the cost of construction against the need for isolation.</p>
<h3>Per-Method (The Default)</h3>
<p>By default, NBenchmark creates a fresh instance of your benchmark class for every single method. If you have three <code>[Benchmark]</code> methods in a class, the engine instantiates the class three times. This is the safest approach, as it ensures that instance fields are reset between benchmarks.</p>
<h3>Per-Class</h3>
<p>When construction is expensive - for example, when you need to establish a database connection or load a 1GB dataset into memory - you can use <code>[InstanceLifetime(InstanceLifetime.PerClass)]</code>. </p>
<p>This tells the engine to reuse the same instance for every benchmark method in the class. This amortizes the construction cost, but it opens the door to contamination. If Method A modifies a private field that Method B relies on, you have introduced a hidden dependency.</p>
<h2>Remedies for shared state</h2>
<p>If you must use <code>PerClass</code> for performance reasons, you need a way to scrub the state between runs.</p>
<h3>IStateReset</h3>
<p>The most robust solution is the <code>IStateReset</code> interface. By implementing <code>ResetAsync</code>, you can define exactly how to return your class to a clean state.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">class</span><span class="tok"> </span><span class="tok-type">DatabaseBenchmark</span><span class="tok"> </span><span class="tok-punct">:</span><span class="tok"> </span><span class="tok-type">IStateReset</span>
</span><span class="md-code-line"><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">private</span><span class="tok"> </span><span class="tok-keyword">readonly</span><span class="tok"> </span><span class="tok-type">MyDbContext</span><span class="tok"> _context </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok-punct">();</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">QueryData</span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> _context</span><span class="tok-punct">.</span><span class="tok-type">Users</span><span class="tok-punct">.</span><span class="tok-method">ToList</span><span class="tok-punct">();</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">async</span><span class="tok"> </span><span class="tok-type">Task</span><span class="tok"> </span><span class="tok-method">ResetAsync</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-comment">// Clear the cache or truncate tables between benchmarks</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-keyword">await</span><span class="tok"> _context</span><span class="tok-punct">.</span><span class="tok-type">Database</span><span class="tok-punct">.</span><span class="tok-method">EnsureDeletedAsync</span><span class="tok-punct">();</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-keyword">await</span><span class="tok"> _context</span><span class="tok-punct">.</span><span class="tok-type">Database</span><span class="tok-punct">.</span><span class="tok-method">EnsureCreatedAsync</span><span class="tok-punct">();</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">}</span>
</span><span class="md-code-line"><span class="tok-punct">}</span>
</span></code></pre><p>NBenchmark calls <code>ResetAsync</code> between benchmarks, immediately after the inter-benchmark Garbage Collection. This ensures that each method starts with a predictable state without the overhead of full class re-instantiation.</p>
<h3>Explicit Declarations</h3>
<p>If the carry-over is deliberate - for example, if you are specifically measuring how a cache performs over time - you can mark the state with <code>[SharedState]</code>. This tells the engine (and other developers) that the contamination is an intended part of the measurement.</p>
<h2>Integrating Dependency Injection</h2>
<p>In real-world applications, your code doesn&#39;t just use <code>new()</code>; it uses a dependency injection (DI) container. NBenchmark supports this via the <code>NBenchmark.DependencyInjection</code> package.</p>
<p>Instead of a parameterless constructor, you can use <code>WithServices</code> or <code>WithScopedServices</code> to provide a <code>IServiceProvider</code> to the worker process. This allows your benchmarks to resolve real services, configuration, and logging exactly as they would in production.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">var</span><span class="tok"> services </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">ServiceCollection</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">AddSingleton</span><span class="tok-punct">&lt;</span><span class="tok-type">ICache</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-type">RedisCache</span><span class="tok-punct">&gt;()</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">BuildServiceProvider</span><span class="tok-punct">();</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">var</span><span class="tok"> suite </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">BenchmarkSuite</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(</span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">CacheBenchmark</span><span class="tok-punct">())</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">WithServices</span><span class="tok-punct">(</span><span class="tok">services</span><span class="tok-punct">);</span>
</span></code></pre><h2>The ASP.NET and EF Pitfall</h2>
<p>A common mistake when combining DI and <code>PerClass</code> lifetimes is the &quot;Scoped Service Trap.&quot;</p>
<p>If you resolve a scoped service (like an Entity Framework <code>DbContext</code>) in the constructor of a <code>PerClass</code> benchmark, that context lives for the entire duration of the suite. In a real application, a <code>DbContext</code> is short-lived. By keeping it alive across multiple benchmarks, you are measuring a bloated, long-lived context that behaves differently than a production request.</p>
<p>NBenchmark&#39;s analyzers catch this at compile time. <strong>NB0011</strong> warns you when a scoped service is used under a <code>PerClass</code> lifetime, and <strong>NB0013</strong> flags mutable instance fields that could lead to contamination. These warnings are your first line of defense against &quot;confident nonsense.&quot;</p>
<p>By managing your lifetimes and scrubbing your state, you ensure that your results are a product of your code&#39;s efficiency, not the order in which you wrote your methods.</p>
<hr>
<p><strong>Go deeper:</strong> Now that we&#39;ve secured the state within a single runtime, let&#39;s look at the bigger picture. What happens when you need to know if a new version of the .NET runtime itself provides a performance win?</p>
<p><a href="https://nbenchmark.net/blog/one-codebase-three-runtimes">One codebase, three runtimes →</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Why your numbers come from another process</title>
      <link>https://nbenchmark.net/blog/why-your-numbers-come-from-another-process</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/why-your-numbers-come-from-another-process</guid>
      <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
      <description>An exploration of process isolation, runtime profiles, and why measuring in-process is a recipe for misleading results.</description>
      <category>Under the hood</category>
      <content:encoded><![CDATA[<p>If you&#39;ve ever wondered why NBenchmark doesn&#39;t just run your code in a simple loop inside your app, the answer is a single word: <strong>Isolation</strong>.</p>
<p>In .NET, the most important decisions that shape your performance aren&#39;t made while your code is running - they are made while the process is starting. JIT tiering, Profile-Guided Optimization (PGO), ReadyToRun (R2R) state, and the Garbage Collection flavor are all decided at the process boundary. If you measure your code in the same process that launched the benchmark, you aren&#39;t measuring your code; you&#39;re measuring the baggage of the harness.</p>
<h2>The 21x Demonstration</h2>
<p>To see why this matters, consider a simple, tight loop. When measured in a dedicated, isolated worker process, the results are stable and fast: 320ns, 322ns, 329ns.</p>
<p>Now, run the same code using the <code>--in-process</code> flag. The results shift dramatically: 7,009ns, 6,733ns... and then, on the third attempt, 329ns.</p>
<p>What happened? In the first two attempts, the JIT compiler was still in &quot;Tier 0&quot; (the fast-to-compile, slow-to-run version). By the third attempt, the JIT finally promoted the method to &quot;Tier 1&quot; (optimized). Because the confidence interval stayed tight during the slow runs, a naive benchmark would tell you that the code is 21 times slower than it actually is, and it would do so with absolute confidence.</p>
<p>By spawning a fresh worker process for every measurement, NBenchmark ensures that JIT state and heap contamination from previous runs cannot leak into your current sample.</p>
<h2>Runtime Profiles: Tuning the Machine</h2>
<p>Isolation isn&#39;t just about cleaning the slate; it&#39;s about choosing the right slate. NBenchmark uses <code>RuntimeProfile</code> to control exactly how the worker process is configured.</p>
<h3>SteadyState (The Default)</h3>
<p>The <code>SteadyState</code> profile is designed for pure throughput measurement. It turns off JIT tiering, PGO, and R2R, and disables background GC. This removes the non-deterministic &quot;noise&quot; of the runtime, giving you a number that represents the fully-optimized steady state of your algorithm.</p>
<h3>Production</h3>
<p>The <code>Production</code> profile reproduces a shipping configuration. It leaves tiering and background GC on. This is deliberately more imprecise, but it tells you how the code will actually behave in a live environment.</p>
<h3>ServerGc</h3>
<p>This profile forces the worker to start with Server GC enabled, regardless of the host&#39;s settings. This is critical for server-side libraries where the GC behavior differs fundamentally from a client-side console app.</p>
<h3>Host</h3>
<p>The <code>Host</code> profile inherits whatever settings the harness process is currently using.</p>
<p>Because these knobs cannot be applied to a process that is already running, an in-process run silently gets none of them. This is why results from different runtime profiles are never placed in the same comparison group; a &quot;SteadyState&quot; number and a &quot;Production&quot; number are fundamentally different measurements.</p>
<h2>Crossing the Wire</h2>
<p>Since the code runs in a separate process, NBenchmark has to move data across a process boundary.</p>
<p>When you use a <code>prepare</code> block in <code>Benchmark.Run</code>, that code executes inside the worker process. This is why the <code>prepare</code>/<code>body</code> split is so critical: you build your state in the worker, on the worker&#39;s heap, using the worker&#39;s JIT state.</p>
<p>This boundary is also why the NBenchmark analyzer NB0014 warns you when a lambda captures local state. That state would have to be serialized and moved across the process boundary, which can introduce hidden costs and change the measurement.</p>
<h3>Advanced State Management</h3>
<p>For complex scenarios, NBenchmark provides two escape hatches:</p>
<ul>
<li><code>[BenchmarkState]</code>: Used for state that is explicitly designed to cross the boundary.</li>
<li><code>[BenchmarkPlan]</code>: Used for suites that must build their own fixtures (like custom containers or live databases) directly inside the worker process via <code>RunPlanAsync</code>.</li>
</ul>
<h2>When to Opt Out</h2>
<p>Despite the benefits, there are times when isolation is too expensive. You can disable it using <code>[Isolation(Isolation.Off)]</code>, <code>WithIsolation(...)</code>, or the <code>--in-process</code> flag.</p>
<p>In-process runs are excellent for &quot;smoke tests&quot; - verifying that your benchmark compiles and runs without spending the time to spawn workers. But for any result you intend to publish or use as a regression gate, isolation is non-negotiable.</p>
<p>If you&#39;re still not convinced, try the <code>--verify-isolation</code> flag. The engine will run the same benchmark isolated and in-process, then show you the divergence table. Usually, the 21x gap is enough to convince anyone.</p>
<hr>
<p><strong>Go deeper:</strong> Now that we&#39;ve seen how a fresh process protects your numbers, let&#39;s look at the one thing that <em>can</em> survive between benchmarks: state.</p>
<p><a href="https://nbenchmark.net/blog/state-that-survives-between-benchmarks">State that survives between benchmarks →</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>When the numbers look wrong</title>
      <link>https://nbenchmark.net/blog/when-the-numbers-look-wrong</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/when-the-numbers-look-wrong</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate>
      <description>A field guide to diagnosing impossible benchmark results, from clock resolution issues to state contamination.</description>
      <category>Running it for real</category>
      <content:encoded><![CDATA[<p>You&#39;ve done everything by the book. You used a dedicated worker process, you pinned your CPU affinity, and you ran five launches. But when the report prints, the numbers look impossible.</p>
<p>Maybe the median is exactly 0 ns. Maybe the error margin is so wide that the result is useless. Or maybe the numbers change every time you shuffle the order of your benchmarks.</p>
<p>When the output looks wrong, the engine is usually trying to tell you something. This is the field guide to the nine most common symptoms in NBenchmark and how to fix them.</p>
<h2>Symptom 1: <code>MarginOfError ±0</code></h2>
<p><strong>The Problem:</strong> Your result is too precise to be real. The median and mean are identical, and the error margin is zero.
<strong>The Cause:</strong> The &quot;Clock Resolution Problem.&quot; Your operation is finishing faster than the system clock can tick. If a method takes 5ns but the clock only updates every 100ns, the engine just sees a sequence of zeros.
<strong>The Fix:</strong> Increase the batch size. Use <code>--ops-per-sample &lt;n&gt;</code> (or <code>WithOpsPerSample</code>) to run more iterations per sample. This pushes the total sample time above the clock&#39;s resolution floor.</p>
<h2>Symptom 2: The Interval Won&#39;t Narrow</h2>
<p><strong>The Problem:</strong> You&#39;ve increased your sample count from 100 to 10,000, but the Error margin remains stubbornly wide.
<strong>The Cause:</strong> Environmental interference. Adding more samples only helps if the noise is random. If the OS is preempting your thread every few milliseconds, you aren&#39;t measuring a distribution; you&#39;re measuring the OS scheduler.
<strong>The Fix:</strong> Check your host. Use <code>--host-quality-warnings</code> to see if the machine is overloaded. Close background apps or move the run to a dedicated performance server.</p>
<h2>Symptom 3: The Bimodal Warning</h2>
<p><strong>The Problem:</strong> The report contains a warning about a &quot;bimodal distribution&quot; or a &quot;split cluster.&quot;
<strong>The Cause:</strong> You aren&#39;t measuring one code path; you&#39;re measuring two. This typically happens when a method has a &quot;cold&quot; path (e.g., first-time cache miss) and a &quot;warm&quot; path. The engine sees two distinct clusters of timings and warns you that a simple median is misleading.
<strong>The Fix:</strong> Separate the paths. Create two different benchmarks - one for the cold start and one for the steady state - to see exactly where the divergence happens.</p>
<h2>Symptom 4: Order-Dependent Results</h2>
<p><strong>The Problem:</strong> Benchmark A is fast when it runs first, but slow when it runs after Benchmark B.
<strong>The Cause:</strong> State contamination. Benchmark B is likely filling a cache, allocating a massive amount of memory, or changing a static flag that affects Benchmark A.
<strong>The Fix:</strong> Enforce isolation. Ensure your benchmarks are not sharing mutable state. If you are using <code>InstanceLifetime.PerClass</code>, implement <code>IStateReset</code> to scrub the state between runs.</p>
<h2>Symptom 5: The 20x Stability Gap</h2>
<p><strong>The Problem:</strong> The results are perfectly stable (tiny error margin), but the number is 20 times slower than you expect.
<strong>The Cause:</strong> You are measuring in-process. When you use <code>--in-process</code>, you bypass the worker process and the <code>RuntimeProfile</code> optimizations. You are measuring the code with JIT tiering, background GC, and other &quot;production&quot; noise that the engine usually strips away.
<strong>The Fix:</strong> Remove the <code>--in-process</code> flag. Let the engine spawn a dedicated worker process to get a clean, steady-state number.</p>
<h2>Symptom 6: <code>NotTested</code> in the Sig Column</h2>
<p><strong>The Problem:</strong> The Significance column for a comparison is blank or says <code>NotTested</code>.
<strong>The Cause:</strong> Insufficient data or incompatible profiles. Either the group has fewer than two valid samples (due to extreme outlier trimming), or you are trying to compare results measured under different <code>RuntimeProfile</code> settings.
<strong>The Fix:</strong> If it&#39;s a data issue, check for extreme noise. If it&#39;s a profile issue, ensure all benchmarks in the suite use the same <code>RuntimeProfile</code>.</p>
<h2>Symptom 7: Drift Restarts in the Log</h2>
<p><strong>The Problem:</strong> The run logs show that the engine restarted the measurement several times due to &quot;Clock Drift.&quot;
<strong>The Cause:</strong> The host machine&#39;s clock shifted or the load changed drastically mid-run. NBenchmark uses a &quot;canary&quot; benchmark to monitor the host; if the canary&#39;s timing drifts beyond the tolerance, the engine knows the current environment is compromised.
<strong>The Fix:</strong> Stabilize the host. Disable Turbo Boost or switch to a dedicated machine with a stable power profile. You can tune the sensitivity with <code>--drift-tolerance</code>.</p>
<h2>Symptom 8: The Errored Benchmark</h2>
<p><strong>The Problem:</strong> A benchmark is marked as <code>Errored</code> with an <code>ErrorMessage</code>.
<strong>The Cause:</strong> The body threw an exception. NBenchmark doesn&#39;t abort the whole suite when one benchmark fails; it captures the error and moves to the next. Note that an <code>OperationCanceledException</code> also counts as an error.
<strong>The Fix:</strong> Fix the bug in the benchmark body. Check the <code>ErrorMessage</code> to see exactly where the failure occurred.</p>
<h2>Symptom 9: &quot;Fast here, Slow in CI&quot;</h2>
<p><strong>The Problem:</strong> The absolute numbers are wildly different between your laptop and the CI runner, but the ratios remain the same.
<strong>The Cause:</strong> Hardware variance. This is normal. A Xeon processor in a data center and an M3 chip in a laptop will never produce the same nanosecond count.
<strong>The Fix:</strong> Stop looking at absolute numbers. Use a <code>ReferenceMethod</code> to measure the ratio. If the ratio is stable across both machines, your performance characteristic is real.</p>
<h2>Summary Table: Symptom to Fix</h2>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Likely Cause</th>
<th>The Fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>MarginOfError ±0</code></td>
<td>Clock Resolution</td>
<td>Raise <code>OpsPerSample</code> (K)</td>
</tr>
<tr>
<td>Wide, stubborn interval</td>
<td>Host Interference</td>
<td>Use dedicated hardware</td>
</tr>
<tr>
<td>Bimodal Warning</td>
<td>Dual code paths</td>
<td>Split into two benchmarks</td>
</tr>
<tr>
<td>Order dependency</td>
<td>State contamination</td>
<td>Implement <code>IStateReset</code></td>
</tr>
<tr>
<td>Stable but 20x slow</td>
<td>In-process run</td>
<td>Use isolated worker process</td>
</tr>
<tr>
<td><code>NotTested</code> (Sig)</td>
<td>Profile mismatch</td>
<td>Match <code>RuntimeProfile</code></td>
</tr>
<tr>
<td>Drift restarts</td>
<td>Host clock shift</td>
<td>Disable Turbo Boost / Stabilize</td>
</tr>
<tr>
<td><code>Errored</code> status</td>
<td>Thrown exception</td>
<td>Fix body logic</td>
</tr>
<tr>
<td>Diff absolute numbers</td>
<td>Hardware variance</td>
<td>Use <code>ReferenceMethod</code> ratios</td>
</tr>
</tbody></table>
<hr>
<p><strong>Go deeper:</strong> You&#39;ve now finished the &quot;Running it for real&quot; section. You know how to measure, report, and gate your performance. Now, it&#39;s time to open the hood and see how the engine actually works.</p>
<p><a href="https://nbenchmark.net/blog/why-your-numbers-come-from-another-process">Why your numbers come from another process →</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Fail the build when it gets slower</title>
      <link>https://nbenchmark.net/blog/fail-the-build-when-it-gets-slower</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/fail-the-build-when-it-gets-slower</guid>
      <pubDate>Sun, 19 Jul 2026 00:00:00 GMT</pubDate>
      <description>Turning performance measurements into enforceable gates using CLI regression limits and test-framework integrations.</description>
      <category>Running it for real</category>
      <content:encoded><![CDATA[<p>A benchmark that nobody enforces is just trivia.</p>
<p>It&#39;s great to have a table in a pull request showing that a refactor is 10% faster, but that&#39;s a manual check. The moment a developer forgets to look at the report, or a reviewer assumes the numbers are &quot;close enough,&quot; performance begins to drift. To stop the slow bleed of performance degradation, you have to move from observation to enforcement.</p>
<p>You need to turn your numbers into consequences.</p>
<h2>Gate one: The CLI regression limit</h2>
<p>For dedicated benchmark projects, the simplest way to enforce performance is the <code>--max-regression-percent</code> flag.</p>
<p>This flag allows you to define a hard limit on how much slower a benchmark can be compared to its baseline. If any median result exceeds this percentage, the process exits with a non-zero code, failing your CI pipeline.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-comment"># Fail the build if any benchmark is more than 5% slower than the baseline</span>
</span><span class="md-code-line"><span class="tok-method">dotnet</span><span class="tok"> benchmark </span><span class="tok-attr">--max-regression-percent</span><span class="tok"> </span><span class="tok-number">5</span>
</span></code></pre><p>The beauty of this approach is that it doesn&#39;t block the evidence. Even when the build fails, NBenchmark still flushes the reporters. Your GitHub Action or Azure DevOps pipeline will fail, but the Markdown table will still be there, showing exactly which implementation crossed the line and by how much.</p>
<h2>Gate two: Test-framework integration</h2>
<p>While CLI flags are great for standalone projects, most engineers want their performance gates to live where their other tests live: in xUnit, NUnit, or MSTest.</p>
<p>NBenchmark provides integration packages (e.g., <code>NBenchmark.Integration.xUnit</code>) that allow you to write performance tests as first-class citizens. Instead of a <code>[Fact]</code>, you use <code>[PerformanceFact]</code>.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">using</span><span class="tok"> </span><span class="tok-type">NBenchmark</span><span class="tok-punct">.</span><span class="tok-type">Integration</span><span class="tok-punct">.</span><span class="tok">xUnit</span><span class="tok-punct">;</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">class</span><span class="tok"> </span><span class="tok-type">PaymentProcessingTests</span>
</span><span class="md-code-line"><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[PerformanceFact</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">ProcessPayment_ShouldBeFast</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-comment">// The entire method body is the benchmark</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-keyword">var</span><span class="tok"> processor </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">PaymentProcessor</span><span class="tok-punct">();</span>
</span><span class="md-code-line"><span class="tok">        processor</span><span class="tok-punct">.</span><span class="tok-method">Process</span><span class="tok-punct">(</span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">PaymentRequest</span><span class="tok-punct">(</span><span class="tok-number">100</span><span class="tok-punct">));</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">}</span>
</span><span class="md-code-line"><span class="tok-punct">}</span>
</span></code></pre><h3>Absolute vs. Relative thresholds</h3>
<p>Once you&#39;ve integrated benchmarks into your tests, you have two ways to define &quot;failure.&quot;</p>
<p><strong>Absolute thresholds</strong> set a hard limit on the cost of an operation:</p>
<ul>
<li><code>MaxMeanNs</code>: Fail if the mean exceeds this value.</li>
<li><code>MaxP95Ns</code>: Fail if the 95th percentile exceeds this value.</li>
<li><code>MaxAllocatedBytes</code>: Fail if the allocation cost is too high.</li>
</ul>
<p>Absolute thresholds are useful for strict SLAs (e.g., &quot;this API must respond in under 50ms&quot;), but they are brittle. They fail when you move from a powerful build server to a slower runner.</p>
<p><strong>Relative thresholds</strong> are the professional choice. By specifying a <code>ReferenceMethod</code> and a <code>MaxSlowdownRatio</code>, you compare the current implementation against a known-good reference measured in the same run.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-attr">[PerformanceFact</span><span class="tok-punct">(</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-type">ReferenceMethod</span><span class="tok"> </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">nameof</span><span class="tok-punct">(</span><span class="tok-type">LegacyProcessPayment</span><span class="tok-punct">),</span><span class="tok"> </span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-type">MaxSlowdownRatio</span><span class="tok"> </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-number">1.2</span><span class="tok-punct">)]</span><span class="tok"> </span><span class="tok-comment">// Fail if 20% slower than legacy</span>
</span><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">NewProcessPayment_ShouldNotRegress</span><span class="tok-punct">()</span><span class="tok"> </span>
</span><span class="md-code-line"><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-comment">// ...</span>
</span><span class="md-code-line"><span class="tok-punct">}</span>
</span></code></pre><p>Because both the reference and the target are measured on the same machine in matching worker processes, the absolute speed of the hardware doesn&#39;t matter. A fast laptop and a slow CI runner will produce the same ratio. You no longer have to check in &quot;baseline files&quot; that go stale the moment you update your compiler.</p>
<h2>Precision in the gate</h2>
<p>By default, <code>[Performance]</code> tests run with a single launch to keep the test suite fast. While this is fine for catching 2x regressions, it&#39;s not enough for high-precision gates.</p>
<p>If you need to enforce a tight 5% limit, you should increase the <code>LaunchCount</code>. Raising the launch count provides a paired-interval ratio, which ensures that a &quot;flaky&quot; run on a noisy CI server doesn&#39;t trigger a false positive.</p>
<p>For those who need to perform assertions manually within a test, <code>PerformanceAssert.Run</code> (NUnit/MSTest) or <code>BenchmarkAssert.Validate</code> (xUnit) allows you to trigger a measurement and validate the result against your constraints in a single line.</p>
<h2>Choosing your gate</h2>
<p>Which enforcement strategy should you use?</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Recommended Gate</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Standalone perf project</td>
<td><code>--max-regression-percent</code></td>
<td>Simple, fast, and reports to CLI</td>
</tr>
<tr>
<td>Unit test suite</td>
<td><code>[PerformanceFact]</code></td>
<td>Integrated into existing test runners</td>
</tr>
<tr>
<td>Strict SLA / Latency Cap</td>
<td><code>MaxP95Ns</code></td>
<td>Ensures absolute limits are met</td>
</tr>
<tr>
<td>Refactor / Comparison</td>
<td><code>ReferenceMethod</code></td>
<td>Hardware-agnostic and stable</td>
</tr>
</tbody></table>
<p>By turning your benchmarks into gates, you stop guessing if your code is fast enough. You define the limit, and the build ensures you never cross it.</p>
<hr>
<p><strong>Go deeper:</strong> You&#39;ve set up the gates, but occasionally, the gates will fail and the numbers will look impossible. When that happens, you need a field guide to diagnose the symptoms.</p>
<p><a href="https://nbenchmark.net/blog/when-the-numbers-look-wrong">When the numbers look wrong →</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Cutting the noise at its source</title>
      <link>https://nbenchmark.net/blog/cutting-the-noise-at-its-source</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/cutting-the-noise-at-its-source</guid>
      <pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate>
      <description>Reducing environmental interference using CPU affinity, process priority, and thread control for maximum measurement precision.</description>
      <category>Running it for real</category>
      <content:encoded><![CDATA[<p>If you&#39;ve ever run the same benchmark twice and gotten two different results, you&#39;ve experienced the &quot;noisy neighbor&quot; problem. </p>
<p>Your code isn&#39;t running in a vacuum. It&#39;s running on a machine where the OS is constantly stealing cycles to handle network interrupts, background telemetry, and a dozen open browser tabs. While NBenchmark&#39;s outlier machinery treats the symptoms of this noise by trimming spikes, the most precise benchmarks start by preventing the noise from reaching the sample stream in the first place.</p>
<h2>The shared-runner confession</h2>
<p>The first rule of performance engineering is that your laptop is not a measurement instrument. </p>
<p>Laptops are designed for bursty, interactive workloads, not steady-state measurement. They have aggressive thermal throttling, variable clock speeds, and background processes that wake up without warning. This is exacerbated in CI environments, where &quot;shared runners&quot; mean your benchmark is competing for L3 cache with three other unrelated build jobs on the same physical core.</p>
<p>If you want a number you can trust, you have to take control of the environment.</p>
<h2>CPU Affinity: Pinning your work</h2>
<p>The OS scheduler frequently moves threads between different CPU cores to balance heat and load. This &quot;core hopping&quot; is a disaster for benchmarks because it flushes the L1 and L2 caches, creating artificial spikes in your timing data.</p>
<p>CPU affinity allows you to pin the worker process to a specific core. When NBenchmark uses affinity, it ensures that the measurement happens on one dedicated core, keeping the caches hot and the timing stable.</p>
<p>You can enable this via the <code>--cpu-affinity</code> CLI flag or the <code>WithHardwareAffinity</code> method. On a machine with 16 cores, pinning your benchmark to core 7 removes the variance caused by the scheduler shifting your workload across the die.</p>
<h2>Process Priority and Thread Control</h2>
<p>Even with a pinned core, a high-priority system process can still preempt your benchmark. To minimize this, NBenchmark can elevate the worker process&#39;s priority.</p>
<p>Using the <code>--priority</code> flag (or <code>WithProcessPriority</code>), the engine tells the OS that the worker process is a high-priority task. This reduces the likelihood of the OS pausing your measurement to handle a background update or a telemetry ping.</p>
<p>Alongside priority, NBenchmark employs <strong>Thread Control</strong>. By default, the engine optimizes how threads are placed and managed to ensure that the timed window is as undisturbed as possible. While you can disable this via <code>--no-thread-control</code>, it is almost always beneficial to leave it on.</p>
<p><em>Note for macOS users: Apple&#39;s kernel restricts many of these operations. While NBenchmark attempts to apply these optimizations, the macOS platform may ignore affinity and priority requests, meaning you will naturally see more variance on Mac hardware than on Linux or Windows.</em></p>
<h2>Knowing when the machine is beyond saving</h2>
<p>There are times when no amount of pinning or priority can save a run. If you are running on a severely overloaded machine, the noise floor becomes so high that the results are meaningless.</p>
<p>NBenchmark includes a host-quality check via the <code>--host-quality-warnings</code> flag (or <code>WithHostQualityWarnings</code>). This feature monitors the system for extreme interference during the run. If it detects that the host is too noisy to provide an honest number, it will surface a warning in your report.</p>
<p>When you see a host-quality warning, don&#39;t try to &quot;fix&quot; the benchmark code. Instead, look at the machine:</p>
<ul>
<li>Close your browser and Slack.</li>
<li>Move the run to a dedicated performance machine.</li>
<li>In CI, switch from a shared runner to a dedicated, &quot;bare metal&quot; instance.</li>
</ul>
<h2>Reproducibility and the seed</h2>
<p>Noise control isn&#39;t just about the OS; it&#39;s also about the order of execution. If you have ten benchmarks and the first one warms up the CPU&#39;s turbo boost, the tenth one will look faster simply because it ran last.</p>
<p>To combat this, NBenchmark uses <code>--order random</code> by default. By shuffling the order of benchmarks in every run, the engine ensures that any systemic bias (like thermal throttling or cache warming) is distributed across all candidates rather than favoring the end of the list.</p>
<p>If you need to reproduce a specific &quot;weird&quot; run for debugging, you can use the <code>--seed</code> flag. Providing a seed ensures that the &quot;random&quot; order is identical across runs, allowing you to isolate whether a spike is caused by the code or by its position in the suite.</p>
<h2>The &quot;in-process&quot; trap</h2>
<p>As a final word of caution: none of these environmental controls work if you use <code>--in-process</code>. </p>
<p>In-process measuring runs your code in the same process as the harness. This means you share the heap, the JIT state, and the priority of the main application. As we&#39;ll see in <a href="https://nbenchmark.net/blog/why-your-numbers-come-from-another-process">post 18</a>, this can lead to results that are off by a factor of 20x or more. In-process runs are excellent for smoke tests, but they are the opposite of noise control.</p>
<hr>
<p><strong>Go deeper:</strong> You&#39;ve now learned how to control the environment and the hardware. But what happens when you&#39;ve done everything right and the numbers <em>still</em> look impossible? </p>
<p><a href="https://nbenchmark.net/blog/when-the-numbers-look-wrong">When the numbers look wrong →</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>One run cannot tell you how much it moves</title>
      <link>https://nbenchmark.net/blog/one-run-cannot-tell-you-how-much-it-moves</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/one-run-cannot-tell-you-how-much-it-moves</guid>
      <pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate>
      <description>Using multiple launches and paired ratios to distinguish between a lucky run and a real performance regression.</description>
      <category>Running it for real</category>
      <content:encoded><![CDATA[<p>There is a dangerous assumption that most developers make when reading a benchmark report: they assume the confidence interval describes the <em>next</em> run.</p>
<p>If a report says the median is 100ns ± 2ns, the reader assumes that if they run the code again, it will almost certainly land between 98ns and 102ns. But that&#39;s not what the interval is telling you. That interval describes the variance <em>within</em> a single run - the noise of the samples in that specific process.</p>
<p>It does not describe the variance <em>between</em> runs.</p>
<p>If you run the same benchmark on a different machine, or even just restart your current machine, you might find a new median of 110ns ± 2ns. Both runs are internally precise, but they disagree by 10%. This is &quot;run-to-run variance,&quot; and it is the primary reason why single-run benchmarks are untrustworthy for detecting regressions.</p>
<h2>The power of multiple launches</h2>
<p>To solve this, NBenchmark introduces the concept of a <strong>launch</strong>. </p>
<p>A launch is not just a repeat of the sampling loop. It is a complete restart of the measurement cycle: the engine spawns a fresh worker process, performs a fresh warmup, and collects a fresh set of samples. </p>
<p>By performing multiple launches, you measure the variance of the environment itself. </p>
<p>In Harness mode, NBenchmark defaults to five launches. For Single and Suite modes, the default is one, though you can increase it using <code>MeasurementOptions.LaunchCount</code> or the <code>--launch-count</code> CLI flag.</p>
<p>Why five? Because in the world of performance engineering, two or three launches are often not enough to clear the noise of a background OS task. Five launches provide enough data to build a statistically significant distribution of the &quot;median&quot; itself.</p>
<h2>Reading the aggregated report</h2>
<p>When you perform multiple launches, the report changes. The primary fields - Median, Mean, and Error - are no longer from a single run. They are averages calculated across all launches.</p>
<p>Below the main results, you&#39;ll see a <strong>Launch Aggregation</strong> table. This table shows the median of every single launch. If the numbers are tightly clustered (e.g., 101, 102, 99, 100, 101), you have a stable environment. If they are wild (e.g., 100, 150, 90, 200, 110), you know that your results are being driven by external noise, and no amount of additional sampling within a single run will fix it.</p>
<p>One technical detail: while the median is averaged across launches, the <code>RawSamples</code> and trimmed marks shown in the advanced detail are taken from the launch nearest to the averaged median. This ensures you are looking at the most representative distribution of the run.</p>
<h2>Paired ratios: The &quot;Is it actually faster?&quot; test</h2>
<p>The most powerful feature of multiple launches is the <strong>paired ratio</strong>.</p>
<p>When you compare Implementation A to Implementation B in a suite, NBenchmark doesn&#39;t just compare the final averaged medians. It calculates the ratio for every single launch.</p>
<ul>
<li>Launch 1: A is 1.1x slower than B</li>
<li>Launch 2: A is 1.05x slower than B</li>
<li>Launch 3: A is 1.15x slower than B</li>
<li>... and so on.</li>
</ul>
<p>These per-launch ratios are then combined on a log scale to produce a paired-interval ratio. This tells you not just &quot;A is slower,&quot; but &quot;Across five different process restarts, A was consistently 1.1x slower than B.&quot;</p>
<p>This is the only way to distinguish between a &quot;lucky&quot; run and a real performance difference. If the significance test (Sig) says &quot;Yes&quot; but the ratio interval is wide and overlaps 1.0, you have a result that is statistically significant but practically unstable. If the interval is tight and far from 1.0, you have an honest regression.</p>
<h2>Practical constraints</h2>
<p>There are two important interactions to keep in mind when configuring launches:</p>
<ol>
<li><strong>The Dry Run:</strong> The <code>--dry-run</code> flag performs exactly one launch. It verifies that your benchmarks compile and run without actually spending the time to gather a full distribution.</li>
<li><strong>In-Process Limitation:</strong> If you use <code>--in-process</code> (which we&#39;ll discuss in post 18), you cannot perform multiple launches. Since the measurement happens in the current process, there is no &quot;fresh worker&quot; to spawn. In-process runs are for smoke tests; real regression testing requires isolation and multiple launches.</li>
</ol>
<p>For those using <code>[Performance]</code> tests in a test suite, the default is one launch to keep the test suite fast. However, raising the launch count on these tests is the best way to turn a &quot;flaky&quot; performance test into a reliable CI gate.</p>
<hr>
<p><strong>Go deeper:</strong> Multiple launches tell you if your environment is noisy, but they don&#39;t fix the noise. To get the most precise numbers possible, you need to stop the OS from interfering in the first place.</p>
<p><a href="https://nbenchmark.net/blog/cutting-the-noise-at-its-source">Cutting the noise at its source →</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Benchmarks that take arguments</title>
      <link>https://nbenchmark.net/blog/benchmarks-that-take-arguments</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/benchmarks-that-take-arguments</guid>
      <pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate>
      <description>Measuring performance as a function of input using parameterized benchmarks and categories to organize scaling sweeps.</description>
      <category>Running it for real</category>
      <content:encoded><![CDATA[<p>If you benchmark a sorting algorithm with a list of 10 items, it will look incredibly fast. If you benchmark it with a million items, it will look slow. Neither number is &quot;wrong,&quot; but neither number is useful on its own.</p>
<p>Performance is not a single value; it is a function of its inputs. To understand how your code actually behaves, you need to measure it across a spectrum of sizes, shapes, and types of data.</p>
<p>NBenchmark handles this through parameterized benchmarks, allowing you to run a single piece of logic across many different inputs without writing multiple benchmark methods.</p>
<h2>Parameterization in Harness mode</h2>
<p>When using a dedicated benchmark class (Harness mode), you can define inputs using the <code>[Arguments]</code> and <code>[ArgumentsSource]</code> attributes.</p>
<h3>Inline literals with <code>[Arguments]</code></h3>
<p>For simple sweeps - like testing a few specific array sizes - <code>[Arguments]</code> is the fastest way. You can apply the attribute multiple times to the same method, with each attribute representing one test case.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">class</span><span class="tok"> </span><span class="tok-type">ScalingBenchmark</span>
</span><span class="md-code-line"><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Arguments</span><span class="tok-punct">(</span><span class="tok-number">10</span><span class="tok-punct">)]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Arguments</span><span class="tok-punct">(</span><span class="tok-number">100</span><span class="tok-punct">)]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Arguments</span><span class="tok-punct">(</span><span class="tok-number">1000</span><span class="tok-punct">)]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">SortData</span><span class="tok-punct">(</span><span class="tok-keyword">int</span><span class="tok"> size</span><span class="tok-punct">)</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-keyword">var</span><span class="tok"> data </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-method">GenerateRandomData</span><span class="tok-punct">(</span><span class="tok">size</span><span class="tok-punct">);</span>
</span><span class="md-code-line"><span class="tok">        data</span><span class="tok-punct">.</span><span class="tok-method">Sort</span><span class="tok-punct">();</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">}</span>
</span><span class="md-code-line"><span class="tok-punct">}</span>
</span></code></pre><h3>Dynamic sweeps with <code>[ArgumentsSource]</code></h3>
<p>When your inputs are complex - such as a generated range of values or a set of files from a directory - use <code>[ArgumentsSource]</code>. This attribute points to a static method or property that returns an <code>IEnumerable</code> of arguments.</p>
<p>This is particularly powerful for creating logarithmic sweeps (e.g., 10, 100, 1000, 10000), which are essential for identifying where an algorithm&#39;s complexity (like $O(n \log n)$ vs $O(n^2)$) starts to dominate the execution time.</p>
<p><em>Note: The NBenchmark analyzer NB0003 ensures that the number of arguments provided by the source matches the method&#39;s parameter count, and NB0012 prevents you from accidentally combining <code>[Arguments]</code> and <code>[ArgumentsSource]</code> on the same method.</em></p>
<h2>Parameterization in Suite mode</h2>
<p>If you are building a comparison suite programmatically, you can use <code>WithParameter</code>. This tells the engine to run every benchmark in the suite against the provided value.</p>
<p>Alternatively, you can use the typed <code>Add</code> overloads to define exactly how the parameters are injected into your lambdas. This allows you to create a matrix of results where you can compare Implementation A vs Implementation B across five different input sizes in a single table.</p>
<p>When you read the resulting table, look for the scaling trend. A linear increase in time as input grows is expected; an exponential spike is a warning that your code will collapse under production loads.</p>
<h2>Organizing the explosion with Categories</h2>
<p>Parameterization is powerful, but it creates a &quot;combinatorial explosion.&quot; If you have 10 benchmarks and each has 10 parameter sets, you now have 100 benchmarks to run. In a CI pipeline, running all 100 on every commit is a waste of time.</p>
<p>NBenchmark solves this with categories. You can mark a method or an entire class with <code>[BenchmarkCategory(&quot;Slow&quot;)]</code> or <code>[BenchmarkCategory(&quot;SmokeTest&quot;)]</code>.</p>
<p>Categories allow you to tier your performance testing:</p>
<ol>
<li><strong>The Smoke Set:</strong> A tiny subset of parameters (e.g., $n=10$) that runs on every PR to catch catastrophic regressions.</li>
<li><strong>The Deep Set:</strong> The full scaling sweep that runs nightly to monitor long-term performance trends.</li>
</ol>
<p>You can filter these at the command line using <code>--include-category</code> and <code>--exclude-category</code>, or programmatically via <code>WithCategories</code>.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-comment"># Run only the fast smoke tests for a quick check</span>
</span><span class="md-code-line"><span class="tok-method">dotnet</span><span class="tok"> benchmark </span><span class="tok-attr">--include-category</span><span class="tok"> SmokeTest</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-comment"># Run everything except the heavy nightly sweeps</span>
</span><span class="md-code-line"><span class="tok-method">dotnet</span><span class="tok"> benchmark </span><span class="tok-attr">--exclude-category</span><span class="tok"> Slow</span>
</span></code></pre><h2>Reading the scaling table</h2>
<p>When you run a parameterized benchmark, NBenchmark names the resulting entries using the pattern <code>MethodName(arg1, arg2, ...)</code>. </p>
<p>In your results table, this allows you to see the scaling curve directly. If you see that <code>SortData(10)</code> takes 100ns and <code>SortData(100)</code> takes 1,000ns, your code is scaling linearly. If <code>SortData(100)</code> suddenly jumps to 10,000ns, you&#39;ve found a non-linear performance cliff.</p>
<p>By combining parameters with significance tests, you can answer high-precision questions: &quot;Is Implementation B significantly faster than A for small inputs, but significantly slower for large ones?&quot; This is the only way to make an informed decision about which algorithm to ship.</p>
<hr>
<p><strong>Go deeper:</strong> Now that you can measure how your code scales with input, there&#39;s one more variable to consider: the machine itself. A single run can be a fluke. To know if a change is truly a regression, you need to see how it moves across multiple launches.</p>
<p><a href="https://nbenchmark.net/blog/one-run-cannot-tell-you-how-much-it-moves">One run cannot tell you how much it moves →</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Output that travels</title>
      <link>https://nbenchmark.net/blog/output-that-travels</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/output-that-travels</guid>
      <pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate>
      <description>Moving beyond the terminal with JSON, Markdown, and CSV reporters to make performance data shareable and archivable.</description>
      <category>Running it for real</category>
      <content:encoded><![CDATA[<p>A number in a terminal window persuades nobody in a pull request. </p>
<p>When you&#39;re arguing for a refactor, &quot;it felt faster on my machine&quot; isn&#39;t a technical argument; it&#39;s a vibe. To make performance data a part of your engineering process, that data needs to move. It needs to be a file that can be attached to a ticket, a table that can be pasted into a PR, or a JSON record that can be fed into a dashboard.</p>
<p>NBenchmark provides a system of stackable reporters that turn raw measurement results into durable assets.</p>
<h2>More than just a print statement</h2>
<p>Most people start with <code>result.Print()</code>, which is great for a quick check. But for professional work, you want the <code>ConsoleReporter</code>. While <code>Print</code> gives you a static snapshot, the <code>ConsoleReporter</code> provides a rich, formatted table with live progress updates as the engine samples your code.</p>
<p>It&#39;s important to note the packaging: the JSON, Markdown, and CSV reporters are built into the core package. The <code>ConsoleReporter</code>, however, lives in <code>NBenchmark.Reporters.Console</code>. This keeps the core library zero-dependency while allowing the terminal UI to use specialized formatting libraries.</p>
<h2>Stacking reporters for different audiences</h2>
<p>You rarely have only one audience for your results. You need a human-readable table for your teammates, and a machine-readable file for your archives. NBenchmark handles this by allowing you to stack multiple reporters in a single run.</p>
<p>When you configure a suite, you can add as many reporters as you need. The engine will pipe the results to all of them simultaneously.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">using</span><span class="tok"> </span><span class="tok-type">NBenchmark</span><span class="tok-punct">;</span>
</span><span class="md-code-line"><span class="tok-keyword">using</span><span class="tok"> </span><span class="tok-type">NBenchmark</span><span class="tok-punct">.</span><span class="tok-type">Reporters</span><span class="tok-punct">.</span><span class="tok-type">Console</span><span class="tok-punct">;</span>
</span><span class="md-code-line"><span class="tok-keyword">using</span><span class="tok"> </span><span class="tok-type">NBenchmark</span><span class="tok-punct">.</span><span class="tok-type">Reporters</span><span class="tok-punct">;</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">var</span><span class="tok"> suite </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">BenchmarkSuite</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-punct">{</span><span class="tok"> </span><span class="tok-comment">/* implementation A */</span><span class="tok"> </span><span class="tok-punct">})</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-punct">{</span><span class="tok"> </span><span class="tok-comment">/* implementation B */</span><span class="tok"> </span><span class="tok-punct">})</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">WithReporter</span><span class="tok-punct">(</span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">ConsoleReporter</span><span class="tok-punct">())</span><span class="tok"> </span><span class="tok-comment">// For the dev running the test</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">WithReporter</span><span class="tok-punct">(</span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">MarkdownReporter</span><span class="tok-punct">(</span><span class="tok-string">&quot;results/benchmark&quot;</span><span class="tok-punct">))</span><span class="tok"> </span><span class="tok-comment">// For the PR</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">WithReporter</span><span class="tok-punct">(</span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">JsonReporter</span><span class="tok-punct">(</span><span class="tok-string">&quot;results/archive&quot;</span><span class="tok-punct">));</span><span class="tok"> </span><span class="tok-comment">// For the history log</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok">suite</span><span class="tok-punct">.</span><span class="tok-method">Run</span><span class="tok-punct">();</span>
</span></code></pre><h2>Choosing the right format</h2>
<p>Each reporter serves a specific purpose in the development lifecycle.</p>
<h3>Markdown: The PR standard</h3>
<p>The <code>MarkdownReporter</code> generates a GitHub-flavored markdown table. This is the gold standard for pull requests. Instead of a screenshot of a terminal (which is unsearchable and hard to read), you paste a real table. Your reviewers can see the Median, Ratio, and Significance columns clearly, making the &quot;why&quot; of your refactor immediately obvious.</p>
<h3>CSV: The spreadsheet power-user</h3>
<p>If you&#39;re doing a massive sweep of parameters and need to generate a trend line or a scatter plot, the <code>CSVReporter</code> is your best tool. It exports the results into a format that Excel or Google Sheets can ingest instantly, allowing you to perform your own secondary analysis on the data.</p>
<h3>JSON: The source of truth</h3>
<p>The <code>JsonReporter</code> is the most important reporter in the set. While Markdown and CSV are for humans, JSON is for the system. It carries the full record of the run, including:</p>
<ul>
<li>Every raw sample collected.</li>
<li>The full histogram of the distribution.</li>
<li>The exact runtime knobs and profile applied.</li>
<li>The auto-tune diagnostic data (warmup stop reasons, CI half-width).</li>
</ul>
<p>If you ever need to re-analyze an old run or build a custom performance dashboard, you start with the JSON file.</p>
<h2>Controlling the detail</h2>
<p>Not every report needs to be a data dump. NBenchmark provides several ways to control the level of detail in your output.</p>
<p>You can set the detail level per reporter, per suite, or via the <code>--detail</code> CLI flag. The <code>Simple</code> level gives you the core ten columns (Median, Mean, Error, etc.), while <code>Advanced</code> adds the deep-dive statistics: quartiles, fences, shape statistics (skewness, kurtosis), and the exact number of samples trimmed by the outlier fence.</p>
<p>One exception: <strong>JSON always carries the full record.</strong> Because JSON is intended as the archive, it ignores detail settings and saves everything.</p>
<h2>Managing the payload</h2>
<p>Because the raw sample sets can become quite large - especially in long-running suites - NBenchmark provides flags to trim the JSON payload without losing the primary metrics.</p>
<ul>
<li><code>--no-raw-samples</code>: Removes the individual sample timings but keeps the calculated statistics.</li>
<li><code>--full-raw-samples</code>: Ensures every single timing is captured, even if the engine usually caps the reservoir.</li>
<li><code>--no-histogram</code>: Removes the distribution buckets to save space.</li>
</ul>
<h2>Single-result extensions</h2>
<p>If you aren&#39;t using a suite and just have a single <code>BenchmarkResult</code> from a <code>Benchmark.Run</code> call, you don&#39;t need to instantiate a full reporter. NBenchmark provides a set of async extension methods for quick exports:</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">var</span><span class="tok"> result </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-type">Benchmark</span><span class="tok-punct">.</span><span class="tok-method">Run</span><span class="tok-punct">(()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-punct">{</span><span class="tok"> </span><span class="tok-comment">/* ... */</span><span class="tok"> </span><span class="tok-punct">});</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">await</span><span class="tok"> result</span><span class="tok-punct">.</span><span class="tok-method">ToJsonAsync</span><span class="tok-punct">(</span><span class="tok-string">&quot;result.json&quot;</span><span class="tok-punct">);</span>
</span><span class="md-code-line"><span class="tok-keyword">await</span><span class="tok"> result</span><span class="tok-punct">.</span><span class="tok-method">ToMarkdownAsync</span><span class="tok-punct">(</span><span class="tok-string">&quot;result.md&quot;</span><span class="tok-punct">);</span>
</span><span class="md-code-line"><span class="tok-keyword">await</span><span class="tok"> result</span><span class="tok-punct">.</span><span class="tok-method">ToCsvAsync</span><span class="tok-punct">(</span><span class="tok-string">&quot;result.csv&quot;</span><span class="tok-punct">);</span>
</span></code></pre><p>These extensions are perfect for small scripts or integration tests where you just need a quick file on disk to verify a result.</p>
<hr>
<p><strong>Go deeper:</strong> Now that your results can travel, let&#39;s look at how to make those results meaningful across different inputs. Most code doesn&#39;t have a single &quot;speed&quot; - it has a scaling curve.</p>
<p><a href="https://nbenchmark.net/blog/benchmarks-that-take-arguments">Benchmarks that take arguments →</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Counting what the GC sees</title>
      <link>https://nbenchmark.net/blog/counting-what-the-gc-sees</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/counting-what-the-gc-sees</guid>
      <pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate>
      <description>How NBenchmark measures allocations and manages GC behavior to balance real-world noise with measurement precision.</description>
      <category>Measurement</category>
      <content:encoded><![CDATA[<p>You can have a benchmark that reports a lightning-fast median execution time, but your users are still complaining about intermittent latency spikes. When you look at the numbers, everything seems fine - until you realize you&#39;ve only been looking at half the story.</p>
<p>In .NET, time is not the only resource that matters. Every byte you allocate is a debt that must eventually be paid by the Garbage Collector (GC). If your code is &quot;fast&quot; but allocates aggressively, you aren&#39;t actually measuring the cost of your algorithm; you&#39;re just deferring the cost to a future GC pause that will eventually stop the world and spike your p99 latency.</p>
<h2>The hidden cost of allocations</h2>
<p>NBenchmark treats allocation measurement as a first-class citizen. By default, every run captures the number of bytes allocated per operation. This appears in your results as the <code>Alloc/op</code> column.</p>
<p>This number represents the mean bytes allocated per single operation. Because NBenchmark captures these counters outside the timed window, allocation tracking is lightweight enough to leave on by default. If you have a specific reason to disable it - such as reducing overhead in an extremely tight loop - you can use the <code>--no-allocations</code> flag or the corresponding configuration setting.</p>
<p>While the mean is the primary metric, the reality of the heap is often found in the extremes. If you enable Advanced detail in your report, you&#39;ll see allocation percentiles:</p>
<ul>
<li><strong>Median:</strong> The typical allocation cost.</li>
<li><strong>P95:</strong> The cost for the slowest 5% of operations.</li>
<li><strong>Max:</strong> The worst-case allocation spike.</li>
</ul>
<p>If your Median is 32 bytes but your Max is 4,000 bytes, you&#39;ve found a &quot;hidden&quot; allocation path - perhaps a rare boxing event or a cache miss - that will cause exactly the kind of latency spikes your users are feeling.</p>
<h2>Natural vs. Scrubbed heaps</h2>
<p>The most critical decision you make when measuring allocations is not <em>whether</em> to measure them, but <em>how</em> the heap is managed between samples. This is controlled by <code>GcBehavior</code>.</p>
<h3>GcBehavior.Natural</h3>
<p><code>GcBehavior.Natural</code> is the default. In this mode, NBenchmark leaves the warmup heap in place and only performs collections between different benchmarks. </p>
<p>This is the honest answer for most latency work. Because the heap is not scrubbed before every sample, a real GC pause can land inside your timed window. This will show up as a spike in your <code>MaxNs</code> and increase your variance. It&#39;s noisy, but it&#39;s an accurate reflection of how your code behaves in a production environment where the GC runs non-deterministically.</p>
<h3>GcBehavior.PerSampleCollect</h3>
<p>If you need to isolate the cost of your code from the noise of the heap, you can use <code>GcBehavior.PerSampleCollect</code>. </p>
<p>In this mode, the engine forces a Generation 0 collection before every single sample and performs a full Generation 2 collection immediately after warmup. This effectively gives every sample a &quot;clean&quot; heap. The result is significantly lower variance and a much tighter confidence interval.</p>
<p>The trade-off is that you are now measuring a machine your users do not have. By scrubbing the heap, you are removing the very GC pauses that define the real-world performance of your application. Use this mode when you want to compare the raw efficiency of two algorithms without the interference of a fragmented heap.</p>
<p>You can set this behavior globally via the <code>--gc</code> CLI flag or programmatically using <code>WithGcBehavior</code>. For more granular control, you can use overrides like <code>ForceGcBeforeEachSample</code> or <code>ForceGcBetweenBenchmarks</code>. Note that <code>ForceGcBetweenBenchmarks</code> is enabled by default under both behaviors, because allowing one benchmark to contaminate the heap of the next is never acceptable.</p>
<h2>The K-batch interaction</h2>
<p>There is one important interaction to watch for when using <code>PerSampleCollect</code>. </p>
<p>As we saw in the previous post, NBenchmark often batches multiple calls into a single sample (using a factor called K) to beat the system clock&#39;s resolution. If your body allocates memory, those allocations accumulate across the entire batch. </p>
<p>Under <code>PerSampleCollect</code>, if a batch is large enough, it can trigger a GC collection <em>inside</em> the timed window of a single sample. When this happens, the engine will surface a warning. The fix is to force the batch size to one using <code>--ops-per-sample 1</code>, ensuring that each timed window contains exactly one operation and its associated allocation cost.</p>
<h2>Corroborating with diagnostics</h2>
<p>To get the full picture, you can enable runtime diagnostics using <code>WithDiagnostics</code>. This adds a block of metadata to your results that reveals what was actually happening under the hood:</p>
<ul>
<li><strong>Collection Counts:</strong> Exactly how many Gen0, Gen1, and Gen2 collections occurred.</li>
<li><strong>Heap State:</strong> The size of the managed heap at the end of the run.</li>
<li><strong>CPU Time:</strong> The actual processor time spent executing, which helps distinguish between &quot;waiting&quot; and &quot;working.&quot;</li>
</ul>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">using</span><span class="tok"> </span><span class="tok-type">NBenchmark</span><span class="tok-punct">;</span>
</span><span class="md-code-line"><span class="tok-keyword">using</span><span class="tok"> </span><span class="tok-type">NBenchmark</span><span class="tok-punct">.</span><span class="tok-type">Reporters</span><span class="tok-punct">.</span><span class="tok-type">Console</span><span class="tok-punct">;</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">class</span><span class="tok"> </span><span class="tok-type">SerializerBenchmark</span>
</span><span class="md-code-line"><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-comment">// Compare two different serialization strategies</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">byte</span><span class="tok-punct">[]</span><span class="tok"> </span><span class="tok-method">JsonSerialize</span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-type">System</span><span class="tok-punct">.</span><span class="tok-type">Text</span><span class="tok-punct">.</span><span class="tok-type">Json</span><span class="tok-punct">.</span><span class="tok-type">JsonSerializer</span><span class="tok-punct">.</span><span class="tok-method">SerializeToUtf8Bytes</span><span class="tok-punct">(</span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-punct">{</span><span class="tok"> </span><span class="tok-type">Id</span><span class="tok"> </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-number">1</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-type">Name</span><span class="tok"> </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-string">&quot;Test&quot;</span><span class="tok"> </span><span class="tok-punct">});</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">byte</span><span class="tok-punct">[]</span><span class="tok"> </span><span class="tok-method">MsgPackSerialize</span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-type">MessagePack</span><span class="tok-punct">.</span><span class="tok-type">MessagePackSerializer</span><span class="tok-punct">.</span><span class="tok-method">Serialize</span><span class="tok-punct">(</span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-punct">{</span><span class="tok"> </span><span class="tok-type">Id</span><span class="tok"> </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-number">1</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-type">Name</span><span class="tok"> </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-string">&quot;Test&quot;</span><span class="tok"> </span><span class="tok-punct">});</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">static</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">Main</span><span class="tok-punct">(</span><span class="tok-keyword">string</span><span class="tok-punct">[]</span><span class="tok"> args</span><span class="tok-punct">)</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-keyword">var</span><span class="tok"> suite </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">BenchmarkSuite</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">            </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(</span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">SerializerBenchmark</span><span class="tok-punct">())</span>
</span><span class="md-code-line"><span class="tok">            </span><span class="tok-punct">.</span><span class="tok-method">WithDiagnostics</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">            </span><span class="tok-punct">.</span><span class="tok-method">WithReporter</span><span class="tok-punct">(</span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">ConsoleReporter</span><span class="tok-punct">());</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok">        suite</span><span class="tok-punct">.</span><span class="tok-method">Run</span><span class="tok-punct">();</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">}</span>
</span><span class="md-code-line"><span class="tok-punct">}</span>
</span></code></pre><p><strong>Example Output (Natural):</strong></p>
<pre class="md-code md-code-text"><code><span class="md-code-line"><span class="tok">Benchmark: JsonSerialize</span>
</span><span class="md-code-line"><span class="tok">  Median: 450 ns</span>
</span><span class="md-code-line"><span class="tok">  Mean:   462 ns</span>
</span><span class="md-code-line"><span class="tok">  Error:  ± 12 ns</span>
</span><span class="md-code-line"><span class="tok">  Alloc:  128 B/op</span>
</span><span class="md-code-line"><span class="tok">  </span>
</span><span class="md-code-line"><span class="tok">  Diagnostics:</span>
</span><span class="md-code-line"><span class="tok">    Gen0 Collections: 12</span>
</span><span class="md-code-line"><span class="tok">    Gen1 Collections: 0</span>
</span><span class="md-code-line"><span class="tok">    Gen2 Collections: 0</span>
</span><span class="md-code-line"><span class="tok">    Total CPU Time: 1.2 ms</span>
</span></code></pre><p><strong>Example Output (PerSampleCollect):</strong></p>
<pre class="md-code md-code-text"><code><span class="md-code-line"><span class="tok">Benchmark: JsonSerialize</span>
</span><span class="md-code-line"><span class="tok">  Median: 410 ns</span>
</span><span class="md-code-line"><span class="tok">  Mean:   412 ns</span>
</span><span class="md-code-line"><span class="tok">  Error:  ± 2 ns</span>
</span><span class="md-code-line"><span class="tok">  Alloc:  128 B/op</span>
</span><span class="md-code-line"><span class="tok">  </span>
</span><span class="md-code-line"><span class="tok">  Diagnostics:</span>
</span><span class="md-code-line"><span class="tok">    Gen0 Collections: 100</span>
</span><span class="md-code-line"><span class="tok">    Gen1 Collections: 0</span>
</span><span class="md-code-line"><span class="tok">    Gen2 Collections: 0</span>
</span><span class="md-code-line"><span class="tok">    Total CPU Time: 1.1 ms</span>
</span></code></pre><p>In this example, switching to <code>PerSampleCollect</code> reduced the mean and tightened the error margin significantly, but it also increased the number of collections by an order of magnitude. The <code>Alloc/op</code> remains the same, but the execution noise is gone.</p>
<h2>From measurement to enforcement</h2>
<p>Measuring allocations is the first step; enforcing a budget is the second. In a professional CI pipeline, you shouldn&#39;t just monitor allocations - you should gate them.</p>
<p>If a refactor increases the bytes per operation from 128 to 1024, it might not slow down the median execution time today, but it will increase the frequency of GC pauses in production. By setting an allocation threshold in your performance tests, you can fail the build the moment your code becomes &quot;trashy,&quot; long before it hits a user&#39;s machine. We&#39;ll cover how to set up these gates in <a href="https://nbenchmark.net/blog/fail-the-build-when-it-gets-slower">post 16</a>.</p>
<hr>
<p><strong>Go deeper:</strong> Now that you know how to measure what&#39;s happening, let&#39;s look at how to get those results out of your terminal and into a format that your team can actually use.</p>
<p><a href="https://nbenchmark.net/blog/output-that-travels">Output that travels →</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>When your code outruns the clock</title>
      <link>https://nbenchmark.net/blog/when-your-code-outruns-the-clock</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/when-your-code-outruns-the-clock</guid>
      <pubDate>Fri, 03 Jul 2026 00:00:00 GMT</pubDate>
      <description>Learn how NBenchmark uses ops-per-sample calibration to accurately measure sub-microsecond operations that finish faster than the system clock can notice.</description>
      <category>Measurement</category>
      <content:encoded><![CDATA[<p>If you are benchmarking a tight loop, a simple mathematical function, or a high-performance data structure, you will eventually hit a wall where your code is simply too fast for the computer to time.</p>
<p>This is the <strong>resolution problem</strong>. </p>
<p>On most modern systems, reading the high-resolution timer itself has a cost - often in the tens of nanoseconds. If your method finishes in 50 nanoseconds, and the act of checking the clock takes 30 nanoseconds, a significant portion of your &quot;measurement&quot; is actually just the cost of the measurement tool. At this scale, the clock is too coarse; you aren&#39;t measuring your code, you&#39;re measuring the jitter of the system timer.</p>
<p>NBenchmark solves this by using <strong>ops-per-sample calibration</strong>.</p>
<h2>The Batching Strategy: Ops-per-Sample (K)</h2>
<p>Rather than trying to time a single execution of a nanosecond-scale operation, NBenchmark times a batch of back-to-back calls as a single &quot;sample.&quot;</p>
<p>The number of calls in this batch is known as <strong>K</strong> (or <strong>Ops-per-sample</strong>). </p>
<p>The logic is simple: if one call is too fast to measure, 1,000 calls will be plenty fast to measure. By grouping them, the engine ensures that the total duration of the sample is large enough to dwarf the cost of the timer reads and the overhead of the loop. </p>
<p>Once the batch is timed, the engine divides the total duration by K to derive the per-operation time.</p>
<p>$$\text{Per-op Time} = \frac{\text{Sample Duration}}{\text{Ops-per-sample (K)}}$$</p>
<h2>How Calibration Works</h2>
<p>In most cases, you don&#39;t need to worry about K because NBenchmark handles it automatically. </p>
<p>During the calibration phase (which happens after warmup but before sampling), the engine probes the operation. It starts with a small K and incrementally increases it until the total sample duration is consistently above a safe threshold - one that is significantly larger than the system&#39;s timer resolution.</p>
<p>This auto-calibration ensures that whether your method takes 10 nanoseconds or 10 microseconds, the resulting measurement is honest and the overhead is minimized.</p>
<h2>When Auto-Calibration is Skipped</h2>
<p>There is one specific scenario where the engine declines to calibrate and forces $K = 1$: <strong>when you use <code>SampleSetup</code> or <code>SampleTeardown</code> hooks.</strong></p>
<p>The ops-per-sample batching works by running the method body in a tight, repeated loop. If you have configured a setup or teardown method to run around every sample, the engine cannot batch the body without also batching the setup/teardown. Since setup and teardown logic often involves state changes that must happen exactly once per operation, batching them would produce incorrect results.</p>
<p>In these cases, NBenchmark defaults to $K = 1$. If your method is extremely fast and you are using setup hooks, you may see the &quot;clock resolution&quot; symptoms described below.</p>
<h2>Pinning K for Reproducibility</h2>
<p>While auto-calibration is usually correct, there are times when you want to pin the batch size. You can do this using <code>--ops-per-sample &lt;n&gt;</code> in the CLI or <code>WithOpsPerSample(n)</code> in the API.</p>
<p>Pinning K is useful when:</p>
<ol>
<li><strong>Comparing across environments:</strong> You want to ensure two different machines are using the exact same batching strategy to eliminate any potential (though rare) variance in calibration logic.</li>
<li><strong>Stability testing:</strong> You suspect that a very large K is masking a periodic spike that would be visible at a smaller K.</li>
</ol>
<p>Note that <code>OpsPerSample</code> is a host-wide setting. It applies to every benchmark in the suite or harness, as it defines the fundamental measurement resolution for the run.</p>
<h2>The Warning Sign: MarginOfError ±0</h2>
<p>If you see a result where the <strong>Error</strong> (the confidence interval) is exactly <code>±0ns (0.0%)</code>, it is rarely a sign of perfect stability. Instead, it is a sign of <strong>quantization</strong>.</p>
<p>A $\pm 0$ error means that every single sample recorded exactly the same value. When this happens with sub-microsecond operations, it almost always means the operation is faster than the clock&#39;s ability to distinguish between two different values. The clock is simply rounding every sample to the same &quot;tick.&quot;</p>
<p>When you see this, the solution is to raise K. By increasing the ops-per-sample, you push the total sample duration into a range where the clock can actually see the variance in your code&#39;s performance.</p>
<h2>Beyond Batching: Jitter and Quantization</h2>
<p>Batching solves the resolution problem, but it doesn&#39;t solve everything. There are deeper issues - like the way the CPU&#39;s TSC (Time Stamp Counter) can drift or how the OS handles power states - that can introduce &quot;jitter&quot; into your numbers.</p>
<p>NBenchmark includes an advanced jitter auto-switch and quantization correction logic that operates beneath the batching layer to further refine the numbers. The deep dive into how the engine manages these hardware-level quirks is covered in the final post of the series.</p>
<h2>Example: Fast Pure Function</h2>
<p>Consider a simple function that calculates a hash.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">int</span><span class="tok"> </span><span class="tok-method">CalculateHash</span><span class="tok-punct">(</span><span class="tok-keyword">string</span><span class="tok"> input</span><span class="tok-punct">)</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-keyword">string</span><span class="tok-punct">.</span><span class="tok-method">GetHashCode</span><span class="tok-punct">(</span><span class="tok">input</span><span class="tok-punct">);</span>
</span></code></pre><p><strong>Run 1: Auto-Calibration (Default)</strong>
The engine detects the operation is $\sim 15\text{ns}$. It calibrates $K$ to $1,000$. It times $1,000$ hashes $\approx 15,000\text{ns}$.
<strong>Result:</strong> $15\text{ns} \pm 0.2\text{ns}$.</p>
<p><strong>Run 2: Pinned K=1 (Forced)</strong>
The engine times a single hash. The timer resolution is $30\text{ns}$. The result is either $30\text{ns}$ or $60\text{ns}$ based on where the clock tick falls.
<strong>Result:</strong> $30\text{ns} \pm 0\text{ns}$ (Quantization Error).</p>
<p>By batching, NBenchmark turns a coin-flip measurement into a defensible number.</p>
<p>For a deeper dive into the cost of memory and the state of the heap during these measurements, see <a href="https://nbenchmark.net/blog/counting-what-the-gc-sees">Counting what the GC sees</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Measured until it is precise enough</title>
      <link>https://nbenchmark.net/blog/measured-until-precise-enough</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/measured-until-precise-enough</guid>
      <pubDate>Tue, 30 Jun 2026 00:00:00 GMT</pubDate>
      <description>Learn how NBenchmark uses an adaptive sampling loop to replace fixed iteration counts with statistical confidence.</description>
      <category>Measurement</category>
      <content:encoded><![CDATA[<p>If you have used most benchmarking tools, you are likely used to the &quot;iteration count&quot; setting. You tell the tool to run the code 100 or 1,000 times, and it gives you the average.</p>
<p>The problem with fixed counts is that they are a guess. If your code is extremely stable, 1,000 samples is a waste of time. If your code is volatile, 1,000 samples might still leave you with a confidence interval so wide that the result is meaningless.</p>
<p>NBenchmark replaces the fixed count with an <strong>adaptive sampling loop</strong>. The engine doesn&#39;t ask &quot;how many times should I run this?&quot; but rather &quot;how much data do I need before I am confident in the answer?&quot;</p>
<h2>The path to a precise number</h2>
<p>Every NBenchmark run follows a three-stage lifecycle designed to eliminate bias and maximize precision.</p>
<h3>1. Warmup and Plateau Detection</h3>
<p>Before a single measurement is recorded, the engine enters the warmup phase. This isn&#39;t just about &quot;heating up&quot; the CPU; it is about reaching <strong>JIT-quiescence</strong>.</p>
<p>The engine monitors the timings of the warmup samples. It looks for a <strong>plateau</strong> - the point where the timings stop dropping sharply (as the JIT promotes code to Tier 1) and begin to stabilize. Once the timings plateau and stay within a specific tolerance for a set period, the engine declares the system &quot;warm&quot; and moves to calibration.</p>
<h3>2. Calibration</h3>
<p>Once warm, the engine determines the <strong>Ops-per-sample (K)</strong>. If your operation is too fast for the system clock to measure accurately, the engine batches multiple calls into a single timed sample. This ensures that the measurement window is large enough to overcome timer resolution issues (covered in detail in post 10).</p>
<h3>3. The Adaptive Loop</h3>
<p>This is the core of the engine. NBenchmark starts collecting samples and, after every batch, recalculates the <strong>Confidence Interval (CI)</strong>. </p>
<p>The loop continues until one of three things happens:</p>
<ol>
<li><strong>The Target is Met:</strong> The half-width of the confidence interval falls below the target precision (e.g., 0.5% of the mean).</li>
<li><strong>The Ceiling is Hit:</strong> The number of samples reaches the <code>MaxSamples</code> limit.</li>
<li><strong>The Budget expires:</strong> The total time spent sampling exceeds the tuning-time budget.</li>
</ol>
<p>This means that for a simple, stable function, NBenchmark might stop at 40 samples. For a complex, volatile operation, it might push to 5,000. In both cases, you get the same level of statistical confidence.</p>
<h2>Controlling the Loop</h2>
<p>While the adaptive loop is the default, you have full control over the measurement process.</p>
<h3>Pinning for Reproducibility</h3>
<p>If you need to ensure that every run uses the exact same amount of data (for example, when comparing across different machines in a very strict environment), you can pin the counts:</p>
<ul>
<li><code>WithSamples(n)</code> or <code>Samples = n</code> (in <code>[Benchmark]</code>)</li>
<li><code>WithWarmupSamples(n)</code> or <code>WarmupSamples = n</code></li>
</ul>
<p><strong>Note:</strong> Pinning the warmup is a conscious choice. By default, NBenchmark&#39;s plateau detection is more reliable than a fixed number because it adapts to the complexity of the method.</p>
<h3>Cold-start Measurement</h3>
<p>Sometimes, the &quot;lie&quot; you want to measure is the cold start. To measure the very first execution - including the JIT overhead - you can skip warmup entirely:</p>
<p><code>WithWarmupSamples(0)</code></p>
<h3>The Dry Run</h3>
<p>To verify that your benchmark is wired correctly without actually spending minutes on measurements, use the <code>--dry-run</code> flag or set <code>Samples = 0</code>. The engine will perform the discovery, setup, and calibration, but it will skip the measurement loop.</p>
<h2>The <code>autoTune</code> Diagnostic</h2>
<p>If you are curious about how the engine arrived at your result, you can inspect the <code>autoTune</code> diagnostic data (available in JSON reports). This data reveals:</p>
<ul>
<li><strong>Resolved Samples:</strong> The final number of samples collected.</li>
<li><strong>Stop Reason:</strong> Whether the loop stopped because the target was met, a ceiling was hit, or the budget ran out.</li>
<li><strong>Achieved CI:</strong> The actual precision reached at the moment of stopping.</li>
</ul>
<h2>Example: Adaptive vs Pinned</h2>
<p>Consider a method with high variance (e.g., a network-bound call).</p>
<p><strong>Default (Adaptive) Run:</strong>
The engine sees the wide variance $\rightarrow$ the CI stays wide $\rightarrow$ it keeps sampling $\rightarrow$ it stops at 1,200 samples once the Error is $\pm 0.5%$.</p>
<p><strong>Pinned Run (<code>WithSamples(100)</code>):</strong>
The engine stops at 100 samples $\rightarrow$ the Error is $\pm 4.2%$.</p>
<p>In the pinned run, you have a number, but you don&#39;t have a <em>precise</em> number. The adaptive loop protects you from accidentally trusting a result that hasn&#39;t converged.</p>
<p>For a deeper dive into how we handle the resolution of the clock when your code is faster than a nanosecond, see <a href="https://nbenchmark.net/blog/when-your-code-outruns-the-clock">When your code outruns the clock</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>The tail is a distribution, not a number</title>
      <link>https://nbenchmark.net/blog/the-tail-is-a-distribution-not-a-number</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/the-tail-is-a-distribution-not-a-number</guid>
      <pubDate>Sun, 28 Jun 2026 00:00:00 GMT</pubDate>
      <description>Learn how NBenchmark handles performance spikes, using evidence-based interference rejection and adaptive fencing to separate noise from signal.</description>
      <category>Reading results</category>
      <content:encoded><![CDATA[<p>If you have ever looked at a performance trace and seen a &quot;spike&quot; - a single sample that is 10x slower than the median - your first instinct is probably to call it an outlier and ignore it.</p>
<p>But not all spikes are created equal. Some are &quot;noise&quot; (the OS stealing your CPU core for a millisecond), while others are &quot;signal&quot; (a rare but real path in your code, like a cache miss or a GC trigger). If you blindly discard every spike, you aren&#39;t measuring your code - you&#39;re measuring a fantasy.</p>
<p>NBenchmark treats samples as a distribution, using a two-stage process to separate the noise from the signal.</p>
<h2>Stage one: Evidence-based interference rejection</h2>
<p>Before any statistical rules are applied, NBenchmark performs a hard check for OS interference. </p>
<p>When the engine runs a sample, it doesn&#39;t just time the body; it monitors the execution environment. If the OS explicitly preempts the worker process - for example, by triggering a context switch or a hard interrupt - the engine knows that the resulting timing is a lie. This isn&#39;t a statistical guess; it is evidence.</p>
<p>Samples that are provably interfered with are discarded immediately. This ensures that the data entering the statistical pipeline is as clean as possible. You can toggle this behavior with <code>--no-interference-filter</code> or <code>WithInterferenceFilter</code>, but in 99% of cases, you want it on.</p>
<h2>Stage two: The adaptive fence</h2>
<p>Even without OS interference, you will still see spikes. These could be thermal throttling, page faults, or internal engine overhead. To handle these, NBenchmark uses a statistical fence.</p>
<h3>The IQR Fence (Default)</h3>
<p>By default, the engine uses the <strong>Interquartile Range (IQR)</strong> method. It calculates the spread between the 25th and 75th percentiles and creates a &quot;fence&quot; around the data. Any sample falling outside this fence is marked as an outlier.</p>
<p>The beauty of the IQR fence is that it is adaptive. If your code is naturally volatile, the fence widens. If your code is extremely stable, the fence tightens. This prevents the engine from over-trimming naturally noisy code or under-trimming stable code.</p>
<h3>Alternative Outlier Modes</h3>
<p>Depending on what you are measuring, the IQR fence might be too aggressive or too lenient. You can change this via <code>WithOutlierMode</code> or <code>--outlier-mode</code>:</p>
<ul>
<li><code>RemoveTop5Percent</code>: A blunt instrument that simply chops off the worst 5% of samples.</li>
<li><code>RemoveTopAndBottom5Percent</code>: Symmetrical trimming for distributions with both low and high spikes.</li>
<li><code>None</code>: Disables trimming entirely. Use this for tail analysis where every single sample, no matter how slow, is part of the signal.</li>
</ul>
<h2>The bimodal warning: Noise or a second path?</h2>
<p>One of the most powerful features of the NBenchmark engine is the <strong>bimodal warning</strong>.</p>
<p>When the engine trims outliers, it doesn&#39;t just throw them away - it analyzes them. If the &quot;discarded&quot; samples aren&#39;t just random spikes but actually form their own tight cluster, the engine warns you.</p>
<p>This is a critical signal. It suggests that your code isn&#39;t suffering from noise, but from a <strong>split execution profile</strong>. This often happens when you have a &quot;cold path&quot; and a &quot;warm path&quot; (for example, the first few calls to a method that trigger a lazy initialization). If you see a bimodal warning, stop looking at the median and start investigating why your code has two different performance identities.</p>
<h2>Shape statistics and the tail</h2>
<p>To understand the &quot;shape&quot; of your performance, NBenchmark provides several advanced metrics in the Advanced detail block:</p>
<ul>
<li><strong>Skewness:</strong> Tells you if your spikes are only on the high end (right-skewed) or if you have unusual &quot;fast&quot; spikes (left-skewed).</li>
<li><strong>Kurtosis:</strong> Measures how &quot;fat&quot; the tails are. High kurtosis means you have frequent, extreme outliers.</li>
<li><strong>MAD (Median Absolute Deviation):</strong> A robust measure of spread that is less sensitive to outliers than Standard Deviation.</li>
<li><strong>CV (Coefficient of Variation):</strong> The ratio of the standard deviation to the mean, providing a normalized measure of volatility.</li>
</ul>
<h2>Example: Reading the Noise</h2>
<p>Consider a benchmark for a cache lookup. Most hits are fast, but a rare miss is slow.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">var</span><span class="tok"> suite </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">BenchmarkSuite</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(</span><span class="tok-string">&quot;CacheLookup&quot;</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> cache</span><span class="tok-punct">.</span><span class="tok-method">Get</span><span class="tok-punct">(</span><span class="tok">key</span><span class="tok-punct">))</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">WithOutlierMode</span><span class="tok-punct">(</span><span class="tok-type">OutlierMode</span><span class="tok-punct">.</span><span class="tok-type">None</span><span class="tok-punct">);</span><span class="tok"> </span><span class="tok-comment">// We want to see every miss</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">await</span><span class="tok"> suite</span><span class="tok-punct">.</span><span class="tok-method">RunAsync</span><span class="tok-punct">();</span>
</span></code></pre><p><strong>Results:</strong></p>
<pre class="md-code md-code-text"><code><span class="md-code-line"><span class="tok">Median: 40ns</span>
</span><span class="md-code-line"><span class="tok">P95: 120ns</span>
</span><span class="md-code-line"><span class="tok">P99: 1,500ns</span>
</span><span class="md-code-line"><span class="tok">Warnings: [Bimodal distribution detected in tail]</span>
</span></code></pre><p>If <code>OutlierMode</code> were set to the default (IQR), those 1,500ns samples would be trimmed and the Median would look great. But by setting it to <code>None</code> and seeing the <strong>Bimodal warning</strong>, we realize the 1,500ns isn&#39;t OS noise - it&#39;s the cost of a cache miss. The &quot;noise&quot; was actually the most important part of the measurement.</p>
<p>For a deeper dive into how we ensure these numbers are precise enough to trust, see <a href="https://nbenchmark.net/blog/measured-until-precise-enough">Measured until it is precise enough</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Significant is not the same as important</title>
      <link>https://nbenchmark.net/blog/significant-is-not-the-same-as-important</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/significant-is-not-the-same-as-important</guid>
      <pubDate>Wed, 24 Jun 2026 00:00:00 GMT</pubDate>
      <description>Learn how NBenchmark uses non-parametric tests to distinguish between random noise and real performance gains.</description>
      <category>Reading results</category>
      <content:encoded><![CDATA[<p>In a performance review, a common pattern emerges: a developer presents a table showing a new implementation with a median of 420ns and the old one at 450ns. They conclude that the new version is 7% faster.</p>
<p>But a lower number is not evidence. In the world of high-resolution timing, 30 nanoseconds can be the result of a slightly different CPU frequency state or a lucky alignment of OS interrupts. The question isn&#39;t &quot;Which number is smaller?&quot; but &quot;Is this difference real, and if so, does it actually matter?&quot;</p>
<p>NBenchmark answers these two distinct questions using the <strong>Sig</strong> and <strong>Magnitude</strong> columns.</p>
<h2>Is the difference real? The Sig column</h2>
<p>The <strong>Sig</strong> (Significance) column tells you if the observed difference between two implementations is statistically significant - meaning it is unlikely to have occurred by chance.</p>
<h3>How it works: Non-parametric testing</h3>
<p>Most naive tools assume that timing data follows a &quot;Normal&quot; (bell curve) distribution. This is almost always false for software benchmarks, which are typically right-skewed by occasional spikes.</p>
<p>To avoid the errors that come with assuming a bell curve, NBenchmark uses non-parametric tests. These tests look at the <em>ranks</em> of the samples rather than their raw values, making them robust against outliers.</p>
<ul>
<li><strong>Two-group comparisons:</strong> NBenchmark uses the <strong>Mann-Whitney U test</strong>. It compares the distributions of the baseline and the candidate to see if one is stochastically smaller than the other.</li>
<li><strong>Three or more groups:</strong> When you have multiple candidates, the engine first runs a <strong>Kruskal-Wallis omnibus test</strong> to see if <em>any</em> of the groups differ. If that test passes, it performs post-hoc pairwise Mann-Whitney U tests using the <strong>Holm-Bonferroni correction</strong> to prevent &quot;p-hacking&quot; (the increased likelihood of finding a false positive when running many tests).</li>
</ul>
<h3>Reading the symbols</h3>
<p>The Sig column uses three primary states:</p>
<ul>
<li><strong>✓ (Checkmark):</strong> The difference is statistically significant. You can be confident the change is real.</li>
<li><strong>✗ (Cross):</strong> The difference is not significant. Any gap in the medians is likely just noise.</li>
<li><strong>Blank:</strong> No comparison was possible (e.g., the method is the baseline).</li>
<li><strong>NotTested:</strong> Occurs when a group has fewer than two samples, making a distribution test impossible.</li>
</ul>
<h3>Tuning the evidence</h3>
<p>The threshold for significance is the <strong>significance level</strong> ($\alpha$). By default, NBenchmark uses $0.05$. This means there is a 5% chance that a &quot;significant&quot; result is actually a false positive.</p>
<p>If you are making a high-stakes architectural decision, you may want stricter evidence. You can lower this threshold using <code>--significance-level 0.01</code> or <code>WithSignificanceLevel(0.01)</code>.</p>
<h2>Does it matter? Magnitude and the practical-effect gate</h2>
<p>A difference can be &quot;significant&quot; without being &quot;important.&quot; If you have enough samples, a test can detect a 0.1% difference as statistically real, but in the real world, a 0.1% gain is negligible.</p>
<p>This is where <strong>Magnitude</strong> comes in. Magnitude uses effect size to classify the difference as <strong>Negligible</strong>, <strong>Small</strong>, <strong>Medium</strong>, or <strong>Large</strong>.</p>
<p>To prevent &quot;statistically significant but practically useless&quot; results from cluttering your report, you can use the <code>WithMinimumPracticalEffect</code> gate. When this is set, NBenchmark will not mark a result with a ✓ unless the effect size also exceeds your specified threshold.</p>
<h2>Significance is not importance</h2>
<p>The most critical lesson in reading a benchmark table is to read <strong>Sig</strong> next to <strong>Ratio</strong>.</p>
<table>
<thead>
<tr>
<th align="left">Scenario</th>
<th align="left">Sig</th>
<th align="left">Ratio</th>
<th align="left">Conclusion</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>The Real Gain</strong></td>
<td align="left">✓</td>
<td align="left">0.70</td>
<td align="left">Real and substantial. Deploy it.</td>
</tr>
<tr>
<td align="left"><strong>The Noise</strong></td>
<td align="left">✗</td>
<td align="left">0.90</td>
<td align="left">The 10% gain is likely a fluke. Ignore it.</td>
</tr>
<tr>
<td align="left"><strong>The Triviality</strong></td>
<td align="left">✓</td>
<td align="left">0.99</td>
<td align="left">The 1% gain is real, but it&#39;s a waste of a PR.</td>
</tr>
</tbody></table>
<h2>Scope and Constraints</h2>
<h3>Where the comparison happens</h3>
<p>By default, significance is calculated within a specific scope:</p>
<ul>
<li><strong>Suite mode:</strong> All candidates in the suite are compared against the baseline.</li>
<li><strong>Harness mode:</strong> Benchmarks within the same class are compared.</li>
</ul>
<p>If your implementations live in separate classes, you can enable <code>--cross-class</code> or <code>WithCrossClassSignificance</code> to allow the engine to compare them.</p>
<h3>The Runtime Profile Rule</h3>
<p>There is one hard constraint: <strong>results measured under different runtime profiles are never compared.</strong></p>
<p>If you run one benchmark with <code>RuntimeProfile.SteadyState</code> and another with <code>RuntimeProfile.Production</code>, the engine will refuse to calculate a Ratio or Sig. This is because the underlying environment (JIT tiering, GC behavior) is fundamentally different; any difference in timing would be a product of the environment, not the code.</p>
<h2>Example: The Refactor Trap</h2>
<p>Imagine you refactored a sorting algorithm. You&#39;re seeing a slightly lower median, but you&#39;re not sure if it&#39;s a real win.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">var</span><span class="tok"> suite </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">BenchmarkSuite</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(</span><span class="tok-string">&quot;LegacySort&quot;</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-method">LegacySort</span><span class="tok-punct">(</span><span class="tok">data</span><span class="tok-punct">))</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(</span><span class="tok-string">&quot;OptimizedSort&quot;</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-method">OptimizedSort</span><span class="tok-punct">(</span><span class="tok">data</span><span class="tok-punct">))</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(</span><span class="tok-string">&quot;ExperimentalSort&quot;</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-method">ExperimentalSort</span><span class="tok-punct">(</span><span class="tok">data</span><span class="tok-punct">))</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">WithBaseline</span><span class="tok-punct">(</span><span class="tok-string">&quot;LegacySort&quot;</span><span class="tok-punct">);</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">await</span><span class="tok"> suite</span><span class="tok-punct">.</span><span class="tok-method">RunAsync</span><span class="tok-punct">();</span>
</span></code></pre><p><strong>Results Table:</strong></p>
<table>
<thead>
<tr>
<th align="left">Method</th>
<th align="left">Median</th>
<th align="left">Ratio</th>
<th align="left">Sig</th>
<th align="left">Mag</th>
</tr>
</thead>
<tbody><tr>
<td align="left">LegacySort</td>
<td align="left">1200ns</td>
<td align="left">1.00</td>
<td align="left">-</td>
<td align="left">-</td>
</tr>
<tr>
<td align="left">OptimizedSort</td>
<td align="left">1180ns</td>
<td align="left">0.98</td>
<td align="left">✓</td>
<td align="left">Negligible</td>
</tr>
<tr>
<td align="left">ExperimentalSort</td>
<td align="left">1100ns</td>
<td align="left">0.91</td>
<td align="left">✗</td>
<td align="left">Small</td>
</tr>
</tbody></table>
<p><strong>The Analysis:</strong></p>
<ul>
<li><code>OptimizedSort</code> is a trap. The difference is statistically real (✓), but the magnitude is <strong>Negligible</strong>. You&#39;ve spent three days on a refactor that provides no practical value.</li>
<li><code>ExperimentalSort</code> looks faster (Ratio 0.91), but the Sig is <strong>✗</strong>. The difference is not statistically significant. You cannot claim this version is faster; you likely just had a lucky run.</li>
</ul>
<p>For a deeper dive into how we handle the volatile samples that can confuse these tests, see <a href="https://nbenchmark.net/blog/the-tail-is-a-distribution-not-a-number">The tail is a distribution, not a number</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>What the table is telling you</title>
      <link>https://nbenchmark.net/blog/what-the-table-is-telling-you</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/what-the-table-is-telling-you</guid>
      <pubDate>Mon, 22 Jun 2026 00:00:00 GMT</pubDate>
      <description>A deep dive into the NBenchmark results table, explaining the difference between the median and mean, and how to interpret tail metrics.</description>
      <category>Reading results</category>
      <content:encoded><![CDATA[<p>When you run a benchmark suite, NBenchmark produces a table. At first glance, it looks like a standard set of performance metrics, but the columns are designed to answer very specific questions about the stability, honesty, and significance of your code.</p>
<p>Understanding these columns is the difference between seeing a &quot;faster number&quot; and knowing you have a &quot;better implementation.&quot;</p>
<h2>The primary metrics: Median, Mean, and Error</h2>
<p>The first few columns provide the central tendency of your samples.</p>
<p><strong>Median</strong> is your primary metric. It represents the middle value of your sample set. We prioritize the median because it is robust; a single massive spike caused by an OS context switch won&#39;t pull the median away from the typical execution time.</p>
<p><strong>Mean</strong> is the arithmetic average. While less robust than the median, the mean is mathematically necessary to build the confidence interval.</p>
<p><strong>Error</strong> is the ± margin of the confidence interval around the mean. It is usually displayed as a time value and a percentage (e.g., <code>±1.2ns (0.4%)</code>). </p>
<p>A wide error margin does not necessarily mean you need more samples. The NBenchmark adaptive loop already handles sample counts to reach a target precision. A stubbornly wide interval usually points to external interference - such as a background process stealing your CPU cycles - rather than a lack of data.</p>
<h2>The two sample sets: Trimmed vs Raw</h2>
<p>This is the most important distinction in the NBenchmark engine. To give you an honest number, the engine does not treat all samples equally.</p>
<h3>The trimmed set</h3>
<p>The engine applies a statistical fence to identify and remove extreme outliers (spikes). The resulting <strong>trimmed set</strong> is used to calculate:</p>
<ul>
<li>Mean</li>
<li>Standard Deviation (StdDev)</li>
<li>Coefficient of Variation (CV)</li>
<li>The Confidence Interval (Error)</li>
</ul>
<p>By calculating these on the trimmed set, we ensure that the &quot;typical&quot; performance isn&#39;t skewed by a random thermal throttle event.</p>
<h3>The raw set</h3>
<p>The <strong>raw pre-trim set</strong> contains every single sample collected, including the spikes. This set is used for the tail metrics:</p>
<ul>
<li>P95 and P99 (the 95th and 99th percentiles)</li>
<li>Min and Max</li>
<li>The histogram</li>
</ul>
<p>We use the raw set here because the &quot;tail&quot; is exactly where the spikes live. If you are building a low-latency system, you care deeply about the P99. You want to know the worst-case scenario, and that includes the samples the fence would have removed. By default, NBenchmark uses <code>TailMetricsBasis.Raw</code> to ensure your tail metrics are honest.</p>
<h2>Comparison and Cost: Ratio, Sig, and Magnitude</h2>
<p>When you define a baseline in a suite, NBenchmark adds three columns that turn raw numbers into a comparison story.</p>
<p><strong>Ratio</strong> tells you how much slower or faster a method is compared to the baseline. A ratio of <code>1.50</code> means the method is 50% slower than the baseline.</p>
<p><strong>Sig (Significance)</strong> answers: &quot;Is this difference real, or is it just noise?&quot; It uses a non-parametric test to determine if the two distributions are statistically distinct. A checkmark (✓) means the difference is significant; a cross (✗) means it is not.</p>
<p><strong>Magnitude</strong> classifies the effect size as Negligible, Small, Medium, or Large. </p>
<p>Crucially, Ratio, Sig, and Magnitude only mean something when read together. A result that is &quot;Significant&quot; but &quot;Negligible&quot; is a difference that is mathematically real but practically useless.</p>
<p><strong>Alloc/op</strong> shows the average bytes allocated per operation. This is the cost the Garbage Collector sees. If your median time is low but your allocations are high, you are borrowing time from the future; eventually, the GC will trigger a pause that lands in your P99.</p>
<h2>The Advanced detail block</h2>
<p>If you run with <code>ReportDetail.Advanced</code>, NBenchmark appends a detailed diagnostic block below the table. This block exposes the &quot;why&quot; behind the numbers:</p>
<ul>
<li><strong>Quartiles and Fences:</strong> The exact boundaries used to identify outliers.</li>
<li><strong>Shape Statistics:</strong> Skewness and Kurtosis, which tell you if your distribution is symmetrical or leaning heavily toward the tail.</li>
<li><strong>Samples Trimmed:</strong> Exactly how many samples were discarded as noise.</li>
<li><strong>Auto-tuned Line:</strong> The history of how the engine decided when to stop sampling.</li>
</ul>
<h2>Example: Simple vs Advanced</h2>
<p>Consider a comparison between two JSON serializers:</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">var</span><span class="tok"> suite </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">BenchmarkSuite</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(</span><span class="tok-string">&quot;System.Text.Json&quot;</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-type">JsonSerializer</span><span class="tok-punct">.</span><span class="tok-method">Serialize</span><span class="tok-punct">(</span><span class="tok">data</span><span class="tok-punct">))</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(</span><span class="tok-string">&quot;FastJson&quot;</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-type">FastJson</span><span class="tok-punct">.</span><span class="tok-method">Serialize</span><span class="tok-punct">(</span><span class="tok">data</span><span class="tok-punct">))</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">WithBaseline</span><span class="tok-punct">(</span><span class="tok-string">&quot;System.Text.Json&quot;</span><span class="tok-punct">);</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">await</span><span class="tok"> suite</span><span class="tok-punct">.</span><span class="tok-method">RunAsync</span><span class="tok-punct">();</span>
</span></code></pre><p><strong>Simple Output:</strong></p>
<table>
<thead>
<tr>
<th align="left">Method</th>
<th align="left">Median</th>
<th align="left">Mean</th>
<th align="left">Error</th>
<th align="left">Ratio</th>
<th align="left">Sig</th>
<th align="left">Mag</th>
<th align="left">Alloc/op</th>
</tr>
</thead>
<tbody><tr>
<td align="left">System.Text.Json</td>
<td align="left">450ns</td>
<td align="left">452ns</td>
<td align="left">±2ns (0.4%)</td>
<td align="left">1.00</td>
<td align="left">-</td>
<td align="left">-</td>
<td align="left">128B</td>
</tr>
<tr>
<td align="left">FastJson</td>
<td align="left">310ns</td>
<td align="left">315ns</td>
<td align="left">±3ns (0.9%)</td>
<td align="left">0.69</td>
<td align="left">✓</td>
<td align="left">Large</td>
<td align="left">64B</td>
</tr>
</tbody></table>
<p><strong>Advanced Detail for FastJson:</strong></p>
<pre class="md-code md-code-text"><code><span class="md-code-line"><span class="tok">Samples: 100 (Trimmed: 97, Outliers: 3)</span>
</span><span class="md-code-line"><span class="tok">Fence: [280ns, 340ns]</span>
</span><span class="md-code-line"><span class="tok">P95: 335ns | P99: 410ns</span>
</span><span class="md-code-line"><span class="tok">Skewness: 1.2 (Right-skewed)</span>
</span><span class="md-code-line"><span class="tok">AutoTune: Target 0.5% reached at sample 82.</span>
</span></code></pre><p>In this example, <code>FastJson</code> is significantly faster (Ratio 0.69) with a large magnitude of improvement. However, the Advanced detail shows the distribution is right-skewed, and the P99 (410ns) is much closer to the <code>System.Text.Json</code> median than the <code>FastJson</code> median is.</p>
<p>For a deeper dive into how we handle the spikes that create those P99s, see <a href="https://nbenchmark.net/blog/the-tail-is-a-distribution-not-a-number">The tail is a distribution, not a number</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>The reviewer that ships with the compiler</title>
      <link>https://nbenchmark.net/blog/the-reviewer-that-ships-with-the-compiler</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/the-reviewer-that-ships-with-the-compiler</guid>
      <pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate>
      <description>Learn how NBenchmark&apos;s built-in analyzers catch common benchmarking mistakes at build time.</description>
      <category>Start here</category>
      <content:encoded><![CDATA[<p>You can write a benchmark that compiles perfectly, runs without errors, and produces a tight confidence interval, yet is fundamentally wrong.</p>
<p>The most common mistakes - throwaway bodies, static methods, or shared state - don&#39;t trigger exceptions. They just silently invalidate your results. NBenchmark solves this by shipping a set of Roslyn analyzers (NB0001-NB0014) that act as a continuous peer review for your performance tests.</p>
<h2>The honesty family: Preventing silent lies</h2>
<p>The most dangerous benchmarks are those that measure nothing. If the JIT compiler realizes a method&#39;s result isn&#39;t used, it may eliminate the body entirely. You&#39;ll see an incredibly fast median, but you&#39;re measuring the cost of a no-op.</p>
<p>NB0004 and NB0005 catch these &quot;empty&quot; benchmarks. NB0004 flags methods with no observable side effects, while NB0005 flags literally empty bodies.</p>
<p>Then there is the lambda trap. In Single mode, it is tempting to pass an <code>Action</code> lambda to <code>Benchmark.Run</code>. However, if that lambda doesn&#39;t return a value, the runner cannot easily sink the result to defeat dead-code elimination. NB0010 warns you about this.</p>
<p>The fix for all three is the same: return a value.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-comment">// ❌ NB0005: Empty body</span>
</span><span class="md-code-line"><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">DoNothing</span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">{</span><span class="tok"> </span><span class="tok-punct">}</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-comment">// ❌ NB0004: No observable side effects (result ignored)</span>
</span><span class="md-code-line"><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">SumIgnored</span><span class="tok-punct">()</span><span class="tok"> </span>
</span><span class="md-code-line"><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">var</span><span class="tok"> x </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-number">1</span><span class="tok"> </span><span class="tok-punct">+</span><span class="tok"> </span><span class="tok-number">1</span><span class="tok-punct">;</span>
</span><span class="md-code-line"><span class="tok-punct">}</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-comment">// ✅ Fixed: Return the value so NBenchmark can sink it</span>
</span><span class="md-code-line"><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">int</span><span class="tok"> </span><span class="tok-method">SumHonest</span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-number">1</span><span class="tok"> </span><span class="tok-punct">+</span><span class="tok"> </span><span class="tok-number">1</span><span class="tok-punct">;</span>
</span></code></pre><h2>The discovery family: Ensuring the engine can run</h2>
<p>NBenchmark&#39;s Harness mode uses reflection to discover and instantiate your benchmark classes. If the class doesn&#39;t follow the required contract, the run will fail at runtime. The analyzers move these failures to build time.</p>
<p>NB0001 ensures your class has a public parameterless constructor. NB0002 prevents <code>[Benchmark]</code> from being placed on static methods, as the engine expects instance-based execution for state management.</p>
<p>If you use <code>[Arguments]</code> or <code>[ArgumentsSource]</code>, NB0003 verifies that the number of arguments provided matches the method&#39;s parameter count (arity). Other guards include NB0006 (preventing multiple baselines in one class) and NB0007 (detecting duplicate lifecycle attributes).</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">class</span><span class="tok"> </span><span class="tok-type">MyBenchmarks</span>
</span><span class="md-code-line"><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-comment">// ❌ NB0002: Benchmarks cannot be static</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">static</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">StaticMethod</span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">{</span><span class="tok"> </span><span class="tok-punct">}</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-comment">// ❌ NB0003: Argument arity mismatch (expected 1, got 2)</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Arguments</span><span class="tok-punct">(</span><span class="tok-number">10</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-number">20</span><span class="tok-punct">)]</span><span class="tok"> </span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">Process</span><span class="tok-punct">(</span><span class="tok-keyword">int</span><span class="tok"> size</span><span class="tok-punct">)</span><span class="tok"> </span><span class="tok-punct">{</span><span class="tok"> </span><span class="tok-punct">}</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-comment">// ✅ Fixed</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Arguments</span><span class="tok-punct">(</span><span class="tok-number">10</span><span class="tok-punct">)]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">ProcessFixed</span><span class="tok-punct">(</span><span class="tok-keyword">int</span><span class="tok"> size</span><span class="tok-punct">)</span><span class="tok"> </span><span class="tok-punct">{</span><span class="tok"> </span><span class="tok-punct">}</span>
</span><span class="md-code-line"><span class="tok-punct">}</span>
</span></code></pre><h2>The range family: Sanity checking the knobs</h2>
<p>Configuring samples and warmup counts is usually automatic, but when you pin them manually, it is easy to provide a value that is either too low to be statistically significant or high enough to hang your CI pipeline.</p>
<p>NB0008 and NB0009 monitor these values. If you set <code>Samples</code> to 1 or a <code>WarmupSamples</code> count that exceeds reasonable bounds, the analyzer flags it.</p>
<h2>The contamination family: Guarding state independence</h2>
<p>Benchmark results are only valid if each sample is independent. If a benchmark modifies an instance field that is shared across samples, the second sample is measuring a different state than the first.</p>
<p>NB0011 and NB0013 target these &quot;contaminated&quot; benchmarks. They flag the use of mutable instance fields or scoped services when the class is marked with <code>InstanceLifetime.PerClass</code>. The full story on managing expensive state is covered in post 19.</p>
<p>Finally, NB0014 flags when a benchmark body captures local state. This is an <strong>Info</strong> severity notification rather than an error. Because NBenchmark runs benchmarks in isolated worker processes, any captured state must be serialized and transferred across a process boundary. If you need complex setup that cannot be easily transferred, you should use <code>[BenchmarkPlan]</code>.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">class</span><span class="tok"> </span><span class="tok-type">StateBenchmark</span>
</span><span class="md-code-line"><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">private</span><span class="tok"> </span><span class="tok-keyword">int</span><span class="tok"> _counter </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-number">0</span><span class="tok-punct">;</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-comment">// ❌ NB0013: Mutable field modified under PerClass lifetime</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Benchmark</span><span class="tok-punct">]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">void</span><span class="tok"> </span><span class="tok-method">Increment</span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> _counter</span><span class="tok-punct">++;</span><span class="tok"> </span>
</span><span class="md-code-line"><span class="tok-punct">}</span>
</span></code></pre><h2>Managing severities in CI</h2>
<p>Analyzers use standard Roslyn severities:</p>
<ul>
<li><strong>Error:</strong> Blocks the build. These are non-negotiable honesty or discovery failures.</li>
<li><strong>Warning:</strong> Notifies you of a likely mistake.</li>
<li><strong>Info:</strong> Provides a hint about how the engine is handling your code.</li>
</ul>
<p>If you encounter a false positive - or if you have a highly specific reason to bypass a guard - you can suppress the warning using <code>#pragma warning disable</code> or a <code>.editorconfig</code> file. In CI environments, it is recommended to treat warnings as errors to ensure no &quot;silent lies&quot; reach your main branch.</p>
<p>For more information on troubleshooting your benchmarks, see the <a href="https://nbenchmark.net/docs/troubleshooting.md">Troubleshooting guide</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>A home for your benchmarks</title>
      <link>https://nbenchmark.net/blog/a-home-for-your-benchmarks</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/a-home-for-your-benchmarks</guid>
      <pubDate>Tue, 16 Jun 2026 00:00:00 GMT</pubDate>
      <description>Organizing benchmarks with Harness mode - attributes, lifecycle hooks, and the dotnet benchmark CLI.</description>
      <category>Start here</category>
      <content:encoded><![CDATA[<p>Up until now, we&#39;ve looked at benchmarks as scripts: a few lines of code in a <code>Program.cs</code> that you run once and move on from. This works for one-off measurements or simple comparisons.</p>
<p>But as a project grows, benchmarks stop being scripts and start being assets. You find yourself wanting to categorize them, filter which ones run in CI, or change the sample count without recompiling the whole project.</p>
<p>This is where <strong>Harness mode</strong> comes in. It transforms your benchmarks from a sequence of calls into a structured project.</p>
<h2>Setting up the Harness</h2>
<p>To move to Harness mode, you create a dedicated console project and replace your manual suite building with a single entry point:</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">using</span><span class="tok"> </span><span class="tok-type">NBenchmark</span><span class="tok-punct">;</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-comment">// This one line handles CLI argument parsing, benchmark discovery, </span>
</span><span class="md-code-line"><span class="tok-comment">// and execution orchestration.</span>
</span><span class="md-code-line"><span class="tok-keyword">await</span><span class="tok"> </span><span class="tok-type">BenchmarkHarness</span><span class="tok-punct">.</span><span class="tok-method">Create</span><span class="tok-punct">(</span><span class="tok">args</span><span class="tok-punct">).</span><span class="tok-method">RunAsync</span><span class="tok-punct">();</span>
</span></code></pre><p>Once this is in place, you no longer write code to <em>run</em> the benchmarks. Instead, you write classes to <em>define</em> them.</p>
<h2>Defining Benchmarks with Attributes</h2>
<p>In Harness mode, any public method in a public class marked with the <code>[Benchmark]</code> attribute is automatically discovered and measured.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">using</span><span class="tok"> </span><span class="tok-type">NBenchmark</span><span class="tok-punct">;</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">class</span><span class="tok"> </span><span class="tok-type">StringBenchmarks</span>
</span><span class="md-code-line"><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Benchmark</span><span class="tok-punct">(</span><span class="tok-type">Baseline</span><span class="tok"> </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">true</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-type">Description</span><span class="tok"> </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-string">&quot;Standard concatenation&quot;</span><span class="tok-punct">)]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">string</span><span class="tok"> </span><span class="tok-method">LegacyConcat</span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-string">&quot;Hello &quot;</span><span class="tok"> </span><span class="tok-punct">+</span><span class="tok"> </span><span class="tok-string">&quot;World&quot;</span><span class="tok-punct">;</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-attr">[Benchmark</span><span class="tok-punct">(</span><span class="tok-type">Samples</span><span class="tok"> </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-number">100</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-type">WarmupSamples</span><span class="tok"> </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-number">20</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-type">Description</span><span class="tok"> </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-string">&quot;Using StringBuilder&quot;</span><span class="tok-punct">)]</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-keyword">public</span><span class="tok"> </span><span class="tok-keyword">string</span><span class="tok"> </span><span class="tok-method">BuilderConcat</span><span class="tok-punct">()</span><span class="tok"> </span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">{</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-keyword">var</span><span class="tok"> sb </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-type">System</span><span class="tok-punct">.</span><span class="tok-type">Text</span><span class="tok-punct">.</span><span class="tok-method">StringBuilder</span><span class="tok-punct">();</span>
</span><span class="md-code-line"><span class="tok">        sb</span><span class="tok-punct">.</span><span class="tok-method">Append</span><span class="tok-punct">(</span><span class="tok-string">&quot;Hello &quot;</span><span class="tok-punct">);</span>
</span><span class="md-code-line"><span class="tok">        sb</span><span class="tok-punct">.</span><span class="tok-method">Append</span><span class="tok-punct">(</span><span class="tok-string">&quot;World&quot;</span><span class="tok-punct">);</span>
</span><span class="md-code-line"><span class="tok">        </span><span class="tok-keyword">return</span><span class="tok"> sb</span><span class="tok-punct">.</span><span class="tok-method">ToString</span><span class="tok-punct">();</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">}</span>
</span><span class="md-code-line"><span class="tok-punct">}</span>
</span></code></pre><p>The <code>[Benchmark]</code> attribute allows you to pin specific configurations to a method:</p>
<ul>
<li><strong><code>Baseline</code></strong>: Marks the method as the reference point for ratios and significance tests.</li>
<li><strong><code>Description</code></strong>: A human-readable name used in the final report.</li>
<li><strong><code>Samples</code> &amp; <code>WarmupSamples</code></strong>: Overrides the engine&#39;s auto-tuning for this specific method.</li>
<li><strong><code>LaunchCount</code></strong>: Determines how many fresh worker processes are spawned to measure run-to-run variance.</li>
</ul>
<h2>The Benchmark Lifecycle</h2>
<p>Real-world benchmarks often need state. Harness mode provides a set of attributes to manage this state without polluting the timed window:</p>
<ul>
<li><strong><code>[GlobalSetup]</code> / <code>[GlobalTeardown]</code></strong>: Run once per class. Use these for expensive operations like opening a database connection or loading a large file into memory.</li>
<li><strong><code>[SampleSetup]</code> / <code>[SampleTeardown]</code></strong>: Run around every single sample. Use these to reset a collection or clear a cache so that each sample starts from a clean slate.</li>
</ul>
<p>All lifecycle methods must be public and are executed outside the measurement window.</p>
<h2>The Rules of Discovery</h2>
<p>For the harness to find your benchmarks, your classes must follow a few simple rules:</p>
<ol>
<li>The class must be <strong>public</strong> and <strong>non-abstract</strong>.</li>
<li>The class must have a <strong>public parameterless constructor</strong>.</li>
</ol>
<p>If you violate these rules, the NBenchmark analyzers (like NB0001) will flag it as a build-time warning, ensuring your benchmarks don&#39;t silently disappear from your runs.</p>
<h2>Controlling the Run via CLI</h2>
<p>The real power of Harness mode is that you can change how your benchmarks run without touching the code. Because <code>BenchmarkHarness.Create(args)</code> consumes the command line, you have total control:</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-comment"># Run only benchmarks related to &quot;String&quot;</span>
</span><span class="md-code-line"><span class="tok-method">dotnet</span><span class="tok"> run -- </span><span class="tok-attr">--filter</span><span class="tok"> String</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-comment"># List all discovered benchmarks without running them</span>
</span><span class="md-code-line"><span class="tok-method">dotnet</span><span class="tok"> run -- </span><span class="tok-attr">--list</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-comment"># Run a &quot;smoke test&quot; to ensure everything wires up (invokes nothing)</span>
</span><span class="md-code-line"><span class="tok-method">dotnet</span><span class="tok"> run -- </span><span class="tok-attr">--dry-run</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-comment"># Output results to a specific directory using the Markdown reporter</span>
</span><span class="md-code-line"><span class="tok-method">dotnet</span><span class="tok"> run -- </span><span class="tok-attr">--reporter</span><span class="tok"> markdown </span><span class="tok-attr">--output</span><span class="tok"> ./bench</span><span class="tok-attr">-results</span>
</span></code></pre><h3>The Hierarchy of Truth</h3>
<p>When a setting is defined in multiple places, NBenchmark follows a strict precedence order:
<strong>Host <code>MeasurementOptions</code> $\rightarrow$ <code>[Benchmark]</code> attribute pins $\rightarrow$ CLI flags</strong></p>
<p>If you set <code>Samples = 100</code> on a method but pass <code>--samples 500</code> in the CLI, the CLI wins. There are two special flags that override everything: <code>--strict-isolation</code> and <code>--in-process</code>. Use the latter only for fast smoke tests, as it bypasses the worker process and produces numbers that are often 20x wrong.</p>
<h2>The <code>dotnet benchmark</code> Global Tool</h2>
<p>If you want to run benchmarks without even having the project open, you can use the <code>dotnet benchmark</code> global tool. This tool can target an assembly directly:</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-method">dotnet</span><span class="tok"> benchmark </span><span class="tok-attr">--project</span><span class="tok"> ./src/MyBenchmarks.csproj</span>
</span></code></pre><p>This allows you to integrate performance gates into your CI/CD pipeline without needing to write a custom runner.</p>
<h2>Which mode should you choose?</h2>
<table>
<thead>
<tr>
<th align="left">Your Goal</th>
<th align="left">Recommended Mode</th>
<th align="left">Why?</th>
</tr>
</thead>
<tbody><tr>
<td align="left">&quot;I just need one number right now&quot;</td>
<td align="left"><strong>Single</strong></td>
<td align="left">Fast, zero setup.</td>
</tr>
<tr>
<td align="left">&quot;I need to compare A vs B&quot;</td>
<td align="left"><strong>Suite</strong></td>
<td align="left">Coordinates samples for honest ratios.</td>
</tr>
<tr>
<td align="left">&quot;I have a living set of benchmarks&quot;</td>
<td align="left"><strong>Harness</strong></td>
<td align="left">Organized, filterable, and CLI-driven.</td>
</tr>
<tr>
<td align="left">&quot;I&#39;m auditing a 3rd party library&quot;</td>
<td align="left"><strong>Global Tool</strong></td>
<td align="left">No need to modify the source code.</td>
</tr>
</tbody></table>
<hr>
<p><strong>Go deeper:</strong> Now that your benchmarks have a home, you need to make sure they are actually correct. In the next post, we&#39;ll look at the built-in analyzers that catch silent mistakes before they hit your report.</p>
<p><a href="https://nbenchmark.net/blog/the-reviewer-that-ships-with-the-compiler">The reviewer that ships with the compiler $\rightarrow$</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Two implementations, one table</title>
      <link>https://nbenchmark.net/blog/two-implementations-one-table</link>
      <guid isPermaLink="true">https://nbenchmark.net/blog/two-implementations-one-table</guid>
      <pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate>
      <description>Using Suite mode in NBenchmark to compare multiple implementations with baselines and significance tests.</description>
      <category>Start here</category>
      <content:encoded><![CDATA[<p>The most common question in performance engineering isn&#39;t &quot;How fast is this?&quot; but &quot;Is A faster than B?&quot;</p>
<p>Comparing two separate <code>Benchmark.Run</code> calls is a mistake. Even on the same machine, the environment shifts: the CPU clocks down, a background process spikes, or the GC kicks in. If you run Implementation A, then run Implementation B five minutes later, you aren&#39;t comparing code - you&#39;re comparing two different moments in time.</p>
<p>NBenchmark&#39;s <strong>Suite mode</strong> solves this by putting every candidate into a single, coordinated run.</p>
<h2>The BenchmarkSuite Builder</h2>
<p>Suite mode uses a fluent builder to define a group of benchmarks that should be compared against one another.</p>
<pre class="md-code"><code><span class="md-code-line"><span class="tok-keyword">using</span><span class="tok"> </span><span class="tok-type">NBenchmark</span><span class="tok-punct">;</span>
</span><span class="md-code-line"><span class="tok-keyword">using</span><span class="tok"> </span><span class="tok-type">NBenchmark</span><span class="tok-punct">.</span><span class="tok-type">Reporters</span><span class="tok-punct">.</span><span class="tok-type">Console</span><span class="tok-punct">;</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">var</span><span class="tok"> suite </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">BenchmarkSuite</span><span class="tok-punct">()</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(</span><span class="tok-string">&quot;Legacy Approach&quot;</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-type">LegacyAlgorithm</span><span class="tok-punct">.</span><span class="tok-method">Execute</span><span class="tok-punct">())</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(</span><span class="tok-string">&quot;Optimized Approach&quot;</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-type">OptimizedAlgorithm</span><span class="tok-punct">.</span><span class="tok-method">Execute</span><span class="tok-punct">())</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">Add</span><span class="tok-punct">(</span><span class="tok-string">&quot;Experimental Approach&quot;</span><span class="tok-punct">,</span><span class="tok"> </span><span class="tok-punct">()</span><span class="tok"> </span><span class="tok-punct">=&gt;</span><span class="tok"> </span><span class="tok-type">ExperimentalAlgorithm</span><span class="tok-punct">.</span><span class="tok-method">Execute</span><span class="tok-punct">())</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">WithBaseline</span><span class="tok-punct">(</span><span class="tok-string">&quot;Legacy Approach&quot;</span><span class="tok-punct">)</span>
</span><span class="md-code-line"><span class="tok">    </span><span class="tok-punct">.</span><span class="tok-method">WithReporter</span><span class="tok-punct">(</span><span class="tok-keyword">new</span><span class="tok"> </span><span class="tok-method">ConsoleReporter</span><span class="tok-punct">());</span>
</span><span class="md-code-line">
</span><span class="md-code-line"><span class="tok-keyword">var</span><span class="tok"> results </span><span class="tok-punct">=</span><span class="tok"> </span><span class="tok-keyword">await</span><span class="tok"> suite</span><span class="tok-punct">.</span><span class="tok-method">RunAsync</span><span class="tok-punct">();</span>
</span></code></pre><p>Every name in a suite must be distinct. If you add two benchmarks with the same name, NBenchmark will throw an <code>ArgumentException</code>. This is because the significance tests use these names as keys to pair samples and calculate ratios.</p>
<h2>Beyond the Averages: Ratio, Sig, and Magnitude</h2>
<p>When you run a suite, NBenchmark doesn&#39;t just give you three separate results. It generates a comparison table. The magic happens in three specific columns:</p>
<ol>
<li><strong>Ratio:</strong> This tells you exactly how much slower (or faster) a candidate is compared to the baseline. A ratio of <code>1.20</code> means the implementation is 20% slower than the baseline.</li>
<li><strong>Sig (Significance):</strong> This is the most important column. A <code>✓</code> means the difference is statistically significant - it is highly unlikely to be the result of random noise. A <code>✗</code> means that while the numbers look different, there isn&#39;t enough evidence to prove the difference is real.</li>
<li><strong>Magnitude:</strong> This categorizes the effect as Negligible, Small, Medium, or Large. It prevents you from spending a week optimizing a &quot;significant&quot; difference that only saves 2 nanoseconds.</li>
</ol>
<h2>Tuning the Suite</h2>
<p>While NBenchmark&#39;s &quot;Auto&quot; mode is usually correct, you can override the engine&#39;s decisions using the builder:</p>
<ul>
<li><strong><code>WithSamples(n)</code> / <code>WithWarmupSamples(n)</code></strong>: Force a specific number of samples. Useful when you need a perfectly reproducible run for a report.</li>
<li><strong><code>WithOutlierMode(mode)</code></strong>: Change how the engine handles spikes. If you are specifically studying the &quot;tail&quot; of your distribution, you can set this to <code>None</code> to keep every single sample.</li>
<li><strong><code>WithProgress()</code></strong>: Enables a live progress bar in the console, which is essential for long-running suites.</li>
</ul>
<h2>Lifecycle and Error Handling</h2>
<p>Suites support <code>WithSuiteSetup</code> and <code>WithSuiteTeardown</code> hooks. These run once per suite, making them ideal for initializing shared resources like a database connection or a large static cache. Teardown is guaranteed to run even if the suite fails.</p>
<p>Crucially, if one benchmark in your suite throws an exception, it doesn&#39;t abort the entire run. NBenchmark marks that specific result as <code>Errored</code>, captures the <code>ErrorMessage</code>, and moves on to the next candidate. Your evidence survives even when your code doesn&#39;t.</p>
<h2>What&#39;s Next?</h2>
<p>Suite mode is powerful for comparisons, but it still requires you to write a <code>Program.cs</code> and manually build your suite. As your set of benchmarks grows from three to thirty, this becomes a maintenance burden.</p>
<p>In the next post, we&#39;ll look at <strong>Harness mode</strong>, where your benchmarks live in classes with attributes, and a CLI tool handles the execution.</p>
<hr>
<p><strong>Go deeper:</strong> If you&#39;re wondering exactly how NBenchmark decides if a difference is &quot;significant,&quot; check out the documentation on significance testing.</p>
<p><a href="https://nbenchmark.net/blog/a-home-for-your-benchmarks">A home for your benchmarks $\rightarrow$</a></p>
]]></content:encoded>
    </item>
  </channel>
</rss>
