1 คะแนน โดย GN⁺ 2025-02-10 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ในเชเดอร์ของ GPU โค้ดที่เลือกค่าด้วย ternary operator หรือ if แบบง่าย ๆ มักถูกจัดการเป็น conditional move (select) ไม่ใช่ conditional branch
  • แม้จะเปลี่ยนไปใช้ step() และการ mask ด้วยเลขคณิต ก็ไม่มี branch ให้กำจัดตั้งแต่แรก ดังนั้นสมมติฐานเรื่องที่เรียกว่า การปรับแต่งเพื่อตัด branch จึงไม่ถูกต้อง
  • เอาต์พุตของคอมไพเลอร์ AMD และ Microsoft แสดงคำสั่งเปรียบเทียบและ conditional mask/move โดยไม่เห็นคำสั่ง jump หรือ branch
  • เวอร์ชัน step() สร้าง mask 0.0/1.0 แล้วใช้การคูณและการบวกเพื่อประกอบผลลัพธ์ ทำให้มี การคำนวณที่ไม่จำเป็น เพิ่มขึ้นเมื่อเทียบกับโค้ดที่ทำ conditional move โดยตรง
  • GPU branch ที่ข้ามบล็อกการคำนวณขนาดใหญ่ตามเงื่อนไขยังคงมีประโยชน์ แต่สำหรับการเลือกค่าแบบง่าย การตรวจสอบ machine code ที่สร้างออกมาจะปลอดภัยกว่า

การเลือกค่าแบบง่ายไม่ใช่ GPU branch

  • ฟังก์ชันตัวอย่าง snap45() คำนวณ x = abs(v.x) จากเวกเตอร์อินพุต แล้วคืนหนึ่งในผลลัพธ์ vec2 สามแบบด้วย ternary operator สองตัว
  • ตรรกะเดียวกันนี้ยังคงเหมือนเดิมแม้เขียนด้วยคำสั่ง if ปกติ
  • “การปรับแต่ง” ที่เป็นปัญหาคือการเปลี่ยน ternary operator เป็น step() และการประกอบผลรวมแบบถ่วงน้ำหนัก
    • สร้าง w0, w1, w2 ด้วย step()
    • คำนวณ res0, res1, res2 แยกกัน
    • ประกอบผลลัพธ์สุดท้ายด้วย w0*res0 + w1*res1 + w2*res2
  • การแปลงนี้เริ่มจากความเข้าใจผิดว่าโค้ดเดิมสร้าง conditional branch
  • การเลือกค่าในรีจิสเตอร์แบบง่ายไม่เปลี่ยน instruction pointer และไม่ทำให้เกิดการทำนายผิดพลาด, pipeline flush หรือการทำให้ instruction cache ใช้ไม่ได้
  • branch จริงบน GPU อาจเร็วและมีประโยชน์เมื่อข้ามบล็อกการคำนวณขนาดใหญ่ตามเงื่อนไข
  • อย่างไรก็ตาม ในกรณีที่เลือกค่าแบบง่ายหรือผลลัพธ์ของการคำนวณเหมือนตัวอย่างนี้ โดยทั่วไปถือได้ว่าไม่มี branch เกิดขึ้นใน machine code ที่สร้างออกมา

ความแตกต่างที่เห็นได้จากเอาต์พุตของคอมไพเลอร์

  • โค้ด GLSL เดิมที่ใช้ ternary operator ถูกคอมไพล์โดยคอมไพเลอร์ AMD เป็นคำสั่งเปรียบเทียบและ conditional mask
    • การเปรียบเทียบ: v_cmp_gt_f32, v_cmp_ngt_f32
    • conditional mask: v_cndmask_b32
  • เอาต์พุตของคอมไพเลอร์ Microsoft ก็แสดงโครงสร้างเดียวกัน
    • การเปรียบเทียบ: lt
    • conditional move: movc
  • เอาต์พุตของคอมไพเลอร์ทั้งสองไม่มี คำสั่ง jump/branch

เหตุผลที่วิธี step() มีต้นทุนสูงกว่า

  • วิธีที่ใช้ step() จะสร้าง mask 0.0 หรือ 1.0 ด้วย conditional move ก่อน แล้วจึง mask ผลลัพธ์ตัวเลือกหลายตัวด้วยการคูณและการบวก
  • โค้ดเดิมทำ conditional move กับค่าที่ต้องการโดยตรง จึงสิ้นเปลืองน้อยกว่าวิธี step() ที่ต้องเพิ่มการสร้าง mask และการประกอบด้วยเลขคณิต
  • บนฮาร์ดแวร์หลายแบบ เวอร์ชันที่ใช้ step() อาจวัดได้ว่าช้ากว่าเวอร์ชันเดิมมาก
  • การเรียก abs() บางส่วนใน GLSL ของโค้ดตัวอย่างไม่ได้เป็นคำสั่ง GPU แยกต่างหาก แต่เข้าไปเป็น instruction modifier ดังนั้นในกรณีนี้จึงมองได้ว่าการเรียก abs() แทบไม่มีต้นทุน
  • การแนะนำ float a = mix(b, c, step(y, x)); ให้เป็นการปรับแต่งของ float a = x < y ? b : c; เป็นแนวทางที่ผิด

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

 
GN⁺ 2025-02-10
ความคิดเห็นใน Hacker News
  • ข้อสรุปของบทความดูเหมือนจะถูกต้อง แต่ถ้าไม่ใช่แค่แสดงผลการสร้างโค้ดของเวอร์ชันที่ดีกว่าเท่านั้น แต่แสดง ผลการสร้างโค้ดของทั้งสองเวอร์ชัน ด้วย เหตุผลน่าจะหนักแน่นกว่านี้
    ในข้อความที่ยกมาบอกว่า “เวอร์ชันที่อ้างว่าปรับให้เหมาะสมนั้นช้ากว่าเวอร์ชันเดิมมาก… สิ้นเปลืองการคูณสองครั้งกับการบวกหนึ่งหรือสองครั้ง… มาดู machine code ที่สร้างขึ้นกัน” แต่ในความเป็นจริงกลับแสดงเฉพาะเวอร์ชันที่ดีซึ่งไม่มีการคูณหรือการบวก
    นั่นพิสูจน์ได้แค่ว่าเวอร์ชันที่ดีนั้นโอเค แต่ยังไม่ได้พิสูจน์ว่าเวอร์ชันที่แย่นั้นแย่กว่า

    • ประเด็นหลักคือ เงื่อนไขไม่ได้สร้าง branch จริง
      ถึงจะแสดงโค้ดที่สร้างขึ้นของอีกเวอร์ชัน ก็คงมีแนวโน้มแค่แสดงให้เห็นว่ามันยาวกว่า และฝั่งนั้นก็คงไม่คาดว่าจะเกิด branch เช่นกัน จึงไม่น่ามีคุณค่ามากนัก
    • โค้ดที่สร้างสำหรับ RDNA 1 อยู่ที่นี่: https://shader-playground.timjones.io/5d3ece620f45091678dcee...
  • ถ้ามีวิธีดี ๆ ในการรู้ว่าเมื่อไร if จะบังคับให้เกิด branch จริง และเมื่อไรจะไม่เกิด ก็คงดี
    เหตุผลที่ผู้คนใช้ mix/lerp ซึ่งอาจแพงกว่า ก็เพราะกลัวว่าจะเกิด branch แม้จะต้องยอมรับ overhead เล็กน้อยก็ตาม
    เป็นเรื่องดีที่โค้ดที่ชัดเจนที่สุดอย่าง v = x > y ? a : b; ทำงานได้ดีจริง แต่การที่ syntax if เดียวกันบางครั้งเป็น branch และบางครั้งไม่ใช่ ก็ทำให้ไม่สบายใจ
    ในบริบทที่ห้ามเกิด branch จริง ๆ ก็อยากให้ branch-if กับ if แบบไม่ branch เป็น keyword คนละตัว และ keyword แบบไม่ branch ควรทำให้คอมไพล์ล้มเหลวถ้าคอมไพเลอร์สร้างแบบไม่มี branch ไม่ได้ ส่วน keyword แบบ branch ก็ควรเตือนถ้าสามารถสร้างได้โดยไม่ต้องมี branch

    • เบื้องหลังเรื่องนี้คือ เอกสารที่ชวนสับสน ของ NVIDIA และคอมไพเลอร์ cg/CUDA
      ตอนแรกน่าจะไม่อยากทำให้โปรแกรมเมอร์กลัว จึงซ่อน execution model แล้วอธิบายด้วย abstraction ที่เรียกว่า “เธรด” และหลังจากนั้นในการโปรโมต GPU ก็ยังใช้แนวว่า “มี CUDA thread จำนวนมหาศาล” ต่อไป
      ผลก็คือเกิดความเชื่อแปลก ๆ ในการเขียนโค้ด GPU
      จริง ๆ แล้ว หลายครั้งการมี branch ในโค้ดก็เป็นเรื่องดี และ branch เองก็เร็ว
      ปัญหาคือ SIMD lane แต่ละ lane ไม่สามารถแยกออกไปคนละ branch ได้ ดังนั้นคอมไพเลอร์จึงปล่อยโค้ดทั้งสองฝั่งแทนการ branch แล้ว mask ผลลัพธ์ตามเงื่อนไข
      ดังนั้นการคำนวณที่อิงจากค่า input ของ shader, vertex, ดัชนีของ compute shader ฯลฯ จะไม่ branch จริง แต่จะรันตามลำดับด้วย masking
      ในตัวอย่างของบทความ ค่าทั้งสองฝั่งของตัวดำเนินการ ? ก็ถูกคำนวณทั้งหมด และโดยทั่วไปเงื่อนไขที่อิงกับค่า SIMD ก็เป็นแบบเดียวกัน
      อย่างไรก็ตาม อาจมี branch แบบลัดเพื่อข้ามการคำนวณอย่างรวดเร็วเมื่อทุก lane มีค่าเดียวกันได้ แต่โดยทั่วไปแล้วจะคำนวณทั้งฝั่งจริงและเท็จ
      มีเพียงเงื่อนไขที่อิงกับ scalar register คือค่าคงที่ของ shader หรือค่า uniform เท่านั้น ที่สร้าง branch จริง และ branch แบบนี้เร็วมาก
    • เรื่องนี้ก็เหมือนกันบน scalar CPU
      ตัวอย่างเช่น คำสั่ง CMOV ถูกนำมาใช้ในคอร์ P6 เมื่อปี 1995
      branch มีต้นทุนสูงแม้บนสถาปัตยกรรม scalar และคอมไพเลอร์จะพยายามตัดสินให้ดีที่สุดว่าเมื่อใดควรใช้กลยุทธ์ทดแทน
      บางครั้งก็ตัดสินผิด แต่ไม่ได้ผิดบ่อยมาก
    • บน GPU ควรมองกลับกันด้วยซ้ำ
      conditional move คือค่าเริ่มต้น และ branch จริงคือการปรับประสิทธิภาพที่เป็นไปได้เฉพาะเมื่อเป็น uniform branch ที่ทั้ง workgroup ไปในทิศทางเดียวกัน
    • ลองนึกถึงตัวอย่างนี้ได้: a = f(z); b = g(z); v = x > y ? a : b;
      ถ้าค่าใช้จ่ายในการเรียก f() และ g() ค่อนข้างสูง การจะปล่อยโค้ดแบบมีเงื่อนไข หรือคำนวณทั้งคู่แล้วค่อยเลือก ก็เป็นเรื่องของการแลกเปลี่ยน
      ไม่ใช่การเลือกที่ง่าย และคอมไพเลอร์จะเป็นผู้ตัดสิน
    • ถ้าภาษา shader มีฟีเจอร์แบบนั้นก็น่าสนใจ
      อาจแยกฟังก์ชันทั้งหมดในโค้ดเหมือนระบายสีว่า branch ได้/ไม่ branch และทำให้ฟังก์ชันที่ทำเครื่องหมายว่าไม่ branch ต้องถูกคอมไพล์ if เป็น conditional move และเรียกได้เฉพาะฟังก์ชันที่ไม่ branch เท่านั้น
  • ความเชื่อว่า “branch บน GPU ช้า” ส่วนใหญ่เกิดจากสมัย PlayStation 3 นานมาแล้ว ซึ่งตอนนั้นมันช้าจริงพอสมควร
    PS3 ใช้ RSX GPU ของ NVIDIA และจำได้ว่าในเอกสารระบุว่า branch ใช้ 6 cycle แต่การวัดจริงมักช้ากว่านั้นเสมอ
    แม้แต่ branch ที่ coherent อย่างสมบูรณ์ซึ่งทุก thread ใน warp ไปเส้นทางเดียวกันก็ยังเป็นแบบนั้น และ branch ที่ incoherent ก็ยิ่งช้ากว่า เพราะคำสั่ง IFEH ใช้ 6 cycle แถม GPU ต้องรัน branch ทั้งสองฝั่ง
    ผมคิดว่าความเชื่อที่สืบมาจนถึงวันนี้ว่า “branch บน GPU ช้า” เริ่มจากตรงนั้น
    branch บน GPU สมัยนี้ โดยเฉพาะ branch ที่ coherent ค่อนข้างถูกแล้ว

    • ถ้าใครพูดแค่ “branch” เฉย ๆ ก็ควรถือว่าหมายถึง incoherent branch
      overhead ของกลไก branch ในปัจจุบันอาจลดลงแล้ว แต่ข้อจำกัดทางกายภาพที่ throughput ของ branch แต่ละฝั่งจะลดลงตามสัดส่วนของ thread ที่ active ยังเหมือนเดิม
      ถ้าทั้งสองฝั่งของ branch ถูก execute ทั้งคู่และความยาวของคำสั่งเท่ากัน ประสิทธิภาพเฉลี่ยของทั้งสองฝั่งจะลดลงอย่างน้อยครึ่งหนึ่ง
      ดังนั้นความเชื่อว่า branch บน GPU ช้าจึงยังอยู่ได้นาน และในทางปฏิบัติก็เป็นเรื่องจริง
      ถ้าเป็นไปได้ก็คุ้มที่จะพยายามจัดรูปปัญหาใหม่ให้ไม่มี branch มากขึ้น
    • branch ที่ coherent แทบจะ “ฟรี” แต่คำสั่งเพิ่มเติมจะเพิ่ม แรงกดดันต่อ register
      เหตุผลหลักในการหลีกเลี่ยง dynamic branch ไม่ใช่เพราะ branch เองช้าโดยเนื้อแท้ แต่ใกล้เคียงกับเรื่องนี้มากกว่า
  • การปรับให้เหมาะสมแบบหลีกเลี่ยงการแตกแขนง ลักษณะนี้เคยได้ผลอยู่ช่วงหนึ่ง
    ผมเคยโปรไฟล์บน Xbox 360 กับ GPU ในตัวของ Intel รุ่นเก่า แต่ตอนนี้ควรเลิกทำแบบนั้นได้แล้ว
    การดึงบิตและการดำเนินการจำนวนเต็มอื่น ๆ ก็คล้ายกัน
    เมื่อก่อนการจำลองด้วยคณิตศาสตร์แบบทศนิยมลอยตัวจะเร็วกว่า แต่ตอนนี้ GPU ทุกตัวมีการดำเนินการจำนวนเต็มที่รวดเร็วแล้ว

    • ผมสงสัยว่าคำว่า “ตอนนี้ GPU ทุกตัวมีการดำเนินการจำนวนเต็มที่รวดเร็วแล้ว” จริงแค่ไหน
      เช่น ถ้าดู RDNA2 ISA ซึ่งเป็นสถาปัตยกรรมของ PS5 และ Xbox Series S|X ดูเหมือนจะเห็นแค่คำสั่งสเกลาร์ 32 บิตสำหรับจำนวนเต็มเท่านั้น
      [0] https://www.amd.com/content/dam/amd/en/documents/radeon-tech...
    • อย่างน้อยบน GPU “ตัวใหญ่” ก็ไม่ได้เป็นปัญหาใหญ่เหมือนเมื่อก่อนแล้ว แต่จริง ๆ บทความนี้ไม่ได้เกี่ยวกับการหลีกเลี่ยงการแตกแขนงเอง
      โค้ดที่ยกมานั้นเป็น โค้ดไร้การแตกแขนง อยู่แล้ว
      คนที่ให้คำแนะนำดูเหมือนจะตัดสินโค้ดแตกแขนงจากแค่ว่าในซอร์สมีไวยากรณ์ที่ดูเหมือนเงื่อนไขหรือไม่ แล้วคิดว่าการเลี่ยงสิ่งนั้นคือการปรับให้เหมาะสม
  • บทความนี้ก็เกี่ยวข้อง: https://medium.com/@jasonbooth_86226/branching-on-a-gpu-18bf...
    “ถ้าคุณถามอินเทอร์เน็ตว่าจะเขียน branch บน GPU อย่างไร เขาอาจพูดเหมือนคุณกำลังเปิดประตูนรกเชิญปีศาจเข้ามา ต้องหลีกเลี่ยงให้ได้ไม่ว่าจะต้องแลกด้วยอะไร และบอกว่าสามารถเลี่ยงได้ด้วยทริกคณิตศาสตร์ประหลาด ๆ อย่าง ternary operator หรือ step() คำแนะนำส่วนใหญ่นี้ถ้ามองในแง่ดีก็ล้าสมัย และหลายครั้งก็ผิดไปเลย มาทำความเข้าใจให้ถูกกัน”

  • โปรเซสเซอร์ก็เปลี่ยน คอมไพเลอร์ก็เปลี่ยน
    ถ้ารายละเอียดพวกนี้สำคัญ วิธีที่ดีที่สุดคือแจกจ่ายหลายเวอร์ชันแล้วเลือกรุ่นที่ เร็วที่สุด ตอนรันไทม์
    อย่างที่เคยพูดไปหลายครั้งแล้ว ผมเคยลบแอสเซมบลีที่เขียนเองออก แล้วแทนด้วย C ธรรมดาหรือโค้ดคล้าย ๆ กัน จนทำให้เร็วขึ้นมาก
    แอสเซมบลีนั้นอาจเร็วกว่าเมื่อ 10–20 ปีก่อน แต่ตอนนี้สถานการณ์เปลี่ยนไปแล้ว

    • ผมคิดว่าการหารุ่นที่เร็วที่สุดของเชดเดอร์ตอนรันไทม์นั้นยุ่งยากมาก
      ไม่ค่อยรู้จักเกมหรือเอนจินที่ทำแบบนั้นจริง ๆ
      โดยหลักการแล้วอาจทำได้
      API ส่วนใหญ่ เช่น D3D, GL, Vulkan เปิดเผย performance counters และแม้ความน่าเชื่อถือจะแตกต่างกันไปตามผู้ผลิต แต่ก็สามารถสร้างฉากทดสอบตัวแทนแล้วเล่นซ้ำหลาย ๆ ครั้งเพื่อวัดการปรับให้เหมาะสมได้
      แต่เกมจำนวนมากใช้ฉากที่สร้างแบบไดนามิกและเชดเดอร์ที่สร้างแบบไดนามิก ดังนั้นจำนวนชุดผสมที่ต้องทดสอบอาจกลายเป็นอุปสรรคได้
      อาจต้องให้ผู้ใช้รอจนกว่า benchmark จะจบ
      ถ้ามีฮาร์ดแวร์ ก็อาจวัดล่วงหน้าบน GPU หลายเจเนอเรชันของแต่ละผู้ผลิต แล้วฮาร์ดโค้ดเฉพาะการตัดสินใจสำคัญ ๆ ได้ แต่ผมไม่ค่อยรู้จัก โครงสร้างพื้นฐานเดิม แบบนั้น
    • ที่น่าสนใจคือไดรเวอร์ NVIDIA ทำอะไรทำนองนั้นอยู่ระดับหนึ่ง
      มันดักเชดเดอร์ของเกมแล้วแทนที่ด้วยเชดเดอร์คัสตอมที่ NVIDIA ปรับให้เหมาะสมแล้ว
      ดังนั้นในบันทึกการเปลี่ยนแปลงของไดรเวอร์ NVIDIA จึงเห็นข้อความทำนอง “ปรับแต่งเกม X ให้เร็วขึ้น 40%”
    • ถ้าแค่เพิ่มเชดเดอร์อีกตัวก็คงพอไหว แต่ใน graphics API แบบ “สมัยใหม่” บางครั้งเชดเดอร์เดียวกันอาจต้องมี permutation เป็นพัน ๆ แบบ และทุกครั้งที่เพิ่ม variant เข้าไปหนึ่งตัว จำนวนก็จะเพิ่มเป็น 2 เท่า
      เราไม่สามารถใช้เวลากับแต่ละเชดเดอร์ได้ไม่จำกัด
      ก็ต้องโปรไฟล์บนฮาร์ดแวร์ที่สนใจ แล้วถ้าวิธีที่เลือกช้ากว่าบนโปรเซสเซอร์ในอนาคตที่สมมติขึ้นมา ก็ช่วยไม่ได้
      หวังได้แค่ว่าโปรเซสเซอร์นั้นจะเร็วพอจนไม่เป็นปัญหา
  • ดูเหมือนว่าความผิดพลาดและความสับสนที่บทความนี้พยายามแก้ไขจะเกิดซ้ำที่นี่ด้วย
    บทความไม่ได้อ้างว่า conditional branch นั้นฟรี
    ในมุมผม มันไม่ใช่บทความที่พูดถึงต้นทุนด้านประสิทธิภาพของโค้ดแตกแขนงด้วยซ้ำ
    ประเด็นของบทความคือรูปแบบตรรกะเงื่อนไขที่ยกมาจะไม่ถูกคอมไพล์เป็นโค้ด conditional branch
    และเราไม่ควรเผยแพร่คำแนะนำที่เป็นอันตรายว่าให้ฝืนซ่อนนิพจน์เงื่อนไขทุกอย่างที่มองเห็นต่อไป
    สำหรับโค้ดแตกแขนงจริง ๆ นั้น เห็นได้ชัดว่าการรันโค้ดแตกแขนงซับซ้อนกว่า
    ไม่มี branch ฟรี และถ้าหลีกเลี่ยง branch ได้ในขอบเขตที่สมเหตุสมผล โค้ดใด ๆ ก็มีโอกาสเร็วขึ้น
    โชคดีที่โค้ดเดิมเป็น โค้ดไร้การแตกแขนง อยู่แล้ว
    เช่นเคย ไม่มีมาตรวัดสากลที่จะบอกได้ว่าการปรับให้เหมาะสมนั้นคุ้มค่าหรือไม่
    [0] ตรงนี้คำว่า “ที่มองเห็น” สำคัญ หมายถึงกรณีที่ไม่สนใจโค้ดที่ generate ออกมา แต่สนใจแค่ว่าซอร์สโค้ดดูไม่เหมือนเงื่อนไขหรือไม่
    [1] แน่นอนว่านี่ไม่ใช่เรื่องโชคดี ผมเดาว่าคงมีใครสักคนส่งข้อเสนอปรับปรุงโค้ดเชดเดอร์ที่ดูชัดเจนแต่ผิดไปให้ IQ

  • แล้วทำไมคอมไพเลอร์ถึงไม่ฉลาดพอจะรู้ว่าเวอร์ชันที่ “ปรับให้เหมาะสมแล้ว” เป็นโค้ดเดียวกันล่ะ?
    มันควรจะเข้าใจ step() และปรับให้เหมาะสมแยกกรณี step() = 0.0, step() == 1.0 ได้ไม่ใช่หรือ?
    อย่างน้อยก็ตัดการคูณออกไปได้หนึ่งครั้ง ดังนั้นโดยทั่วไปแม้จะถูกเปลี่ยนเป็น conditional load/store หรืออย่างอื่น ก็น่าจะได้ประโยชน์เสมอ

    • ในทางปฏิบัติก็อาจเป็นอย่างนั้นได้
      คอมไพเลอร์บางตัวมีโอกาสสูงที่จะทำการปรับแบบนี้ในบางกรณี แต่ก็เป็นไปได้ชัดเจนเช่นกันที่จะเขียนเวอร์ชันที่คอมไพเลอร์ไม่เข้าใจ
    • ปัญหาอีกอย่างของการปรับให้เหมาะสมคือ ต้องไม่ใช้เวลานานเกินไปในการลองความเป็นไปได้ทั้งหมด
      การปรับให้เหมาะสมส่วนใหญ่เกิดขึ้นฝั่งไดรเวอร์ และงานที่ใช้เวลานานเกินไปจะปรากฏออกมาเป็น อาการสะดุดจากการคอมไพล์เชดเดอร์
      ผมบอกไม่ได้ว่าการปรับให้เหมาะสมนี้เกิดขึ้นจริงหรือไม่ แต่เป็นปัจจัยที่ต้องพิจารณาเสมอ
  • เหตุผลที่เวอร์ชัน “เพิ่มประสิทธิภาพ” ที่เป็นปัญหากลับช้ากว่า เป็นเพราะฟังก์ชัน step() จริง ๆ แล้วถูกอิมพลีเมนต์ประมาณนี้:
    float step( float x, float y ) { return x < y ? 1.0 : 0.0; }
    แล้วเราจะรู้ได้อย่างไรว่าฟังก์ชันของ OpenGL เรียกใช้ primitive ของ GPU หรือถูก emulation อยู่?

    • วิธีเดียวคือทำเหมือนบทความต้นทาง คือคอมไพล์ shader, disassemble แล้วอ่าน assembly
      ผมเคยทำแบบนั้นกับ HLSL shader บ่อย ๆ และได้เรียนรู้เกี่ยวกับชุดคำสั่งเสมือนมามาก
      ตัวอย่างเช่น น่าสนใจที่ GPU มีคำสั่ง sincos แต่ฟังก์ชันตรีโกณมิติผกผันจะถูก emulation ระหว่างการคอมไพล์
    • เหตุผลว่าทำไมต้องรู้ ขึ้นอยู่กับเป้าหมาย
      ถ้าประสิทธิภาพสำคัญ ก็อาจจำเป็นต้องรู้
      แต่แค่ข้อเท็จจริงว่า step ไม่ใช่คำสั่งเฉพาะทาง แต่ถูกอิมพลีเมนต์เป็นฟังก์ชันไลบรารีบนเงื่อนไข ไม่ได้บอกประสิทธิภาพเมื่อเทียบกับคำสั่งเฉพาะทาง ดังนั้นไม่จำเป็นต้องยึดติดกับตัวอิมพลีเมนต์มากเกินไป
      ถ้าอยากรู้เรื่องสถาปัตยกรรม GPU ก็ดู disassembly, โค้ดไดรเวอร์โอเพนซอร์ส, LLVM และเอกสาร ISA ได้
    • ผมไม่เคยเห็นกรณีที่ GPU มี primitive พิเศษ นอกเหนือจากฟังก์ชันแบบที่มักเห็นใน assembly สไตล์ PC
      ทุกครั้งที่ดู shader ที่ถูก decompile โดยรวมก็คล้ายกับสิ่งที่คิดในภาษา C
      specification อย่าง OpenGL กำหนดพฤติกรรมของฟังก์ชัน built-in จำนวนมาก และ implementation ก็ทำให้เป็นไปตาม specification นั้นด้วยคำสั่ง assembly มาตรฐาน
      ลองหาเว็บไซต์ออนไลน์ที่ decompile ไปยังหลายสถาปัตยกรรมดูได้
    • นี่เป็นคำถามที่ดีซึ่งพบได้บ่อยในงานเขียนโปรแกรมโดยรวม และยังเป็นเหตุผลสำคัญว่าทำไมเวลา optimize ต้อง วัดก่อน
      โดยปกติเราไม่จำเป็นต้องรู้หรือสนใจว่าฟังก์ชัน built-in ถูกอิมพลีเมนต์อย่างไร
      ถ้าคุณกำลังสนใจอยู่ ก็น่าจะกำลังคิดเรื่องการ optimize และคำตอบตอนนั้นคือ “วัดแล้วตรวจสอบว่าอะไรดีกว่า”
    • จุดที่ผมสับสนดูเหมือนจะเป็นเรื่องที่คำว่า “branch” มีความหมายที่นิยามตามฮาร์ดแวร์มากกว่าความหมายที่ผมเรียนรู้มา
      ตามความหมายที่ผมเรียนมา conditional statement ก็คือ branch
      ในระดับ machine code control flow ถูกเลือกระหว่าง runtime ดังนั้น conditional jump จึงเป็น branch ตามนิยาม
      การใช้ step() ไม่ใช่การเปลี่ยนตรรกะให้เป็นเลขคณิต แต่เป็นเพียงการซ่อนตรรกะไว้ใน call ของฟังก์ชันไลบรารีเท่านั้น
      การที่ step() เป็นฟังก์ชัน built-in หรือเป็นฟังก์ชันที่ปรากฏในบทความคณิตศาสตร์ไม่ได้ทำให้ต่างออกไป
      ในคณิตศาสตร์ นิยามของ step() ก็เป็น conditional statement ตรง ๆ
      ถ้าจะ optimize ให้ถูกต้องโดยไม่มี conditional statement ต้องเลือกฟังก์ชันต่อเนื่องที่คล้ายผลลัพธ์ที่ต้องการ แล้วปรับพารามิเตอร์ให้เข้าใกล้เป้าหมายที่สุด
      โดยทั่วไปจะเลือกพหุนาม รันวิธีประมาณค่าแบบวนซ้ำมาตรฐาน แล้วได้ f(x) ที่ไม่มี branch มีแค่การบวก·การคูณ และค่าคงที่ที่ “เจาะจงอย่างประหลาด”
      ส่วนที่ผู้เขียนยืนยันหนักแน่นว่า conditional move ไม่ใช่ “branch” นั้น ผมไม่ค่อยเข้าใจ
      ที่ abs() ไม่ได้กลายเป็นคำสั่ง GPU แต่ลดรูปเป็น instruction modifier จนเหมือนฟรีได้ เป็นเพราะ representation แบบ two's complement ของจำนวนเต็มและ IEEE-754 floating point ทำให้จัดการบิตเครื่องหมายเป็นบิตบนสุดได้
      ดังนั้น abs() ก็จบแค่ทำให้บิตบนสุดเป็น 0 เสมอ หรือ mask ตอนคำสั่งอ่าน
      แต่ step() หรือ ternary operation ใด ๆ และเท่าที่ผมรู้ คำสั่ง conditional move ไม่ใช่กรณีพิเศษแบบนั้น
      ความรู้พื้นฐานอย่าง abs(), sqrt(), ฟังก์ชันตรีโกณมิติถือว่าใกล้เคียงความรู้มาตรฐาน ส่วนที่เหลือก็สงสัยว่าจะสำคัญแค่ไหนอยู่ดี
      step() จำเป็นต้องมีเงื่อนไขอยู่ที่ไหนสักแห่ง และไม่ว่าจะทำเอง ฝากให้ไลบรารีทำ หรือฝากให้ฮาร์ดแวร์ทำ ลักษณะพื้นฐานก็ไม่เปลี่ยน
  • ผมเคยตกหลุมพรางนี้มาแล้ว
    Claude หรือ ChatGPT ก็มักเสนอสิ่งนี้เป็นการ optimization
    แต่ทุกครั้งที่ลองวัด ประสิทธิภาพกลับตกลง และบางครั้งตกลงค่อนข้างมากด้วย

    • ไม่ใช่เรื่องแปลก
      LLM ก็แค่ทำซ้ำสิ่งที่อยู่ใน corpus สำหรับฝึก
      ถ้าอินเทอร์เน็ตส่วนใหญ่แนะนำเนื้อหาผิด ๆ แบบ “optimization” ด้วย conditional move นี้ LLM ก็จะแนะนำแบบนั้นด้วย
    • LLM ทำซ้ำสิ่งที่ผู้คนบนอินเทอร์เน็ตพูด และผู้คนก็มักผิดบ่อย