Visual Studio ve Rider Profiler'ları: CPU Usage, .NET Object Allocation, Instrumentation, Database ve Async Araçları
IDE profiler'ları geliştirici makinesinde en hızlı sonucu verir; Visual Studio'nun Database aracı N+1'i aynı metinli yüzlerce kısa sorgu olarak gösterir. Bookstore'un Siparişlerim sayfasında lazy loading'in istek başına 80 SQL komutu gönderdiğini ve thread pool'u tıkadığını aynı event'leri komut satırından okuyarak ölçüyor, Include ile throughput'u 30, projection ile 41 katına çıkarıyor; Copilot Profiler Agent'ı ve Rider'ın dotTrace/dotMemory karşılıklarını konumlandırıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (container'da runtime 10.0.15) ile derlenip denenmiştir; Entity Framework Core 10.0.15 (SqlServer ve Proxies paketleri), SQL Server 2025 container'ı, dotnet-trace ve dotnet-counters 10.0.750501, Microsoft.Diagnostics.Tracing.TraceEvent 4.0.0 ve yük için k6 2.4.0 kullanılmıştır. Visual Studio 2026 ve Rider bu makinede çalıştırılmadı; IDE adımları Microsoft Learn ve JetBrains dokümanlarından aktarılmıştır, ölçümler aynı event'leri komut satırından okuyarak alınmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) "Siparişlerim" sayfası için müşterinin son 20 siparişini, her siparişin kalemlerini ve kitap adlarını dönen bir endpoint'e sahip. Her siparişte 3 kalem var. Kod Entity Framework Core (EF Core) ile yazılmış ve lazy loading açık. Bu sayfa tek bir istekte veritabanına kaç SQL komutu gönderir: 1, 21, 61, 81? İkinci soru: N+1'i Include ile düzeltmek ile yalnızca sayfanın ihtiyacı olan kolonları seçen bir projection'a geçmek arasında throughput farkı olur mu, olursa ne kadar? Cevaplar Database bölümündeki çıktıda ve düzeltme tablosunda.
Visual Studio 2026 Performance Profiler'ı: Hangi Araç Ne İçin?
Visual Studio'da profiling'in iki giriş kapısı var. Debug ederken (F5) açılan Diagnostic Tools penceresi CPU, bellek ve .NET Counters grafiklerini gösteriyor, breakpoint'le birlikte kullanılabiliyor. Asıl ölçüm ise Debug > Performance Profiler'dan (Alt+F2) yapılıyor: uygulama debugger'sız, tercihen Release build ile başlatılıyor ve sonuç oturum bitince rapor olarak açılıyor. Microsoft Learn de en doğru sayıların bu yoldan, Release build ile geldiğini söylüyor; debugger kendi başına exception ve module load event'lerini yakaladığı için süreleri değiştiriyor. Visual Studio 2026'da başlangıç sayfası araçları iki sekmeye ayırıyor: birlikte çalışabilenler Flexible, tek başına çalışması gerekenler Standalone sekmesinde; Copilot açıksa bir de Recommended sekmesi kodu inceleyip araç öneriyor.
| Araç | Cevapladığı soru | Not |
|---|---|---|
| CPU Usage | CPU zamanı hangi method'larda? | Örneklemeli; flame graph ve hot path görünümleri var |
| .NET Object Allocation Tracking | Hangi tip, nereden, ne kadar ayrılıyor? | Yalnız Performance Profiler'da, GC (garbage collector; kullanılmayan object'leri temizleyen runtime bileşeni) bilgisiyle birlikte |
| Instrumentation | Bir method tam olarak kaç kez çağrıldı, duvar saatiyle ne kadar sürdü? | Kesin sayılar, bekleme süreleri dahil; overhead'i yüksek |
| .NET Async | Async işler ne zaman başladı, ne kadar sürdü? | Async akışı zaman çizelgesinde gösteriyor |
| Database | Hangi sorgular, kaç kez, ne kadar sürede çalıştı? | ADO.NET (.NET'in temel veritabanı erişim katmanı) ve EF Core; .NET Core/5+ projeleri |
| .NET Counters | Runtime ve ASP.NET Core metric'leri ne durumda? | dotnet-counters'ın IDE içindeki karşılığı |
| Events Viewer | Hangi HTTP isteği, log ve exception ne zaman oldu? | Diğer araçlarla aynı zaman çizelgesinde |
Learn'deki tabloya göre CPU Usage, .NET Object Allocation, .NET Async, .NET Counters, Events Viewer ve Database araçları Linux ve WSL (Windows Subsystem for Linux; Windows içinde çalışan Linux ortamı) hedefinde de çalışıyor. Visual Studio dotnet-trace ile alınmış .nettrace dosyalarını da açabiliyor; bu, production'dan gelen trace'i IDE'nin görünümleriyle incelemenin en kısa yolu. Kendi oturumların .diagsession olarak kaydediliyor ve sonradan açılabiliyor.
Vaka: Siparişlerim ve Lazy Loading
Lab'a bu yazı için SQL Server'a bağlanan küçük bir veri katmanı ekledim: 2.000 kitap, 500 müşteri, müşteri başına 40 sipariş, sipariş başına 3 kalem. Connection string verilmezse bu kısım hiç devreye girmiyor, önceki yazıların endpoint'leri eskisi gibi çalışıyor:
// 07: connection string verilirse SQL Server (ConnectionStrings__Bookstore)
string? connectionString = builder.Configuration.GetConnectionString("Bookstore");
if (!string.IsNullOrEmpty(connectionString))
{
builder.Services.AddDbContext<BookstoreContext>(options => options
.UseSqlServer(connectionString)
.UseLazyLoadingProxies());
}
Endpoint, ilk bakışta temiz görünen bir LINQ (Language Integrated Query; C# içine gömülü sorgu dili) sorgusundan ibaret:
// yavaş sürüm: sipariş listesi bir kez geliyor, kalemler ve kitaplar tek tek lazy load ediliyor
app.MapGet("/api/customers/{id:int}/orders", async (int id, BookstoreContext db, CancellationToken ct) =>
{
List<Order> orders = await db.Orders
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.PlacedAt)
.Take(20)
.ToListAsync(ct);
return orders.Select(o => new OrderSummary(
o.Id, o.PlacedAt, o.Status,
o.Items.Sum(i => i.UnitPrice * i.Quantity),
o.Items.Select(i => new OrderLine(i.Book.Title, i.Quantity, i.UnitPrice)).ToList())).ToList();
});
o.Items ve i.Book virtual navigation property'ler; lazy loading proxy'si onlara ilk erişildiğinde veritabanına gidiyor. Kod incelemesinde gözden kaçmasının nedeni de bu: sorgu satırı görünmüyor. Mekaniği EF Core serisinin "EF Core'da İlişkili Veri Yükleme: Include, Explicit ve Lazy Loading, Split Query" yazısında anlatıldı; burada onu bir profiler'la bulmaya odaklanıyoruz. Uygulamayı lab'ın Linux container'ında SQL Server'a bağlayıp 20 sanal kullanıcıyla 20 saniye yük verdim:
docker run -d --name profseries-api --cpus 4 --memory 2g -p 5481:8080 \
-e ConnectionStrings__Bookstore="Server=host.docker.internal,1433;Database=BookstoreDb;User Id=sa;Password=<password>;TrustServerCertificate=True" \
profseries-api:lab07
http_req_duration..............: avg=203.13ms p(50)=187.99ms p(95)=370.57ms p(99)=498.21ms max=717.23ms
http_reqs......................: 1979 98.114997/s
20 siparişlik bir sayfa için p95 371 ms ve saniyede 98 istek. Bu hızda bir sayfa production'da ilk kampanya gününde sorun çıkarır.
Database Aracıyla N+1'i Bulmak
Visual Studio'da adımlar kısa: Alt+F2, Database kutusunu işaretle (seçilemiyorsa Standalone araçlarla birlikte seçmeye çalışıyorsundur, diğerlerini kaldır), Start, sayfayı birkaç kez çağır, Stop Collection. Rapor çalışan sorguları zaman sırasıyla bir tabloda listeliyor; Duration sütununa tıklayınca en uzun süren başa geliyor, sorguya sağ tıklayıp Go To Source File ile onu çalıştıran koda gidiliyor. Grafikten bir zaman aralığı seçince tablo yalnızca o aralıktaki sorgulara iniyor. N+1'in imzası bu tabloda kolay görünür: aynı metne sahip, yalnızca parametresi değişen yüzlerce kısa sorgu.
Bu aracın beslendiği veri sihirli değil: EF Core'un ve SqlClient'ın DiagnosticSource üzerinden yayınladığı komut event'leri. dotnet-trace 10'da aynı veri için hazır bir profile var: database. Bu makinede Visual Studio olmadığı için aynı tabloyu komut satırından çıkardım:
docker exec profseries-api dotnet-trace collect -p 1 --profile database \
--providers Microsoft.AspNetCore.Hosting --duration 00:00:00:07 -o /tmp/db-lazy.nettrace
Trace'te her komut iki event: CommandExecuting komut metniyle, CommandExecuted süresiyle, ikisi de DiagnosticSource'un Activity2/Start ve Activity2/Stop event'leri olarak geliyor. 06'daki Bookstore.TraceStats'e bunları activity ID ile eşleştirip komut metnine göre gruplayan bir db komutu ekledim:
source.Dynamic.AddCallbackForProviderEvent("Microsoft-Diagnostics-DiagnosticSource", "Activity2/Start", e =>
{
if (Argument(e, "CommandText") is string sql)
running[e.ActivityID] = sql;
});
source.Dynamic.AddCallbackForProviderEvent("Microsoft-Diagnostics-DiagnosticSource", "Activity2/Stop", e =>
{
if (!running.Remove(e.ActivityID, out string? sql))
return;
TimeSpan duration = TimeSpan.TryParse(Argument(e, "Duration"), out TimeSpan d) ? d : TimeSpan.Zero;
(int count, TimeSpan total) = byCommand.GetValueOrDefault(sql);
byCommand[sql] = (count + 1, total + duration);
});
requests 402, commands 32195, 80.1 commands per request
23753 0.55 ms avg 13.1 s total
SELECT TOP(1) [b].[Id], [b].[Isbn], [b].[Price], [b].[Title] FROM [Books] AS [b] WHERE [b].[Id] = @p
8040 0.58 ms avg 4.6 s total
SELECT [o].[Id], [o].[BookId], [o].[OrderId], [o].[Quantity], [o].[UnitPrice] FROM [OrderItems] AS [o] WHERE [o].[OrderId] = @p
402 137.99 ms avg 55.5 s total
SELECT TOP(@p) [o].[Id], [o].[CustomerId], [o].[PlacedAt], [o].[Status] FROM [Orders] AS [o] WHERE [o].[CustomerId] = @id ORDER BY [o].[PlacedAt] DESC
Tahmin sorusunun cevabı: istek başına 80 komut. 1 sipariş listesi, 20 sipariş için 20 kalem sorgusu ve 60 kalem için kitap sorguları; teorik toplam 81, ama aynı istekte aynı kitap ikinci kez geçtiğinde EF Core'un identity resolution'ı onu tekrar sorgulamıyor. 7 saniyelik trace 115 MB çıktı, çünkü database profile'ı her komutun metnini ve argümanlarını taşıyor; production'da kısa tut.
Tabloda bir tuhaflık var: tek async sorgu olan sipariş listesi ortalama 138 ms sürüyor, binlerce lazy load sorgusu ise yarım milisaniye. Bu sorgunun kendisi yavaş değil; aynı sorgu biraz sonra göreceğimiz düzeltilmiş sürümlerde istek başına birkaç milisaniyede bitiyor. Fark, ölçülen sürenin neyi kapsadığında.
.NET Counters: N+1'in Thread Pool'a Etkisi
Visual Studio'nun .NET Counters aracı dotnet-counters'ın gösterdiğini IDE'de gösteriyor. Yük sürerken EF Core'un meter'ını ve thread pool metric'lerini izledim:
docker exec -it profseries-api dotnet-counters monitor -p 1 \
--counters "Microsoft.EntityFrameworkCore,System.Runtime[dotnet.thread_pool.queue.length,dotnet.thread_pool.thread.count]"
[Microsoft.EntityFrameworkCore]
microsoft.entityframeworkcore.active_dbcontexts ({dbcontext}) 15
microsoft.entityframeworkcore.compiled_query_cache_hits ({hits}) 495,280
microsoft.entityframeworkcore.compiled_query_cache_misses ({misses}) 6
microsoft.entityframeworkcore.queries ({query}) 495,286
[System.Runtime]
dotnet.thread_pool.queue.length ({work_item}) 14
dotnet.thread_pool.thread.count ({thread}) 7
(Çıktıyı kısalttım.) Microsoft.EntityFrameworkCore meter'ı EF Core 9'dan beri var ve lazy load'lar da queries sayacına giriyor; 20 saniyelik ölçümde sayacın 5 saniyelik artışı 39.671'di, saniyede 98 isteğe bölünce yine istek başına 80. Asıl ipucu thread pool'da: 7 thread çalışırken kuyrukta 14 iş bekliyor. Lazy loading senkron I/O yapıyor; o.Items'a erişen kod, cevap gelene kadar thread'i bloke ediyor. 20 eşzamanlı istek thread'leri tutunca async sipariş sorgusunun devamı kuyrukta sıra bekliyor, EF Core'un ölçtüğü 138 ms'nin büyük kısmı bu bekleme. Thread pool starvation'ın teşhisi ve neden yeni thread'lerin yavaş eklendiği Debugging ve Problem Çözme serisinin "Hang ve Yavaşlık Teşhisi: dotnet-stack, dotnet-trace ve Thread Pool Starvation" yazısında anlatıldı. Burada önemli olan şu: N+1 yalnızca veritabanına giden komut sayısını değil, uygulamanın kendi kapasitesini de yiyor.
Düzeltme: Include mı, Projection mı?
İki düzeltmeyi de ayrı endpoint olarak yazıp aynı yükle ölçtüm. İlki ilişkili veriyi baştan yükleyen Include:
// düzeltme 1: grafiği Include ile tek seferde yükle
app.MapGet("/api/customers/{id:int}/orders-include", async (int id, BookstoreContext db, CancellationToken ct) =>
{
List<Order> orders = await db.Orders
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.PlacedAt)
.Take(20)
.Include(o => o.Items).ThenInclude(i => i.Book)
.ToListAsync(ct);
return orders.Select(o => new OrderSummary(
o.Id, o.PlacedAt, o.Status,
o.Items.Sum(i => i.UnitPrice * i.Quantity),
o.Items.Select(i => new OrderLine(i.Book.Title, i.Quantity, i.UnitPrice)).ToList())).ToList();
});
İkincisi sayfanın ihtiyacı olan alanları sorgunun içinde seçen bir projection:
// düzeltme 2: yalnızca sayfanın ihtiyacı olan kolonları tek sorguda seç
app.MapGet("/api/customers/{id:int}/orders-v2", async (int id, BookstoreContext db, CancellationToken ct) =>
await db.Orders
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.PlacedAt)
.Take(20)
.Select(o => new OrderSummary(
o.Id, o.PlacedAt, o.Status,
o.Items.Sum(i => i.UnitPrice * i.Quantity),
o.Items.Select(i => new OrderLine(i.Book.Title, i.Quantity, i.UnitPrice)).ToList()))
.ToListAsync(ct));
Üç endpoint'in JSON çıktısını aynı müşteri için karşılaştırıp değerlerin aynı olduğunu doğruladım. Sonuçlar, aynı 20 sanal kullanıcı ve 20 saniyelik yükle; CPU dotnet-counters'ın dotnet.process.cpu.time artışından:
| Sürüm | İstek/sn | p95 | p99 | Komut/istek | Uygulama CPU'su |
|---|---|---|---|---|---|
| Lazy loading | 98 | 370,6 ms | 498,2 ms | 80 | 1,3 çekirdek |
Include | 2.997 | 11,7 ms | 17,6 ms | 1 | 3,6 çekirdek |
| Projection | 4.032 | 8,1 ms | 11,2 ms | 1 | 2,4 çekirdek |
İkinci tahmin sorusunun cevabı: evet, fark var. Include throughput'u 30 katına çıkardı; projection üstüne yüzde 35 daha ekledi ve bunu daha az CPU ile yaptı. Projection'ın tek sorgusu dotnet-trace'te şöyle görünüyor (kısalttım): kalemlerin toplamı bir subquery, kalemler ile kitap adları LEFT JOIN ile geliyor ve Books tablosundan yalnızca Title okunuyor.
requests 3646, commands 3646, 1.0 commands per request
3646 1.76 ms avg 6.4 s total
SELECT [o2].[Id], [o2].[PlacedAt], [o2].[Status], [o2].[c], [s].[Title], [s].[Quantity], [s].[UnitPrice], ...
Include'un daha çok CPU harcamasının nedeni, entity'leri tam olarak materialize etmesi, change tracker'a kaydetmesi ve lazy loading açık olduğu için her biri için proxy oluşturması. Sayfa yalnızca okuyorsa projection hem ağ trafiğini hem uygulama işini azaltıyor. Include birden çok koleksiyonu birleştirdiğinde satır sayısının katlanması (cartesian explosion) ve AsSplitQuery seçeneği EF Core serisinin "EF Core'da Performans Temelleri: N+1, Projection, AsNoTracking ve Compiled Query" yazısında.
Copilot Profiler Agent ve Rider'daki Karşılıklar
Visual Studio 2026'da Copilot Chat'e @Profiler yazarak (ya da agent mode'da Profiler Agent'ı araç olarak seçerek) profiling'i bir AI asistanına yaptırabiliyorsun. Learn'deki eğitime göre agent CPU ve allocation verisi topluyor, darboğazları açıklıyor, projeye BenchmarkDotNet ekleyip benchmark yazıyor, önerdiği değişikliği uyguluyor ve benchmark'ı yeniden koşturup önce/sonra sonucunu raporluyor. Profiling'e yeni başlayan biri için hangi araçtan başlanacağını bulmayı ciddi biçimde hızlandırıyor. Ama agent'ın ölçtüğü şey kendi yazdığı mikro benchmark: bu yazıdaki vakada sorun bir method'un hızında değil, istek başına 80 round trip'te ve thread pool'da. Bir mikro benchmark bunu göremez. Agent'ın önerisini kabul etmeden önce aynı yükle uçtan uca tekrar ölç; serinin 09. yazısında bu döngüyü elle kuruyoruz.
Rider'da (yazım anında 2026.3) aynı işler JetBrains'in iki profiler'ı ile yapılıyor. dotTrace performans tarafında: Sampling genel tablo için, Tracing ve Line-by-line bir algoritmanın içini kesin sayılarla görmek için (Visual Studio'daki Instrumentation'ın karşılığı), Timeline ise çağrıları zaman çizelgesine bağlıyor ve SQL sorgularını ancak bu modda gösteriyor. dotMemory bellek snapshot'ları alıp karşılaştırıyor; 05'teki gcdump diff'inin IDE'deki karşılığı. Rider'ın Dynamic Program Analysis'ı (DPA) ise uygulama çalışırken arka planda veritabanı sorunlarını da işaretliyor; JetBrains'in dokümanına göre aynı komut varsayılan olarak 50'den fazla çalışırsa N+1 şüphesiyle uyarı veriyor. Eşik ve varsayılanlar sürümden sürüme değiştiği için kendi sürümündeki ayarlara bak. Rider'ın debug yapılandırması (launchSettings.json ile profil seçimi) IDE'ler serisinin "IDE'de Debug Configuration: launchSettings.json, launch.json ve .NET API + Angular'ı Birlikte Debug Etmek" yazısında.
Üretimde Dikkat
IDE profiler'ları geliştirici makinesi için. Production'da aynı veriyi almak istiyorsan dotnet-trace'in database profile'ı ya da bir sonraki yazıdaki dotnet-monitor var; ama bu profile komut metinlerini ve DiagnosticSource argümanlarını taşıyor. Aldığım trace'te her komutla birlikte connection string de vardı: sunucu adı, veritabanı ve kullanıcı adı (SqlClient parolayı çıkarıyor). Parametre değerleri olmasa da trace dosyasını hassas veri gibi sakla. Visual Studio'nun komut satırı toplayıcısı VSDiagnostics.exe, Visual Studio kurulu olmayan bir makinede .diagsession toplamak için var; Learn'de ayrıntısı anlatılıyor.
N+1'i production'da kod okumadan yakalamanın en ucuz yolu bir oran izlemek: microsoft.entityframeworkcore.queries artışını istek sayısına böl. Bu oran bir deploy'dan sonra 1-3'ten 80'e çıkıyorsa profiler açmadan önce nereye bakacağını biliyorsun. Bu metric'leri dashboard'a taşımak DevOps tarafındaki "Observability" serisinin konusu.
Sık Düşülen Tuzaklar
- Debugger bağlıyken, Debug build'de profillemek: optimizasyonlar kapalı ve debugger kendi event'lerini yakalıyor; sayılar Release'teki gibi değil.
- Instrumentation'ı sampling gibi düşük maliyetli sanmak: kesin çağrı sayısı verir ama her çağrıya ek yük bindirir, kısa method'ları olduğundan pahalı gösterir. Önce CPU Usage, gerekirse dar bir kapsamda Instrumentation.
- Database aracında en uzun süren sorguya bakıp sayıyı atlamak: N+1'de her sorgu hızlıdır, sorun adettedir. Tabloyu sorgu metnine göre grupla ya da say.
- Lazy loading'in senkron I/O yaptığını unutmak: async bir endpoint'in içinde bile thread'i bloke eder.
- Copilot Profiler Agent'ın mikro benchmark sonucunu uçtan uca iyileşme sanmak.
- N+1'i
Includeile kapatıp sayfanın aslında hangi kolonlara ihtiyacı olduğunu sormamak.
Küçük Bir Deneme
★☆☆ Windows'ta Visual Studio'yla lab'ın /api/customers/7/orders endpoint'ini Database aracıyla profille; sorguları Duration'a göre sırala, sonra aynı metne sahip sorguları say ve sonucu bu yazıdaki 80 komutla karşılaştır.
★★☆ orders-include'a AsNoTracking() ve AsSplitQuery() ekleyen iki sürüm daha yaz. Komut sayısını db komutuyla, CPU'yu dotnet-counters ile ölç ve projection'a ne kadar yaklaştıklarını tabloya ekle.
★★★ Bookstore.TraceStats'in db komutuna istek başına komut sayısının dağılımını (p50, p99, en yüksek) ekle; bunun için komutları Request/Start ve Request/Stop arasındaki activity ilişkisine göre isteklere bağlaman gerekecek.
IDE profiler'ları geliştirici makinesinde hızlı sonuç veriyor, ama production'daki bir pod'a Visual Studio bağlayamazsın. Bir sonraki yazıda production tarafına geçiyoruz: dotnet-monitor'ı Kubernetes'te sidecar olarak çalıştıracak, yavaş isteklerde kendiliğinden trace alan collection rule'lar yazacak ve continuous profiling'in bugünkü durumuna bakacağız.