Membangun Parser JSON Berkinerja Tinggi
(dave.cheney.net)- Presentasi GopherCon Singapore 2023 dari Dave Cheney membahas proses desain untuk membuat parser JSON streaming di Go yang mempertahankan API mirip
encoding/json, tetapi meningkatkan throughput dan mengurangi alokasi - JSON tidak memiliki penanda panjang, sehingga input harus dibaca sampai akhir; batas bawah performanya minimal read(N)+parse(N), jadi kendala utamanya adalah mengurangi kunjungan ulang byte/token, penyalinan, alokasi, dan pemanggilan fungsi di hot path
encoding/json.Decoder.Tokenmengembalikan token sebagaiinterface{}, yang praktis, tetapi nilai konkret akan escape ke heap sehingga menghasilkan alokasi yang sebanding dengan jumlah token, dan bahkan untuk satu token"hello"terjadi 3 allocs/oppkg/jsonmengurangi biaya hot path melaluiNextTokenyang mengembalikan sub-slice[]bytedari input, sliding window padabyteReader, inlining manual, pemanggilan langsung metode state, dan penghapusan bounds check- Pada akhirnya,
pkg/json.Scannermelakukan tokenisasi tanpa alokasi jika diberi buffer,Decoder.Token2–3 kali lebih cepat daripadaencoding/json.Decoder.Token, danDecoder.NextTokenyang alokasinya lebih sedikit menunjukkan performa 8–10 kali lebih cepat
Tujuan dan batasan dasar
- Tujuannya adalah membuat parser JSON berkinerja tinggi sebagai studi desain paket Go
- Sasaran desainnya ada tiga
- Mendukung pemrosesan streaming tanpa memuat seluruh input ke memori
- Memberikan throughput lebih tinggi dan alokasi lebih sedikit sambil tetap cukup kompatibel dengan API tingkat tinggi
json.Decoderdariencoding/json - Menyediakan API yang lebih efisien, tanpa alokasi atau dengan batas alokasi, di luar API
encoding/json
- Jika seluruh input terlebih dahulu di-buffer ke memori, akan muncul risiko ketersediaan ketika ukuran input tidak diketahui atau tak terbatas, dan latensi sebelum pemrosesan juga meningkat
- Pembacaan streaming memproses data segera saat tiba, dan dapat menumpangtindihkan pembacaan dengan pemrosesan
Kompleksitas waktu parsing JSON
- JSON tidak memiliki penanda panjang, sehingga untuk mengetahui berapa banyak yang harus dibaca, seluruh input harus dibaca
- Untuk mem-parse elemen ke-1.000 dalam array JSON, 999 elemen sebelumnya juga harus dibaca dan diproses, sehingga pemrosesan input tidak bisa dilewati
- Batas bawah performa berbanding lurus dengan ukuran input; bukan hanya membaca sederhana, karena harus melewati state machine JSON untuk menemukan awal dan akhir token, sehingga minimal menjadi
read(N)+parse(N) - Kriteria untuk mengurangi biaya tambahan adalah sebagai berikut
- Jika N byte telah dibaca, setiap byte sebisa mungkin diproses hanya sekali
- Token yang sama juga diproses hanya sekali
- Di hot path
ScanneratauDecoder, batasi jumlah pemanggilan fungsi menjadiO(tokens), bukanO(bytes) - Kurangi penyalinan untuk mengurangi jumlah kunjungan ulang ke byte yang sama
- Kurangi alokasi untuk menurunkan biaya alokasi heap, akses struktur data bersama, lock, kontensi cache, dan GC
Tokenisasi dan desain API
- Decoder JSON secara garis besar terbagi menjadi dua tahap
- Scanner atau tokenizer yang mengubah stream byte menjadi stream token JSON
- Unmarshaller yang menerapkan stream token JSON ke objek Go
encoding/json.Decoder.Tokenmengembalikan token sebagaiinterface{}- String direpresentasikan sebagai
string, angka sebagaifloat64, boolean sebagaibool,nullsebagainil, dan delimiter sebagaijson.Delim - Pendekatan ini nyaman digunakan karena merepresentasikan nilai dan tipe token sekaligus
- String direpresentasikan sebagai
- Kenyamanan memiliki biaya
- Brad Fitzpatrick menyebut Token API sebagai garbage factory
- Karena desain API
Decoder.Token, nilai konkret yang dialokasikan untuk tiap token escape ke heap - Jumlah alokasi terikat pada jumlah token dalam input
- Dalam benchmark untuk satu token
"hello",encoding/jsonmenunjukkan 355ns/op, 19.7MB/s, 37.0B/op, 3.00 allocs/op - Desain API menentukan alokasi, dan alokasi dapat berdampak langsung pada performa
Token []byte dan informasi tipe implisit
- Tipe token JSON dapat diketahui hanya dari karakter pertamanya
{,}: awal dan akhir objek[,]: awal dan akhir arrayt: truef: falsen: null": string-,0~9: angka
- API
Decoder.NextTokendaripkg/jsontidak mengubah input[]bytemenjadi nilai Go, melainkan mengembalikan byte yang mewakili token langsung sebagai sub-slice dari input - Byte pertama dari
[]byteyang dikembalikan menunjukkan tipe token - API ini memiliki batasan
- Output bukan salinan, melainkan sub-slice dari input, sehingga ada batasan masa berlaku
- Ini mirip dengan API
bufio.Scanner - Untuk menangani tipe token atau nilai string/angka aktual dengan lebih nyaman, diperlukan abstraksi tingkat lebih tinggi
Pembacaan efisien: byteReader
- Pendekatan tradisional
io.Reader.Readmenyalin data dari reader ke buffer, dan penyalinan ini sendiri memiliki biaya io.Reader.Readmenyerahkan pengelolaan buffer kepada pemanggil- Jika membaca byte demi byte, mungkin diperlukan ruang untuk menyimpan atau memutar balik byte yang telah dilewati
- Pendekatan membaca ke buffer besar lalu mencari awal dan akhir token membutuhkan banyak pengelolaan, penyalinan, dan perluasan buffer ketika akhir token tidak ada di dalam buffer
- Sebagai alternatif, digunakan
byteReaderyang terinspirasi oleh iopipe dari Steven Schveighoffer dan ide Phil Pearl byteReadermenyediakan sliding window di atasio.Reader, mirip denganbufio.Readertetapi dengan API yang lebih efisienwindow()mengembalikan jendela data yang saat ini belum dibacarelease(n)membuang n byte di bagian depan jendelaextend()membaca lebih banyak data dari reader di bawahnya untuk memperluas jendela
- Benchmark pencarian whitespace adalah baseline yang mengunjungi setiap karakter dan hanya memeriksa apakah karakter tersebut whitespace; pada berbagai input hasilnya sekitar 2.04–2.07GB/s
- Contoh kode penghitung whitespace ada di github.com/davecheney/whitespace
Optimasi scanner
Scanner.Nextmelewati whitespace di antara token, menentukan token dari karakter pertama pada jendela, lalu membaca hingga akhir token- Performa awal
Scanner.Nextsekitar 1/4 hingga 2/5 dari baseline whitespace- Contoh:
Scanner/canada510MB/s,citm_catalog677MB/s,sample837MB/s
- Contoh:
- Optimasi pertama adalah mengganti pembaruan field
s.offsetdengan variabel lokaloffsets.offsetbernilai 0 saat masuk dan keluar fungsi, sehingga perubahan internal tidak terlihat dari luar- Dengan menggunakan variabel lokal, compiler menghindari penulisan memori sementara
citm_catalogturun dari 2.52ms menjadi 1.80ms, berkurang 28.46%;sampleturun dari 828µs menjadi 528µs, berkurang 36.24%
- Alasan efeknya berbeda untuk tiap input adalah perbedaan jumlah whitespace
canadahanya memiliki 33 whitespacecitmmemiliki 1.227.563 whitespace
- Optimasi kedua adalah manual inlining
Scanner.tokenke dalamScanner.Next- Compiler Go tidak dapat meng-inline otomatis
Scanner.token,parseString,parseNumber,Scanner.Next, dan lainnya karena loopforserta kompleksitas fungsi Scanner.NextdanScanner.tokendipanggil untuk setiap token input, sehingga ada biaya dua pemanggilan fungsi per token
- Compiler Go tidak dapat meng-inline otomatis
- Setelah manual inlining, throughput meningkat 9–24%
canadanaik dari 512MB/s menjadi 642MB/s, meningkat 24.50%citm_catalognaik dari 960MB/s menjadi 1105MB/s, meningkat 15.16%samplenaik dari 1.33GB/s menjadi 1.46GB/s, meningkat 9.11%
- Efek optimasi dapat dirangkum menjadi dua hal
- Pembaruan
s.offsetdikurangi dari 1 kali per byte menjadi 1 kali per token - Menghindari pemanggilan fungsi di hot path dapat meningkatkan performa
- Pembaruan
Validasi dan Decoder.NextToken
- Scanner saja dapat memisahkan token, tetapi pemrosesan JSON lengkap memerlukan validasi state
- JSON adalah state machine, dan token yang dapat muncul berikutnya dibatasi oleh token saat ini
- Misalnya, setelah membaca
{,"username", hanya:yang valid
- Misalnya, setelah membaca
Decoder.NextTokenmenambahkan logika state di atasScanner.Nextuntuk memastikan urutan token valid- State dibagi menjadi value, string key objek, kolon objek, value objek, koma objek, value array, koma array, state selesai, dan sebagainya
- Bahkan pada implementasi validasi awal,
pkg/jsonmenunjukkan hasil 8–10 kali lebih cepat daripadaencoding/jsoncanada:pkg/json399MB/s,encoding/json34.6MB/scitm_catalog:pkg/json713MB/s,encoding/json87.1MB/ssample:pkg/json1.23GB/s,encoding/json216MB/s
Optimasi transisi state
- Di pusat
Decoder.NextTokenterdapat pernyataanswitch switchumum dapat diimplementasikan seperti serangkaian pernyataanif, sehingga rangkaian cabang yang panjang memecah instruction stream dan membebani branch predictor CPU- Ada juga cara menggunakan tabel untuk mencari metode state dari nilai state, tetapi contoh implementasinya tidak dikompilasi karena loop inisialisasi
- Sebagai gantinya, digunakan method expression Go untuk menyimpan metode langsung di
d.state, bukan enum stateDecoder.NextTokenmemanggil metode state saat ini secara langsung, sepertireturn d.state(d, tok)
- Pendekatan computed goto ini saja tidak banyak meningkatkan performa
- Beberapa input hampir tidak berubah, dan pada
twitter,code,examplejustru sedikit lebih lambat samplemenjadi 1.15% lebih cepat
- Beberapa input hampir tidak berubah, dan pada
- Perubahan ini memungkinkan optimasi berikutnya, yaitu outlining
Outlining dan penghapusan bounds check
- Setelah outlining,
Decoder.NextTokenhanya melakukanreturn d.state(d), dan setiap metode state langsung memanggild.scanner.Next() - Karena
toktidak diteruskan sebagai argumen metode state, call stack berkurang 3 words - Pemeriksaan
len(tok) < 1danswitch tok[0]berada dalam fungsi yang sama, sehingga penghapusan bounds check menjadi mungkin- Sebelumnya, pemeriksaan
len(tok)berada diDecoder.NextToken, sedangkan metode state dipanggil melalui method expression dan tidak di-inline - Karena itu,
tok[0]di metode state memerlukan bounds check - Jika pemeriksaan panjang dilakukan dalam fungsi yang sama, compiler dapat membuktikan bahwa panjang
tokminimal 1
- Sebelumnya, pemeriksaan
Decoder.NextTokensendiri juga menjadi sederhana sehingga bisa di-inline- Pemanggil pada dasarnya melihat pemanggilan langsung ke metode state saat ini, bukan
dec.NextToken() - Biaya pemanggilan fungsi dihilangkan
- Pemanggil pada dasarnya melihat pemanggilan langsung ke metode state saat ini, bukan
Hasil benchmark akhir
pkg/json.Scannerlevel terendah melakukan tokenisasi streaming tanpa alokasi jika diberi buffer beberapa KBcanada: 638.78MB/s, 0 B/op, 0 allocs/opcitm_catalog: 1110.51MB/s, 0 B/op, 0 allocs/opsample: 1471.01MB/s, 0 B/op, 0 allocs/op
pkg/json.Decoder.Token2–3 kali lebih cepat daripadaencoding/json.Decoder.Tokencanada: 101.98MB/s vs 33.19MB/scitm_catalog: 333.23MB/s vs 82.71MB/ssample: 788.59MB/s vs 209.12MB/s
pkg/json.Decoder.NextTokenmemiliki alokasi jauh lebih sedikit dan 8–10 kali lebih cepatcanada: 466.52MB/s, 136 B/op, 3 allocs/op vs 34.42MB/s, 17.740.399 B/op, 889.106 allocs/opcitm_catalog: 798.58MB/s, 136 B/op, 3 allocs/op vs 86.08MB/s, 5.661.597 B/op, 324.692 allocs/opsample: 1346.85MB/s, 1144 B/op, 9 allocs/op vs 217.44MB/s, 723.781 B/op, 26.095 allocs/op
- Pada API level tertinggi,
pkg/jsondapat melakukan unmarshal ke objek Go dengan cara yang sama sepertiencoding/jsoncanada: 82.08MB/s vs 58.70MB/scitm_catalog: 215.66MB/s vs 104.00MB/ssample: 615.99MB/s vs 128.04MB/s
- Tautan presentasi ada di dave.cheney.net/paste/gophercon-sg-2023.html, dan kodenya ada di github.com/pkg/json
Topik yang didapat dari desain
-
Alokasi memengaruhi performa
- Walaupun GC mengalokasikan dengan cepat dan mengumpulkan dengan efisien, tidak melakukan alokasi selalu lebih cepat
- Desain API dapat menghilangkan alokasi
- Sebagian besar peningkatan kecepatan paket ini berasal dari pengurangan alokasi
- Waktu yang tidak dihabiskan pada jalur alokasi heap dan siklus GC digunakan untuk scanning
- API
encoding/json.Decodermembutuhkan alokasi karena mengembalikan nilai primitive sebagaiinterface{} - Nilai escape ke heap sehingga pada praktiknya menjadi pointer ke nilai
- Dalam pemrosesan data, alokasi dapat menjadi biaya performa terbesar dalam algoritme
- Mengurangi biaya per byte dan per token dengan cermat adalah faktor peningkatan performa terbesar kedua
- Arah yang penting adalah mengubah pemanggilan fungsi per byte menjadi pemanggilan fungsi per token
- Berangkat dari asumsi bahwa
encoding/jsonbisa lebih lambat karena API-nya, dan jika API lain dapat diterima, performa 2–3 kali lipat dapat diperoleh pada beberapa jalur unmarshal, serta 8–10 kali lipat pada tokenisasi
1 komentar
Komentar Hacker News
Kelihatannya cukup bagus. Sepanjang karier saya, saya sudah terlalu sering membuat parser JSON, tetapi sangat menyenangkan punya referensi yang menunjukkan langkah demi langkah bagaimana merancang parser JSON yang masuk akal dan cepat
Namun, JSON tidak benar-benar membutuhkan tokenizer yang eksplisit. Konsep token bisa dihilangkan, lalu parsing dan tokenisasi bisa digabung sepenuhnya. Biasanya memang begitu, dan keseluruhannya jadi lebih sederhana
Di bahasa seperti ECMAScript ini jauh lebih sulit, karena ada kasus yang tampak seperti subset dari tata bahasa ekspresi bertanda kurung, seperti arrow function, lalu baru dipastikan tergantung apakah
=>muncul, sehingga bisa memerlukan lookahead sepanjang arbitrerIni tulisan yang enak diikuti, dan alurnya jelas soal bagaimana melakukannya jika ingin mengimplementasikannya sendiri
Kalau yang dicari adalah performa murni di lingkungan produksi, Daniel Lemire punya https://github.com/simdjson/simdjson yang juga layak dilihat. Ada juga port Go dari MinIO, https://github.com/minio/simdjson-go
Hal yang saya pelajari saat membuat parser JSON cepat memang banyak dipengaruhi karakteristik tiap bahasa, tetapi kalau digeneralisasi kira-kira begini
Pada tokenisasi, alokasi heap harus dihindari. Lebih baik tokenizer mengembalikan struct yang dialokasikan di stack, atau fungsi yang mengembalikan token
int64yang memaketkan posisi awal token, panjang, offset tipe, dan sebagainyaPada parsing juga sebaiknya menghindari alokasi heap, dan untuk klien yang ingin bekerja dengan potongan buffer bisa didukung antarmuka seperti
getString(key String)Saat melakukan deserialisasi ke objek yang field-nya diketahui saat kompilasi, biasanya dibuat
switchberdasarkan panjang key sebelum membandingkan nilai stringDi pipeline data yang banyak memproses JSON, hanya dari pemilihan library JSON saja perbedaan performa 3–10x bisa terjadi, dan parser utama umumnya cenderung mengalokasikan objek
Jika kelas untuk serialisasi/deserialisasi sudah diketahui saat kompilasi, Jackson di Java bekerja cukup baik, tetapi dengan coding yang hati-hati dan profiling biasanya masih bisa didorong sekitar 2x lagi
Sebaliknya, jika harus menangani JSON arbitrer, parser arus utama cenderung melakukan banyak alokasi, jadi parser yang ditulis sendiri dan lebih intrusif bisa menghindari itu, dan keuntungan performanya sangat besar saat memproses ribuan hingga jutaan objek per detik
Saya pernah membuat tokenizer dan parser GraphQL dengan pendekatan serupa, dan ini juga tanpa alokasi memori serta cukup cepat. Kalau penasaran dengan kodenya, lihat https://github.com/wundergraph/graphql-go-tools
Saya juga pernah presentasi soal ini, sayangnya tidak direkam. Saya hampir gila saat mencoba memeras Go semaksimal mungkin :D
Atau mungkin ada dampak lain juga?
Di n2[1] saya membutuhkan tokenizer cepat, dan mengalami masalah pembuat sampah yang sama. Pada dasarnya ada campuran kumpulan token konstan seperti
json.Delimdan string yang memicu alokasiSolusi yang menurut saya cukup bagus adalah membuat tokenizer generik terhadap suatu
T, lalu menerima fungsi yang mengubah slice byte menjadiT, sehingga yang dipakai adalahT, bukan stringDengan begitu pemanggil bisa memberikan representasi yang lebih efisien, misalnya yang lebih sedikit alokasinya, dan pada saat yang sama untuk unit test tokenizer tetap bisa diuji dengan nyaman memakai fungsi identitas
Dalam arti tertentu ini mirip dengan menggabungkan tokenizer dan parser saat build, tetapi berkat generics, tokenizer tetap bisa menjaga lapisannya tanpa perlu tahu representasi parser
[1] https://github.com/evmar/n2
Dengan desain API yang lebih baik, memang mungkin mengungguli standard library, tetapi saat membuat parser streaming yang benar-benar lengkap, pada praktiknya sulit untuk tidak meninggalkan struct dalam keadaan setengah terisi lalu keluar di tengah jalan sebelum menemukan error. Standard library tampaknya juga menjadikan ini sebagai batasan desain yang eksplisit
Mungkin saya melewatkan sesuatu, tetapi penulis berulang kali bilang dia membuat parser “streaming” tanpa menjelaskan apa sebenarnya maksudnya
Secara khusus, tidak ada penjelasan tentang bagaimana key yang berulang ditangani di “hash table”. Saya penasaran, apakah kode sink dipanggil dua kali saat ada key berulang, atau apakah ia menunggu sampai seluruh “hash table” selesai dibaca baru memanggil kode sink
Menurut saya JSON itu bersifat hierarkis, panjangnya tidak diketahui sebelumnya, dan yang paling penting ada key berulang, sehingga pada dasarnya tidak cocok untuk streaming
Memang mungkin membuat sebagian subset JSON lebih ramah streaming, tetapi kalau begitu tidak ada alasan khusus untuk tetap memaksakan JSON. Jika solusinya adalah mengubah JSON, menurut saya format lain yang memang bukan JSON akan lebih baik
Senang melihat Phil Pearl disebut
https://github.com/bytedance/sonic juga patut dilihat
Cukup mengejutkan bahwa tidak ada cara untuk mengatakan “tolong inline fungsi ini” pada fungsi yang terlalu besar sehingga tidak di-inline
Operasi dasar menghitung dan mencari whitespace tampaknya akan jauh lebih cepat jika divektorkan dengan SIMD, tetapi saya paham itu di luar cakupan penulis
Pernyataan “tidak realistis mengharapkan seluruh input bisa dimuat ke memori” itu tidak benar untuk sebagian besar aplikasi
Mungkin orang bisa bilang Go bukan alat yang tepat untuk pekerjaan itu, tetapi dengan optimasi seperti ini saya rasa tidak ada alasan itu tidak bisa dilakukan
Saya pernah memasukkan data JSON berukuran gigabyte, jadi parser streaming sangat saya hargai. Lagi pula, dukungan streaming juga jadi sinyal bahwa penulis memahami berbagai use case dan punya engineering yang lebih baik
Memori itu murah dan nyaris gratis hanya dalam teori, bukan di dunia nyata
mmap()bukan juga pilihan yang layak? Itu terpisah dari kasus ketika benar-benar butuh streaming, misalnya saat data di bagian awal file JSON tunggal perlu diproses lebih cepat seperti aliran transaksi atau pekerjaan