Oğuzhan KARAGÜZEL

OĞUZHAN KARAGÜZEL

Yazılım geliştirici

ReadyToRun ile Soğuk Başlangıç: Deploy Sonrası İlk İstek Neden Yavaş?

Yazar: Oğuzhan KARAGÜZEL ·

Yeni sürümü yayına aldınız, siteyi açtınız, ilk sayfa bir saniyeden uzun sürdü. Sayfayı yenilediniz, bu sefer 50 milisaniyede geldi. Kodda bir hata yok; bu, .NET uygulamalarının soğuk başlangıç maliyeti. Bu yazıda ilk isteğin neden yavaş olduğunu, ReadyToRun'un bunun ne kadarını giderdiğini, nasıl açıldığını ve ne zaman hiçbir işe yaramadığını kendi yaptığım ölçümlerle anlatacağım.

İlk istekte neler oluyor?

C# kodunu derlediğinizde doğrudan işlemcinin çalıştırabileceği makine kodu oluşmaz; ara bir dil (IL, Intermediate Language) oluşur. IL'i makine koduna, uygulama çalışırken devreye giren JIT (Just-In-Time) derleyicisi çevirir. JIT bir metodu ilk çağrıldığı anda derler. Bir web uygulamasında ilk istek de binlerce metodun ilk kez çağrıldığı an:

  • Bağımlılık enjeksiyonu (DI) servisleri ilk kez oluşturur.
  • Yönlendirme (routing), model bağlama (model binding) ve filtreler ilk kez çalışır.
  • EF Core modelini kurar ve ilk sorguyu SQL'e çevirir.
  • Razor görünümleri ilk kez çalışır.

Bunların hepsi JIT için yeni iş. Sonraki isteklerde bu metotlar zaten derlenmiş olduğundan aynı sayfa çok daha hızlı geliyor. Bu sitenin altyapısı Elevare'de yerelde ölçtüğümde, uygulama açıldıktan sonraki ilk sayfa yaklaşık 600 ms, ısınmış bir sayfa ise yaklaşık 50 ms sürüyordu.

.NET'in kendi kütüphanelerinde bu sorun büyük ölçüde zaten çözülmüş: paylaşılan çerçeve (Microsoft.NETCore.App, Microsoft.AspNetCore.App) önceden derlenmiş olarak geliyor. JIT'in asıl derlediği şey sizin kodunuz ve NuGet'ten gelen paketler. EF Core gibi paketlerin içinde sadece IL var.

ReadyToRun nedir?

ReadyToRun (R2R), uygulamayı yayınlarken IL'in yanına o platform için önceden derlenmiş makine kodu ekleyen bir derleme şekli. Uygulama çalışırken R2R kodu olan metotlar için JIT'e gerek kalmıyor, hazır kod doğrudan kullanılıyor. IL de dosyada durmaya devam ettiği için uygulama yine sıradan bir .NET uygulaması: aynı çalışma zamanında, aynı yansıma (reflection) imkânlarıyla çalışıyor.

R2R'yi "her şeyi önceden derleyen" Native AOT'tan ayıran şey de bu. R2R işi önden halleder ama JIT hâlâ oradadır ve gerektiğinde devreye girer. İkisinin farkını JIT ve AOT Farkı yazısında ayrıntılı anlattım.

Nasıl açılır?

R2R bir yayınlama (publish) ayarı. Üretilen makine kodu platforma özel olduğu için hedef platformu da vermeniz gerekiyor:

Bash
dotnet publish -c Release -r linux-x64 --self-contained false -p:PublishReadyToRun=true

Ya da proje dosyasında:

XML
<PropertyGroup>
  <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
  <PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>

--self-contained false, uygulamanın sunucudaki .NET çalışma zamanını kullanmaya devam etmesini sağlıyor. Resmi aspnet Docker imajında çalıştıracaksanız doğru seçim bu.

Docker'da hem x64 hem ARM sunucular için imaj çıkarıyorsanız platformu derleme sırasında seçmeniz gerekiyor. Elevare'in Dockerfile'ında bu iş şöyle yapılıyor:

Dockerfile
ARG TARGETARCH

RUN RID=linux-$([ "$TARGETARCH" = "arm64" ] && echo arm64 || echo x64) \
    && dotnet restore WebMvc.csproj -r $RID -p:PublishReadyToRun=true

RUN RID=linux-$([ "$TARGETARCH" = "arm64" ] && echo arm64 || echo x64) \
    && dotnet publish WebMvc.csproj -c Release -o /app/publish --no-restore \
       -r $RID --self-contained false -p:PublishReadyToRun=true

TARGETARCH'ı BuildKit kendisi dolduruyor. Eski "legacy builder" doldurmuyor, o durumda betik x64'e düşüyor. Restore adımına da aynı -r ve PublishReadyToRun değerlerini vermeyi unutmayın; çünkü R2R derleyicisi (crossgen) restore sırasında inen bir paket.

Ölçtüm: ne kadar kazandırıyor?

Farkı görmek için iki örnek uygulama hazırladım ve ikisini de hem JIT (normal yayın) hem ReadyToRun olarak derledim. Ölçtüğüm şey, uygulamayı başlattığım andan ilk başarılı HTTP cevabına kadar geçen süre. Her birini 15 kez başlattım ve ortanca değeri aldım. Ortam: Docker Desktop (WSL2) üzerinde 2 çekirdek ve 2 GB bellekle sınırladığım bir Linux konteyneri, .NET 10.

UygulamaDerlemeİlk yanıtBellekYayın boyutu
Minimal API (iki uç nokta)JIT195 ms62 MB152 KB
Minimal API (iki uç nokta)ReadyToRun195 ms63 MB168 KB
MVC + Razor + EF Core (SQLite)JIT1129 ms118 MB6,9 MB
MVC + Razor + EF Core (SQLite)ReadyToRun659 ms118 MB16 MB

Sonuç iki parça:

  • Küçük minimal API'de R2R hiçbir şey kazandırmadı. Uygulamanın kendi kodu birkaç satır. Çalışan kodun neredeyse tamamı zaten önceden derlenmiş ASP.NET çerçevesinden geliyor. R2R'nin derleyecek bir şeyi yok.
  • MVC, Razor ve EF Core kullanan uygulamada ilk yanıt yüzde 42 hızlandı (1129 ms'den 659 ms'ye). Bu sefer denetleyiciler (controller), Razor görünümü ve en önemlisi EF Core gibi sadece IL içeren paketler önceden derlenmiş oldu.

Bu ölçümleri küçük örnekler üzerinde ve kendi bilgisayarımda yaptım, sizin uygulamanızda oranlar farklı çıkacaktır. Ama gidişat belli: uygulamanızda ne kadar çok kendi kodunuz ve IL paketi varsa R2R o kadar çok kazandırıyor.

Karşılığında ne veriyorsunuz?

R2R'nin iki dezavantajı var:

  • Yayın büyüyor. Her dosyada IL ile makine kodu yan yana duruyor. Örnekte yayın 6,9 MB'tan 16 MB'a çıktı. Docker imajı ve deploy süresi de buna göre büyüyor.
  • R2R kodu en iyi kod değil. Önceden derlenen kod, uygulamanın çalışırken nasıl kullanılacağını bilmeden üretiliyor. .NET bunu kademeli derlemeyle (tiered compilation) dengeliyor: sık çağrılan metotlar bir süre sonra JIT tarafından, çalışma sırasında toplanan bilgilerle (dinamik PGO) yeniden ve daha iyi derleniyor. Yani R2R başlangıcı hızlandırıyor ama uzun süre çalışan bir uygulamanın en yüksek hızını düşürmüyor, çünkü sıcak kod zaten yeniden derleniyor.

Bir de R2R kodu platforma özel. linux-x64 için çıkarılmış bir yayın, ARM sunucuda R2R kodunu kullanamaz. O yüzden imajı hangi platformda çalışacaksa o platform için derlemek gerekiyor.

Ne zaman kullanmalı?

  • Kullanın: MVC, Razor Pages, Blazor Server ya da EF Core kullanan, kodu ve bağımlılığı bol uygulamalarda. Özellikle sık deploy edilen, yük arttıkça yeni örnek açılan ya da trafiği az olduğu için sık sık soğuyan sitelerde.
  • Gerek yok: Birkaç uç noktası olan küçük servislerde. Ölçümde gördüğünüz gibi kazanç sıfıra yakın. Böyle servislerde başlangıç gerçekten önemliyse bakmanız gereken yer Native AOT.
  • Tek başına yetmez: R2R JIT maliyetini azaltıyor ama ilk istekteki diğer işleri (EF Core modelinin kurulması, ilk veritabanı bağlantısı, boş önbellekler) ortadan kaldırmıyor.

Elevare'de R2R'ye tek başına güvenmiyoruz. Uygulama açıldığında ve önbellek temizlendiğinde yayındaki sayfaları arka planda kendisi isteyen bir "ısıtma" servisi de çalışıyor. Böylece ilk ziyaretçi de ısınmış bir sayfa görüyor. Bu yapıyı ASP.NET Core Output Cache ile etiket tabanlı temizleme yazısında anlattım.

Sonuç

Deploy'dan sonraki ilk isteğin yavaşlığı büyük ölçüde JIT'in ilk çağrılarda yaptığı derleme işinden geliyor. ReadyToRun bu işi yayın sırasında önceden yaparak ilk isteği hızlandırıyor; benim ölçümümde MVC ve EF Core kullanan bir uygulamada ilk yanıt yüzde 42 kısaldı. Karşılığında daha büyük bir yayın ve platforma özel bir derleme alıyorsunuz. Kodu az bir minimal API'de ise hiç fark yaratmadı. Kendi uygulamanızda denemek için bir -p:PublishReadyToRun=true ve bir ölçüm yeterli. Deploy düzenimizin başka bir yüzünü, diski dolduran Docker artıklarını da Sunucum Neden Kilitlendi? yazısında anlattım.