1 poin oleh GN⁺ 2024-12-19 | 1 komentar | Bagikan ke WhatsApp
  • Gem bawaan Ruby json ditingkatkan dengan menghilangkan bottleneck lewat profiling, sehingga mengurangi tekanan praktis untuk beralih ke oj demi kecepatan
  • Tujuannya bukan selalu mengalahkan oj, melainkan menyediakan pemrosesan JSON yang cukup cepat dan dapat diprediksi tanpa monkey patching seperti Oj.mimic_JSON dan Oj.optimize_rails
  • oj memang lebih cepat di beberapa benchmark, tetapi menimbulkan beban stabilitas operasional dan kompatibilitas API, seperti mengabaikan opsi script_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.json berukuran 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/json cukup 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.2 dan oj tidak terlalu besar pada beberapa benchmark yang mendekati ukuran nyata
    • Parsing dokumen JSON 467KiB berisi 100 tweet: json 2.7.2 membutuhkan 1.9ms, sedangkan oj 1.6ms
    • Pembuatan dokumen yang sama: json 2.7.2 membutuhkan 0.8ms, sedangkan oj 0.4ms
  • 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
  • oj dipakai di banyak proyek, termasuk codebase Shopify, dan popularitasnya kemungkinan besar karena kecepatannya
  • Ketidaksesuaian API akibat monkey patching

    • Oj.mimic_JSON sering digunakan untuk melakukan monkey patch pada gem json, sementara Oj.optimize_rails pada ActiveSupport::JSON
    • JSON.dump(data, script_safe: true) dapat meng-escape </script> menjadi <\/script> agar JSON aman dimasukkan ke dalam tag <script>
    • Karena oj tidak mengenal opsi script_safe dan mengabaikannya, gem yang aman saat berdiri sendiri dapat menciptakan kemungkinan serangan XSS di dalam aplikasi yang memanggil Oj.mimic_JSON
    • Oj.optimize_rails juga 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_rails dan Oj.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, oj merupakan salah satu penyebab crash Ruby yang menonjol, dan menjadi yang paling bermasalah setelah grpc
    • Menulis native gem membutuhkan pemahaman tentang Ruby VM, terutama GC; tanpa itu, crash atau kerusakan memori dapat terjadi
    • Codebase oj memiliki 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 Oj dari monolith-nya, dan dalam proses itu menemukan perbedaan halus antara Oj.mimic_JSON dan json yang sebenarnya

Menemukan bottleneck dengan benchmark dan profiling

  • Tujuannya adalah agar ruby/json berperilaku mirip dengan oj baik dalam penggunaan nyata maupun microbenchmark, sehingga daya tarik memakai Oj.mimic_JSON karena kecepatan berkurang
  • Langkah pertama adalah menyusun benchmark suite
  • 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.dump dengan payload twitter.json, 9% waktu digunakan oleh isLegalUTF8 milik JSON sendiri, dan 1.9% oleh rb_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 ASCII
    • ENC_CODERANGE_UNKNOWN: belum di-scan
    • ENC_CODERANGE_VALID: encoding valid
    • ENC_CODERANGE_7BIT: encoding valid dan hanya berisi karakter ASCII
    • ENC_CODERANGE_INVALID: encoding tidak valid
  • convert_UTF8_to_JSON_ASCII yang lama memanggil rb_enc_str_asciionly_p di 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 coderange yang sudah dihitung
    • Dalam bentuk Ruby, strukturnya adalah me-raise JSON::GeneratorError jika bukan string.ascii_only? dan string.encoding != Encoding::UTF_8 atau !string.valid_encoding?
    • Baik #ascii_only? maupun #valid_encoding? memanfaatkan coderange yang di-cache, sehingga scan string terjadi paling banyak sekali
  • Berbeda dari ekspektasi 9%, peningkatan nyata hanya sekitar 3%
    • Sebagian besar waktu yang sebelumnya digunakan di isLegalUTF8 berpindah ke convert_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.json menjadi 1,03x lebih cepat, dari 1077.3 i/s ke 1113.3 i/s

Memeriksa kondisi yang lebih murah dan lebih mungkin terlebih dahulu

  • fbuffer_inc_capa tercatat memakan 5.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->capa bernilai 0, sehingga sebagian juga duplikatif dengan pemeriksaan required > fb->capa
  • Setelah perbaikan, kasus paling umum yaitu “kapasitas buffer cukup” diperiksa terlebih dahulu, dan RB_LIKELY serta RB_UNLIKELY digunakan untuk memberi petunjuk branch prediction kepada CPU
  • Fungsi ditandai sebagai inline sehingga biaya pemanggilan berkurang, dan pada sebagian besar kasus pekerjaan yang diperlukan menjadi sekecil pengurangan dan perbandingan
  • Perubahan ini menaikkan benchmark pembuatan twitter.json dari 1068.6 i/s menjadi 1224.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/json lebih besar dibanding implementasi alternatif, sehingga terlihat buruk dalam microbenchmark
  • JSON.generate dapat menerima opsi seperti array_nl, object_nl, indent, dan space untuk 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
  • 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/s ke 3,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 * lewat rb_enc_get(obj), lalu membandingkan apakah encoding tersebut US-ASCII atau UTF-8
  • rb_enc_get adalah 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
  • json sudah tahu targetnya adalah String, dan informasi yang dibutuhkan hanya apakah encoding-nya ASCII atau UTF-8, sehingga RB_ENCODING_GET dapat digunakan untuk membandingkan index encoding secara langsung
  • Perubahan ini meningkatkan benchmark pembuatan twitter.json dari 1159.6 i/s menjadi 1253.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.json dari 1258.1 i/s menjadi 1630.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

 
GN⁺ 2024-12-19
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

    • Ada juga seri bagus dari Peter Zhu: https://blog.peterzhu.ca/ruby-c-ext/
      Memang tentang ekstensi C, tetapi membantu memahami beberapa konsep
    • “Produktivitas luar biasa” memang benar, tetapi dia juga benar-benar orang yang sangat cerdas. Saya pernah bekerja di kantor yang sama dengannya di Shopify, dan dia berada di level yang terasa sulit dijangkau
  • 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?

  • Tulisan yang bagus dan pekerjaan yang bagus. Apakah masih ada alasan untuk memakai Oj ke depannya?

    • Saya penulisnya
      Oj punya API yang sangat besar yang tidak ingin ditiru oleh gem json bawaan. Misalnya “SAJ” (parsing bergaya SAX), berbagai cara escaping, dan sebagainya
      Tujuan 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_pure dihapus, dan kalau begitu sayang sekali. Ada yang tahu detailnya? Bagian paling menarik dari tulisan itu bagi saya lebih pada optimasi Ruby daripada optimasi C

  • Bacaan 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

    • Kurang lebih sudah dijawab di https://news.ycombinator.com/item?id=42450085
      Singkatnya, karena ruby/json didistribusikan 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 yakin
      Secara 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
    • Hal yang bagus dari tulisan ini adalah ia merupakan pekerjaan engineering nyata pada codebase yang sudah ada. Bukan mengganti semuanya atau mengganti library demi sedikit lebih cepat, melainkan benar-benar menggali kode yang ada dan memperbaiki bukan hanya kecepatan, tetapi juga efisiensi
      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?

    • Saya tidak benar-benar tahu apa yang dimaksud dengan intrinsics
      Gem json diimplementasikan dalam C, jadi bagi YJIT, yaitu JIT dari implementasi referensi, ia adalah black box
      TruffleRuby 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

    • Dulu tidak berguna di CPU modern, tetapi di sebagian CPU sekarang kembali cukup berguna. https://www.phoronix.com/news/GCC-Clang-Intel-x86-Branch-Hin...
      “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”