- บทเรียนแอสเซมบลีแรกของ FFmpeg สรุประดับเริ่มต้นว่าเหตุใด SIMD assembly ที่เขียนด้วยมือ จึงจำเป็นในการประมวลผลมัลติมีเดีย และธรรมเนียมการเขียนฟังก์ชันแบบ FFmpeg
- ผู้อ่านเป้าหมายควรรู้เรื่อง C pointer และแนวคิดระดับมัธยมปลายเกี่ยวกับสเกลาร์·เวกเตอร์ การบวก·การคูณ โดยบทเรียนอิง x86 64-bit และไวยากรณ์ Intel
- ใน FFmpeg คำว่า assembly function, SIMD, vectorise ถูกใช้แทบจะในความหมายเดียวกัน และวิธีประมวลผลองค์ประกอบข้อมูลหลายตัวในครั้งเดียวเหมาะกับการประมวลผลภาพ·วิดีโอ·เสียง
- ในแง่ประสิทธิภาพ จะนิยม แอสเซมบลีแบบเขียนเอง โดยมีการเปรียบเทียบว่า intrinsics มักช้ากว่าประมาณ 10~15% การทำ vectorization อัตโนมัติของ dav1d เร็วขึ้นประมาณ 2x ส่วนเวอร์ชันเขียนด้วยมือเคยทำได้ถึง 8x
- ฟังก์ชันตัวอย่างแรกใช้
x86inc.asm, INIT_XMM sse2, cglobal, movu, paddb, RET เพื่อบวกค่า 16 ไบต์จากบัฟเฟอร์ uint8_t สองตัวด้วย SIMD แล้วบันทึกกลับไปยังบัฟเฟอร์ตัวแรก
เป้าหมายของบทเรียนและความรู้พื้นฐานที่ต้องมี
- FFmpeg Assembly Language Lesson One เป็นบทเรียนเบื้องต้นที่ครอบคลุมพื้นฐานของวิธีเขียนภาษาแอสเซมบลีใน FFmpeg และช่วยให้เข้าใจว่าเกิดอะไรขึ้นจริงภายในคอมพิวเตอร์
- ความรู้ที่จำเป็นมีดังนี้
- ภาษา C โดยเฉพาะ pointer
- หากไม่รู้ C แนะนำให้ศึกษา The C Programming Language
- แนวคิดคณิตศาสตร์ระดับมัธยมปลายเกี่ยวกับสเกลาร์และเวกเตอร์ การบวก การคูณ
ภาษาแอสเซมบลีกับ SIMD
- ภาษาแอสเซมบลี คือภาษาโปรแกรมที่เขียนโค้ดซึ่งสอดคล้องโดยตรงกับคำสั่งที่ CPU ประมวลผล ให้อยู่ในรูปที่มนุษย์อ่านได้
- โค้ดแอสเซมบลีที่มนุษย์อ่านได้จะผ่าน assembler และถูกแปลงเป็นข้อมูลไบนารี machine code ที่ CPU เข้าใจ
- โค้ดแอสเซมบลีของ FFmpeg ส่วนใหญ่เป็นรูปแบบ SIMD(Single Instruction Multiple Data)
- คำสั่งเดียวทำงานกับองค์ประกอบข้อมูลหลายตัวพร้อมกัน
- เรียกอีกอย่างว่า vector programming
- การโปรแกรมแบบสเกลาร์ทั่วไปประมวลผลองค์ประกอบข้อมูลทีละตัว
- SIMD เหมาะกับการประมวลผลภาพ วิดีโอ และเสียงที่ต้องจัดการข้อมูลจำนวนมากซึ่งวางเรียงต่อเนื่องกันในหน่วยความจำ
- ใน FFmpeg สำนวนต่อไปนี้ถูกใช้แทบจะในความหมายเดียวกัน
assembly function
SIMD
vectorise
- หมายถึงการเขียนฟังก์ชันแอสเซมบลีด้วยมือเพื่อประมวลผลองค์ประกอบข้อมูลหลายตัวในครั้งเดียว
เหตุผลที่ FFmpeg เขียนแอสเซมบลีเองโดยตรง
- เป้าหมายหลักคือ เพิ่มความเร็วในการประมวลผลมัลติมีเดีย
- การเขียนโค้ดแอสเซมบลีมักทำให้เร็วขึ้นมากกว่า 10x
- สำคัญต่อการลดอาการสะดุดในการเล่นวิดีโอแบบเรียลไทม์
- สามารถลดการใช้พลังงานและเพิ่มอายุแบตเตอรี่ได้
- ฟังก์ชันเข้ารหัสและถอดรหัสวิดีโอถูกใช้งานอย่างมหาศาลทั้งโดยผู้ใช้ปลายทางและในดาต้าเซ็นเตอร์ ดังนั้นการปรับปรุงเล็กน้อยก็สะสมผลได้อย่างรวดเร็ว
- FFmpeg ใช้ แอสเซมบลีที่เขียนด้วยมือ แทน intrinsics
- intrinsics คือฟังก์ชันคล้าย C ที่สอดคล้องกับคำสั่งแอสเซมบลี
- โดยทั่วไปช้ากว่าแอสเซมบลีที่เขียนด้วยมือ 10~15%
- ตัวเลขนี้แตกต่างกันไปตามคอมไพเลอร์ และผู้สนับสนุน intrinsics อาจไม่เห็นด้วย
- ยังมีความเห็นว่าอ่านยากเพราะใช้ Hungarian Notation
- inline assembly อาจยังเหลืออยู่ในโค้ดรุ่นเก่าบางส่วนของ FFmpeg หรือโปรเจกต์อย่าง Linux Kernel
- เป็นวิธีเขียนแอสเซมบลีโดยตรงในโค้ด C ไม่ใช่ไฟล์แยก
- ในโปรเจกต์อย่าง FFmpeg ความเห็นส่วนใหญ่คืออ่านยาก คอมไพเลอร์รองรับไม่กว้าง และดูแลรักษายาก
- สำหรับจุดประสงค์การเรียน แนะนำให้มองข้ามความเห็นที่ว่าการทำ vectorization อัตโนมัติของคอมไพเลอร์ก็เพียงพอแล้ว
- ในการทดสอบล่าสุดของ โปรเจกต์ dav1d การทำ vectorization อัตโนมัติให้ความเร็วเพิ่มขึ้นประมาณ 2x
- เวอร์ชันที่เขียนด้วยมือสามารถทำได้ถึง 8x
ขอบเขตไวยากรณ์และเอกสารอ้างอิง
- บทเรียนมุ่งเน้น แอสเซมบลี x86 64-bit
- เรียกอีกอย่างว่า amd64 และทำงานบน CPU Intel ได้ด้วย
- แอสเซมบลีสำหรับ CPU อื่น เช่น ARM, RISC-V อาจขยายเพิ่มเติมในอนาคต
- ไวยากรณ์แอสเซมบลี x86 มีทั้ง AT&T และ Intel
- ไวยากรณ์ AT&T เก่ากว่า และมองว่าอ่านยากกว่าไวยากรณ์ Intel
- บทเรียนใช้ ไวยากรณ์ Intel
- หนังสือทั่วไปหรือแหล่งข้อมูลออนไลน์อย่าง Stack Overflow อาจไม่เป็นประโยชน์นักสำหรับอ้างอิงแอสเซมบลีของ FFmpeg
- เพราะใช้แอสเซมบลีไวยากรณ์ Intel ที่เขียนด้วยมือ
- แหล่งข้อมูลออนไลน์จำนวนมากมุ่งเน้นการโปรแกรมระบบปฏิบัติการ การโปรแกรมฮาร์ดแวร์ และโค้ดที่ไม่ใช่ SIMD
- แอสเซมบลีของ FFmpeg เป็นแนวทางเฉพาะที่เน้นการประมวลผลภาพประสิทธิภาพสูง
- แผนภาพในช่วงท้ายของ The Art of 64-bit assembly ที่แสดงภาพคำสั่ง SIMD และการทำงานของมันอาจเป็นประโยชน์
- มีเซิร์ฟเวอร์ Discord สำหรับถามคำถาม
แนวคิดพื้นฐานของรีจิสเตอร์
- รีจิสเตอร์ คือพื้นที่ภายใน CPU สำหรับประมวลผลข้อมูล
- CPU ไม่คำนวณกับหน่วยความจำโดยตรง แต่จะโหลดข้อมูลเข้ามาในรีจิสเตอร์ ประมวลผล แล้วเขียนกลับไปยังหน่วยความจำ
- ในแอสเซมบลี โดยทั่วไปไม่สามารถคัดลอกข้อมูลจากตำแหน่งหน่วยความจำหนึ่งไปยังอีกตำแหน่งหนึ่งโดยตรงได้ ต้องผ่านรีจิสเตอร์ก่อน
รีจิสเตอร์ใช้งานทั่วไป
- GPR(General Purpose Register) คือรีจิสเตอร์ใช้งานทั่วไปที่เก็บข้อมูลหรือที่อยู่หน่วยความจำได้
- ในที่นี้เก็บค่าได้สูงสุด 64-bit
- เก็บ pointer ได้ด้วย
- ทำปฏิบัติการอย่างการบวก การคูณ การ shift ได้
- หนังสือแอสเซมบลีจำนวนมากอธิบายรายละเอียดและภูมิหลังทางประวัติศาสตร์ของ GPR อย่างยืดยาว
- ในโค้ดแอสเซมบลีของ FFmpeg, GPR ส่วนใหญ่ถูกใช้เหมือน scaffolding และความซับซ้อนส่วนใหญ่ไม่จำเป็นหรือถูกทำให้เป็น abstraction
รีจิสเตอร์เวกเตอร์และขนาดข้อมูล
- รีจิสเตอร์เวกเตอร์ เก็บองค์ประกอบข้อมูลได้หลายตัว
- รีจิสเตอร์เวกเตอร์หลักของ x86 มีดังนี้
mm: รีจิสเตอร์ MMX ขนาด 64-bit เป็นของเก่าและปัจจุบันไม่ค่อยใช้แล้ว
xmm: รีจิสเตอร์ XMM ขนาด 128-bit ใช้งานได้แพร่หลาย
ymm: รีจิสเตอร์ YMM ขนาด 256-bit มีความซับซ้อนบางอย่างเมื่อใช้งาน
zmm: รีจิสเตอร์ ZMM ขนาด 512-bit มีโอกาสใช้งานจำกัด
- การคำนวณส่วนใหญ่ในการบีบอัดและคลายวิดีโอเป็นแบบ ฐานจำนวนเต็ม ดังนั้นบทเรียนจึงเน้นจำนวนเต็มด้วย
- รีจิสเตอร์
xmm หนึ่งตัวขนาด 128-bit สามารถตีความได้ดังนี้
- byte 16 ตัว แต่ละตัว 8-bit
- word 8 ตัว แต่ละตัว 16-bit
- doubleword 4 ตัว แต่ละตัว 32-bit
- quadword 2 ตัว แต่ละตัว 64-bit
- ตัวย่อจะถูกใช้สำคัญในภายหลัง
- byte: ข้อมูล 8-bit
- word: ข้อมูล 16-bit
- doubleword: ข้อมูล 32-bit
- quadword: ข้อมูล 64-bit
- double quadword: ข้อมูล 128-bit
บทบาทของ x86inc.asm
x86inc.asm คือ เลเยอร์ abstraction น้ำหนักเบา ที่ใช้ใน FFmpeg, x264, dav1d
- มีฟีเจอร์หลายอย่างเพื่อช่วยให้โปรแกรมเมอร์แอสเซมบลีเขียนโค้ดได้ง่ายขึ้น
- หนึ่งในฟีเจอร์สำคัญช่วงต้นคือการติดป้ายกำกับ GPR เป็น
r0, r1, r2 เป็นต้น
- ไม่ต้องจำชื่อรีจิสเตอร์จริง
- เพราะใน FFmpeg, GPR ส่วนใหญ่ทำหน้าที่เป็น scaffolding จึงลดภาระในการเขียนลง
ตัวอย่าง asm แบบสเกลาร์ง่าย ๆ
mov r0q, 3
inc r0q
dec r0q
imul r0q, 5
- บรรทัดแรกเก็บ immediate value
3 ลงในรีจิสเตอร์ r0 แบบ quadword
- immediate value คือค่าที่ถูกเก็บอยู่ในโค้ดแอสเซมบลีเอง ไม่ใช่ค่าที่ดึงมาจากหน่วยความจำ
- ในไวยากรณ์ Intel operand ต้นทางทางขวาจะถูกส่งไปยัง operand ปลายทางทางซ้าย
- อ่านได้เหมือน
r0q = 3
- ลำดับคล้ายการทำงานของ
memcpy
- suffix
q ของ r0q แสดงว่ารีจิสเตอร์ถูกใช้เป็น quadword
- การทำงานถัดไปเป็นดังนี้
inc ทำให้ค่าเป็น 4
dec ทำให้ค่ากลับเป็น 3
imul คูณด้วย 5 ทำให้สุดท้าย r0q เป็น 15
- คำสั่งที่มนุษย์อ่านได้อย่าง
mov, inc เรียกว่า mnemonic
- assembler จะแปลงสิ่งนี้เป็น machine code
- ตัวพิมพ์ใหญ่
MOV, INC และตัวพิมพ์เล็ก mov, inc เหมือนกัน
- ใน FFmpeg จะเขียน mnemonic ด้วยตัวพิมพ์เล็ก และสงวนตัวพิมพ์ใหญ่ไว้สำหรับ macro
ตัวอย่างฟังก์ชัน SIMD แรก
%include "x86inc.asm"
SECTION .text
;static void add_values(uint8_t *src, const uint8_t * src2)
INIT_XMM sse2
cglobal add_values, 2, 2, 2, src, src2
movu m0, [srcq]
movu m1, [src2q]
paddb m0, m1
movu [srcq], m0
RET
- ฟังก์ชันนี้บวกข้อมูลของ
src และ src2 ด้วย SIMD แล้วเก็บผลกลับไปที่ตำแหน่ง src
%include "x86inc.asm" รวม helper, ชื่อที่กำหนดไว้ล่วงหน้า และ macro ที่พัฒนาโดยชุมชน x264, FFmpeg, dav1d
SECTION .text ระบุ section ที่โค้ดสำหรับรันถูกวางไว้
- ข้อมูลค่าคงที่สามารถวางไว้ใน section
.data ได้
;static void add_values(uint8_t *src, const uint8_t * src2) คือคอมเมนต์ที่แสดงรูปแบบอาร์กิวเมนต์ของฟังก์ชัน C
- ในแอสเซมบลี เครื่องหมายเซมิโคลอน
; ทำหน้าที่เป็นคอมเมนต์เหมือน // ใน C
INIT_XMM sse2 ทำให้ใช้ รีจิสเตอร์ XMM และชุดคำสั่ง sse2
- เพราะ
paddb เป็นคำสั่ง sse2
cglobal add_values, 2, 2, 2, src, src2 กำหนดฟังก์ชัน C add_values
- มีอาร์กิวเมนต์ฟังก์ชัน 2 ตัว
- GPR ที่จะใช้ในฟังก์ชันรวมอาร์กิวเมนต์แล้วมี 2 ตัว
- จำนวนรีจิสเตอร์ XMM ที่จะใช้คือ 2 ตัว
- สองรายการสุดท้ายคือป้ายกำกับอาร์กิวเมนต์ฟังก์ชัน
src, src2
- โค้ดเก่าอาจใช้ GPR อย่าง
r0, r1 โดยตรงโดยไม่มีป้ายกำกับอาร์กิวเมนต์
load, packed add, store
movu m0, [srcq]
movu m1, [src2q]
movu เป็นรูปย่อของ movdqu และหมายถึง move double quad unaligned
- alignment จะกล่าวถึงในบทเรียนต่อ ๆ ไป ในที่นี้ให้มองว่าเป็นคำสั่งย้ายข้อมูล 128-bit จาก
[srcq]
- ใน
mov วงเล็บเหลี่ยมหมายถึงการ dereference ที่อยู่
- เป็นแนวคิดคล้าย
*src ใน C
- การทำงานนี้คือ load
- suffix
q หมายถึงขนาดของ pointer
- บนระบบ 64-bit หมายถึง 8 ไบต์ ซึ่งเป็นขนาด pointer ของ C
- x86asm ใช้ 32-bit บนระบบ 32-bit
- การ load จริงเป็น 128-bit
- รีจิสเตอร์เวกเตอร์ถูกอ้างอิงด้วย
m0 ที่เป็น abstraction แทนชื่อเต็มอย่าง xmm0
- เชื่อมโยงกับวิธีทำให้โค้ดเดียวกันรองรับขนาดรีจิสเตอร์ SIMD หลายแบบในบทเรียนต่อ ๆ ไป
paddb m0, m1
paddb บวกองค์ประกอบ byte ของแต่ละรีจิสเตอร์เข้าด้วยกัน
- prefix
p หมายถึง packed และใช้แยกคำสั่งเวกเตอร์ออกจากคำสั่งสเกลาร์
- suffix
b แสดงการบวกในหน่วย byte
- เมื่อนำรีจิสเตอร์สองตัวที่มี byte 16 ตัวมาบวกกัน จะบวกองค์ประกอบตามตำแหน่งที่สอดคล้องกัน เช่น
a+q, b+r, c+s
movu [srcq], m0
RET
movu [srcq], m0 เขียนข้อมูลผลลัพธ์กลับไปยังที่อยู่ที่ pointer srcq ชี้อยู่
RET เป็น macro ที่ระบุว่าฟังก์ชัน return
- ฟังก์ชันแอสเซมบลีแทบทั้งหมดของ FFmpeg แก้ไขข้อมูลที่รับเข้ามาเป็นอาร์กิวเมนต์ แทนที่จะ return ค่า
- ในแบบฝึกหัด จะต่อด้วยวิธีสร้าง function pointer สำหรับฟังก์ชันแอสเซมบลีที่ใช้งานได้และเรียกใช้มัน
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
แหล่งข้อมูลอื่นในหัวข้อเดียวกัน: https://blogs.gnome.org/rbultje/2017/07/14/writing-x86-simd-...
จากคอมเมนต์ที่นี่ จะเห็นว่าความมีประโยชน์ของ SIMD ที่เขียนด้วยมือมีความเห็นตั้งแต่ “ไม่ชัดเจนเลย” ไปจนถึง “จำเป็นต่อภารกิจ” แต่ดูเหมือนฝ่าย “ไม่ชัดเจนเลย” จะมีมากกว่า เลยขออธิบายฝั่ง จำเป็นต่อภารกิจ สักหน่อย
FFmpeg เป็นกรณีตัวอย่างที่ชัดเจนเพราะถูกใช้งานบ่อย แต่ผมคิดว่า dav1d ซึ่งเป็นตัวถอดรหัส AV1 สำหรับงาน production แบบทั่วไป เป็นตัวอย่างที่วัดผลกระทบของ SIMD ที่เขียนด้วยมือได้ง่ายกว่า
dav1d ถูกใช้แทบ everywhere ตั้งแต่เบราว์เซอร์หลัก ๆ ไปจนถึงระบบปฏิบัติการ Android และเข้ามาแทนที่ libgav1 โดยเหตุผลสำคัญของความสำเร็จคือความเร็วมหาศาล ซึ่งเป็นไปได้เพราะส่วนใหญ่ของ codebase เป็น SIMD ที่เขียนด้วยมือ
การที่ภาษาอย่าง Zig มีการรองรับ SIMD ในตัวเป็นเรื่องดี แต่สำหรับบาง use case ที่ควรตรวจสอบความต่างด้านประสิทธิภาพที่อาจเกิดขึ้น ก็จำเป็นต้องเขียนเอง โค้ดบางบรรทัดของ dav1d ถูกเรียกใช้วันละหลายล้านล้านครั้ง จึงต้องเร็วที่สุดเท่าที่เป็นไปได้ และความต่างระหว่าง SIMD ที่เขียนด้วยมือกับ SIMD ที่คอมไพเลอร์สร้างให้อาจมากถึง 50% ในบางกรณี จึงเป็นเรื่องสำคัญ
ผมเองก็มีส่วนเกี่ยวข้องอยู่บ้างกับงานเขียนโค้ดที่จะถูกเรียกใช้จำนวนมหาศาลคล้าย ๆ กัน จึงคิดว่าแหล่งข้อมูลอย่าง FFmpeg Assembly Language School ค่อนข้างสำคัญ หากต้องการไม่ให้ทักษะแบบนี้สูญหายไป
calling convention อาจต่างกันไปในแต่ละเมธอด ทำให้มีการบันทึกและโหลดจากสแต็กน้อยกว่าโค้ดที่คอมไพเลอร์สร้างขึ้นตาม calling convention ปกติของแพลตฟอร์มมาก
ตัวอย่างเช่น matrix multiplication ตัวใหม่ของเราเร็วกว่าไลบรารียอดนิยมสำหรับ LLM inference และมีบางกรณีที่เป็นเช่นนั้นแม้คู่แข่งจะใช้ AMX ส่วนเราใช้ AVX512BF16 เหตุผลอาจเป็นคอขวดด้าน threading หรืออาจเป็นอย่างอื่น แต่เมื่อมี JIT เข้ามาเกี่ยวข้องก็ยิ่งวิเคราะห์ได้ยาก
ถ้าเราต้องเขียน kernel เฉพาะแพลตฟอร์มเอง ผลลัพธ์แบบนี้คงไม่เกิดขึ้น เวลาต่อวันมีจำกัด และการเขียน implementation เดียวด้วย Highway ทำให้เราสำรวจ design space ที่กว้างกว่าได้ รวมถึง auto tuner ที่เลือกได้ไม่เพียงชนิด kernel ใหม่และ block size แต่ยังเลือกกลยุทธ์ parallelization และพารามิเตอร์ของมันด้วย
ในขั้นที่สองอาจจูนบางส่วนด้วยมือได้ แต่ผมหวังว่าจะมีการสำรวจภาพกว้างก่อน ก่อนจะไป micro-optimize เรื่อง register allocation และ calling convention
ผมชอบ Zig แต่เข้าใจว่าโดยมากก็ยังต้องใช้ intrinsic เฉพาะ CPU เหมือน C/C++
GCC และ Clang รองรับ attribute
vector_sizeและ operator overload สำหรับ arithmetic ของ type ที่ “ถูก vectorize” เหล่านั้น และนอกจากนี้ยังมีอีกมากมาย ที่จริง intrinsic อย่าง_mm256_mul_psก็ implement ประมาณ#define _mm256_mul_ps(a,b) (__m256)((v8sf)(a) * (v8sf)(b))ความมีประโยชน์ของฟีเจอร์เหล่านี้มากกว่าสิ่งที่ทำได้ใน Zig มาก
ฝั่ง encoder มักต้อง “จัดโครงสร้าง” ปัญหาเพื่อให้ตัด loop และ load ออกได้ตั้งแต่ต้นเป็นพิเศษ แต่คอมไพเลอร์ไม่สามารถสร้างโค้ด auto-vectorization ประเภทนั้นได้
สมัยก่อนเคยทำเวอร์ชัน SIMD ของฟังก์ชันหลักไว้ค่อนข้างมาก แต่ตอนนี้แทบไม่ได้ทำแล้ว วิธีที่น่าลองคือแยกโค้ดส่วนนั้นออกมาแล้วรันใน Compiler Explorer [0] อันยอดเยี่ยม
จากนั้นก็ดูโค้ดที่ถูกสร้างขึ้น
ทุกวันนี้ auto-vectorization มักสร้างเวอร์ชัน SIMD ของฟังก์ชันได้ค่อนข้างดี และหลายครั้งแค่ต้องให้ “hint” กับคอมไพเลอร์ เช่น ระบุการจัดแนว หรือให้ประเภทของ vector source/target เอง
ถ้า “จัดสไตล์” โค้ด C โดยคิดไปด้วยว่าคอมไพเลอร์น่าจะทำอะไรได้ ก็ทำอะไรได้มาก เช่น ใช้ตัวแปรกลางให้มากขึ้น และซอย operation ที่ต้องการให้ละเอียดมาก ๆ
ต่อให้เลวร้ายสุด ๆ คือคอมไพเลอร์ยังฉลาดไม่พอ ก็ยังสามารถเอา assembly ที่สร้างออกมาเป็นฐานแล้วแก้ไขได้ โดยไม่ต้องเขียน boilerplate เอง
ในกรณีส่วนใหญ่ ฟังก์ชัน C ที่ได้จะถูก vectorize ได้ดีเท่ากับหรือดีกว่าที่ผมเขียนมือเอง และในอีกหลายกรณีก็ “ใกล้พอ” จนแทบไม่ต่างกันมาก อีกทั้งโค้ดนั้นยังมีโอกาสสูงที่จะถูก vectorize ได้ดีบน WASM หรือ NEON โดยไม่ต้องมีเวอร์ชันเฉพาะด้วย
[0] https://godbolt.org/
ทั้ง GCC และ Clang รองรับ vector extension บางส่วน ทำให้สามารถทำสิ่งต่าง ๆ อย่าง scatter/gather load/store, shuffle, และการ mask องค์ประกอบในรีจิสเตอร์เดียว ได้แบบพกพาในระดับหนึ่ง ซึ่งเป็นสิ่งที่ถ้าเขียนเป็น C “ล้วน” จะสื่อให้อ่านง่ายและให้คอมไพเลอร์หลายเวอร์ชันสร้างโค้ดตามที่คาดไว้เสมอได้ยาก
แต่เพราะต้องรองรับคอมไพเลอร์และแพลตฟอร์มอื่นด้วย ในบิลด์จริงสุดท้ายก็เลยนำ assembly ที่สร้างจากไฟล์ซอร์สนั้นมาใช้
นี่คือ การบวกแบบ saturating ของ unsigned byte ทั้ง x86-64 และ ARM64 รองรับโดยตรงด้วยคำสั่งเดียวคือ PADDUSB และ UQADD.16B
แต่คอมไพเลอร์ทุกตัวทำพังจากคำอธิบายที่ตรงไปตรงมา ไม่ก็ vectorize ไม่สำเร็จ หรือสร้างโค้ด vectorized ที่ใหญ่และช้ากว่าจำเป็น
ทั้งที่นี่เป็น primitive operation สำหรับ vectorization ที่พื้นฐานและเรียบง่ายมาก ๆ แล้ว การทำให้คอมไพเลอร์ใช้คำสั่งที่ซับซ้อนกว่า เช่น rounded narrowing saturating right shift (UQRSHRN) นั้นยากหรือแทบเป็นไปไม่ได้
โดยปกติแล้ว เขียนเองไปเลยมักเร็วกว่า
pshufbที่กว้างกว่า 16 ไบต์ในกรณีทั่วไปแม้ในกรณีความกว้าง 16 ไบต์ ถ้าใช้ definition จริงของ
pshufbก็จะไม่ได้pshufbแต่ถ้าใช้เวอร์ชันที่มี undefined behavior ถึงจะได้pshufbผมเป็นผู้เขียนบทเรียนนี้
ถามอะไรก็ได้
ถ้ามี assembly มากขนาดนั้น การที่ FFmpeg ทำงานบน Mac ของผมได้ดูเหมือนปาฏิหาริย์เลย มันถูกพอร์ตด้วยมือหรือเปล่า?
ผมสงสัยว่า ณ จุดไหนถึงจะตระหนักว่า “อันนี้ถ้าเขียนเป็น assembly น่าจะเร็วขึ้นได้” ถ้าเจอฟังก์ชันที่ทำเป็น assembly แล้วน่าจะเร็วจริง ๆ จะเริ่มเขียนอย่างไร? เอาเอาต์พุตที่คอมไพเลอร์แปลงเป็น assembly มาเป็นจุดตั้งต้น หรือเขียนตั้งแต่ศูนย์? เรื่องนั้นสำคัญไหม?
ในแง่การใช้งานของนักพัฒนาและประสิทธิภาพของโค้ด เทียบกับ SIMD แบบดั้งเดิมแล้วเป็นอย่างไร? เรากำลังไปสู่โลกที่จำนวนชุดคำสั่ง SIMD ที่ต้องเขียนโปรแกรมลดลงหรือไม่?
x86asm.incเป็นตัวจัดการ หรือไม่ได้ใส่ใจเรื่องนี้เขียนแค่ test แล้วให้ LLM ลองอัลกอริทึม 10,000 แบบ จากนั้นให้ profile ผลลัพธ์ไม่ได้หรือ?
หรือแม้ให้ random seed 10,000 ค่า LLM ก็ยังหาคำตอบที่ดีที่สุดได้ยาก?
ถามเพราะสงสัยเฉย ๆ การ optimize x86 ด้วยมือไม่ใช่เรื่องง่าย เวลาคิดต้องจัดรีจิสเตอร์ทั้งหมดไว้ในหัวและไล่ดูชุดผสมต่าง ๆ แถมยังต้องรู้ด้วยว่าแต่ละชุดคำสั่งใช้เวลานานแค่ไหน อีกทั้งบางคำสั่งยังมีกรณีพิเศษแปลก ๆ ที่มนุษย์คำนึงถึงได้ยาก ซึ่งอาจทำให้ช้าลงมากหรือเร็วขึ้นมากได้
อยากถามคนที่เคยลองทำจริง ๆ ว่า การเรียนหรือเขียนแอสเซมบลีมี ความสนุก แบบ LISP หรือ RISC-V ไหม หรือเป็นสิ่งที่เรียนเพื่อไปทำงานอย่างอื่น เหมือนเรียน COBOL เพราะงานระบบบางอย่าง?
สนใจมาตลอด แต่ในงานประจำก็ไม่มีเหตุผลชัดเจนให้ลงลึก เลยสงสัยว่าคุ้มไหมที่จะทุ่มเวลาเพื่อความสนุก
ตอนนี้ค่อนข้างชอบเขียนแอสเซมบลีแล้ว หลังจากบทช่วยสอนก็ไม่ได้ทำมากนัก นอกจากทำไลบรารีอาร์เรย์ที่เรียกจาก C ได้
ในระดับนี้ เหมือนแทบไม่เหลือ เวทมนตร์ ให้ค้างคา เลยรู้สึกว่าสนุก คุณบอกอย่างแม่นยำจริง ๆ ว่าต้องเกิดอะไรขึ้น และสิ่งที่เห็นโดยมากก็กลายเป็นผลลัพธ์ตามนั้น
ยังทำให้เข้าใจการลิงก์ดีขึ้นมาก และช่วยให้เข้าใจสิ่งที่เคยรู้ในระดับสูงแต่รายละเอียดเคยคลุมเครือด้วย
ตอนนี้ก็อยากไปดูบทช่วยสอน FFmpeg นี้เหมือนกัน เพราะอันนี้เป็น x86 ไม่ใช่ ARM
มีโมเดลเชิงทฤษฎีที่เรียบง่ายกว่านี้อย่าง Turing machine หรือ lambda calculus แต่สถาปัตยกรรมที่โปรแกรมเมอร์ใช้งานจริงก็มีคุณสมบัติที่ผ่อนปรนอยู่ระดับหนึ่ง
ไม่ใช่สิ่งที่ต้องกลัว แอสเซมบลีไม่ได้ซับซ้อนเท่ากับ ยืดยาว ทำอะไรก็เต็มไปด้วยการโหลดและสโตร์ โหลดและสโตร์ ซ้ำแล้วซ้ำอีก
ถ้าเติมมาโครกับการตรวจตอน build time เข้าไปเล็กน้อย หรือวางไว้ในบริบทของระบบ Forth ที่ห่ออินเทอร์พรีเตอร์สำหรับ “รันก้อนแอสเซมบลี” เพื่อให้พัฒนาแบบโต้ตอบและสคริปต์ได้ มันก็ไม่ได้ห่างจาก C มากนัก และช่วยตัดเวทมนตร์ของคอมไพเลอร์ออกไป
แนะนำให้ลองเข้าหาแบบเรโทรด้วย เครื่อง 8 บิตในอีมูเลเตอร์จะจำกัดโมเดลการทำงานไว้ในพื้นที่เล็ก ๆ ที่มีเอกสารดี และข้อจำกัดทำให้วิธีคิดที่จะลองทำงานมากขึ้นด้วยแอสเซมบลีมีคุณค่า บนสถาปัตยกรรมหลังยุค 32 บิตที่ทรัพยากรเหลือเฟือ เรื่องแบบนี้ไม่ค่อยเกิดขึ้น
คนที่พัฒนาแอสเซมบลีเป็นงานอาจมีความชอบที่เฉพาะเจาะจงกว่านี้ แต่สำหรับมือใหม่ สิ่งที่ต้องการที่สุดคือสภาพแวดล้อมที่มีเอกสารและตัวอย่างดี ๆ Rosetta Code มีตัวอย่างแอสเซมบลีดี ๆ ที่ใช้เรียนได้
จำได้ว่าในคลาสมหาวิทยาลัย เราแข่งกันว่าใครจะเขียนโปรแกรมแอสเซมบลีที่ทำงานหนึ่งได้เร็วที่สุด ทุกคนพยายามเค้นประสิทธิภาพสูงสุดและลองวิธีคลี่ลูปหลายแบบเพื่อหลีกเลี่ยง branch prediction ที่แย่
ไม่รู้ว่าคืนนั้นก่อนส่งงานไปถึง Ballmer Peak หรือเปล่า แต่ผมลองโครงสร้างที่คนอื่นส่วนใหญ่พลาด แล้วชนะไปแบบเฉียดฉิว
อีกอย่าง ยังมีความสนุกมหาศาลจากการดู https://github.com/chrislgarry/Apollo-11 แล้วพูดเล่นว่า “นี่มันระบบ Unix นี่นา รู้จักอันนี้!” ความอัศจรรย์ที่อ่านภาษาที่พามนุษยชาติไปดวงจันทร์ได้ไม่เคยจางหาย
ตอบสั้น ๆ คือ ใช่
ช่วงเวลาที่เข้าใจว่าทรานซิสเตอร์ ลอจิกเกต สถาปัตยกรรม CPU และการเขียนโปรแกรมระดับสูงประกอบเข้าด้วยกันอย่างไร คุ้มค่ากับความพยายาม แม้จะไม่ใช้แอสเซมบลีในงานอาชีพเลยก็ตาม
บางครั้งก็มีประโยชน์ แต่ความสนุกส่วนใหญ่มาจากการวางไบต์สุดท้ายให้อยู่ตรงที่ของมัน หรือขุดคุ้ยไบนารีที่ไม่มีใครแตะมาหลายสิบปี หรือสร้างอีมูเลเตอร์ที่เมื่อก่อนทำไม่ได้
มันเป็นหนึ่งในไม่กี่ด้านที่ยังรู้สึกถึง เวทมนตร์ เหมือนตอนเริ่มต้นใหม่ ๆ
ส่วนตัวแล้วไม่คิดว่าการเขียนแอสเซมบลีเองแทนการใช้อินทรินซิกจะมีคุณค่ามากนัก แต่ การอ่านแอสเซมบลี ช่วยได้จริง ๆ
ผมใช้ Compiler Explorer(https://godbolt.org/) บ่อย ๆ เพื่อดูแอสเซมบลีที่สร้างขึ้น และเข้าใจการปรับแต่งที่คอมไพเลอร์ทำตอน optimize ประสิทธิภาพ
“เพื่อทำให้การประมวลผลมัลติมีเดียเร็วขึ้น การเขียนโค้ดแอสเซมบลีมักทำให้ได้ความเร็วเพิ่มขึ้นเกิน 10 เท่า และสำคัญเป็นพิเศษเมื่อพยายามเล่นวิดีโอแบบเรียลไทม์โดยไม่สะดุด”
ดีใจที่เห็น การอ้างอิง K&R นั่นเป็นหนังสือที่ผมซื้อมาเพื่อเรียน C และการเขียนโปรแกรมโดยรวม
ตอนแรกลองเริ่มด้วย C++ เป็นภาษาแรก แต่รู้สึกว่ามันนามธรรมเกินไปและเรียนยาก เพราะผมเอาแต่สงสัยว่าข้างในเกิดอะไรขึ้นกันแน่
เอกสารนี้กำลังดีเลย ผมรู้ x86 assembly สมัย 386 แต่บนโปรเซสเซอร์ที่สูงกว่านั้นมันซับซ้อนเกินไป
อยากเรียน SIMD บน CPU รุ่นใหม่ให้มากขึ้น ดูเป็นแหล่งข้อมูลที่ยอดเยี่ยม
ประโยคที่ว่า “โปรดสังเกตว่าส่วนต่อท้าย
qระบุขนาดพอยน์เตอร์ *(*กล่าวคือ ใน C บนระบบ 64 บิต หมายถึง *sizeof(*src) == 8 และ x86asm ฉลาดพอที่จะใช้ 32 บิตบนระบบ 32 บิต) แต่การโหลดจริงเป็น 128 บิต” นั้นชวนสับสนi.eน่าจะเป็นi.e.,และ*(*หมายถึงอะไร? มันควรเป็นแค่วงเล็บเปิดเฉย ๆ ไม่ใช่หรือ?ผมก็ไม่รู้ด้วยว่าในบริบทไหน
*sizeof(*src)ถึงจะถือว่าใช้ได้ เท่าที่รู้sizeofไม่ได้ให้ผลลัพธ์เป็นพอยน์เตอร์รู้สึกเหมือนมีดอกจันสุ่ม ๆ โรยอยู่ในประโยคนั้น หรือพยายามผสมดอกจันที่หมายถึงตัวเอียงกับไวยากรณ์ C แล้วพลาด
ไม่สนใจเรื่องวิธีแบ่งส่วน แค่อยากบอกว่าคู่มือนี้ดีจริง ๆ
ตอนที่เคยสนใจเรื่อง ระดับต่ำมาก ๆ น่าจะมีเอกสารแบบนี้อยู่ด้วย
แอสเซมบลีเร็วกว่า C ถึง 10 เท่าจริงหรือ? ในช่วงหนึ่งมันคงเคยเป็นเรื่องจริงแน่ ๆ แต่ตอนนี้ยังเป็นแบบนั้นอยู่ไหม? คอมไพเลอร์หยุดนิ่งถึงขนาดเข้าใกล้แอสเซมบลีที่เขียนมือไม่ได้จริงหรือ?
นักพัฒนา FFmpeg ค่อนข้างขึ้นชื่อว่าไม่เห็นด้วยกับการใช้อินทรินสิก และถ้าจำไม่ผิด แม้ประสิทธิภาพจะดีเทียบเท่าแอสเซมบลีที่เทียบกันได้ ก็ยังไม่ยอมรับเข้า codebase อยู่ดี อย่างไรก็ตาม จากการประเมินในบทความเอง ความต่างระหว่างอินทรินสิกกับแอสเซมบลี ก็อยู่ราว 10~15%
ถ้าเปรียบเทียบแอสเซมบลีที่ปรับแต่งอย่างละเอียดกับ C แบบตรงไปตรงมา ในกรณีที่สามารถทำ vectorization ได้แต่คอมไพเลอร์นำไปใช้ไม่ได้ ก็อาจเห็นความต่าง 10 เท่าได้ เรื่องแบบนี้พบได้บ่อย เพราะ auto-vectorization พอเกินกรณีง่าย ๆ ไปแล้ว โดยรวมก็ยังไม่ค่อยดีนัก
อย่างไรก็ตาม ก็ไม่น่าแปลกใจที่โค้ดที่ผู้เชี่ยวชาญเขียนจะเหนือกว่าโค้ดแบบตรงไปตรงมาอย่างมาก
พูดให้ง่ายกว่านั้น คอมไพเลอร์ C ไม่สามารถดูแค่ implementation C ธรรมดา ๆ แล้วอนุมานได้ว่าผู้เขียนพยายามแสดงคณิตศาสตร์บางอย่างด้วย SIMD intrinsics ที่มีประสิทธิภาพกว่า เพราะมันเข้าถึง เจตนาเชิงคณิตศาสตร์ ของผู้เขียนไม่ได้
ยังมีข้อพิจารณาเฉพาะตามเป้าหมายด้วย คอมไพเลอร์โดยธรรมชาติย่อมเป็นคอมไพเลอร์อเนกประสงค์ ปัญหาทรัพยากร เช่น การจัดสรรรีจิสเตอร์ เป็นปัญหา NP-complete และเทียบเท่ากับปัญหา knapsack แทบไม่มีใครอยากให้คอมไพเลอร์ใช้เวลาหลายชั่วโมงเพื่อหาแอสเซมบลีที่เหมาะสมที่สุดแบบสัมบูรณ์ และอีกเรื่องหนึ่งคือจะรู้ความเหมาะสมที่สุดนั้นแบบ static ได้หรือไม่
คอมไพเลอร์ฉลาดกับโค้ดทั่วไป แต่ codec ไม่ใช่โค้ดทั่วไป ผมไม่ใช่โปรแกรมเมอร์ FFmpeg แต่มีพื้นฐานที่เคยทำงานกับเสียงมา
สำหรับปัญหาที่ใช้ SIMD ได้ เราสามารถคาดหวังได้อย่างสมเหตุสมผลว่าจะเร็วกว่า implementation แบบ scalar ตรงไปตรงมาประมาณ 2 ถึง 16 เท่า
ผมคงเขียนแอสเซมบลีให้ดีกว่า C ได้ 10 เท่าไม่ได้ แต่ก็จะไม่สมมติว่าไม่มีใครทำได้