1 คะแนน โดย GN⁺ 2024-08-27 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • เมื่อเขียนข้อมูลลงใน 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 ที่เกี่ยวข้องกับการเขียนลง pipe
    • copy_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 ความคิดเห็น

 
GN⁺ 2024-08-27
ความเห็นจาก 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 เลยแม้แต่น้อย กำลังมองหาคนมาร่วมทำ

    • อย่างน้อยถ้าเป็น use case ใน user space ก็ไม่แน่ใจว่าจำเป็นต้องมีฟีเจอร์ใหม่ใน kernel จริงหรือไม่ เมื่อก่อนเคยทำ ring buffer แบบ single-producer/single-consumer ใน user space ที่เลียนพฤติกรรมของ pipe ได้ค่อนข้างใกล้เคียงโดยใช้ eventfd
      มันสามารถ sleep/poll ได้เมื่อ ring buffer เต็มหรือว่าง และนอกเหนือจากนั้นก็ทำงานได้แบบไม่ต้องล็อกและไม่มี system call overhead
    • สงสัยว่ามีการวางแผน วิธีส่งสัญญาณมาตรฐาน เพื่อบอกอีกฝั่งหรือไม่ว่าปลายทั้งสองของ pipe รองรับ ring buffer แบบนี้ เพื่อให้ libc จัดการให้แบบโปร่งใสได้ ไม่อย่างนั้นก็ยังไม่ค่อยเห็นว่ามันมีข้อได้เปรียบอะไรเหนือ shared memory กับการซิงก์ด้วย futex สำหรับกรณีของ pipe โดยเฉพาะ
    • เป็นไปได้ว่า ringbuffer_wait() ก็อาจส่งสัญญาณผ่าน poll() ในสถานะ พร้อมอ่าน ได้เหมือนกัน
    • สงสัยว่าอินเทอร์เฟซ ring buffer ที่มีอยู่จะหันมาใช้สิ่งนี้หรือไม่ หรือจะกลายเป็น สถานการณ์แบบ xkcd927 แทน ไม่ว่าอย่างไรก็ดูเป็นความพยายามที่น่าสนใจ
    • การมีบัฟเฟอร์นั้นมีเหตุผลของมัน และแนวทางนี้อาจสร้าง failure mode แปลก ๆ และช่องโหว่ในสคริปต์ได้ ประเด็นสำคัญคือผู้ผลิต stream คนไหนก็อาจช้ากว่าผู้บริโภคบางรายได้เสมอ
      แค่มี hiccup ชั่วครู่ก็พอจะทำให้ pipe พังได้ถ้าไม่มีบัฟเฟอร์มากพอ และขนาดบัฟเฟอร์ที่ต้องใช้ก็แตกต่างกันไปในแต่ละระบบ
  • ที่ JMP ไม่ใช่ RET ตรง ๆ เป็นเพราะตัวเลือก CONFIG_RETHUNK ใน objdump disassembly ที่เห็นคือผลจากการแทน RET ด้วย JMP __x86_return_thunk
    https://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/...

    • ต่อให้รู้รายละเอียดพวกนี้ ก็น่าจะมีนักพัฒนา kernel อยู่ไม่กี่คนเท่านั้นที่ยังจะเลือกใช้นามแฝงใช้ครั้งเดียวแบบกวน ๆ อย่างนั้น
  • การเรียก Linux pipe ว่า “ช้า” ก็เหมือนเรียก Toyota Corolla ว่า “ช้า” ถ้าไม่ใช่ use case สุดขั้ว มันก็เร็วพออยู่แล้ว
    แข่งรถอยู่หรือเปล่า? แล้วเป็นประเภทที่ความเร็วสำคัญกว่าทักษะไหม? ถ้าใช่ก็ซื้อรถที่เร็วกว่า ไม่อย่างนั้นก็ขับ Corolla ต่อไป

    • นี่ไม่ใช่โค้ดในโปรเจกต์บางตัวที่จะถูกรันแค่หลายพันล้านครั้งตลอดอายุงาน แต่มันคือโค้ดที่ถูกใช้บ่อยบนคอมพิวเตอร์ตั้งแต่หลายล้านไปจนถึงหลายพันล้านเครื่อง
      ดังนั้นต่อให้ปรับปรุงประสิทธิภาพได้เพียงเล็กน้อยมาก ๆ ก็ยังคุ้มทางเศรษฐกิจที่จะใช้เวลาไปกับการ optimize
    • สำหรับผู้เขียนบทความเอง pipe ที่ว่า “ช้า” นั้นกำลังย้ายข้อมูลได้ที่ 17GB/s หรือมากกว่า 130Gbps
      ใช้ pipe มาสารพัดงานเกิน 10 ปีแล้วก็ไม่เคยติดคอขวดเพราะความเร็วของ pipe เลย ส่วนใหญ่เครื่องมืออย่าง tar, gzip, find, grep, nc น่าจะเป็นคอขวดมากกว่า แม้แน่นอนว่าเมื่อเทียบกับสิ่งที่มันทำ เครื่องมือพวกนี้ก็เร็วมากอยู่ดี
    • มีโปรเจกต์หนึ่งที่ใช้ SDK แบบ proprietary สำหรับถอดรหัสวิดีโอดิบ มันส่งข้อมูลที่ถอดรหัสแล้วออกมาเป็น RGBA ล้วน แล้วให้ FFMpeg อ่านผ่าน pipe เพื่อนำไปเข้ารหัสใหม่ด้วย codec มาตรฐาน
      FFMpeg ไม่สามารถรวม Non-Free SDK เข้าไปในซอร์สได้ และการบันทึก RGBA ล้วนลงไฟล์ก็ไม่สมเหตุสมผลแบบสุด ๆ ดังนั้น pipe จึงเป็นทางเดียว และนี่ก็เป็นเหตุผลที่สมควรสำหรับการต้องการ pipe throughput สูง
    • การทำให้สิ่งที่ถูกใช้อยู่ทุกหนแห่งเร็วขึ้นอีกไม่กี่เปอร์เซ็นต์นั้นคุ้มค่ามาก ถึงงานแต่ละชิ้นจะไม่ได้เร็วขึ้นอย่างเห็นได้ชัด แต่เมื่อรวมกันทั่วโลกแล้วจะประหยัดได้ทั้ง พลังงานและเวลา มหาศาล
    • บางครั้ง Corolla ที่เร็วขึ้นก็อาจเป็นคำตอบที่ดีที่สุดจริง ๆ
      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 ยังอัปเดตมันอยู่ทุกปี
    • อีกจุดที่น่าสนใจคือ Linux ได้เปลี่ยนวิธีใช้ ERMS และ FSRM สำหรับการคัดลอกบน x86 หลายครั้งหลังเคอร์เนล 6.1 ที่ใช้ในบทความ อ้างอิงจากเครื่องของฉันที่มีทั้ง FSRM และ ERMS — ซึ่งน่าแปลกที่ตัวแรกไม่ได้บอกเป็นนัยว่าต้องมีตัวหลัง — บน Linux 6.8 ท่อธรรมดาพร้อมบัฟเฟอร์ 32KiB ทำได้ 17GB/s
    • สำหรับ memcpy ขนาดสั้น ยังรออยู่ให้ rep movsb และ rep stosb เร็วพอจนลบเวอร์ชันที่เป็นลูป C ธรรมดาทิ้งได้
    • ถ้าอย่างนั้นก็น่าสงสัยว่าเมื่อไร C compiler จะอินไลน์ 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...

    • เรื่องนั้นเกิดกับ CPU บางรุ่นของ Intel เท่านั้น
  • การสื่อสารระหว่างโปรเซส แทบทุกรูปแบบนั้น “ช้า” เป็นการยอมแลกต้นทุนด้านประสิทธิภาพเพื่อความปลอดภัย

    • แต่จริงๆ ไม่จำเป็นต้องเสียต้นทุนมากขนาดนั้นก็ได้ สิ่งที่ pipe ให้มามีไม่มาก ต้นทุนก็ควรแทบไม่มี
      โดยเฉพาะอย่างยิ่ง ไม่มีเหตุผลมากนักที่การสื่อสารระหว่างโปรเซสที่เร็วที่สุดจะช้ากว่าการเรียกฟังก์ชันยาวๆ
    • pipe ไม่ได้มีไว้เพื่อความปลอดภัย แต่มีไว้เป็น การเพิ่มประสิทธิภาพ สำหรับส่งข้อมูลระหว่างโปรแกรมที่มีอยู่แล้ว
  • และกำลังโดน Hacker News hug of death อีกแล้ว ต้องขออภัยหากหน้าเว็บยังโหลดหลายวินาที แม้คราวนี้จะดีขึ้นกว่ารอบก่อนเพราะมี WordPress page caching

  • เดิมทีฉันไม่ค่อยเข้าใจว่าทำไม splice ต้องช้าขนาดนั้น เขาชี้ว่ามันช้ากว่า vmsplice เพราะมีการจัดสรรบัฟเฟอร์และใช้คำสั่งแบบสเกลาร์ แต่ฉันไม่เข้าใจว่าทำไมสิ่งนั้นถึงจำเป็น
    ทำไมเราไม่เขียน splice ใหม่ให้เป็นแบบ vmsplice ไปเลยล่ะ? ต้องมีเหตุผลดีๆ แน่ แค่ฉันคงมองข้ามไป

    • คำตอบที่เป็นไปได้อยู่ด้านล่างนี้เอง: https://news.ycombinator.com/item?id=41351870
      vmsplice ใช้งานกับ file descriptor ได้ไม่ครบทุกประเภท
  • น่าจะน่าสนใจถ้าได้เห็นเวอร์ชันที่ใช้ io_uring ด้วย เพราะอาจแชร์เคอร์เนลและบัฟเฟอร์ล่วงหน้าเพื่อหลีกเลี่ยงการคัดลอกบางส่วนได้ และน่าจะเลี่ยงโอเวอร์เฮดจาก system call ได้ด้วย เพียงแต่ในที่นี้อย่างหลังดูแทบไม่มีนัยสำคัญ

  • เป็นคำกล่าวที่ กล้ามาก สำหรับบล็อกที่โหลดนานราว 20 วินาที

    • แต่บทความนี้ขึ้นไปอยู่บนสุดของ Hacker News แล้ว ก็คงต้องมองกันอย่างใจดีหน่อย
      ตัวบทความเองดูยอดเยี่ยม และมีอะไรให้เรียนรู้มากเกี่ยวกับสิ่งที่เกิดขึ้นภายใน