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:
| Benchmark | Cranelift | LLVM v0.5.0 | Delta |
|---|---|---|---|
| method_calls | 16ms | 1,084ms | 68 kat yavaş |
| object_create | 5ms | 318ms | 64 kat yavaş |
| matrix_multiply | 61ms | 184ms | 3 kat yavaş |
| math_intensive | 370ms | 131ms | 2,8 kat hızlı |
| nested_loops | 32ms | 57ms | 1,8 kat yavaş |
| fibonacci(40) | 505ms | 1,156ms | 2,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ği, zamanlayabildiği ve döngülerden kaldırabildiği ~13 inline IR komutudur. object_create 318ms'den 9ms'ye düştü.
2. i32 döngü sayaçları
NaN-boxing, her TypeScript sayısının f64 olduğu anlamına gelir. Döngü sayaçları dahil. f64 indüksiyon değişkenleriyle for (let i = 0; i < 100_000_000; i++) döngüsü felaket: f64 artırma, f64 karşılaştırma, her dizi indekslemede f64'ten i64'e dönüşüm.
Codegen, indüksiyon değişkeninin kanıtlanabilir şekilde tamsayı değerli olduğu for-döngüleri tespit eder ve paralel i32 stack slot'u ayırır. Döngü koşulu fcmp'den icmp slt i32'ye geçerek f64 sayaçını tamamen ortadan kaldırır.
Bu, array_write'ı 11ms'den 3ms'ye, nested_loops'u 18ms'den 9ms'ye ve array_read'i 11ms'den 4ms'ye taşıdı.
3. Fast-math bayrakları
Her f64 aritmetik komutuna reassoc contract bayrakları ekliyoruz. reassoc, LLVM'nin seri akümülatör zincirlerini paralel olanlara bölmesine olanak tanır ve contract fused multiply-add'e izin verir. Perry NaN bitlerini değer etiketi olarak kullandığı için nnan ve ninf'i kapalı tutuyoruz.
Bu bayraklarla, LLVM'nin döngü vektörleştiricisi math_intensive'de devreye giriyor; bu da 131ms'den 14ms'ye düştü — Node'u 3,5x yeniyor.
4. Tamsayı modülo fast path
JavaScript'te f64 üzerindeki % operandı fmod'dur, ki bu ARM'de bir libm çağrısıdır. Ama tamsayı değerli f64 operandlar için fptosi → srem → sitofp yapabilir ve libm gidiş-dönüşünü tamamen atlayabiliriz. Codegen, tamsayı değerli operandları tespit etmek için statik analiz kullanır — runtime kontrolü gerekmez.
factorial'ı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ızlı.
5. &Inodot;ç içe döngüler için LICM
LLVM kutudan çıkan haliyle loop-invariant code motion yapar, ancak NaN-boxing yapıyı gizler. arr.length, etiket kontrolü olan NaN-boxed bir pointer üzerinden yüklemeye dönüşür — açıkça invariant değil.
Codegen, for (...; i < arr.length; ...) kalıbını tespit eder ve uzunluğu döngüden önce bir stack slot'una ön yükler; statik bir walker döngü gövdesinin dizinin uzunluğunu değiştiremeyeceğini doğrular. Sayaç bu kaldırılmış uzunlukla sınırlandığında, IndexGet/IndexSet sınır kontrollerini tamamen atlar.
6. Shape-cache'li nesneler
Codegen bir nesnenin class'ını bildiğinde, alan offsetlerini derleme zamanında çözer ve doğrudan indeksli yüklemeler emit eder — runtime dispatch yok. Metot dispatch için, obj.method(args) doğrudan bir call @perry_method_Class_name(this, args) olur — vtable yok, inline cache yok, hash lookup yok.
LLVM geçişi bunu evrensel slow path'e geriletmişti. Statik dispatch'i geri yüklemek bize method_calls kurtarmasını verdi — 1.084ms'den tekrar 1ms'ye. Node'dan 11 kat hızlı.
Bölüm 5: Bugünkü Rakamlar
Üç çalıştırmanın medyanı, macOS ARM64 (Apple Silicon, M1 Max), Node.js v25:
| Benchmark | Perry | Node.js | vs Node |
|---|---|---|---|
| factorial | 24ms | 591ms | 24.6x |
| method_calls | 1ms | 11ms | 11x |
| loop_overhead | 12ms | 53ms | 4.4x |
| math_intensive | 14ms | 49ms | 3.5x |
| array_read | 4ms | 13ms | 3.2x |
| closure | 97ms | 303ms | 3.1x |
| array_write | 3ms | 8ms | 2.6x |
| string_concat | 1ms | 2ms | 2x |
| nested_loops | 9ms | 16ms | 1.7x |
| prime_sieve | 4ms | 7ms | 1.7x |
| matrix_multiply | 21ms | 34ms | 1.6x |
| fibonacci(40) | 932ms | 991ms | 1.06x |
| binary_trees | 9ms | 9ms | berabere |
| mandelbrot | 24ms | 24ms | berabere |
| object_create | 9ms | 8ms | 0.9x |
15'te 14 galibiyet. Tek kayı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ın LLVM yerine Cranelift'i seçmesinin bir numaralı nedeni derleme hızıdır. Haydi bundan konuşalım.
LLVM, Perry'nin dosya başına derleme süresini 20-50ms veya yaklaşık %8-19 artırdı. 5x değil. 2x değil. Tek haneli ile düşük çift haneli yüzdelik.
Nedeni, codegen'in Perry'nin pipeline'ındaki darboğaz olmamasıdır. Tipik bir dosya için dağılım:
- SWC parsing: ~%30
- HIR lowering (AST → IR, tip çıkarımı): ~%25
- IR dönüşüm pass'ları (closure dönüşümü, async lowering, inlining): ~%15
- Codegen (LLVM IR metin emisyonu +
clang -c -O3): ~%20 - Linking (
cc+ runtime kütüphanesi): ~%10
Codegen beş dilimin biri. O dilimi ikiye katlasanız bile toplam sadece %5-10 hareket eder. Kullanıcının perry compile yazdığı ve binary'yi sonsuza dek çalıştırdığı bir AOT derleyici yapıyorsanız, hesap şudur: derleme zamanında 25ms daha harca, her çalıştırmada 24x'e kadar tasarruf et.
Bölüm 7: Neyi Farklı Yapardım
Eğer Perry'yi bugün başlatıyor olsaydım ve doğrudan LLVM'ye atlayabilseydim, atlamazdım. Cranelift aşaması gerçekten değerliydi. LLVM'nin karmaşıklık vergisi olmadan frontend üzerinde iterasyon yapmamızı sağladı, karşılaştırma için çalışan bir temel hat verdi ve HIR'imizi backend'ler arasında taşınabilir olacak kadar temiz tutmaya zorladı.
Farklı yapacağım şey geçişin kendisi. v0.5.0'ı çoğu işlem runtime helper çağrılarından geçerek yayınladık, bunları sonra inline yapmayı planlıyorduk. Bu yanlıştı. Doğru sıra şu olurdu: önce sıcak yolları belirle, geçişten önce bunları inline olarak alçalt ve ancak LLVM backend'i en azından eşit seviyeye geldiğinde yayınla.
Ders sıkıcı olan: optimizasyon sınırları optimizer kalitesinden daha önemli. LLVM olağanüstü bir yazılım parçası, ama göremediği kodda size yardımcı olamaz. Eğer codegen'iniz her şeyi opak runtime çağrıları üzerinden yönlendiriyorsa, kaynak programınız ile var olan her optimizasyon pass'ı arasına bir duvar örmüşsünüz demektir.
Sonuç
Perry artık yalnızca LLVM, 15 benchmark'ın 14'ünde Node'dan hızlı ve yayında. Geçiş planladığımdan uzun sürdü, ortada beklediğimden fazla acıttı ve geriye dönüp bakıldığında kesin olarak doğru karar. Cranelift bizi v0.5'e getirdi; LLVM bizi geri kalan yolda taşıyor.
Perry'yi denemek istiyorsanız:
brew install perryts/perry/perry
perry init my-app && cd my-app
perry compile src/main.ts -o my-app && ./my-appKaynak kod: github.com/PerryTS/perry — Docs: docs.perryts.com — Benchmark'ları kendiniz çalıştırın: cd benchmarks/suite && ./run_benchmarks.sh
Sorularınız varsa, hatalar bulursanız veya codegen backend'leri hakkında tartışmak isterseniz, GitHub issue'ları açık. Hepsini okuyorum.
— Ralph