Mert Özen Her Satırda Daha İleri
Backend C# İleri Seviye — #12

C#'ta Performans: Allocation, Struct'lar ve BenchmarkDotNet ile Ölçmek

Mert Özen 7 Kas 2026 10 dk 3 görüntülenme
C#'ta Performans: Allocation, Struct'lar ve BenchmarkDotNet ile Ölçmek

Stopwatch'un neden yetmediği, BenchmarkDotNet 0.15.8 kurulumu ve tabloyu okumak, class ile struct'ın ölçülmüş farkı, boxing ve closure kaynaklı gizli allocation'lar ve benchmark tuzakları.

Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.

Önce Tahmin Et

Aynı List<int>'i toplayan iki method. Gövdeleri karakteri karakterine aynı; tek fark parametre tipi. Biri List<int> alıyor, diğeri IEnumerable<int>. Bin element'li bir listede aralarında hız ya da bellek farkı olur mu? Olursa ne kadar?

private static int Sum(List<int> items)
{
    int total = 0;
    foreach (int item in items)
        total += item;
    return total;
}

private static int Sum(IEnumerable<int> items)
{
    int total = 0;
    foreach (int item in items)
        total += item;
    return total;
}

Cevabı yazının ortasında, ölçerek vereceğiz. Önce "ölçmek" ne demek, ona bakalım.

Stopwatch Neden Yetmez

İlk akla gelen, kodu bir döngüde çalıştırıp Stopwatch ile süre tutmak:

using System.Diagnostics;
using System.Globalization;

const string line = "978-0441013593;Dune;9.99;12";

for (int round = 1; round <= 3; round++)
{
    var sw = Stopwatch.StartNew();
    decimal total = 0;
    for (int i = 0; i < 1_000; i++)
        total += Parse(line);
    sw.Stop();
    Console.WriteLine($"round {round}: {sw.Elapsed.TotalMilliseconds.ToString("F3", CultureInfo.InvariantCulture)} ms");
}

static decimal Parse(string text)
{
    string[] parts = text.Split(';');
    return decimal.Parse(parts[2], CultureInfo.InvariantCulture) * int.Parse(parts[3]);
}
round 1: 4.255 ms
round 2: 0.111 ms
round 3: 0.112 ms

Aynı kod, aynı veri; ilk tur diğerlerinden yaklaşık kırk kat yavaş. Sebep kodun kendisi değil, runtime. İlk turda JIT method'ları makine koduna çeviriyor, decimal.Parse'ın kültür verisi yükleniyor, tiered compilation önce hızlı ama az optimize kod üretiyor ve sık çalışan method'ları sonra yeniden derliyor. Buna garbage collector'ın ne zaman devreye gireceği, işlemcinin frekans değiştirmesi ve makinede o an çalışan diğer işler eklenince tek bir Stopwatch ölçümü neredeyse hiçbir şey söylemez.

Bir de compiler ve JIT'in akıllılığı var: sonucunu kullanmadığın bir hesaplamayı JIT tamamen silebilir ve "sıfır nanosaniye" ölçersin. İyi bir ölçüm aracı ısınma turları atmalı, ölçümü yeterince tekrarlamalı, istatistik çıkarmalı, sonuçların kullanıldığından emin olmalı ve bellek tahsisini de raporlamalı. .NET dünyasında bu aracın adı BenchmarkDotNet.

BenchmarkDotNet'i Kurmak

Benchmark'lar ayrı bir console projesinde yaşar. Paketi eklemek yeterli:

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

Program.cs tek satır; BenchmarkSwitcher assembly'deki bütün benchmark class'larını bulur ve komut satırından hangisinin çalışacağını seçmene izin verir:

using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(ParseBenchmarks).Assembly).Run(args);

Benchmark mutlaka Release build ile çalışır. Normalde dotnet run -c Release yeter; bu makinede .NET 6 runtime olmadığı için şöyle çalıştırdım:

dotnet build -c Release
DOTNET_ROLL_FORWARD=Major dotnet bin/Release/net6.0/Bookstore.Benchmarks.dll --filter "*"

Bir not: BenchmarkDotNet 0.15.8'in bazı bağımlılıkları artık net6.0'ı resmi olarak desteklemiyor ve build sırasında System.CodeDom 9.0.5 doesn't support net6.0 and has not been tested with it. gibi uyarılar veriyor. Ölçüm yine çalıştı, ama .NET 6 zaten destek dışı; gerçek bir projede benchmark projesini desteklenen bir runtime'a (net8.0 ya da net10.0) hedeflemek doğru olan. Aşağıdaki bütün sayılar .NET 8.0.25 runtime'ında koştu.

İlk Ölçüm: Split ve Span

Span yazısında allocation farkını saymıştık; şimdi süreyi de ölçelim. Bir benchmark class'ı sıradan bir class: her [Benchmark] method'u bir ölçüm, Baseline = true olan karşılaştırmanın referansı, [MemoryDiagnoser] ise allocation sütunlarını ekler:

using System.Globalization;
using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class ParseBenchmarks
{
    private const string Line = "978-0441013593;Dune;9.99;12";

    [Benchmark(Baseline = true)]
    public decimal Split()
    {
        string[] parts = Line.Split(';');
        decimal price = decimal.Parse(parts[2], CultureInfo.InvariantCulture);
        int stock = int.Parse(parts[3]);
        return price * stock;
    }

    [Benchmark]
    public decimal Span()
    {
        ReadOnlySpan<char> rest = Line.AsSpan();
        for (int skip = 0; skip < 2; skip++)
            rest = rest[(rest.IndexOf(';') + 1)..];

        int sep = rest.IndexOf(';');
        decimal price = decimal.Parse(rest[..sep], provider: CultureInfo.InvariantCulture);
        int stock = int.Parse(rest[(sep + 1)..]);
        return price * stock;
    }
}

Method'lar sonucu döndürüyor. Bu önemli: BenchmarkDotNet dönen değeri "kullanır", böylece JIT hesaplamayı silemez. Ölçümün yapıldığı makine ve runtime, raporun en üstünde:

BenchmarkDotNet v0.15.8, macOS 27.0.1 (26A434) [Darwin 27.0.0]
Apple M5, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.402
  [Host]     : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a
  DefaultJob : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a
| Method | Mean     | Error    | StdDev   | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|------- |---------:|---------:|---------:|------:|--------:|-------:|----------:|------------:|
| Split  | 52.45 ns | 0.756 ns | 0.670 ns |  1.00 |    0.02 | 0.0249 |     208 B |        1.00 |
| Span   | 27.45 ns | 0.196 ns | 0.174 ns |  0.52 |    0.01 |      - |         - |        0.00 |

Tabloyu Okumak

Mean, bir çağrının ortalama süresi: Split 52 nanosaniye, Span 27 nanosaniye. Error ve StdDev ölçümün ne kadar oynadığını gösterir; ortalamanın yanında küçük kaldıkları sürece sonuca güvenebilirsin. Ratio baseline'a oran: Span, Split'in yaklaşık yarısı kadar sürüyor (0.52). Gen0, bin çağrı başına generation 0 toplama sayısı; Allocated ise çağrı başına ayrılan bellek. Span yazısında GC.GetAllocatedBytesForCurrentThread() ile saydığımız 208 byte burada da aynı.

Sayıları mutlak değil, göreli oku. Bu ölçümler bir Apple M5'te, makinede başka build'ler paralel çalışırken alındı; aynı benchmark'ı sakin bir makinede ya da başka bir işlemcide koşarsan nanosaniyeler değişir. Allocation sütunu ise neredeyse hiç değişmez; o yüzden ilk baktığım sütun genelde Allocated. Ratio da makineler arasında süreden çok daha kararlıdır.

class mı struct mı

Sipariş satırlarını modellerken class ile struct arasındaki farkı ölçelim. Aynı iki property'li tipi iki şekilde tanımlayıp bin tanesini bir array'e dolduruyoruz ve topluyoruz:

using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class ShapeBenchmarks
{
    private const int Count = 1_000;

    [Benchmark(Baseline = true)]
    public decimal Classes()
    {
        var lines = new OrderLineClass[Count];
        for (int i = 0; i < Count; i++)
            lines[i] = new OrderLineClass(9.99m, i % 5 + 1);

        decimal total = 0;
        foreach (OrderLineClass line in lines)
            total += line.Price * line.Quantity;
        return total;
    }

    [Benchmark]
    public decimal Structs()
    {
        var lines = new OrderLineStruct[Count];
        for (int i = 0; i < Count; i++)
            lines[i] = new OrderLineStruct(9.99m, i % 5 + 1);

        decimal total = 0;
        foreach (OrderLineStruct line in lines)
            total += line.Price * line.Quantity;
        return total;
    }
}

public sealed class OrderLineClass
{
    public OrderLineClass(decimal price, int quantity)
    {
        Price = price;
        Quantity = quantity;
    }

    public decimal Price { get; }
    public int Quantity { get; }
}

public readonly struct OrderLineStruct
{
    public OrderLineStruct(decimal price, int quantity)
    {
        Price = price;
        Quantity = quantity;
    }

    public decimal Price { get; }
    public int Quantity { get; }
}
| Method  | Mean     | Error     | StdDev    | Ratio | Gen0   | Gen1   | Allocated | Alloc Ratio |
|-------- |---------:|----------:|----------:|------:|-------:|-------:|----------:|------------:|
| Classes | 7.830 us | 0.0488 us | 0.0433 us |  1.00 | 5.7373 | 0.3510 |   46.9 KB |        1.00 |
| Structs | 5.354 us | 0.0142 us | 0.0119 us |  0.68 | 2.8610 |      - |  23.46 KB |        0.50 |

Class versiyonu 46.9 KB ayırdı: array'in kendisi (bin referans) artı heap'te bin ayrı object; her biri object header'ı, bir decimal ve bir int taşıyor. Struct versiyonunda object yok; değerler doğrudan array'in içinde, art arda duruyor ve toplam bellek yarıya indi. Süre de yüzde otuz kısaldı. Gen1 sütununa da bak: class versiyonunda bazı object'ler ilk toplamadan sağ çıkıp bir üst nesle terfi etti; GC için ek iş.

Bu "her şeyi struct yap" demek değil. Struct'lar kopyalanarak taşınır; büyük bir struct'ı sık sık method'lara geçirmek ya da collection'lardan okumak, kazandığından fazlasını kaybettirebilir. ref yazısında gördüğümüz in ve readonly struct bu yüzden var. Kural: küçük, immutable, çok sayıda oluşturulan veri (sipariş satırı, koordinat, para tutarı) struct'a iyi aday; kimliği olan, değişen, paylaşılan şeyler class.

Gizli Allocation'lar

Şimdi açılıştaki soruya dönelim. İki toplama method'una bir de LINQ ile ve elle yazılmış bir sayma ekledik:

using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class HiddenAllocationBenchmarks
{
    private List<int> _stock = new();

    [Params(5)]
    public int Threshold { get; set; }

    [GlobalSetup]
    public void Setup() => _stock = Enumerable.Range(0, 1_000).Select(i => i % 10).ToList();

    [Benchmark(Baseline = true)]
    public int SumList() => Sum(_stock);

    [Benchmark]
    public int SumEnumerable() => Sum((IEnumerable<int>)_stock);

    [Benchmark]
    public int CountWithLambda()
    {
        int threshold = Threshold;
        return _stock.Count(s => s < threshold);
    }

    [Benchmark]
    public int CountWithLoop()
    {
        int count = 0;
        foreach (int s in _stock)
        {
            if (s < Threshold)
                count++;
        }
        return count;
    }

    private static int Sum(List<int> items)
    {
        int total = 0;
        foreach (int item in items)
            total += item;
        return total;
    }

    private static int Sum(IEnumerable<int> items)
    {
        int total = 0;
        foreach (int item in items)
            total += item;
        return total;
    }
}
| Method          | Threshold | Mean       | Error    | StdDev   | Median     | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|---------------- |---------- |-----------:|---------:|---------:|-----------:|------:|--------:|-------:|----------:|------------:|
| SumList         | 5         |   461.0 ns |  9.16 ns | 20.48 ns |   464.3 ns |  1.00 |    0.07 |      - |         - |          NA |
| SumEnumerable   | 5         | 2,167.8 ns | 43.07 ns | 73.14 ns | 2,184.3 ns |  4.71 |    0.28 | 0.0038 |      40 B |          NA |
| CountWithLambda | 5         | 2,427.4 ns | 47.15 ns | 56.13 ns | 2,433.9 ns |  5.28 |    0.29 | 0.0153 |     128 B |          NA |
| CountWithLoop   | 5         |   431.4 ns |  8.58 ns | 18.28 ns |   441.5 ns |  0.94 |    0.06 |      - |         - |          NA |

Gövdesi aynı olan iki method arasında 4.7 kat fark var ve IEnumerable<int> alan her çağrıda 40 byte ayırıyor. List<T>'in enumerator'ü bir struct. Parametre List<int> olduğunda compiler foreach'i bu struct üzerinden kurar; çağrılar doğrudan ve JIT bunları inline edebilir. Parametre IEnumerable<int> olduğunda GetEnumerator() interface üzerinden çağrılır ve IEnumerator<int> döner: struct heap'e kutulanır (boxing, 40 byte) ve her MoveNext ile Current bir interface çağrısına dönüşür.

CountWithLambda'da 128 byte var: LINQ'in Count'u da kaynağı IEnumerable<T> olarak dolaşıyor (aynı 40 byte), üstüne lambda local bir değişkeni yakaladığı için her çağrıda bir closure object'i ve bir delegate oluşuyor. Elle yazılmış döngü sıfır allocation ve beş kat hızlı.

Buradan çıkan ders "LINQ kullanma" değil. Bin element'te 2 mikrosaniye, bir web isteğinin toplam süresinin yanında hiçbir şey. Ders şu: sıcak bir yolda, saniyede milyonlarca kez çalışan bir kodda bu küçük maliyetler birikir ve sen onları ancak ölçerek görürsün. CountWithLoop satırının altındaki MultimodalDistribution uyarısı da not edilmeli: BenchmarkDotNet süre dağılımında iki tepe gördü. Arka planda çalışan diğer işler bunun olası sebebi; böyle bir uyarı gördüğünde ölçümü tekrarlarsın.

Sık Düşülen Tuzaklar

Debug build ile ölçmek. Debug build'de optimizasyon kapalıdır ve sonuçlar anlamsızdır. BenchmarkDotNet bunu kendisi yakalar ve çalışmayı reddeder:

//    * Assembly Bookstore.Benchmarks which defines benchmarks is non-optimized
Benchmark was built without optimization enabled (most probably a DEBUG configuration). Please, build it in RELEASE.

Sonucu kullanmamak. Benchmark method'u void ise ve hesapladığı değeri bir yere koymuyorsa JIT hesaplamayı silebilir. Değeri döndür. LINQ'te bunun özel bir hâli var: _stock.Where(...) döndüren bir benchmark sorguyu hiç çalıştırmaz, sadece sorguyu tarif eden object'i oluşturur (deferred execution). Ölçtüğün şey birkaç nanosaniyelik bir object oluşturma olur. ToList(), Count() ya da bir toplama ile sorguyu çalıştır.

Kurulum maliyetini ölçmek. Test verisini benchmark method'unun içinde oluşturursan, verinin hazırlanmasını da ölçersin. Hazırlığı [GlobalSetup]'a koy; yukarıdaki listeyi böyle oluşturduk. Farklı boyutları denemek için [Params] kullan.

Mikro sonucu uygulamaya genellemek. "Span iki kat hızlı" bu benchmark için doğru; uygulamanın iki kat hızlanacağı anlamına gelmez. Bir isteğin 50 milisaniyesi veritabanında geçiyorsa, parse'tan kazanılan 25 nanosaniye görünmez. Önce bir profiler ya da gerçek metriklerle darboğazın nerede olduğunu bul, sonra o noktayı BenchmarkDotNet ile mikro düzeyde iyileştir.

Tek koşuya güvenmek. Ölçümleri paylaşmadan önce en az bir kez tekrarla, makine bilgisini raporla ve mümkünse sakin bir makinede koş. LINQ serisinin performans yazısında aynı aracı LINQ sorgularına uyguluyoruz.

Küçük Bir Deneme

★☆☆ ShapeBenchmarks'a [Params(10, 1_000, 100_000)] ile bir Count property'si ekle ve sabiti kaldır. Boyut büyüdükçe Ratio ve Gen1/Gen2 sütunları nasıl değişiyor?

★★☆ CountWithLambda'yı, local değişken yerine bir const int kullanan bir lambda ile tekrar yaz. Allocation kaç byte'a düştü? Kalan byte'lar nereden geliyor?

★★★ OrderLineStruct'a altı tane daha decimal field ekleyip struct'ı büyüt ve element'leri foreach yerine bir method'a değer olarak geçirerek topla. Sonra aynı method'u in parametresiyle yaz. Üç versiyonu (class, büyük struct değerle, büyük struct in ile) ölç ve sonucu yorumla.

Performansı ölçerek konuşmayı öğrendik. Bir sonraki yazıda uygulamanın parçalarını birbirine nasıl bağladığımıza geçiyoruz: Microsoft.Extensions.DependencyInjection ile dependency injection'ın temelleri, lifetime'lar ve captive dependency tuzağı.