- PgDog พร็อกซีส่วนขยายของ PostgreSQL ได้นำ direct binding ของ Rust มาใช้แทน การซีเรียลไลซ์ด้วย Protobuf เพื่อเพิ่มประสิทธิภาพการพาร์ส SQL
- โครงสร้างเดิมที่อิงกับ Protobuf ถูกแทนที่ด้วย การแปลงตรงระหว่าง C–Rust (bindgen + wrapper ที่สร้างโดย Claude) ทำให้ การพาร์สเร็วขึ้น 5.45 เท่า และการ deparse เร็วขึ้น 9.64 เท่า
- คอขวดด้านประสิทธิภาพถูกพบในฟังก์ชัน pg_query_parse_protobuf และหลังจากลองใช้แคชแล้ว ทีมงานจึงปรับโครงสร้างเพื่อแก้ปัญหาที่ต้นเหตุ
- ทีมงานใช้ Claude LLM เพื่อสร้าง โค้ดแปลง Rust–C จำนวน 6,000 บรรทัด แบบอัตโนมัติ และนำไปใช้กับฟังก์ชันหลักอย่าง
parse, deparse, fingerprint, scan
- การเพิ่มประสิทธิภาพนี้ช่วยให้ การใช้ CPU และ latency ของ PgDog ลดลง ทำให้มีประสิทธิภาพดีขึ้นมากในฐานะพร็อกซีสำหรับการสเกล PostgreSQL แบบแนวนอน
PgDog และข้อจำกัดของ Protobuf
- PgDog เป็นพร็อกซีสำหรับขยาย PostgreSQL และภายในใช้ libpg_query เพื่อพาร์ส SQL query
- เขียนด้วย Rust และเดิมสื่อสารกับไลบรารี C ผ่าน การซีเรียลไลซ์/ดีซีเรียลไลซ์ด้วย Protobuf
- แม้ Protobuf จะเร็ว แต่ วิธี direct binding เร็วกว่า
- ทีม PgDog fork
pg_query.rs เพื่อถอด Protobuf ออกและสร้าง direct binding ระหว่าง C–Rust
- ผลลัพธ์คือการพาร์ส query เร็วขึ้น 5.45 เท่า และ deparse เร็วขึ้น 9.64 เท่า
ผลลัพธ์เบนช์มาร์ก
- สามารถทำซ้ำเบนช์มาร์กได้จาก fork repository ของ PgDog
pg_query::parse (Protobuf): 613 QPS
pg_query::parse_raw (direct C–Rust): 3357 QPS
pg_query::deparse (Protobuf): 759 QPS
pg_query::deparse_raw (direct Rust–C): 7319 QPS
การวิเคราะห์คอขวดและการลองใช้แคช
- เมื่อวิเคราะห์เวลาใช้งาน CPU ด้วยโปรไฟเลอร์ samply พบว่าฟังก์ชัน pg_query_parse_protobuf เป็นคอขวด
- มีการลองปรับปรุงบางส่วนด้วยแคช
- ใช้ แคชแบบ hash map ตามอัลกอริทึม LRU โดยเก็บ AST ไว้และใช้ข้อความ query เป็นคีย์
- หากใช้ prepared statement ก็สามารถนำกลับมาใช้ซ้ำได้
- แต่ ORM บางตัวสร้าง query ที่ไม่ซ้ำกันนับพันรายการ หรือ PostgreSQL driver รุ่นเก่า ไม่รองรับ prepared statement ทำให้แคชมีประสิทธิภาพต่ำ
การถอด Protobuf โดยใช้ LLM
- ทีม PgDog ใช้ Claude LLM เพื่อสร้าง Rust binding ที่ถอด Protobuf ออก
- AI ทำงานได้อย่างมีประสิทธิภาพเมื่อขอบเขตงานชัดเจนและตรวจสอบได้
- Claude อ้างอิงสเปก Protobuf ของ
libpg_query เพื่อ แมป C struct ไปเป็น Rust struct
- หลังจากทำงานแบบวนซ้ำเป็นเวลา 2 วัน ก็ได้โค้ด Rust แบบ recursive จำนวน 6,000 บรรทัด
- เมื่อนำไปใช้กับฟังก์ชัน
parse, deparse, fingerprint, scan พบว่า ประสิทธิภาพดีขึ้น 25% ตามเกณฑ์ของ pgbench
รายละเอียดของโครงสร้างการพัฒนา
- การแปลงระหว่าง Rust และ C ใช้ unsafe functions เพื่อแมป struct โดยตรง
- ส่ง C struct ไปยัง Postgres API เพื่อสร้าง AST แล้วแปลงกลับมาเป็น Rust แบบ recursive
- AST node แต่ละตัวถูกประมวลผลผ่านฟังก์ชัน convert_node โดยมีการแมปโทเคนของไวยากรณ์ SQL หลายร้อยรายการ
- แต่ละประเภทของ node เช่น SELECT, INSERT มีฟังก์ชันแปลงแยกเฉพาะ
- ผลลัพธ์ที่แปลงแล้วนำ struct เดิมของ Protobuf (
protobuf::ParseResult) กลับมาใช้ต่อ เพื่อให้ ตรวจสอบความถูกต้องด้วยการเปรียบเทียบระดับ byte ในการทดสอบ ได้
- อัลกอริทึมแบบ recursive ใช้การจัดสรรหน่วยความจำน้อยและมีประสิทธิภาพกับ CPU cache สูง จึงเร็วกว่าแบบอิงลูป
- การใช้ลูปกลับช้ากว่าเพราะมีการจัดสรรหน่วยความจำที่ไม่จำเป็นและต้อง lookup ใน hash map เพิ่มเติม
บทสรุป
- การลด overhead ของ Postgres parser ช่วยลด latency, หน่วยความจำ และการใช้ CPU ของ PgDog ได้พร้อมกัน
- ด้วยการปรับปรุงนี้ PgDog พัฒนาไปเป็น พร็อกซีสำหรับขยาย PostgreSQL ที่เร็วขึ้นและมีต้นทุนการดำเนินงานต่ำลง
- ขณะนี้ PgDog กำลังเปิดรับวิศวกรที่จะมาร่วมสร้าง การสเกล PostgreSQL แบบแนวนอน (next iteration) ไปด้วยกัน
3 ความคิดเห็น
ผมอาจกำลังตีความต้นฉบับผิดก็ได้ แต่บทความที่เกี่ยวกับ Rust หลายชิ้นดูเหมือนจะเขียนราวกับว่ามันเร็วขึ้นเพราะ “เป็น Rust” โดยละเลยประเด็นสำคัญไป
ประเด็นหลักของบทความนี้คือประสิทธิภาพดีขึ้นจากการลด overhead ของการ serialization ที่ไม่จำเป็นต่างหาก
พอกลับมาอ่านอีกที ตอนนี้ก็รู้สึกว่าไม่ได้เป็นบทความที่สรรเสริญ Rust ขนาดนั้นเหมือนกัน หรือเป็นเพราะบทความอื่น ๆ ทำให้ผมเกิดภาพลบไปแล้วกันนะ?
ผมก็รู้สึกเหมือนกันว่าชื่อบทความต้นฉบับอันนี้ ต่างจากเนื้อหาจริงตรงที่มันดูเป็น Rust มากเกินไป จนเหมือนจะโฟกัสไปที่การเพิ่มประสิทธิภาพ เลยขอปรับแก้นิดหน่อยครับ。
ดูเหมือนว่าบทความเกี่ยวกับ Rust มักจะมีแนวโน้มแบบนั้นบ่อย ๆ เลยคงต้องอ่านแบบกรอง ๆ ไว้สักหน่อยครับ
ความเห็นจาก Hacker News
ชื่อเรื่องทำให้ดูเหมือน Rust ให้ ประสิทธิภาพดีขึ้น 5 เท่า แต่ความย้อนแย้งคือจริง ๆ แล้วของเดิมช้าลงต่างหาก
ปัญหาคือซอฟต์แวร์ที่เขียนด้วย Rust ต้องใช้ libpg_query ที่เป็น C แต่เชื่อมต่อกันโดยตรงไม่ได้ จึงใช้ Rust–C binding ที่อิง Protobuf
วิธีนี้ช้า เลยสุดท้ายเขาใช้ความช่วยเหลือจาก LLM เขียน binding แบบใหม่ที่พกพาได้น้อยกว่าแต่ปรับแต่งได้ดีกว่ามาก
ถ้าตั้งแต่แรกเขียนด้วย C ก็คงไม่ต้องมีขั้นตอนแปลงข้อมูลเลย ดังนั้นชื่อเรื่องที่แม่นกว่าน่าจะเป็น “ลดการสูญเสียประสิทธิภาพที่เกิดจากการใช้ Rust”
ชั้นแปลงข้อมูลให้ทั้งความสามารถในการพกพาและความปลอดภัย แต่สุดท้ายก็ทำให้เกิด การคัดลอก·การแปลง·การทำซีเรียลไลซ์ ซ้ำ ๆ และเป็นหนึ่งในสาเหตุที่ทำให้แอปช้าลง
การเรียก C library จาก Rust นั้นง่ายมาก และก็มี safe wrapper อยู่แล้วมากมาย
แทบไม่เคยเห็นสถาปัตยกรรมที่เอา Protobuf มาคั่นกลางแบบนี้เลย และนั่นต่างหากคือคอขวด
ชื่อเรื่องดูคล้ายมีมแนว “เขียนใหม่ด้วย Rust” ที่เอาไว้ล่อให้กดมากกว่า
เดิมทีไลบรารีออกแบบผิดโดยมีการ serialize/deserialize ซ้ำไปมาอยู่แล้ว และแก่นสำคัญคือการเอาส่วนนั้นออก
ชื่อเรื่องที่แม่นกว่าคือ “แทนที่ Protobuf ด้วย API ทั่วไปแล้วเร็วขึ้น 5 เท่า”
ใน Rust การทำ C binding เป็นเรื่องที่ง่ายที่สุด และถ้า API ไม่ใหญ่ก็ตรงไปตรงมามาก
มองว่า Protobuf เป็น เครื่องมือที่ไม่เหมาะ สำหรับการแลกเปลี่ยนข้อมูลในหน่วยความจำ
ต่อไปด้วยแรงหนุนจาก LLM น่าจะได้เห็นการพอร์ตไปหลายภาษาเพิ่มขึ้นแบบระเบิด
ชื่อเรื่องชวนให้เข้าใจผิดพอสมควร
เนื้อหาแทบทั้งหมดคือ “เอาขั้นตอนซีเรียลไลซ์ของ Protobuf ออกแล้วมันเร็วขึ้น”
ทำให้ client กับ server อัปเดตแยกกันได้โดยยังทำงานร่วมกันได้ และยังทำให้การสื่อสารข้ามภาษาเป็นเรื่องง่าย
ในระบบขนาดใหญ่ ความยืดหยุ่นแบบนี้สำคัญมาก
memcpyหรือmmapเร็วกว่ามาก แต่ฝั่ง Rust มักหลีกเลี่ยงวิธีแบบนั้นที่ ไม่ปลอดภัยสาเหตุที่ช้าอาจไม่ใช่เพราะ Rust แต่เพราะเอา Protobuf ไปใช้เป็น ฟอร์แมตจัดเก็บแบบอเนกประสงค์
สุดท้ายแก่นแท้คือการลดรูปให้ตรงกับงานเฉพาะทาง
การใส่ Rust ลงในชื่อดูเป็นการเลือกเพื่อเรียกคลิกมากกว่า
ผู้เขียนดั้งเดิมของ pg_query มาอธิบายที่มา
เดิมที pganalyze ใช้มันเพื่อ parse คำสั่ง Postgres หา table reference และใช้ในการ rewrite/format query
ช่วงแรกใช้ JSON แต่ภายหลัง เปลี่ยนมาใช้ Protobuf เพื่อให้สามารถทำ binding แบบ type-safe สำหรับหลายภาษาได้ง่ายขึ้น (Ruby, Go, Rust, Python ฯลฯ)
สำหรับภาษาอย่าง Rust นั้น FFI ดีกว่า แต่กับภาษาอื่นภาระในการดูแลรักษาจะสูงกว่า
เขาสนับสนุนแนวทางของ Lev และมีแผนจะเพิ่ม ฟังก์ชันสำหรับเข้าถึง libpg_query ผ่าน FFI โดยตรง ในอนาคต
อย่างไรก็ตาม ถ้าไม่ใช่งานที่ performance สำคัญมาก Protobuf ก็ยังเป็นตัวเลือกที่ สะดวกกว่า
คำว่า “เร็วขึ้น 5 เท่า” ทำให้นึกถึงมุกของ Cap’n Proto ที่บอกว่า “เร็วแบบไม่มีที่สิ้นสุด”
ชื่อเรื่องอาจเว่อร์ไป แต่ตัวงานจริงน่าประทับใจ
สิ่งที่ทำไม่ใช่การเอา Protobuf ออกไปทั้งหมด แต่เป็นการ optimize วิธีใช้งาน มัน
ประโยคแนว “พอเปลี่ยนเป็น X แล้วเร็วขึ้น 5 เท่า” มักแปลว่า “ไปแก้ implementation ที่เละเทะมาก่อนหน้านี้มา”
บทเรียนสำคัญคือ
Rust FFI เองก็มี overhead ดังนั้นสิ่งที่ทำให้ได้ผลจริงไม่ใช่ภาษา แต่เป็น การออกแบบ data flow ใหม่ และความพยายามในการ optimize
FlatBuffers เร็วกว่า แต่เหตุผลที่คนยังใช้ Protobuf คือมันถูก องค์กรใหญ่ดูแลรักษา
สุดท้ายภาพจำว่า “Google ทำก็แปลว่าปลอดภัย” ไม่มีหลักฐานรองรับ
code.google.com) แล้วสุดท้ายมันก็เจ๊งถ้ามีแค่โครงสร้าง zero-copy ที่มี การแชร์หน่วยความจำกับฟิลด์เวอร์ชัน ก็เพียงพอแล้ว เลยไม่เห็นเหตุผลว่าทำไมต้องใช้ Protobuf
มองว่าประสิทธิภาพของ Protobuf แย่จนเหมือนเรื่องตลก
ควรใช้ ฟอร์แมต zero-copy ที่ทำให้การ serialize แทบไม่มีต้นทุน
ตัวอย่างเช่น Lite³ ที่ฉันทำ เร็วกว่า FlatBuffers 242 เท่า
เหตุผลที่คนใช้ Protobuf มีอยู่มากมายในโลกจริง เช่น ecosystem, schema, tooling ของแต่ละภาษา ฯลฯ
จริง ๆ แล้วปัญหาไม่ใช่ Rust หรือ Protobuf แต่เป็น implementation การซีเรียลไลซ์ที่ไม่มีประสิทธิภาพ ใน abstraction layer ของ PostgreSQL
pgdog แค่เอาชั้นนั้นออกแล้วส่งข้อมูลผ่าน C API โดยตรง
ตัดสิ่งที่ไม่จำเป็นออกก็เร็วขึ้นเป็นเรื่องธรรมดา
แต่สำหรับบางคนก็ยังมีกรณีที่ จำเป็นต้องใช้การซีเรียลไลซ์ อยู่
สำหรับคนกลุ่มนั้น ชื่อเรื่องแบบ “เปลี่ยนเป็น Rust” คือสารที่สื่อผิด
สุดท้ายแล้วสำหรับกรณีส่วนใหญ่ JSON ก็เพียงพอ และถ้าต้องการให้เร็วจริงก็ควรหลีกเลี่ยงการซีเรียลไลซ์ไปเลย
นี่เป็น การเปรียบเทียบที่ไม่ยุติธรรม
การใช้โปรโตคอลซีเรียลไลซ์กับการสื่อสารแบบ IPC ย่อมมี overhead อยู่แล้ว
เป็นตัวอย่างที่เข้ากับคำพูดว่า “ถ้าเร็วขึ้น 20% คือการปรับปรุง แต่ถ้าเร็วขึ้น 10 เท่า แปลว่าของเดิมถูกทำมาผิดตั้งแต่แรก” พอดี