- ตัวแยกวิเคราะห์ WASM ที่เขียนด้วย Rust นั้นเร็วในเชิงโครงสร้าง แต่ โอเวอร์เฮดจากการคัดลอกข้อมูลและการซีเรียลไลซ์ที่ขอบเขตระหว่าง JS-WASM กลับกลายเป็นคอขวดด้านประสิทธิภาพ
- การ คืนค่าอ็อบเจ็กต์โดยตรงผ่าน
serde-wasm-bindgenช้ากว่าการซีเรียลไลซ์ JSON อยู่ 9~29% เนื่องจากต้นทุนของการแปลงข้อมูลอย่างละเอียดระหว่างรันไทม์ - เมื่อนำ ไปป์ไลน์ทั้งหมดพอร์ตไปเป็น TypeScript ก็สามารถทำให้ ประสิทธิภาพต่อการเรียกหนึ่งครั้งเร็วขึ้น 2.2~4.6 เท่า บนสถาปัตยกรรมเดียวกัน
- ในการประมวลผลแบบสตรีมมิง ได้ ปรับปรุงจาก O(N²) → O(N) ด้วยการแคชระดับประโยค ทำให้ ความเร็วในการประมวลผลรวมเร็วขึ้น 2.6~3.3 เท่า
- สรุปแล้ว ยืนยันได้ว่า WASM เหมาะกับงานที่คำนวณหนักและเรียกไม่บ่อย และ ไม่เหมาะกับการแยกวิเคราะห์เป็นอ็อบเจ็กต์ JS หรือฟังก์ชันที่ถูกเรียกบ่อย
โครงสร้างและข้อจำกัดของตัวแยกวิเคราะห์ Rust WASM
- ตัวแยกวิเคราะห์
openui-langประกอบด้วยไปป์ไลน์ 6 ขั้นตอนที่ แปลง DSL ที่ LLM สร้างขึ้นให้เป็น React component tree- ขั้นตอน:
autocloser → lexer → splitter → parser → resolver → mapper → ParseResult - แต่ละขั้นตอนทำหน้าที่โทเคไนซ์ วิเคราะห์ไวยากรณ์ ตีความตัวแปร แปลง AST เป็นต้น
- ขั้นตอน:
- แม้โค้ด Rust จะเร็วในตัวเอง แต่กระบวนการ คัดลอกสตริงระหว่าง JS↔WASM, ซีเรียลไลซ์ JSON และดีซีเรียลไลซ์ เกิดขึ้นทุกครั้งที่เรียก
- คัดลอกสตริงอินพุต (JS→WASM), พาร์สภายใน Rust, ซีเรียลไลซ์ผลลัพธ์เป็น JSON, คัดลอก JSON (WASM→JS), และดีซีเรียลไลซ์ใน JS
- โอเวอร์เฮดที่ขอบเขตนี้ครอบงำประสิทธิภาพโดยรวม และความเร็วในการคำนวณของ Rust ไม่ใช่คอขวด
การลองใช้ serde-wasm-bindgen และความล้มเหลว
- เพื่อหลีกเลี่ยงการซีเรียลไลซ์ JSON จึงนำ
serde-wasm-bindgenมาใช้เพื่อ คืนค่า struct ของ Rust เป็นอ็อบเจ็กต์ JS โดยตรง - แต่กลับพบว่า ช้าลง 30%
- JS ไม่สามารถอ่านหน่วยความจำของ struct Rust ได้โดยตรง และเลย์เอาต์หน่วยความจำของแต่ละรันไทม์ต่างกัน จึงต้องมี การแปลงข้อมูลระดับฟิลด์
- ในทางกลับกัน การซีเรียลไลซ์ JSON คือการสร้างสตริงหนึ่งครั้งภายใน Rust แล้วให้ JS จัดการผ่าน
JSON.parseที่ถูกปรับแต่งมาอย่างดี
- ผลเบนช์มาร์ก
Fixture JSON round-trip serde-wasm-bindgen การเปลี่ยนแปลง simple-table 20.5µs 22.5µs -9% contact-form 61.4µs 79.4µs -29% dashboard 57.9µs 74.0µs -28%
การเปลี่ยนไปใช้ TypeScript และประสิทธิภาพที่ดีขึ้น
- พอร์ตโครงสร้าง 6 ขั้นตอนเดิม ไปเป็น TypeScript ทั้งหมด ตัดขอบเขต WASM ออก และ รันโดยตรงบน V8 heap
- ผลเบนช์มาร์กแบบต่อการเรียกหนึ่งครั้ง
Fixture TypeScript WASM เร็วขึ้น simple-table 9.3µs 20.5µs 2.2 เท่า contact-form 13.4µs 61.4µs 4.6 เท่า dashboard 19.4µs 57.9µs 3.0 เท่า - เพียงแค่เอา WASM ออกก็ลดต้นทุนต่อการเรียกได้มาก แต่ความไม่มีประสิทธิภาพของโครงสร้างสตรีมมิงยังคงอยู่
ปัญหา O(N²) ของการพาร์สแบบสตรีมมิงและการปรับปรุง
- เมื่อเอาต์พุตของ LLM ถูกส่งมาเป็นหลายชังก์ จะเกิดความไม่มีประสิทธิภาพแบบ O(N²) จากการพาร์สสตริงสะสมทั้งหมดใหม่ทุกครั้ง
- ตัวอย่าง: เอกสาร 1000 ตัวอักษร พาร์สทีละ 20 ตัวอักษร 50 ครั้ง → ต้องประมวลผลรวม 25,000 ตัวอักษร
- แนวทางแก้คือการนำ incremental caching ระดับประโยค มาใช้
- แคชประโยคที่สมบูรณ์แล้ว และพาร์สใหม่เฉพาะประโยคที่ยังดำเนินอยู่
- รวม AST ที่แคชไว้กับ AST ใหม่ก่อนคืนผลลัพธ์
- เบนช์มาร์กในระดับสตรีมทั้งหมด
Fixture TS แบบ naive TS แบบ incremental เร็วขึ้น simple-table 69µs 77µs ไม่มี contact-form 316µs 122µs 2.6 เท่า dashboard 840µs 255µs 3.3 เท่า - ยิ่งมีหลายประโยคมากเท่าไร ผลของแคชก็ยิ่งมากขึ้น และปรับปรุง throughput โดยรวมให้เป็นเชิงเส้นได้
บทเรียนจากการใช้ WASM
- กรณีที่เหมาะสม
- งานที่คำนวณหนักและมีปฏิสัมพันธ์น้อย: การประมวลผลภาพ·วิดีโอ การเข้ารหัส การจำลองฟิสิกส์ ออดิโอ codec เป็นต้น
- การพอร์ตไลบรารีเนทีฟเดิมมาใช้: SQLite, OpenCV, libpng เป็นต้น
- กรณีที่ไม่เหมาะสม
- การพาร์สข้อความที่มีโครงสร้างให้เป็นอ็อบเจ็กต์ JS: ต้นทุนการซีเรียลไลซ์ครอบงำทั้งหมด
- ฟังก์ชันที่มีอินพุตสั้นแต่ถูกเรียกบ่อย: ต้นทุนที่ขอบเขตสูงกว่าต้นทุนการคำนวณ
- บทเรียนสำคัญ
- ต้อง ทำโปรไฟล์หาตำแหน่งคอขวดก่อน แล้วค่อยเลือกภาษา
- การ ส่งอ็อบเจ็กต์โดยตรงด้วย
serde-wasm-bindgenมีต้นทุนสูงกว่า - การปรับปรุงความซับซ้อนของอัลกอริทึมมีประสิทธิภาพมากกว่าการเปลี่ยนภาษา
- WASM และ JS ไม่ได้แชร์ heap ร่วมกัน และต้นทุนการแปลงข้อมูลจะมีอยู่เสมอ
ผลลัพธ์สุดท้าย: การเปลี่ยนไปใช้ TypeScript และ incremental caching ทำให้ ประสิทธิภาพต่อการเรียกดีขึ้น 2.2~4.6 เท่า และทั้งสตรีมดีขึ้น 2.6~3.3 เท่า
2 ความคิดเห็น
หรือจริง ๆ แล้วมีเจตนาจะเหน็บแนมบทความปรับปรุงประสิทธิภาพของ Rust แบบขั้นสูงกันแน่นะ..
ความคิดเห็นจาก Hacker News
ประเด็นสำคัญจริง ๆ ไม่ใช่ TypeScript แทน Rust แต่เป็นการแก้อัลกอริทึมแบบสตรีมมิงจาก O(N²) เป็น O(N)
การเปลี่ยนแปลงแค่นี้ที่ทำผ่านการแคชระดับ statement ก็ทำให้เร็วขึ้นถึง 3.3 เท่าแล้ว
แยกจากการเลือกภาษา ปัจจัยหลักที่ผู้ใช้รับรู้ได้ในแง่ latency ที่ดีขึ้นก็คือส่วนนี้
รู้สึกว่าพาดหัวประเมินจุดทางวิศวกรรมที่น่าสนใจนี้ต่ำเกินไป
ตัวบทความเองก็น่าสนใจ แต่ช่วงนี้เริ่มเหนื่อยกับ พาดหัวล่อคลิก ที่มากเกินไป
เขาวัดเวลาแต่ละรอบเรียกแล้วใช้ค่า median แต่ในเบราว์เซอร์มีตรรกะป้องกัน timing attack ใน JS engine อยู่ เลยสงสัยเรื่องความแม่นยำ
คำพูดแนวว่า “เขียนโค้ดใหม่จากภาษา L ไปเป็น M แล้วเร็วขึ้น” เป็นผลลัพธ์ที่แทบจะแน่นอนอยู่แล้ว
เพราะมันเป็นโอกาสให้แก้ความยุ่งเหยิงและการตัดสินใจที่ผิดพลาด รวมถึงนำ แนวทางที่ดีกว่า ที่เพิ่งมีมาใช้
จริง ๆ ต่อให้ L=M ก็เหมือนกัน การเพิ่มความเร็วมักไม่ได้มาจากภาษา แต่เกิดจากกระบวนการ รีไรต์และออกแบบใหม่
ผมเคยขุดลึกลงไปอีกเพื่อปรับปรุงประสิทธิภาพของการซีเรียลไลซ์ออบเจ็กต์ตรงรอยต่อระหว่าง Rust กับ JS
แนวทางของ serde ดูไม่ค่อยดีในเชิงประสิทธิภาพ และผมได้สรุปความพยายามในการปรับปรุงเรื่องนี้ไว้ในบล็อกโพสต์ของผม
ผมสงสัยว่าทำไม Open UI ถึงไม่ทำงานเกี่ยวกับ WASM
แต่พอเห็นว่าบริษัทใหม่ครั้งนี้ใช้ชื่อว่า Open UI ก็เลยยิ่งสับสน
เดิมที Open UI W3C Community Group เป็นกลุ่มที่ทำมาตรฐานอย่าง HTML popover, select ที่ปรับแต่งได้, invoker command และ accordion มานานกว่า 5 ปี
พวกเขาทำงานได้ยอดเยี่ยมมากจริง ๆ
เขาบอกว่ารวม serde-wasm-bindgen เข้าไปเพราะอยาก “ข้ามการไป-กลับของ JSON” แต่สุดท้ายมันก็ดูเหมือน การประดิษฐ์ JSON แบบไบนารีขึ้นมาใหม่
ทุกวันนี้ JSON ของ V8 ถูกปรับแต่งมาอย่างหนักอยู่แล้ว และอิมพลีเมนต์อย่าง simdjson ก็ประมวลผลได้ระดับกิกะไบต์ต่อวินาที
ผมเลยคิดว่าโอกาสที่ JSON จะเป็นคอขวดนั้นต่ำ
ผมชอบ ดีไซน์ ของบล็อกนั้นมากจริง ๆ
ชอบ sidebar แบบ ‘scrollspy’ ที่ไฮไลต์หัวข้อตามตำแหน่งสก์รอลล์เป็นพิเศษ
Claude บอกว่าน่าจะทำด้วย fumadocs.dev
ผมยังไม่ค่อยเข้าใจว่าตัวพาร์เซอร์ Rust WASM นี้มีจุดประสงค์อะไร
ในบทความอธิบายส่วนนี้ไม่ชัด เลยอยากได้คำอธิบายเพิ่ม
ดูเหมือนว่าจุดประสงค์คือป้องกันข้อมูลรั่วไหลจาก prompt injection
พาร์เซอร์จะคอมไพล์ chunk ที่สตรีมมาจาก LLM เพื่อประกอบ UI แบบเรียลไทม์
ก่อนหน้านี้พอมี chunk ใหม่มาก็รีสตาร์ตพาร์เซอร์ตั้งแต่ต้นทุกครั้ง แต่พอเปลี่ยนเป็น การประมวลผลแบบ incremental (ระหว่างพอร์ตจาก Rust ไป TypeScript) ประสิทธิภาพก็ดีขึ้นมาก
ผมสงสัยว่าเดี๋ยวนี้ TypeScript ไม่ได้รันบนฐาน Golang แล้วหรือ
พูดเล่นนะ แต่ถ้าเขียนกลับไปเป็น Rust อีกที อาจได้ ประสิทธิภาพเพิ่ม 3 เท่า อีกรอบก็ได้ /s