Mert Özen Her Satırda Daha İleri
Test ve Kalite Performance ve Load Testing — #07

BenchmarkDotNet İleri Seviye: Parametreler, Baseline, Diagnoser'lar, Runtime Karşılaştırma ve CI'da Regresyon Yakalamak

Mert Özen 14 Kas 2026 17 dk 14 görüntülenme
BenchmarkDotNet İleri Seviye: Parametreler, Baseline, Diagnoser'lar, Runtime Karşılaştırma ve CI'da Regresyon Yakalamak

BenchmarkDotNet'in ileri kullanımı, gerçekçi senaryoları ParamsSource ile tanımlamak, eski kodu baseline yapıp oranı okumak, Memory, Exception, Threading ve Disassembly diagnoser'larıyla farkın sebebini görmek ve regresyonu göreli, istatistiksel karşılaştırmayla yakalamaktır. Bookstore'un fiyat hesabında .NET 11'in LINQ sürümünü yüzde 30-38 hızlandırıp döngüyü değiştirmediğini, 0.15.8'in net11.0'ı tanımadığını ve try/catch'li ayrıştırmanın on kat yavaşladığını ölçüyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve .NET 11 ile derlenip denenmiştir; benchmark'lar BenchmarkDotNet 0.15.8 ile, .NET 11 karşılaştırması BenchmarkDotNet 0.16.0-preview.2 ile çalıştırılmış, dotnet/performance reposundaki ResultsComparer'ın kullanımı aracın README'sinden aktarılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) sepet fiyat hesabı iki şekilde yazılabilir: LINQ (Language Integrated Query; C# içinde koleksiyon sorgulama) ile satırların üzerinden iki kez geçerek, ya da tek bir döngüyle. Kampanya akşamı checkout'un saniyede yüzlerce kez çağırdığı bir kod bu. Dört soru. Hangisi kaç kat hızlı? .NET 11'e geçmek ikisini aynı oranda mı hızlandırır? Fiyat listesindeki satırların yüzde 10'u bozuksa, decimal.Parse'ı try/catch ile sarmak TryParse'tan kaç kat yavaş olur? Son olarak, paylaşımlı bir CI runner'ında bir benchmark'ın yüzde 30 yavaşladığını nasıl güvenle söylersin? BenchmarkDotNet'in kurulumu ve tablo okumanın temelleri C# İleri Seviye serisinin "C#'ta Performans: Allocation, Struct'lar ve BenchmarkDotNet ile Ölçmek" yazısında; burada onların üstüne çıkıyoruz.

Benchmark Projesini Kurmak

Benchmark'lar ayrı bir console projesinde, bench/ klasöründe duruyor ve Bookstore.Api'yi referans ediyor; böylece ölçülen kod production'daki kodun kendisi. Fiyat hesabı API'de CartPricing class'ında: OrderService siparişin toplamını onunla hesaplıyor, davranışını da birim testleri koruyor (kurumsal indirim 20 adetten itibaren yüzde 10). Proje iki framework'e birden derleniyor, çünkü aynı benchmark'ı .NET 10 ve .NET 11 üzerinde koşturacağız:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <!-- Her runtime için ayrı build: aynı benchmark'lar .NET 10 ve .NET 11 üzerinde koşabilsin. -->
    <TargetFramework></TargetFramework>
    <TargetFrameworks>net10.0;net11.0</TargetFrameworks>
    <RootNamespace>Bookstore.Benchmarks</RootNamespace>
    <Optimize>true</Optimize>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\Bookstore.Api\Bookstore.Api.csproj" />
  </ItemGroup>

</Project>

Reponun kökündeki global.json SDK'yı 10.0.100 bandında tutuyor; net11.0 hedefi .NET 11 SDK'sı ister. Bu yüzden bench/ klasörüne kendi global.json'unu koydum: bu klasörden çalıştırılan dotnet komutları 11.0.100 SDK'sını kullanıyor, API ise net10.0 olarak derlenmeye devam ediyor. Benchmark projesini aynı sebeple solution'a eklemedim; kökten yapılan dotnet build ve dotnet test .NET 10 SDK'sıyla çalışmaya devam ediyor. .NET 11 SDK'sı 10 Kasım 2026'dan beri GA (genel kullanıma açık sürüm); .NET 10 runtime'ının da kurulu olması gerekiyor. Koşum her zaman Release'te: dotnet run -c Release -f net10.0 -- --filter "*PricingBenchmarks*".

[ParamsSource] ve Baseline

[Params] sabit değerler için yeterli, ama "1 kitap", "12 kitap" ve "okulun 40 farklı kitaptan 25'er adetlik siparişi" gibi bir arada anlam taşıyan senaryolar için [ParamsSource] daha uygun. Bir method ya da property'den object listesi döndürür. Sonuç tablosunda object'in ToString()'i görünür, bu yüzden record'a okunur bir ad verdim:

using BenchmarkDotNet.Attributes;
using Bookstore.Ordering;

namespace Bookstore.Benchmarks;

// Adı olan bir sepet şekli: ParamsSource sonuç tablosunda ToString() değerini gösterir.
public sealed record CartShape(string Name, int Lines, int QuantityPerLine, bool Institutional)
{
    public override string ToString() => Name;
}

[MemoryDiagnoser]
public class PricingBenchmarks
{
    private PriceLine[] lines = [];

    [ParamsSource(nameof(Carts))]
    public CartShape Cart { get; set; } = null!;

    public static IEnumerable<CartShape> Carts() =>
    [
        new("1 book", 1, 1, false),
        new("12 books", 12, 1, false),
        new("school 40x25", 40, 25, true)
    ];

    [GlobalSetup]
    public void Setup()
    {
        Random random = new(42);
        lines = [.. Enumerable.Range(0, Cart.Lines)
            .Select(i => new PriceLine($"979{i:D10}", Math.Round(99m + (decimal)random.NextDouble() * 900m, 2), Cart.QuantityPerLine))];
    }

    // CartPricing'ten önceki ilk sürüm: satırlar üzerinde LINQ, iki geçiş.
    [Benchmark(Baseline = true)]
    public PriceQuote Linq()
    {
        decimal subtotal = lines.Sum(l => OrderItem.CalculateLineTotal(l.UnitPrice, l.Quantity));
        int quantity = lines.Sum(l => l.Quantity);
        decimal discount = Cart.Institutional && quantity >= CartPricing.InstitutionalMinimumQuantity
            ? Math.Round(subtotal * CartPricing.InstitutionalDiscountRate, 2, MidpointRounding.AwayFromZero)
            : 0m;
        return new PriceQuote(subtotal, discount, subtotal - discount);
    }

    [Benchmark]
    public PriceQuote Span() => CartPricing.Quote(lines, Cart.Institutional);
}

Baseline = true, diğer method'ların o method'a oranını (Ratio) hesaplatır. Baseline'ı "eski hâl" olarak seçmek okumayı kolaylaştırır: 0,47, yeni kodun eski kodun yarısından az sürdüğü anlamına gelir. [GlobalSetup] veriyi her parametre kombinasyonu için bir kez, ölçüm dışında hazırlar. Random(42) gibi sabit bir seed, iki koşunun aynı veriyle çalışmasını garanti eder. Benchmark method'larının değer döndürmesi de önemli: dönen değeri BenchmarkDotNet tüketir, böylece JIT (just-in-time; kodu çalışma anında makine koduna çeviren derleyici) hesabı "kullanılmıyor" diye silemez.

Diagnoser'lar: Memory, Exception ve Threading

[MemoryDiagnoser]'ı C# İleri 12'den biliyoruz: işlem başına allocation ve GC (garbage collector; kullanılmayan object'leri temizleyen runtime bileşeni) sayısı. Bookstore'un tedarikçi fiyat listesini içeri alan kod için [ExceptionDiagnoser] ekledim. Listedeki bazı satırlarda fiyat yerine "n/a" yazıyor:

[MemoryDiagnoser]
[ExceptionDiagnoser]
public class ImportBenchmarks
{
    private string[] rows = [];

    [Params(0, 10)]
    public int BadPercent { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        Random random = new(42);
        rows = [.. Enumerable.Range(0, 1_000).Select(i =>
            $"979{i:D10};{(random.Next(100) < BadPercent ? "n/a" : (99 + i % 900).ToString(CultureInfo.InvariantCulture) + ".90")}")];
    }

    [Benchmark(Baseline = true)]
    public decimal ParseWithCatch()
    {
        decimal total = 0m;
        foreach (string row in rows)
        {
            try
            {
                total += decimal.Parse(row.AsSpan(row.IndexOf(';') + 1), CultureInfo.InvariantCulture);
            }
            catch (FormatException)
            {
                // bozuk satırı atla
            }
        }
        return total;
    }

    [Benchmark]
    public decimal TryParse()
    {
        decimal total = 0m;
        foreach (string row in rows)
        {
            if (decimal.TryParse(row.AsSpan(row.IndexOf(';') + 1), CultureInfo.InvariantCulture, out decimal price))
            {
                total += price;
| Method         | BadPercent | Mean      | Error    | StdDev   | Ratio | RatioSD | Gen0   | Exceptions | Allocated | Alloc Ratio |
|--------------- |----------- |----------:|---------:|---------:|------:|--------:|-------:|-----------:|----------:|------------:|
| ParseWithCatch | 0          |  27.26 us | 0.241 us | 0.202 us |  1.00 |    0.01 |      - |          - |         - |          NA |
| TryParse       | 0          |  27.33 us | 0.423 us | 0.395 us |  1.00 |    0.02 |      - |          - |         - |          NA |
|                |            |           |          |          |       |         |        |            |           |             |
| ParseWithCatch | 10         | 254.33 us | 4.815 us | 4.504 us |  1.00 |    0.02 | 4.8828 |   111.0000 |   41736 B |        1.00 |
| TryParse       | 10         |  25.34 us | 0.405 us | 0.379 us |  0.10 |    0.00 |      - |          - |         - |        0.00 |

Bozuk satır yokken iki yöntem aynı. Satırların yüzde 10'u bozuk olduğunda 1.000 satırda 111 exception fırlıyor ve try/catch'li sürüm on kat yavaşlıyor, 41 KB allocation yapıyor. Exception sayısı sütunu olmadan bu farkın sebebini tahmin etmen gerekirdi. Tahmin sorusunun üçüncüsünün cevabı bu: on kat.

[ThreadingDiagnoser] ise thread pool'a giden iş sayısını (Completed Work Items) ve lock contention'ları sayar. Fiyat değişikliğinden sonra gece bütün sepetleri yeniden fiyatlayan işi sıralı ve Parallel.For ile karşılaştırdım:

// Fiyat değişikliğinden sonra gece bütün sepetleri yeniden fiyatlamak: Parallel.For değer mi?
[ThreadingDiagnoser]
public class BatchPricingBenchmarks
{
    private PriceLine[][] carts = [];
    private PriceQuote[] quotes = [];

    [Params(100, 10_000)]
    public int Carts { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        Random random = new(42);
        carts = [.. Enumerable.Range(0, Carts).Select(_ =>
            Enumerable.Range(0, 1 + random.Next(5)).Select(i => new PriceLine($"979{i:D10}", 249.90m, 1 + random.Next(3))).ToArray())];
        quotes = new PriceQuote[Carts];
    }

    [Benchmark(Baseline = true)]
    public void Sequential()
    {
        for (int i = 0; i < carts.Length; i++)
        {
            quotes[i] = CartPricing.Quote(carts[i], institutional: false);
        }
    }

    [Benchmark]
    public void Parallel() =>
        System.Threading.Tasks.Parallel.For(0, carts.Length, i => quotes[i] = CartPricing.Quote(carts[i], institutional: false));
}
| Method     | Carts | Mean       | Error     | StdDev     | Median     | Ratio | RatioSD | Completed Work Items | Lock Contentions |
|----------- |------ |-----------:|----------:|-----------:|-----------:|------:|--------:|---------------------:|-----------------:|
| Sequential | 100   |   2.389 us | 0.0474 us |  0.1050 us |   2.386 us |  1.00 |    0.06 |                    - |                - |
| Parallel   | 100   |   6.952 us | 0.4572 us |  1.2517 us |   6.419 us |  2.92 |    0.54 |               5.1395 |                - |
|            |       |            |           |            |            |       |         |                      |                  |
| Sequential | 10000 | 251.785 us | 5.0251 us |  9.0613 us | 250.766 us |  1.00 |    0.05 |                    - |                - |
| Parallel   | 10000 | 102.255 us | 3.6551 us | 10.3093 us |  98.081 us |  0.41 |    0.04 |               8.4739 |                - |

100 sepette paralel sürüm üç kat yavaş: 2,4 µs'lik iş, thread pool'a beş iş göndermenin maliyetinden küçük. 10.000 sepette aynı kod 2,5 kat hızlı. Paralelliğin işe yarayıp yaramadığı iş miktarına bağlı ve bunu ancak ölçerek bilebilirsin.

DisassemblyDiagnoser ve EventPipeProfiler

İki implementasyon arasındaki farkın nereden geldiğini görmenin bir yolu, JIT'in ürettiği makine kodunu okumak. DisassemblyDiagnoser bunu benchmark'ın içinden yapar: [DisassemblyDiagnoser(maxDepth: 2)] ya da komut satırında --disasm. Makine kodu işlemci mimarisine özel. Windows/x64 makinede alacağın çıktıyı göstermek için benchmark'ı x64 bir Linux container'ında koşturdum:

## .NET 10.0.15 (10.0.15, 10.0.1526.53008), X64 RyuJIT x86-64-v3 (Job: ShortRun(IterationCount=3, LaunchCount=1, WarmupCount=3))

; Bookstore.Ordering.CartPricing.Quote(System.ReadOnlySpan`1<Bookstore.Ordering.PriceLine>, Boolean)
       push      rbp
       ...
       call      qword ptr [7FFF8151C450]; System.Decimal+DecCalc.DecAddSub(DecCalc ByRef, DecCalc ByRef, Boolean)
       ...
       call      CORINFO_HELP_NEWSFAST
       ...
       call      qword ptr [7FFF8151C228]; System.Decimal.op_Multiply(System.Decimal, System.Decimal)
       call      qword ptr [7FFF8151C468]; System.Decimal.Round(System.Decimal ByRef, Int32, System.MidpointRounding)
; Total bytes of code 1853

Çıktıda OrderItem.CalculateLineTotal'a bir çağrı yok: JIT onu Quote'un içine inline etmiş. Döngüdeki asıl iş decimal toplamı (DecAddSub); tek allocation (CORINFO_HELP_NEWSFAST) dönen PriceQuote object'i, tablodaki 64 byte. Bu container Linux olduğu için register'lar Linux x64 calling convention'ına göre; Windows'ta parametreler farklı register'larla gelir ama komutlar aynı mantıkta. Tablo da Code Size adında yeni bir sütun kazanıyor: benchmark'ın makine kodu boyutu.

Kodun nerede zaman harcadığını method seviyesinde görmek için EventPipeProfiler'ı kullan: --profiler EP ile benchmark koşarken bir CPU sampling trace'i toplar ve speedscope'ta açılabilen bir dosya üretir:

dotnet run -c Release -f net10.0 -- --filter "Bookstore.Benchmarks.PricingBenchmarks.Span" --profiler EP --job short
// * Diagnostic Output - EventPipeProfiler *
Exported 3 trace file(s). Example:
.../Bookstore.Benchmarks.PricingBenchmarks.Span(Cart_ 1 book)-20261114-174358.speedscope.json

"school 40x25" trace'inde örneklerin dağılımı, managed frame'lere göre:

 71.7%  Bookstore.Api!Bookstore.Ordering.CartPricing.Quote(value class System.ReadOnlySpan`1<class Bookstore.Ordering.PriceLine>,bool)
 22.0%  System.Private.CoreLib!System.Decimal+DecCalc.DecAddSub(value class DecCalc&,value class DecCalc&,bool)
  2.4%  Bookstore.Benchmarks-ShortRun-1!BenchmarkDotNet.Autogenerated.Runnable_2.OverheadActionUnroll(int64)
  1.6%  Bookstore.Benchmarks!Bookstore.Benchmarks.PricingBenchmarks.Span()

Trace'i okumanın, flame graph'ı yorumlamanın ayrıntıları ".NET'te Profiling ve Diagnostics" serisinin "CPU Profiling: dotnet-trace ile Flame Graph Okumak" yazısında. Benchmark'tan trace üretmenin farkı, ölçtüğün kodun tam olarak benchmark method'u olması: uygulamanın geri kalanının gürültüsü yok.

.NET 10 ile .NET 11'i Karşılaştırmak

Aynı benchmark'ı iki runtime'da koşturmanın yolu, birden fazla job tanımlamak. Komut satırında bu --runtimes ile yapılır. 0.15.8 ile denediğimde:

dotnet run -c Release -f net10.0 -- --filter "*PricingBenchmarks*" --runtimes net10.0 net11.0
The provided runtime "net11.0" is invalid. Available options are: hostprocess, notrecognized, mono, ...
net70, net80, net90, net10_0, nativeaot60, ...

0.15.8, .NET 11 GA olmadan önce, Kasım 2025'te çıktı ve net11.0'ı tanımıyor. Job'u kodda CoreRuntime.CreateForNewVersion("net11.0", ".NET 11.0") ile kurmayı da denedim; bu sefer SDK doğrulaması NotImplementedException fırlattı. .NET 11 desteği 0.16.0-preview.1 ile geldi. Karşılaştırmayı 0.16.0-preview.2 ile yaptım. Paket sürümünü Directory.Packages.props'ta bir property'ye bağladım, böylece stabil sürüm varsayılan kalıyor:

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <!-- 0.15.8 son stabil sürüm. .NET 11 job'ları 0.16 ister: BenchmarkDotNetVersion environment variable'ını 0.16.0-preview.2 yap. -->
    <BenchmarkDotNetVersion Condition="'$(BenchmarkDotNetVersion)' == ''">0.15.8</BenchmarkDotNetVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.15" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.15" />
    <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.15" />
    <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.15" />
    <PackageVersion Include="xunit.v3" Version="4.0.3" />
    <PackageVersion Include="WireMock.Net" Version="2.31.0" />
    <PackageVersion Include="BenchmarkDotNet" Version="$(BenchmarkDotNetVersion)" />
  </ItemGroup>
</Project>
# PowerShell: BenchmarkDotNet'in kendi build'leri de aynı sürümü görsün diye environment variable olarak
$env:BenchmarkDotNetVersion = "0.16.0-preview.2"
dotnet run -c Release -f net10.0 -- --filter "Bookstore.Benchmarks.PricingBenchmarks.*" --runtimes net10.0 net11.0
| Method | Job       | Runtime   | Cart         | Mean      | Error    | StdDev    | Median    | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|------- |---------- |---------- |------------- |----------:|---------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| Linq   | .NET 10.0 | .NET 10.0 | 1 book       |  32.32 ns | 0.771 ns |  2.211 ns |  31.57 ns |  1.00 |    0.00 | 0.0153 |     128 B |        1.00 |
| Span   | .NET 10.0 | .NET 10.0 | 1 book       |  15.13 ns | 0.352 ns |  1.011 ns |  14.81 ns |  0.47 |    0.04 | 0.0076 |      64 B |        0.50 |
| Linq   | .NET 11.0 | .NET 11.0 | 1 book       |  20.04 ns | 0.386 ns |  0.940 ns |  19.82 ns |  0.62 |    0.05 | 0.0115 |      96 B |        0.75 |
| Span   | .NET 11.0 | .NET 11.0 | 1 book       |  15.54 ns | 0.393 ns |  1.152 ns |  15.44 ns |  0.48 |    0.05 | 0.0076 |      64 B |        0.50 |
|        |           |           |              |           |          |           |           |       |         |        |           |             |
| Linq   | .NET 10.0 | .NET 10.0 | 12 books     | 139.44 ns | 2.772 ns |  3.300 ns | 139.71 ns |  1.00 |    0.00 | 0.0153 |     128 B |        1.00 |
| Span   | .NET 10.0 | .NET 10.0 | 12 books     |  64.17 ns | 1.003 ns |  0.837 ns |  64.14 ns |  0.46 |    0.01 | 0.0076 |      64 B |        0.50 |
| Linq   | .NET 11.0 | .NET 11.0 | 12 books     |  89.54 ns | 1.748 ns |  2.508 ns |  89.05 ns |  0.64 |    0.02 | 0.0114 |      96 B |        0.75 |
| Span   | .NET 11.0 | .NET 11.0 | 12 books     |  64.03 ns | 1.532 ns |  4.320 ns |  63.64 ns |  0.46 |    0.03 | 0.0076 |      64 B |        0.50 |
|        |           |           |              |           |          |           |           |       |         |        |           |             |
| Linq   | .NET 10.0 | .NET 10.0 | school 40x25 | 422.87 ns | 8.304 ns | 20.834 ns | 418.59 ns |  1.00 |    0.00 | 0.0153 |     128 B |        1.00 |
| Span   | .NET 10.0 | .NET 10.0 | school 40x25 | 212.95 ns | 7.282 ns | 20.658 ns | 206.75 ns |  0.50 |    0.05 | 0.0076 |      64 B |        0.50 |
| Linq   | .NET 11.0 | .NET 11.0 | school 40x25 | 296.01 ns | 9.266 ns | 27.175 ns | 289.69 ns |  0.70 |    0.07 | 0.0114 |      96 B |        0.75 |
| Span   | .NET 11.0 | .NET 11.0 | school 40x25 | 192.08 ns | 3.349 ns |  5.687 ns | 190.70 ns |  0.46 |    0.03 | 0.0076 |      64 B |        0.50 |

İlk iki tahmin sorusunun cevabı. .NET 10'da döngülü sürüm LINQ'in yaklaşık yarısı kadar sürüyor ve yarısı kadar allocation yapıyor. .NET 11'e geçmek ikisini aynı oranda hızlandırmıyor: LINQ sürümü yüzde 30-38 hızlanıyor ve 128 yerine 96 byte allocate ediyor, döngülü sürüm ise neredeyse aynı kalıyor. Runtime'ın kazancı, LINQ'in delegate ve enumerator maliyetinde. Zaten sade olan döngüde kazanılacak bir şey kalmamış. Yine de .NET 11'deki LINQ, .NET 10'daki döngüye yetişmiyor.

Tablonun altındaki iki uyarıyı da oku. "MultimodalDistribution", ölçümlerin tek bir tepe etrafında değil birden fazla grupta toplandığını söylüyor; arka planda başka iş yükü olan bir makinede sık görülür. "Outliers" satırları ise BenchmarkDotNet'in varsayılan olarak üst uçtaki aykırı ölçümleri attığını gösteriyor. Mutlak değerler makineye ve işlemci mimarisine özel; senin makinende farklı çıkar. Taşınabilir olan oranlar ve allocation byte'ları.

CI'da Regresyon Yakalamak

Benchmark'ı CI'da koşturup "Mean 10 ns arttı" diye alarm vermek işe yaramaz. 04'te konuştuğumuz paylaşımlı runner gürültüsü burada nanosaniyeler seviyesinde etkili: aynı benchmark iki koşu arasında yüzde 10-20 oynayabilir. İşe yarayan iki şey var. Birincisi göreli karşılaştırma: baseline ile yeni kodu aynı job'da, aynı makinede, art arda ölç. Bu yazıdaki Ratio sütunu tam olarak bu. İkincisi istatistiksel karşılaştırma: iki koşunun yalnız ortalamasını değil bütün ölçümlerini karşılaştırıp farkın gürültüden büyük olup olmadığını test et. dotnet/performance reposundaki ResultsComparer bunu yapıyor. BenchmarkDotNet'in fulljson export'unu okuyor ve iki sonuç kümesini bir eşikle karşılaştırıyor.

Denemek için CartPricing'e bir regresyon soktum. "Daha okunur" olsun diye satırları bir listeye kopyalayıp LINQ ile toplayan bir değişiklik:

    public static PriceQuote Quote(ReadOnlySpan<PriceLine> lines, bool institutional)
    {
        // "Daha okunur": satırların liste kopyası üzerinde LINQ.
        List<PriceLine> items = [.. lines];
        decimal subtotal = items.Sum(l => OrderItem.CalculateLineTotal(l.UnitPrice, l.Quantity));
        int quantity = items.Sum(l => l.Quantity);
# main'deki kod
dotnet run -c Release -f net10.0 -- --filter "Bookstore.Benchmarks.PricingBenchmarks.Span" --exporters fulljson --artifacts results/base
# değişiklikten sonra
dotnet run -c Release -f net10.0 -- --filter "Bookstore.Benchmarks.PricingBenchmarks.Span" --exporters fulljson --artifacts results/diff

# dotnet/performance/src/tools/ResultsComparer
dotnet run -c Release -p:PERFLAB_TARGET_FRAMEWORKS=net10.0 -f net10.0 -- --base results/base --diff results/diff --threshold 5%
main:        | Method | Cart         | Mean      | Error    | StdDev   | Gen0   | Allocated |
             | Span   | 1 book       |  11.78 ns | 0.225 ns | 0.211 ns | 0.0076 |      64 B |
             | Span   | 12 books     |  55.76 ns | 0.658 ns | 0.549 ns | 0.0076 |      64 B |
             | Span   | school 40x25 | 163.36 ns | 0.958 ns | 0.800 ns | 0.0076 |      64 B |
change:      | Span   | 1 book       |  25.20 ns | 0.510 ns | 1.086 ns | 0.0201 |     168 B |
             | Span   | 12 books     | 105.93 ns | 1.925 ns | 1.608 ns | 0.0305 |     256 B |
             | Span   | school 40x25 | 315.18 ns | 6.255 ns | 11.748 ns | 0.0572 |     480 B |

Değişiklik her sepet tipinde süreyi yaklaşık iki katına çıkarıyor (2,1, 1,9 ve 1,9 kat) ve allocation'ı 64 byte'tan 168-480 byte'a çıkarıyor. Bu fark gürültüden açıkça büyük; Error sütunları (güven aralığının yarısı) iki koşuda da ortalamanın yüzde 1-4'ü. Aynı makinede, aynı gün aldığım diğer koşularda bu benchmark'ın değeri 15 ns'ye kadar çıkmıştı. Bu da tek bir sayının neden yetmediğini gösteriyor. ResultsComparer'ı bu yazıda çalıştırmadım. README'sine göre iki klasördeki *full.json dosyalarını tam benchmark adıyla eşleştiriyor, verdiğin eşik ve bir istatistiksel testle "Slower" ve "Faster" tabloları üretiyor, eşiğin altındaki ya da gürültü seviyesindeki farkları göstermiyor.

Dördüncü tahmin sorusunun cevabı bu yöntem: ResultsComparer farkı eşikle birlikte istatistiksel bir testle değerlendiriyor ve yalnız anlamlı farkları "Slower" ya da "Faster" olarak listeliyor. CI'da bu tabloda "Slower" satırı varsa job'u başarısız saymak, ya da PR'a (pull request; değişikliğin ana branch'e alınması için açılan istek) yorum olarak yazmak bir sonraki adım. Eşiği gürültüye göre seç: paylaşımlı runner'da yüzde 5 çok sıkı olabilir; nightly koşuların kendi aralarındaki farkına bakıp normal oynama aralığını öğren. Aynı runner'da hem base hem diff'i art arda koşturmak, iki koşu arasındaki donanım farkını ortadan kaldırır.

Sürüm Notları: 0.15.7 Analyzer'ları, 0.15.8 ve 0.16 Preview

0.15.7 (Kasım 2025) BenchmarkDotNet'e Roslyn analyzer'ları ekledi: benchmark class'ının yapısını, [Arguments] ve [Params] kullanımını, baseline'ın tekliğini ve BenchmarkRunner.Run çağrılarını derleme sırasında kontrol ediyorlar. Hatalı bir benchmark artık koşarken değil, derlenirken yakalanıyor. Örneğin iki method'u birden baseline yaptığımda:

PricingBenchmarks.cs(36,16): error BDN1107: Only one benchmark method can be marked as baseline per class
PricingBenchmarks.cs(47,16): error BDN1107: Only one benchmark method can be marked as baseline per class

Analyzer bunu doğrudan hata olarak raporluyor ve build duruyor. 0.15.8 (Kasım 2025) analyzer'ları birden fazla hedef framework için düzeltti, argüman ve parametre doğrulaması için yeni bir kural ekledi ve bir OpenMetrics exporter'ı getirdi. 0.16.0'ın iki preview'u Eylül 2026'da çıktı: .NET 11 desteği, .NET 12 runtime ve toolchain desteği, OSR'ın (On-Stack Replacement; uzun süren bir döngünün çalışırken optimize edilmiş koda geçirilmesi) varsayılan olarak kapatılması ve eski API'lerin kaldırılması bunlardan bazıları. Bu yazı yazılırken 0.16 stabil değil; production benchmark'ların için 0.15.8'de kal, .NET 11 karşılaştırması gibi ihtiyaçlar için preview'u yukarıdaki gibi yalnız o koşuda kullan.

Üretimde Dikkat

BenchmarkDotNet mikro seviyede, tek bir method'un davranışını ölçer. I/O, veritabanı, ağ ve eşzamanlı kullanıcıların uçtan uca davranışı onun işi değil; o load test'in işi. Fiyat hesabının yarıya inmesi checkout'un p95'ini ancak fiyat hesabı hot path'teyse değiştirir. 06'daki ölçümde checkout'un süresinin büyük kısmı veritabanı ve ödeme stub'ındaydı. Bu yüzden benchmark'tan çıkan sonucu load test'le doğrula. Profiling serisinin "Diagnose, Fix, Prove" yazısı bu iki ölçümü birlikte kullanmanın örneği.

Benchmark'ları güç tasarrufu modunda bir dizüstünde, tarayıcı ve IDE açıkken koşturma; BenchmarkDotNet bazı durumlarda uyarı verir ama her şeyi yakalayamaz. Sonuçları raporlarken ortamı belirt: runtime sürümü, işlemci mimarisi, job ayarları. Bu yazıdaki tablolarda donanım başlığını bilerek çıkardım. Mutlak sayılar makineye özel, karşılaştırmalar aynı koşudaki oranlar üzerinden.

Sık Düşülen Tuzaklar

  • Debug build'de ya da debugger bağlıyken benchmark koşturmak.
  • Benchmark method'unun sonucunu döndürmemek ve JIT'in hesabı silmesine izin vermek.
  • Farklı makinelerde ya da farklı günlerde alınmış iki sonucun mutlak değerlerini karşılaştırmak.
  • Paylaşımlı CI runner'ında sabit bir nanosaniye eşiğiyle alarm vermek; göreli ve istatistiksel karşılaştırma kullan.
  • Mikro benchmark'taki iyileşmeyi uçtan uca iyileşme sanmak.
  • Preview bir BenchmarkDotNet sürümünü bütün projeye yaymak; yalnız ihtiyaç duyan koşuda kullan.
  • Multimodal dağılım ve outlier uyarılarını okumadan Mean'e bakmak.

Küçük Bir Deneme

★☆☆ PricingBenchmarks'a [Params(false, true)] ile Institutional diye ikinci bir parametre ekle ve ParamsSource'taki alanı kaldır. Tablodaki satır sayısı nasıl değişti? Hangi gösterim daha okunur?

★★☆ ImportBenchmarks'ı .NET 10 ve .NET 11'de karşılaştır. Exception fırlatmanın maliyeti runtime'la değişiyor mu?

★★★ GitHub Actions'a bir job ekle: main ve PR branch'ini aynı runner'da art arda benchmark'lasın, ResultsComparer'ı çalıştırsın ve "Slower" satırı varsa job'u başarısız saysın. Aynı commit'i iki kez karşılaştırdığında kaç yanlış alarm aldığını ölç ve eşiği buna göre ayarla.

Seri burada bitiyor. Load test'in türlerini ve kabul kriterini, gerçekçi bir workload model'i, k6 ve JMeter'ı, CI'daki gate'i, sonuçları okumayı ve mikro seviyede regresyonu konuştuk. Ortak nokta şu: performans hakkındaki her cümlenin arkasında bir yük, bir ortam ve tekrarlanabilir bir ölçüm olmalı.