.NET 11 Performans: JIT Deabstraction ve Escape Analysis
Stephen Toub’un her sürümde tekrarladığı performans yazısının.NET 11 halkası yayımlandı. Anlattığı şey bir birikim hikayesi: kaldırılan bir sınır kontrolü, artık hiç yapılmayan bir tahsis, alınmayan bir kilit, SIMD’e devredilen bir dizi kopyası..NET 11’de runtime ve kütüphanelerde biriken yüzlerce böyle küçük iyileştirme üst üste geldiğinde ölçülebilir bir hız farkına dönüşüyor. Bu yazıda kaynak metnin kapsadığı bölümleri, özellikle JIT tarafındaki deabstraction ve escape analysis çalışmalarını, kendi benchmark ortamınızı kurup doğrulayabileceğiniz biçimde özetliyorum.
Benchmark ortamını kurmak
Yazıdaki ölçümlerin neredeyse tamamı BenchmarkDotNet ile yazılmış ve her biri kendi başına çalışacak şekilde hazırlanmış. Çoğu benchmark aynı kodu iki runtime üzerinde karşılaştırdığı için makinenizde hem .NET 10 hem de .NET 11 kurulu olmalı. Ardından boş bir konsol projesi oluşturuluyor:
dotnet new console -o benchmarks
cd benchmarks
Proje dosyası, BenchmarkDotNet’in iki sürüm için de derleme yapabilmesi amacıyla çoklu hedefleme yapacak şekilde değiştiriliyor:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net11.0;net10.0</TargetFrameworks>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ServerGarbageCollection>true</ServerGarbageCollection>
<SystemPackageVersion Condition="'$(TargetFramework)' == 'net10.0'">10.0.12</SystemPackageVersion>
<SystemPackageVersion Condition="'$(TargetFramework)' == 'net11.0'">11.0.0-rc.1.26425.128</SystemPackageVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.16.0-preview.1" />
<PackageReference Include="System.IO.Hashing" Version="$(SystemPackageVersion)" />
<PackageReference Include="System.Runtime.Caching" Version="$(SystemPackageVersion)" />
<PackageReference Include="System.Numerics.Tensors" Version="$(SystemPackageVersion)" />
</ItemGroup>
</Project>
Test etmek istediğiniz benchmark’ın içeriğini Program.cs dosyasının tamamının yerine kopyalayıp çalıştırmanız yeterli. Her örneğin en üstünde kullanılacak komut yorum satırı olarak duruyor. En yaygın biçim şu:
dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
Bu komut Release yapılandırmasında derliyor, benchmark’ı hem.NET 10 hem.NET 11 üzerinde çalıştırıyor ve yan yana karşılaştırma üretiyor. İkinci yaygın biçim ise iki farklı kodlama yaklaşımını tek runtime üzerinde karşılaştırırken kullanılıyor:
dotnet run -c Release -f net11.0 --filter "*"
Alışıldık uyarı burada da geçerli: bunlar mikro-benchmark ve birçoğu göz kırpmadan kısa süren işlemleri ölçüyor. Sonuçlar donanımınıza, işletim sisteminize, runtime yapılandırmanıza ve o anda makinenizde çalışan diğer işlere göre değişir.
Neden JIT ile başlanıyor?
Yönetilen kodun tamamı eninde sonunda JIT derleyicisinden geçiyor, dolayısıyla performans çalışmasının etkisi en geniş olan alanlardan biri orası. C#, F# ve Visual Basic önce ara dile (IL) derleniyor, JIT de bu IL’i CPU’nun çalıştırdığı yerel komutlara çeviriyor. JIT’te yapılan bir iyileştirme, optimize edilen desen nerede geçiyorsa oradaki uygulama ve kütüphane kodundan yararlanıyor; çoğu zaman kaynak kodda değişiklik yapmak veya uygulamayı yeniden derlemek bile gerekmiyor. Sıcak bir yolda tek bir komutun kaldırılması ya da bir kontrolün gereksiz olduğunun kanıtlanması toplamda anlam kazanıyor.
Deabstraction: soyutlamanın bedelini çalışma zamanında geri almak
Geliştiriciler soyutlamayı seviyor ama her soyutlamanın bedelini çalışma zamanında ödemek istemiyoruz. Runtime, etkilerinin gözlemlenemez olduğunu kanıtlayabildiğinde bir soyutlamayı geri alabiliyor: sanal bir çağrının hangi somut metodu çağıracağını belirleyebiliyor, bir heap tahsisinin mevcut yığın çerçevesinden hiç çıkmadığını fark edebiliyor, bir arayüz dönüşümünde metot içinde daha önce kurulmuş bir tip bilgisini yeniden kullanabiliyor. Bu sürecin adı “deabstraction” ve.NET yıllardır bu alanda ilerliyor,.NET 11’de de devam ediyor.
C#’ta her interface yazdığınızda bir sözleşme kuruyorsunuz. IEnumerable<T>‘in diziler, listeler, LINQ ve özel iterator’lar üzerinde aynı şekilde çalışabilmesi bu esneklik sayesinde. Ama CPU sözleşmelerden habersiz, yalnızca komut çalıştırmayı biliyor. Kaynak yazıdaki Animal/Dog/Cat örneğinde JIT, _animal alanının hangi somut tipi tuttuğunu derleme zamanında bilmiyor, bu yüzden nesnenin başındaki metot tablosu işaretçisini (vtable pointer) yükleyip Speak için ayrılmış slota giden kodu üretiyor:
; x64
mov rcx, [rcx+8] ; load _animal
mov rax, [rcx] ; load method table
mov rax, [rax+40] ; load vtable chunk
call qword ptr [rax+20]
Tek bir Speak çağrısı için birbirine bağımlı üç bellek erişimi ve dolaylı bir çağrı ödeniyor. Daha büyük maliyet ise kaybedilen inline fırsatı; hedef dolaylı olduğu için JIT çağrılan metodu inline edemiyor. Inline etmek yalnızca çağrı maliyetini düşürmüyor, çağrılan metodun kodunu da sabit yayılımı, ölü kod eleme, sınır kontrolü eleme ve ek devirtualization gibi optimizasyonlara açıyor. Böylece art arda gelen küçük sanal çağrılar, devirtualize edilip inline edildiğinde kaynak koda hiç benzemeyen, çok daha ucuz birkaç komuta indirgenebiliyor.
Statik devirtualization ve PGO
JIT bazı durumlarda hedefi statik olarak belirleyebiliyor. Nesne hemen öncesinde new Dog() ile tahsis edilmişse, değişkenin tipi sealed bir sınıfsa ya da NativeAOT ile tüm program derlemesi sırasında Animal‘dan türeyen tek tipin Dog olduğu görülüyorsa doğrudan Dog.Speak() çağrısı üretip inliner’a yol açıyor.
Statik analizin yetmediği yerlerde devreye profil güdümlü optimizasyon (PGO) giriyor. Katmanlı derlemede metot ilk çağrıldığında neredeyse hiç optimizasyon yapılmadan derleniyor (Tier 0). JIT bu derlemeye, hangi dalların alındığını ve sanal çağrı noktalarında hangi somut tiplerin göründüğünü kaydeden problar ekleyebiliyor. Metot yeterince çağrılır veya yeterince döngüye girerse runtime, toplanan profili kullanan optimize edilmiş bir sürüm (Tier 1) üretilmesini istiyor.
Profil bir çağrı noktasında hep Dog göründüğünü söylese bile bu gelecekte de öyle olacağının garantisi değil, o yüzden JIT çalışma zamanı kontrolü üretiyor. Hızlı yol doğrudan (ve inline edilebilir) çağrıya, diğer yol özgün sanal çağrıya gidiyor:
// Approximately what the JIT generates
if (animal?.GetType() == typeof(Dog))
{
((Dog)animal).Speak(); // devirtualized, inlinable
}
else
{
animal.Speak(); // original virtual call, hopefully rare
}
“Tahmin et ve doğrula” biçimindeki bu desen guarded devirtualization (GDV) olarak anılıyor ve gerçek iş yüklerindeki en büyük throughput kazanımlarının çoğu buradan geliyor. Yalnızca sanal gönderime değil arayüz gönderimine de uygulanabiliyor. Arayüz gönderimi sanal gönderimden biraz daha pahalı zaten, çünkü bir tip istediği kadar arayüz uygulayabiliyor ve arayüz slotları sabit vtable konumlarına doğrudan eşlenmiyor.
Escape analysis: heap yerine stack
.NET’te nesneler genelde GC heap’inde tahsis edilir ve erişilemez hale geldiklerinde toplanır. Heap tahsisi çoğunlukla yalnızca bir işaretçiyi ilerletmek kadar hızlıdır; yer kalmadığında ise maliyet ciddi biçimde artar ve bir garbage collection gerekebilir. Tahsis edilen her nesne, eninde sonunda temizlenmesi gerektiği için toplama maliyetinin amortize edilmiş payını da taşır.
Escape analysis, “bu nesne mevcut metottan hiç çıkıyor mu?” sorusunu soran bir derleyici tekniği. Yeni tahsis edilen bir nesnenin kaçmadığı kanıtlanabiliyorsa JIT onu GC heap’inde tutmak zorunda kalmıyor, stack’te tahsis edebiliyor. Stack tahsisi, zaten bir yazmaçta duran yığın işaretçisini azaltmaktan ibaret olduğu için bump-pointer tahsisinden bile hızlı; daha önemlisi yığın çerçevesi metot dönüşünde atomik olarak serbest kaldığından GC üzerinde hiç etki bırakmıyor.
JIT son birkaç sürümde escape analysis’in kapsamını kademeli olarak genişletiyor..NET 9 ve.NET 10’da delegate ve closure’ların, Nullable<T> geçici değerlerinin ve küçük yardımcı nesnelerin stack’te tahsis edilmesine yönelik önemli yatırımlar yapılmıştı. Buradaki ana fikir şu: her yanlış pozitif (JIT’in aslında kaçmayan bir nesneyi kaçıyor sanması) önlenebilecek bir heap tahsisi demek..NET 11 bu listeyi birkaç yönden kısaltıyor.
Nullable boxing
Kaynaktaki benchmark, int? değerlerinin kutulanmasını ölçüyor:
// dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args);
[MemoryDiagnoser(false), HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")]
public class Benchmarks
{
private int? _nullableNull;
private int? _nullableValue = 42;
[Benchmark]
public object? BoxNullableNull() => (object?)_nullableNull;
[Benchmark]
public object? BoxNullableValue() => (object?)_nullableValue;
[Benchmark]
public string? FormatNullableInt() => Format(_nullableValue);
private static string? Format<T>(T value)
{
if (value is IFormattable formattable)
return formattable.ToString(null, null);
return null;
}
}
| Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
|---|---|---|---|---|---|
| BoxNullableNull | .NET 10.0 | 2.095 ns | 1.00 | – | – |
| BoxNullableNull | .NET 11.0 | 1.764 ns | 0.84 | – | – |
| BoxNullableValue | .NET 10.0 | 9.213 ns | 1.00 | 24 B | 1.00 |
| BoxNullableValue | .NET 11.0 | 4.126 ns | 0.45 | 24 B | 1.00 |
| FormatNullableInt | .NET 10.0 | 9.583 ns | 1.00 | 24 B | 1.00 |
| FormatNullableInt | .NET 11.0 | 1.987 ns | 0.21 | – | 0 |
dotnet/runtime#122167 numaralı değişiklik, nullable kutulamayı JIT içinde genişletiyor ve geçici kutuyu escape analysis’in görebileceği hale getiriyor; daha önce bir runtime yardımcı fonksiyonu bu kutuyu gizliyordu. null girdide iki sürümde de tahsis yok, çünkü kutulanan bir şey yok. BoxNullableValue ise kutulanmış nesneyi dönduruyor, yani nesne kaçıyor; 24 baytlık tahsis her iki sürümde de duruyor. FormatNullableInt‘te ise.NET 11’deki JIT geçici 24 baytlık kutunun kaçmadığını görüp heap tahsisini tamamen ortadan kaldırıyor.
Conditional Escape Analysis ve enumerator’lar
Escape analysis enumerator’lar için de ilerledi. Conditional Escape Analysis (CEA) desteği.NET 10 ile gelmişti,.NET 11 bu analizin güvenle tanıyabildiği desen kümesini genişletiyor. Klasik escape analysis, bir tahsisten doğan referansın JIT’in artık izleyemeyeceği bir yere (örneğin bilinmeyen bir çağrıya) akıp akmadığını soruyor; akabiliyorsa nesne heap’te kalmak zorunda. Bu analiz doğası gereği muhafazakar ve büyük ölçüde akıştan bağımsız: nesne herhangi bir yolda bir arayüz çağrısına geçebiliyorsa, o çağrıyı içeren yolun tahsisi içeren yolla karşılıklı dışlayıcı olduğunu kanıtlamaya çalışmıyor.
Sorun şu ki GDV, bir IEnumerable<T> üzerinde foreach yapıldığında tam olarak böyle bir yapı üretiyor: arayüz çağrısı, olası koleksiyon tipi için hızlı dal ve özgün arayüz çağrısını barındıran yedek dal olmak üzere iki dallı bir tip kontrolüne dönüşüyor. Hızlı dal üzerinde devirtualization ve inline etme çoğu zaman ilgili koleksiyon tipine ait bir enumerator tahsisini ortaya çıkarırken, sonraki enumerator kontrolleri yedek çağrıları korumaya devam ediyor. CEA’nın genişletilmesi, bu kalıplarda enumerator’ın gerçekten kaçmadığını kanıtlayabilmeyi hedefliyor.
Nasıl okunmalı?
Yazının kendisi kasıtlı olarak uzun, yüzlerce ayrı iyileştirmeyi tek tek geziyor. Buradaki özet kaynağın giriş bölümünü, benchmark kurulumunu ve JIT’teki deabstraction ile escape analysis konularını kapsıyor; geri kalan bölümler için orijinal yazıya başvurmak gerekiyor. Pratik yol, kendi sıcak yollarınıza denk gelen desenleri seçip yukarıdaki proje şablonuyla kendi donanımınızda ölçmek. Mikro-benchmark sonuçları ortamdan ortama değiştiği için sizin iş yükünüzde ne kadar kazanç olduğunu yalnızca kendi ölçümünüz söyleyebilir.
Kaynaklar ve İleri Okuma
- Performance Improvements in.NET 11 — Stephen Toub,.NET Blog
- Performance Improvements in.NET 10
- Performance Improvements in.NET 9
- Performance Improvements in.NET 8
- Performance Improvements in.NET 7
- Performance Improvements in.NET 6
- Performance Improvements in.NET 5
- Performance Improvements in.NET Core 3.0
- Performance Improvements in.NET Core 2.1
- Performance Improvements in.NET Core
- BenchmarkDotNet NuGet paketi
- .NET 10 indirme sayfası
- .NET 11 indirme sayfası







Yorum gönder