การสร้าง JSON Parser ประสิทธิภาพสูง
(dave.cheney.net)- การบรรยาย GopherCon Singapore 2023 ของ Dave Cheney กล่าวถึงกระบวนการออกแบบการสร้าง สตรีมมิง JSON parser ใน Go โดยยังคง API ที่คล้ายกับ
encoding/jsonแต่เพิ่ม throughput และลดการ allocate - JSON ไม่มีตัวบอกความยาว จึงต้องอ่านอินพุตจนจบ และขีดล่างของประสิทธิภาพอย่างน้อยคือ read(N)+parse(N) ดังนั้นข้อจำกัดหลักคือการลดการย้อนกลับไปเยี่ยมชมไบต์และโทเค็นเดิม การคัดลอก การ allocate และการเรียกฟังก์ชันใน hot path
encoding/json.Decoder.Tokenคืนค่าโทเค็นเป็นinterface{}ซึ่งสะดวก แต่ค่ารูปธรรมจะ escape ไปอยู่บน heap ทำให้เกิด การ allocate ตามสัดส่วนจำนวนโทเค็น และแม้แต่โทเค็น"hello"เพียงตัวเดียวก็เกิด 3 allocs/oppkg/jsonลด ต้นทุน hot path ด้วยNextTokenที่คืน subslice ของ[]byteจากอินพุต, sliding window ของbyteReader, การ inline ด้วยมือ, การเรียกเมธอดสถานะโดยตรง และการกำจัด bounds check- สุดท้าย
pkg/json.Scannerสามารถ tokenize แบบไม่ allocate เมื่อมีบัฟเฟอร์ให้,Decoder.Tokenเร็วกว่าencoding/json.Decoder.Token2~3 เท่า และDecoder.NextTokenที่ allocate น้อยกว่าแสดงประสิทธิภาพเร็วกว่า 8~10 เท่า
เป้าหมายและข้อจำกัดพื้นฐาน
- เป้าหมายคือการสร้าง JSON parser ประสิทธิภาพสูง ในฐานะกรณีศึกษาการออกแบบแพ็กเกจ Go
- เป้าหมายการออกแบบมีสามข้อ
- รองรับ การประมวลผลแบบสตรีมมิง ที่ไม่โหลดอินพุตทั้งหมดขึ้นหน่วยความจำ
- ให้ throughput สูงขึ้นและ allocate น้อยลง ขณะยังเข้ากันได้อย่างสมเหตุสมผลกับ API ระดับสูง
json.Decoderของencoding/json - นอกจาก API ของ
encoding/jsonแล้ว ยังให้ API ที่มีประสิทธิภาพกว่าแบบไม่ allocate หรือมีขอบเขตจำกัด
- หาก buffer อินพุตทั้งหมดไว้ในหน่วยความจำก่อน จะเกิดความเสี่ยงด้านความพร้อมใช้งานเมื่อไม่รู้ขนาดอินพุตหรืออินพุตไม่มีที่สิ้นสุด และยังเพิ่มเวลารอก่อนเริ่มประมวลผล
- การอ่านแบบสตรีมมิงประมวลผลได้ทันทีเมื่อข้อมูลมาถึง และสามารถซ้อนทับการอ่านกับการประมวลผลได้
ความซับซ้อนเชิงเวลาของการ parse JSON
- JSON ไม่มี ตัวบอกความยาว จึงต้องอ่านอินพุตทั้งหมดเพื่อให้รู้ว่าต้องอ่านเท่าไร
- หากต้อง parse องค์ประกอบลำดับที่ 1,000 ของ JSON array ก็ต้องอ่านและประมวลผลองค์ประกอบ 999 ตัวก่อนหน้า จึงไม่สามารถข้ามการประมวลผลอินพุตได้
- ขีดล่างของประสิทธิภาพแปรตามขนาดอินพุต และไม่ใช่แค่อ่านอย่างเดียว แต่ต้องผ่าน state machine ของ JSON เพื่อหาจุดเริ่มและจุดจบของโทเค็น จึงอย่างน้อยเป็น
read(N)+parse(N) - เกณฑ์ในการลดต้นทุนเพิ่มเติมมีดังนี้
- หากอ่าน N ไบต์แล้ว แต่ละไบต์ควรถูกประมวลผลเพียงครั้งเดียวเท่าที่เป็นไปได้
- โทเค็นเดียวกันก็ควรถูกประมวลผลเพียงครั้งเดียว
- จำกัดจำนวนการเรียกฟังก์ชันใน hot path ของ
ScannerหรือDecoderให้เป็นO(tokens)ไม่ใช่O(bytes) - ลดการคัดลอกเพื่อลดจำนวนครั้งที่ต้องกลับไปเยี่ยมชมไบต์เดิม
- ลดการ allocate เพื่อลดต้นทุน heap allocation, การเข้าถึงโครงสร้างข้อมูลที่ใช้ร่วมกัน, lock, cache contention และ GC
การ tokenize และการออกแบบ API
- JSON decoder แบ่งกว้าง ๆ เป็นสองขั้นตอน
- scanner หรือ tokenizer ที่แปลง byte stream เป็น JSON token stream
- unmarshaller ที่นำ JSON token stream ไปใช้กับอ็อบเจกต์ Go
encoding/json.Decoder.Tokenคืนค่าโทเค็นเป็นinterface{}- สตริงแทนด้วย
string, ตัวเลขด้วยfloat64, boolean ด้วยbool,nullด้วยnil, delimiter ด้วยjson.Delim - วิธีนี้สะดวกต่อการใช้งาน เพราะแสดงทั้งค่าและชนิดของโทเค็นพร้อมกัน
- สตริงแทนด้วย
- ความสะดวกมีต้นทุนตามมา
- Brad Fitzpatrick เรียก Token API ว่า garbage factory
- ตามการออกแบบ API
Decoder.Tokenค่ารูปธรรมที่ allocate ให้แต่ละโทเค็นจะ escape ไปอยู่บน heap - จำนวนการ allocate ถูกผูกกับจำนวนโทเค็นของอินพุต
- ใน benchmark โทเค็น
"hello"เพียงตัวเดียวencoding/jsonแสดงผล 355ns/op, 19.7MB/s, 37.0B/op, 3.00 allocs/op - การออกแบบ API เป็นตัวกำหนดการ allocate และการ allocate อาจส่งผลโดยตรงต่อประสิทธิภาพ
โทเค็น []byte และข้อมูลชนิดแบบโดยนัย
- โทเค็น JSON รู้ชนิดได้จากตัวอักษรแรกเท่านั้น
{,}: จุดเริ่มและจุดจบของอ็อบเจกต์[,]: จุดเริ่มและจุดจบของ arrayt: truef: falsen: null": สตริง-,0~9: ตัวเลข
- API
Decoder.NextTokenของpkg/jsonไม่แปลง[]byteอินพุตเป็นค่า Go แต่คืนไบต์ที่แทนโทเค็นเป็น subslice จากอินพุตโดยตรง - ไบต์แรกของ
[]byteที่คืนมาบอกชนิดของโทเค็น - API นี้มีข้อจำกัด
- เอาต์พุตไม่ใช่สำเนา แต่เป็น subslice ของอินพุต จึงมีข้อจำกัดเรื่องอายุการใช้งาน
- ลักษณะนี้คล้ายกับ API ของ
bufio.Scanner - หากต้องการจัดการชนิดโทเค็นหรือค่าสตริง/ตัวเลขจริงให้สะดวกขึ้น ต้องมี abstraction ระดับสูงกว่า
การอ่านอย่างมีประสิทธิภาพ: byteReader
- วิธีดั้งเดิมแบบ
io.Reader.Readคัดลอก ข้อมูลจาก reader ไปยังบัฟเฟอร์ และการคัดลอกนี้เองก็มีต้นทุน io.Reader.Readปล่อยให้ผู้เรียกจัดการบัฟเฟอร์- หากอ่านทีละไบต์ อาจต้องมีพื้นที่เก็บไบต์ที่ผ่านมา หรือพื้นที่สำหรับย้อนกลับ
- วิธีอ่านใส่บัฟเฟอร์ขนาดใหญ่แล้วหาจุดเริ่มและจุดจบของโทเค็น จะต้องมีงานจัดการจำนวนมาก การคัดลอก และการขยายบัฟเฟอร์เมื่อจุดจบของโทเค็นไม่ได้อยู่ในบัฟเฟอร์
- ทางเลือกคือใช้
byteReaderซึ่งได้รับแรงบันดาลใจจาก iopipe ของ Steven Schveighoffer และแนวคิดของ Phil Pearl byteReaderให้ sliding window บนio.Readerและคล้ายกับbufio.Readerแต่มี API ที่มีประสิทธิภาพกว่าwindow()คืนหน้าต่างข้อมูลปัจจุบันที่ยังไม่ได้อ่านrelease(n)ทิ้ง n ไบต์ด้านหน้าของหน้าต่างextend()อ่านข้อมูลเพิ่มจาก reader ชั้นล่างเพื่อขยายหน้าต่าง
- benchmark การค้นหา whitespace เป็น baseline ที่เยี่ยมชมแต่ละอักขระเพื่อตรวจว่าเป็น whitespace หรือไม่ และแสดงผลประมาณ 2.04~2.07GB/s ในหลายอินพุต
- โค้ดตัวอย่างตัวนับ whitespace อยู่ที่ github.com/davecheney/whitespace
การปรับแต่ง scanner
Scanner.Nextข้าม whitespace ระหว่างกลาง ตรวจชนิดโทเค็นจากอักขระตัวแรกของหน้าต่าง แล้วอ่านจนถึงจุดจบของโทเค็น- ประสิทธิภาพช่วงต้นของ
Scanner.Nextอยู่ที่ประมาณ 1/4~2/5 ของ baseline whitespace- เช่น
Scanner/canada510MB/s,citm_catalog677MB/s,sample837MB/s
- เช่น
- การปรับแต่งแรกคือเปลี่ยนการอัปเดตฟิลด์
s.offsetไปใช้ตัวแปร localoffsets.offsetเป็น 0 ตอนเข้าและออกจากฟังก์ชัน ดังนั้นการเปลี่ยนแปลงภายในจึงไม่ปรากฏต่อภายนอก- การใช้ตัวแปร local ทำให้คอมไพเลอร์หลีกเลี่ยงการเขียนหน่วยความจำชั่วคราวได้
citm_catalogลดจาก 2.52ms เป็น 1.80ms หรือลดลง 28.46%,sampleลดจาก 828µs เป็น 528µs หรือลดลง 36.24%
- เหตุผลที่ผลลัพธ์ต่างกันตามอินพุตคือ จำนวน whitespace แตกต่างกัน
canadaมี whitespace เพียง 33 ตัวcitmมี whitespace 1,227,563 ตัว
- การปรับแต่งที่สองคือ inline ด้วยมือ
Scanner.tokenเข้าไปในScanner.Next- คอมไพเลอร์ Go ไม่สามารถ inline
Scanner.token,parseString,parseNumber,Scanner.Nextฯลฯ โดยอัตโนมัติได้ เนื่องจากforloop และความซับซ้อนของฟังก์ชัน Scanner.NextและScanner.tokenถูกเรียกต่อหนึ่งโทเค็นอินพุต จึงเกิดต้นทุนการเรียกฟังก์ชันสองครั้งต่อโทเค็น
- คอมไพเลอร์ Go ไม่สามารถ inline
- หลัง inline ด้วยมือ throughput ดีขึ้น 9~24%
canadaเพิ่มจาก 512MB/s เป็น 642MB/s หรือเพิ่มขึ้น 24.50%citm_catalogเพิ่มจาก 960MB/s เป็น 1105MB/s หรือเพิ่มขึ้น 15.16%sampleเพิ่มจาก 1.33GB/s เป็น 1.46GB/s หรือเพิ่มขึ้น 9.11%
- ผลของการปรับแต่งสรุปได้สองข้อ
- ลดการอัปเดต
s.offsetจาก 1 ครั้งต่อไบต์เป็น 1 ครั้งต่อโทเค็น - การหลีกเลี่ยงการเรียกฟังก์ชันใน hot path อาจช่วยปรับปรุงประสิทธิภาพได้
- ลดการอัปเดต
การตรวจสอบความถูกต้องและ Decoder.NextToken
- scanner เพียงอย่างเดียวสามารถแบ่งโทเค็นได้ แต่การประมวลผล JSON ที่สมบูรณ์ต้องมี การตรวจสอบสถานะ
- JSON เป็น state machine และโทเค็นถัดไปที่มาได้จะถูกจำกัดตามโทเค็นปัจจุบัน
- ตัวอย่างเช่น หลังจากอ่าน
{,"username"แล้ว มีเพียง:เท่านั้นที่ถูกต้อง
- ตัวอย่างเช่น หลังจากอ่าน
Decoder.NextTokenเพิ่ม state logic บนScanner.Nextเพื่อตรวจสอบว่าลำดับโทเค็นถูกต้องหรือไม่- สถานะแบ่งเป็น value, object key string, object colon, object value, object comma, array value, array comma, end state เป็นต้น
- แม้ในการตรวจสอบความถูกต้องรุ่นแรก
pkg/jsonก็แสดงผลเร็วกว่าencoding/json8~10 เท่าcanada:pkg/json399MB/s,encoding/json34.6MB/scitm_catalog:pkg/json713MB/s,encoding/json87.1MB/ssample:pkg/json1.23GB/s,encoding/json216MB/s
การปรับแต่ง state transition
- แกนกลางของ
Decoder.NextTokenคือคำสั่งswitch switchทั่วไปอาจถูก implement เหมือนชุดคำสั่งifต่อเนื่อง ทำให้ branch ยาว ๆ แบ่ง instruction stream และเพิ่มภาระให้ branch predictor ของ CPU- มีวิธีใช้ตารางเพื่อหาเมธอดสถานะจากค่าสถานะ แต่ตัวอย่าง implementation คอมไพล์ไม่ผ่านเพราะมีลูป initialization
- แทนที่จะทำเช่นนั้น ใช้ method expression ของ Go เพื่อเก็บเมธอดไว้ใน
d.stateโดยตรง แทนการเก็บค่าสถานะแบบ enumDecoder.NextTokenเรียกเมธอดสถานะปัจจุบันโดยตรง เช่นreturn d.state(d, tok)
- วิธี computed goto นี้เพียงอย่างเดียวไม่ได้ปรับปรุงประสิทธิภาพมากนัก
- บางอินพุตแทบไม่เปลี่ยน และใน
twitter,code,exampleช้าลงเล็กน้อย sampleเร็วขึ้น 1.15%
- บางอินพุตแทบไม่เปลี่ยน และใน
- การเปลี่ยนแปลงนี้ทำให้การปรับแต่งถัดไป คือ outlining เป็นไปได้
Outlining และการกำจัด bounds check
- หลัง outlining แล้ว
Decoder.NextTokenทำเพียงreturn d.state(d)และเมธอดสถานะแต่ละตัวเรียกd.scanner.Next()โดยตรง - เนื่องจากไม่ส่ง
tokเป็นอาร์กิวเมนต์ให้เมธอดสถานะ จึงลด call stack ได้ 3 words - เมื่อการตรวจ
len(tok) < 1และswitch tok[0]อยู่ในฟังก์ชันเดียวกัน จึงสามารถ กำจัด bounds check ได้- ก่อนหน้านี้การตรวจ
len(tok)อยู่ในDecoder.NextTokenและเมธอดสถานะถูกเรียกผ่าน method expression จึงไม่ถูก inline - ดังนั้น
tok[0]ในเมธอดสถานะจึงยังต้องมี bounds check - เมื่อทำการตรวจความยาวในฟังก์ชันเดียวกัน คอมไพเลอร์สามารถพิสูจน์ได้ว่า
tokมีความยาวอย่างน้อย 1
- ก่อนหน้านี้การตรวจ
Decoder.NextTokenเองก็เรียบง่ายขึ้นจน inline ได้- ผู้เรียกเห็นแทบจะเป็นการเรียกเมธอดสถานะปัจจุบันโดยตรง แทน
dec.NextToken() - ต้นทุนการเรียกฟังก์ชันจึงถูกกำจัด
- ผู้เรียกเห็นแทบจะเป็นการเรียกเมธอดสถานะปัจจุบันโดยตรง แทน
ผล benchmark สุดท้าย
pkg/json.Scannerชั้นล่างสุดทำ streaming tokenization แบบไม่ allocate ได้เมื่อมีบัฟเฟอร์ไม่กี่ KB ให้canada: 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.Tokenเร็วกว่าencoding/json.Decoder.Token2~3 เท่าcanada: 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.NextTokenallocate น้อยกว่ามากและเร็วกว่า 8~10 เท่าcanada: 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
- ที่ API ระดับสูงสุด
pkg/jsonสามารถ unmarshal เป็นอ็อบเจกต์ Go ได้ในลักษณะเดียวกับencoding/jsoncanada: 82.08MB/s vs 58.70MB/scitm_catalog: 215.66MB/s vs 104.00MB/ssample: 615.99MB/s vs 128.04MB/s
- ลิงก์การบรรยายอยู่ที่ dave.cheney.net/paste/gophercon-sg-2023.html, โค้ดอยู่ที่ github.com/pkg/json
ประเด็นที่ได้จากการออกแบบ
-
การ allocate ส่งผลต่อประสิทธิภาพ
- แม้ GC จะ allocate ได้เร็วและเก็บกวาดได้มีประสิทธิภาพ แต่การไม่ allocate ย่อมเร็วกว่าเสมอ
- การออกแบบ API สามารถทำให้ไม่ต้อง allocate ได้
- ความเร็วที่เพิ่มขึ้นส่วนใหญ่ของแพ็กเกจนี้มาจากการลดการ allocate
- เวลาที่ไม่ถูกใช้ไปกับเส้นทาง heap allocation และรอบ GC ถูกนำไปใช้กับการ scan
- API
encoding/json.Decoderต้อง allocate เพราะคืนค่า primitive เป็นinterface{} - ค่า escape ไปอยู่บน heap และโดยพฤตินัยกลายเป็น pointer ไปยังค่านั้น
- ในการประมวลผลข้อมูล การ allocate อาจเป็นต้นทุนประสิทธิภาพที่ใหญ่ที่สุดของอัลกอริทึม
- การลดต้นทุนต่อไบต์และต่อโทเค็นอย่างระมัดระวังเป็นปัจจัยใหญ่อันดับสองของการปรับปรุงประสิทธิภาพ
- ทิศทางสำคัญคือเปลี่ยนการเรียกฟังก์ชันต่อไบต์ให้เป็นการเรียกฟังก์ชันต่อโทเค็น
- เริ่มจากสมมติฐานว่า
encoding/jsonอาจช้ากว่าเพราะ API และหากยอมรับ API แบบอื่นได้ ก็สามารถได้ประสิทธิภาพเพิ่มขึ้น 2~3 เท่าในบางเส้นทาง unmarshal และ 8~10 เท่าในการ tokenize
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ดูดีทีเดียว ตลอดเส้นทางอาชีพ ผมทำ JSON parser มามากเกินไปแล้ว แต่การมีเอกสารอ้างอิงที่แสดงทีละขั้นว่าควรออกแบบ JSON parser ที่สมเหตุสมผลและรวดเร็วอย่างไรนี่ดีมากจริง ๆ
อย่างไรก็ดี JSON ไม่จำเป็นต้องมี tokenizer แยกชัดเจน แนวคิดเรื่อง token สามารถตัดออก แล้วรวมการ parse กับการ tokenize เข้าด้วยกันทั้งหมดได้ ปกติก็ทำกันแบบนี้ และโดยรวมจะเรียบง่ายกว่า
ในภาษาอย่าง ECMAScript จะยากกว่ามาก เพราะมีกรณีที่ตอนแรกดูเหมือนเป็น subset ของไวยากรณ์นิพจน์ในวงเล็บ เช่น arrow function แล้วค่อยตัดสินได้เมื่อเห็น
=>ทำให้อาจต้องมองล่วงหน้าได้ยาวแบบไม่จำกัดเป็นบทความที่อ่านตามได้ดี และวาง flow ชัดว่าถ้าจะ implement เองควรทำแบบนี้
ถ้าใน production ต้องการ performance ล้วน ๆ ก็น่าดู https://github.com/simdjson/simdjson ของ Daniel Lemire ด้วย มี Go port ของ MinIO ที่ https://github.com/minio/simdjson-go เช่นกัน
สิ่งที่ได้เรียนรู้จากการทำ JSON parser ที่เร็ว ๆ คือมันปนรายละเอียดเฉพาะของแต่ละภาษาอยู่มาก แต่ถ้าสรุปให้ทั่วไปคือแบบนี้
ในขั้น tokenize ควรหลีกเลี่ยง heap allocation tokenizer ควรคืน struct ที่ allocate บน stack หรือทำเป็นฟังก์ชันที่คืน token แบบ
int64ซึ่ง pack ตำแหน่งเริ่มต้น ความยาว และ type offset ของ token ไว้ในขั้น parse ก็ควรหลีกเลี่ยง heap allocation เช่นกัน และอาจรองรับ interface อย่าง
getString(key String)สำหรับ client ที่ต้องการ slice จาก buffer มาใช้ตอน deserialize เป็น object ที่รู้ field ได้ตั้งแต่ compile time โดยปกติจะ generate
switchตามความยาวของ key ก่อนจะเปรียบเทียบค่า stringใน data pipeline ที่ประมวลผล JSON จำนวนมาก แค่เลือก JSON library ก็ทำให้ performance ต่างกันได้ 3~10 เท่า และ parser หลัก ๆ ส่วนใหญ่มักพยายาม allocate object
ถ้า class ที่จะ serialize/deserialize รู้ได้ตั้งแต่ compile time Java Jackson ก็ทำได้ค่อนข้างดี แต่ด้วยการเขียนโค้ดอย่างระมัดระวังและ profiling ก็สามารถรีดเพิ่มได้อีกราว 2 เท่า
ในทางกลับกัน ถ้าต้องประมวลผล JSON ใด ๆ parser กระแสหลักมักจะ allocate เยอะ ดังนั้น parser ที่เขียนเองและ intrusive กว่าสามารถหลีกเลี่ยงสิ่งนี้ได้ และเมื่อประมวลผลวัตถุตั้งแต่หลายพันถึงหลายล้านรายการต่อวินาที ผลได้ด้าน performance จะใหญ่มาก
ผมทำ GraphQL tokenizer และ parser ด้วยแนวทางคล้ายกัน และอันนี้ก็ไม่มี memory allocation แถมเร็วพอสมควร ถ้าสนใจโค้ดดูได้ที่ https://github.com/wundergraph/graphql-go-tools
เคยพูดในหัวข้อนี้ด้วย แต่น่าเสียดายที่ไม่ได้อัดวิดีโอไว้ พยายามรีด Go ให้ได้มากที่สุดจนแทบจะเสียสติ :D
หรือมันมีผลกระทบในทางอื่นด้วยหรือไม่
ใน n2[1] ต้องการ tokenizer ที่เร็ว และเจอปัญหา ตัวสร้างขยะ แบบเดียวกัน โดยพื้นฐานคือปัญหาที่มีชุด token คงที่อย่าง
json.Delimปนกับ string ที่ก่อให้เกิด allocationวิธีแก้ที่ผมคิดว่าค่อนข้างดีคือทำ tokenizer ให้เป็น generic สำหรับ
Tใด ๆ แล้วรับฟังก์ชันที่แปลง byte slice เป็นTจากนั้นใช้Tแทน stringแบบนี้ caller สามารถส่ง representation ที่มีประสิทธิภาพกว่า เช่น representation ที่ allocation น้อยกว่า เข้ามาได้ และในขณะเดียวกันในการ unit test ก็ใช้ identity function เพื่อทดสอบ tokenizer ได้สะดวก
ในความหมายหนึ่ง มันคล้ายกับการ fuse tokenizer กับ parser ตอน build time แต่ด้วย generic ทำให้ tokenizer ไม่ต้องรู้ representation ของ parser ก็ยังรักษาการแบ่งชั้นไว้ได้
[1] https://github.com/evmar/n2
การปรับปรุงเหนือ standard library ด้วย การออกแบบ API ที่ดีกว่านั้นเป็นไปได้ แต่การทำ parser แบบ streaming เต็มรูปแบบโดยไม่เติม struct ไปครึ่งหนึ่งแล้วออกกลางคันก่อนจะพบ error นั้นแทบจะยากในทางปฏิบัติ ดูเหมือน standard library กำหนดเรื่องนี้ไว้เป็นข้อจำกัดเชิงออกแบบอย่างชัดเจนด้วย
ผมอาจพลาดอะไรไป แต่ผู้เขียนพูดซ้ำ ๆ ว่าสร้าง parser แบบ “streaming” โดยไม่ได้อธิบายว่ามันหมายถึงอะไรจริง ๆ
โดยเฉพาะในส่วน “hash table” ไม่มีคำอธิบายว่าจัดการ key ซ้ำอย่างไร อยากรู้ว่าถ้าเจอ key ซ้ำจะเรียก sink code สองครั้ง หรือรอจนอ่าน “hash table” ทั้งหมดเสร็จก่อนแล้วค่อยเรียก sink code
ในความเห็นผม JSON เป็นโครงสร้างแบบลำดับชั้น ไม่รู้ความยาวล่วงหน้า และที่สำคัญที่สุดคือมี key ซ้ำ จึงไม่เหมาะกับ streaming โดยธรรมชาติ
อาจทำ subset บางส่วนของ JSON ให้เป็นมิตรกับ streaming มากขึ้นได้ แต่ถ้าจะทำอย่างนั้นก็ไม่มีเหตุผลต้องไปแก้ JSON ถ้าทางแก้คือการเปลี่ยน JSON ผมคิดว่าใช้ format อื่นที่ไม่ใช่ JSON ไปเลยดีกว่า
ดีใจที่มีการกล่าวถึง Phil Pearl
https://github.com/bytedance/sonic ก็น่าดูเช่นกัน
น่าแปลกที่ไม่มีวิธีบอกว่า “ให้ inline ฟังก์ชันนี้จริง ๆ” สำหรับฟังก์ชันที่ใหญ่เกินไปจนไม่ได้ inline
งานพื้นฐานอย่างการนับ/ค้นหา whitespace น่าจะเร็วขึ้นมากถ้า vectorize ด้วย SIMD แต่ก็เข้าใจว่าอยู่นอกขอบเขตของผู้เขียน
ประโยคที่ว่า “การคาดหวังว่าจะเก็บ input ทั้งหมดไว้ใน memory ได้เป็นเรื่องไม่สมจริง” นั้นผิดสำหรับแอปพลิเคชันส่วนใหญ่
อาจบอกได้ว่า Go ไม่ใช่เครื่องมือที่เหมาะกับงานนั้น แต่ถ้า optimize แบบนี้ ผมก็ไม่เห็นเหตุผลว่าทำไมจะทำไม่ได้
ผมเคยยัดข้อมูล JSON ระดับกิกะไบต์เข้าไป ดังนั้น streaming parser จึงน่าขอบคุณมาก แถมการรองรับ streaming ยังเป็นสัญญาณว่าผู้เขียนรู้ use case หลายแบบและทำ engineering ได้ดีกว่า
memory นั้นถูกและเกือบฟรีเฉพาะในทฤษฎี แต่ในโลกจริงไม่ใช่แบบนั้น
mmap()ก็เป็นตัวเลือกได้ไม่ใช่หรือ กรณีที่ต้องการ streaming จริง ๆ เช่นต้องประมวลผลข้อมูลช่วงต้นให้เร็วจากไฟล์ JSON เดี่ยว เหมือน stream ของธุรกรรมหรืองาน นั่นเป็นอีกเรื่อง