ฟังก์ชัน tolower() ที่ทำด้วย AVX-512
(dotat.at)- นี่คือการทดลองใช้ AVX-512-BW ประมวลผลครั้งละ 64 ไบต์ในการคัดลอกสตริงพร้อมแปลง ASCII ตัวพิมพ์ใหญ่เป็นตัวพิมพ์เล็ก เพื่อดึงประสิทธิภาพ SIMD ออกมาได้แม้กับสตริงขนาดเล็ก
- แกนหลักของการทำงานคือการเปรียบเทียบว่าแต่ละไบต์อยู่ในช่วง
'A'ถึง'Z'หรือไม่ จากนั้นใช้ การดำเนินการแบบมาสก์ที่บวกค่า'a' - 'A'เฉพาะตำแหน่งนั้น - ส่วนท้ายที่เหลือของทั้งสตริงสั้นและสตริงยาวจัดการด้วย masked load/store เพื่อลดต้นทุนการจัดการชิ้นส่วนเล็ก ๆ ที่โค้ด SIMD มักเจอ
- จากการวัดการคัดลอกขนาดราว 1MiB บน Clang 16, Debian 11 และ AMD Ryzen 9 7950X โดยใช้ชังก์ตั้งแต่ 1 ไบต์ถึง 1KiB พบว่า
tolower64อยู่ในกลุ่มที่ทำงานได้เร็วอย่างสม่ำเสมอเมื่อเทียบกับตัวอื่น - บน Zen 4 นั้น AVX-512-BW ดูเหมาะกับงานประมวลผลสตริงมาก แต่ยังไม่ได้ตรวจสอบ ARM SVE และ RISC-V Vector extension แบบละเอียดด้วยตัวเอง
สร้าง tolower() แบบ 64 ไบต์ด้วย AVX-512-BW
- เป้าหมายคือการทำ เคอร์เนล
tolower()แบบ SIMD ที่คัดลอกสตริงไปพร้อมกับแปลงอักขระ ASCII ตัวพิมพ์ใหญ่เป็นตัวพิมพ์เล็ก - AVX-512-BW เป็นส่วนขยายที่รองรับการคำนวณระดับไบต์และเวิร์ด ซึ่งใช้งานได้บนโปรเซสเซอร์ AMD Zen รุ่นใหม่
- AVX-512 แบ่งเป็นหลายส่วนขยาย ทำให้สถานะการรองรับค่อนข้างซับซ้อน
- ฝั่ง Intel ถูกมองว่าการรองรับนั้นไม่สม่ำเสมอเป็นพิเศษ
- ARM SVE ก็มี masked load/store ระดับไบต์ ที่เหมาะกับงานประมวลผลสตริงเช่นกัน
- ใช้งานได้บนคอร์ big-ARM Neoverse รุ่นใหม่ เช่น Amazon Graviton
- แต่ใช้ไม่ได้บน Apple Silicon
- RISC-V Vector extension ก็มีสไตล์ใกล้เคียงกับ ARM SVE และใช้งานได้บนคอมพิวเตอร์บอร์ดเดี่ยวขนาดเล็กหลายรุ่น
วิธีทำงานของ tolower64()
tolower64()เป็นเคอร์เนลที่อิง AVX-512 และประมวลผล 64 ไบต์ ต่อครั้ง- ขั้นแรกจะเติมค่าอ้างอิงลงในเวกเตอร์รีจิสเตอร์ที่บรรจุ 64 ไบต์
'A''Z''a' - 'A'
- จากนั้นนำเวกเตอร์อักขระอินพุต
cไปเปรียบเทียบกับ'A'และ'Z'เพื่อสร้างมาสก์ขนาด 64 บิตแต่ละตัว- ตำแหน่งที่
c >= 'A' - ตำแหน่งที่
c <= 'Z'
- ตำแหน่งที่
- นำมาสก์ทั้งสองมารวมกันด้วย
_kand_mask64()เพื่อสร้างมาสก์is_upperที่ระบุเฉพาะ ตำแหน่งตัวพิมพ์ใหญ่ - ขั้นสุดท้ายใช้
_mm512_mask_add_epi8()- ไบต์ที่
is_upperเป็น false จะคงค่าcเดิมไว้ - ไบต์ที่
is_upperเป็น true จะกลายเป็นc + ('a' - 'A')
- ไบต์ที่
การจัดการสตริงยาวและสตริงสั้น
- สำหรับสตริงยาว ส่วนใหญ่จะประมวลผลด้วย vector load/store แบบไม่ต้องจัดแนว ตามปกติ
_mm512_loadu_epi8()tolower64()_mm512_storeu_epi8()
- สำหรับสตริงสั้นและชิ้นส่วนท้ายที่เหลือของสตริงยาว จะใช้ masked unaligned load/store
- มาสก์ถูกสร้างให้เปิดเฉพาะ
lenบิตด้านล่างuint64_t len_bits = (~0ULL) >> (64 - len)- นำขึ้นไปเป็น SIMD mask register ด้วย
_cvtu64_mask64(len_bits)
_mm512_maskz_loadu_epi8()จะเติม 0 ลงในตำแหน่งของรีจิสเตอร์ปลายทางที่มาสก์ปิดอยู่_mm512_mask_storeu_epi8()จะบันทึกเฉพาะตำแหน่งที่มาสก์เปิดอยู่- วิธีนี้คือหัวใจสำคัญที่ทำให้จัดการชิ้นส่วนสตริงขนาดเล็กได้รวดเร็ว
เงื่อนไขเบนช์มาร์กและตัวที่ใช้เปรียบเทียบ
- เบนช์มาร์กรันบน Clang 16, Debian 11 และ AMD Ryzen 9 7950X
- สิ่งที่วัดคือการคัดลอกข้อมูลราว 1MiB โดยเปลี่ยนความยาวของชังก์ตั้งแต่ 1 ไบต์ถึง 1KiB
- เพื่อสะท้อนความต่างด้านการจัดแนวของสตริงต้นทางและปลายทาง จึงเว้นไม่กี่ไบต์ไว้ระหว่างสตริงแต่ละชุด ซึ่งไบต์เหล่านี้ไม่นับรวมในปริมาณการวัด 1MiB
- เนื่องจาก Ryzen 9 7950X มี L2 cache ขนาด 1MiB ต่อคอร์ จึงคาดว่าแต่ละการทดสอบจะลามไปถึง L3 cache
- แต่ละฟังก์ชันถูกคอมไพล์แยกกันเพื่อหลีกเลี่ยงผลรบกวนจากการ inline และการย้ายโค้ด
- แต่ในโค้ดจริงมีแนวโน้มว่าจะส่งเสริมการ inline มากกว่าป้องกัน
ผลลัพธ์: ประสิทธิภาพที่ลื่นไหลของ tolower64
tolower64สีชมพู โดยรวมแล้วอยู่ใกล้กลุ่ม ที่เร็วที่สุด ในบรรดาฟังก์ชันที่ทดสอบอย่างสม่ำเสมอ- เมื่อความยาวเป็น 65 ไบต์ จะตกลงเล็กน้อยจากการข้ามไปยังเวกเตอร์ตัวที่สอง
- หลังจากนั้นก็ไต่ขึ้นอย่างรวดเร็วและไม่มีร่องประสิทธิภาพลึก ๆ แสดงว่า masked load/store มีประสิทธิภาพในการจัดการชิ้นส่วนสตริงสั้น
copybytes64สีเขียว เป็นmemcpyเวอร์ชันที่ใช้ AVX-512 ในลักษณะคล้ายกัน- ไม่ได้เร็วกว่่า
tolower64มากนัก - Clang รุ่นใหม่เข้าใจความหมายของฟังก์ชันนี้และเขียนใหม่ทั้งหมด จึงใช้ Clang 11 คอมไพล์
- ไม่ได้เร็วกว่่า
copybytes1สีส้ม เป็นmemcpyแบบไบต์ต่อไบต์- คอมไพล์ด้วย Clang 11
- แสดงให้เห็นว่า heuristic สำหรับ auto-vectorization ของ Clang 11 ค่อนข้างไม่ดีนักกับชิ้นส่วนสตริงที่เล็กกว่า 256 ไบต์
tolowerสีแดง เป็น baseline ที่เรียกtolower()มาตรฐานจาก<ctype.h>และช้ามากtolower1สีม่วง เป็นtolower()แบบไบต์ต่อไบต์ที่คอมไพล์ด้วย Clang 16- auto-vectorization ของ Clang 16 ดีขึ้นมากเมื่อเทียบกับ Clang 11
- แต่ก็ยังช้ากว่าเวอร์ชันที่เขียนด้วยมือ และสร้าง โค้ดที่ซับซ้อนกว่ามาก
- การจัดการชิ้นส่วนสตริงสั้นยังไม่ดีเท่า
tolower64ทำให้กราฟประสิทธิภาพแกว่งเป็นยอดแหลม
tolower8สีน้ำตาล เป็น SWARtolower()จากบทความก่อนหน้า- Clang พยายามทำ auto-vectorization แต่ฟังก์ชันซับซ้อนเกินไปจนผลลัพธ์ไม่ดี
- แม้คอมไพล์ด้วย Clang 16 ก็ยังเห็นหน้าผาประสิทธิภาพที่ 256 ไบต์แบบเดียวกับสไตล์ Clang 11
memcpyสีน้ำเงิน เรียกใช้memcpyของ glibc- ช่วงแรกทำได้เร็ว แต่มีบางช่วงที่ตกลงไปเหลือประมาณครึ่งหนึ่งของความเร็ว
copybytes64 - ยังไม่ทราบสาเหตุ
- ช่วงแรกทำได้เร็ว แต่มีบางช่วงที่ตกลงไปเหลือประมาณครึ่งหนึ่งของความเร็ว
บทสรุปและโค้ด
- AVX-512-BW เหมาะมากกับการจัดการสตริง โดยเฉพาะ สตริงสั้น
- บน Zen 4 มันเร็วมาก และ intrinsic functions ก็ใช้งานค่อนข้างง่าย
- จุดเด่นที่สุดคือ ประสิทธิภาพที่ลื่นไหล
- แทบไม่เห็นร่องประสิทธิภาพที่เกิดจาก auto-vectorization สลับไปใช้โค้ดสเกลาร์เมื่อเจอชิ้นส่วนสตริงเล็ก ๆ
- เนื่องจากไม่สามารถเข้าถึงอุปกรณ์ที่รองรับ ARM SVE หรือ RISC-V Vector extension ได้สะดวก จึงยังไม่ได้ศึกษาส่วนขยายทั้งสองนี้อย่างละเอียด
- สามารถดูโค้ดได้ที่ git repository ของเว็บไซต์
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ทริก “unsafe read beyond of death” นั้น แม้ฮาร์ดแวร์จะยอมให้ทำได้ แต่ในโมเดลหน่วยความจำของ Rust และ LLVM จะถือว่าเป็นพฤติกรรมที่ไม่ถูกกำหนดไว้
เช่นเดียวกับพฤติกรรมที่ไม่ถูกกำหนดไว้อื่น ๆ คอมไพเลอร์อาจสมมติระหว่างการทำ optimization ได้ว่า “เรื่องแบบนั้นไม่เกิดขึ้น” จึงอาจทำให้เกิดผลลัพธ์ที่คาดไม่ถึง และถ้าจะเลี่ยงก็ต้องใช้อินไลน์แอสเซมบลี
https://github.com/ogxd/gxhash/issues/82
โหลดแบบ “อ่านค่าที่เกินขอบเขตการจัดสรรเป็นองค์ประกอบที่ไม่ระบุค่า และให้เป็นพฤติกรรมที่ไม่ถูกกำหนดไว้เฉพาะตอนที่ฮาร์ดแวร์รับไม่ได้” ดูเหมือนไม่น่าจะรองรับยากนัก และต่อให้ภายในเป็นเพียงชื่อเรียกอีกชื่อของการเรียกแอสเซมบลีดังกล่าวก็ยังเพียงพอ
ถัดจากนั้นไปอีก ถ้าสามารถรับประกันได้ว่าหลังการจัดสรรทุกชนิด เช่น
malloc, สแตก, ค่าคงที่ ฯลฯ จะมีแอดเดรสที่ไม่ก่อให้เกิด fault อย่างน้อยราว 64 ไบต์ ก็คงดี แต่เรื่องนี้ต้องอาศัยความร่วมมือจากหลายองค์ประกอบจึงซับซ้อนกว่ามากใน custom allocator นี่เป็นเรื่องเล็กน้อย แต่ในกรณีนั้นก็จะใช้โค้ด SIMD กับข้อมูลที่อยู่นอก custom heap ได้ยาก และยังต้องผูกติดกับความเป็นไปได้เล็กน้อยที่จะเกิด segfault
sanitizer หรือ Valgrind ก็ยังมีประโยชน์อยู่ เพราะสามารถติดตามค่าที่ออกนอกขอบเขตในฐานะค่าที่ยังไม่กำหนด และแจ้งข้อผิดพลาดเมื่อมีการใช้งานจริงได้
อยากรู้ว่าถ้าอ่านจากเพจที่ไม่ได้แมปหรือหน่วยความจำที่ถูกป้องกันไว้จะเกิดอะไรขึ้น และยังไม่ได้ดูโค้ดจึงไม่รู้ว่าการรับประกันเรื่อง alignment ช่วยหลีกเลี่ยงจุดนี้ได้หรือไม่
พฤติกรรมที่ไม่ถูกกำหนดไว้เป็นคำศัพท์เฉพาะทางในมาตรฐาน C ดังนั้นการเหมารวมแบบนี้จึงดูแปลก และ ANSI C ก็ไม่ได้อนุญาตให้ตั้งสมมติฐานเช่นนั้นไว้อย่างชัดเจน ส่วน ISO C แม้จะเปิดกว้างกว่า แต่ก็ไม่ได้ให้เหตุผลรองรับสมมติฐานนี้อย่างเป็นรูปธรรม
คำอธิบายแนว “UB = สมมติว่าเกิดขึ้นไม่ได้” นั้น สำหรับผมถือว่าใกล้เคียงกับการขู่ให้กลัวแบบไม่ค่อยซื่อตรงพอสมควร
พอเห็นโค้ดที่สะอาดและประสิทธิภาพดีในบทความนี้ ก็เริ่มสงสัยว่าการติดตั้งใช้งาน AVX512 ของ AMDกับ AVX10 ที่ Intel วางแผนไว้จะสู้กันอย่างไร
ดูเหมือนแก่นหลักของ AVX10 จะอยู่ที่การแก้ปัญหาสถานการณ์ P-core/E-core ของ Intel ขณะที่ AMD เลือกแนวทางที่ดีกว่า คือใช้ทั้งการติดตั้งใช้งานแบบเต็มความกว้างใน Zen5 หรือแบบประมวลผล 256 บิต 2 รอบใน Zen4 และ Zen5 Mobile ตามความเหมาะสม โดยยังคง API ให้ลื่นไหล
ผลการเพิ่มประสิทธิภาพครั้งใหญ่ในบทความนี้ก็มาจากคอร์ Zen4 ทั้งหมด และ AVX512 มีข้อดีมากมาย จึงน่าหงุดหงิดที่ Intel จำกัดมันมากเกินไปเพื่อแบ่งส่วนตลาด จนแทบปิดกั้นการนำไปใช้ในโค้ดไคลเอนต์ทั่วไปโดยปริยาย
ตลาดปฏิเสธแนวทางแยกเส้นทางโค้ดตาม CPU มาตลอด และในทางปฏิบัติ SIMD implementation ที่สำคัญก็มักยึดตัวหารร่วมต่ำสุด
AVX10.1/256 กับ AVX512VL มีส่วนย่อยร่วมกัน ดังนั้นเมื่อเวลาผ่านไปนานพอจน CPU ส่วนใหญ่รองรับ ผู้คนก็น่าจะหันไปเจาะจงส่วนย่อยนั้น
AMD คงยังเก็บชัยชนะแบบง่าย ๆ ได้ต่อไปในแอป benchmark บางตัวที่อัปเดตรองรับ AVX512 แล้ว แต่ถ้า Intel ยังเดินตามแผน AVX10 ต่อ AMD เองก็น่าจะลงเอยด้วยการใช้ SIMD pipeline แบบประมวลผล 2 รอบอย่างเต็มรูปแบบ เพื่อรองรับ AVX10/256 ได้อย่างมีประสิทธิภาพ โดยยังคงความเข้ากันได้กับ AVX512
Intel ตัดสินใจพลาดมามากในสิบปีที่ผ่านมา แต่การแบ่งตลาดด้วย instruction set น่าจะเป็นหนึ่งในการตัดสินใจที่แย่ที่สุด เท่ากับเป็นการทำลายแรงส่งและความสนใจต่อการพัฒนาสมัยใหม่ด้วยตัวเอง และสิ่งที่สำคัญกว่าความกว้างจริง ๆ ก็คือฟีเจอร์อย่างการทำงานกับ mask ดังนั้นหวังว่าจะใส่ AVX10/256 ลงมาทั้งไลน์ผลิตภัณฑ์
คำนี้มีความหมายเฉพาะ แต่ไม่ตรงกับการทำงานจริง
Zen 4 เพียงแค่ดีโค้ดการทำงานของ ZMM register ออกเป็นหลาย micro-op แล้วจัดตารางไปยังหน่วย 256 บิตที่ว่างอยู่ ส่วน shuffle แบบเต็มความกว้าง 512 บิตก็ใช้ฮาร์ดแวร์เฉพาะจัดการเป็นกรณีพิเศษเพื่อหลีกเลี่ยงการจำลองที่มีต้นทุนสูง
เพราะอย่างนั้น Zen 4 ที่มีหน่วย SIMD 256 บิต 4 หน่วยจึงทำงานได้ราวกับเป็นคอร์ 2×512 บิตที่ทรงพลัง และการติดตั้งใช้งานนี้ก็ไม่ได้เป็นวิธีราคาถูกเลย แถมอาจเป็นรูปแบบที่ดีที่สุดในฮาร์ดแวร์ฝั่งผู้บริโภคจนถึงตอนนี้ด้วยซ้ำ
พวกเขามีเวลาจะแก้เรื่องนี้มาหลายปีแล้ว และแม้ AMD จะรองรับ แต่ก็ยังไม่ได้รับการยอมรับเพราะส่วนแบ่งตลาด ซึ่งน่ารำคาญมาก ส่วน AVX10 ก็น่าเสียดายที่ดูจะทำให้ Intel ยื้อโลกนี้ไว้ได้นานขึ้น
สำหรับเดสก์ท็อป ผมอยากเห็นคอร์ที่ดีกว่า จำนวนคอร์ที่มากกว่า และ instruction set ที่กำหนดมาตรฐานมาดี เปิดใช้ฟีเจอร์ที่มีประโยชน์อย่าง SIMD ที่กว้างขึ้น,
float16,gather/scatterและ AMD ก็กำลังทำได้ค่อนข้างดีในทางกลับกัน Intel กลับเอาคอร์อ่อนมาแปะข้างคอร์ที่พอใช้ได้ แล้วก็จำกัดคอร์ที่พอใช้ได้นั้นให้ต้องเข้ากับคอร์อ่อน ออก CPU ที่มีจำนวนคอร์เท่าเดิมอยู่หลายเจเนอเรชัน ทำให้ดูเหมือนมีคอร์มากด้วยการเติมคอร์อ่อนเข้ามา ออก instruction set ย่อยมากมายจนแทบสร้างชุดร่วมที่มีประโยชน์ได้ยาก และยังทิ้งการรองรับคำสั่งที่ตัวเองเคยทำเหมือนให้สัญญาไว้
ความนิยมของผู้ประกอบเครื่องเดสก์ท็อปคือ Intel ในยุค 90, AMD ช่วงต้นยุค 2000, Intel อีกครั้งในช่วงปลายยุค 2000 และทศวรรษ 2010 ส่วนตอนนี้กลับมาเป็น AMD แล้ว สงสัยว่า Intel จะทำอะไรเพื่อสร้างฐานกลับคืนมานอกจากคอยขัดขาคู่แข่ง และการแข่งขันก็ควรดำเนินต่อไปเพื่อไม่ให้อีกฝ่ายสบายเกินไป
เอกสารที่อ่านเล่นได้สนุก: http://www.unicode.org/Public/3.1-Update1/CaseFolding-4.txt
ตัวอย่างเช่น
"straße".upper()จะกลายเป็น'STRASSE'อีกทั้งถ้าไม่กำหนด locale เวลาแปลง i ไม่มีจุดของกลุ่มภาษาตุรกีไปกลับระหว่างตัวพิมพ์ใหญ่/เล็ก
'ı'.upper().lower()จะกลายเป็น'i'ทำให้เพี้ยนมีโปรโตคอลที่ไม่แยกตัวพิมพ์ใหญ่เล็กแบบ ASCII อยู่หลายตัว และมักเจอบ่อยใน hot path ของเซิร์ฟเวอร์จำนวนมาก
แบบแรกมักใช้ pure ASCII ในการเข้ารหัส 8 บิตก็พอ แต่แบบหลังจะซับซ้อนขึ้น
ที่อยู่ DNS เป็นตัวอย่างง่าย ๆ ซึ่งในทางเทคนิคเก็บ Unicode ได้แทบทั้งหมด แต่ตอน resolve DNS จริงจะถูกแปลงเป็น subset ของ ASCII ที่จำกัดมาก และกระบวนการ resolve นั้นไม่แยกตัวพิมพ์ใหญ่เล็ก
แน่นอนว่าก็มีภาษาโปรแกรมที่รองรับระบบอักขระทั้งหมดของ Unicode แต่ยังมี identifier ที่ไม่แยกตัวพิมพ์ใหญ่เล็กด้วย ถ้าคุณกำลังจัดการของแบบนั้นอยู่ ก็ขอแสดงความเสียใจด้วย
maßeกลายเป็นMASSEนั้น ภาษาเยอรมันก็มี Eszett ตัวพิมพ์ใหญ่คือ ẞ ด้วยแม้จะยังไม่แพร่หลายมากและมีฟอนต์ที่รองรับไม่มากนัก แต่ในทางทฤษฎีตอนนี้มันมีอยู่แล้ว
รู้สึกว่าคำอธิบายเรื่อง “การบวก mask” ในบทความอาจจะผิดหรือเปล่า
น่าจะต้องบวกตอน
is_upperเป็น false และคัดลอกเดิมไว้ตอนเป็น true หรือไม่to_upperกลับด้าน ควรเรียกว่าto_lowerมากกว่าขอบคุณที่ชี้จุดที่ทำให้งง ตอนนี้แก้ทั้งบทความและโค้ดแล้ว
tolowerตัวพิมพ์ใหญ่ A คือ
0x40และตัวพิมพ์เล็กคือ0x60ดังนั้นการ บวก 0x20 ต้องเกิดตอนis_upperเป็น trueการปรับแต่งแบบ SWAR นี้มักมีประโยชน์เฉพาะเมื่อสตริงถูกจัดแนวกับแอดเดรส 8 ไบต์
ถ้านำอัลกอริทึม SWAR ไปใช้กับสตริงที่ไม่จัดแนว ก็มักจะช้ากว่าอัลกอริทึมเดิม
ถ้าแยกเป็น 3 ขั้นตอนคือ จัดการช่วงต้นจนถึงแอดเดรสที่จัดแนว, จัดการส่วนเนื้อหาที่จัดแนวแล้ว, และจัดการส่วนท้ายที่สั้นกว่า 8 ไบต์ จำนวนคำสั่งจะยิ่งเพิ่มขึ้น
มีกรณีคล้ายกันพร้อมเบนช์มาร์กเกี่ยวกับคำกล่าวที่ผิดว่า
utf8.IsValidใน Go เร็วกว่าอยู่ที่นี่: https://github.com/sugawarayuuta/charcoal/pull/1งานด้านหน่วยความจำยังคงทำแบบจัดแนวและใช้ขนาดเวกเตอร์เต็มเสมอ แต่สามารถใส่ mask เฉพาะ element ที่ใช้ได้จริง
ต่อให้การทำงานหน่วยความจำของเวกเตอร์แบบ masked จะไม่จัดแนว หรือคร่อมหน้าเพจที่ไม่ได้แมปหรือถูกป้องกันไว้ หาก lane นั้นถูกปิดด้วย mask ก็จะไม่เกิด fault
ยังมีคำสั่ง load พิเศษที่ลดความยาวเวกเตอร์ลงจนถึงก่อน element แรกที่จะทำให้เกิด fault สำหรับงานอย่าง
strlen()ที่ไม่รู้ความยาวล่วงหน้าด้วยการบวกแบบมาสก์ดูเท่มาก คงจะดีถ้าใน .NET intrinsic สามารถจัดการมาสก์รีจิสเตอร์ของ AVX512 ได้โดยตรง แต่ตอนนี้ต้องพึ่ง “รูปแบบสำนวนที่คอมไพเลอร์รู้จัก”
ถ้าวิเคราะห์ลูปแกนหลักที่ผู้เขียนทำไว้ด้วย uiCA(CQA/MAQAO) โดยอิง Ice Lake จะได้ประมาณ 32B/cycle และถ้าคิดเป็น 3GHz ก็จะได้เกือบ 96GiB/s หากสมมติว่าไม่มีคอขวดด้านหน่วยความจำ แน่นอนว่าสำหรับอัลกอริทึมแบบนี้ การเข้าถึงหน่วยความจำคือคอขวดเสมอ
แต่ก็ดูเหมือนยังไม่ใกล้เคียงการใช้ประโยชน์ได้เต็มที่นัก และถ้าใช้ Clang ก็จะได้ผลการ unroll ที่คลี่ออกได้ดีกว่าและมีการเลือกคำสั่งที่ดีกว่า จนไปถึง 42.67B/cycle แม้ L2 cache ก็น่าจะรักษา throughput ระดับนั้นได้ยาก แต่ก็น่าสนใจที่การแปลงตัวพิมพ์เล็ก/ใหญ่ของสตริงความยาวปานกลางจบลงในเวลาพอๆ กับที่แสงจากหน้าจอไปถึงกระจกตา
เมื่อไม่กี่เดือนก่อนผมเคยทำการแปลงตัวพิมพ์ใหญ่/เล็ก ASCII ภายใน UTF-8 แบบคล้ายกันด้วย C#: https://github.com/U8String/U8String/blob/main/Sources/U8Str...
เพราะสตริงสั้นครองโค้ดเบสส่วนใหญ่ การแปลงแบบ unroll สำหรับกรณีที่สั้นกว่าความยาวเวกเตอร์จึงสำคัญ และ
switchจะถูกคอมไพล์เป็น jump table กับ branchless fall-throughตอนนี้ใช้แค่ 256 บิต เพราะในกรณีอย่าง Zen 3 หรือ 4 ที่มีเพียง SIMD unit แบบ 256×4 ก็อิ่มตัวอยู่แล้ว มีตัวอย่างเปรียบเทียบกับเวอร์ชัน C แบบวางคู่กันอยู่ที่นี่: https://godbolt.org/z/eTGYhTPan
บน AVX512 ดูเหมือนว่าน่าจะเปลี่ยนคำสั่ง 3 ตัวด้วย
vpternlogdได้ และตอนที่ยังใช้ฮาร์ดแวร์ AVX512 ได้ .NET ก็เคย optimize แบบนั้นด้วยความกว้าง 256 บิต + AVX512VL แต่ตอนนี้แปลกตรงที่ทำซ้ำด้วยความกว้าง 512 บิตไม่ได้น่าจะเห็นความพยายาม SWAR ที่ล้มเหลวฝั่ง
switchdispatch ด้วย อยากรู้ว่าไลเซนส์ของบทความนี้เป็นแบบไหน ถ้าผ่าน test suite ก็อยากหยิบไปใช้ถ้ามองจากโครงสร้างของคอมไพเลอร์ทั้งสองก็พอเข้าใจได้ แต่บางครั้งผลลัพธ์ก็ดีขึ้น บางครั้งก็แย่ลง
หลายปีก่อนผมเคยทำโปรเจกต์ที่ vectorize หนักมากและต้องคอมไพล์ได้ทั้งสองตัว สุดท้ายเลยต้องเก็บ inline assembly สำหรับเป้าหมายเฉพาะพร้อมกับเวอร์ชันอ้างอิง C และไฟล์
.Sไว้ในรีโพซิทอรีMakefile ก็เละขึ้น แถมต้องใส่ benchmark เข้าไปใน test suite ด้วย ทำให้ภาระการดูแลรักษาสูงมาก เลยสรุปว่าการใช้ intrinsic เป็นเครื่องมือระดับล่างที่ดีกว่า auto-vectorization นั้นต้องทำอย่างระมัดระวังมาก
ตัวอย่าง: ที่ https://godbolt.org/z/T4Pjhrz5d output ของ GCC เป็นไปตามคาด แต่ output ของ Clang น่าตกใจและช้ากว่าจริง พอจับรันในลูป ตาม uiCA แล้ว GCC ใช้ 4 cycle เทียบกับ 7 cycle และยังเห็นได้จาก benchmark ของแอปจริงที่ฟังก์ชันนี้ถูกรันหลายพันล้านครั้งในอัลกอริทึม brute-force
พอลองไปดูในโค้ดเบสของ LLVM ก็จำได้ว่าเคยเห็นปัญหาว่า Clang 16 อาจปล่อยคำสั่ง mask AVX-512 บางตัวออกมาไม่ได้เลยเพราะ internal refactoring
ไม่ได้ตั้งเป้าหมายจะรีดประสิทธิภาพสูงสุดตั้งแต่แรก ตอนแรกแค่อยากดูว่ามันทำงานได้ไหม แต่กลายเป็นว่าความพยายามครั้งแรกออกมาดีเกินคาดเป็นโบนัส
สิ่งที่สนใจหลักคือ สตริงที่สั้นกว่าเวกเตอร์รีจิสเตอร์ และการกำจัดร่องต่ำในกราฟ throughput
ถ้าตามลิงก์โค้ดท้ายบล็อกโพสต์ไปจะมีข้อมูลไลเซนส์อยู่ โดยยกเว้นส่วน MPL-2.0 ที่เดิมเขียนไว้สำหรับ BIND แล้ว ส่วนที่เหลือเป็น 0BSD หรือ MIT-0
(x >= 'a' && x <= 'z')ใหม่เป็น รูปแบบ(x - 'a') < ...เพื่อลดคำสั่งลงหนึ่งตัวบางครั้งยังลดได้ถึงขั้นไม่ต้องโหลดรีจิสเตอร์ด้วยเพราะการเข้ารหัส opcode ที่แปลกๆ
ไม่รู้ว่า swar คืออะไร
ปกติหมายถึงเทคนิคที่ pack หลายรายการไว้ในรีจิสเตอร์เดียว แล้วใช้งานให้คล้าย SIMD โดยไม่ต้องมีคำสั่ง SIMD แบบ explicit
ตัวอย่างเช่น ถ้าใส่เลข 31 บิตกับ 32 บิตไว้ในรีจิสเตอร์ 64 บิตและเหลือ 1 บิตไว้สำหรับ carry คุณก็ทำการบวกสองครั้งได้ด้วยการบวก 64 บิตครั้งเดียว
ในวงการเกมก็ใช้ทริกแบบนี้กับกราฟิกมานานแล้ว โดย pack ค่า RGB(A) ไว้ในจำนวนเต็ม 32 บิต และใน ScummVM ก็มีโค้ดที่อินเตอร์โพเลตพิกเซล RGB 16 บิต 2 พิกเซล รวมทั้งหมด 6 คอมโพเนนต์ภายในค่า 32 บิต: https://github.com/scummvm/scummvm/blob/master/graphics/scal...
หลังจาก Unicode ปรากฏขึ้น แนวคิดเรื่องตัวพิมพ์ใหญ่และตัวพิมพ์เล็กก็กลายเป็นบึงโคลนไปแล้ว
ถ้าจะทำให้ถูกต้องจริงๆ ต้องใช้ข้อมูลจำนวนมาก
ถ้าคุณกำลังทำงานที่การเสร็จทันเวลาขึ้นอยู่กับความเร็วในการรัน ASCII
tolowerก็ควรเปลี่ยนเกมและเปลี่ยนสมมติฐานไปเลยดีกว่าเมื่อก่อนผมเคยใส่ขอบสีดำรอบภาพเพื่อหลีกเลี่ยงปัญหาการอ่านเลยบัฟเฟอร์ของ SIMD แบบหมดจด
มันทำงานได้ดีมาก และในแง่ความเร็วก็เอาชนะ implementation บางตัวของ OpenCV ได้ แต่ก็ใช่ว่าจะควบคุมอินพุตได้สมบูรณ์แบบแบบนั้นเสมอไป
สงสัยว่าเคยลองทำแบบนี้ไหม ผลของ auto-vectorization ดูค่อนข้างสะอาดทีเดียว
https://godbolt.org/z/1c5joKK5n
tolower1ดู bullet ด้านล่างของกราฟได้