การพัฒนาคอมไพเลอร์ JIT ตัวใหม่สำหรับ PostgreSQL
(pinaraf.info)- เอนจิน JIT เชิงทดลอง pg-copyjit มุ่งสร้างโค้ดที่ “เร็วพอ” สำหรับคิวรี PostgreSQL แบบสั้น โดยมีต้นทุนการสร้างต่ำกว่า LLVM
- ค่าประเมิน cost ของ PostgreSQL ไม่ได้สอดคล้องกับเวลารันจริงโดยตรง ทำให้ LLVM JIT ที่มีต้นทุนด้านการ optimize สูง อาจกลับกลายเป็นผลเสียสำหรับคิวรีสั้น
- วิธีแบบ copy-and-patch คือการคอมไพล์ stencil ที่เขียนด้วย C ไว้ล่วงหน้า แล้วคัดลอกและแพตช์ชิ้นส่วนที่ต้องใช้ตอนรันให้ทำงานเหมือนฟังก์ชันใหม่
- pg-copyjit เชื่อมต่อผ่านอินเทอร์เฟซ JIT provider ของ PostgreSQL และจะ fallback ไปยัง PostgreSQL interpreter โดยอัตโนมัติเมื่อเจอ opcode ที่ยังไม่ได้ implement
- ตอนนี้ยังอยู่ในขั้น proof of concept ที่ทำงานบน PostgreSQL 16 และ AMD64 โดยการสร้างโค้ดใช้เวลาเพียงระดับหลายร้อยไมโครวินาที แต่การ build, เอกสาร และการรองรับการใช้งานยังไม่พร้อม
จุดที่ pg-copyjit กำลังมุ่งไป
- pg-copyjit คือเอนจิน JIT เชิงทดลองเพื่อทำให้เซิร์ฟเวอร์ PostgreSQL เร็วขึ้น
- โค้ดในตอนนี้ยังใกล้เคียงกับระดับที่ แฮ็กเกอร์สายลึก จะหยิบไปทดลองมากกว่าการใช้บนเซิร์ฟเวอร์ production
- ฟีดแบ็กที่คาดหวังคือผลการทดลองที่น่าสนใจ ตัวอย่างการเพิ่มประสิทธิภาพ และไอเดียด้านการ implement มากกว่าระดับที่ยอมรับ downtime ของแอปพลิเคชันสำคัญทางธุรกิจได้
ทำไม LLVM JIT ถึงเป็นภาระใน PostgreSQL
- PostgreSQL มี คอมไพเลอร์ JIT บนพื้นฐาน LLVM ที่ Andres Freund นำเข้ามาอยู่แล้ว
- LLVM สามารถสร้างโค้ดที่มีประสิทธิภาพได้ แต่ในรูปแบบการใช้งานของ PostgreSQL นั้นมีต้นทุนด้าน JIT compile และ optimization สูง
- หากไม่ใช้ optimizer ก็อาจแย่กว่าการไม่คอมไพล์เลย และถ้าใช้ optimizer ต้นทุนก็อาจสูงขึ้นอีก
- การตัดสินใจว่าจะใช้ JIT หรือไม่ อาศัย query cost estimation ทั่วไป
- ค่า cost ของ PostgreSQL เป็นค่าที่ใช้เปรียบเทียบกันระหว่างคิวรี ไม่ได้หมายถึงเวลารันจริง
- คิวรีที่มี cost 100 อาจใช้เวลา 1 วินาที และคิวรีที่มี cost 1000 อาจจบใน 100ms ก็ได้
- ถ้าคิวรีเร็วขึ้น 10ms แต่ใช้เวลา optimize 50ms เวลารวมก็แย่ลง
- อีกวิธีที่จะทำให้ LLVM JIT มีประโยชน์มากขึ้นคือการ แคชและนำคิวรีที่คอมไพล์แล้วกลับมาใช้ซ้ำ แต่การ implement ไม่ใช่งานเล็ก
โครงสร้างของวิธี copy-and-patch
- copy-and-patch เป็นแนวทางสร้าง JIT compiler ที่ถูกนำเสนอในงานวิจัยปี 2021 และถูกใช้ใน JIT engine ของ Python 3.13 ด้วย
- หน่วยหลักคือ stencil ที่เขียนด้วย C
- stencil คือฟังก์ชันที่มีช่องว่างให้เติม
- ถูกคอมไพล์ล่วงหน้าด้วย clang
- การรองรับ gcc ยังถูกพักไว้ก่อน
- ในช่วงคอมไพล์ จะนำ stencil ที่ต้องใช้มาต่อกัน เติมช่องว่าง แล้วกระโดดไปยังฟังก์ชัน “ที่คอมไพล์แล้ว” ซึ่งสร้างขึ้นใหม่
- โฟลว์พื้นฐานนั้นเรียบง่าย
- คัดลอก stencil ไปยังพื้นที่หน่วยความจำใหม่
- แพตช์ค่าที่จำเป็น
- รันโค้ดที่ได้
- ยังมีพื้นที่สำหรับ optimization เพิ่มเติม
- คำนวณค่าที่คำนวณได้ตั้งแต่ตอนคอมไพล์ไว้ล่วงหน้า
- แยกลูปออกเป็นหลาย stencil แล้วทำ unroll
- รวมหลาย stencil เข้าด้วยกันเพื่อทำ optimization ทีเดียวในรูปแบบ meta-stencil
วิธีเชื่อมเข้ากับ PostgreSQL
- JIT ของ PostgreSQL มีโครงสร้าง provider ที่ขยายได้
.soจะต้องมีฟังก์ชัน_PG_jit_provider_initเพียงตัวเดียว และในฟังก์ชันนี้จะทำการตั้งค่า callback 3 ตัวcompile_exprrelease_contextreset_after_error
- callback หลักคือ
compile_expr- อินพุตคือ pointer ของ expression ที่ประกอบด้วย opcode ชื่อ
ExprState* - คอมไพล์ opcode เหล่านั้นด้วยวิธีที่ต้องการ
- ทำให้โค้ดที่สร้างขึ้นสามารถรันได้
- เปลี่ยน
evalfuncจาก PostgreSQL interpreter ไปเป็นโค้ดที่สร้างขึ้น
- อินพุตคือ pointer ของ expression ที่ประกอบด้วย opcode ชื่อ
- หากเจอ opcode ที่ยังไม่ได้ implement ก็สามารถ fallback กลับไปใช้ PostgreSQL interpreter ได้อัตโนมัติ
ขั้นตอนการคอมไพล์ของ pg-copyjit
- อัลกอริทึม copy-and-patch ของ pg-copyjit ในตอนนี้ยังเป็นรูปแบบเรียบง่ายที่มี optimization ย่อยเพียงไม่กี่อย่าง
- สำหรับแต่ละ opcode คอมไพเลอร์จะตรวจสอบ ชุด stencil
- ถ้ามี stencil ที่ตรงกับ opcode ก็จะนำไปต่อเข้ากับโค้ดที่กำลังสร้าง
- ถ้าไม่มี stencil จะหยุดการคอมไพล์ และปล่อยให้ PostgreSQL interpreter รับหน้าที่รัน
- หลังจากต่อ stencil แล้ว ก็จะแพตช์แต่ละช่องด้วยค่าที่จำเป็น
- stencil ของ opcode
CONSTจะประกาศopเป็นExprEvalStepภายนอก และทิ้งช่องสำหรับใส่ address ของopไว้ในไฟล์.oที่คอมไพล์แล้ว - ชุด stencil จะเก็บข้อมูล relocation นี้ไว้ และ JIT compiler จะใส่ address ของโครงสร้าง opcode ปัจจุบันลงไปเพื่อสร้างโค้ดที่รันได้
- ขั้นตอน build คือสร้าง stencil ทั้งหมดเป็นไฟล์
.oเดียวก่อน จากนั้นดึง assembly code และ relocation ออกมา แปลงเป็นโครงสร้างที่ใช้ใน C
สถานะการพัฒนาและประสิทธิภาพ
- ในช่วงแรก มีการดึง assembly code ออกมาด้วยมือเพื่อให้ opcode 3 ตัวที่จำเป็นสำหรับ
SELECT 42;ทำงานได้ - หลังจากนั้นได้เขียนสคริปต์ DirtyPython เพื่อทำให้การดึง assembly code เป็นอัตโนมัติ และภายในไม่กี่ชั่วโมงก็เพิ่มความสามารถต่อไปนี้ได้
- function call
- คิวรีตารางเดี่ยว
- data type ที่ซับซ้อนขึ้น
- optimization บางส่วน
- สถานะที่ยืนยันได้ในตอนนี้มีดังนี้
- ทำงานบน PostgreSQL 16
- คาดว่าน่าจะใช้ได้กับรีลีสก่อนหน้า แต่สภาพแวดล้อมที่ยืนยันแล้วคือ PostgreSQL 16
- สถาปัตยกรรมที่รองรับมีเพียง AMD64
- มีแผนจะเพิ่มการรองรับ ARM64
- เป้าหมายอย่าง POWER64 และ S390x ก็อยู่ในความสนใจ แต่ อาจต้องมีการแพตช์คอมไพเลอร์และเข้าถึงเครื่องของสถาปัตยกรรมนั้น
- ตัวเลขประสิทธิภาพในตอนนี้ยังมาจากสภาพที่แทบไม่ได้ optimize
- การสร้างโค้ดเสร็จภายใน หลายร้อยไมโครวินาที
- อยู่ในระดับที่พอใช้กับคิวรีสั้นได้
- สำหรับ
SELECT 42;แบบ no JIT ใช้ 0.3ms, copyjit ใช้ 0.6ms, LLVM แบบไม่มี optimization ใช้ 1.6ms และ LLVM แบบ optimize ใช้ 6.6ms
- LLVM อาจสร้างโค้ดที่เร็วมากได้ แต่เป้าหมายของ pg-copyjit คือการสร้าง โค้ดที่เร็วพอได้อย่างรวดเร็ว ดังนั้นจึงเปรียบเทียบสองเครื่องมือนี้แบบตรง ๆ ได้ยาก
- มีการ benchmark คิวรี 2 แบบบนตาราง 90k rows ที่ไม่มีดัชนี และเมื่อมีเงื่อนไข
whereที่มีงาน CPU ประสิทธิภาพจะดีกว่า interpreter - benchmark ทำบนโน้ตบุ๊ก จึงมีข้อจำกัดด้านความน่าเชื่อถือ และในอนาคตมีแผนจะทำ benchmark ที่เหมาะสมกว่านี้บนเดสก์ท็อป
- แม้ opcode ที่ implement แล้วจะยังมีไม่มาก แต่ก็ยังสามารถรันคิวรีอะไรก็ได้
- JIT engine จะส่งข้อความสำหรับส่วนที่ยังไม่ได้ implement
- การรันจริงจะให้ interpreter รับผิดชอบ
การเปิดโค้ดและงานที่ยังเหลือ
- โค้ดถูกเผยแพร่ไว้บน GitHub ที่ pg-copyjit
- ตอนนี้ยังโฟกัสกับตัวโค้ดมากกว่าการจัดระเบียบ git history หรือการทำเอกสาร
- หากต้องการ build ต้องรันไฟล์
build-stencils.shด้วยตนเองก่อน - ในสถานะปัจจุบันยังไม่สามารถให้การรองรับการใช้งานได้ จึงยังไม่ได้จัดทำเอกสาร
- งานที่ยังเหลือค่อนข้างชัดเจน
- implement opcode เพิ่มเติม
- สำรวจ optimization
- ปรับปรุงความสะดวกในการ build
- จัดระเบียบให้พร้อมสำหรับการแพ็กเกจ
- สคริปต์ build ในตอนนี้ออกแบบมาเฉพาะสำหรับ Debian และ PostgreSQL 16
กรณีใช้งานที่คาดไว้และการขยายไปยังสถาปัตยกรรมอื่น
- เป้าหมายคือทำให้สามารถแพ็กเกจได้อย่างปลอดภัยจนผู้ใช้สามารถนำไป deploy บนเซิร์ฟเวอร์ production ของตนเองได้
- ยังมีแนวคิดเรื่องการแยกใช้ JIT ตามประเภทของเซิร์ฟเวอร์
- เซิร์ฟเวอร์ GIS ที่คิวรีคุ้มค่ากับต้นทุน optimization อาจใช้ LLVM JIT
- ฐานข้อมูลของเว็บแอปพลิเคชันที่เวลา response ของคิวรีสั้นสำคัญ อาจใช้ pg-copyjit
- การพอร์ตไปยังสถาปัตยกรรมอื่นก็เป็นเป้าหมายที่จริงจัง
- ผู้พัฒนาระบุว่าคิดถึงยุคของสถาปัตยกรรมที่หลากหลายอย่าง Alpha, Itanium, Sparc และ M68k และไม่ต้องการมีส่วนร่วมกับปัญหา monoculture ที่ยึดติดกับสถาปัตยกรรมเดียว
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
คอมไพเลอร์ JIT ต้อง inject ที่อยู่หน่วยความจำโดยตรง ทำให้โค้ดที่สร้างขึ้นผูกกับ query และ process นั้น
execution plan ก็เจอปัญหาเดียวกัน เพราะไม่ได้เป็นโครงสร้างข้อมูลในหน่วยความจำที่ออกแบบมาให้แชร์ข้าม process ได้
DB อย่าง MSSQL ไม่มีปัญหานี้ เพราะเป็นสถาปัตยกรรม process เดียวที่ใช้ thread และนี่ก็เป็นเหตุผลหนึ่งที่รองรับ concurrent connections ได้มากกว่าโดยไม่ต้องใช้ external pooler
MSSQL ยัง serialize execution plan เป็น representation ที่ไม่ผูกกับ process แล้วเก็บไว้ใน DB ได้ด้วย จึงนำไปใช้กับฟีเจอร์อย่าง plan forcing ได้
วิธีคอมไพล์แบบดั้งเดิมก็ดูเหมือน “copy-and-patch” ที่ใช้ intermediate language อื่นที่ไม่ใช่ C
เช่น ใช้ pass อะไร ใช้ backend optimization แบบไหน เป็นต้น
จากประสบการณ์ของเรา [1] ถ้าปิด optimization แล้วปรับจูนให้ตรงกับเวลา compile ด้วย backend pipeline แบบ
-O0LLVM ก็เร็วขึ้นได้พอสมควร แต่ก็ยังช้ากว่าวิธีอื่น 10–20 เท่านอกจากนี้เรายังมีประสบการณ์ว่าโค้ดที่สร้างด้วย copy-and-patch ทำงานค่อนข้างช้าและ optimize ได้ยาก เราลองมาหลายอย่างแล้ว [2; Sec. 5] แต่ช่องว่างก็ยังมากอยู่ ดูผลการประเมินบนฐานข้อมูลได้ใน Fig. 3
อยากรู้ว่ามีตัวเลขการชะลอตัวของเวลารันเมื่อเทียบกับ LLVM หรือไม่ และมีแผน implement multi-tier JIT ที่สลับจากโค้ดที่คอมไพล์เร็วไปเป็นโค้ดที่ LLVM optimize แล้วแบบ dynamic หรือไม่
[1]: https://home.in.tum.de/~engelke/pubs/2403-cgo.pdf
[2]: https://home.in.tum.de/~engelke/pubs/2403-cc.pdf
ราวปี 2010 ตอนเรียนเขียนโปรแกรม โดยเฉพาะ interpreter ผมคิดว่าเป็นเรื่องที่รู้กันดีว่าถ้าใช้อย่างระมัดระวัง ก็สามารถ
memcpyชิ้นส่วนโค้ด executable ที่คอมไพเลอร์สร้างไว้ได้กับดักใหญ่ในตอนนั้นคือ NX bit เพิ่งเริ่มแพร่หลาย แม้แต่บน Linux คนส่วนใหญ่ก็ยังมองว่า distro แบบ 32-bit เป็นเรื่องปกติ และยังมีคนประหลาดใจที่ CPU รองรับ 64-bit
ต่อมาผมยังเคยใช้ netbook ที่ไม่รองรับโค้ด 64-bit เลยด้วย
น่าเสียดายที่ผมใช้เวลากับโค้ดส่วนอื่นมากเกินไป เลยไม่ได้ขุดเรื่องนี้ให้ลึกพอจนทำอะไรที่ใช้งานได้จริง
เสียดายที่การรับข้อเสนอการบรรยายปิดไปแล้ว แต่มีส่วน “unconference” อยู่ อย่างไรก็ดีหัวข้อจะถูกกำหนดกันที่งาน จึงรับประกันไม่ได้
ถ้ามีแคช query ที่รันบ่อยก็จะถูกแคชและ optimize ได้ aggressive ขึ้น ดูเหมือนว่าสองแนวทางนี้จะเข้ากันได้ดีในแบบที่ช่วยชดเชยต้นทุนการคอมไพล์
แน่นอนว่าตัวมันเองก็จะเพิ่มความซับซ้อนและปัญหาปวดหัวชุดใหม่ทั้งหมด
ดู “notes” ใน https://www.postgresql.org/docs/current/sql-prepare.html
พูดแบบลดทอนมาก ๆ คือมี pointer ที่ชี้ไปยังบางส่วนของ query รั่วไหลอยู่ทั่ว execution engine
ถ้าจะกำจัดสิ่งนี้ ต้องยกเครื่องครั้งใหญ่พอสมควร ครอบคลุม execution engine, planner และอาจรวมถึงส่วนอื่น ๆ ด้วย
แม้ใน session เดียวกัน query ที่คอมไพล์แล้วสองตัวก็จะมีโค้ดที่คอมไพล์ต่างกันเพราะเหตุนี้ ทั้ง LLVM และ copyjit ของผมต้อง inject ที่อยู่ของหลาย struct เข้าไปใน assembly code
โดยปกติต้องเข้าไปเพิ่ม hint หรือบังคับ execution plan เอง
แม้แต่ query ง่าย ๆ อย่าง
SELECT * FROM t WHERE x = TRUE;ก็อาจกลายเป็นฝันร้ายได้ ขึ้นอยู่กับการกระจายตัวของค่าxในตารางใน Postgres ผมแทบไม่เคยเจอปัญหาแบบนั้น แต่ก็ยอมรับว่าไม่เคยใช้ Postgres ร่วมกับ prepared statement
เคยเห็น query ที่ใช้เวลาวางแผนช้า (เกิน 100ms) อยู่บ้าง ดังนั้นแคชอาจมีประโยชน์ แต่จำไม่ได้ว่ามีกรณีไหนที่จำเป็นต้อง optimize จริง ๆ
คล้ายกับ stencil ที่นำเสนอที่นี่ แต่โดยปกติโค้ดจะเป็น instance เดียว จึงไม่ค่อยมีการทำสำเนา
ตัวอย่างเช่น Doom บน DOS ก็ใช้เทคนิค optimization แบบนี้ จำเป็นต้องใช้เพื่อรีดประสิทธิภาพของ rendering loop ที่ตึงมากบน CPU รุ่นเก่าให้เพียงพอ