LINQ Performansı: Çoklu Enumeration, Materialize ve BenchmarkDotNet ile Ölçmek
Aynı sorgunun farkında olmadan kaç kez çalıştığı, ToList'in ne zaman kurtarıp ne zaman zarar verdiği, Count() > 0 yerine Any() ve TryGetNonEnumeratedCount; BenchmarkDotNet 0.15.8 ile döngü içindeki Where'in lookup'a göre 50 kat yavaşlığını ve gürültülü bir makinede döngü ile LINQ karşılaştırmasını gerçek sayılarla ölçmek.
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
Dört kitaplık bir listede fiyatı 40'ın üzerinde olanları filtreleyen bir sorgu var. Predicate'in içine bir sayaç koyduk. Önce sonuç var mı diye bakıyoruz, varsa kaç tane olduğunu yazıyoruz, sonra da başlıkları listeliyoruz. Sence son satırda calls kaç olur?
var books = new List<Book>
{
new("978-0132350884", "Clean Code", 37.90m, 12),
new("978-0135957059", "The Pragmatic Programmer", 42.50m, 0),
new("978-0321125217", "Domain-Driven Design", 54.00m, 3),
new("978-0201633610", "Design Patterns", 49.90m, 7),
};
int calls = 0;
IEnumerable<Book> expensive = books.Where(b =>
{
calls++;
return b.Price > 40m;
});
if (expensive.Any())
Console.WriteLine($"Count: {expensive.Count()}");
foreach (Book book in expensive)
Console.WriteLine(book.Title);
Console.WriteLine($"Predicate calls: {calls}");
public record Book(string Isbn, string Title, decimal Price, int Stock);
Cevap: Aynı Sorgu Üç Kez Çalıştı
Count: 3
The Pragmatic Programmer
Domain-Driven Design
Design Patterns
Predicate calls: 10
Dört kitap için predicate on kez çağrıldı. expensive bir liste değil, bir sorgu; üç farklı yerde kullandık ve üçünde de baştan çalıştı. Any() ilk eşleşmeyi bulana kadar iki kitaba baktı. Count() dört kitabın hepsine baktı. foreach da dördüne yeniden baktı. 2 + 4 + 4 = 10.
Bu yazının konusu tam olarak bu: LINQ'in kendisi nadiren yavaştır, ama onu nasıl kullandığın kolayca pahalı olabilir. Önce bu maliyetleri görünür yapacağız, sonra sezgiye güvenmek yerine BenchmarkDotNet ile ölçeceğiz.
Çoklu Enumeration
Bellekteki dört kitap için on predicate çağrısı önemsiz. Kaynak bir veritabanı sorgusu, bir HTTP çağrısı ya da bir dosya olduğunda iş değişir:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
IEnumerable<Order> orders = LoadOrders();
Console.WriteLine($"Orders: {orders.Count()}");
Console.WriteLine($"Revenue: {orders.Sum(o => o.Total)}");
Console.WriteLine($"Largest: {orders.Max(o => o.Total)}");
Console.WriteLine("--- materialized ---");
List<Order> loaded = LoadOrders().ToList();
Console.WriteLine($"Orders: {loaded.Count}");
Console.WriteLine($"Revenue: {loaded.Sum(o => o.Total)}");
Console.WriteLine($"Largest: {loaded.Max(o => o.Total)}");
// Veritabanından okuyormuş gibi davranan kaynak
static IEnumerable<Order> LoadOrders()
{
Console.WriteLine(" (query executed)");
yield return new Order(1, 7, 80.40m);
yield return new Order(2, 3, 37.90m);
yield return new Order(3, 7, 54.00m);
}
public record Order(int Id, int CustomerId, decimal Total);
(query executed)
Orders: 3
(query executed)
Revenue: 172.30
(query executed)
Largest: 80.40
--- materialized ---
(query executed)
Orders: 3
Revenue: 172.30
Largest: 80.40
İlk bölümde (query executed) üç kez yazıldı: sayı, toplam ve en büyük değer için kaynak üç kez baştan okundu. Gerçek bir uygulamada bu, aynı SQL sorgusunun veritabanına üç kez gitmesi demek. Daha kötüsü, iki okuma arasında veri değişebilir ve sayı ile toplam birbirini tutmayan iki ayrı anın fotoğrafı olur. İkinci bölümde ToList() ile sonucu bir kez materialize ettik; sorgu bir kez çalıştı ve geri kalan her şey bellekteki liste üzerinden yapıldı.
Bir yemek tarifi düşün. IEnumerable<T> tarifin kendisi; her okuyuşunda yemeği yeniden pişirirsin. ToList() yemeği bir kez pişirip tabağa koymak. Misafirlerin her biri için ayrı ayrı pişirmek istemiyorsan tabağı kullan.
Bu hatayı gözle yakalamak zor, çünkü kod tamamen masum görünür. Kendine şu soruyu sormayı alışkanlık hâline getir: bu değişken bir sonuç mu, yoksa bir sorgu mu? Tipi IEnumerable<T> ise ve birden fazla satırda kullanılıyorsa, her kullanımın kaynağı yeniden çalıştırıp çalıştırmadığını kontrol et.
Materialize Etmek: Ne Zaman ToList
Bu kadar şeyden sonra her sorgunun sonuna ToList() koymak cazip gelebilir. Koyma. Materialize etmek bedava değil: bütün sonucu bir kerede belleğe alır ve deferred execution'ın sağladığı her şeyi, erken durabilmeyi, streaming'i, kaynağın son hâlini görmeyi kaybettirir.
Pratik kural şu. Sonucu birden fazla kez kullanacaksan, kaynak pahalıysa ya da belirli bir andaki durumun sabit bir kopyasına ihtiyacın varsa materialize et. Sonucu tek bir foreach ile bir kez dolaşacaksan, sadece Any() ya da First() gibi erken duran bir soruya cevap arıyorsan ya da veri belleğe sığmayacak kadar büyükse etme. Bir method'dan sonuç döndürürken de karar ver: IEnumerable<T> döndürmek "bu lazy olabilir, dikkatli kullan" demektir; IReadOnlyList<T> döndürmek "bu hazır, istediğin kadar dolaş" demektir.
Gerçek projelerde sık gördüğüm bir pattern, zincirin ortasına serpiştirilmiş ToList()'ler: orders.ToList().Where(...).ToList().Select(...).ToList(). Her biri bir ara liste ayırır ve bir tanesi bile kodu daha doğru yapmaz. Materialize etmek için tek bir yer seç: sonucun gerçekten tekrar kullanılacağı yer.
Count(), Any() ve TryGetNonEnumeratedCount
Bir sequence'ın boş olup olmadığını Count() > 0 ile sormak, saymanın maliyetini ödeyip sonucun çoğunu çöpe atmak demek:
var books = new List<Book>
{
new("978-0132350884", "Clean Code", 37.90m, 12),
new("978-0135957059", "The Pragmatic Programmer", 42.50m, 0),
new("978-0321125217", "Domain-Driven Design", 54.00m, 3),
new("978-0201633610", "Design Patterns", 49.90m, 7),
};
int calls = 0;
IEnumerable<Book> cheap = books.Where(b =>
{
calls++;
return b.Price < 50m;
});
Console.WriteLine($"Count() > 0: {cheap.Count() > 0}, calls: {calls}");
calls = 0;
Console.WriteLine($"Any(): {cheap.Any()}, calls: {calls}");
Report("List<Book>", books);
Report("Where(...)", cheap);
Report("Select(...)", books.Select(b => b.Title));
Report("Repeat(...)", Enumerable.Repeat("Clean Code", 3));
static void Report<T>(string name, IEnumerable<T> source)
{
bool cheapCount = source.TryGetNonEnumeratedCount(out int count);
Console.WriteLine($"{name,-12} known: {cheapCount,-5} count: {count}");
}
public record Book(string Isbn, string Title, decimal Price, int Stock);
Count() > 0: True, calls: 4
Any(): True, calls: 1
List<Book> known: True count: 4
Where(...) known: False count: 0
Select(...) known: True count: 4
Repeat(...) known: True count: 3
Count() > 0 predicate'i dört kez çalıştırdı, Any() ilk eşleşmede durdu ve bir kez çalıştırdı. Boşluk kontrolü için her zaman Any() kullan.
Count()'un kendisi akıllıdır: kaynak bir ICollection<T> ise, örneğin bir List<T> ya da array, saymadan Count property'sini okur. Kaynak bir Where zinciriyse bunu yapamaz ve sonuna kadar dolaşır. .NET 6 ile gelen TryGetNonEnumeratedCount bu farkı sana açıkça gösterir: sayı dolaşmadan biliniyorsa true döner, bilinmiyorsa dolaşmayı denemeden false döner. Listede ve Repeat'te sayı belli. Select bir listenin üzerinde olduğunda element sayısını değiştirmediği için sayı yine bilinebiliyor. Where'de ise kaç element'in geçeceğini bilmenin yolu yok. Bir kaynağı sırf boyutunu öğrenmek için dolaşmak istemediğin yerlerde, örneğin bir listeye önceden kapasite ayırırken, bu method işe yarar. Bu çıktılar .NET 8.0.25 runtime'ından; hangi kaynağın sayısını "bildiği" bir implementasyon ayrıntısı ve sürümden sürüme genişleyebilir.
BenchmarkDotNet ile Ölçmek
Performans hakkında konuşmanın tek dürüst yolu ölçmek. Stopwatch ile tek bir çalıştırmayı ölçmek seni yanıltır: JIT ısınması, garbage collector, işlemcinin frekans değişimleri ve makinede çalışan diğer işler tek bir ölçümü anlamsızlaştırır. BenchmarkDotNet bunları senin yerine halleder: kodu ısıtır, birçok kez çalıştırır, istatistik çıkarır ve sonuçların ne kadar güvenilir olduğunu söyler.
Ölçüm projesini ayrı bir console projesi olarak kur, dotnet add package BenchmarkDotNet ile paketi ekle ve her zaman Release modda çalıştır: dotnet run -c Release. Debug modda optimizasyonlar kapalıdır ve ölçtüğün şey gerçek kodun değil, hata ayıklama build'inin hızıdır. Bu yazıdaki ölçümlerde BenchmarkDotNet 0.15.8 kullandım. Proje, serinin geri kalanı gibi net6.0 hedefli; paketin bazı bağımlılıkları derlerken "net6.0'ı desteklemiyor" uyarısı verdi, makinede .NET 6 runtime'ı olmadığı için ölçümler de roll-forward ile .NET 8.0.25'te koştu. Kendi projende ölçüm projesini uygulamanın çalışacağı runtime'la hedefle.
LINQ'i ölçerken ilk tuzak, deferred execution'ın ta kendisi:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<DeferredBenchmarks>();
public record Book(string Isbn, string Title, decimal Price, int Stock);
[MemoryDiagnoser]
public class DeferredBenchmarks
{
private List<Book> _books = null!;
[GlobalSetup]
public void Setup() => _books = Enumerable.Range(1, 10_000)
.Select(i => new Book($"978-{i:D9}", $"Book {i}", 10m + i % 70, i % 20))
.ToList();
// Sorguyu döndürür ama çalıştırmaz
[Benchmark]
public IEnumerable<Book> ReturnsQuery() => _books.Where(b => b.Price > 40m);
[Benchmark]
public List<Book> ReturnsList() => _books.Where(b => b.Price > 40m).ToList();
}
// Validating benchmarks:
// Benchmark DeferredBenchmarks.ReturnsQuery: DefaultJob
// * Benchmark DeferredBenchmarks.ReturnsQuery returns a deferred execution result (IEnumerable<Book>). You need to either change the method declaration to return a materialized result or consume it on your own. You can use .Consume() extension method to do that.
ReturnsQuery sorguyu tarif edip döndürüyor ama hiç çalıştırmıyor. Ölçseydik birkaç nanosaniyelik, tamamen anlamsız bir sayı görürdük. BenchmarkDotNet bunu fark etti ve ölçüme hiç başlamadan durdu. Benchmark method'u ya materialize edilmiş bir sonuç döndürmeli, ReturnsList gibi, ya da sonucu kendisi tüketmeli; hata mesajındaki Consume() extension'ı bunun için var.
Ölçüm: Döngünün İçinde Where
Gerçek projelerde en çok zaman kaybettiren LINQ hatası, bir döngünün içinde bütün bir collection'ı tekrar tekrar filtrelemek. 500 müşterinin her biri için 20.000 siparişin içinden o müşterinin siparişlerini bulup toplayan üç yol yazdık. Aynı programda bir de basit bir toplama işini döngüyle ve LINQ ile karşılaştıran ikinci bir class var:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run(new[] { typeof(LookupBenchmarks), typeof(LoopBenchmarks) });
public record Order(int Id, int CustomerId, decimal Total);
public record Book(string Isbn, string Title, decimal Price, int Stock);
public static class TestData
{
public static List<Order> Orders(int count, int customers)
{
var random = new Random(42);
return Enumerable.Range(1, count)
.Select(i => new Order(i, random.Next(1, customers + 1), random.Next(10, 200)))
.ToList();
}
public static List<Book> Books(int count)
{
var random = new Random(42);
return Enumerable.Range(1, count)
.Select(i => new Book($"978-{i:D9}", $"Book {i}", random.Next(10, 80), random.Next(0, 20)))
.ToList();
}
}
[MemoryDiagnoser]
public class LookupBenchmarks
{
private List<Order> _orders = null!;
private int[] _customerIds = null!;
[GlobalSetup]
public void Setup()
{
_orders = TestData.Orders(20_000, 500);
_customerIds = Enumerable.Range(1, 500).ToArray();
}
[Benchmark(Baseline = true)]
public decimal WhereInsideLoop()
{
decimal grandTotal = 0;
foreach (int id in _customerIds)
grandTotal += _orders.Where(o => o.CustomerId == id).Sum(o => o.Total);
return grandTotal;
}
[Benchmark]
public decimal ToLookupOnce()
{
ILookup<int, Order> byCustomer = _orders.ToLookup(o => o.CustomerId);
decimal grandTotal = 0;
foreach (int id in _customerIds)
grandTotal += byCustomer[id].Sum(o => o.Total);
return grandTotal;
}
[Benchmark]
public decimal ToDictionaryOnce()
{
Dictionary<int, decimal> totals = _orders
.GroupBy(o => o.CustomerId)
.ToDictionary(g => g.Key, g => g.Sum(o => o.Total));
decimal grandTotal = 0;
foreach (int id in _customerIds)
if (totals.TryGetValue(id, out decimal total))
grandTotal += total;
return grandTotal;
}
}
[MemoryDiagnoser]
public class LoopBenchmarks
{
private List<Book> _books = null!;
[GlobalSetup]
public void Setup() => _books = TestData.Books(10_000);
[Benchmark(Baseline = true)]
public int ForLoop()
{
int stock = 0;
for (int i = 0; i < _books.Count; i++)
if (_books[i].Price > 40m)
stock += _books[i].Stock;
return stock;
}
[Benchmark]
public int ForeachLoop()
{
int stock = 0;
foreach (Book book in _books)
if (book.Price > 40m)
stock += book.Stock;
return stock;
}
[Benchmark]
public int Linq() => _books.Where(b => b.Price > 40m).Sum(b => b.Stock);
}
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 | Gen1 | Allocated | Alloc Ratio |
|----------------- |------------:|----------:|----------:|------:|--------:|--------:|--------:|----------:|------------:|
| WhereInsideLoop | 19,114.4 us | 370.92 us | 427.15 us | 1.00 | 0.03 | - | - | 78.13 KB | 1.00 |
| ToLookupOnce | 369.6 us | 1.46 us | 1.36 us | 0.02 | 0.00 | 73.7305 | 25.8789 | 603.27 KB | 7.72 |
| ToDictionaryOnce | 378.7 us | 7.19 us | 7.39 us | 0.02 | 0.00 | 81.0547 | 27.8320 | 663.61 KB | 8.49 |
WhereInsideLoop her müşteri için 20.000 siparişin hepsine bakıyor: 500 × 20.000, yani on milyon predicate çağrısı. ToLookupOnce siparişleri bir kez dolaşıp müşteriye göre bir lookup kuruyor, sonra her müşteri için doğrudan kendi siparişlerine gidiyor. Sonuç yaklaşık 50 kat fark: 19,1 ms'ye karşı 0,37 ms. ToDictionaryOnce da aynı mertebede. Bu bir mikro-optimizasyon değil, algoritmanın karmaşıklığını O(n × m)'den O(n + m)'ye indirmek.
Bedelini bellek sütununda görüyorsun: lookup ve dictionary, ara yapılar için yaklaşık 8 kat fazla bellek ayırdı ve Gen0/Gen1 sütunlarında garbage collector'a iş çıkardı. Bu durumda değer; 600 KB'lık bir ara yapı karşılığında 18 ms kazanıyorsun. Ama her ölçümde iki sütuna birden bak: zaman ve bellek. Biri iyileşirken diğeri kötüleşebilir.
Ölçüm: Döngü mü LINQ mi
İkinci class, 10.000 kitap içinde fiyatı 40'ın üzerinde olanların toplam stoğunu üç yolla hesaplıyor. Aynı benchmark'ı iki kez çalıştırdım. İlk koşu:
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Allocated | Alloc Ratio |
|------------ |---------:|---------:|---------:|---------:|------:|--------:|----------:|------------:|
| ForLoop | 30.87 us | 1.301 us | 3.836 us | 30.72 us | 1.02 | 0.18 | - | NA |
| ForeachLoop | 21.77 us | 0.744 us | 2.195 us | 20.82 us | 0.72 | 0.11 | - | NA |
| Linq | 73.87 us | 0.739 us | 0.692 us | 74.02 us | 2.43 | 0.30 | 72 B | NA |
İkinci koşu:
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Allocated | Alloc Ratio |
|------------ |----------:|---------:|---------:|----------:|------:|--------:|----------:|------------:|
| ForLoop | 28.32 us | 1.455 us | 4.268 us | 26.94 us | 1.02 | 0.21 | - | NA |
| ForeachLoop | 36.77 us | 0.735 us | 1.962 us | 36.72 us | 1.33 | 0.19 | - | NA |
| Linq | 103.01 us | 2.051 us | 2.362 us | 103.19 us | 3.71 | 0.51 | 72 B | NA |
İki tablo yan yana konunca dersin ta kendisi ortaya çıkıyor. İlk koşuda foreach, for'dan hızlı göründü; ikinci koşuda tam tersi. BenchmarkDotNet her iki döngü için de MultimodalDistribution uyarısı verdi, yani ölçümlerin tek bir değer etrafında toplanmadığını söyledi. Sebebi bu makinede: ölçüm sırasında aynı bilgisayarda başka build'ler ve benchmark'lar çalışıyordu; ikinci koşu sırasında yük ortalaması 10 çekirdekli işlemcide 14'ü geçti. Böyle bir ortamda yüzde 30'luk farklar gürültüdür. StdDev sütunu ortalamanın yüzde 10–15'i kadarsa, o satırdan kesin bir sonuç çıkarma.
İki koşuda da değişmeyen sonuç şu: LINQ sürümü döngülerin yaklaşık 2,4 ila 3,7 katı sürdü ve 72 byte ayırdı; bu, zincirin iterator object'leri için. Mutlak olarak 10.000 kitap için onlarca mikrosaniyelik bir fark. Bu kod saniyede binlerce kez çalışan sıcak bir yoldaysa döngüye çevirmek mantıklı olabilir. Bir HTTP isteği başına bir kez çalışıyorsa, aynı istekte bir veritabanı sorgusunun harcadığı milisaniyelerin yanında görünmez; okunaklı olanı seç. Döngü içindeki Where ise her iki durumda da düzeltilmesi gereken bir hata.
Sık Düşülen Tuzaklar
Debug modda ölçmek. BenchmarkDotNet optimizasyonsuz bir build'i fark eder ve ölçüme hiç başlamadan durur; mesajında build'in büyük ihtimalle Debug olduğunu ve Release ile derlemen gerektiğini söyler. Bu kontrolü kapatmanın yolları var, kapatma. Release olmayan ölçüm, ölçüm değildir.
Gürültülü makinede tek bir koşuya güvenmek. Yukarıdaki iki tablo bunun canlı örneği. Ölçümü sakin bir makinede tekrarla, warning'leri oku ve sadece büyük, tekrarlanabilir farklara karar bağla.
Yanlış şeyi optimize etmek. LINQ'i döngüye çevirerek kazandığın mikrosaniyeler, döngünün içindeki Where'i bir lookup'a çevirerek kazandığın milisaniyelerin yanında küçük kalır. Önce algoritmaya, sonra çoklu enumeration'a, en son operatörlerin kendisine bak.
Döngünün içinde materialize etmek. foreach (var c in customers) { var list = orders.ToList(); ... } gibi bir satır, her müşteri için bütün siparişleri yeni bir listeye kopyalar. Materialize edeceksen döngüden önce, bir kez yap.
Küçük Bir Deneme
★☆☆ Çoklu enumeration örneğindeki LoadOrders'ı kullanarak sayıyı, toplamı ve en büyük değeri tek bir foreach ile, kaynağı bir kez okuyarak hesapla. ToList()'li sürümle karşılaştırınca hangisi daha okunaklı?
★★☆ LookupBenchmarks'a müşteri sayısını değiştiren bir [Params(10, 500, 5_000)] ekle. WhereInsideLoop ile ToLookupOnce arasındaki fark müşteri sayısıyla nasıl değişiyor? 10 müşteride lookup hâlâ kazanıyor mu?
★★★ LoopBenchmarks'a kitapları IEnumerable<Book> olarak alan bir foreach sürümü ekle. List<Book> üzerindeki foreach'ten farklı bir sonuç çıkıyor mu? Struct enumerator ve interface üzerinden çağrı farkını araştır.
Bir sonraki yazıda LINQ'i örnek listelerden çıkarıp gerçek veriye götürüyoruz: bir JSON dosyasından okunan siparişlerle doğrulama, raporlama ve dosyaya geri yazma senaryoları.