22 คะแนน โดย GN⁺ 2025-06-24 | 3 ความคิดเห็น | แชร์ทาง WhatsApp
  • วิเคราะห์ ประสิทธิภาพของ 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 ความคิดเห็น

 
iolothebard 2025-06-27

ว้าว! น่าสนุกดีนะ! (แต่ไม่เข้าใจเลยว่ากำลังพูดเรื่องอะไรอยู่…)

 
doolayer 2025-06-26

|

 
GN⁺ 2025-06-24
ความคิดเห็นบน 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 ความคิดเห็น)

  • เป็นบทความที่ยอดเยี่ยมจริง ๆ และก็น่ายินดีมากที่ถูกหยิบกลับมาพูดถึงเป็นระยะ

    • แก้คำผิด โดยระบุว่า comes → comes up
  • รู้สึกเสียดายที่ยังไม่มีคอมเมนต์เลย และอยากใช้ 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