Ölçüm

.NET 10 aynı kodda 267 MB çöpü buharlaştırdı — sınırı tam olarak nerede, ölçtüm

.NET 10'un genişleyen escape analizi hangi kod şekillerini yığına indiriyor, hangilerini indirmiyor? Apple Silicon'da JIT anahtarını açıp kapatarak ölçtüm ve dizinin stack'e sığma sınırını bayt bayt buldum.

Bu yazının İngilizcesi: English version.

.NET 10'un duyuru listesinde herkesin hızlı geçtiği bir madde var: "escape analysis genişletildi, küçük diziler ve delegate'ler artık yığında ayrılabiliyor." Sürüm notlarında bir paragraf, altında iki assembly listesi. Kimse "peki sınır nerede" diye sormuyor.

Ben sordum, çünkü kısa ömürlü küçük diziler benim sıcak döngülerimin ekmeği: skor doğrulama, giriş tamponu, kısa istatistik pencereleri. Yıllardır bu tür yerlerde ArrayPool mu stackalloc mu diye düşünüyorum. Eğer JIT bu işi bedavaya yapıyorsa bu düşünmeyi bırakabilirim. Yeter ki hangi şekiller ve hangi boyuta kadar olduğunu bileyim.

Aşağıdakiler ölçüm. Makine: Apple Silicon Mac (arm64), .NET SDK 10.0.302, çalışma zamanı 10.0.10, Release derleme. Çoğu .NET performans yazısı x64'te ölçüyor; buradaki sayılar arm64'ten.

Ölçüm nasıl yapıldı: iki sürüm değil, tek ikili

Alışılmış yöntem .NET 9 ile .NET 10'u yan yana koymak. Sevmedim: iki ayrı çalışma zamanı arasında değişen tek şey escape analizi değil ki — GC ayarları, kütüphane kodu, JIT'in yüzlerce başka kararı da değişiyor.

Bunun yerine aynı ikiliyi, aynı makinede, tek bir JIT anahtarını çevirerek iki kez çalıştırdım:

DOTNET_JitObjectStackAllocation=0   # optimizasyonu kapat
(varsayılan)                        # açık

Bu anahtar çalışma zamanının JIT yapılandırma listesinde RELEASE_CONFIG_INTEGER olarak duruyor, yani sürüm derlemesinde de çalışıyor. Aradaki fark artık tek bir optimizasyon. Ölçtüğüm şey GC.GetAllocatedBytesForCurrentThread() farkı: iterasyon başına öbeğe (heap) düşen bayt. Her vaka 600.000 tur ısıtıldı (tier-1'e çıksın diye), sonra 2 milyon tur ölçüldü.

Önce en can alıcı sayı

Gerçekçi bir iş yazdım: her turda 8 elemanlık bir tampon dizi, onu tutan bir struct ve küçük bir lambda. Skor doğrulama gibi döngülerde sık gördüğüm şekil. 5 milyon tur:

5 milyon tur — öbeğe ayrılan bellek
.NET 10, varsayılan0 MB · 0 Gen0 · 57–69 ms
Stack allocation kapalı267 MB · 33 Gen0 · 72–82 ms

267 megabayt ve 33 Gen0 toplaması, kaynak kodda tek karakter değişmeden ortadan kalkıyor.

Dikkat: süre kazancı %15–20 civarı, yani bellek kazancıyla orantılı değil. Sebebi de mantıklı — Gen0 toplaması zaten ucuz. Asıl kazanç toplam sürede değil, duraklamaların ve bellek ayak izinin yok olmasında. Bir oyun sunucusunda bu ayrım, ortalama gecikme ile p99 gecikme arasındaki fark demek.

JIT neyi soruyor

Karar mekanizması tek bir soruya bakıyor:

Metot içinde new int[8]nesne doğdu
JIT: escape analizibu referans metodun dışına sızıyor mu — static alana yazılıyor, satır içine alınmamış bir metoda geçiyor ya da geri döndürülüyor mu?
SızmıyorYığına (stack) iner. GC hiç haberdar olmaz; metot dönünce yer kendiliğinden gider.
SızıyorÖbeğe (heap) iner. Klasik Gen0 çöpü, hiçbir şey değişmez.

.NET 10'un getirdiği asıl yenilik bu soruyu sormak değil — .NET 9 da soruyordu. Yeni olan, "sızıyor" cevabını veren durumların azalması: artık değer tipi diziler, referans tipi diziler, struct alanında tutulan diziler ve delegate nesneleri de bu elemeden geçebiliyor (Microsoft'un sürüm notları).

Hangi şekil geçiyor, hangisi geçmiyor

11 deseni ayrı ayrı ölçtüm. Sol sütun optimizasyon kapalıyken, sağ sütun açıkken; ikisi de iterasyon başına:

DesenKapalıAçıkSüre (kapalı → açık)
int[] a = {x, 2, 3}40 B0 B11,98 → 5,87 ns
string[] w = {"Hello", "World!"}40 B0 B12,46 → 2,12 ns
Lambda + yakalanan yerel değişken88 B24 B20,44 → 10,69 ns
Struct alanında tutulan dizi40 B0 B14,01 → 5,46 ns
Kutulanmış int (object o = i)0 B0 B7,53 → 4,30 ns
Dizi → IEnumerable üzerinden foreach72 B40 B18,40 → 10,43 ns
Static alana yazılan dizi40 B40 B12,64 → 12,87 ns
Metoda argüman geçilen dizi40 B40 B13,38 → 13,45 ns
Uzunluğu değişken dizi (new int[n])40 B40 B12,19 → 12,27 ns
List<int>(4)72 B72 B15,84 → 19,00 ns
"id-" + i.ToString()83,6 B83,6 B21,06 → 21,52 ns

Buradan çıkan üç şey:

1. Referans tipi dizide kazanç en büyük. İki string'lik dizi 12,46 ns'den 2,12 ns'ye iniyor — altıda bir. Ayrılma maliyetinin yanında GC'nin yazma bariyerlerinin de düşmesi işin içinde.

2. Lambda yarı yarıya kurtuluyor. 88 bayttan 24 bayta. Kalan 24 bayt, derleyicinin yakalanan değişken için ürettiği closure sınıfı (<>c__DisplayClass). Func nesnesi yığına indi, closure inmedi. Bu bir kusur değil, bilinçli sınır: sürüm notları closure'ların yığına indirilmesinin sonraki bir sürümde planlandığını yazıyor. Yani "lambda artık bedava" diye bir şey yok, "lambda artık yarı fiyat" var.

var f = (int x) => x + local;derleyici iki nesne üretir
Func nesnesidavranışı taşır
.NET 10: yığına iner88 B → 24 B
Closure sınıfıyakalanan local değişkenini taşır
Hâlâ öbektekalan 24 baytın tamamı bu

3. Kontrol grubu doğru davrandı. Static alana yazma, metoda geçirme ve değişken uzunluk — üçü de iki modda birebir aynı sayıyı verdi. Ölçüm düzeneğinin doğru çalıştığının kanıtı bu satırlar; hepsi 0 çıksaydı bir yerde yanlış ölçüyor olurdum.

Bir de sürpriz: kutulama zaten iki modda da sıfır. object o = i; return (int)o; deseninde JIT kutuyu tamamen yok ediyor ve bunu JitObjectStackAllocation anahtarından bağımsız yapıyor — kutu yığına inmiyor, hiç var olmuyor.

Sınır nerede: 528 bayt, ama saydığı gibi değil

Asıl merak ettiğim buydu. Dizi boyutunu büyüterek nereye kadar yığında kaldığını aradım. Kaba tarama ve ikiye bölmeyle sınır tek bir elemana kadar daraldı: int[128] yığında, int[129] öbekte.

Çalışma zamanı kaynağındaki varsayılan JitObjectStackAllocationSize = 528. Ama 128 elemanlı bir int dizisi bellekte 24 + 512 = 536 bayt. 536, 528'den büyük; yine de geçiyor. Sayı tutmuyor.

Tutması için JIT'in nesne başlığını 24 değil 16 bayt sayıyor olması gerekiyor (senkronizasyon bloğu hariç: metot tablosu + uzunluk + veri). Bu teoriyi üç ayrı eleman tipiyle sınadım — hepsinde sınır tam olarak 16 + veri ≤ 528 çıktı:

Sınırın iki yakası — iterasyon başına öbek
int[128] · 16+512 = 5280 B — yığın
int[129] · 16+516 = 532544 B — öbek
long[64] · 16+512 = 5280 B — yığın
long[65] · 16+520 = 536544 B — öbek
byte[512] · 16+512 = 5280 B — yığın
byte[520] · 16+520 = 536544 B — öbek

Üç farklı eleman tipi, tek bir eşik. Pratik çeviri: yaklaşık yarım kilobayta kadar olan sabit boyutlu diziler yığına iniyor. Bu 128 int, 64 long ya da 64 nesne referansı demek — gündelik tampon dizilerinin çoğu bu sınırın altında.

Sınır ayarlanabilir de, ama burada güzel bir tuzak var: bu ortam değişkenleri onaltılık okunuyor. DOTNET_JitObjectStackAllocationSize=4096 yazarsanız 4096 değil 0x4096 = 16.534 ayarlamış olursunuz. Ben bunu fark etmeden önce ölçümü bir kez yanlış yorumladım; sınırı =218 (yani 0x218 = 536) vererek doğrulayınca anlaşıldı.

Yine de: bu anahtar desteklenen bir ayar değil. Sınırı yükseltmek yığın (stack) tüketimini artırır ve derin çağrı zincirlerinde stack overflow'a giden yolu kısaltır. Ölçüm için harika, üretim için hayır.

Peki ne yapacağım

Bilmediklerim

Sınırın neden 528 olduğunu bilmiyorum — muhtemelen tipik yığın çerçevesi büyüklüğüne göre seçilmiş bir denge, ama bu benim tahminim, kaynakta gerekçesini bulamadım.

IEnumerable üzerinden foreach vakasında 72 bayttan 40 bayta inişin hangi parçanın yığına indiğini de tam ayıramadım; sürüm notları bu alandaki de-abstraction işinin sürdüğünü söylüyor, ölçüm de yarım bir kazanç gösteriyor.

Bir de arm64'e özel bir madde var ki ölçemedim: .NET 10 Arm64'te yazma bariyerlerini değiştirdi ve sürüm notları GC duraklamalarında %8 ile %20 arası iyileşme bildiriyor. Bunu ayırt etmek için tek anahtarlı temiz bir A/B düzeneği kuramadım; sayıyı kendi ölçtüğüm bir şey olarak değil, Microsoft'un iddiası olarak aktarıyorum.

Bu blogda reklam vermek ya da birlikte iş yapmak

MCALAB bağımsız bir stüdyo. Sponsorluk, çapraz tanıtım ya da bir iş birliği için:

ads@mcalab.com.tr

Ayrıntılar: Reklam & iş birliği. Kullanıcı desteği için destek sayfası.