- วิเคราะห์ ประสิทธิภาพของ Unix pipe ที่อิมพลีเมนต์บน Linux ผ่านการปรับแต่งแบบค่อยเป็นค่อยไป
- แบนด์วิดท์ของ โปรแกรม pipe แบบเรียบง่ายเวอร์ชันแรก วัดได้ราว 3.5GiB/s และบทความนี้อธิบายกระบวนการเพิ่มความเร็วได้มากกว่า 20 เท่าผ่านการทำ profiling และเปลี่ยน system call
- อธิบายเทคนิคการปรับแต่งหลากหลายรูปแบบ เช่น การใช้ Zero-Copy system call อย่าง vmsplice และ splice เพื่อลดการคัดลอกข้อมูลที่ไม่จำเป็น และการเพิ่มขนาดเพจ
- แก้คอขวดด้วย Huge Page และเทคนิค busy loop จนทำความเร็วสูงสุดได้ถึง 62.5GiB/s
- ให้มุมมองเชิงลึกเกี่ยวกับ องค์ประกอบสำคัญในงานเขียนเซิร์ฟเวอร์สมรรถนะสูงและเคอร์เนลโปรแกรมมิง เช่น pipe, paging, ต้นทุนของ synchronization และ Zero-Copy
ภาพรวมและบทนำ
- บทความนี้กล่าวถึง วิธีที่ Unix pipe ถูกอิมพลีเมนต์บน Linux โดยเขียนโปรแกรมทดสอบสำหรับอ่านและเขียนข้อมูลผ่าน pipe ขึ้นมาเอง แล้วค่อย ๆ ปรับแต่งประสิทธิภาพทีละขั้น
- เริ่มจากโปรแกรมเรียบง่ายที่มีแบนด์วิดท์ราว 3.5GiB/s และหลังจากปรับแต่งหลายรูปแบบก็เพิ่มประสิทธิภาพได้ราว 20 เท่า
- การตัดสินใจปรับแต่งในแต่ละขั้นอ้างอิงจาก ผล profiling ด้วยเครื่องมือ perf และซอร์สโค้ดที่เกี่ยวข้องเปิดเผยไว้ที่ GitHub - pipes-speed-test
- แรงบันดาลใจมาจากการสังเกต โปรแกรม FizzBuzz สมรรถนะสูง (36GiB/s) ที่ประมวลผลข้อมูลผ่าน pipe ได้รวดเร็วมาก
- หากมีความรู้พื้นฐานภาษา C ก็สามารถทำความเข้าใจเนื้อหาได้ไม่ยาก
การวัดประสิทธิภาพของ pipe: เวอร์ชันแรกที่ยังช้า
- จากผลการรันตัวอย่างของโปรแกรม FizzBuzz สมรรถนะสูง พบว่าสามารถประมวลผลข้อมูลผ่าน pipe ได้ถึง 36GiB ต่อวินาที
- FizzBuzz แสดงผลเป็นบล็อกขนาด L2 cache (256KiB) เพื่อ สร้างสมดุลระหว่างการเข้าถึงหน่วยความจำกับ overhead ของ IO
- โปรแกรมทดสอบประสิทธิภาพ pipe ในบทความนี้ก็เขียนให้อ่าน/เขียนซ้ำเป็นบล็อกขนาด 256KiB เช่นกัน และเพื่อการวัดผลจึง อิมพลีเมนต์ทั้งฝั่ง read และ write ขึ้นมาเองทั้งหมด
write.cpp จะเขียนบัฟเฟอร์ 256KiB เดิมซ้ำ ๆ ส่วน read.cpp จะอ่านครบ 10GiB แล้วจบการทำงานพร้อมแสดง throughput
- ผลทดสอบพบว่า read/write ผ่าน pipe ทำได้เพียง 3.7GiB/s ซึ่งช้ากว่า FizzBuzz ราว 10 เท่า
คอขวดของการทำงานฝั่ง write และโครงสร้างภายใน
- เมื่อติดตาม call graph ระหว่างรันโปรแกรมด้วยเครื่องมือ perf พบว่าเวลาเกือบครึ่งหนึ่งถูกใช้ไปกับขั้นตอนเขียนลง pipe หรือ
pipe_write
- ภายใน
pipe_write เวลาส่วนใหญ่หมดไปกับ การคัดลอกและจัดสรรเพจหน่วยความจำ (copy_page_from_iter, __alloc_pages)
- pipe บน Linux ถูกอิมพลีเมนต์เป็น ring buffer โดยแต่ละเอนทรีจะอ้างอิงเพจที่เก็บข้อมูลจริง
- ขนาดบัฟเฟอร์รวมของ pipe เป็นค่าคงที่ และเมื่อ pipe เต็ม การ write จะบล็อก ส่วนเมื่อว่างเปล่า read ก็จะเข้าสู่สถานะบล็อก
- ในโครงสร้าง C (
pipe_inode_info, pipe_buffer) ค่า head และ tail ใช้แทนตำแหน่งเขียน/อ่านตามลำดับ และเก็บข้อมูล offset กับความยาวของแต่ละเพจไว้ด้วย
ลอจิกการอ่าน/เขียนของ pipe
pipe_write ทำงานตามลำดับดังนี้
- หาก pipe เต็ม จะรอจนกว่าจะมีพื้นที่ว่าง
- เติมพื้นที่ที่เหลือในตำแหน่ง head ปัจจุบันก่อน
- หากยังมีข้อมูลเหลือ จะจัดสรรเพจใหม่ คัดลอกข้อมูลลงบัฟเฟอร์ และอัปเดต head
- ทุกการทำงานถูกป้องกันด้วย lock จึงมี overhead จาก synchronization
- ฝั่งอ่านก็ใช้โครงสร้างเดียวกัน โดยเลื่อน tail และคืนเพจที่อ่านเสร็จแล้ว
- โดยแก่นแล้วจะเกิด การคัดลอกสองรอบ จากหน่วยความจำฝั่งผู้ใช้ไปยังเคอร์เนล และจากเคอร์เนลกลับสู่ userspace ทำให้มี overhead ค่อนข้างสูง
Zero-Copy: ปรับแต่งด้วย Splice/vmsplice
- แนวทางทั่วไปของการทำ IO ให้เร็วคือการ bypass เคอร์เนลหรือทำให้การคัดลอกข้อมูลน้อยที่สุด
- Linux รองรับ system call อย่าง splice และ vmsplice เพื่อให้การย้ายข้อมูลระหว่าง pipe กับ userspace สามารถข้ามการคัดลอกได้
splice: ย้ายข้อมูลระหว่าง pipe กับ file descriptor
vmsplice: ย้ายข้อมูลระหว่างหน่วยความจำฝั่งผู้ใช้กับ pipe
- ทั้งสอง system call นี้สามารถ ย้ายเพียงการอ้างอิงโดยไม่ต้องย้ายข้อมูลจริง
- ตัวอย่างเช่น เมื่อใช้
vmsplice สามารถแบ่งบัฟเฟอร์ 256KiB ออกเป็นสองส่วน และทำ double buffering โดยสลับ vmsplice แต่ละครึ่งเข้า pipe
- เมื่อนำ
vmsplice มาใช้จริง ความเร็วเพิ่มขึ้น มากกว่า 3 เท่า (ราว 12.7GiB/s) และเมื่อใช้ splice ที่ฝั่งอ่านเพิ่มเติมก็เพิ่มเป็น 32.8GiB/s
คอขวดที่เกี่ยวข้องกับเพจ และการใช้ Huge Page
- จากการวิเคราะห์ด้วย perf พบว่าคอขวดของ
vmsplice กระจุกอยู่ที่ pipe lock (mutex_lock) และ การดึงเพจ (iov_iter_get_pages)
iov_iter_get_pages ทำหน้าที่แปลงหน่วยความจำฝั่งผู้ใช้ (virtual address) ไปเป็นเพจจริงในหน่วยความจำ (physical page) แล้วเก็บการอ้างอิงไว้ใน pipe
- ระบบ paging ของ Linux ไม่ได้ใช้เพียง เพจขนาด 4KiB เท่านั้น แต่ยังรองรับขนาดอื่นตามสถาปัตยกรรม เช่น 2MiB (huge page)
- เมื่อใช้ Huge Page (เช่น 2MiB) จะช่วยลด overhead จากการแปลงเพจได้อย่างชัดเจน เพราะจัดการ page table และจำนวนการอ้างอิงน้อยลง
- เมื่อนำ huge page มาใช้ในโปรแกรม throughput สูงสุดเพิ่มเป็น 51.0GiB/s หรือเพิ่มขึ้นอีกราว 50%
การใช้ busy loop
- คอขวดที่เหลืออยู่คือ งานด้าน synchronization เช่น การรอให้มีพื้นที่ว่างใน pipe เพื่อเขียน และการปลุก reader ขึ้นมาทำงาน
- ใช้ออปชัน
SPLICE_F_NONBLOCK และเมื่อเกิด EAGAIN ก็เรียกซ้ำแบบ busy loop เพื่อ ตัด overhead จากการ scheduling ของเคอร์เนล
- หลังใช้เทคนิคนี้ throughput สูงสุดเพิ่มเป็น 62.5GiB/s หรือดีขึ้นอีก 25%
- แม้ busy loop จะใช้ทรัพยากร CPU เต็ม 100% แต่ก็เป็นรูปแบบที่พบได้บ่อยในเซิร์ฟเวอร์สมรรถนะสูง
สรุปและประเด็นอื่น ๆ
- บทความนี้อธิบายวิธีเพิ่มประสิทธิภาพของ pipe แบบก้าวกระโดดทีละขั้นด้วย การวิเคราะห์ perf และศึกษาซอร์สโค้ดของ Linux
- ผู้อ่านจะได้เห็นประเด็นสำคัญของงานเขียนโปรแกรมสมรรถนะสูงจากตัวอย่างจริง ทั้ง pipe, splice, paging, Zero-Copy และต้นทุนของ synchronization
- ในโค้ดจริงยังมีการปรับแต่งเพิ่มเติม เช่น จัดสรรบัฟเฟอร์ให้อยู่คนละเพจ เพื่อลด refcount contention
- การทดสอบรันโดยตรึงแต่ละโปรเซสของโปรแกรมไว้กับคนละคอร์ด้วย
taskset
- ตระกูล
splice อาจมีความเสี่ยงในเชิงการออกแบบ และเป็นประเด็นถกเถียงมายาวนานในหมู่นักพัฒนาเคอร์เนล
3 ความคิดเห็น
ว้าว! น่าสนุกดีนะ! (แต่ไม่เข้าใจเลยว่ากำลังพูดเรื่องอะไรอยู่…)
|
ความคิดเห็นบน Hacker News
ยังจำประสบการณ์ตอนพอร์ตแอปพลิเคชันที่อิงกับ Linux pipe ไปยัง Windows ได้ไม่ลืม ตอนนั้นคิดว่าเพราะเป็นมาตรฐาน POSIX ประสิทธิภาพคงไม่ต่างกันมาก แต่กลับช้ามาก ถึงขั้นที่ตอนรอการเชื่อมต่อของ pipe ทั้ง Windows แทบค้างไปเลย หลายปีต่อมาเคยทำแบบเดียวกันอีกครั้งบน Win10 ด้วย C# ซึ่งดีขึ้นเล็กน้อย แต่ช่องว่างด้านประสิทธิภาพก็ยังน่าอับอายอยู่ดี
เท่าที่ทราบ ช่วงไม่กี่ปีมานี้ Windows ได้เพิ่ม AF_UNIX socket เข้ามาแล้ว เลยสงสัยว่าเมื่อเทียบกับ Win32 pipe แล้ว อะไรให้ประสิทธิภาพดีกว่ากัน เดาว่าน่าจะดีกว่า
ตอนที่บอกว่า "ประสิทธิภาพแย่มาก" หมายถึง I/O หลังจากที่ pipe เชื่อมต่อแล้ว หรือหมายถึงขั้นตอนก่อนเชื่อมต่อกันแน่ ถ้าเป็นหลังเชื่อมต่อแล้วก็น่าแปลกใจ แต่ถ้าปัญหาอยู่ที่การเชื่อมต่อ/ยกเลิกการเชื่อมต่อซ้ำ ๆ ก็พอเข้าใจได้ว่า OS อาจไม่ได้ optimize ไว้ เพราะแทบไม่มีความจำเป็นต้องทำแบบนั้นอยู่แล้ว จึงขึ้นกับ use case ว่าจะมองอย่างไร
สิ่งที่ผมตรวจสอบล่าสุดคือบน Windows ประสิทธิภาพของ local TCP ดีกว่า pipe มาก
POSIX กำหนดแค่พฤติกรรม ไม่ได้กำหนดประสิทธิภาพ และแต่ละแพลตฟอร์มกับ OS ก็มีลักษณะเฉพาะด้านประสิทธิภาพของตัวเอง
เมื่อก่อนเคยเจอประสบการณ์ตรงกันข้าม ไม่ใช่เรื่อง pipe แต่เป็นตอนที่แอป PHP บน Linux สื่อสารกับ SOAP API ที่ทำด้วย .NET แล้วฝั่ง implementation ของ .NET ตอบสนองได้เร็วกว่าที่จำได้
เผื่ออ้างอิง มีหลายวิธีอย่าง readv() / writev(), splice(), sendfile(), funopen(), io_buffer() เป็นต้น โดย splice() โดดเด่นมากเมื่อใช้ส่งข้อมูลขนาดใหญ่แบบ zero-copy ระหว่าง pipe กับ UNIX socket แต่เป็น Linux-only, splice() เป็นวิธีที่เร็วที่สุดเพราะจัดการการส่งข้อมูลได้โดยตรงโดยไม่ต้องมีการจัดสรรหน่วยความจำใน user space, การจัดการบัฟเฟอร์เพิ่มเติม, memcpy(), หรือการไล่ดู iovec, และยังมีการขอให้ช่วยยืนยันด้วยว่าบนตระกูล BSD สำหรับ pipe นั้น readv()/writev() ถือว่าเหมาะที่สุดจริงหรือไม่ ไม่ว่าอย่างไร บทความนี้น่าประทับใจมาก
sendfile() ให้ประสิทธิภาพสูงมากสำหรับ zero-copy แบบไฟล์→ซ็อกเก็ต ใช้ได้ทั้งบน Linux และ BSD แต่รองรับเฉพาะไฟล์→ซ็อกเก็ตเท่านั้น, sendmsg() ใช้กับ pipe ทั่วไปไม่ได้ ใช้ได้กับ UNIX domain/INET/ซ็อกเก็ตประเภทอื่น ๆ, และอีกอย่าง บน Linux นั้น sendfile ภายในถูก implement ด้วย splice จึงเคยมีประสบการณ์ใช้งานจริงกับการส่งไฟล์→อุปกรณ์บล็อกด้วย
splice() เป็นตัวเลือกที่ดีที่สุดบน Linux สำหรับการส่งข้อมูลปริมาณมากความเร็วสูงระหว่าง pipe แต่ถ้าใช้ io_uring ได้อย่างถูกต้อง ก็อาจคาดหวังประสิทธิภาพที่ใกล้เคียงกันหรือดีกว่าได้
shared memory และการส่ง file descriptor แบบ shm_open นั้นในทางปฏิบัติเร็วกว่า และ portable อย่างสมบูรณ์
มีการบอกต่อว่าใน HN ครั้งก่อนก็มีการถกเถียงบทความนี้กันอย่างคึกคัก พร้อมลิงก์ https://news.ycombinator.com/item?id=31592934 (200 ความคิดเห็น), https://news.ycombinator.com/item?id=37782493 (105 ความคิดเห็น)
เป็นบทความที่ยอดเยี่ยมจริง ๆ และก็น่ายินดีมากที่ถูกหยิบกลับมาพูดถึงเป็นระยะ
รู้สึกเสียดายที่ยังไม่มีคอมเมนต์เลย และอยากใช้ splice ให้มากกว่านี้ แต่ก็กังวลเรื่องความปลอดภัยหรือปัญหาความเข้ากันได้ของ ABI ที่กล่าวถึงช่วงท้ายบทความ จึงตั้งคำถามว่า splice จะยังคงถูกดูแลต่อไปหรือไม่ และถ้าจะเพิ่มประสิทธิภาพด้วยการแพตช์ให้ pipe ปกติใช้ splice เสมอ ความยากจะอยู่ในระดับไหน
ถามว่าใน Linux รุ่นใหม่มีอะไรที่คล้ายกับ Doors ของ SunOS หรือไม่ กำลังมองหาเทคโนโลยีที่ดีกว่า AF_UNIX สำหรับแอปพลิเคชันแบบ embedded ที่ต้องแลกเปลี่ยนข้อมูลขนาดเล็กและไวต่อ latency มาก
ในแง่ latency นั้น shared memory เร็วที่สุด แต่จำเป็นต้องมีการปลุก task (โดยปกติมักใช้ futex), Google เคยพัฒนา system call ชื่อ FUTEX_SWAP ซึ่งจะทำให้สามารถ handoff โดยตรงจาก task หนึ่งไปยังอีก task หนึ่งได้ แต่ไม่ทราบว่าหลังจากนั้นเป็นอย่างไรต่อ
คำว่า 'Doors' เป็นคำที่ทั่วไปเกินไปจนค้นหายาก เลยขอให้ช่วยอธิบายเพิ่มเติม
ขอข้อมูลเพิ่มว่าปัญหาของ AF_UNIX ตอนนี้คืออะไร ขาดความสามารถที่ต้องการอยู่หรือไม่ latency สูงเกินที่ต้องการ หรือว่าโครงสร้าง API แบบ server/client socket ไม่ตรงกับสิ่งที่ต้องการ
เพิ่มข้อมูลสั้น ๆ ว่าบทความนี้เขียนขึ้นในปี 2022