Bloga Dön
compilersllvmcraneliftperformancemilestone

Cranelift'ten LLVM'ye: Perry Nasıl 24 Kat Hızlandı

Perry'nin Cranelift'ten LLVM'ye backend geçişi tamamlandı. v0.5.12 itibarıyla LLVM tek kod üretim backend'i ve Perry artık 15 benchmark'ın 14'ünde Node.js'i yeniyor — 1,06x ile 24,6x arasında değişen marjlarla.

Buraya gelmek düz bir yol değildi. v0.5.0'daki ilk geçiş, birkaç benchmark'ı yerini aldığı Cranelift sürümünden 70 kat daha yavaş hale getirdi. Bu yazı ne olduğunun, neden yine de geçiş yaptığımızın, neyin bozulduğunun, neyin düzelttiğinin ve rakamların diğer tarafta nasıl göründüğünün uzun versiyonudur.

Eğer bir derleyici yapıyorsanız, codegen backend'lerini değerlendiriyorsanız veya sadece “LLVM'ye geç” ifadesinin neden nadiren göründüğü kadar basit olmadığını merak ediyorsanız, bu yazı sizin için.

Bölüm 1: Neden Geçiş Yaptık?

Perry, TypeScript'i doğrudan yerel makine koduna derler. Node yok, V8 yok, Electron yok, WebView yok. Önerme “TypeScript yaz, yerel bir binary çıkar” ve eğer o binary gerçekten hızlı değilse tüm değer önermesi çöker.

Perry'nin ilk birkaç minor sürümünde codegen backend'i Cranelift'ti. Cranelift mükemmel — wasmtime'ın arkasındaki codegen, SpiderMonkey'nin baseline JIT'i tarafından kullanılıyor ve hızlı, öngörülebilir derleme ile temiz bir gömülme hikayesi gerektiğinde tercih edilen araç. Yeni bir dil bootstrap eden bir proje için doğru başlangıç noktasıydı.

Ancak iki şey bizi sonunda ondan uzaklaştırdı.

1. Optimizer tavanı

Cranelift bilerek hızlı, tek katmanlı bir optimize edici derleyicidir. Görevi “hızla makul kod üret,” “sınırsız zaman verilerek mümkün olan en iyi kodu üret” değil. Bu, JIT için doğru takasdır. Tüm satış noktası yerel performans olan bir AOT derleyici için yanlış takas.

LLVM'nin middle-end'ine yirmi yılı aşkın emek dökülmüştür. Loop vectorization, LICM, GVN, SCCP, instruction combining, inlining heuristics, fast-math reassociation, alias analysis — daha küçük bir projenin bunu yakalayacağı gerçekçi bir dünya yok. Perry “Node'dan hızlı” diyecekse, bu mekanizmaya ihtiyacımız var.

2. arm64_32 sorunu

Acil zorlayan faktör Apple Watch'tu. arm64_32, Apple'ın Series 4 ve sonrası için tanıttığı bir ABI — 64-bit komutlar, 32-bit pointer'lar. Cranelift bunu desteklemiyor ve destek gelmesi için gerçekçi bir yol yoktu. Perry'nin “tek kod tabanından 9 platform” iddiasının inandırıcı olması için watchOS eksik olamazdı. LLVM arm64_32'yi kutudan çıkan haliyle destekliyor.

Bazı hedeflerin LLVM gerektireceğini kabul ettiğimizde, iki backend'i sürdürmek sürdürülemez hale geldi. &Inodot;ki backend iki set hata, iki set optimizasyon pass'ı, iki test matrisi, iki performans temeli demek. Dürüst cevap: birini seç.

LLVM'yi seçtik.

Bölüm 2: Cranelift Hakkında

Devam etmeden önce: bu yazı bir Cranelift eleştirisi değil. Cranelift parlak bir mühendislik eseri ve JIT, sandbox runtime veya derleme gecikmesinin tepe throughput'tan daha önemli olduğu herhangi bir şey yapıyorsanız, listenizin başına yakın olmalı. wasmtime onu iyi bir nedenle kullanıyor. Bytecode Alliance örnek teşkil eden bir çalışma yapıyor.

Perry'nin ihtiyaçları farklı. Önceden derliyoruz, binary'yi bir kez gönderiyoruz ve kullanıcı milyonlarca kez çalıştırıyor. Bu asimetri — nadiren derle, her zaman çalıştır — tam olarak LLVM'nin daha ağır optimizer'ınün kendini amorti ettiği rejim. Farklı iş için farklı araç.

Bölüm 3: Geçiş Felaketi

v0.5.0, LLVM'nin tek backend olduğu ilk sürümdü. Derleme süresinde küçük bir gerileme ve çalışma zamanı performansında anlamlı bir iyileşme bekliyorduk. &Inodot;kincisinin tersini elde ettik.

O zaman yayınlamak istemediğim tablo:

BenchmarkCraneliftLLVM v0.5.0Delta
method_calls16ms1,084ms68 kat yavaş
object_create5ms318ms64 kat yavaş
matrix_multiply61ms184ms3 kat yavaş
math_intensive370ms131ms2,8 kat hızlı
nested_loops32ms57ms1,8 kat yavaş
fibonacci(40)505ms1,156ms2,3 kat yavaş

Bazı iş yükleri hızlandı. Çoğu dramatik şekilde kötüleşti. method_calls — idiomatik TypeScript class kullanımını temsil ettiği için en önemli benchmark'lardan biri — iki sürüm önceki gönderdiklerimizden neredeyse 70 kat daha kötüydü.

Asıl yanlış giden ne

Perry, değer temsili için NaN-boxing kullanır. Her TypeScript değeri 64-bit bir word'dür. f64 sayılar doğrudan depolanr; diğer her şey (nesneler, stringler, boolean'lar, undefined, null) bir IEEE 754 quiet NaN'ın kullanılmayan bitlerine kodlanır.

Avantajı: sayılar sıfır maliyetli. Boxing yok, tagging yok, aritmetik için bellek ayırma yok.

Dezavantajı: sayısal olmayan her değer üzerindeki her işlem, açmak, işlemek ve yeniden paketlemek için bit manipülasyonu gerektirir. Eğer bu diziler codegen'inizde inline IR olarak yaşıyorsa, optimizer bunları birleştirip basitleştirebilir. Eğer runtime helper fonksiyon çağrıları olarak yaşıyorsa, optimizer opak bir çağrı görür ve vazgeçer.

Cranelift backend'imiz, sıcak işlemler için çok sayıda inline lowering geliştirmişti — özellik yüklemeleri, metot dispatch'i, nesne ayırma, f64 etiketli değerler üzerinde tamsayı aritmetik. LLVM geçişi, önce doğru kod çıkarma çıkarına, bunların neredeyse tamamını perry-runtime'daki runtime helper'ları üzerinden yönlendirdi. Her helper LLVM IR'de bir call komutuydu.

LLVM mükemmel, ama gövdesini hiç görmediği bir fonksiyonu inline yapamaz. perry-runtime ayrı derlenir, sonunda bağlanır ve optimizer'ın perspektifinden her helper çağrısı bir kara kutudur. Sonuç, Cranelift backend'inin ~5 inline aritmetik komutu olarak derlediği sıcak döngülerin artık fonksiyon çağrılarına — yazmaç kaydı, stack frame kurulumu, her şey — milyonlarca kez tekrarlanan şekilde derlenmesiydi.

70x buradan geldi. Kötü codegen değil. Kötü inlining sınırları.

Bölüm 4: Düzeltme

Cranelift rakamlarını kurtarma ve aşma çalışması kabaca altı kategoriye ayrıldı. Hiçbiri egzotik değil. Çoğu, sadece doğru yerlerde uygulanması gereken ders kitabı derleyici optimizasyonları.

1. Nesne ayırma için inline bump allocator

object_create, method_calls'dan sonraki en kötü gerilemeydi. Eski yol her new Point() için js_object_alloc_class_with_keys'i çağırıyordu — bir fonksiyon çağrısı, bir thread-local arena erişimi, bir shape-cache araması ve GC header + nesne header yazma.

Düzeltme: bump allocation'ı LLVM IR'de inline olarak emit et. Nesne ayıran her fonksiyon, thread-local bir InlineArenaState struct'ına önbelleklenmiş bir pointer alır. Ayırma şöyle olur:

; state is a ptr to InlineArenaState { data: ptr, offset: i64, size: i64 }
%off_ptr = getelementptr i8, ptr %state, i64 8
%offset  = load i64, ptr %off_ptr           ; current bump offset
%new_off = add i64 %offset, 96              ; GcHeader(8) + ObjectHeader(24) + 8 fields(64)
%sz_ptr  = getelementptr i8, ptr %state, i64 16
%size    = load i64, ptr %sz_ptr            ; current block capacity
%fits    = icmp ule i64 %new_off, %size
br i1 %fits, label %fast, label %slow
fast:
  store i64 %new_off, ptr %off_ptr          ; bump the offset
  %data = load ptr, ptr %state              ; data pointer at offset 0
  %raw  = getelementptr i8, ptr %data, i64 %offset
  store i64 <packed_gc_header>, ptr %raw    ; GcHeader as one i64
slow:
  call ptr @js_inline_arena_slow_alloc(ptr %state, i64 96, i64 8)

Fast path, LLVM'nin görebildi&gbreve;i, zamanlayabildi&gbreve;i ve döngülerden kald&inodot;rabildi&gbreve;i ~13 inline IR komutudur. object_create 318ms'den 9ms'ye dü&scedil;tü.

2. i32 döngü sayaçlar&inodot;

NaN-boxing, her TypeScript say&inodot;s&inodot;n&inodot;n f64 oldu&gbreve;u anlam&inodot;na gelir. Döngü sayaçlar&inodot; dahil. f64 indüksiyon de&gbreve;i&scedil;kenleriyle for (let i = 0; i < 100_000_000; i++) döngüsü felaket: f64 art&inodot;rma, f64 kar&scedil;&inodot;la&scedil;t&inodot;rma, her dizi indekslemede f64'ten i64'e dönü&scedil;üm.

Codegen, indüksiyon de&gbreve;i&scedil;keninin kan&inodot;tlanabilir &scedil;ekilde tamsay&inodot; de&gbreve;erli oldu&gbreve;u for-döngüleri tespit eder ve paralel i32 stack slot'u ay&inodot;r&inodot;r. Döngü ko&scedil;ulu fcmp'den icmp slt i32'ye geçerek f64 sayaç&inodot;n&inodot; tamamen ortadan kald&inodot;r&inodot;r.

Bu, array_write'&inodot; 11ms'den 3ms'ye, nested_loops'u 18ms'den 9ms'ye ve array_read'i 11ms'den 4ms'ye ta&scedil;&inodot;d&inodot;.

3. Fast-math bayraklar&inodot;

Her f64 aritmetik komutuna reassoc contract bayraklar&inodot; ekliyoruz. reassoc, LLVM'nin seri akümülatör zincirlerini paralel olanlara bölmesine olanak tan&inodot;r ve contract fused multiply-add'e izin verir. Perry NaN bitlerini de&gbreve;er etiketi olarak kulland&inodot;&gbreve;&inodot; için nnan ve ninf'i kapal&inodot; tutuyoruz.

Bu bayraklarla, LLVM'nin döngü vektörle&scedil;tiricisi math_intensive'de devreye giriyor; bu da 131ms'den 14ms'ye dü&scedil;tü — Node'u 3,5x yeniyor.

4. Tamsay&inodot; modülo fast path

JavaScript'te f64 üzerindeki % operand&inodot; fmod'dur, ki bu ARM'de bir libm ça&gbreve;r&inodot;s&inodot;d&inodot;r. Ama tamsay&inodot; de&gbreve;erli f64 operandlar için fptosi → srem → sitofp yapabilir ve libm gidi&scedil;-dönü&scedil;ünü tamamen atlayabiliriz. Codegen, tamsay&inodot; de&gbreve;erli operandlar&inodot; tespit etmek için statik analiz kullan&inodot;r — runtime kontrolü gerekmez.

factorial'&inodot;n 1.553ms'den 24ms'ye inmesinin — ve Node'un 591ms'inden 24ms'ye inmesinin tüm nedeni budur. Node'dan 24,6 kat h&inodot;zl&inodot;.

5. &Inodot;ç içe döngüler için LICM

LLVM kutudan ç&inodot;kan haliyle loop-invariant code motion yapar, ancak NaN-boxing yap&inodot;y&inodot; gizler. arr.length, etiket kontrolü olan NaN-boxed bir pointer üzerinden yüklemeye dönü&scedil;ür — aç&inodot;kça invariant de&gbreve;il.

Codegen, for (...; i < arr.length; ...) kal&inodot;b&inodot;n&inodot; tespit eder ve uzunlu&gbreve;u döngüden önce bir stack slot'una ön yükler; statik bir walker döngü gövdesinin dizinin uzunlu&gbreve;unu de&gbreve;i&scedil;tiremeyece&gbreve;ini do&gbreve;rular. Sayaç bu kald&inodot;r&inodot;lm&inodot;&scedil; uzunlukla s&inodot;n&inodot;rland&inodot;&gbreve;&inodot;nda, IndexGet/IndexSet s&inodot;n&inodot;r kontrollerini tamamen atlar.

6. Shape-cache'li nesneler

Codegen bir nesnenin class'&inodot;n&inodot; bildi&gbreve;inde, alan offsetlerini derleme zaman&inodot;nda çözer ve do&gbreve;rudan indeksli yüklemeler emit eder — runtime dispatch yok. Metot dispatch için, obj.method(args) do&gbreve;rudan bir call @perry_method_Class_name(this, args) olur — vtable yok, inline cache yok, hash lookup yok.

LLVM geçi&scedil;i bunu evrensel slow path'e geriletmi&scedil;ti. Statik dispatch'i geri yüklemek bize method_calls kurtarmas&inodot;n&inodot; verdi — 1.084ms'den tekrar 1ms'ye. Node'dan 11 kat h&inodot;zl&inodot;.

Bölüm 5: Bugünkü Rakamlar

Üç çal&inodot;&scedil;t&inodot;rman&inodot;n medyan&inodot;, macOS ARM64 (Apple Silicon, M1 Max), Node.js v25:

BenchmarkPerryNode.jsvs Node
factorial24ms591ms24.6x
method_calls1ms11ms11x
loop_overhead12ms53ms4.4x
math_intensive14ms49ms3.5x
array_read4ms13ms3.2x
closure97ms303ms3.1x
array_write3ms8ms2.6x
string_concat1ms2ms2x
nested_loops9ms16ms1.7x
prime_sieve4ms7ms1.7x
matrix_multiply21ms34ms1.6x
fibonacci(40)932ms991ms1.06x
binary_trees9ms9msberabere
mandelbrot24ms24msberabere
object_create9ms8ms0.9x

15'te 14 galibiyet. Tek kay&inodot;p object_create; burada V8'in allocator'ü gerçekten mükemmel ve %12 içindeyiz.

Bölüm 6: Derleme Süresi Sorusu

&Inodot;nsanlar&inodot;n LLVM yerine Cranelift'i seçmesinin bir numaral&inodot; nedeni derleme h&inodot;z&inodot;d&inodot;r. Haydi bundan konu&scedil;al&inodot;m.

LLVM, Perry'nin dosya ba&scedil;&inodot;na derleme süresini 20-50ms veya yakla&scedil;&inodot;k %8-19 art&inodot;rd&inodot;. 5x de&gbreve;il. 2x de&gbreve;il. Tek haneli ile dü&scedil;ük çift haneli yüzdelik.

Nedeni, codegen'in Perry'nin pipeline'&inodot;ndaki darbo&gbreve;az olmamas&inodot;d&inodot;r. Tipik bir dosya için da&gbreve;&inodot;l&inodot;m:

  • SWC parsing: ~%30
  • HIR lowering (AST → IR, tip ç&inodot;kar&inodot;m&inodot;): ~%25
  • IR dönü&scedil;üm pass'lar&inodot; (closure dönü&scedil;ümü, async lowering, inlining): ~%15
  • Codegen (LLVM IR metin emisyonu + clang -c -O3): ~%20
  • Linking (cc + runtime kütüphanesi): ~%10

Codegen be&scedil; dilimin biri. O dilimi ikiye katlasan&inodot;z bile toplam sadece %5-10 hareket eder. Kullan&inodot;c&inodot;n&inodot;n perry compile yazd&inodot;&gbreve;&inodot; ve binary'yi sonsuza dek çal&inodot;&scedil;t&inodot;rd&inodot;&gbreve;&inodot; bir AOT derleyici yap&inodot;yorsan&inodot;z, hesap &scedil;udur: derleme zaman&inodot;nda 25ms daha harca, her çal&inodot;&scedil;t&inodot;rmada 24x'e kadar tasarruf et.

Bölüm 7: Neyi Farkl&inodot; Yapard&inodot;m

E&gbreve;er Perry'yi bugün ba&scedil;lat&inodot;yor olsayd&inodot;m ve do&gbreve;rudan LLVM'ye atlayabilseydim, atlamazd&inodot;m. Cranelift a&scedil;amas&inodot; gerçekten de&gbreve;erliydi. LLVM'nin karma&scedil;&inodot;kl&inodot;k vergisi olmadan frontend üzerinde iterasyon yapmam&inodot;z&inodot; sa&gbreve;lad&inodot;, kar&scedil;&inodot;la&scedil;t&inodot;rma için çal&inodot;&scedil;an bir temel hat verdi ve HIR'imizi backend'ler aras&inodot;nda ta&scedil;&inodot;nabilir olacak kadar temiz tutmaya zorlad&inodot;.

Farkl&inodot; yapaca&gbreve;&inodot;m &scedil;ey geçi&scedil;in kendisi. v0.5.0'&inodot; ço&gbreve;u i&scedil;lem runtime helper ça&gbreve;r&inodot;lar&inodot;ndan geçerek yay&inodot;nlad&inodot;k, bunlar&inodot; sonra inline yapmay&inodot; planl&inodot;yorduk. Bu yanl&inodot;&scedil;t&inodot;. Do&gbreve;ru s&inodot;ra &scedil;u olurdu: önce s&inodot;cak yollar&inodot; belirle, geçi&scedil;ten önce bunlar&inodot; inline olarak alçalt ve ancak LLVM backend'i en az&inodot;ndan e&scedil;it seviyeye geldi&gbreve;inde yay&inodot;nla.

Ders s&inodot;k&inodot;c&inodot; olan: optimizasyon s&inodot;n&inodot;rlar&inodot; optimizer kalitesinden daha önemli. LLVM ola&gbreve;anüstü bir yaz&inodot;l&inodot;m parças&inodot;, ama göremedi&gbreve;i kodda size yard&inodot;mc&inodot; olamaz. E&gbreve;er codegen'iniz her &scedil;eyi opak runtime ça&gbreve;r&inodot;lar&inodot; üzerinden yönlendiriyorsa, kaynak program&inodot;n&inodot;z ile var olan her optimizasyon pass'&inodot; aras&inodot;na bir duvar örmü&scedil;sünüz demektir.

Sonuç

Perry art&inodot;k yaln&inodot;zca LLVM, 15 benchmark'&inodot;n 14'ünde Node'dan h&inodot;zl&inodot; ve yay&inodot;nda. Geçi&scedil; planlad&inodot;&gbreve;&inodot;mdan uzun sürdü, ortada bekledi&gbreve;imden fazla ac&inodot;tt&inodot; ve geriye dönüp bak&inodot;ld&inodot;&gbreve;&inodot;nda kesin olarak do&gbreve;ru karar. Cranelift bizi v0.5'e getirdi; LLVM bizi geri kalan yolda ta&scedil;&inodot;yor.

Perry'yi denemek istiyorsan&inodot;z:

brew install perryts/perry/perry
perry init my-app && cd my-app
perry compile src/main.ts -o my-app && ./my-app

Kaynak kod: github.com/PerryTS/perry — Docs: docs.perryts.com — Benchmark'lar&inodot; kendiniz çal&inodot;&scedil;t&inodot;r&inodot;n: cd benchmarks/suite && ./run_benchmarks.sh

Sorular&inodot;n&inodot;z varsa, hatalar bulursan&inodot;z veya codegen backend'leri hakk&inodot;nda tart&inodot;&scedil;mak isterseniz, GitHub issue'lar&inodot; aç&inodot;k. Hepsini okuyorum.

— Ralph