Her Şeyi Optimize Etmek: Bir Hafta, 68 Sürüm ve 547x JSON Hızlanması
Son blog yazısı Perry v0.5.12 ile yayınlanmıştı. Bugün v0.5.80'deyiz. Bu yedi günde 68 patch sürümü, neredeyse tamamen tek bir şeye odaklanmış durumda: kalan her yavaş yolu hızlı bir yola dönüştürmek.
v0.5.0'daki LLVM geçişi v0.5.12 itibarıyla Cranelift ile eşit seviyeye geri döndü. Bu bir hikayenin sonu ve başkasının başlangıcıydı. LLVM artık her şeyi görüyor. Soru artık “bu neden yavaş?” olmaktan çıktı ve “bu neden zaten hızlı değil?” olmaya başladı — ki bu çok daha ele alınabilir bir soru.
Bu yazı o haftanın bir turu. JSON 547x hızlanma aldı. mimalloc global allocator oldu. Özellik erişimi monomorfik bir inline cache kazandı. Buffer'lar noalias metadata'sı ile tipli pointer slotları kazandı. Fastify ve WebSocket sunucuları bir dakika sonra çökmeyi bıraktı. Ve benchmark'lar yeniden hareket etti.
1. JSON: 547x uçurumu kapatmak
v0.5.29'da Perry'nin JSON.parse'ı, 20 kayıtlı bir dizide Node'dan 547 kat daha yavaştı. v0.5.46 itibarıyla 1,3 kat. Bu rakam haftanın tek başına en büyük deltası ve adım adım anlatmaya değer çünkü bu yazıdaki diğer her optimizasyon aynı temaya ait bir varyasyon: yapmak zorunda olmadığınız işi yapmayın.
Orijinal parser, her özellik için bir Vec, her nesne için bir anahtar Vec'i ve anahtar cache'i için RefCell korumalı bir thread-local ayırıyordu. Her string'i kopyalıyordu. Her alan adını yeniden hash'liyordu. Her kayıt için yepyeni bir nesne şekli oluşturuyordu, 20 kaydın tamamı aynı alanları aynı sırada içerse bile. Node'un parser'ı bu deseni fark edip tüm kayıtlar arasında tek bir şekli paylaşarak hallediyor. Perry'ninki halletmiyordu.
Düzeltme dört adımda geldi:
- Thread-local bir
PARSE_KEY_CACHEüzerinden anahtar interning (v0.5.45). &Inodot;lk kayıt N anahtar string ayırıyor; 2'den 20'ye kadar olan kayıtlar sıfır ayırıyor. Tekrarlanan anahtarlar aynı pointer'a çözünür, bu da onları strcmp olmadan shape-cache arama anahtarları olarak kullanılabilir kılar. - Transition cache üzerinden şekil paylaşımı (v0.5.45).
js_object_set_field_by_nametarafından oluşturulan nesneler aynı transition grafiğinde dolaşır. Şema tekrarlandığındakeys_arraypointer'ı paylaşılır ve polimorfik inline cache'in isabet etmesi için ihtiyaç duyduğu tam da budur. - Sıfır kopyalı string parsing + artan şekilde nesne inşası (v0.5.46).
parse_string_bytesartık ters bölen kaçış olmadığındaParsedStr::Borrowed(&[u8])döndürüyor — ki bu her anahtar ve çoğu değer için ortak durum.parse_objectönce bir Vec'e toplamak yerine alanları doğrudan yazıyor. - Parse sırasında GC baskılama (v0.5.60, #59 kapanır). Büyük bir dizinin parse edilmesi sıkı bir döngüde binlerce küçük nesne ayırıyor. Her biri GC eşik kontrolünü gıdıklıyordu. “parse devam ediyor” bayrağı ayarlanması, parse dönene kadar toplamayı erteliyor — etkin heap boyutu aynı, bookkeeping dallanması çok daha az.
Sonra stringify. Homojen diziler üzerinde JSON.stringify — aynı şekil, milyonlarca kez — nesne başına tam özellik iterasyonu yapıyordu, ki bu şekil kararlı bir dizi için saf israf. Beş adımlı bir düzeltme bu uçurumun da büyük kısmını kapattı:
- v0.5.62: sayılar için itoa / ryu hızlı yolları, HashSet yerine derinliğe dayalı döngüsel referans kontrolü.
- v0.5.63:
toJSONkoruyucusu + kalıcı anahtar cache'i + inline dispatch (birikip toplam oluşturan çağrı başına üç maliyet). - v0.5.65: homojen şekilli stringify şablonu + ASCII kaçış hızlı yolu. Her eleman aynı şekle sahip olduğunda anahtar/iki nokta/virgül iskelesi bir kez önceden hesaplanır.
- v0.5.70, v0.5.72, v0.5.75: çağrı başına shape-şablon cache'i, parse-kalanı GC açığını kapa, kalan sabit çağrı başına yükü yok et.
- v0.5.79: küçük değer yolu. Sayılar, boolean'lar ve kısa stringler, nesne mekanizmasının hiçbirini kurmayan doğrudan bir yoldan geçer.
Birikmiş sonuç: haftanın başında Node'dan 547x uzak olan bir JSON pipeline'ı artık gerçekçi iş yüklerinde parse'ta kabaca 1,3x uzakta ve stringify'da rekabetçi.
2. Allocator hikayesi
Perry çok şey ayırıyor. Her nesne literali, her dizi literali, her string birleştirme, her closure. Allocator sıcak ve v0.5'in çoğu için Rust'ın varsayılan sistem allocator'ı artı kısa ömürlü değerler için bir thread-local arena idi.
v0.5.67 global allocator'ı mimalloc ile değiştirdi. Bu, Cargo.toml'da tek satırlık bir değişiklik ve çok sayıda küçük ayırma yapan herhangi bir iş yükünde — ki bu her TypeScript programı demek — hemen karşılığını veriyor. v0.5.66 bundan önce geldi ve tüm gc_malloc thread-local state'ini çağrı başına tek bir TLS erişiminde birleştirdi, böylece mimalloc'a giden yol mümkün olduğu kadar ucuzdu.
v0.5.68 bunu arena'da ayırılan stringler ile daha ileri götürdü. Kısa ömürlü stringler (ara concat sonuçları, split() parçaları, parser çalışma alanı) global allocator'ı tamamen atlıyor ve doğal sınırlarda sıfırlanan thread başına bir bump arena'ya inş ediyor. JSON parsing için bu tek başına iki haneli yüzde kazancıydı.
Ve hiç ayırma yapmayan iki optimizasyon:
- Kaçmayan nesnelerin skaler yerine geçmesi (v0.5.17, sonra v0.5.76'da nesne literalleri). Bir nesne kendisini çevreleyen fonksiyondan asla çıkmıyorsa, var olması gerekmez. Alanları sıradan local'lara dönüşür. LLVM, nesneyi opak bir allocator çağrısının arkasında gizlemeyi bıraktığınızda bunu kutudan çıkan haliyle halleder.
- Kaçmayan dizilerin skaler yerine geçmesi (v0.5.73). Aynı fikir — dizi kaçmıyorsa, elemanları SSA değerlerine dönüşür ve tüm ayırma ortadan kalkar.
Dizi literali yolu için özellikle v0.5.69 tam boyutlu bir hızlı yol ekledi (boyut derleme zamanında bilindiğinde kapasite-büyütme mekanizmasını atla) ve v0.5.74 küçük dizi literalleri için bump allocator IR'sini inline yaptı böylece LLVM ayırmayı görebilir, katlayabilir, hoist edebilir veya elimine edebilir. Dizi ağırlıklı benchmark'lar bir adım daha hareket etti.
Kapanış olarak, v0.5.25 daha sessiz bir hatayı düzeltti: gc_malloc kendi yolunda toplama tetiklemiyordu, dolayısıyla malloc-ağırlıklı iş yükleri herhangi bir kontrol yapılmadan önce heap'i sınırsız şekilde büyütebiliyordu. v0.5.61 eşiğe adaptif adım boyutlama ekledi, ki aslında istediğiniz şey budur: heap küçükken ucuza kontrol et, büyükken daha seyrek kontrol et.
3. Özellik erişimi gerçek bir inline cache kazandı
Her modern JavaScript motorunun özellik erişimi üzerinde polimorfik bir inline cache'i (PIC) vardır. Perry'nin v0.5 serisinin çoğu için PropertyGet, thread-local bir hash ile shape-tablo aramasından geçiyordu. Soğuk kod için bu iyi. Belirli bir çağrı konumundaki özellik okumalarınızın %95'i aynı şekli gördüğünde — ki neredeyse her zaman öyledir — iyi değil.
v0.5.44, PropertyGet için monomorfik bir inline cache getirdi. Her PropertyGet konumu, konum başına bir cache girişi alır: beklenen bir şekil pointer'ı ve bir alan offset'i. Hit yolu tek bir karşılaştırma artı indeksli bir yüklemedir. Miss yolu, cache'i güncelleyen yavaş bir helper'a düşer.
; obj.foo için monomorfik IC hızlı yolu
%shape_ptr = load ptr, ptr %obj_shape_slot
%expected = load ptr, ptr @ic_expected_12
%hit = icmp eq ptr %shape_ptr, %expected
br i1 %hit, label %ic_hit, label %ic_miss
ic_hit:
%off = load i32, ptr @ic_offset_12
%addr = getelementptr i8, ptr %obj, i32 %off
%val = load i64, ptr %addr
; ... val'ı kullan
br label %contv0.5.51 dinamik özellik yazmaları için içerik-hash shape-transition cache'i ekledi. Aynı alanları aynı sırada büyüten iki nesne aynı transition'a hash edilir, dolayısıyla aynı şekli paylaşırlar — ve bu da PIC'in okuma tarafının gerçekten isabet etmesi anlamına gelir.
v0.5.55 transition cache'inden son TLS erişimini de çıkardı. v0.5.46, >8 alanı olan nesnelerin inline slot'ların ötesine, başlatılmamış belleğe okuma yaptığı bir PIC miss-handler hatasını düzeltti (#55 kapanır). v0.5.78, PropertyGet'in PIC'ini ham sayılar gibi pointer olmayan alıcılara indekslemekten alakoyan bir koruyucu ekledi — ki bu fazla iyimser tip daraltımında olabiliyordu ve IC'deki son kararlılık sorunlarından biriydi.
Net etki: özellik ağırlıklı kod — pratikte çoğu TypeScript demek — sadece IC sayesinde bir hafta öncekinden kabaca 2-3x daha hızlı.
4. Tamsayılar, bitwise ve | 0 kalıbı
NaN-boxing her sayıyı bir f64 yapar. TypeScript programcıları tamsayı semantiği zorlamak için x | 0 yazar. V8 on beş yılını bunu ucuzlatmaya harcadı. Perry bu haftayı yetişmeye harcadı.
Sırayla değişiklik yığını:
- v0.5.48:
(int / const) | 0içinsdiv. LLVM bunusmulh + asr'a katlar, ki bufdiviçin ~10 cycle yerine ~2 cycle. - v0.5.48: Uint8ArrayGet sınırlarında
@llvm.assume. Sınır kontrolü dal+phi elmasını vektörleştiricinin düşünebileceği tek bir temel blok ile değiştirir. - v0.5.49: ToInt32 spesifikasyonu gereği NaN/Infinity ile bitwise işlemlerin 0 üretmesi için düzeltme. Önce doğruluk.
- v0.5.50: Değerin bilinen şekilde sonlu olduğu bilindiğinde 5 komutluk NaN/Inf koruyucusunu atlayan
toint32_fast. Artı küçük helper'lardaalwaysinlineve clamp tespiti. - v0.5.52: Clamp fonksiyonlarını doğrudan
smin/smaxintrinsic'leri ile hedefle. Clamp, artırmadan sonra en yaygın tamsayı kalıbıdır. - v0.5.53: Bilinen şekilde sonlu bir değerde
x | 0vex >>> 0bir noop olur — sadecefptosi + sitofp, hiç koruyucu yok. - v0.5.56: i32-native bitwise işlemler; Uint8ArrayGet/Set'te i32 indeks ve değer.
- v0.5.58, v0.5.60:
Math.imulpolyfill yolu yerine native i32 çarpımasına alçalır. Polyfill tespiti, kullanıcının yazdığıMath.imulshim'lerini tanır ve değiştirir. - v0.5.59: Saf fonksiyon init inlining + tamsayı local seeding. Callee küçük ve saf olduğunda fonksiyon local tamsayı analizi çağrı sınırlarının ötesini görebilir.
- v0.5.37–v0.5.40: Akümülatör kalıbı int-aritmetik hızlı yolu. Klasik
for (...) acc += f(i)döngüsü tipler izin verdiğinde baştan sona i32'de kalır.
v0.5.41 ince olanı. Codegen modül düzeyinde bir const K: number[][] = [[...], ...] gördüğünde, her şeyi .rodata'da düz bir [N x i32] sabitine alçaltır. K[y][x] tek bir getelementptr + load i32 olur. v0.5.43'teki int-analiz köprüsü ile birleştiğinde, image_conv'a (4K RGB frame üzerinde 5×5 Gaussian blur) tek bir sürümde 3x hızlanma veren de budur.
5. Buffer'lar ve Uint8Array
&Inodot;kili iş yükleri — kripto, görüntü işleme, parsing, ağ — Buffer ve Uint8Array içinde yaşar. v0.5.64 bunlara tipli pointer slotları artı noalias metadata'sı verdi. Bir Buffer eskiden alloca double'da NaN-boxed bir double iken, artık alloca i64'da ham bir i64 pointer'ı; LLVM ek açıklamaları ile optimizer'a “bu pointer kapsamdaki diğer pointer'larla alias oluşturmuyor” diyor. Bu, optimizer'ın aksi halde yapmayı reddedeceği load/store yeniden sıralamasını, vektörleştirmeyi ve register tahsisini serbest bırakır.
v0.5.80, buradaki son doğruluk sorununu kapattı: fonksiyon başına sıfırlanan modül genelinde bir buffer alias-scope sayaçı vardı ve bu nadir durumlarda LLVM'nin bir scope ID'si paylaşmaması gereken scope'lar arasında mantık yürütmesine izin verebiliyordu. Şimdi sayaç modül geneli ve noalias hikayesi sağlam.
v0.5.53, Uint8ArraySet'i dalsız yaptı — sınır dışına 0 yazan bir if/else yerine maskeli bir store. v0.5.54, uzun desenler için Two-Way indexOf ve arena'da ayırılan bir split ekledi; ki bunlar birlikte string ağırlıklı Buffer parsing'indeki açığın büyük kısmını kapattı.
6. Stringler: ASCII hızlı yoldur
JavaScript stringleri UTF-16, ama gerçek dünyadaki stringlerin çoğu (anahtarlar, tanımlayıcılar, HTTP başlıkları, JSON iskelesi) ASCII. v0.5.71 ASCII stringler için O(1) charCodeAt ve codePointAt ekledi — UTF-16 taraması yok, sadece byte yüklemesi. v0.5.20 zaten indexOf, slice ve charAt'ın ASCII'de UTF-16 taramasını atlamasını sağlamıştı.
Aynı sürümdeki bir doğruluk notu: String.length artık byte sayısı yerine UTF-16 kod birimleri (ECMAScript spec) döndürüyor. "café".length'in 4 yerine 5 döndürdüğü gizli bir hata vardı.
7. Sunucular artık gerçekten ayakta kalıyor
Haftanın en gösterişsiz çalışması aynı zamanda en kullanıcı görünür olanıydı: uzun süre çalışan Node tarzı sunucuların — Fastify, ws, http, net — birkaç dakika sonra çökmemesini sağlamak.
Çöküşlerin hepsinin ortak bir kök sebebi vardı: GC, dinleyici closure'larını bilmiyordu. wss.on('message', handler) yazdığınızda, closure değişkenleri yakalar, ki bunlar GC'de ayırılmış bir hücrenin içinde alanlar olarak yaşar. Eğer GC kök tarayıcısı bu hücreleri ziyaret etmesini bilmiyorsa, yakalamaları geri alınır ve bir sonraki mesaj olayı serbest bırakılmış belleğe dereference yapar.
- v0.5.26:
net.Socketolay dinleyicisi closure'larını kök taramaya dahil et (#35 kapanır). - v0.5.27:
ws,http,events,fastify'a genişlet. - v0.5.28: Modül düzeyindeki globalleri GC kökleri olarak kaydet (#36 kapanır). Bir katman yukarıdaki yaşam süresi hatası.
- v0.5.21: Fastify/WebSocket request handler'ları içinde
gc()güvenliği — açık GC çağrısı, request handler'lar arena'ya pointer tutarken çalışıyordu (#31 kapanır).
GC çalışmasının yanında, v0.5.20 bir ana olay döngüsü gönderdi — bir placeholder değil, gerçek olanı — bu da WebSocket ve timer tabanlı sunucuları, son senkron çağrı döndükten sonra çıkmak yerine canlı tutuyor (#28'e ref). Bu, Perry'yi bir üretim HTTP sunucusu olarak çalıştırmaya çalışan herkes için en etkili düzeltmeydi. Fastify artık ayakta kalıyor. WebSocket sunucuları artık ayakta kalıyor.
v0.5.19, JSValue FFI arg/return'leri için SysV AMD64 ABI uyumsuzluğunu düzeltti — Linux'ta native FFI çağrılarının argümanları sessizce bozabildiği bir sorun. v0.5.18, axios için native dispatch ekledi (get/post/put/delete/patch), response.status ve response.data dahil. v0.5.30, fastify request.header() ve request.headers[] dispatch'ini düzeltti; ki bu büyük/küçük harf duyarsız aramalar için undefined döndürüyordu.
8. @perry/postgres: tüm bunları gerekli kılan sürücü
Bu haftaki çalışmanın büyük kısmı tek bir iş yükünden kaynaklanıyordu: Perry-native üzerinde çalışan tam Node uyumlu bir Postgres sürücüsü elde etmek. Sürücü TLS yeteneğine sahip, modüller arası codec kaydı var, cancel/close/notify destekliyor ve artık pg, postgres.js ve tokio-postgres'a karşı benchmark ediliyor.
Sürücü tarafı perf çalışması derleyici tarafına paralel gitti:
- Sütun başına codec'i hoist et ve hücre başına Buffer kopyalarını düşür. Ara ayırmalardan kaçınmak için int8 için BigInt(string).
- Nesne formundaki satırlar için dinamik şekil başına Row constructor. Sorgunuz her zaman aynı sütunları döndürüyorsa, sürücü ilk kez şekil-özelleştirilmiş bir satır constructor'ı oluşturur ve yeniden kullanır — ki bu, derleyicinin PIC'i ile birleştiğinde satırlar üzerindeki alan erişimini diğer nesnelerdeki alan erişimi kadar hızlı yapar.
- int8/numeric/date için ham string isteyen çağrıcılar için
parseTypes: 'minimal'opt-out'u.
Bu, derleyicinin her zaman sağlamak istediği pozitif geri bildirim döngüsü. Gerçek bir sürücü gerçek darboğazları ortaya çıkarır. Darboğaz, GitHub issue'su olarak dosyalanan tek satırlık bir reprodüksiyon alır. Bir haftalık derleyici düzeltmelerinden sonra, sürücü daha hızlı ve derleyici herkes için daha hızlı. Yedi güne sıkıştırılmış tüm plan bu.
9. &Inodot;simlendirilmeye değer doğruluk düzeltmeleri
Performans çalışması, bir nehri taramak market arabalarını yüzeye çıkardığı gibi doğruluk sorunlarını yüzeye çıkarır. Kısmi bir liste:
- Promise.race, reddedilmede
.reasonyerine.value'yu okuyordu, dolayısıyla reddetmeler sessizce yutuluyordu (v0.5.13–v0.5.14). - Promise.any artık tüm girdi promise'leri reddettiğinde uygun bir
AggregateErroratıyor.Promise.withResolverseklendi vequeueMicrotasksıralaması düzeltildi. [..."hello"]artık bozuk bir nesne yerine bir karakter dizisi üretiyor (#16 kapanır).- BigInt aritmetiği ve
BigInt()zorlaması (#33 kapanır). i64 bigint hızlı yolu (v0.5.29) ortak durumu ucuzlatır. - Buffer.indexOf / Buffer.includes, sayısal byte argümanı ile byte değerleri yerine buffer pointer'larına karşı karşılaştırıyordu (#56 kapanır).
- NaN/Infinity ile bitwise işlemler ToInt32 spesifikasyonu gereği 0 üretiyor (#57 kapanır).
- Windows x86_64: platforma özel beş düzeltme —
localtime,clangkeşfi ve bir avuç codegen ayarı — Windows x86_64'ü tekrar yeşile getirdi (v0.5.72).
10. Rakamlar
Son yazıdaki başlık benchmark'ı, Node'dan 24,6x daha hızlı olan factorial'dı. Bu rakam değişmedi. Bu hafta hareket eden şey etrafındaki her şey:
| &Inodot;ş yükü | v0.5.12 | v0.5.80 | Delta |
|---|---|---|---|
| JSON.parse (20 kayıtlı şema) | Node'dan 547x yavaş | Node'dan 1,3x yavaş | ~420x |
| image_conv (4K 5×5 blur) | 1.980ms | 457ms | 4,3x |
| Özellik ağırlıklı kod (PIC hit) | taban | 2-3x | 2-3x |
| Fibonacci(40) | 401ms | 309ms | 1,3x |
| Yük altında Fastify çalışma süresi | çökmeden önce ~60s | süresiz | ∞ |
Node'a karşı tüm 15 benchmark'lık suite hala 14 galibiyet ve 1 beraberlik — son yazıyla aynı tablo, genel olarak biraz daha iyi rakamlarla. Bu haftaki gerçek hareket, o suite'de olmayan iş yüklerinde: JSON, görüntü işleme, uzun süre çalışan sunucular. Açıklar oradaydı, ve kapanan da onlar.
11. Sıradaki ne
Hâlâ peşinde olduğumuz tek benchmark image_conv'un Zig'e karşı olanı. Perry 457ms'de; Zig 246ms'de. Bu açık mimari, optimizasyon-pass düzeyinde değil ve üç yerde yaşıyor:
- Tipli buffer local'ları. Buffer çalışmasının çoğu bu hafta girdi, ancak buffer tipli fonksiyon parametreleri ve local'ları hâlâ her erişimde unbox ediyor. Döngü sayıcıları için kullandığımız
i64slot yaklaşımı buffer'lara genişlemeli. - &Inodot;ç/sınır döngü bölünmesi. Blur döngüsü her pikseli clamp ediyor, buna ihtiyacı olmayan piksellerin %99,9'u dahil. Sınır bölgelerine (clamped) ve içe (clamp yok) bölmek, LLVM'nin iç kısmı NEON
ld3/st3ile vektörleştirmesine olanak tanır. - Double-ABI FNV-1a hash. Hash helper'ı NaN-box ABI üzerinden çağrılıyor. Sıcak yollar için ham i64 in/out'a özelleştirmek birkaç saatlik bir iş ve her hash-ağırlıklı iş yükünde karşılığını verecek.
Bunlar PERF_ROADMAP.md'de takip ediliyor. Bir sonraki döngüde görünmelerini bekleyin.
Toparlama
Bu haftanın deseni — 68 patch sürümü, neredeyse tamamı performans, bir JSON açığı 547x'ten 1,3x'e — LLVM geçişi tepesinin iyi tarafına geçtiğinizde olan şey. Optimizer artık bir duvar yerine bir müttefik ve geriye kalanların çoğu küçük, belirli, ölçülebilir iş: yavaş bir yol bul, optimizer'ın neden içinden göremediğini anla, yapıyı açığa çıkar, tekrar ölç. Bu commit'lerin hiçbiri egzotik değil. Sadece gereken yerlerde uygulanıyorlar.
Bunlardan herhangi birini denemek isterseniz:
brew install perryts/perry/perry
perry init my-app && cd my-app
perry compile src/main.ts -o my-app && ./my-appKaynak: github.com/PerryTS/perry — Docs: docs.perryts.com — Changelog: CHANGELOG.md
Yeterince hızlı olmayan issue'lar, reprodüksiyonlar ve benchmark'lar: gelmeye devam etsin. Bu hız yalnızca hata raporları tek satırlık reprodüksiyonlara dönüştürülecek kadar spesifik olduğu için çalışıyor. Bu yazıdaki her commit'in bir nedenden dolayı ekli bir #N'si var.
— Ralph