ประสิทธิภาพของ Linux pipe ลดลง
(qsantos.fr)- เมื่อเขียนข้อมูลลงใน Linux pipe ด้วย
writeจะช้ากว่าการเขียนหน่วยความจำธรรมดามาก และในสภาพแวดล้อมทดลอง ทำได้เพียงราว เขียนลง pipe 17GB/s เทียบกับ เขียนลงบัฟเฟอร์หน่วยความจำ 167GB/s - คอขวดไม่ได้อธิบายได้ด้วยการคัดลอกข้อมูลเพียงอย่างเดียว แต่เป็นต้นทุนสะสมของ การจัดสรร page, lock และรูทีนคัดลอกของเคอร์เนลภายใน
pipe_write vmspliceไม่คัดลอกบัฟเฟอร์ใน user space ไปยังเคอร์เนล แต่เชื่อมบัฟเฟอร์เข้ากับ pipe แทน จึงเลี่ยง path ที่มีต้นทุนสูงอย่าง__alloc_pages,_raw_spin_lock_irq,copy_user_enhanced_fast_string- ในกรณี throughput ของ Fizz Buzz วิธีที่ใช้
vmspliceทำได้ถึง 60.8GiB/s บนคอร์เดียว และ 208.3GiB/s บนหลายคอร์ ส่วนตัวอย่างvmspliceจากการทดลองแยกต่างหากทำได้ 210GB/s - เนื่องจากมีการแก้ไขข้อผิดพลาด การตีความเรื่อง penalty จากการไม่ใช้ SIMD จึงเชื่อถือได้ยาก และ IPC ระหว่างโปรเซสไม่ได้จบแค่ใน L1 cache จึงคาดหวังให้ 167GB/s เป็น throughput จริงของ pipe ได้ยาก
จุดเริ่มต้น: ช่องว่างขนาดใหญ่ที่ vmsplice สร้างขึ้น
- บางโปรแกรมใช้ system call
vmspliceเพื่อย้ายข้อมูลผ่าน pipe ให้เร็วขึ้น - ในการแข่งขัน throughput ของ Fizz Buzz บน Code Golf StackExchange คำตอบแบ่งออกเป็นสองกลุ่มใหญ่
- วิธีที่ไม่ใช้
vmspliceอยู่ที่ระดับไม่กี่ GiB ต่อวินาที โดยคำตอบของ neil ทำได้ถึง 8.4GiB/s - วิธีที่ใช้
vmspliceขึ้นไปถึง 15.5GiB/s ของ tkluck, 60.8GiB/s ของ ais523 และ 208.3GiB/s แบบหลายคอร์ของ david
- วิธีที่ไม่ใช้
- การลดการคัดลอกระหว่าง kernel space กับ user space เพียงอย่างเดียวอธิบายความต่างราว 7 เท่าบนคอร์เดียวได้ยาก
- ในการทดลองเอง คำตอบของ ais523 ทำได้ 96.4GiB/s ส่วนคำตอบของ david ทำได้ 277GB/s เมื่อใช้ 7 คอร์ หรือราว 40GB/s ต่อคอร์
เส้นฐาน: การเขียนหน่วยความจำใน user space
- โปรแกรม Rust ที่คัดลอกบัฟเฟอร์ 32KiB ซ้ำ ๆ ในหน่วยความจำ user space โดยไม่ใช้ system call ทำได้ 167GB/s ในสภาพแวดล้อมทดลอง
- ตัวเลขนี้ถือว่าอยู่ในระดับเดียวกับความเร็วเขียน L1 cache ของ CPU ที่ใช้
- ระบบทดลองคือ Ryzen 9 7950X3D, DDR5 6000T/s, Debian 12, Linux 6.1.0-18-amd64
- ปิดใช้งานตัวเลือก mitigation ของ CPU ด้วย
mitigations=off
- การ profiling ด้วย
ftraceพบว่าเวลาส่วนใหญ่ถูกใช้ใน__memset_avx512_unaligned_erms - อย่างไรก็ตาม การแก้ไขข้อผิดพลาดทำให้การตีความนี้มีข้อจำกัด
- คำสั่ง ณ จุดที่หยุดคือ
rep stosซึ่ง ไม่ใช่คำสั่ง AVX-512 - เหตุผลที่ throughput ยังคงอยู่ที่ 167GB/s แม้จำกัดให้ใช้เฉพาะ AVX2 และ SSE2 ก็เพราะทุกกรณีใช้
rep stos
- คำสั่ง ณ จุดที่หยุดคือ
ต้นทุนจริงเมื่อเขียนลง pipe ด้วย write
- เมื่อเขียนบัฟเฟอร์ขนาดเดียวกันลง pipe ด้วย
stdout.write()และให้pv >/dev/nullอ่านออกไป throughput จะลดเหลือ 17GB/s - ผล profiling พบว่าเวลาส่วนใหญ่ถูกใช้ใน system call
writeโดยเฉพาะ 95% อยู่ภายในpipe_write - ภายใน
pipe_writeต้นทุนมาจากการเตรียม page, lock และการคัดลอกรวมกัน__alloc_pages: 36% ของเวลาทั้งหมด ใช้เตรียม memory page ใหม่สำหรับ pipe__mutex_lock.constprop.0: 25% ของเวลาทั้งหมด เป็นต้นทุน lock สำหรับการเขียนลง pipe_raw_spin_lock_irq: 5% ของเวลาทั้งหมด ปรากฏเป็นต้นทุน lock ที่เกี่ยวข้องกับการเขียนลง pipecopy_user_enhanced_fast_string: ราว 20% ของเวลาทั้งหมด ใช้คัดลอกข้อมูลจาก user space ไปยังฝั่งเคอร์เนล
- เนื่องจาก
pvใช้spliceย้าย page ไปยัง/dev/nullขณะอ่าน จึงยากที่จะนำ page ชุดเดิมไม่กี่ page มาใช้ซ้ำในลูปต่อเนื่อง
รูทีนคัดลอกของเคอร์เนลและการตีความที่แก้ไขแล้ว
- เมื่อ disassemble
copy_user_enhanced_fast_stringการคัดลอกบัฟเฟอร์ขนาดใหญ่ถูกจัดการด้วยคำสั่งREP MOV - ฟังก์ชันนี้ไม่ได้เขียนด้วย C แต่ implement ด้วย assembly code ของ Linux kernel จึงเป็น path ที่ตั้งใจไว้ ไม่ใช่การตกหล่นของ compiler optimization
- ในการทดลองเดิม มีการเรียก
rep movsbโดยตรงจาก user space แล้วได้ 80GB/s และเชื่อมโยงผลนี้กับการตีความว่ารูทีนคัดลอกของเคอร์เนลช้ากว่าราว 2 เท่า - ภายหลังมีการแก้ไขว่าเงื่อนไขทดลองต่างออกไป
- บัฟเฟอร์ 32KiB จำนวน 2 ชุดทำให้ L1 data cache อิ่มตัว
- เมื่อใช้บัฟเฟอร์ 16KiB ประสิทธิภาพเพิ่มเป็น 153GB/s
- ดังนั้นการตีความเดิมที่ว่าการไม่ใช้ vector instruction ในการคัดลอกของเคอร์เนลเป็น penalty ขนาดใหญ่จึงเชื่อถือได้ยาก
- ถึงอย่างนั้น ประเด็นที่ว่า overhead ด้านการจัดการหน่วยความจำ ในการเขียนลง pipe มีมากก็ยังคงอยู่
path ของเคอร์เนลที่ vmsplice เลี่ยงได้
vmspliceลดต้นทุนการใช้ pipe ด้วยการส่งบัฟเฟอร์ทั้งก้อนใน user space เข้า pipe โดยไม่คัดลอกไปยังเคอร์เนล- ตัวอย่าง
./writeในpipes-speed-testของ Francesco ถูกใช้เป็นตัวอย่างขั้นต่ำที่เขียน'X'อย่างไม่มีที่สิ้นสุด - ตัวอย่างนี้ทำได้ 210GB/s แต่ต่างจากงานสร้างข้อมูลทั่วไป เพราะส่งบัฟเฟอร์เดิมซ้ำ ๆ ให้
vmsplice- หากไม่ใช่ stream ของ byte คงที่ ก็ต้องเติมข้อมูลใหม่ลงบัฟเฟอร์
- ตอนนั้นเพดานของการเขียนหน่วยความจำธรรมดาที่ 167GB/s จะกลับมาเกี่ยวข้องอีกครั้ง
- แม้ใน path ของ
vmspliceก็ยังใช้เวลา 37% ใน__mutex_lock.constprop.0 - แต่
__alloc_pages,_raw_spin_lock_irq,copy_user_enhanced_fast_stringที่เห็นใน path ของwriteไม่ปรากฏ - แทนที่ด้วย path หลักอย่าง
add_to_pipe,import_iovec,iov_iter_get_pages2ซึ่งแสดงให้เห็นว่าvmspliceเลี่ยงส่วนที่แพงของwrite
ข้อสรุปที่เหลืออยู่และข้อควรระวัง
- จากการทดลอง path ที่เขียนลง Linux pipe ด้วย
writeช้ากว่าการเขียนหน่วยความจำธรรมดาราว 10 เท่า - ข้อสรุปเดิมคือ ในการเขียนลง pipe ต้นทุนของ lock และการบันทึก/กู้คืน SIMD context มีมาก และ
spliceกับvmspliceเลี่ยงสิ่งเหล่านี้ได้ - หลังการแก้ไขข้อผิดพลาด ควรมองข้อสรุปอย่างจำกัดมากขึ้น
- overhead ด้านการจัดการหน่วยความจำ ของเคอร์เนลยังคงเป็นปัจจัยสำคัญของการเสื่อมประสิทธิภาพ pipe
- การตีความว่าการไม่ใช้ vector instruction เป็น penalty ใหญ่เท่าที่คาดไว้นั้นไม่ถูกต้อง
- เพราะ IPC ระหว่างโปรเซสไม่สามารถเกิดขึ้นด้วย L1 cache เพียงอย่างเดียว 167GB/s จึงเป็น throughput ที่ทำได้ยากเมื่อรวมการอ่าน pipe จริงเข้าไปด้วย
- มีการแก้ไขข้อผิดพลาดสำคัญบางส่วน และความน่าเชื่อถือของผลลัพธ์อาจมีข้อจำกัด ดังนั้นควรตีความตัวเลขเป็นการดูทิศทาง
- หากต้องการเพิ่ม throughput ของ pipe ไม่ควรลดแค่จำนวน system call แต่ต้องดู path ภายในเคอร์เนลที่
writeผ่าน รวมถึงวิธีจัดการบัฟเฟอร์ด้วย
1 ความคิดเห็น
ความเห็นจาก Hacker News
มี side project ที่พยายามจัดการปัญหานี้อยู่: https://lwn.net/Articles/976836/
ไอเดียคือสร้าง system call สำหรับขอ ring buffer สำหรับ file descriptor ทุกตัวที่รองรับ รวมถึง pipe ด้วย และถ้าปลายทั้งสองฝั่งรองรับการใช้ ring buffer ก็สามารถแมป ring buffer เดียวกันเพื่อทำ zero-copy I/O ได้ และในบางกรณีอาจไม่ต้องเรียก kernel เลยแม้แต่น้อย กำลังมองหาคนมาร่วมทำ
มันสามารถ sleep/poll ได้เมื่อ ring buffer เต็มหรือว่าง และนอกเหนือจากนั้นก็ทำงานได้แบบไม่ต้องล็อกและไม่มี system call overhead
ringbuffer_wait()ก็อาจส่งสัญญาณผ่านpoll()ในสถานะ พร้อมอ่าน ได้เหมือนกันแค่มี hiccup ชั่วครู่ก็พอจะทำให้ pipe พังได้ถ้าไม่มีบัฟเฟอร์มากพอ และขนาดบัฟเฟอร์ที่ต้องใช้ก็แตกต่างกันไปในแต่ละระบบ
ที่
JMPไม่ใช่RETตรง ๆ เป็นเพราะตัวเลือก CONFIG_RETHUNK ในobjdumpdisassembly ที่เห็นคือผลจากการแทนRETด้วยJMP __x86_return_thunkhttps://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
https://github.com/torvalds/linux/blob/v6.1/arch/x86/lib/ret...
คำสั่ง NOP ต้นและท้ายฟังก์ชันไม่ได้มาจาก ftrace แต่ มาจากแมโคร ASM_CLAC/ASM_STAC แมโครเหล่านี้จะเว้นที่ไว้ให้เติมเป็นคำสั่ง CLAC/STAC ตอนรันไทม์หากตรวจพบ X86_FEATURE_SMAP ซึ่งทั้งสองคำสั่งมีขนาด 3 ไบต์พอดีกับจำนวน NOP
https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
https://github.com/torvalds/linux/blob/v6.1/arch/x86/include...
https://github.com/torvalds/linux/blob/v6.1/arch/x86/kernel/...
การเรียก Linux pipe ว่า “ช้า” ก็เหมือนเรียก Toyota Corolla ว่า “ช้า” ถ้าไม่ใช่ use case สุดขั้ว มันก็เร็วพออยู่แล้ว
แข่งรถอยู่หรือเปล่า? แล้วเป็นประเภทที่ความเร็วสำคัญกว่าทักษะไหม? ถ้าใช่ก็ซื้อรถที่เร็วกว่า ไม่อย่างนั้นก็ขับ Corolla ต่อไป
ดังนั้นต่อให้ปรับปรุงประสิทธิภาพได้เพียงเล็กน้อยมาก ๆ ก็ยังคุ้มทางเศรษฐกิจที่จะใช้เวลาไปกับการ optimize
ใช้ pipe มาสารพัดงานเกิน 10 ปีแล้วก็ไม่เคยติดคอขวดเพราะความเร็วของ pipe เลย ส่วนใหญ่เครื่องมืออย่าง tar, gzip, find, grep, nc น่าจะเป็นคอขวดมากกว่า แม้แน่นอนว่าเมื่อเทียบกับสิ่งที่มันทำ เครื่องมือพวกนี้ก็เร็วมากอยู่ดี
FFMpeg ไม่สามารถรวม Non-Free SDK เข้าไปในซอร์สได้ และการบันทึก RGBA ล้วนลงไฟล์ก็ไม่สมเหตุสมผลแบบสุด ๆ ดังนั้น pipe จึงเป็นทางเดียว และนี่ก็เป็นเหตุผลที่สมควรสำหรับการต้องการ pipe throughput สูง
https://www.toyota.com/grcorolla/
รถพวกนี้ยอดเยี่ยมมากทั้งในแง่วิศวกรรมและสมรรถนะ และยังเหมือนเป็นแฮ็กเพื่อเลี่ยงกฎที่ทำให้นำ GR Yaris ซึ่งเป็นรุ่นที่ตั้งใจทำมาตั้งแต่แรก เข้าสู่ตลาดสหรัฐฯ ได้ยาก ผมคิดว่ามันมีบริบทด้านวิศวกรรม/สมรรถนะ/การแฮ็ก/ตลาดมากพอที่คน HN จะรับมุกนี้ได้สบาย ๆ แถมประธานบริษัทก็ยังขับเองอยู่ด้วย
แม้จะไม่ใช่ประเด็นหลักของบทความ แต่บน CPU สมัยใหม่
rep movsbเร็วพอๆ กับเวอร์ชันแบบเวกเตอร์ที่เร็วที่สุด เพราะ CPU รู้และเร่งคำสั่งนี้อยู่แล้วชื่อฟังก์ชันในเคอร์เนล
copy_user_enhanced_fast_stringก็สื่อถึงเรื่องนี้เช่นกัน ฟีเจอร์ของ CPU ที่เกี่ยวข้องคือ ERMS (Enhanced Repeat Move String, ทำให้rep movsbเร็วขึ้นเมื่อขนาดเกินค่าหนึ่ง) และ FSRM (Fast Short Repeat Move String, ทำให้การคัดลอกขนาดสั้นเร็วขึ้น)rep movsbเร็วได้จนถึงค่า threshold บางจุด แต่หลังจากนั้นการเก็บข้อมูลแบบปกติหรือแบบ non-temporal store จะเร็วกว่าค่า threshold ทั้งหมดอธิบายไว้ที่ https://codebrowser.dev/glibc/glibc/sysdeps/x86_64/multiarch...
และค่าเหล่านี้ก็ไม่ได้ตายตัวด้วย Noah Goldstein ยังอัปเดตมันอยู่ทุกปี
memcpyขนาดสั้น ยังรออยู่ให้rep movsbและrep stosbเร็วพอจนลบเวอร์ชันที่เป็นลูป C ธรรมดาทิ้งได้memcpy()ที่ความยาวเปลี่ยนแปลงได้ เหมือนที่มันอินไลน์memcpyความยาวคงที่อยู่แล้วมีประเด็นเกี่ยวกับ AVX512 ที่ไม่เห็นในบทความ นอกจากโอเวอร์เฮดของ
xsave/xrstorแล้ว AVX512 ยังกินพลังงานมากและทำให้เกิดการปรับสเกลความถี่ CPU ด้วย รายละเอียดและความซับซ้อนของเรื่องนี้ดูได้ที่ [1], [2][1] https://www.intel.com/content/dam/www/central-libraries/us/e...
[2] https://www.intel.com/content/www/us/en/developer/articles/t...
การสื่อสารระหว่างโปรเซส แทบทุกรูปแบบนั้น “ช้า” เป็นการยอมแลกต้นทุนด้านประสิทธิภาพเพื่อความปลอดภัย
โดยเฉพาะอย่างยิ่ง ไม่มีเหตุผลมากนักที่การสื่อสารระหว่างโปรเซสที่เร็วที่สุดจะช้ากว่าการเรียกฟังก์ชันยาวๆ
และกำลังโดน Hacker News hug of death อีกแล้ว ต้องขออภัยหากหน้าเว็บยังโหลดหลายวินาที แม้คราวนี้จะดีขึ้นกว่ารอบก่อนเพราะมี WordPress page caching
เดิมทีฉันไม่ค่อยเข้าใจว่าทำไม
spliceต้องช้าขนาดนั้น เขาชี้ว่ามันช้ากว่าvmspliceเพราะมีการจัดสรรบัฟเฟอร์และใช้คำสั่งแบบสเกลาร์ แต่ฉันไม่เข้าใจว่าทำไมสิ่งนั้นถึงจำเป็นทำไมเราไม่เขียน
spliceใหม่ให้เป็นแบบvmspliceไปเลยล่ะ? ต้องมีเหตุผลดีๆ แน่ แค่ฉันคงมองข้ามไปvmspliceใช้งานกับ file descriptor ได้ไม่ครบทุกประเภทน่าจะน่าสนใจถ้าได้เห็นเวอร์ชันที่ใช้ io_uring ด้วย เพราะอาจแชร์เคอร์เนลและบัฟเฟอร์ล่วงหน้าเพื่อหลีกเลี่ยงการคัดลอกบางส่วนได้ และน่าจะเลี่ยงโอเวอร์เฮดจาก system call ได้ด้วย เพียงแต่ในที่นี้อย่างหลังดูแทบไม่มีนัยสำคัญ
เป็นคำกล่าวที่ กล้ามาก สำหรับบล็อกที่โหลดนานราว 20 วินาที
ตัวบทความเองดูยอดเยี่ยม และมีอะไรให้เรียนรู้มากเกี่ยวกับสิ่งที่เกิดขึ้นภายใน