.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:
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:
new int[8]nesne doğdu.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:
| Desen | Kapalı | Açık | Süre (kapalı → açık) |
|---|---|---|---|
int[] a = {x, 2, 3} | 40 B | 0 B | 11,98 → 5,87 ns |
string[] w = {"Hello", "World!"} | 40 B | 0 B | 12,46 → 2,12 ns |
| Lambda + yakalanan yerel değişken | 88 B | 24 B | 20,44 → 10,69 ns |
| Struct alanında tutulan dizi | 40 B | 0 B | 14,01 → 5,46 ns |
Kutulanmış int (object o = i) | 0 B | 0 B | 7,53 → 4,30 ns |
Dizi → IEnumerable üzerinden foreach | 72 B | 40 B | 18,40 → 10,43 ns |
| Static alana yazılan dizi | 40 B | 40 B | 12,64 → 12,87 ns |
| Metoda argüman geçilen dizi | 40 B | 40 B | 13,38 → 13,45 ns |
Uzunluğu değişken dizi (new int[n]) | 40 B | 40 B | 12,19 → 12,27 ns |
List<int>(4) | 72 B | 72 B | 15,84 → 19,00 ns |
"id-" + i.ToString() | 83,6 B | 83,6 B | 21,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 üretirlocal değişkenini taşır3. 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ı:
Üç 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
- Kısa ömürlü, sabit boyutlu küçük diziler artık suçlu değil.
new int[8]'i bir döngünün içinde yazmaktan çekinmeyi bırakıyorum — yarım kilobaytın altında ve metottan sızmıyorsa GC'yi hiç görmüyor. stackallocveArrayPoolgerekçesi zayıfladı ama ölmedi. Sınırın üstündeki, uzunluğu çalışma anında belli olan ya da metottan çıkan tamponlarda hâlâ tek çözüm onlar. Aradaki fark: artık ölçmeden havuza sarılmak için sebep yok.- Lambda'yı sıcak döngüde hâlâ dikkatli kullanıyorum. Yakalanan değişken varsa turda 24 bayt duruyor. Yakalama yoksa derleyici zaten delegate'i önbelleğe alıyor ve sıfır bayt oluyor.
List<T>bu partiye davetli değil. Dört elemanlı bir listede bile 72 bayt yerinde duruyor.
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.trAyrıntılar: Reklam & iş birliği. Kullanıcı desteği için destek sayfası.