SearchValues<T> แสดงให้เห็นถึงความได้เปรียบที่วัดผลได้ใน .NET 10 เมื่อต้องสแกนบัฟเฟอร์ขนาดใหญ่เพื่อหาตัวคั่น (delimiters) ที่มีอยู่อย่างเบาบาง โดย SearchValues ใช้เวลาเพียง 3.3 ms เทียบกับ IndexOfAny ที่ใช้เวลา 5.6 ms บนข้อมูลขนาด 32 MB
ทำไมถึงต้องมี SearchValues
.NET 8 ได้แนะนำ SearchValues<T> เพื่อคำนวณชุดตัวอักษร (หรือไบต์) ไว้ล่วงหน้า และปล่อยให้ runtime เลือกอัลกอริทึมการสแกนที่เร็วที่สุดสำหรับชุดข้อมูลนั้น โดยสร้างออบเจกต์เพียงครั้งเดียว แล้วนำกลับมาใช้ใหม่ทุกครั้งที่คุณต้องการค้นหาค่าใดๆ ใน span ซึ่ง runtime อาจใช้คำสั่งแบบ vectorized, ลูปแบบ branch-free หรือเทคนิคระดับต่ำอื่นๆ ที่การเขียนโค้ดขึ้นมาเองแบบแมนนวลไม่สามารถทำได้เทียบเท่าหากไม่ใช้ความพยายามอย่างมาก
ผลการทดสอบ (Benchmarks) ที่สำคัญ
การทดสอบที่เป็นประเด็นในการสนทนาใช้บัฟเฟอร์ขนาด 32 MB ที่มีตัวคั่น 5 ตัวอักษร โดยมีการวัดเวลา 3 วิธีบน .NET 10:
- IndexOfAny(char[]) – 10.2 ms
- การใช้ลูป foreach แบบแมนนวลบน span – 19.4 ms
- SearchValues
– 9.7 ms
SearchValues ชนะ IndexOfAny ที่มีมาให้ในตัวด้วยเวลาที่ต่างกันเพียงครึ่งมิลลิวินาทีเท่านั้น ผลลัพธ์นี้ไม่ใช่การเคลมที่เกินจริงว่า "เร็วกว่าห้าเท่า" อย่างที่มีการพูดถึงกันในฟอรัม เพราะ .NET สมัยใหม่มีการปรับแต่ง IndexOfAny สำหรับชุดข้อมูลขนาดเล็กไว้แล้ว ทำให้ช่องว่างความเร็วแคบลง
แต่เมื่อตัวคั่นมีอยู่อย่างเบาบาง เช่น ปรากฏขึ้นเพียงหนึ่งครั้งในทุกๆ 40 KB แทนที่จะเป็นทุกๆ 60 bytes ภาพจะเปลี่ยนไป:
- IndexOfAny(char[]) – 5.6 ms
- SearchValues
– 3.3 ms
ในกรณีนี้ SearchValues เร็วกว่า 1.7 × เพราะเอนจินสามารถวิ่งผ่านข้อมูลที่ไม่ตรงเงื่อนไขเป็นระยะทางยาวๆ ด้วยขั้นตอนแบบ vectorized ในขณะที่ IndexOfAny จะถอยกลับไปใช้วิธีการที่ไม่ได้รวดเร็วเท่า
ต้นทุนที่แฝงอยู่
SearchValues ไม่ได้มาฟรีๆ การสร้างออบเจกต์ต้องมีการจัดสรรหน่วยความจำและสร้างตารางการค้นหา (lookup tables) ภายใน หากคุณสร้างมันขึ้นมาใหม่ในทุกๆ การเรียกใช้เมธอด ค่าใช้จ่ายส่วนเกิน (overhead) จะกลบความเร็วที่ได้จาก runtime ไปจนหมด ผลการทดสอบที่เรียกใช้รูทีนการสแกนหนึ่งล้านครั้งบนบรรทัดสั้นๆ แสดงให้เห็นว่า:
- Static (นำกลับมาใช้ใหม่) SearchValues – รวม 25.1 ms
- SearchValues แบบสร้างใหม่ทุกครั้งที่เรียก – รวม 70.2 ms
การสร้างออบเจกต์ใหม่ทุกครั้งที่เรียกทำให้การทำงานทั้งหมดช้าลงถึงสามเท่าเมื่อเทียบกับการไม่ใช้ SearchValues เลย ดังนั้นควรเก็บอินสแตนซ์ไว้ในฟิลด์ static readonly หรือใช้วิธีการอื่นเพื่อนำกลับมาใช้ใหม่ในการเรียกครั้งต่อๆ ไป
เมื่อไหร่ที่ควรเลือกใช้ SearchValues
- ข้อมูลขาเข้าขนาดใหญ่ และมีจุดที่ตรงเงื่อนไขน้อย – เส้นทางแบบ vectorized จะโดดเด่นมากเมื่อตัวสแกนสามารถข้ามช่วงข้อมูลยาวๆ โดยไม่เจอตัวคั่นได้
- การสแกนซ้ำๆ ด้วยชุดข้อมูลเดิม – หากต้องค้นหาตัวอักษรชุดเดิมหลายครั้ง การเสียเวลาตั้งค่าเพียงครั้งเดียวจะคุ้มค่ามาก
เมื่อไหร่ที่ควรใช้ IndexOfAny ต่อไป
- สตริงสั้นๆ หรือมีจุดที่ตรงเงื่อนไขจำนวนมาก – ค่าใช้จ่ายในการตั้งค่าเพิ่มเติมจะมากกว่าความเร็วที่เพิ่มขึ้นเพียงเล็กน้อย
- โค้ดที่ใช้ IndexOfAny อยู่แล้ว – การปรับแต่งของ .NET สมัยใหม่นั้นดีเยี่ยมสำหรับชุดตัวอักษรขนาดเล็กอยู่แล้ว ดังนั้นการเปลี่ยนมาใช้
SearchValuesอาจไม่เห็นความแตกต่างอย่างมีนัยสำคัญ
ข้อผิดพลาดที่พบบ่อย
- การสร้างออบเจกต์ไว้ภายใน hot loops – นำไปสู่ความล่าช้าถึงสามเท่าตามที่แสดงไว้ข้างต้น
- การพยายาม "ฉลาดกว่า" runtime ด้วยการเขียนลูปเอง – เวอร์ชัน foreach แบบแมนนวลนั้นช้ากว่าทั้งสองวิธีที่มีมาให้ในตัวถึงสองเท่า ซึ่งยืนยันว่าไลบรารีของ .NET นั้นยากที่จะเอาชนะได้หากไม่มีความรู้เชิงลึกเกี่ยวกับคำสั่ง SIMD
- การทึกทักเอาเองว่ามันจะเร็วขึ้นในทุกกรณี – ชัยชนะเพียงครึ่งมิลลิวินาทีบนข้อมูลที่มีความหนาแน่นสูงพิสูจน์ว่า
SearchValuesไม่ใช่สูตรโกงที่ใช้ได้กับทุกอย่าง ประโยชน์ของมันขึ้นอยู่กับลักษณะของข้อมูล
บทสรุป
SearchValues<T> จะให้ความได้เปรียบที่ชัดเจนก็ต่อเมื่อคุณสแกนบัฟเฟอร์ขนาดใหญ่เพื่อหาตัวอักษรที่ปรากฏไม่บ่อย และสามารถยอมรับต้นทุนในการสร้างออบเจกต์เพียงครั้งเดียวได้ สำหรับสถานการณ์การค้นหาสตริงทั่วไปส่วนใหญ่ ไม่ว่าจะเป็นข้อมูลขาเข้าสั้นๆ, การเจอจุดที่ตรงเงื่อนไขบ่อยครั้ง หรือโค้ดที่ใช้ IndexOfAny อยู่แล้ว วิธีที่มีมาให้ในตัวยังคงเป็นทางเลือกที่ง่ายและเร็วกว่า จงใช้ SearchValues อย่างเจาะจง เก็บอินสแตนซ์ไว้ในแคช แล้วคุณจะหลีกเลี่ยงบทลงโทษที่แฝงอยู่พร้อมกับได้รับประสิทธิภาพที่แท้จริง
ซอร์สโค้ดและชุดการทดสอบฉบับเต็ม: https://dev.to/ssukhpinder/when-searchvalues-actually-pays-off-310l
