ปรับปรุงการประมวลผลสตริงของ serde_json ให้เร็วขึ้น 20%
(purplesyringa.moe)- serde_json ซึ่งถูกใช้งานเสมือนเป็นตัวแยกวิเคราะห์ JSON พื้นฐานของ ecosystem Rust สามารถเพิ่มประสิทธิภาพได้ 10%, 23%, 32% ในเบนช์มาร์กที่มีสตริงจำนวนมาก ด้วยการปรับแต่งภายในเพียงเล็กน้อย
- การคำนวณตำแหน่งของข้อผิดพลาดถูกเปลี่ยนจากการไล่สแกนส่วนต้นของสตริงโดยตรง มาเป็นการใช้ memchr เพื่อหาจำนวนบรรทัดและตำแหน่งขึ้นบรรทัดใหม่ล่าสุด ทำให้ช่องว่างด้านความเร็วของเส้นทาง error ซึ่งเคยช้ากว่าเส้นทางสำเร็จมากกว่า 2 เท่าลดลง
- ลูปหลักของการ parse สตริงเปลี่ยนจากการหา
\,"และอักขระควบคุมแยกกัน มาเป็นการตรวจพร้อมกันในครั้งเดียวด้วย SWAR ทำให้ได้ latency ต่ำกับสตริงสั้นแม้ไม่มี SIMD จริง - การถอดรหัส escape แบบ
\uลดต้นทุนของ branch และ shift ด้วย LUT, จำนวนเต็ม signed และตาราง shift ที่เตรียมไว้ล่วงหน้า ทำให้การ parse JSON ของ War and Peace ฉบับภาษารัสเซียเร็วขึ้นจาก 284 MB/s เป็น 344 MB/s - หลังจากนั้นคอขวดที่ย้ายไปอยู่ที่การเข้ารหัส UTF-8 ก็ถูกแก้ด้วย การสร้าง UTF-8 แบบแมนนวล เพื่อหลีกเลี่ยงต้นทุนการ initialize และคัดลอกของ
char::encode_utf8และVec::extend_from_sliceทำให้เบนช์มาร์กเดียวกันขึ้นไปถึง 374 MB/s
ทำไมการปรับแต่ง serde_json ถึงให้ผลมาก
serdeคือเฟรมเวิร์ก serialization/deserialization หลักของ Rust และserde_jsonคือชุดทางการสำหรับจัดการ JSON ที่ถูกใช้อย่างแพร่หลาย- ณ เวลาที่เผยแพร่
serde_jsonมี reverse dependency 26,916 รายการ ขณะที่simd-jsonมี 66 รายการ - เมื่อมีการใช้งานในระดับนี้ แม้การปรับปรุงเล็กน้อยภายใน
serde_jsonก็สามารถสะสมผลกระทบต่อ ecosystem Rust ได้ทั้งระบบ - ในสถานการณ์ที่ผู้ใช้จำนวนมากไม่สามารถเปลี่ยนไปใช้ JSON parser ตัวอื่นได้ง่าย การ optimize แบบความเสี่ยงต่ำภายในไลบรารีเดิมจึงใช้งานได้จริง
การคำนวณตำแหน่งข้อผิดพลาด: จากลูปธรรมดาไปสู่ memchr
- ในเบนช์มาร์กเส้นทาง error,
serde_jsonช้ากว่าเส้นทางสำเร็จของข้อมูลชุดเดียวกันมากกว่า 2 เท่า- ในชุดข้อมูล
canada,citm_catalog,twitterเส้นทาง error ช้ากว่าเส้นทางสำเร็จ -48% ถึง -77%
- ในชุดข้อมูล
- คอขวดคือฟังก์ชัน
position_of_index()ที่แปลง index เป็น line/column เพื่อจัดรูปแบบข้อความข้อผิดพลาด- implementation เดิมจะวนผ่าน
self.slice[..i]ทีละไบต์ และอัปเดต line กับ column ทุกครั้งที่เจอ\n
- implementation เดิมจะวนผ่าน
- เมื่อลองแยกการคำนวณเป็นสองขั้น ก็สามารถใช้
memchrได้- นับจำนวน
\nในself.slice[..i]เพื่อหาเลขบรรทัดแบบเริ่มจาก 0 - หาตำแหน่ง
\nตัวสุดท้าย แล้วนำมาหักออกจากiเพื่อหาหมายเลขคอลัมน์
- นับจำนวน
- memchr มี implementation ที่ optimize สำหรับการค้นหาและนับอักขระเดี่ยว และภายในใช้ SIMD
- หลัง PR #1160 ถูกรวมเข้ามา ประสิทธิภาพของเส้นทาง error ดีขึ้นอย่างมาก
- เส้นทาง error ของ
canadaแบบ DOM เพิ่มจาก 122 MB/s เป็น 216 MB/s citm_catalogแบบ struct เพิ่มจาก 195 MB/s เป็น 736 MB/s- แม้เส้นทาง error ยังช้ากว่าเส้นทางสำเร็จ แต่ช่องว่างลดลง
- เส้นทาง error ของ
การค้นหา string escape: หา 3 เงื่อนไขพร้อมกันในครั้งเดียว
- ลูปหลักเดิมของการ parse สตริงจะดูตาราง
ESCAPEแล้วข้ามไบต์ที่ไม่ใช่ escape ไป- escape ที่ต้องจัดการในสตริง JSON คือ
\,"และอักขระควบคุม ASCII ที่มีค่าไม่เกิน0x1F - ตามสเปก JSON อักขระควบคุมภายในสตริงไม่ถูกอนุญาต
- escape ที่ต้องจัดการในสตริง JSON คือ
- ความพยายามครั้งแรกใช้
memchr2เพื่อหา\หรือ"ก่อน แล้วค่อยตรวจอักขระควบคุมแยกต่างหาก- ทำให้ต้องวนอ่านสตริง สองรอบ คือรอบเร็วหนึ่งครั้งและรอบช้าอีกหนึ่งครั้ง จึงช้ากว่าเดิม
- การ vectorize แค่ครึ่งหนึ่งแล้วปล่อยที่เหลือเป็น scalar ไม่ได้นำไปสู่การปรับปรุงโดยรวม
- ความพยายามครั้งที่สองใช้
memchr2ร่วมกับ SIMD แบบเขียนเองเพื่อตรวจว่าไม่มีอักขระควบคุม- สำหรับสตริงสั้น ต้นทุนของการเรียกฟังก์ชันเลือก implementation ตอนรันไทม์สูงเกินไป
- สำหรับสตริงยาว ก็ยังมีปัญหาอ่านหน่วยความจำซ้ำสองครั้ง
- implementation สุดท้ายจึงจัดรูปใหม่ให้หา
\,"และอักขระควบคุมได้ใน pass เดียว
ใช้ SWAR เพื่อทำงานใกล้เคียง SIMD
- เพื่อหลีกเลี่ยงการต้องมีโค้ด SIMD แยกตามแพลตฟอร์ม จึงใช้เทคนิค SWAR(SIMD Within A Register)
- แทนที่จะใช้ SIMD 128 บิต ก็ปฏิบัติกับ word 64 บิตเสมือนเป็น 8 ไบต์
- ใช้การคำนวณระดับบิตเพื่อตรวจพร้อมกันว่าแต่ละไบต์ตรงเงื่อนไขหรือไม่
- การตรวจอักขระควบคุมแปลงเงื่อนไข
c >= 0 && c < 0x20ให้อยู่ในรูปการคำนวณบิต- สำหรับกลุ่ม 8 ไบต์ จะตรวจด้วย mask รูปแบบ
!c & (c - 0x2020202020202020) & 0x8080808080808080 - แม้ borrow จากการลบ 64 บิตอาจแพร่ไปยังไบต์ที่สูงกว่า แต่ไม่เป็นปัญหาสำหรับเป้าหมายที่ต้องการหาอักขระควบคุมตัวแรกจากตำแหน่งต่ำสุด
- สำหรับกลุ่ม 8 ไบต์ จะตรวจด้วย mask รูปแบบ
\และ"ก็ตรวจด้วยแนวทางคล้ายกันและรวมเป็นนิพจน์เดียว- นิพจน์ทั้งหมดประกอบด้วยการคำนวณบิต 9 ขั้น
- ถ้าเป็น SIMD บน x86 จะใช้เพียง 7 ขั้น จึงมี throughput สูงกว่า แต่สำหรับสตริงสั้น latency สำคัญกว่า
- ใน json-benchmark โค้ด SWAR นี้มีประสิทธิภาพดีกว่าโค้ด SIMD จริง
- สำหรับสตริงที่สั้นมาก SWAR อาจช้ากว่าโค้ด scalar
- พบ regression แถวสตริงยาวประมาณ 5 อักขระ
- เพื่อป้องกันแพตเทิร์นที่พบบ่อยอย่างสตริงว่าง
""และ escape ต่อเนื่อง\r\n,\uD801\uDC37จึงมีการตรวจว่าตัวแรกเป็น escape หรือไม่ก่อนเข้าลูป SWAR
- ผลของการ optimize การค้นหาสตริงแตกต่างกันไปตามชุดข้อมูล
twitterแบบ struct เพิ่มจาก 638 MB/s เป็น 785 MB/s หรือ +23%twitterแบบ DOM เพิ่มจาก 305 MB/s เป็น 335 MB/s หรือ +10%citm_catalogแบบ struct เพิ่มจาก 865 MB/s เป็น 905 MB/s หรือ +5%- สตริงว่างยังคงช้าลงไม่เกิน 2% ในบางไมโครบेंช์มาร์ก
การ optimize การถอดรหัส Unicode escape
serde_jsonรองรับทั้งสตริง Unicode แบบดิบและ escape แบบ\u- เช่น
"🥺"และ"\ud83e\udd7a"
- เช่น
- การถอดรหัสเลขฐานสิบหกแบบเดิมใช้ LUT 256 ช่องเพื่อแมปอักขระแต่ละตัวให้เป็นค่า 0~15
- รองรับ
'0'..='9','A'..='F','a'..='f' - อักขระไม่ถูกต้องจะถูกทำเครื่องหมายด้วย sentinel value
- รองรับ
- เนื่องจาก escape แบบ
\uต้องอ่านเลขฐานสิบหก 4 หลัก วิธีเดิมจึงมีทั้ง shift, add, compare และ conditional branch ในแต่ละรอบ - implementation ที่ปรับปรุงแล้วจะไม่คืน error ในทุกครั้งระหว่างวนลูป แต่ประมวลผลครบทั้ง 4 หลักก่อนแล้วค่อยตรวจความถูกต้อง
- implementation สุดท้ายใช้ LUT แบบ
[i8; 256]และกำหนดให้ digit ที่ไม่ถูกต้องเป็น-1จากนั้นคำนวณด้วยจำนวนเต็ม 32 บิต- ถ้าสำเร็จผลลัพธ์จะไม่ติดลบ และถ้าล้มเหลวผลลัพธ์จะติดลบ
- บน x86 การโหลดจากหน่วยความจำและ sign extension อาจรวมกันเป็น
movsxได้
- เพื่อลด latency ของ shift จึงใช้ตารางสองชุด
HEX0: ค่าเดิมHEX1: ตารางที่เลื่อนซ้าย 4 บิตไว้ล่วงหน้า
- จากนั้นจัดรูปเป็น
decode_four_hex_digits()ที่ unroll ลูปอย่างชัดเจน - การ optimize นี้ทำให้การ parse War and Peace ภาษารัสเซียในรูปแบบ JSON-encoded เร็วขึ้นจาก 284 MB/s เป็น 344 MB/s หรือ 21%
คอขวดที่ UTF-8 encoding และการสร้างแบบแมนนวล
- หลังจาก optimize การถอดรหัส Unicode escape แล้ว คอขวดได้ย้ายไปอยู่ที่ การเข้ารหัส UTF-8
- UTF-8 เข้ารหัส codepoint เป็น 1~4 ไบต์ตามความยาว
- 1 ไบต์:
0xxxxxxx - 2 ไบต์:
110xxxxx 10xxxxxx - 3 ไบต์:
1110xxxx 10xxxxxx 10xxxxxx - 4 ไบต์:
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
- 1 ไบต์:
char::encode_utf8ใน Rust standard library เขียนลงบัฟเฟอร์&mut [u8]ดังนั้นบัฟเฟอร์ต้องมีค่าu8ที่ถูกต้องอยู่แล้ว- จึงไม่สามารถเขียนลงบัฟเฟอร์ที่ยังไม่ได้ initialize ได้โดยตรง
- เนื่องจาก UTF-8 เป็น encoding ความยาวแปรผัน LLVM จึงลบ zeroization ที่ไม่จำเป็นออกได้ยาก
serde_jsonเดิมใช้วิธีscratch.extend_from_slice(c.encode_utf8(&mut [0u8; 4]).as_bytes())- การใช้บัฟเฟอร์ภายในแบบ
[0u8; 4]ในทางทฤษฎีอาจช่วย alias analysis ได้ - แต่ในทางปฏิบัติ LLVM ยังคงสร้างการเรียก
memcpyเพราะต้องคัดลอกข้อมูล 1~4 ไบต์ที่มีความยาวแปรผัน
- การใช้บัฟเฟอร์ภายในแบบ
- เพื่อหลีกเลี่ยงการเรียก
memsetและmemcpyจึงสร้าง UTF-8 โดยตรง- ตัวอัลกอริทึมเองค่อนข้างเรียบง่าย แต่จำเป็นต้องใช้ unsafe
- เมื่อรวมกับการเปลี่ยนแปลงเล็กน้อยอื่น ๆ เบนช์มาร์ก War and Peace ดีขึ้นเพิ่มจาก 344 MB/s เป็น 374 MB/s หรืออีก +9%
ผลลัพธ์สุดท้าย
- ในเบนช์มาร์ก JSON ที่มีสตริงจำนวนมาก ประสิทธิภาพของ
serde_jsonดีขึ้น 10%, 23%, 32% ตามลำดับ - เนื่องจากข้อมูล JSON จำนวนมากมีสตริงเป็นสัดส่วนสูง การ optimize นี้จึงอาจส่งผลต่อโค้ด Rust จำนวนมากที่ใช้
serde_json - การปรับปรุงเกิดจากการกำจัดคอขวดหลายจุดต่อเนื่องกัน เช่น การคำนวณตำแหน่งข้อผิดพลาด การค้นหา string escape การถอดรหัส Unicode escape และการเข้ารหัส UTF-8
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
ทริก UTF-8 ทำให้ค่อนข้างไม่สบายใจ เพราะเคยเห็นการโจมตีแบบทำให้ parser สับสนมาเยอะเกินไป
ผมใช้ serde เพราะ ความถูกต้อง มากกว่าความเร็ว เลยหวังว่าจะได้ fuzz จนสุดทางด้วยการใส่สตริง UTF-8 ที่ผิดรูปแบบเข้าไปเป็นจำนวนมาก
ไม่ได้หมายความว่าจะไม่มีบั๊ก แต่ internal state ของ parser ไม่ควรใหญ่ และดูเหมือนว่าจะทำ exhaustive test ได้ด้วย
เท่มากที่ serde ขยับเร็วขนาดนี้
เมื่อกี้เพิ่งดู simdutf8 แล้วพบว่า PR สำหรับ การ parse UTF-8 ที่รองรับ SIMD ค้างมาเกือบ 5 ปีแล้ว
https://github.com/rust-lang/rust/issues/68455
อ่านแล้วสนุกดี เพราะบล็อกของคนนี้ให้ ฟีล jart แรงมาก
ตรงที่ว่า “คงต้องประดิษฐ์ล้อขึ้นมาใหม่ แต่พอคิดดูแล้วมันก็เจ๋งทีเดียว” นี่ไม่แน่ใจว่าพูดจริงหรือประชด
อ่านแล้วเริ่มหัวเราะเยาะคนเขียน แต่หน้าที่เหลือดูเหมือนจะ self-deprecating ค่อนข้างหนัก
“การสอน วิธีคิด สำคัญพอ ๆ กับการสอนเขียนโค้ด แต่แทบไม่ค่อยเกิดขึ้น” งั้นเหรอ
รู้สึกได้ถึงความโอหังที่คิดว่าอีกฝ่ายไม่คิด
ไม่ได้สมมติว่าคนอื่นไม่คิด แต่เป็นการสังเกตมากกว่าว่า บทความบล็อกและบทความวิธีใช้ส่วนใหญ่แสดงแค่ผลลัพธ์สุดท้าย แต่ไม่ค่อยแสดงขั้นตอนที่พาไปถึงตรงนั้น
การสอนวิธีคิดหมายถึงต้องค้นคว้า และข้ามขั้นตอนนั้นไม่ได้ อีกทั้งการค้นคว้าก็ไม่ได้ทำให้ความรับผิดชอบในการสรุปด้วยตัวเองหายไป
การข้ามอย่างใดอย่างหนึ่งในสองอย่างนี้ทำได้ง่าย แต่เป็นวิธีที่ผิด
serde_json พอ build แบบ debug กับ release อย่างละรอบ dependency ก็กินไป 3GB แล้ว
ถ้าใช้ serde ในโปรเจกต์ที่ active อยู่ไม่กี่โปรเจกต์ พื้นที่ดิสก์ก็หมด
ไม่เข้าใจว่าการ parse JSON ทำไมต้องมี dependency 3GB และถึงจะเห็นด้วยกับการใช้โค้ดซ้ำ แต่ dependency ฝั่ง JSON ของ serde ดูค่อนข้างรก
แค่มี exploit ใน dependency ตัวใดตัวหนึ่ง Rust ecosystem ครึ่งหนึ่งก็เปราะบางแล้ว
Rust ควรมี JSON ในตัว
indexmap = { version = "2.2.3", optional = true }itoa = "1.0"memchr = { version = "2", default-features = false }ryu = "1.0"serde = { version = "1.0.194", default-features = false }มีความเป็นไปได้สูงว่าสิ่งที่วัดจริง ๆ ไม่ใช่ “dependency 3GB”
พูดเหมือนเป็นข้อเท็จจริงโดยไม่มีหลักฐาน เลยมั่นใจไม่ได้ แต่ถ้าให้เดา น่าจะใช้
#[derive(Serialize, Deserialize)]จำนวนมากจนเกิดการ generate โค้ดปริมาณมาก แล้วไปวัดขนาดทั้งไดเรกทอรีtargetการ build แบบง่าย ๆ อย่างที่คอมเมนต์อื่น ๆ แสดงออกมา อยู่ระดับไม่กี่สิบ MB
นั่นเป็นแค่วิธีดึง ความบวม เข้ามา และ standard library ก็ไม่ได้ต่างจาก crate อื่น นอกจากการรับประกันเสถียรภาพ จึงไม่มีข้อดี
สุดท้ายมีผลแค่ผูก release cycle ของไลบรารีเข้ากับ release cycle ของ compiler
จริง ๆ แล้วเมื่อก่อน
rustc-serializeก็ใกล้เคียงกับของในตัว และ Rust เคยลองแนวทางนั้นมาแล้วอีกอย่าง serde_json ในสถานะเริ่มต้นก็ไม่ได้ใหญ่
ทั้ง serde_json และ serde ไม่ได้ใหญ่ และทั้งคู่ยังคง MSRV ที่ต่ำมากซึ่ง crate อื่น ๆ รองรับได้ไม่ดี ดังนั้นโดยพื้นฐานแล้วจึงแทบมี dependency เยอะไม่ได้ด้วยซ้ำ
dependency tree ของโปรเจกต์ Rust ที่มีขนาดพอสมควรจะกลายเป็นสิ่งที่ค่อนข้างน่ากลัวอย่างรวดเร็ว และการ audit dependency ทั้งหมดนี้แทบเป็นไปไม่ได้ในทางปฏิบัติ อีกทั้งความน่าเชื่อถือของหลายตัวก็ต่ำ
หลังจากทำงานกับ Rust มาหลายปี ผมคงไม่แตะ Rust อีกจนกว่า ecosystem จะโตเต็มที่กว่านี้มาก
ความเป็นผู้ใหญ่แบบนั้นคงต้องมาจากการที่บริษัทใหญ่ ๆ adopt กันในวงกว้าง ไม่อย่างนั้นก็อาจใช้เฉพาะกับโปรเจกต์ no-std, no-deps หรือโปรเจกต์ที่เป็นตัวแทน C ล้วน ๆ
แต่ถ้า Zig เสถียรแล้ว ก็อาจเบียด Rust ออกจาก use case นี้ได้เหมือนกัน
มันใหญ่แบบประหลาดเกินไปจนเดาว่าน่าจะเป็นบั๊ก
ไม่ว่าจะ compile อะไรก็ตาม โฟลเดอร์
targetจะสะสมเป็นระดับกิกะไบต์ แต่ก็ไม่ได้เป็นตัวแทนของ artifact สุดท้ายหลังลบ debug information ออก หรืออย่างน้อยใช้การตั้งค่าที่ละเอียดน้อยลงtargetดูเหมือนจะมีอย่างน้อยหลาย GB อยู่เสมอจริง ๆ