1 puan yazan GN⁺ 2024-12-19 | 1 yorum | WhatsApp'ta paylaş
  • Ruby’nin varsayılan json gem’i, profillemeyle darboğazları ortadan kaldırarak hız nedeniyle oj’ye geçme yönündeki pratik baskıyı azaltacak şekilde iyileştirildi
  • Amaç, oj’yi her durumda geçmek değil; Oj.mimic_JSON ve Oj.optimize_rails gibi monkey patching olmadan da yeterince hızlı ve öngörülebilir JSON işleme sunmak
  • oj bazı benchmark’larda hızlıydı; ancak script_safe seçeneğini yok sayması, Rails serileştirme farkları ve Ruby çökmeleri gibi nedenlerle operasyonel kararlılık ve API uyumluluğu yükü oluşturdu
  • Başlıca optimizasyonlar; yinelenen UTF-8 kontrollerinin kaldırılması, yaygın koşulların önce kontrol edilmesi, generator ayar maliyetinin azaltılması, encoding pointer takibinden kaçınılması ve lookup table tabanlı escape kontrolüyle yapıldı
  • twitter.json 467KiB üretim benchmark’ında değişikliklere göre %3, %8, %15, %30 iyileşme görüldü; küçük Hash üretimi ise yalnızca ayar maliyetinin azaltılmasıyla 1,51 kat hızlandı

json gem’ini hızlandırmanın arka planı

  • Kısa süre önce json gem’inin maintainer’ı olduktan sonra eski hataları düzeltirken performans iyileştirmelerine de odaklanıldı; sonuçta çoğu benchmark’ta Ruby için en hızlı JSON parser ve generator haline geldi
  • Performans patch’lerinin çoğu özel bir sihirden çok, profillemeyle darboğazları bulup basit israfı azaltmaya yakındı
  • Temel motivasyon, ruby/json yeterince hızlanarak kullanıcıların sırf hız yüzünden alternatif gem seçmek zorunda kalmadığı bir duruma ulaşmaktı

oj yerine kullanımın getirdiği yük

  • json 2.7.2 ile oj arasındaki fark, gerçek boyuta yakın bazı benchmark’larda büyük değildi
    • 100 tweet’ten oluşan 467KiB JSON belgesini parse etmede json 2.7.2 1.9ms, oj ise 1.6ms sürdü
    • Aynı belgeyi üretmede json 2.7.2 0.8ms, oj ise 0.4ms sürdü
  • Birçok kullanım senaryosunda yavaş olan kısım JSON serileştirmenin kendisi değil, Active Record modellerini Ruby Hash ve Array’lere dönüştüren üst katmandır
  • oj, Shopify kod tabanı dahil birçok projede kullanılıyordu ve popülerliğinin nedeni büyük olasılıkla hızdı
  • Monkey patching’in API uyumsuzluğu

    • Oj.mimic_JSON sıklıkla json gem’ini, Oj.optimize_rails ise ActiveSupport::JSON’ı monkey patch etmek için kullanılır
    • JSON.dump(data, script_safe: true), JSON’u bir <script> etiketi içine güvenli biçimde koymak için </script> ifadesini <\/script> olarak escape edebilir
    • oj, script_safe seçeneğini bilmediği için yok sayar; bu nedenle tek başına güvenli olan bir gem bile Oj.mimic_JSON çağırmış bir uygulama içinde XSS saldırısı olasılığı yaratabilir
    • Oj.optimize_rails de nesne serileştirmede ince farklar oluşturabilir
    • ActiveSupport::JSON::Encoding.time_precision = 0 durumunda ActiveSupport::JSON.encode(t) saniye düzeyinde bir string üretebilir
    • Oj.optimize_rails ve Oj.mimic_JSON sonrasında milisaniye içeren bir string çıktısı görülen örnekler vardır
    • Bu örnek, yükleme sırasından kaynaklanan bir corner case’tir; ancak geçmişte bundan daha fazla davranış değişiyordu
  • Üretim ortamı kararlılığı sorunları

    • Büyük ölçekli ortamlarda oj, Ruby çökmelerinin öne çıkan nedenlerinden biriydi ve grpc’den sonra en sorunlu olanlardandı
    • Native gem yazmak, Ruby VM’i ve özellikle GC’yi anlamayı gerektirir; aksi halde çökme veya bellek bozulması oluşabilir
    • oj kod tabanında güvenmeyi zorlaştıran hack’ler vardı ve bir dönem hataları atlatmak için bazı durumlarda GC’yi devre dışı bırakıyordu
    • GC yeniden etkinleştirildiğinde major GC cycle tetiklenebilir
    • Bu tür kodlar mikrobenchmark’lar için avantajlı olsa da gerçek production performansını düşürebilir
    • Bu deneyim nedeniyle Shopify’ın monolith’inden Oj kaldırıldı; bu süreçte Oj.mimic_JSON ile gerçek json arasındaki ince farklar doğrulandı

Benchmark ve profillemeyle darboğazları bulmak

  • Amaç, ruby/json’ın hem gerçek kullanımda hem mikrobenchmark’larda oj’ye benzer davranması ve hız nedeniyle Oj.mimic_JSON kullanma cazibesini azaltmasıydı
  • İlk adım bir benchmark suite oluşturmaktı
  • C profiler olarak samply kullanıldı
    • Firefox Profiler uyumlu raporlar üretmesi, paylaşımı kolaylaştıran bir avantajdır

Yinelenen UTF-8 kontrolünü kaldırmak

  • twitter.json payload’u ile JSON.dump profillendiğinde zamanın 9%’u JSON’un kendi isLegalUTF8 fonksiyonunda, 1.9%’u rb_enc_str_asciionly_p içinde harcanıyordu
  • Ruby String, coderange adlı dahili bir özelliğe sahiptir; string’in encoding durumunu veya yalnızca ASCII olup olmadığını bir kez taradıktan sonra cache’ler
    • ENC_CODERANGE_UNKNOWN: Henüz taranmamış
    • ENC_CODERANGE_VALID: Encoding geçerli
    • ENC_CODERANGE_7BIT: Encoding geçerli ve yalnızca ASCII karakterler var
    • ENC_CODERANGE_INVALID: Encoding geçerli değil
  • Eski convert_UTF8_to_JSON_ASCII, başta rb_enc_str_asciionly_p çağırıyor, ardından UTF-8 geçerliliğini doğrulamak için string’i yeniden manuel tarayarak yinelenen iş yapıyordu
  • Değişiklikten sonra UTF-8 geçerliliği, zaten hesaplanmış coderange karşılaştırılarak belirleniyor
    • Ruby ifadesiyle yapı şu şekilde: string.ascii_only? değilse string.encoding != Encoding::UTF_8 veya !string.valid_encoding? olduğunda JSON::GeneratorError raise edilir
    • Hem #ascii_only? hem de #valid_encoding? cache’lenmiş coderange kullandığı için string taraması en fazla bir kez yapılır
  • Beklenen %9’un aksine gerçek iyileşme yaklaşık %3 düzeyinde kaldı
    • isLegalUTF8 içinde harcanan zamanın önemli bir bölümü convert_UTF8_to_JSON’a taşındı
    • Nedeni kesin değil; ancak %9’un büyük kısmı string byte’larını RAM’den CPU cache’e getirme maliyeti olabilir
    • twitter.json üretim benchmark’ı 1077.3 i/s değerinden 1113.3 i/s değerine çıkarak 1.03x hızlandı

Daha ucuz ve daha olası koşulları önce kontrol etmek

  • fbuffer_inc_capa, toplam çalışma süresinin 5.7%’si olarak görünüyordu ve zamanın çoğu buffer’ın zaten allocate edilip edilmediğini kontrol etmeye gidiyordu
  • Bu fonksiyon buffer’a bir şey yazılan her sefer çağrılır; ancak ilk çağrıdan sonra buffer her zaman zaten allocate edilmiş olur
  • Eski yapı, neredeyse hiç tutmayan koşulu önce kontrol ettiği için israf yaratıyordu; buffer henüz allocate edilmemişse fb->capa 0 olduğundan required > fb->capa kontrolüyle de kısmen yineleniyordu
  • Düzeltmeden sonra en yaygın durum olan “buffer kapasitesi yeterli” önce kontrol ediliyor; RB_LIKELY ve RB_UNLIKELY ile CPU branch prediction’a ipucu veriliyor
  • Fonksiyon inline olarak işaretlendiği için çağrı maliyeti azaldı ve çoğu durumda gereken iş çıkarma ve karşılaştırma seviyesine indi
  • Bu değişiklik, twitter.json üretim benchmark’ını 1068.6 i/s değerinden 1224.7 i/s değerine çıkararak 1,15 kat hızlandırdı
  • Aynı ilke Ruby koduna da uygulanabilir: en ucuz ve gerçekleşme olasılığı en yüksek koşulu önce kontrol etmek

JSON generator’ın ayar maliyetini azaltmak

  • Ruby committer’ı Yusuke Endoh, namıdiğer Mame, ruby/json optimizasyonuna da katıldı ve çeşitli optimizasyonlar içeren eski bir PR vardı
  • Değişikliklerin çoğu, JSON üretimi öncesinde gereken ayar maliyetini azaltmaya odaklandı
    • Argüman parse etme
    • generator ve ilgili struct’ları allocate etme
    • Üretim işine girmeden önceki hazırlıklar
  • ruby/json’ın bu ayar maliyeti alternatif implementasyonlardan yüksek olduğundan mikrobenchmark’larda kötü görünüyordu
  • JSON.generate, pretty JSON üretimi için array_nl, object_nl, indent, space gibi seçenekler alabilir
  • Eskiden verilen string’lerle ayraç buffer’ları önceden hesaplanıyordu
    • Örn: ",#{opts[:array_nl]}", ",#{opts[:object_nl]}", ":#{opts[:space]}"
    • Amaç uzun bir parçayı tek seferde append etmekti; ancak pratikte tasarruf edilen iş küçüktü
    • Çoğu durumda bu seçenekler kullanılmadığı için ön hesaplama maliyeti daha baskın hale geliyordu
  • Mame bu optimizasyonu fiilen geri alarak ayar maliyetini büyük ölçüde azalttı
    • Büyük benchmark’larda fark çok büyük değil
    • Küçük Hash 65 bytes üretim benchmark’ı 2,112,189.3 i/s değerinden 3,199,311.0 i/s değerine çıkarak 1,51 kat hızlandı

Pointer takibinden kaçınıp encoding index’i karşılaştırmak

  • Mame’in bir diğer optimizasyonu, bir rb_enc_get çağrısını kaldırmaktı
  • JSON, string’in UTF-8 uyumlu olup olmadığını sık sık kontrol etmek zorundaydı; eski yöntemde rb_enc_get(obj) ile rb_encoding * alınıp US-ASCII veya UTF-8 olup olmadığı karşılaştırılıyordu
  • rb_enc_get, savunmacı, yüksek seviye bir API olduğu için birçok tip kontrolü yapar
    • String, Symbol, Regexp, File, Data gibi çeşitli nesnelerle çalışabilecek şekilde tasarlanmıştır
    • Çok sayıda koşul içerir; CPU branch prediction yanılırsa maliyet artabilir
  • Ruby String kavramsal olarak bir encoding referansı taşır; ancak gerçekte 64-bit pointer yerine her String’in iç bitmap’inde daha küçük bir 7-bit encoding index saklanır
  • Tüm encoding nesnesi pointer’ını almak için VM’in dahili global dizisi üzerinden gerçek encoding’i bulmak gerekir; bu da düşük seviye kodda pointer takibi anlamına gelir
    • Zaten CPU cache’teyse hızlıdır; ancak RAM’den getirilmesi gerekiyorsa CPU beklemek zorunda kalır
  • json, hedefin zaten String olduğunu biliyor ve gereken bilgi yalnızca ASCII veya UTF-8 olup olmadığı olduğu için RB_ENCODING_GET ile encoding index’i doğrudan karşılaştırabilir
  • Bu değişiklik, twitter.json üretim benchmark’ını 1159.6 i/s değerinden 1253.3 i/s değerine yükselterek 1,08 kat hızlandırdı

Lookup table ile string escape işlemini hızlandırmak

  • JSON string dump işlemi, her karakterin olduğu gibi kopyalanıp kopyalanamayacağını veya escape gerektirip gerektirmediğini kontrol etmek zorunda olduğu için maliyetlidir
  • Naif yöntem her karakter için birkaç koşulu kontrol eder
    • ASCII control character olup olmadığını kontrol etme
    • \n, \r, \t, \f, \b olup olmadığını kontrol etme
    • " veya \ olup olmadığını kontrol etme
  • Lookup table yöntemi, bu kararı statik bir dizide önceden hesaplar ve her karakter için birden fazla karşılaştırma yapmak yerine dinamik offset’ten boolean okur
  • Biraz daha fazla statik bellek kullanma karşılığında loop çok daha hızlı hale gelir
  • Çoğu string’de escape gerektiren karakter olmadığı varsayımıyla Mame önce fast path olup olmadığını ucuzca kontrol eden, uygunsa tüm string’i tek seferde buffer’a kopyalayan bir ön koşul ekledi
  • Mame’in patch’i C kodu olduğu için daha karmaşıktır; ancak aynı pattern’i kullanır
  • Yalnızca bu değişiklikle twitter.json üretim benchmark’ı 1258.1 i/s değerinden 1630.2 i/s değerine çıkarak 1,30 kat hızlandı

Devam eden optimizasyonlar

  • Ele alınacak başka optimizasyonlar da kaldığından devam yazısının geleceği duyuruldu
  • Daha sonra part two yayımlandı

1 yorum

 
GN⁺ 2024-12-19
Hacker News yorumları
  • byroot’un çalışmalarını gerçekten seviyorum. Yalnızca katkılarının türü değil, üretkenliğinin ölçeği de her zaman şaşırtıcı derecede etkileyici
    Ruby core tarafına birkaç kez girmeye çalıştım ama kendi seviyeme uygun ve olumlu katkı yapabileceğim bir iş bulamadım; birkaç hafta sonuç alamayınca da motivasyonum kayboldu. Yazıda paylaşılan türden bir bağlam edinmek gerçekten çok zor
    Ruby C tarafında çalışan insanlar daha sık yazsa, Ruby’yi daha da geliştirmek için gereken yetkinliklere sahip kişi sayısı artar gibi geliyor. C profiler tavsiyeleri de iyiydi; Ruby gem’lerinden C kodu içeren birini alıp optimizasyonla yeniden uğraşmaya başlamayı bile düşünebilirim

    • Peter Zhu’nun da harika bir yazı dizisi var: https://blog.peterzhu.ca/ruby-c-ext/
      C extension üzerine olsa da bazı kavramları anlamaya yardımcı oluyor
    • “İnanılmaz üretken” kısmı doğru ama aynı zamanda gerçekten inanılmaz derecede zeki biri. Shopify’da aynı ofiste çalışmıştım; ulaşılmaz görünen bir seviyede
    1. bölüm de yayımlanmış: https://byroot.github.io/ruby/json/2024/12/18/optimizing-rub...
  • Burada anılması gereken bir şey de Rails’in varsayılan kullanım biçimi olan jbuilder. jbuilder doğrudan JSON serialization kısmı olmasa da, Ruby/Rails’te JSON render etmeyi yavaşlatan şeyler listesi yapsam en üste koyarım
    jbuilder ile çok sayıda partial render ederseniz gerçekten çok yavaşlıyor

  • Bu konudaki yazıların takibi kolay ve kendi Ruby kodumu da benchmark edip optimize etme isteği uyandırıyor. Hem yazı hem de yapılan iş çok iyiydi

  • Gözden kaçırmış olabilirim ama tüm optimizasyonlar uygulandıktan sonra yeni sürümün Twitter JSON dump’ını parse/encode etmesinin ne kadar sürdüğünü gösteren bir yer var mı?

  • Harika bir yazı ve yapılan iş de çok iyi. Bundan sonra Oj kullanmak için hâlâ bir sebep var mı?

    • Yazarıyım
      Oj’nin, temel json gem’inin taklit etmeye niyetli olmadığı çok büyük bir API’si var. Örneğin “SAJ” (SAX tarzı parsing), birden fazla escape yöntemi gibi şeyler
      Benim hedefim yalnızca kullanım senaryolarının kabaca %95’inde Oj’ye ihtiyaç bırakmamak; o yüzden pek çok kullanım için Oj hâlâ faydalı olacaktır
  • Bu yazı yayımlandığından beri, şu anda bakımsız olan bu implementasyona kıyasla ne kadar daha hızlı hale geldiğini merak ediyorum
    https://netflixtechblog.com/fast-json-api-serialization-with...
    Bu saf Ruby implementasyonunun epey temiz olduğunu düşünmüştüm ama gerçek prodüksiyonda hiç kullanmadım. Uzun zamandır terk edilmiş durumda
    Genel olarak saf Ruby implementasyonlarının mevcut durumunu da merak ediyorum. json_pure kaldırılmış gibi görünüyor; öyleyse üzücü. Bunun arka planını bilen var mı? Yazının en ilginç kısmı benim için C optimizasyonundan çok Ruby optimizasyonu tarafı

  • Keyifle okudum. Yalnız Ruby’ye özgü olmayan optimizasyonlarda, örneğin escape karakterleri için lookup table gibi şeylerde, bunu zaten yapan simdjson gibi mevcut kütüphanelerden neden yararlanılmadığını merak ediyorum

    • Buna kısmen https://news.ycombinator.com/item?id=42450085 içinde cevap vermişti
      Kısacası ruby/json, Ruby ile birlikte dağıtıldığı için Ruby’nin kısıtlarıyla uyumlu olmak zorunda; şu an bu da saf C99 ve C++ yok anlamına geliyor. simdjson’un Apache 2 lisansı da sorun olabilir ama emin değilim
      Genel olarak dragonbox gibi harika C++ kütüphanelerini kullanmayı isterdim ama yapamıyorum
      Son olarak kontrol ettiğimde simdjson yalnızca parser sağlıyordu. ruby/json gem’i ise hem parsing hem encoding yapıyor; dolayısıyla problem alanının sadece yarısına yardımcı oluyor
    • Bu yazının güzel yanı, mevcut bir codebase üzerinde yapılan gerçek mühendislik çalışması olması. Biraz daha hız kazanmak için her şeyi söküp başka bir şey koymaya ya da kütüphane değiştirmeye çalışmıyor; doğrudan gerçek kodun içine girip sadece hızı değil verimliliği de gerçekten iyileştirmeye çalışıyor
      Modern projelerde bu tür işler yeterince yapılmıyor. Bu tür işler daha düzenli yapılsaydı, baştan simdjson ya da oj gibi kütüphanelere ihtiyaç olur muydu emin değilim. Bu problem alanı o kadar da zor değil
  • Ruby JSON intrinsics kullanıyor mu? Kullanabilir mi?
    Bir de çeşitli JIT’lerle nasıl etkileşiyor?

    • intrinsics ile tam olarak neyi kastettiğinizi bilmiyorum
      json gem’i C ile yazıldığı için YJIT, yani referans implementasyonun JIT’i açısından bir kara kutu
      TruffleRuby JIT, eskiden Sulong ile C extension’ları yorumlayıp dil sınırlarını aşacak şekilde JIT yapabiliyordu ama bildiğim kadarıyla çeşitli uyumluluk sorunları yüzünden yakın zamanda bundan vazgeçti
      Ayrıca TruffleRuby’de JSON parser C ile implement edilmiş olsa da encoder saf Ruby: https://github.com/ruby/json/blob/e1f6456499d497f33f69ae4c1a...
  • Yanlış hatırlamıyorsam branch prediction hint modern CPU’larda işe yaramıyor

    • Modern CPU’larda işe yaramıyordu ama bazı CPU’larda yeniden bir miktar faydalı hale geldi. https://www.phoronix.com/news/GCC-Clang-Intel-x86-Branch-Hin...
      “Redwood Cove mikro mimarisinden itibaren, predictor’ın belirli bir branch hakkında kayıtlı bilgisi yoksa ve o branch’te Intel SSE2 branch taken hint, yani 3EH instruction prefix’i varsa, codec branch’i decode ederken branch prediction’ı not-taken’dan taken’a çevirir. Ardından ön taraftaki pipeline’ı flush eder ve pipeline’ı taken yolunu izlemeye yönlendirir
      ...
      Bu hint yalnızca predictor’da o branch için kayıtlı bilgi olmadığında kullanılır. Kod şişmesini ve instruction fetch bant genişliği kaybını önlemek için, çok sayıda iterasyona sahip loop’ların içindeki branch’ler gibi hot code branch’lerine hint eklenmemelidir. Çünkü predictor büyük olasılıkla o branch hakkında zaten bilgi tutuyordur. İdeal olarak hint’ler seyrek çalışan ama çoğunlukla taken olan branch’lere eklenmelidir, ancak bu tür branch’leri tespit etmek zor olabilir. Compiler’ın bir execution path’i fall-through olarak yerleştiremediği durumlarda, profile-guided optimization’ın parçası olarak hint eklemesi önerilir. Redwood Cove mikro mimarisi, hint yerleşimini yönlendirmek için yeni performance monitoring event’leri sunuyor”