Perry
tr

From Cranelift to LLVM: How Perry Got 24x Faster

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 her benchmark'ta Node.js'i yeniyor — 1,7x ile 24,6x arasında değişen marjlarla (iki beraberlikle).

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)

401ms

991ms

2.5x

binary_trees

9ms

9ms

berabere

mandelbrot

24ms

24ms

berabere

object_create

9ms

8ms

0.9x

Her benchmark bir galibiyet veya berabere. En yakın sonuç object_create (9ms vs 8ms); burada V8'in allocator'ü gerçekten mükemmel.

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, her benchmark'ta 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-app

Kaynak 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