1 คะแนน โดย GN⁺ 2 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • SIMD ไม่ใช่เทคนิคซับซ้อนสำหรับซอฟต์แวร์สมรรถนะสูงเท่านั้น แต่เป็นวิธีเพิ่มประสิทธิภาพที่ใช้ได้ในชีวิตประจำวัน โดยประมวลผลข้อมูลต่อเนื่องครั้งละหลายค่าเพื่อเร่งลูปธรรมดา
  • โดยทั่วไปโค้ด SIMD จะมีโครงสร้าง 5 ขั้นตอนคือ กระจายค่าคงที่, วนลูปทีละเวกเตอร์, คำนวณแบบขนาน, ยุบผลลัพธ์หรือบันทึกผล, และจัดการส่วนท้ายแบบสเกลาร์
  • ลูปค้นหา code point ของ Ghostty เปรียบเทียบ u32 ได้ครั้งละ 4·8·16 ค่า และในทางทฤษฎีสามารถเพิ่ม throughput ได้สูงสุด 4 เท่าบน ARM NEON, 8 เท่าบน AVX2 และ 16 เท่าบน AVX-512
  • throughput โดยรวมของเทอร์มินัลบนเดสก์ท็อป Intel ที่รองรับ AVX2 เร็วขึ้นประมาณ 5 เท่า และถ้าไม่มีความกว้างเวกเตอร์ที่รองรับหรือยังมีอินพุตเหลืออยู่ ลูปสเกลาร์เดิมจะจัดการทั้งอินพุตหรือส่วนที่เหลือ
  • auto-vectorization ของคอมไพเลอร์อาจพลาดโอกาสแม้ในลูปง่าย ๆ ดังนั้นควรตรวจผลลัพธ์ที่ optimize แล้วก่อน แต่สำหรับ hot loop สำคัญ การเขียน SIMD แบบชัดเจนช่วยให้พฤติกรรมและประสิทธิภาพคาดเดาได้

SIMD ทำอะไร

  • SIMD ทำให้ CPU ประมวลผลหลายค่าแบบขนานด้วยคำสั่งเดียว
    • แทนที่จะเปรียบเทียบทีละไบต์ ก็สามารถเปรียบเทียบได้ครั้งละ 4, 8 หรือมากกว่านั้น
    • ลูปอย่าง for (byte in bytes), for (character in string), for (value in array) มีโอกาสแปลงเป็นการประมวลผล ตามความกว้างเวกเตอร์
  • ถ้าข้อมูลมีตั้งแต่หลายร้อย หลายพัน หรือหลายล้านไบต์ ก็อาจได้ความเร็วเพิ่มเฉพาะจุด 4 เท่า, 8 เท่าหรือมากกว่านั้นตามความกว้างการประมวลผลแบบขนาน
  • ถ้าข้อมูลมีแค่ไม่กี่ค่าหรือหลักสิบ การใช้ SIMD ก็ไม่คุ้ม
  • simdutf และ simdjson ใช้เทคนิค SIMD ที่ซับซ้อน แต่ SIMD ในงานทั่วไปไม่จำเป็นต้องซับซ้อนขนาดนั้น
  • ตัวอย่างใช้ Zig แต่ โครงสร้าง 5 ขั้นตอน นี้ใช้ได้กับภาษาอื่นด้วย เพียงแต่แต่ละภาษารองรับคำสั่ง SIMD ต่างกัน

โครงสร้าง 5 ขั้นตอนที่เกิดซ้ำ

  1. กระจายค่าคงที่ที่ต้องใช้ไปยังทุก lane และถ้าจำเป็นก็เริ่มต้น vector accumulator
  2. วนอินพุตครั้งละ ขนาดความกว้างเวกเตอร์
  3. รันการเปรียบเทียบหรือคำนวณเลขคณิตแบบขนานบนทุก lane
  4. ยุบผลลัพธ์ของเวกเตอร์หรือบันทึกผลตามอัลกอริทึม
  5. ส่วนที่เหลือซึ่งใส่เวกเตอร์เต็มไม่ได้ให้จัดการด้วย scalar tail ซึ่งก็คือลูปเดิม
  • เมื่อคุ้นกับโครงสร้างนี้แล้ว คุณจะสามารถแยกลูปทั่วไปออกเป็น 5 ขั้นตอนเดียวกัน ทำให้การเขียน SIMD ง่ายพอ ๆ กับลูปสเกลาร์
  • ถ้ายังอธิบายด้วยโครงสร้างนี้อย่างง่ายไม่ได้ ตอนนี้อาจเหมาะกว่าที่จะข้ามการใช้ SIMD ไปก่อน

ลูปค้นหาจริงของ Ghostty

  • Ghostty จะกินข้อมูลจากอาร์เรย์ code point ที่ถอดรหัสแล้วจนกว่าจะเจอค่าที่น้อยกว่าหรือเท่ากับ 0xF
    • ข้อมูลของเทอร์มินัลส่วนใหญ่เป็นตัวอักษรธรรมดาที่ต้องแสดงผล จึงสามารถจับเป็นชุดเพื่อประมวลผลได้
    • ลูปนี้พยายามหาจุดสิ้นสุดของช่วงที่ยังพิมพ์ออกได้ถัดไปให้เร็วที่สุด
  • เดิมที implementation แบบสเกลาร์จะตรวจ code point ทีละตัว
while (end < cps.len and cps[end] > 0xF) end += 1;
  • implementation แบบเวกเตอร์ใช้เวกเตอร์ทั่วไปโดยไม่พึ่ง builtin เฉพาะ CPU และมีโค้ดเพิ่มจากแบบสเกลาร์เพียง 12 บรรทัด
  • การเพิ่ม throughput ที่คาดหวังสอดคล้องกับจำนวน lane ของเวกเตอร์
    • ARM NEON และ Apple Silicon: สูงสุด 4 เท่า
    • AVX2 ที่ CPU x86 สมัยใหม่ส่วนใหญ่รองรับ: สูงสุด 8 เท่า
    • AVX-512 ที่รองรับบน Intel บางรุ่นและ AMD Zen 4 ขึ้นไป: สูงสุด 16 เท่า
  • บนเดสก์ท็อป Intel ที่รองรับ AVX2 throughput โดยรวมที่วัดตั้งแต่อินพุตของโปรแกรมเทอร์มินัลไปจนถึงสถานะสุดท้ายของเทอร์มินัลเร็วขึ้นประมาณ 5 เท่า
    • ไม่ได้ความเร็วตามทฤษฎีเต็มทั้งหมด เพราะยังมีงานรอบ ๆ SIMD ด้วย
  • อักขระควบคุม C0 ยังมีอยู่หลัง 0xF แต่ 0xF คือเกณฑ์ที่ใช้ในเส้นทางโค้ดของ Ghostty นี้
    • ESC และ control sequence อื่น ๆ จะถูกจัดการในอีกเส้นทางหนึ่ง

ขั้นตอนที่ 1: กระจายค่าคงที่

if (simd.lanes(u32)) |lanes| {
    const V = @Vector(lanes, u32);
    const threshold: V = @splat(0xF);
  • simd.lanes(u32) ของ Ghostty จะคืนจำนวน u32 ที่ CPU เป้าหมายประมวลผลพร้อมกันได้
    • แต่ละค่าถูกเรียกว่า lane
    • ARM คืน 4, AVX2 คืน 8 และ AVX-512 คืน 16
    • ถ้าไม่มีขนาดเวกเตอร์ให้ใช้ จะคืน null และข้ามโค้ด SIMD ไป
  • @Vector(lanes, u32) จะสร้างชนิดเวกเตอร์ที่มีจำนวน lane ตามนั้น
    • ถ้า lanes เป็น 8 ก็หมายความว่า V หนึ่งตัวเก็บ u32 สำหรับประมวลผลแบบขนานได้ 8 ค่า
  • การเปรียบเทียบเวกเตอร์ต้องให้ทั้งสองฝั่งเป็นเวกเตอร์ จึงใช้ @splat(0xF) เพื่อทำซ้ำค่า 0xF ไปยังทุก lane
{ 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
  • อัลกอริทึมนี้ไม่ต้องมี vector accumulator แต่บางอัลกอริทึมอาจ initialize accumulator ในขั้นตอนนี้

ขั้นตอนที่ 2: วนทีละหนึ่งเวกเตอร์

while (end + lanes <= cps.len) : (end += lanes) {
    const values: V = cps[end..][0..lanes].*;
  • ถ้า lanes เป็น 8 ลูปจะเข้าก็ต่อเมื่อยังเหลือค่าอย่างน้อย 8 ค่า และจะโหลด 8 ค่านั้นเข้า values
  • เมื่อจบแต่ละรอบ end จะเพิ่มทีละ จำนวน lane ไม่ใช่ 1
  • ต้องสามารถโหลดเวกเตอร์เต็มได้ ดังนั้นถ้าเหลือเพียง 5 ค่า ก็จะไม่อ่านเวกเตอร์ 8 lane
  • ค่าที่ใส่เวกเตอร์ไม่ได้จะไปให้ scalar tail ในขั้นตอนที่ 5 จัดการ

ขั้นตอนที่ 3: เปรียบเทียบแบบขนานบนทุก lane

const greater_than_threshold = values > threshold;
  • เมื่อทั้ง values และ threshold เป็นเวกเตอร์ ตัวดำเนินการ > จะเปรียบเทียบ lane ที่ตรงกันทั้งหมดด้วย vector operation เดียว
  • ถ้าเป็น 8 lane ก็เท่ากับทำการเปรียบเทียบ 8 ครั้งแบบขนานสำหรับ cps[end] > 0xF
values:                 { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
threshold:              {  0xF,  0xF,  0xF,  0xF,  0xF,  0xF,  0xF,  0xF }
greater_than_threshold: { true, true, true, false, true, true, true, true }
  • ไม่มีลูปด้านในแบบชัดเจน และผลลัพธ์คือเวกเตอร์ที่เก็บค่า boolean แยกตาม lane
  • โครงสร้างเดียวกันนี้ใช้ได้ไม่ใช่แค่กับการเปรียบเทียบ แต่รวมถึงการบวก คูณ ค่าต่ำสุด ค่าสูงสุด และการดำเนินการอื่นที่ชนิดเวกเตอร์รองรับ
  • แม้การเปรียบเทียบจะเป็น vector operation เดียว แต่การโหลดเวกเตอร์ การยุบผลลัพธ์ และการหาตำแหน่ง lane ที่ล้มเหลวยังต้องใช้คำสั่งเพิ่ม

ขั้นตอนที่ 4: ยุบผลลัพธ์เวกเตอร์

if (@reduce(.And, greater_than_threshold)) continue;
  • @reduce(.And, ...) จะรวม boolean ทั้งหมดด้วย and ให้เหลือ boolean เดียว
  • ถ้าทุก lane เป็น true ก็ข้ามไปเวกเตอร์ถัดไป แต่ถ้ามีอย่างน้อยหนึ่ง lane เป็น false ก็ต้องหาตำแหน่งที่ล้มเหลวให้เจอ
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;
  • @bitCast จะแปลงเวกเตอร์ boolean เป็น integer mask ที่มี 1 บิตต่อ lane
    • 1 หมายถึงค่ามากกว่า 0xF
    • 0 หมายถึงการเปรียบเทียบล้มเหลว
  • เมื่อกลับบิตของ mask แล้ว การเปรียบเทียบที่ล้มเหลวจะกลายเป็น 1 และ @ctz จะนับจำนวนบิต 0 ก่อนหน้า 1 ตัวแรก
values:                 { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
greater_than_threshold: { true, true, true, false, true, true, true, true }
mask:                   {    1,    1,    1,     0,    1,    1,    1,    1 }
~mask:                  {    0,    0,    0,     1,    0,    0,    0,    0 }
  • ในตัวอย่างนี้ @ctz(~mask) จะคืน 3 และเลื่อน end ไปยัง lane ลำดับที่ 3 ซึ่งเป็นตำแหน่งของอักขระควบคุมตัวแรก 0x0A
  • การยุบผลลัพธ์เป็นส่วนที่ต่างกันมากที่สุดของ 5 ขั้นตอนในแต่ละอัลกอริทึม
    • การหาผลรวมอาจยุบ vector accumulator ให้เหลือเลขเดียว
    • การแปลงข้อมูลอาจบันทึกทั้งเวกเตอร์ลง output buffer ได้เลย
    • การค้นหานี้สร้าง bit mask เพื่อหาตำแหน่งของ lane ที่ต้องการ

ขั้นตอนที่ 5: จัดการ scalar tail

while (end < cps.len and cps[end] > 0xF) end += 1;
  • ถ้าความยาวอินพุตไม่หารลงตัวด้วยความกว้างเวกเตอร์ ลูปสเกลาร์เดิมจะจัดการส่วนที่เหลือ
  • หลังลูปเวกเตอร์ 8 lane อาจเหลือค่าได้ตั้งแต่ 0 ถึง 7 ค่า
  • บน CPU ที่ simd.lanes(u32) คืน null ส่วน SIMD จะถูกข้ามไปและลูปสเกลาร์จะจัดการอินพุตทั้งหมด
  • implementation เดิมจึงรับหน้าที่ทั้ง จัดการส่วนที่เหลือและเป็น fallback เพื่อความเข้ากันได้ พร้อมกัน
  • เวกเตอร์ทั่วไปช่วยตัดไวยากรณ์เฉพาะ CPU ออก แต่ไม่ได้ตัดการสร้างโค้ดเฉพาะ CPU ทิ้งไป
    • Zig จะแปลง vector operation ให้เป็นชุดคำสั่งที่เปิดใช้อยู่บน target นั้น

สิ่งที่ auto-vectorization พลาดได้

  • คอมไพเลอร์สามารถทำ auto-vectorization ให้โค้ดง่าย ๆ เช่นลูปคำนวณเลขคณิตที่มีรูปแบบสม่ำเสมอและไม่มี control flow ซับซ้อนได้
  • ก่อนจะเขียน SIMD เอง ควรคอมไพล์เวอร์ชันสเกลาร์ด้วยตัวเลือก optimize และตรวจดู โค้ดที่สร้างขึ้น ก่อน
  • คอมไพเลอร์สำหรับ production มักพลาดโอกาสในการ vectorize อยู่บ่อย และแม้งานวิจัยด้าน auto-vectorization จะดำเนินมาหลายสิบปี แต่งาน วิจัยล่าสุด ก็ยังเริ่มต้นจากปัญหานี้
  • ถ้าลูปนั้นสำคัญพอที่ความเร็วเพิ่ม 5 เท่ามีผล ก็ควรเขียน vectorization แบบชัดเจนเพื่อให้พฤติกรรมคาดเดาได้
    • จะช่วยหลีกเลี่ยงสถานการณ์ที่การแก้โค้ดส่วนอื่นหรือการอัปเดตคอมไพเลอร์ทำให้ลูปเวกเตอร์เงียบ ๆ กลับไปเป็นลูปสเกลาร์

ขอบเขต SIMD ที่นักพัฒนาควรเรียนรู้

  • เมื่อพบ hot loop ที่ค้นหา เปรียบเทียบ นับ หรือแปลงข้อมูลต่อเนื่องจำนวนมาก คุณควรนึกถึงการประมวลผลตามความกว้างเวกเตอร์ได้
  • SIMD ในงานทั่วไปมีรูปแบบที่เป็นระเบียบ ได้แก่ เตรียมค่าคงที่ โหลดเวกเตอร์ คำนวณแบบขนาน ยุบผลลัพธ์ และ scalar tail
  • ถ้าภาษารองรับ SIMD ได้ดี คุณก็ปรับปรุงประสิทธิภาพได้โดยไม่ต้องรู้แอสเซมบลีหรือรายละเอียดเฉพาะ CPU ด้วยตัวเอง
  • ระดับความรู้ที่นักพัฒนาทุกคนต้องมีไม่ใช่เทคนิคซับซ้อนแบบ simdutf หรือ simdjson แต่เป็นการมองเห็นโอกาสในการใช้ SIMD และใช้โครงสร้างร่วมเหล่านี้ให้เป็น

1 ความคิดเห็น

 
GN⁺ 2 시간 전
ความคิดเห็นจาก Hacker News
  • เป็นบทความที่ดี แต่การเริ่มต้นด้วยการบอกว่า SIMD เข้าใจง่ายและเขียนง่ายพอ ๆ กับ for loop แล้วตัวอย่างแรกก็เปลี่ยนโค้ดสเกลาร์หนึ่งบรรทัดให้กลายเป็น 12 บรรทัดนั้นไม่น่าเชื่อถือเท่าไร
    พูดตรง ๆ ว่า SIMD นั้นยากแต่ผลลัพธ์คุ้มค่าน่าจะดีกว่า ถ้าเขียนสำหรับมือใหม่ ก็ไม่ควรใช้ศัพท์เฉพาะของ SIMD อย่าง broadcast ตั้งแต่ขั้นแรกโดยไม่อธิบาย ส่วนขั้นที่ 5 ที่อธิบาย การจัดการส่วนท้ายแบบสเกลาร์ นั้นเป็นโครงสร้างที่ดี

    • SIMD กับตัวอย่างแรกไม่เชิงว่ายาก แต่ใกล้เคียงกับ งานที่จุกจิกกว่ามาก
      ต้องดูว่าฮาร์ดแวร์ประมวลผลได้ทีละกี่รายการ จัดกลุ่มงานตามขนาดนั้น คลี่ผลลัพธ์กลับมา จัดการรายการที่เหลือแยกต่างหาก และแม้แต่ค่าคงที่ก็ต้องสร้างเป็นเวกเตอร์ที่ทำซ้ำค่าไว้ แต่ละอย่างไม่ได้ยาก แต่เพิ่มปริมาณงานจนทำให้ยุ่งยาก
    • เคยเรียน Parallel-C ที่สร้างขึ้นในช่วงกระแส Transputer ราวปี 1990 ซึ่งเป็นภาษาที่เพิ่มความสามารถด้านการเขียนโปรแกรมแบบขนานเข้าไปใน C
      ฟีเจอร์ที่ชอบที่สุดคือ par(; ; ) ซึ่งคอมไพเลอร์จะทำให้ for loop เป็นแบบขนานโดยอัตโนมัติภายใต้เงื่อนไขขอบเขตบางอย่าง
    • ผมน่าจะใกล้เคียงกับกลุ่มผู้อ่านเป้าหมายพอดี เลยอ่านอย่างสนใจ แต่ระดับความยากพุ่งขึ้นเร็วเกินไป จนรู้สึกคล้ายกับ มีมวาดนกฮูก อันโด่งดัง
    • ตัว SIMD เองนั้นเรียบง่าย แต่สิ่งที่ชวนฝืนคือวิธีใช้ การดำเนินการแบบข้อมูลขนานในภาษาสเกลาร์
    • หนึ่งในความผิดพลาดใหญ่ที่สุดของการสอนเทคโนโลยี คือการประกาศว่าหัวข้อนั้นง่ายเพื่อขจัดความกลัวของผู้เรียน อย่าบอกว่าง่าย แต่ควรแสดงให้เห็นจริง ๆ
      ถ้าหัวข้อนั้นซับซ้อนจริง ก็ควรแบ่งเป็นส่วนเล็กและเรียบง่ายกว่าเดิม จากนั้นจัดลำดับให้ดีเพื่อช่วยให้ไต่เส้นโค้งการเรียนรู้ที่ชันได้ และทำให้ผู้เรียนเชื่อว่าคุ้มค่า
  • คำแนะนำที่ดีกว่าคือทุกคนควรรู้จัก array programming เพราะการปรับแต่ง SIMD โดยมากต้องใช้วิธีคิดแบบนั้น และเทคนิคที่เฉพาะกับ packed SIMD เท่านั้นกลับมีน้อยกว่าที่คิด
    array programming ทำให้คอมไพเลอร์ทำ auto-vectorization ได้ง่าย จึงมักสร้างโค้ดที่มีประสิทธิภาพดีได้แม้ไม่เขียน SIMD โดยตรง

    • array programming ที่ทำการเปรียบเทียบทั้งหมดก่อน แล้วค่อยหา failure แรกทีหลัง ไม่ได้ช่วยมากนักเมื่อช่วงการทำงานสั้น เพราะไม่มี early exit ในตัว จึงอาจเสียเวลากับการเปรียบเทียบที่ไม่จำเป็นจำนวนมาก
    • ไม่ได้ชอบภาษาปิดซอร์ส และ MATLAB ก็มีข้อบกพร่องมากมาย แต่การเขียน โค้ดแบบเวกเตอร์ สำหรับการจำลองเชิงตัวเลขในมหาวิทยาลัยนั้นเป็นธรรมชาติมากและมีประสิทธิภาพ
      แม้ประสบการณ์จะยังไม่มาก แต่ Julia ดูเหมือนจะใกล้เคียงที่สุดกับภาษาที่ทันสมัยกว่าและแสดงออกได้ดีกว่า พร้อมความสามารถด้านเวกเตอร์คล้ายกัน
  • ช่วงไม่กี่วันที่ผ่านมาได้ปรับแต่งการคำนวณเมทริกซ์ในโปรเจกต์ชีวสารสนเทศด้วย AVX-512 และพอใจมาก
    แอปพลิเคชันส่วนใหญ่มีคอขวดอยู่ที่การอ่านชุดข้อมูลขนาดใหญ่จากหน่วยความจำ ดังนั้นแทนที่จะอ่านซ้ำ ๆ เพื่อทำหลายการดำเนินการ เราสามารถใช้รีจิสเตอร์ AVX และ fused kernel ประมวลผลทั้งหมดในครั้งเดียวได้ การเร่งความเร็ว 5 เท่าก็พบได้บ่อย และแม้จะใช้อินทรินสิกโดยตรง แต่ถ้าใช้ crate wide การดำเนินการทั่วไปจะง่ายมาก: https://docs.rs/wide/latest/wide/

  • นักพัฒนาส่วนใหญ่อย่างท่วมท้นไม่มีความจำเป็นต้องเรียน SIMD เลย สงสัยว่าทำไมต้องทำให้คนเข้าใจผิดว่าถ้านักพัฒนาทุกคนไม่รู้สิ่งนี้ก็ไม่ใช่นักพัฒนาจริง ๆ

    • อย่างน้อยก็ควรรู้ว่า SIMD มีอยู่และทำอะไรได้บ้าง ในฐานะนักพัฒนา น่าจะเคยเขียน hot loop ที่บวกหรือเปรียบเทียบค่าธรรมดา ๆ และความรู้ว่าคอมไพเลอร์อาจปรับแต่งสิ่งนี้ให้เหมาะกับสถาปัตยกรรม CPU เป้าหมายได้นั้นมีประโยชน์ในหลายสถานการณ์
  • ควรเปลี่ยนชื่อเป็น “ทุกคนควรรู้ว่า เมื่อไร SIMD ไม่ถูกนำมาใช้” มากกว่า
    คอมไพเลอร์ยุคใหม่ทำ vectorization ได้ดีมาก แต่บางครั้งแค่สมมติฐานหนึ่งข้อหรือ branch ที่ขึ้นกับข้อมูลหนึ่งจุด ก็ทำให้ถอยกลับไปเป็นโค้ดสเกลาร์ทันที การรู้วิธีดู รายงานการปรับแต่ง ของคอมไพเลอร์อาจมีค่ามากกว่าการรู้วิธีเขียน SIMD

    • ทางแก้ของ auto-vectorization ที่แย่ก็คือการเขียนโค้ด SIMD เอง เลยไม่แน่ใจว่าการรู้วิธีดูรายงานการปรับแต่งจะมีค่ากว่านั้นจริงหรือไม่
      ถ้าทำได้แค่ระบุปัญหา สุดท้ายก็จบที่ “น่าเสียดาย”
    • เหตุการณ์แบบนั้นเกิดขึ้นจริง: https://xcancel.com/mitchellh/status/2079672171321081908#m
  • ปีที่แล้วตอนทำออดิโอซินธิไซเซอร์ ผมเริ่มเรียน x86 และ ARM SIMD: https://github.com/seclorum/SIMDSynth
    โครงสร้างซินธิไซเซอร์แบบหลาย timbre และหลายเสียงเหมาะมากสำหรับเรียนรู้หลักการ SIMD เพราะนำการประมวลผลเดียวกันไปใช้กับหลายกระแสข้อมูล อย่างไรก็ตาม การดีบักค่อนข้างยากมาก จนรู้สึกว่าต้องการซิมูเลเตอร์ที่ช่วยให้เข้าใจสถานะของแต่ละ processing pipeline ได้จริง ๆ และการสำรวจเครื่องมือ SIMD ก็น่าจะต้องลงทุนลงแรงอีกมาก

  • บทความดี และอยากให้ภาษามากขึ้นรองรับ SIMD แต่ในสถานการณ์ที่สองภาษายอดนิยมที่สุด ไม่รองรับ SIMD แบบเนทีฟ การพูดว่า “โปรแกรมเมอร์ทุกคนควรรู้” ก็ดูแปลกอยู่บ้าง

    • ยากที่จะสรุปว่าภาษายอดนิยมที่สุดคือภาษาที่ วิศวกรซอฟต์แวร์ ซึ่งเป็นกลุ่มเป้าหมายของบทความแบบนี้ใช้กันมากที่สุด
  • แม้คุณจะไม่ได้เขียน SIMD เองหรือวางแผนให้ AI ทำแทน ก็ควรรู้ว่างานแบบใดบนฮาร์ดแวร์แบบใดที่ SIMD ช่วยให้เร็วขึ้นได้ เพื่อจะได้ออกแบบ อัลกอริทึมและโครงสร้างโค้ด ให้เอื้อต่อการใช้ SIMD
    ผลกระทบของ data dependency, ต้นทุนของการเพิ่มความกว้างขององค์ประกอบเวกเตอร์และวิธีหลีกเลี่ยง, วิธีเปลี่ยนเงื่อนไขและ branch ให้เป็น mask, คุณสมบัติอย่าง “ไม่มีคำสั่งหาร” จะซึมซับได้ง่ายขึ้นมากหากลองใช้ SIMD โดยตรงแม้เพียงเล็กน้อย

    • ยังพูดถึงไม่พอว่า SIMD จะเร็วขึ้นเมื่อไร
      มันทำงานได้ดีเมื่อสแกนหรือแปลงข้อมูลต่อเนื่องขนาดใหญ่ในครั้งเดียว แต่ถ้าต้องตัดสินใจทุก ๆ ไม่กี่ไบต์ของอินพุต ก็อาจเร็วพอ ๆ กับแบบสเกลาร์หรือช้ากว่าได้ SIMD ไม่ใช่ปุ่มเร่งความเร็ววิเศษ
  • นี่เป็นวิดีโอมีประโยชน์ที่ Casey Muratori อธิบายกระบวนการที่ทีมพัฒนา The Witness ใช้ SIMD แก้ปัญหาประสิทธิภาพจริง: https://www.youtube.com/watch?v=Ge3aKEmZcqY

    • เป็นการบรรยายที่ยอดเยี่ยม แต่วิดีโอยาวเกินไปสำหรับแนะนำให้คนอื่นดู ถ้ามี เวอร์ชันบทความ ที่โฟกัสประเด็นหลักก็คงดี
      เป็นตัวอย่างที่ดีของ vertical integration เพื่อประสิทธิภาพ แสดงให้เห็นว่าหลังจากเข้าใจว่าทำไม abstraction ทั่วไปจึงมีอยู่และทำไมต้องเป็นแบบทั่วไปแล้ว ใน use case เฉพาะหนึ่ง ๆ เราสามารถบูรณาการแนวตั้งตั้งแต่นิยามปัญหาไปจนถึง SIMD เพื่อให้ได้ประโยชน์อย่างมาก
  • ก่อนจะลงไปถึงการปรับแต่งระดับจุลภาคอย่าง SIMD ควรพิจารณา โครงสร้างข้อมูลและรูปแบบการเข้าถึง อย่างจริงจังก่อน
    เคยนำ SIMD ไปใช้กับโค้ด Zig เก่า แต่โมเดลโครงสร้างข้อมูลสวนทางกับการปรับให้เหมาะสมโดยสิ้นเชิง เหมือนเอายางรถแข่งสมรรถนะสูงไปใส่รถเก่าที่เครื่องยนต์พัง เป็นการรีบ optimize แบบฉบับที่ไม่ได้วัดประสิทธิภาพ และไม่ได้คำนึงถึงตำแหน่งการจัดสรรหน่วยความจำ
    ตอนนี้มองข้อมูลเหมือนตาราง SQL และออกแบบโครงสร้างโดยยึดคีย์หลักที่เป็นไปได้กับรูปแบบการเข้าถึงเป็นศูนย์กลาง เมื่อก่อนใช้ tree ที่ชี้ไปยัง struct อื่น ๆ บน heap จึงรับข้อเสียทั้งหมด ทั้งข้อเสียของ linked list, fragmentation จาก heap vector จำนวนมาก, และต้นทุนการสร้าง·ทำลายที่ช้า แค่ Drop อย่างเดียวก็ใช้เวลารันไปส่วนใหญ่แล้ว
    tree สามารถทำให้เป็นเชิงเส้นได้เสมอ จึงควรพิจารณารูปแบบการเข้าถึง·การแทรก, ว่าจริง ๆ เป็น tree หรือเป็น graph แบบอื่น, และจะเก็บเป็น Vec หรือ struct ที่มีหลาย Vec ผลลัพธ์คือโค้ดเร็วขึ้นและเรียบง่ายขึ้น ข้อมูลรวมอยู่ในอาร์เรย์ชนิดเดียวกัน ทำให้ใช้การ optimize SIMD ของคอมไพเลอร์และ L1 cache ได้ง่ายขึ้น และเมื่อจำเป็นก็สามารถเขียน โค้ด SIMD แบบไร้ branch เองได้
    เอกสารที่เกี่ยวข้อง: https://hn.algolia.com/?dateRange=all&page=0&prefix=true&que...

    • หากต้องการทำให้ hot loop เร็วขึ้นด้วย SIMD สิ่งสำคัญคือ การจัดวางข้อมูลและโครงสร้างที่เป็นมิตรกับ cache
      ในจุดคอขวดจริง ๆ ควรหลีกเลี่ยงการจัดสรรหน่วยความจำ, การค้นหา virtual function table, และการอ้างอิงทางอ้อมที่มากเกินไป แม้แต่ vector ของ C++ ก็ไม่ได้ดีที่สุดเสมอไป หากอาจเรียกการจัดสรรโดยไม่คาดคิด
    • ในฐานะวิศวกรด้านประสิทธิภาพ นี่เป็นปัญหาที่พบอยู่เสมอ ประสิทธิภาพเริ่มต้นจากสถาปัตยกรรม และใน hot path ที่การจัดวางข้อมูลไม่ดี ก็มีขีดจำกัดว่าคั้นประสิทธิภาพออกมาได้แค่ไหน
      ในทางกลับกัน โค้ดแบบ data-oriented แทบจะรองรับ threading และ SIMD ได้ง่ายเสมอ
    • พื้นฐานยิ่งกว่านั้นคือ รูปแบบการเข้าถึงหน่วยความจำ มีความสำคัญ
      ที่น่าสนใจคือท้ายที่สุดโค้ด CPU ก็จะถูกเขียนในสไตล์ GPU และวิธีหนึ่งคือใช้ struct of arrays แบบ Parquet แทน array of objects
    • ตารางเป็นวิธีใช้งาน graph แบบทั่วไปได้อย่างมีประสิทธิภาพ และตราบใดที่ไม่สามารถ specialize graph ได้ นี่คือรูปแบบการแทนข้อมูลที่ดีที่สุดเท่าที่ผมรู้จัก