Optimasi JSON Ruby, Bagian 1
(byroot.github.io)- Gem bawaan Ruby
jsonditingkatkan dengan menghilangkan bottleneck lewat profiling, sehingga mengurangi tekanan praktis untuk beralih keojdemi kecepatan - Tujuannya bukan selalu mengalahkan
oj, melainkan menyediakan pemrosesan JSON yang cukup cepat dan dapat diprediksi tanpa monkey patching sepertiOj.mimic_JSONdanOj.optimize_rails ojmemang lebih cepat di beberapa benchmark, tetapi menimbulkan beban stabilitas operasional dan kompatibilitas API, seperti mengabaikan opsiscript_safe, perbedaan serialisasi Rails, dan crash Ruby- Optimasi utama dilakukan dengan menghapus pemeriksaan UTF-8 yang duplikatif, memeriksa kondisi umum lebih dulu, mengurangi biaya konfigurasi generator, menghindari penelusuran pointer encoding, serta pemeriksaan escape berbasis lookup table
- Pada benchmark pembuatan
twitter.jsonberukuran 467KiB, tiap perubahan menghasilkan peningkatan 3%, 8%, 15%, dan 30%; pembuatan Hash kecil menjadi 1,51 kali lebih cepat hanya dengan mengurangi biaya konfigurasi
Latar belakang membuat gem json lebih cepat
- Setelah baru-baru ini menjadi maintainer gem
json, penulis memperbaiki bug lama sekaligus berfokus pada peningkatan performa, dan hasilnya gem ini menjadi parser dan generator JSON tercepat untuk Ruby di sebagian besar benchmark - Sebagian besar patch performa bukanlah trik khusus, melainkan pekerjaan menemukan bottleneck lewat profiling dan mengurangi pemborosan sederhana
- Motivasi utamanya adalah membuat
ruby/jsoncukup cepat sehingga pengguna tidak perlu memilih gem pengganti hanya karena alasan kecepatan
Beban yang muncul dari penggunaan oj sebagai pengganti
- Perbedaan antara
json 2.7.2danojtidak terlalu besar pada beberapa benchmark yang mendekati ukuran nyata- Parsing dokumen JSON 467KiB berisi 100 tweet:
json 2.7.2membutuhkan1.9ms, sedangkanoj1.6ms - Pembuatan dokumen yang sama:
json 2.7.2membutuhkan0.8ms, sedangkanoj0.4ms
- Parsing dokumen JSON 467KiB berisi 100 tweet:
- Dalam banyak use case, bagian yang lambat bukan serialisasi JSON itu sendiri, melainkan lapisan di atasnya yang mengubah model Active Record menjadi Hash dan Array Ruby
ojdipakai di banyak proyek, termasuk codebase Shopify, dan popularitasnya kemungkinan besar karena kecepatannya-
Ketidaksesuaian API akibat monkey patching
Oj.mimic_JSONsering digunakan untuk melakukan monkey patch pada gemjson, sementaraOj.optimize_railspadaActiveSupport::JSONJSON.dump(data, script_safe: true)dapat meng-escape</script>menjadi<\/script>agar JSON aman dimasukkan ke dalam tag<script>- Karena
ojtidak mengenal opsiscript_safedan mengabaikannya, gem yang aman saat berdiri sendiri dapat menciptakan kemungkinan serangan XSS di dalam aplikasi yang memanggilOj.mimic_JSON Oj.optimize_railsjuga dapat menimbulkan perbedaan halus dalam serialisasi objek- Dalam kondisi
ActiveSupport::JSON::Encoding.time_precision = 0,ActiveSupport::JSON.encode(t)dapat menghasilkan string dalam satuan detik - Setelah
Oj.optimize_railsdanOj.mimic_JSON, ada contoh keluaran string yang menyertakan milidetik - Kasus ini adalah corner case akibat urutan load, tetapi dulu ada lebih banyak perilaku yang berubah
-
Masalah stabilitas di lingkungan operasional
- Di lingkungan berskala besar,
ojmerupakan salah satu penyebab crash Ruby yang menonjol, dan menjadi yang paling bermasalah setelahgrpc - Menulis native gem membutuhkan pemahaman tentang Ruby VM, terutama GC; tanpa itu, crash atau kerusakan memori dapat terjadi
- Codebase
ojmemiliki hack yang membuatnya sulit dipercaya, dan pada satu titik menonaktifkan GC dalam beberapa situasi untuk mengakali bug - Saat GC diaktifkan kembali, major GC cycle dapat terpicu
- Kode seperti ini bisa menguntungkan microbenchmark, tetapi dapat menurunkan performa produksi nyata
- Karena pengalaman ini, Shopify menghapus
Ojdari monolith-nya, dan dalam proses itu menemukan perbedaan halus antaraOj.mimic_JSONdanjsonyang sebenarnya
- Di lingkungan berskala besar,
Menemukan bottleneck dengan benchmark dan profiling
- Tujuannya adalah agar
ruby/jsonberperilaku mirip denganojbaik dalam penggunaan nyata maupun microbenchmark, sehingga daya tarik memakaiOj.mimic_JSONkarena kecepatan berkurang - Langkah pertama adalah menyusun benchmark suite
- Mencakup microbenchmark dan benchmark yang lebih realistis
- Berbasis benchmark suite dari gem rapidjson-ruby milik John Hawthorn, dengan beberapa tambahan
- Untuk profiler C, digunakan samply
- Kelebihannya adalah menghasilkan laporan yang kompatibel dengan Firefox Profiler sehingga mudah dibagikan
Menghapus pemeriksaan UTF-8 yang duplikatif
- Saat melakukan profiling
JSON.dumpdengan payloadtwitter.json,9%waktu digunakan olehisLegalUTF8milik JSON sendiri, dan1.9%olehrb_enc_str_asciionly_p - Ruby String memiliki atribut internal bernama
coderange, yang men-scan lalu menyimpan cache status encoding string atau apakah string hanya berisi ASCIIENC_CODERANGE_UNKNOWN: belum di-scanENC_CODERANGE_VALID: encoding validENC_CODERANGE_7BIT: encoding valid dan hanya berisi karakter ASCIIENC_CODERANGE_INVALID: encoding tidak valid
convert_UTF8_to_JSON_ASCIIyang lama memanggilrb_enc_str_asciionly_pdi awal, lalu kembali men-scan string secara manual untuk memeriksa validitas UTF-8, sehingga melakukan pekerjaan duplikatif- Setelah perubahan, validitas UTF-8 ditentukan dengan membandingkan
coderangeyang sudah dihitung- Dalam bentuk Ruby, strukturnya adalah me-raise
JSON::GeneratorErrorjika bukanstring.ascii_only?danstring.encoding != Encoding::UTF_8atau!string.valid_encoding? - Baik
#ascii_only?maupun#valid_encoding?memanfaatkancoderangeyang di-cache, sehingga scan string terjadi paling banyak sekali
- Dalam bentuk Ruby, strukturnya adalah me-raise
- Berbeda dari ekspektasi 9%, peningkatan nyata hanya sekitar 3%
- Sebagian besar waktu yang sebelumnya digunakan di
isLegalUTF8berpindah keconvert_UTF8_to_JSON - Alasannya tidak pasti, tetapi kemungkinan besar sebagian besar dari 9% itu adalah biaya membawa byte string dari RAM ke CPU cache
- Benchmark pembuatan
twitter.jsonmenjadi1,03xlebih cepat, dari1077.3 i/ske1113.3 i/s
- Sebagian besar waktu yang sebelumnya digunakan di
Memeriksa kondisi yang lebih murah dan lebih mungkin terlebih dahulu
fbuffer_inc_capatercatat memakan5.7%dari total runtime, dan sebagian besar waktunya dipakai untuk memeriksa apakah buffer sudah dialokasikan- Fungsi ini dipanggil setiap kali sesuatu ditulis ke buffer, tetapi setelah panggilan pertama, buffer selalu sudah dialokasikan
- Struktur lama boros karena memeriksa kondisi yang hampir tidak pernah benar terlebih dahulu, dan jika buffer belum dialokasikan,
fb->capabernilai0, sehingga sebagian juga duplikatif dengan pemeriksaanrequired > fb->capa - Setelah perbaikan, kasus paling umum yaitu “kapasitas buffer cukup” diperiksa terlebih dahulu, dan
RB_LIKELYsertaRB_UNLIKELYdigunakan untuk memberi petunjuk branch prediction kepada CPU - Fungsi ditandai sebagai
inlinesehingga biaya pemanggilan berkurang, dan pada sebagian besar kasus pekerjaan yang diperlukan menjadi sekecil pengurangan dan perbandingan - Perubahan ini menaikkan benchmark pembuatan
twitter.jsondari1068.6 i/smenjadi1224.7 i/s, atau 1,15 kali lebih cepat - Prinsip yang sama juga dapat diterapkan pada kode Ruby: periksa kondisi yang paling murah dan paling mungkin terjadi terlebih dahulu
Mengurangi biaya konfigurasi generator JSON
- Ruby committer Yusuke Endoh alias Mame juga ikut mengoptimalkan
ruby/json, dan ada PR lama yang berisi beberapa optimasi - Banyak perubahan berfokus pada pengurangan biaya konfigurasi yang diperlukan sebelum pembuatan JSON
- Parsing argumen
- Alokasi generator dan struktur terkait
- Pekerjaan persiapan sebelum memasuki proses pembuatan
- Biaya konfigurasi
ruby/jsonlebih besar dibanding implementasi alternatif, sehingga terlihat buruk dalam microbenchmark JSON.generatedapat menerima opsi sepertiarray_nl,object_nl,indent, danspaceuntuk menghasilkan pretty JSON- Sebelumnya, buffer pemisah dihitung di awal dari string yang diberikan
- Contoh:
",#{opts[:array_nl]}",",#{opts[:object_nl]}",":#{opts[:space]}" - Tujuannya adalah menambahkan satu potongan panjang sekaligus, tetapi pekerjaan yang benar-benar dihemat kecil
- Dalam sebagian besar kasus, opsi-opsi itu tidak digunakan, sehingga biaya pra-perhitungan justru lebih besar
- Contoh:
- Mame pada dasarnya membalikkan optimasi ini dan mengurangi biaya konfigurasi secara signifikan
- Pada benchmark besar, perbedaannya tidak besar
- Benchmark pembuatan Hash kecil 65 byte menjadi 1,51 kali lebih cepat, dari
2,112,189.3 i/ske3,199,311.0 i/s
Menghindari penelusuran pointer dan membandingkan index encoding
- Optimasi lain dari Mame adalah menghapus satu pemanggilan
rb_enc_get - JSON sering perlu memeriksa apakah string kompatibel dengan UTF-8, dan sebelumnya dilakukan dengan mengambil
rb_encoding *lewatrb_enc_get(obj), lalu membandingkan apakah encoding tersebut US-ASCII atau UTF-8 rb_enc_getadalah API level tinggi yang defensif, sehingga melakukan banyak pemeriksaan tipe- API ini dirancang untuk menangani berbagai objek seperti String, Symbol, Regexp, File, Data, dan lainnya
- Ada banyak conditional, dan biayanya bisa besar jika CPU branch prediction meleset
- Secara konseptual Ruby String memiliki referensi encoding, tetapi dalam praktiknya, alih-alih pointer 64-bit, Ruby menyimpan index encoding 7-bit yang lebih kecil dalam bitmap internal tiap String
- Untuk mendapatkan pointer objek encoding lengkap, VM harus mencari encoding sebenarnya melalui array global internal, yang dalam kode level rendah termasuk penelusuran pointer
- Ini cepat jika sudah berada di CPU cache, tetapi jika harus diambil dari RAM, CPU harus menunggu
jsonsudah tahu targetnya adalah String, dan informasi yang dibutuhkan hanya apakah encoding-nya ASCII atau UTF-8, sehinggaRB_ENCODING_GETdapat digunakan untuk membandingkan index encoding secara langsung- Perubahan ini meningkatkan benchmark pembuatan
twitter.jsondari1159.6 i/smenjadi1253.3 i/s, atau 1,08 kali lebih cepat
Mempercepat escape string dengan lookup table
- Dump string JSON mahal karena harus memeriksa apakah tiap karakter bisa disalin apa adanya atau perlu di-escape
- Cara naif memeriksa beberapa kondisi untuk setiap karakter
- Apakah karakter kontrol ASCII
- Apakah
\n,\r,\t,\f, atau\b - Apakah
"atau\
- Pendekatan lookup table menghitung keputusan ini sebelumnya dalam array statis, lalu untuk tiap karakter membaca boolean pada offset dinamis, alih-alih melakukan banyak perbandingan
- Dengan memakai sedikit lebih banyak memori statis, loop menjadi jauh lebih cepat
- Dengan asumsi sebagian besar string tidak berisi karakter yang perlu di-escape, Mame menambahkan prasyarat untuk terlebih dahulu memeriksa fast path secara murah, lalu jika cocok menyalin seluruh string ke buffer sekaligus
- Patch Mame lebih kompleks karena berupa kode C, tetapi menggunakan pola yang sama
- Perubahan ini saja menaikkan benchmark pembuatan
twitter.jsondari1258.1 i/smenjadi1630.2 i/s, atau 1,30 kali lebih cepat
Optimasi berikutnya
- Masih ada optimasi lain yang akan dibahas, sehingga tulisan lanjutan diumumkan
- Setelah itu bagian kedua dipublikasikan
1 komentar
Komentar Hacker News
Saya sangat menyukai karya byroot. Bukan hanya jenis kontribusinya, tetapi skala produktivitasnya selalu mengejutkan
Saya pernah beberapa kali mencoba masuk ke pekerjaan di sisi Ruby core, tetapi tidak menemukan hal yang sesuai dengan kemampuan saya sekaligus bisa menjadi kontribusi positif, dan kalau beberapa minggu tidak ada hasil, motivasi saya hilang. Karena memang sangat sulit membangun konteks seperti yang dibagikan dalam tulisan itu
Kalau orang-orang Ruby C lebih sering menulis, sepertinya akan ada lebih banyak orang dengan kemampuan yang dibutuhkan untuk membuat Ruby lebih baik. Saran tentang profiler C juga bagus, dan saya jadi berpikir bisa mulai dengan mengambil Ruby gem yang berisi kode C lalu mengutak-atik lagi optimasinya
Memang tentang ekstensi C, tetapi membantu memahami beberapa konsep
Bagian 2 juga sudah diterbitkan: https://byroot.github.io/ruby/json/2024/12/18/optimizing-rub...
Satu hal yang layak disebut adalah jbuilder, cara pakai default di Rails. jbuilder sendiri bukan bagian serialisasi JSON, tetapi kalau bicara soal hal yang membuat rendering JSON di Ruby/Rails lambat, itu akan ada di posisi teratas daftar saya
Merender banyak partial dengan jbuilder benar-benar lambat
Tulisan tentang topik ini mudah diikuti, dan membuat saya ingin mencoba membenchmark dan mengoptimalkan kode Ruby saya sendiri. Baik tulisannya maupun pekerjaannya sama-sama bagus
Mungkin saya terlewat, tetapi apakah ada bagian yang menunjukkan berapa lama versi baru dengan semua optimasi diterapkan untuk mem-parse/meng-encode dump JSON Twitter?
https://github.com/ruby/json/releases/tag/v2.7.3
https://github.com/ruby/json/releases/tag/v2.8.0
Tulisan yang bagus dan pekerjaan yang bagus. Apakah masih ada alasan untuk memakai Oj ke depannya?
Oj punya API yang sangat besar yang tidak ingin ditiru oleh gem
jsonbawaan. Misalnya “SAJ” (parsing bergaya SAX), berbagai cara escaping, dan sebagainyaTujuan saya hanya membuat Oj tidak diperlukan untuk kira-kira 95% use case, jadi Oj tetap akan berguna untuk banyak kebutuhan
Setelah tulisan ini keluar, saya penasaran seberapa jauh lebih cepat dibanding implementasi yang sekarang tidak dipelihara ini
https://netflixtechblog.com/fast-json-api-serialization-with...
Saya pikir implementasi Ruby murni ini cukup rapi, tetapi belum pernah memakainya di produksi sungguhan. Sudah lama ditinggalkan
Secara umum saya juga penasaran dengan keadaan implementasi Ruby murni. Sepertinya
json_puredihapus, dan kalau begitu sayang sekali. Ada yang tahu detailnya? Bagian paling menarik dari tulisan itu bagi saya lebih pada optimasi Ruby daripada optimasi CBacaan yang menarik. Namun untuk optimasi yang tidak spesifik Ruby, misalnya lookup table untuk karakter escape, saya penasaran kenapa tidak memanfaatkan library yang sudah melakukan hal seperti itu, seperti simdjson
Singkatnya, karena
ruby/jsondidistribusikan bersama Ruby, ia harus kompatibel dengan batasan Ruby, dan saat ini itu berarti C99 murni dan tanpa C++. Lisensi Apache 2 dari simdjson juga mungkin menjadi masalah, meski saya tidak yakinSecara umum saya ingin memakai library C++ yang bagus seperti dragonbox, tetapi tidak bisa
Selain itu, terakhir kali saya cek, simdjson hanya menyediakan parser. Gem ruby/json melakukan parsing dan encoding, jadi itu hanya membantu separuh dari ruang masalahnya
Pekerjaan seperti ini terlalu jarang dilakukan dalam proyek modern. Kalau hal seperti ini dilakukan lebih rutin, saya bertanya-tanya apakah library seperti simdjson atau oj memang akan diperlukan sejak awal. Ruang masalah ini tidak sesulit itu
Apakah Ruby JSON memakai intrinsics? Bisakah memakainya?
Dan bagaimana ia berinteraksi dengan berbagai JIT?
Gem
jsondiimplementasikan dalam C, jadi bagi YJIT, yaitu JIT dari implementasi referensi, ia adalah black boxTruffleRuby JIT dulu bisa menginterpretasikan ekstensi C dengan sulong dan melakukan JIT melintasi batas bahasa, tetapi setahu saya belakangan mereka menghentikan pendekatan itu karena berbagai masalah kompatibilitas
Selain itu, di TruffleRuby parser JSON diimplementasikan dalam C, tetapi encoder-nya Ruby murni: https://github.com/ruby/json/blob/e1f6456499d497f33f69ae4c1a...
Kalau ingatan saya benar, branch prediction hints tidak berguna di CPU modern
“Mulai dari mikroarsitektur Redwood Cove, jika predictor tidak memiliki informasi tersimpan tentang suatu branch dan branch itu memiliki Intel SSE2 branch taken hint, yaitu prefix instruksi 3EH, codec akan membalik prediksi branch dari not-taken menjadi taken saat mendecode branch tersebut. Kemudian pipeline depan di-flush dan pipeline diarahkan untuk mengambil jalur taken
...
Hint ini hanya digunakan ketika predictor tidak memiliki informasi tersimpan tentang branch tersebut. Untuk menghindari pembengkakan kode dan penurunan bandwidth instruction fetch, jangan menambahkan hint pada branch di hot code, seperti branch di dalam loop dengan jumlah iterasi tinggi. Sebab besar kemungkinan predictor sudah menyimpan informasi tentang branch itu. Idealnya, hint hanya ditambahkan pada branch yang jarang dieksekusi tetapi sebagian besar taken, meski branch seperti itu bisa sulit diidentifikasi. Jika compiler tidak dapat menempatkan salah satu jalur eksekusi sebagai fall-through, disarankan menambahkan hint sebagai bagian dari profile-guided optimization. Mikroarsitektur Redwood Cove memperkenalkan event performance monitoring baru untuk memandu penempatan hint”