Mutex ที่เร็วที่สุด
(justine.lol)- ในสถานการณ์ที่มี การแข่งขันสูง ความแตกต่างของการติดตั้งใช้งาน Mutex จะเห็นได้ชัดเจน โดย
pthread_mutex_tของ Cosmopolitan Libc แสดงเวลาในการรันที่สั้นกว่าและการใช้ CPU ที่ต่ำกว่าการติดตั้งใช้งานหลักบน Windows และ Linux - ในการทดสอบบน Windows ด้วย 24-core Threadripper 29070WX Cosmopolitan เร็วกว่า Microsoft SRWLOCK 2.75 เท่า และใช้ทรัพยากร CPU น้อยกว่า 18 เท่า
- บน Linux ด้วย 96-core Threadripper Pro 7995WX เร็วกว่า glibc 3 เท่า และเร็วกว่า musl libc 11 เท่า โดยช่องว่างของเวลา CPU ยิ่งกว้างกว่าเดิม
- บน MacOS M2 Ultra นั้น Apple Libc นำหน้าเล็กน้อย และ Cosmopolitan ใช้อัลกอริทึมที่เรียบง่ายซึ่งพึ่งพา system call ulock ของ XNU บนสภาพแวดล้อม ARM
- พื้นฐานของประสิทธิภาพนี้มาจากการผนวกรวม nsync ของ Google โดยมี CAS fast path, คิวผู้รอ, futex/ulock/
WaitOnAddress(), การป้องกัน starvation และการออกแบบ designated waker เป็นหัวใจสำคัญ
วิธีทำเบนช์มาร์ก Mutex ภายใต้การแข่งขัน
- การทดสอบจะสร้าง 30 เธรด และให้แต่ละเธรดเพิ่มค่าเลขจำนวนเต็มส่วนกลาง
g_choresเดียวกัน 100,000 ครั้ง - การเพิ่มค่าแต่ละครั้งจะทำใน critical section ที่เล็กมาก ระหว่าง
pthread_mutex_lock()และpthread_mutex_unlock() - ค่าที่วัดใช้หน่วยไมโครวินาที และแยกเวลาออกเป็นสามแบบ
- wall time: เวลาจริงที่โปรแกรมใช้รัน โดยรวมโอเวอร์เฮดของการสร้างเธรดและ join
- user time: เวลา CPU ที่ใช้ใน user space
- system time: เวลา CPU ที่ใช้ในเคอร์เนล
- เนื่องจากหลายเธรดรันแบบขนาน ผลรวมของ user time และ system time จึงอาจมากกว่า wall time ได้
- ในสถานการณ์ที่ไม่มีการแข่งขัน ความต่างด้านประสิทธิภาพระหว่างการติดตั้งใช้งานมักเล็ก แต่ใน สถานการณ์ที่มีการแข่งขัน ความต่างของการออกแบบ Mutex จะปรากฏชัด
Windows: Cosmopolitan เร็วกว่า SRWLOCK
- การทดสอบบน Windows ทำบน 24-core Threadripper 29070WX
- MutexShootout ของ Mark Waterman ประเมินว่า SRWLOCK ของ Windows เป็นการติดตั้งใช้งานที่แข็งแกร่งที่สุดในสถานการณ์ที่มีการแข่งขันสูง
- ภายใต้เงื่อนไขเดียวกัน
pthread_mutex_tของ Cosmopolitan ทำ wall time ได้สั้นกว่า SRWLOCK และใช้ CPU ต่ำกว่า
| การติดตั้งใช้งาน | wall time | user time | system time |
|---|---|---|---|
Cosmopolitan pthread_mutex_t |
148,940µs | 328,125µs | 62,500µs |
| Microsoft SRWLOCK | 410,416µs | 5,515,625µs | 1,640,625µs |
Microsoft CRITICAL_SECTION |
949,187µs | 7,937,500µs | 5,078,125µs |
MSVC 2022 std::mutex |
991,750µs | 12,156,250µs | 4,031,250µs |
| spin lock | 1,165,435µs | 24,515,000µs | 15,000µs |
Cygwin pthread_mutex_t |
9,780,803µs | 1,937,000µs | 6,156,000µs |
- Cosmopolitan Mutex เร็วกว่า 2.75 เท่า เมื่อเทียบกับ Microsoft SRWLOCK และใช้ทรัพยากร CPU น้อยกว่า 18 เท่า
- เมื่อเทียบกับ Cygwin Mutex ซึ่งให้การติดตั้งใช้งาน POSIX บน Windows แล้ว เร็วกว่า 65 เท่า
- Cygwin Mutex ให้ผลลัพธ์ที่ช้ากว่า spin lock เสียอีกในกรณีใช้งานนี้
Linux: ช่องว่างของเวลา CPU มากกว่า wall time
- การทดสอบบน Linux ทำบน 96-core Threadripper Pro 7995WX
| การติดตั้งใช้งาน | wall time | user time | system time |
|---|---|---|---|
Cosmopolitan pthread_mutex_t |
36,905µs | 44,511µs | 23,492µs |
glibc pthread_mutex_t |
101,353µs | 150,706µs | 2,724,851µs |
| spin lock | 202,423µs | 4,694,749µs | 2,000µs |
Musl libc pthread_mutex_t |
411,013µs | 2,167,898µs | 9,926,850µs |
- Cosmopolitan Mutex เร็วกว่า glibc 3 เท่า และ เร็วกว่า musl libc 11 เท่า
- หากดูจากเวลา CPU จะใช้ทรัพยากรน้อยกว่า 42 เท่า เมื่อเทียบกับ glibc และน้อยกว่า 178 เท่า เมื่อเทียบกับ musl libc
- ในเวิร์กโหลดที่ทุกเธรดต้องทำงานแบบอนุกรม Cosmopolitan อาจดูใน
htopเหมือนมีคอร์เดียวที่ทำงานอยู่ - ในสถานการณ์เดียวกัน glibc และ musl libc อาจกินการใช้งาน CPU ได้สูงมาก ทำให้เป็นภาระมากขึ้นเมื่อรันหลายงานบนเซิร์ฟเวอร์เดียวกัน
MacOS: Apple Libc นำหน้าเล็กน้อย
- การทดสอบบน MacOS ทำบน M2 Ultra
| การติดตั้งใช้งาน | wall time | user time | system time |
|---|---|---|---|
| Apple Libc | 52,263µs | 43,202µs | 911,009µs |
Cosmopolitan pthread_mutex_t |
54,700µs | 63,055µs | 1,003,674µs |
- บน MacOS M2 ARM64 นั้น Apple Libc เร็วกว่า Cosmopolitan Mutex เล็กน้อย
- การติดตั้งใช้งาน Mutex แบบทั่วไปของ Cosmopolitan ทำงานได้ไม่ดีบนแพลตฟอร์มนี้
- บน MacOS ARM นั้น Cosmopolitan ใช้อัลกอริทึมที่เรียบง่ายกว่าซึ่งอิงจาก Futexes Are Tricky ของ Ulrich Drepper
- วิธีนี้มอบงานหนักส่วนใหญ่ให้กับ system call ulock ของ XNU ทำให้ได้ประสิทธิภาพเกือบเทียบเท่าการติดตั้งใช้งานของ Apple
พื้นฐานของประสิทธิภาพ: การผนวกรวม nsync
- หัวใจของประสิทธิภาพ Cosmopolitan Mutex คือการผนวกรวมไลบรารี nsync ของ Google
- nsync เป็นไลบรารีที่มี GitHub star 371 ดวง และเขียนโดย Mike Burrows แห่ง Google
- ระหว่างกระบวนการผนวกรวมเข้ากับ Cosmopolitan ได้มีการทำงานดังต่อไปนี้
- ค้นพบและแก้บั๊กที่ไม่เคยถูกพบมานานในฟังก์ชัน unlock ของ Mutex ใน nsync
- พอร์ตไปใช้ C11 atomic operation บน AARCH64 ทำให้ nsync Mutex ภายใต้การแข่งขัน เร็วกว่า upstream nsync 30%
- เขียนการผนวกระดับระบบอย่าง futex ขึ้นใหม่เพื่อให้รองรับการพกพาของรันไทม์
- ทำให้ทำงานร่วมกับการยกเลิกเธรดของ POSIX ได้อย่างราบรื่น
วิธีการทำงานของ nsync
- nsync จะเริ่มจากลอง CAS(compare and swap) แบบมองโลกในแง่ดีทันที เพื่อให้ได้ล็อกอย่างรวดเร็ว
- หากได้ล็อกไม่สำเร็จ จะเพิ่มเธรดผู้เรียกเข้าไปใน ลิสต์เชื่อมโยงสองทาง ของผู้รอ
- ผู้รอแต่ละรายมี semaphore ของตัวเองอยู่บน cache line แยกอิสระ
- เมื่อเธรดเข้าสู่สถานะรอแล้ว จะไม่แตะต้องล็อกหลักอีก
- สิ่งนี้สำคัญต่อการลดโอเวอร์เฮดการสื่อสารที่เกิดขึ้นเมื่อหลายคอร์แตะ cache line เดียวกัน
- พื้นหลังที่เกี่ยวข้องเชื่อมไปยัง What Every Programmer Should Know About Memory ของ Ulrich Drepper
- nsync ใช้ futex ของระบบปฏิบัติการเพื่อทำให้เธรดหลับ
- บน MacOS futex ถูกเรียกว่า ulock
- บน Windows
WaitOnAddress()ทำหน้าที่แบบเดียวกับ futex - ในบรรดาระบบปฏิบัติการที่ Cosmo รองรับ มีเพียง NetBSD ที่ไม่มี futex โดยใช้ POSIX semaphore ที่ติดตั้งใน kernel space และต้องใช้ file descriptor ใหม่สำหรับแต่ละ semaphore
- nsync หลีกเลี่ยง starvation ด้วยแนวคิด “long wait”
- หากผู้รอถูกปลุก 30 ครั้งแต่ล้มเหลวในการได้ล็อกทุกครั้งภายใน ก็จะเพิ่มบิตบนล็อกเพื่อไม่ให้เธรดที่ยังไม่เคยรอได้ล็อกก่อน
- เมื่อมีบิตนี้อยู่ CAS เริ่มต้นของเธรดที่เพิ่งเข้ามาใหม่จะล้มเหลวจนกว่าคิวรอจะถูกระบายออกไปในระดับหนึ่ง
- กรณีใช้งานที่มีการแข่งขันบน critical section ขนาดเล็กจะเร็วขึ้นด้วยแนวคิด designated waker
- เมื่อมีเธรดบางตัวตื่นขึ้นเพื่อพยายามได้ล็อก จะมีการตั้งค่าบิตบนล็อกหลัก
- ใน nsync ฟังก์ชัน unlock เป็นผู้รับผิดชอบในการปลุกเธรดผู้รอถัดไป
- บิตนี้ช่วยให้เธรดที่กำลัง unlock ไม่จำเป็นต้องปลุกผู้รอคนที่สอง หากมีเธรดที่ถูกปลุกแล้วอยู่ก่อนหน้า
- ซอร์สโค้ดที่เกี่ยวข้องอยู่ใน
cosmopolitan/third_party/nsync/mu.cและcosmopolitan/libc/intrin/pthread_mutex_lock.c
บริการจริงและโค้ดสำหรับตรวจสอบ
- เดโมสดที่ใช้ Cosmo Mutex สามารถดูได้ที่เซิร์ฟเวอร์ http://ipv4.games/
- บริการนี้รันอยู่บน GCE VM 2 คอร์ และจนถึงตอนนี้เคยทนต่อบอตเน็ต DDoS ขนาดสูงสุด 49,131,669 IP มาแล้ว
- ด้วย nsync จึงสามารถย้าย SQL query ไปยังเธรดเบื้องหลัง และใช้โครงสร้างที่เธรดส่งข้อความหากันได้
- สามารถดูตัวชี้วัดสถานะได้ที่ /statusz
- โค้ดเบนช์มาร์กวัด wall time ด้วย
gettimeofday()และวัด user time กับ system time ด้วยgetrusage() - ตอนท้ายจะตรวจสอบว่า
g_chores == THREADS * ITERATIONSเพื่อยืนยันว่ามีการทำงานเพิ่มค่าครบทั้งหมด
ข้อควรระวังเมื่อดู spin lock
- ในสถานการณ์ที่ไม่มีการแข่งขัน ความต่างระหว่างการติดตั้งใช้งาน Mutex มีน้อย และ spin lock ที่เขียนไม่กี่บรรทัดอาจดีกว่าก็ได้
- แต่ควรใช้ spin lock เฉพาะเมื่อไม่มีทางเลือกอื่นจริง ๆ เท่านั้น
- มันมีประโยชน์ในที่อย่างเคอร์เนล ซึ่งมีข้อจำกัดระดับต่ำมากจนใช้วิธีที่ซับซ้อนกว่านี้ได้ยาก
- spin lock ยังอาจถูกใช้เป็นรายละเอียดการติดตั้งใช้งานภายในของ nsync lock ได้ด้วย
- หากดูประสิทธิภาพของ lock จาก wall time เพียงอย่างเดียว spin lock อาจดูดี ดังนั้นควรตรวจดู เวลา CPU ร่วมกันด้วยผ่าน
getrusage()
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
การได้เห็นการนำ mutex แบบใหม่มาเทียบกันนั้นน่าสนใจเสมอ แต่วิธีทำเบนช์มาร์กนี้ไม่ค่อยถูกใจ ดูแทบจะเป็น ไมโครเบนช์มาร์ก
คนที่นำ lock เร็ว ๆ ไปใช้จริงมักใช้โปรแกรมมัลติเธรดขนาดใหญ่มากเป็นเครื่องมือทดสอบประสิทธิภาพหลัก ในเวิร์กโหลดที่ซับซ้อน ซึ่งความยาวของ critical section จำนวนเธรดที่แย่งกัน และระดับการแข่งขันเปลี่ยนแปลงหลากหลาย ปัจจัยที่ทำให้ mutex เร็วหรือช้าดูเหมือนจะต่างกันไป
เผื่อเป็นข้อมูล ผมเป็นคนเขียน lock เร็วของ WebKit คิดค้น ParkingLot abstraction สำหรับการทำ lock (ใช้ใน Rust และ Unreal Engine ด้วย) และเคยทำงานวิจัยกับ paper เกี่ยวกับ lock เร็วสำหรับ Java มาก่อน
ในฐานะโปรแกรมเมอร์เสียงแบบเรียลไทม์ ค่าใช้จ่ายในการจับ mutex ที่ยังไม่ได้ถูกล็อกอยู่สำคัญกว่า ในแอปของเรา สถานการณ์นี้เกิดขึ้นมากที่สุดอย่างท่วมท้น ในทำนองเดียวกัน ผมอยากรู้ค่าใช้จ่ายของ
try-lockoperation ที่จะล้มเหลวด้วย ไม่ใช่ตอนที่มีเธรด N ตัวมาแย่งกันCosmopolitan เป็นโอเพนซอร์ส ดังนั้นจะวัดเองก็ได้ แต่ก็ยังรู้สึกเสียดาย
เหมือนกับ hashmap นั่นแหละ แทบไม่มี hashmap เดียวที่ดีกว่าสำหรับทุกเวิร์กโหลดที่เป็นไปได้
mutex ที่เมื่อได้ lock ไม่สำเร็จแล้วหลับเป็นเวลาคงที่ (เช่น 100µs) แทบจะทำให้งานถูกรวมเป็นก้อนเสมอ จึงเข้าใกล้พฤติกรรมแบบนี้และอาจ “ชนะ” ในเบนช์มาร์กได้ แต่ในแอปพลิเคชันจริง ถ้ามีการแข่งขันแม้เพียงเล็กน้อย mutex แบบนั้นจะแย่มาก
ไม่ได้หมายความว่า mutex นี้แย่ หรือ pthread mutex ดี แต่หมายความว่าไมโครเบนช์มาร์กนั้นไม่ได้วัดสิ่งที่น่าจะทำนายประสิทธิภาพของแอปพลิเคชันจริงได้
ตรงส่วนที่ว่า “เหตุผลที่ Cosmopolitan Mutex ดีคือเพราะใช้ไลบรารี nsync” ผมเพิ่งเคยได้ยินชื่อ nsync เป็นครั้งแรก แต่ Mike Burrows ก็เป็นคนเขียน implementation ของ mutex ที่ใช้ใน production ของ Google ด้วย: https://github.com/abseil/abseil-cpp/blob/master/absl/synchr...
เลยสงสัยว่าทำไม implementation ของ mutex ตัวนี้ถึงไม่อยู่ในเบนช์มาร์ก และถ้าบน macOS มอบหมายให้
__ulockทำงาน การใช้แค่ member functionswait(),notify_one()ใน atomic library ของ libc++ ก็น่าจะทำให้ได้ผลแบบเดียวกันอย่างเรียบง่ายกว่าเคยมีเธรดใหญ่เกี่ยวกับการปรับปรุง implementation ของ mutex ใน Rust ด้วย: https://github.com/rust-lang/rust/issues/93740#issuecomment-... จุดที่น่าสนใจคือมีการคุยรายละเอียดการทำงานภายในของ implementation mutex ยอดนิยมแทบทั้งหมด
อาจเป็นเรื่องจริง แต่ผมยืนยันเองไม่ได้ เขาเป็นวิศวกรที่ฉลาดสุด ๆ และให้ความสำคัญกับประสิทธิภาพมาก เพียงแต่เราไม่ได้รันอะไรบนเซิร์ฟเวอร์เครื่องเดียวนาน ๆ
implementation ของ mutex ใน Rust ปัจจุบันเพิ่งถูกนำเข้าเมื่อต้นปีนี้ และแม้บน Linux อาจไม่ได้ต่างกันมาก แต่บน Windows กับ Mac ผมเข้าใจว่าเป็นงานใหม่
ถึงอย่างนั้น สิ่งที่ Mara อธิบายเกี่ยวกับภายในของ implementation อื่น ๆ ก็ยังน่าสนใจอยู่ แต่ควรตรวจดูด้วยว่าข้อมูลนั้นล้าสมัยสำหรับสถานการณ์ของตัวเองหรือไม่
https://awards.acm.org/award-recipients/burrows_9434147
ประโยคที่ว่า “ถึงจะยังเป็นไลบรารี C ใหม่ที่ยังมีจุดหยาบอยู่บ้าง แต่มันดีขึ้นเร็วมากจนการไม่ใช้ในโปรดักชันเริ่มดูเหมือนเป็นการละเลยความรับผิดชอบทางวิชาชีพ” ฟังดูค่อนข้างแปลก แม้จะประเมิน โปรเจกต์ Cosmopolitan ไว้สูง แต่คำกล่าวอ้างเรื่องความเหนือกว่าแบบเกินจริงเช่นนี้มักเป็นสัญญาณเตือนที่ค่อนข้างไม่ดี
เข้าใจได้ว่าบางคนอาจมองว่ามันแรงเกินไป ก่อนหน้านี้ก็เคยเกิดดราม่าในลักษณะนั้นกับ llamacpp มาแล้ว
สิ่งที่สำคัญที่สุดในโปรดักชันไม่ใช่ “ดีขึ้นเร็วอย่างเหลือเชื่อ” แต่คือ เสถียรภาพ ความคาดการณ์ได้ และความน่าเชื่อถือ แน่นอนว่าประสิทธิภาพก็สำคัญ โค้ดที่เร็วขึ้นอาจช่วยลดอินฟราสตรักเจอร์ ลดต้นทุน และดีต่อสิ่งแวดล้อมได้ แต่ความเร็วอยู่ลำดับท้ายสุด
ตัวอย่างเช่น APE รู้สึกว่าเป็นแฮ็กที่น่าประทับใจมาก แต่ก็สามารถวิจารณ์ได้ว่า “นี่หมายความว่าตอนนี้มันไม่ได้แค่ไม่ปลอดภัยบนแพลตฟอร์มเดียว แต่สามารถไม่ปลอดภัยพร้อมกันได้หลายแพลตฟอร์มแล้วหรือ?”
ยิ่งอยู่ในวงการเทคโนโลยีนานขึ้น ก็ยิ่งตระหนักว่าผลประโยชน์ร่วมแบบได้กันถ้วนหน้าอย่างสมบูรณ์นั้นหายากมาก และส่วนใหญ่ล้วนเป็น trade-off ที่มีทั้งสิ่งที่ได้และสิ่งที่เสีย
ออกนอกประเด็นไปไกลเลย แต่ในฐานะนักพัฒนาเกม ผมกลับชอบ mutex ที่ช้า ซึ่งทำงานดีบักเยอะ ๆ ในทุก developer build เช่น มีชื่อ/ID สำหรับดีบัก ติดตาม owner รายงานเวลาที่ใช้ไปกับ contention ไปยัง profiler และรายงานการเปลี่ยน ownership ไปยัง profiler ด้วย
เกมมักจัดโครงสร้าง concurrency แตกต่างออกไป และก็มีการพัฒนาแพตเทิร์นเพื่อหลีกเลี่ยง lock แต่แพตเทิร์นเหล่านั้นใช้งานยาก และโปรแกรมเมอร์ต้องเปลี่ยนโครงสร้างโค้ด โค้ดส่วนใหญ่เริ่มจาก “ใส่ lock ตรงนี้ไปก่อน แล้วให้ผ่าน milestone ให้ได้”
lock ที่เร็วก็อาจช้าลงอย่างคาดเดาไม่ได้ และถ้าเคยมี real-time guarantee ก็จะทำให้พังได้ โดยเฉลี่ยอาจเร็ว แต่ tail latency ก็ไม่ได้หายไป ผมไม่อยากเป็นคนที่ต้องกลับมาตามหาว่า “ทำไมเกมเรากระตุก” แต่โดยปกติแล้วผมก็มักกลายเป็นคนนั้น
ดังนั้นใช้ lock ที่ช้าดีกว่า แบบที่เห็นเป็นสีแดงก้อนใหญ่ใน profiler นั่นแหละ ถ้าเห็นว่ามันโดนหนักอยู่ ก็ refactor เพื่อลบมันออกได้
รู้ว่านี่เป็นข้อเรียกร้องที่ยาก ในโปรดักชันระดับ AAA คนที่ใช้ profiler เป็นมีนับนิ้วได้ ต่อให้ดูมาหลายโปรดักชันก็เป็นแบบนั้นเสมอ
ขอโทษที่บ่น แต่ก็หวังว่างานวิจัยเรื่อง primitive และอัลกอริทึม concurrency ที่เร็วจะเดินหน้าต่อไป
ในเกม ถ้าเป็นไปได้ เราไม่อยากมี lock contention เลย และในหลายกรณีก็พิสูจน์ได้ว่าการจับ lock นั้นไม่จำเป็น ตัวอย่างเช่น แต่ละเฟรมถูกแบ่งเป็นหลาย phase และการเข้าถึงแบบ mutable ไปยัง shared resource บางอย่างจำเป็นเฉพาะใน phase หนึ่งเท่านั้น เช่น
update()ก่อนrender()หรือการ hot reload แอสเซ็ตเมื่อใช้ scoped thread กับกฎการยืมของ Rust เราสามารถจัดโครงสร้างให้ไม่ต้องใช้ mutex เลย และมั่นใจได้ว่าหากภายหลังโค้ดเปลี่ยนจนจำเป็นต้องใช้จริง ๆ compiler จะรายงาน error อย่างเข้มงวดทันที
ถ้าเลือกได้ การได้ compile error ย่อมดีกว่า spike ใน profiler เสมอ
ในอีกด้านหนึ่ง กลุ่ม Cosmo/APE/redbean ดูยอดเยี่ยมจริง ๆ และคอมเมนต์ในบทความที่เกี่ยวข้องก็ส่วนใหญ่เป็นบวก แทบไม่มีเนื้อหาที่โต้แย้งแนวคิดเองด้วย แต่ในอีกด้านหนึ่ง ผมแทบไม่เคยได้ยินว่ามีคนอื่นใช้สิ่งนี้อยู่เลย
แน่นอนว่าไม่ใช่ทุกคนจะแชร์งานของตัวเองอย่างกว้างขวาง แต่ถ้าผ่านมาหลายปีแล้วก็น่าจะได้เห็นบทความ retrospective ของโปรเจกต์สักสองสามชิ้นบ้าง การกล่าวถึง Cosmo/APE/redbean ที่ผมเห็นทั้งหมดมาจากไซต์ของ Justine ทั้งนั้น
เลยสงสัยว่า มีหลุมพรางที่ซ่อนอยู่หรือเปล่า? เป็นเครื่องมือที่ทำอะไรไม่ดีบางอย่างเพื่อให้ได้ผลลัพธ์หรือเปล่า? เป็นมุกหรือการ troll แบบ tom7 ที่ผมไม่เข้าใจเพราะไม่รู้ลึกเรื่อง compiler หรือ runtime หรือเปล่า? หรือจริง ๆ แล้วเป็นเครื่องมือสุดบรรเจิดที่ยังไม่แพร่หลายเท่านั้น?
คนส่วนใหญ่ที่ทำซอฟต์แวร์ข้ามแพลตฟอร์มไม่ได้ต้องการไฟล์ executable เดียวที่รันได้บนทุกแพลตฟอร์ม แต่ต้องการ codebase เดียวที่ทำงานได้ถูกต้องบนแต่ละแพลตฟอร์มที่รองรับ
จากมุมมองนั้น ภาษาอย่าง Go ที่หากเลี่ยง CGO ได้ก็ cross-compile ไปยังทุก target ได้ จึงเป็นสิ่งที่น่าใช้ แต่เวทมนตร์ของ APE ที่รันได้สามรูปแบบ แม้จะฉลาดมาก ก็ไม่ได้ทำให้มั่นใจว่าจะทำงานได้ตลอดไป และสำหรับคนส่วนใหญ่ก็แทบไม่มีประโยชน์เชิงปฏิบัติมากนัก
แต่ละแพลตฟอร์มมีข้อกำหนดด้าน packaging และ signing ของตัวเอง ดังนั้น compile แยกเป็น target รายแพลตฟอร์มจะดีกว่า
ตัวอย่างเช่น ถ้าคุณสามารถ cross-compile โปรเจกต์ไปยังระบบปฏิบัติการและแพลตฟอร์มอื่น ๆ ได้อยู่แล้ว หรือมี build infrastructure แบบนั้นอยู่แล้ว ก็ไม่มีเหตุผลต้องหาโซลูชันที่สร้าง binary เดียวที่ทำงานได้ทุกที่
อีกอย่าง APE ใช้การแฮ็กที่ฉลาดเพื่อให้รันได้บนหลายระบบปฏิบัติการ แล้วถ้าสักวันหนึ่งการแฮ็กนั้นพังเพราะรูปแบบไฟล์ executable พัฒนาไปล่ะ? ถ้าไม่มีใครมีเวลาปรับ APE ให้รองรับความเปลี่ยนแปลงนั้นล่ะ?
ในทางกลับกัน เครื่องมือที่น่าเบื่อ อย่าง gcc, clang, go, rust จะยังคงได้รับการอัปเดต และยังทำงานต่อไปได้บนระบบปฏิบัติการที่วิวัฒนาการอยู่เรื่อย ๆ ดังนั้นผมจึงเลือกอยู่กับฝั่งที่น่าเบื่อ เหตุผลที่ไม่สนใจของฉลาด ๆ ก็เพราะของน่าเบื่อมันทำงานได้ดีสำหรับผมอยู่แล้ว
จะรันโดยไม่รวม weights เข้าไป แล้วให้อ่าน weights จาก filesystem ก็ได้ นี่อาจเป็นวิธีที่ง่ายที่สุดในการ “ดาวน์โหลดแล้วรันได้ทันที” สำหรับ local LLM
แต่ถ้าจะใช้เป็นเทคโนโลยีพื้นฐานอย่าง
libcก็ดูเหมือนจะมีประโยชน์หลัก ๆ ในฐานะของเล่นสนุก ๆ หรือโปรเจกต์ส่วนตัวขนาดเล็กมากกว่าในบริบทนั้น เวลามันถูกนำเสนอเป็นทางเลือกจริงจังแทนสิ่งอย่าง
glibc,musl,msvcrtจึงรู้สึกแปลกนิดหน่อย เป็นการแฮ็กที่น่ารักมาก แต่ถ้าผมพบว่ามีสิ่งที่ผมพึ่งพาอย่างจริงจังใช้อยู่ ก็คงค่อนข้างตกใจบน Hugging Face ก็มีการอัปโหลดโมเดลยอดนิยมที่ repackaging เป็นรูปแบบนั้นอยู่เป็นประจำ: https://huggingface.co/models?search=llamafile
อย่างไรก็ตาม มันจะมีประโยชน์ใช้งานจริงมากไปกว่าการลองโมเดลขนาดเล็กอย่างรวดเร็วหรือไม่ ก็เป็นอีกประเด็นหนึ่ง
ถ้ามันดีขนาดนั้น ก็สงสัยว่าทำไมไลบรารี C ทุกตัวถึงไม่เอาทริกเดียวกันไปใช้
เดาว่าทริกพวกนั้นอาจเร็วเสมอเฉพาะบนสถาปัตยกรรมบางแบบ, CPU บางรุ่น, เวิร์กโหลดหรือแพตเทิร์นการเข้าถึงบางอย่างเท่านั้น ถ้าเบนช์มาร์กเวิร์กโหลดหลากหลายบนฮาร์ดแวร์ทั้งหมดที่รองรับอย่างถูกต้อง อาจไม่ได้ข้อดีแบบเดียวกันก็ได้
หรือไม่เช่นนั้น ความหมายเชิงสัญญา (semantics) ของ pthread API ที่ Cosmopolitan พยายาม implement อาจแตกต่างกันอย่างละเอียด และ implementation นี้อาจไม่ได้ปฏิบัติตามสเปกอย่างเคร่งครัด
นึกภาพยากว่าผู้เขียน libc หลายรายจะตามงานวิจัยล่าสุดของ primitive ระดับระบบปฏิบัติการไม่ทัน
malloc ของ glibc ก็พอใช้ได้ แต่ในด้านความเร็วและความสามารถในการขยายโดยรวม แพ้ทางเลือกที่ทันสมัยกว่าได้ง่าย มี fragmentation หนักและแย่ลงเมื่อเวลาผ่านไป อีกทั้งยังมีค่าปรับแต่งอย่าง
MALLOC_ARENA_MAXจำนวนมากที่ส่งผลใหญ่ต่อเวิร์กโหลดจริง ส่วน musl malloc นั้นแย่มากในแง่ประสิทธิภาพทุกระดับ การใช้ allocator ของ musl ในโปรแกรมหลายเธรดเคยทำให้ประสิทธิภาพพังหนักจนแทบเรียกได้ว่าเป็นความประมาทmusl ยังไม่มีของอย่างรูทีนเปรียบเทียบสตริงที่ปรับแต่งด้วย SIMD ด้วย ถ้ารู้ว่า CPU cycles ถูกใช้ไปกับงานพวกนี้มากแค่ไหนในโปรแกรมที่ไม่เล็กน้อย คุณจะประหลาดใจ และมันก็เห็นชัดในโปรไฟล์จริง การปรับปรุงส่วนนี้ทำให้แทบทุกโปรแกรมดีขึ้นโดยทั่วไป รูทีนที่ปรับแต่งของ glibc นั้นดี แต่ก็ดูเหมือนว่ายังเร็วขึ้นได้อีก
สิ่งเหล่านี้ไม่ใช่ “การปรับแต่งที่เฉพาะกับสถาปัตยกรรมเดียวจนทั่วไปไม่ได้” โดยเฉพาะสองด้านนี้เป็นพื้นที่ที่มีการสำรวจและเข้าใจกันดี ซึ่งลดเวลาแบบ wall-clock ได้ 2~5 เท่าในแทบทุกเวิร์กโหลด และยังปรับปรุงการใช้ working set ระยะยาวได้มาก แล้วทำไมถึงไม่ถูกนำไปใช้? เป็นไปได้ว่าสาเหตุเหมือนเคย คือมีอย่างอื่นต้องทำ หรือมีลำดับความสำคัญที่ขัดกัน เช่น musl ที่ให้ความสำคัญกับความเรียบง่ายมากกว่าประสิทธิภาพสูงสุด
ไม่ได้จะโทษโปรเจกต์เหล่านี้ ไม่มีใครพูดว่า “โปรแกรมของฉันช้าแย่มาก ถูกออกแบบมาให้ทำอะไรไม่ดีสักอย่าง และฉันภูมิใจกับมัน” เพียงแต่ความคิดที่ว่าคนทำโปรเจกต์เหล่านั้นเลือกออกแบบเฉพาะ Pareto frontier ที่สมบูรณ์แบบนั้นไม่สมจริงเลย และไม่สะท้อนวิธีที่โปรเจกต์ส่วนใหญ่ดำเนินไปจริง ๆ
การจะเปลี่ยนอะไรใน glibc หรือสิ่งเทียบเท่าฝั่ง C++ ใช้เวลานานเหมือนตลอดกาล
synchronization primitive มีหลายชนิด แต่ pthreads รองรับแค่บางส่วน ถ้าจำกัดตัวเองไว้แค่นั้น โดยมากก็เท่ากับยอมเสียประสิทธิภาพเพื่อแลกกับ portability
ผมไม่รู้เรื่อง maintainer ของ libc แต่ในฐานะคนที่ maintain บางอย่างอยู่ ผมไม่ได้พยายาม implement งานวิจัยล่าสุด สิ่งที่ทำคือรักษาเสถียรภาพและตรวจให้แน่ใจว่าประสิทธิภาพยอมรับได้ การนำงานวิจัยมา implement อยู่นอกงบ “การบำรุงรักษา” ของผม
ชายคนหนึ่งกับนักสถิติกำลังเดินไปตามถนนแล้วเห็นธนบัตร 50 ยูโร นักสถิติเดินต่อไป ส่วนชายคนนั้นหยุดแล้วพูดว่า “ดูสิ มีเงินอยู่บนพื้น” นักสถิติจึงตอบว่า “คงเป็นของปลอมแหละ ถ้าเป็นของจริงคงมีคนเก็บไปแล้ว” แล้วก็เดินต่อไป ชายอีกคนจึงหยิบเงินนั้นไป
เธรดและ mutex เป็นองค์ประกอบที่ทำให้วิทยาการคอมพิวเตอร์ซับซ้อนที่สุดอย่างหนึ่ง implementation ใหม่ ๆ ควรถูกมองอย่างกังขาเสมอ จนกว่าจะถูกใช้งานในวงกว้างเป็นเวลาหลายปี
บั๊กในกลไก threading แบบนี้มักหลุดรอดแม้แต่การตรวจทานที่เข้มข้นที่สุด ตอน Java โผล่มาช่วงกลางยุค 90 ก็เผยบั๊กสารพัดเกี่ยวกับเธรดและ mutex บน Solaris
สิ่งที่ต้องการไม่ใช่ implementation ของ mutex ที่เร็วที่สุด แต่คือ implementation ที่เชื่อถือได้
โค้ดนี้ไม่ได้เบนช์มาร์กประสิทธิภาพของการล็อก mutex แต่เบนช์มาร์ก การแข่งขันแย่ง mutex ถ้าคุณใช้ล็อกแบบนี้อยู่ ก็ควรประเมินโค้ดใหม่
ทุกครั้งที่แต่ละเธรดเพิ่มค่า
g_choresก็จะล็อกและปลดล็อก mutex ทำให้เกิด overhead จากการ acquire และ release mutex บ่อย ๆ โดยทำซ้ำ 100,000 ครั้งต่อเธรดoverhead นี้บดบังความแตกต่างด้านประสิทธิภาพจริงระหว่างกลไกล็อก เพราะเบนช์มาร์กถูกครอบงำด้วยการแข่งขันแย่งล็อก ไม่ใช่งานจริง เบนช์มาร์กแบบนี้ไม่มีประโยชน์
เป็นแฟนของ Justine และผลงานของเธอ แต่กรณีนี้น่าจะเป็น test case สำหรับเบนช์มาร์ก mutex ที่น่าสนใจน้อยที่สุดแล้ว สถานการณ์ที่หลายเธรดคอยทุบ mutex ตัวเดียวกันไม่หยุด เป็นสิ่งที่ควรหลีกเลี่ยงตั้งแต่แรก
ดังนั้น implementation ของ mutex ตัวไหนจัดการกรณีนี้ได้ดีที่สุดจึงไม่ค่อยน่าสนใจนักในมุมมองของผม
มีเรื่องสำคัญอีกอย่างที่ขาดไป คือในสถานการณ์ที่มีการแข่งขัน ล็อกที่ประสิทธิภาพแย่อาจสร้างผลกระทบเชิงระบบที่เลวร้ายมาก เช่นทำให้เกิด hotspot ใน memory network และสิ่งนี้ก็น่าจะปรากฏให้เห็นที่นี่ด้วย
มีหลายกรณีที่นึกออกซึ่งหลายเธรดมารุม mutex ตัวเดียวกัน ตัวอย่างง่าย ๆ คือการเติมข้อมูลลงโครงสร้างข้อมูลอย่าง list หรือ dictionary พร้อมกัน
ใช้ message passing ก็ทำได้ แต่ก็อาจใช้หน่วยความจำมากกว่า และช้ากว่าการรอเพื่อเขียนลงตำแหน่งร่วมกัน
production ไม่ได้เกี่ยวกับความเร็ว ประสิทธิภาพ หรือสิ่งที่เห็นชัดว่าเป็น “แฮ็กฉลาด ๆ”
ถ้าต้องสละประสิทธิภาพ 50% เพื่อรับประกันว่าจะไม่ถูกเรียกให้ไปซ่อมระบบที่พังตอนตี 3 วันอาทิตย์ ผมก็จะเลือกแบบนั้นทุกครั้ง
production เป็นเรื่องของ ความน่าเชื่อถือ และการเขียนโค้ดที่เชื่อถือได้ยากกว่าการเขียนโค้ดที่ “เร็ว” ถึง 10 เท่า