เกมสองเวอร์ชันที่มี gameplay เหมือนกันเป๊ะ สามารถเล่นลื่นไม่ลื่นต่างกันได้แบบสุดขั้ว ความต่างนั้นแทบไม่ใช่เรื่องดวงเลย — มันขึ้นอยู่กับว่ามีใครวัดจริง ๆ ไหมว่าเวลาและหน่วยความจำหายไปไหน แทนที่จะเดา บทนี้พูดถึงเครื่องมือที่ Unity ให้มาเพื่อตอบคำถาม "ทำไมมันช้า" ด้วยตัวเลขจริง ไม่ใช่ความรู้สึก: หน้าต่าง Profiler, Frame Debugger, Memory Profiler, และนิสัยที่ต้องเช็คบนอุปกรณ์จริงเสมอ แทนที่จะเชื่อตัวเลขจาก editor
การเดาเรื่อง performance เป็นอะไรที่ล่อใจ เพราะมันรู้สึกเหมือนเร็วกว่า — คุณดูโค้ด ตัดสินใจว่าอะไร "ดูเหมือน" จะแพง แล้วก็ไปแก้ตรงนั้น ปัญหาคือเกมสมัยใหม่มีชิ้นส่วนที่ทำงานพร้อมกันเยอะมาก: script ของ gameplay, physics, animation, particle system, UI, rendering pipeline, และ garbage collector ที่ทำงานอยู่เบื้องหลัง อาการช้ามักจะมาจากส่วนที่คุณไม่ได้มองอยู่เลยด้วยซ้ำ
นี่คือเรื่องจริง (แบบที่เกิดขึ้นได้กับทีมจริง ๆ) ของการเดาผิด
ทีมเล็ก ๆ ทีมหนึ่งกำลังทำ boss fight ให้เสร็จสำหรับเกมมือถือ บนโทรศัพท์ที่ใช้ทดสอบ เกมมีอาการกระตุก (stutter) — สะดุดเห็นชัด ๆ — ประมาณทุก ๆ สองวินาทีระหว่างต่อสู้กับบอส โปรแกรมเมอร์ดูโค้ดของบอส เห็นระบบ pathfinding ที่คำนวณเส้นทางไปหาผู้เล่นใหม่ ทุกเฟรมเลย แล้วคิดว่า "อันนี้แพงชัวร์ คำนวณเส้นทางใหม่ 60 ครั้งต่อวินาทีมันเปลืองไปหน่อย" เลยใช้เวลาสองวันเขียน pathfinding ใหม่ให้คำนวณแค่ทุก 10 เฟรมแทนที่จะเป็นทุกเฟรม — งานจริงจัง ทำอย่างระมัดระวังและถูกต้องด้วย
พอ build ใหม่ รันบนโทรศัพท์เครื่องเดิม อาการกระตุกยังอยู่ ที่อัตราเดิมเป๊ะ แทบไม่เปลี่ยนเลย frame time เฉลี่ยขยับจาก 14.9 ms เป็น 14.6 ms — ไม่ใช่ว่าไม่เปลี่ยนเลย แต่ก็ไม่พอที่จะอธิบายอาการสะดุดที่เห็นชัดทุกสองวินาที
จนกระทั่งพวกเขาเปิดหน้าต่าง Profiler ของ Unity แล้วดูจริง ๆ ถึงรู้ว่าโค้ด pathfinding ต่อให้รันทุกเฟรม ก็เสียแค่ประมาณ 0.3 ms เท่านั้น — เสี้ยวเล็ก ๆ ของ budget 16.6 ms ต้นตอที่แท้จริง เห็นชัดตั้งแต่แวบแรกที่มอง คือ spike ก้อนใหญ่ที่ตรงกับจังหวะอาการสะดุดพอดี: การหยุดชะงักจาก garbage collection ที่เกิดจาก health bar ของบอสสร้าง text ใหม่ทุกเฟรม ไม่ว่าค่า health จะเปลี่ยนหรือไม่ก็ตาม นิสัยแย่ ๆ อันเดียวนี้สร้างขยะ (garbage) มากพอที่จะทำให้เกิดการหยุดชะงักหลาย millisecond ประมาณทุกสองวินาที ส่วนที่ 9 ของบทนี้จะไล่ดู bug ตัวนี้ในโค้ดแบบละเอียด
บทเรียนตรงนี้ไม่ใช่ "pathfinding ไม่มีทางแพง" หรือ "ให้สงสัย UI ไว้ก่อนเสมอ" บทเรียนคือ คุณบอกไม่ได้หรอกว่าเวลาหายไปไหนแค่จากการอ่านโค้ดแล้วเดา — คุณต้องวัด profiler เปลี่ยนคำว่า "ฉันคิดว่าอันนี้ช้า" ให้กลายเป็น "ฟังก์ชันนี้เป๊ะ ๆ บนเฟรมนี้เป๊ะ ๆ กินเวลาไปกี่ millisecond" ซึ่งเป็นวิธีเดียวที่จะรู้ว่าคุณกำลังแก้สิ่งที่ถูกต้อง ก่อนที่จะเสียเวลาไปสองวัน
"Frames per second" (fps จำนวนเฟรมที่เกมวาดเสร็จสมบูรณ์ใน 1 วินาที) คือตัวเลขที่ผู้เล่นเห็น แต่ไม่ใช่ตัวเลขที่คุณควรคิดตอน optimize ตัวเลขที่สำคัญจริง ๆ คือ frame time: ใช้เวลากี่ millisecond ในการสร้างหนึ่งเฟรม fps ก็แค่เอา 1000 หารด้วย frame time เป็น millisecond
แปลง target fps ให้เป็น frame budget (เวลาสูงสุดที่หนึ่งเฟรมได้รับอนุญาตให้ใช้) แบบนี้:
frame budget (ms) = 1000 / target fps
60 fps -> 1000 / 60 = 16.666... ms (round to 16.6 ms)
30 fps -> 1000 / 30 = 33.333... ms (round to 33.3 ms)
ตัวเลขนั้นคือ budget รวมของ ทุกอย่าง ที่ต้องเกิดขึ้นก่อนที่เฟรมถัดไปจะแสดงได้: อ่าน input, รัน Update() ของทุก script, physics, animation, particle system, audio, สร้างรายการสิ่งที่ต้องวาด, และ GPU วาดมันจริง ๆ ถ้าเฟรมไหนใช้เวลาเกิน budget เฟรมนั้นจะปรากฏช้าลง — เกิดอาการสะดุด ที่เรียกอีกอย่างว่า hitch หรือ spike
สังเกตว่างาน CPU 7 ms กับงาน GPU 9 ms ชุดเดียวกันนี้ ใส่เข้าไปใน budget 30 fps ได้แบบสบาย ๆ มีที่เหลือเยอะ แต่พอใส่เข้าไปใน budget 60 fps กลับพอดีเป๊ะ แทบไม่เหลือที่เลย นี่คือเหตุผลว่าทำไม "ทำให้รันที่ 60 fps" ถึงยากกว่า "ทำให้รันที่ 30 fps" มาก — คุณมีเวลาแค่ประมาณครึ่งเดียวในการทำงานปริมาณเท่ากัน
เฟรมของเกมคุณถูกสร้างขึ้นโดยตัวประมวลผลสองตัวที่ทำงานร่วมกัน: CPU (central processing unit — รัน script C# ของคุณ, physics, animation, และตัดสินใจว่าต้องวาดอะไรบ้าง) กับ GPU (graphics processing unit — วาด pixel จริง ๆ) มันไม่ได้ทำงานทีละอย่างเรียงกันแบบธรรมดา Unity ทำให้มันซ้อนทับกัน (overlap) เพื่อให้ CPU เริ่มเตรียมเฟรมถัดไปได้ ในขณะที่ GPU ยังวาดเฟรมปัจจุบันอยู่ การซ้อนทับแบบนี้เรียกว่า pipelining
เพราะการซ้อนทับนี้ frame time สุดท้ายของคุณในสภาวะคงที่ (steady state) จะประมาณเท่ากับต้นทุนที่ใหญ่กว่าในสองอย่าง ไม่ใช่ผลรวมของทั้งคู่ ถ้า CPU ใช้ 11 ms และ GPU ใช้ 6 ms frame time ของคุณจะใกล้เคียง 11 ms ไม่ใช่ 17 ms — GPU ทำเสร็จก่อนแล้วก็รอ เรื่องนี้สำคัญมากตอน optimize: ถ้าคุณ CPU-bound (งาน CPU เป็นต้นทุนที่ใหญ่กว่าและเป็นตัวจำกัด) การทำให้ shader ถูกลงหรือลด resolution แทบไม่ช่วยอะไรเลย เพราะ GPU ไม่เคยเป็นคอขวดตั้งแต่แรก ถ้าคุณ GPU-bound (งาน GPU เป็นต้นทุนที่ใหญ่กว่าและเป็นตัวจำกัด) การเขียน script ฝั่ง gameplay ใหม่ให้เร็วขึ้นก็แทบไม่ช่วยเช่นกัน
แล้วจะรู้ได้ยังไงจริง ๆ ว่าเป็นแบบไหน แทนที่จะเดา มีสองวิธีที่เชื่อถือได้:
Gfx.WaitForPresent หรือรอ render thread แปลว่า CPU ทำงานของตัวเองเสร็จเร็วแล้วกำลังค้างรอ GPU — นั่นคือสัญญาณของ GPU-boundความแตกต่างนี้ตัดสินทุกอย่างว่าคุณควรใช้เวลาต่อไปตรงไหน: การ optimize โค้ดฝั่ง CPU ทั้งที่จริง ๆ คุณ GPU-bound (หรือกลับกัน) อาจกินเวลาเป็นวัน ๆ โดยแทบไม่มีผลที่วัดได้เลย — เป็นข้อผิดพลาดเดียวกับในส่วนที่ 1 เป๊ะ แค่ย้ายมาอยู่บนแกน CPU/GPU แทนที่จะเป็นแกน AI/UI
Profiler ของ Unity (หน้าต่างในตัวที่บันทึกว่าแต่ละส่วนของเกมใช้เวลาและหน่วยความจำเท่าไหร่ ในทุก ๆ เฟรม) เปิดได้จาก Window > Analysis > Profiler มันแบ่งเป็นสองส่วนที่ทำงานร่วมกัน
ครึ่งบนแสดงแถบกราฟที่ซ้อนกัน หนึ่งแถบต่อหนึ่ง module (CPU Usage, Rendering, Memory, Audio และอื่น ๆ) กราฟของแต่ละ module ประกอบด้วยแท่งแนวตั้งบาง ๆ — หนึ่งแท่งต่อหนึ่งเฟรม — และความสูงของแท่งคือกี่ millisecond ที่เฟรมนั้นใช้ไป ภายใน CPU Usage module แต่ละแท่งยังถูกแบ่งเป็นแถบสีต่าง ๆ แสดงคร่าว ๆ ว่าเวลาของเฟรมนั้นแบ่งไปให้หมวดหมู่ไหนบ้าง เช่น Scripts, Rendering, Physics, และ Garbage Collection
คลิกที่แท่งไหนก็ได้เพื่อเลือกเฟรมนั้น สิ่งที่คุณเลือกในครึ่งบนจะเป็นตัวกำหนดว่าครึ่งล่างจะแสดงอะไร
ครึ่งล่างจะแตกเฟรมที่เลือกออกเป็นรายฟังก์ชัน ใน Hierarchy view มันจะหน้าตาเหมือนตาราง: ชื่อฟังก์ชัน, กี่ millisecond ที่มันใช้ไป (Time ms), และกี่ byte ที่มันจองไว้บน managed heap (GC Alloc) แถวต่าง ๆ ซ้อนกันเป็นลำดับชั้น — ขยาย PlayerLoop ออกมาจะเห็นระบบต่าง ๆ ที่ Unity รัน ขยายอันใดอันหนึ่งในนั้นออกมาจะเห็นเมธอดของ MonoBehaviour ที่มันเรียก ไปเรื่อย ๆ จนถึงโค้ดของคุณเอง
Timeline view แสดงข้อมูลชุดเดียวกันแต่วางบนแกนเวลาจริง ๆ แยกตาม thread ซึ่งทำให้ช่องว่างและการรอเห็นได้ง่ายกว่าแบบตาราง ทั้งสอง view แสดงข้อมูลชุดเดียวกัน Hierarchy view มักจะเร็วกว่าสำหรับการหา "ตัวเลขที่ใหญ่ที่สุดคืออะไร" ส่วน Timeline view เหมาะกว่าสำหรับการดูว่า "ตอนนั้นทุกอย่างกำลังทำอะไรกันอยู่พอดี"
ทั้งสอง view แยกงานตาม thread main thread คือที่ที่โค้ด gameplay ของคุณรัน — ทุกเมธอดของ MonoBehaviour, physics step, animation update, บวกกับการสร้างรายการคำสั่งวาดสำหรับ GPU หลายแพลตฟอร์ม Unity ยังใช้ render thread แยกต่างหาก งานเดียวของมันคือเอารายการคำสั่งนั้นไปส่งให้ graphics driver จริง ๆ เพื่อที่ main thread จะได้ไม่ต้องรอ driver เอง ช่องว่างใหญ่ ๆ บนแถวของ render thread มักแปลว่ามันกำลังรอ main thread ส่งงานมาให้ ช่องว่างใหญ่ ๆ บน main thread มักแปลว่ามันกำลังรอ render thread หรือ GPU
Spike (เรียกอีกอย่างว่า hitch หรือ stutter ผลที่ผู้เล่นเห็นได้จริง) ก็คือแท่งของเฟรมหนึ่งที่โผล่สูงกว่าแท่งรอบข้าง คุณไม่จำเป็นต้องอ่านตัวเลขแม่นยำเพื่อสังเกตมันหรอก — แถวของแท่งความสูงใกล้เคียงกันเรียงกันไป แล้วมีแท่งหนึ่งโผล่สูงกว่าเพื่อนอย่างชัดเจน นั่นแหละคือ spike และมันคือเฟรมแบบที่คุณควรคลิกเป็นอันดับแรก
เฟรมทั้งสองข้างของ spike ที่เป็นปกติ จริง ๆ แล้วเป็นข้อมูลที่มีประโยชน์: มันบอกคุณว่าต้นทุนนี้ไม่ได้คงที่ (แบบว่า "ฉากนี้มี object เยอะเกินไปตลอดเวลา") แต่มันเป็น event-driven — มีอะไรบางอย่างเกิดขึ้นเฉพาะบนเฟรมนั้นเฟรมเดียว สาเหตุที่พบบ่อยของ spike แบบโดด ๆ นี้: การรัน garbage collection (ส่วนที่ 8), การโหลด asset แบบ synchronous, physics query ที่บางครั้งทำงานหนักกว่าปกติมาก หรือการ rebuild layout ของ UI ที่ถูกกระตุ้นจากการเปลี่ยนแปลงจุดใดจุดหนึ่ง คลิกที่ spike ดู Hierarchy view ด้านล่าง ตัวเลข Time ms หรือ GC Alloc ที่ใหญ่ที่สุดใน breakdown ของเฟรมนั้น แทบจะเป็นคำตอบเสมอ
GC Alloc แสดงอยู่ จากนั้นคลิกที่หัวคอลัมน์นั้นเพื่อ sort ตามมัน spike ที่เกิดจาก garbage collection จะเลื่อนขึ้นไปอยู่บนสุดทันที แทนที่คุณจะต้อง scroll หาแล้วเดาเองProfiler บอกคุณว่าเฟรมหนึ่งใช้เวลา สมมติ 3.3 ms ไปกับ rendering — แต่ไม่บอกว่า object ไหน หรือทำไม Frame Debugger ของ Unity (Window > Analysis > Frame Debugger) ตอบคำถามนั้น: มันหยุดเฟรมหนึ่งไว้ แล้วให้คุณ step ผ่านทุก draw call (คำสั่งหนึ่งชุดที่บอก GPU ว่า "วาด mesh นี้ ด้วย material นี้ ตอนนี้เลย") ตามลำดับที่ Unity สั่งจริง ๆ ทีละคำสั่ง ให้เห็นภาพค่อย ๆ ก่อตัวขึ้นบนหน้าจอทีละ call
สำหรับแต่ละ draw call ที่เลือก panel ด้านข้างจะบอกเป๊ะ ๆ ว่ามันมาจาก GameObject และ material ไหน, ใช้ shader และ pass อะไร, จำนวน vertex กับ triangle, และ — สำคัญมาก — Unity สามารถ batch มันได้ไหม (รวมกับ draw call อื่นเป็นการส่งข้อมูลให้ GPU ครั้งเดียวเพื่อลด overhead) ถ้าไม่ได้ ทำไมถึงไม่ได้ (material ต่างกัน, shader keyword ต่างกัน และอื่น ๆ ที่ทำให้ batching พัง) การเห็น UI element ตัวเดียวกันถูก list เป็นสอง draw call แยกกัน แบบแถว [190] ด้านบน คือเบาะแสชัด ๆ ที่บ่งบอกว่า UI element ตัวนั้นถูก rebuild บ่อยเกินความจำเป็น — เป็น bug ตระกูลเดียวกับปัญหา health bar text จากส่วนที่ 1
Memory Profiler (package แยกต่างหาก com.unity.memoryprofiler ติดตั้งผ่าน Package Manager) ตอบคำถามที่ต่างจากหน้าต่าง Profiler: ไม่ใช่ "ใช้เวลาเท่าไหร่" แต่เป็น "อะไรกำลังใช้ RAM เท่าไหร่ ตอนนี้เลย" คุณเก็บ snapshot (การจับภาพหน่วยความจำทั้งหมด ณ ช่วงเวลาหนึ่ง) แล้ว Unity จะให้ breakdown เต็ม ๆ มา
Summary view แบ่งหน่วยความจำเป็นสองหมวดใหญ่ ที่คุณมีพื้นฐานอยู่แล้ว: native memory (หน่วยความจำที่ฝั่ง engine C++ ของ Unity เป็นเจ้าของ — texture, mesh, audio clip, และ engine object อื่น ๆ) กับ managed memory (C# heap จากบทเรื่อง memory ก่อนหน้านี้ — ทุก object, array, และ string ที่ script ของคุณจองไว้ ถูกติดตามโดย garbage collector)
Tree Map view วาด breakdown ชุดเดียวกันนี้เป็นกล่องซ้อนกันที่ขนาดตามหน่วยความจำที่ใช้ ทำให้ texture ที่ไม่ได้ใช้ตัวใหญ่มาก ๆ หรือ array ที่บวมขึ้นเรื่อย ๆ โผล่ให้เห็นชัดด้วยตา แทนที่จะซ่อนอยู่ในลิสต์ตัวเลขยาว ๆ แต่ฟีเจอร์ที่ทรงพลังที่สุดคือการ เปรียบเทียบ snapshot สองอัน — เก็บอันแรกทันทีหลังจาก level โหลดเสร็จ เล่นไปสักพัก เก็บอันที่สอง แล้วให้ Unity diff ให้ อะไรก็ตามที่โตขึ้นแล้วไม่ยอมลดลงมา (array ของ texture ที่ควรถูก unload ไปแล้ว, list ของศัตรูที่โตขึ้นเรื่อย ๆ เพราะตัวที่ตายแล้วไม่เคยถูกเอาออก) คือตัวเก็งอันดับต้น ๆ ของ memory leak — บางอย่างถูกทำให้ยังมีชีวิตอยู่โดย reference ที่ไม่มีใครตั้งใจจะเก็บไว้ บ่อยครั้งคือ event subscription ที่ไม่เคย unsubscribe
จากบทเรื่อง memory ของ C# ก่อนหน้านี้ คุณรู้อยู่แล้วว่า object, array, และ string อาศัยอยู่บน heap (managed memory ที่ถูกติดตามและถูกปล่อยคืนในที่สุดโดย garbage collector หรือ GC — ระบบที่หาหน่วยความจำบน heap ที่ไม่มีใครอ้างอิงถึงอีกแล้ว แล้วเอากลับคืนมา) ในขณะที่ value type อย่าง int กับ float ปกติจะอาศัยอยู่บน stack และถูกเก็บกวาดอัตโนมัติเมื่อฟังก์ชันจบการทำงาน ทุกครั้งที่โค้ดของคุณสร้าง object, array, หรือ string ใหม่ด้วย new (หรือแบบ implicit เช่นการต่อ string) มันคือ allocation — หน่วยความจำที่ถูกจองมาจาก heap ที่ GC จะต้องมาจัดการในที่สุด
allocation เล็ก ๆ อันเดียวแทบไม่มีต้นทุนอะไรเลย ปัญหาอยู่ที่ อัตรา: ถ้าเกมของคุณ allocate ซ้ำ ๆ ทุกเฟรม — แม้จะเป็นปริมาณเล็กน้อย — heap จะเต็มขึ้นเรื่อย ๆ และในที่สุด garbage collector จะต้องรัน collection เพื่อหาและเก็บกวาดขยะ ในอดีต garbage collector เริ่มต้นของ Unity เป็นแบบ stop-the-world: ตอนที่มันรัน ทุก thread ของคุณจะหยุดทำงานสนิทจนกว่ามันจะเสร็จ ตั้งแต่ Unity 2019.1 เป็นต้นมา มี Incremental Garbage Collector เป็นตัวเลือก ที่กระจายงานนั้นออกเป็นชิ้นเล็ก ๆ ข้ามหลายเฟรม แทนที่จะหยุดยาวทีเดียว — วิธีนี้ลดอาการสะดุดที่เห็นได้เยอะมาก แต่มันไม่ได้ทำให้ allocation ฟรี ทุก allocation ในเกมของคุณยังคงถูกสะสมเข้ากองที่ collector ต้องไล่ตรวจในที่สุดอยู่ดี ไม่ว่าจะแบบ incremental หรือไม่ก็ตาม
นี่คือรูปร่างของ spike แบบเดียวกับในส่วนที่ 5 เป๊ะ และเป็นสิ่งที่เกิดขึ้นจริงในเรื่องเล่าส่วนที่ 1: allocation เล็ก ๆ ที่เกิดซ้ำจากการ rebuild UI text ทุกเฟรม สุดท้ายก็ข้าม threshold ภายในของ Unity แล้วกระตุ้นให้เกิด collection — การหยุดชะงักหลาย millisecond ที่ผู้เล่นรู้สึกได้ว่าเป็นอาการสะดุด ตามจังหวะที่ใกล้เคียงกับความเร็วที่ขยะสะสมขึ้นมา คอลัมน์ GC Alloc ของ Profiler คือวิธีที่คุณจับสิ่งนี้ได้ก่อนที่ผู้เล่นจะเจอ: ฟังก์ชันไหนก็ตามที่แสดงตัวเลข GC Alloc ไม่เท่ากับศูนย์ บนเฟรมที่รันทุกเฟรม (แบบ Update()) ควรค่าแก่การดูซ้ำอีกครั้ง แม้ตัวเลขจะดูเล็กก็ตาม — เพราะสิ่งที่ทำให้เกิดการหยุดชะงักในที่สุดคือ อัตรา ไม่ใช่ขนาดครั้งเดียว
นี่คือ bug ตัวเดียวกันเป๊ะจากเรื่องเล่าในส่วนที่ 1 ในรูปแบบโค้ด พร้อมวิธีแก้และต้นทุนจริงของแต่ละเวอร์ชันตามที่ Profiler รายงาน
using UnityEngine;
using UnityEngine.UI;
public class HealthBarBad : MonoBehaviour
{
public Text label;
public int currentHealth = 100;
void Update()
{
// BAD: builds a brand new string every single frame,
// even on frames where currentHealth did not change at all.
label.text = "HP: " + currentHealth + " / 100";
}
}
string ใน C# เป็น immutable (พอถูกสร้างแล้ว เนื้อหาของ string จะถูกเปลี่ยนแบบแก้ไขทับตำแหน่งเดิมไม่ได้เลย) — คุณเรียนเรื่องนี้ไปแล้วในบท C# ก่อนหน้านี้ นั่นหมายความว่า "HP: " + currentHealth + " / 100" แก้ไข string เดิมที่มีอยู่ไม่ได้ มันต้องสร้าง string object ใหม่เอี่ยมบน heap หนึ่งตัวหรือมากกว่านั้น แล้วทิ้งตัวเก่าไป ทำแบบนี้ใน Update() หมายความว่ามันเกิดขึ้น 60 ครั้งต่อวินาที ไม่ว่า currentHealth จะเปลี่ยนหรือไม่ก็ตาม
44 byte ฟังดูไม่เยอะเลย แต่ 44 byte คูณ 60 เฟรมต่อวินาที เท่ากับประมาณ 2.6 KB ต่อวินาที และมันสะสมขึ้นเรื่อย ๆ: หลังจากประมาณสองวินาที ขยะที่สะสมไว้จะข้าม threshold ภายในของ Unity garbage collector จะรัน และบนโทรศัพท์ระดับกลาง การหยุดชะงักแบบ stop-the-world นั้นอาจกินเวลา 20-30 ms — มากกว่า frame budget ทั้งหมดที่ 60 fps รวดเดียว นั่นคือ spike ตัวเดียวกันเป๊ะจากเรื่องเล่าในส่วนที่ 1
using UnityEngine;
using UnityEngine.UI;
public class HealthBarGood : MonoBehaviour
{
public Text label;
public int currentHealth = 100;
int lastShownHealth = -1; // an impossible value, forces the first update
void Update()
{
// GOOD: only touch the string when the value actually changed.
if (currentHealth == lastShownHealth) return;
lastShownHealth = currentHealth;
label.text = "HP: " + currentHealth + " / 100";
}
}
เวอร์ชันนี้ยังใช้การต่อ string แบบเดิมเป๊ะ — จุดที่แก้ไม่ใช่วิธีที่ฉลาดกว่าในการสร้าง string แต่คือการไม่สร้างมันเลยเว้นแต่ค่าจะเปลี่ยนจริง ๆ ระหว่าง boss fight ค่า health อาจเปลี่ยนแค่ไม่กี่ครั้งต่อวินาทีอย่างมากที่สุด ไม่ใช่ 60 ครั้งต่อวินาที
ผลลัพธ์ที่เห็นบนหน้าจอเหมือนเดิม แม้แต่โค้ดสร้าง string ก็เหมือนเดิม แต่ allocation ลดลงจากประมาณ 60 ครั้งต่อวินาที เหลือแค่ไม่กี่ครั้งต่อการต่อสู้หนึ่งครั้ง — ไม่พอที่จะกระตุ้นให้เกิด collection pause ระหว่างเล่นเกมเลย นี่คือรูปแบบทั่วไปของการแก้ allocation ส่วนใหญ่: คุณแทบไม่ต้องใช้เทคนิคแปลกใหม่อะไรเลย ส่วนใหญ่แค่ต้องหยุดทำสิ่งที่แพงในเฟรมที่ไม่มีอะไรเปลี่ยนแปลง
Update() โดยไม่สนใจว่าข้อมูลเปลี่ยนหรือเปล่าตัวอย่างในส่วนที่ 9 เล็กพอที่จะสังเกตด้วยตาเปล่า แต่โค้ดเกมจริง ๆ ไม่ได้เป็นแบบนั้น มีสามเทคนิคที่ช่วยตีวง spike ให้เหลือแค่บรรทัดเดียวเป๊ะ ๆ
คลิกเฟรมที่มี spike จากนั้นใน Hierarchy view ด้านล่าง sort ตามคอลัมน์ GC Alloc (มากไปน้อย) ขยายแถวบนสุด แล้วแถวถัดไป ไล่ตามตัวเลขที่ใหญ่ที่สุดลงไปใน call stack — จาก PlayerLoop ไปถึง Update.ScriptRunBehaviourUpdate ไปถึงเมธอด Update() ของ MonoBehaviour ตัวที่ตรงเป๊ะ วิธีนี้อย่างเดียวก็หาปัญหาส่วนใหญ่เจอแล้ว เพราะ Unity ติดตาม allocation ต่อฟังก์ชันให้อยู่แล้วโดย default
โดย default Profiler จะติดตามแค่ระบบและฟังก์ชันของ Unity เองที่ถูก mark ด้วย profiler sample เท่านั้น ถ้า allocation เกิดขึ้นข้างในเมธอด helper แบบ private ที่ลึกลงไปหลายชั้น — โค้ดที่ view default จะไม่แยกออกมาให้เห็น — เปิด Deep Profile (checkbox ใกล้ ๆ ปุ่ม record ของ Profiler) เพื่อติดตามการเรียกเมธอด C# ทุกตัวเลย มันช้าลงเยอะตอนรันและเพิ่ม overhead ของตัวมันเองที่สังเกตได้ชัด ดังนั้นเปิดใช้แค่สั้น ๆ เพื่อหาบรรทัดที่ต้องการ แล้วปิดกลับไปเหมือนเดิม
สำหรับโค้ดที่มีหลายส่วนแยกกันชัดเจนอยู่ใน Update() เดียว ครอบแต่ละส่วนด้วย marker ที่ตั้งชื่อไว้ เพื่อให้มันโผล่เป็นแถวของตัวเองแยกต่างหาก แทนที่จะรวมกันเป็นตัวเลขเดียวของทั้งเมธอด:
using UnityEngine;
using UnityEngine.Profiling;
void Update()
{
Profiler.BeginSample("HealthBar.RebuildText");
label.text = "HP: " + currentHealth + " / 100";
Profiler.EndSample();
Profiler.BeginSample("HealthBar.UpdateColor");
label.color = Color.Lerp(Color.red, Color.green, currentHealth / 100f);
Profiler.EndSample();
}
ตอนนี้ Profiler จะแสดง HealthBar.RebuildText กับ HealthBar.UpdateColor เป็นสองแถวแยกกัน แต่ละแถวมีตัวเลข Time ms กับ GC Alloc ของตัวเอง แทนที่จะเป็นตัวเลขรวมอันเดียวของ Update() ทั้งหมด ที่ซ่อนไว้ว่าครึ่งไหนคือปัญหาจริง
Frame Debugger ของ Unity เองแสดงให้เห็นลำดับของ draw call แต่ไม่ได้ลงรายละเอียดลึกว่า GPU เองใช้เวลาทำอะไรบ้างข้างใน draw call เดียว สำหรับเรื่องนั้น เครื่องมือ GPU debugging เฉพาะทางจะจับภาพหนึ่งเฟรมแล้วให้คุณตรวจสอบมันในระดับ hardware
เครื่องมือทั้งสองตัวมีไว้เพื่อตอบคำถามเดียวในระดับแนวคิด ที่มือใหม่ทุกคนควรรู้ตั้งแต่ก่อนจะแตะเครื่องมือด้วยซ้ำ: ต้นทุนของ GPU ถูกครอบงำโดย pixel หรือโดย vertex
Fill-rate คือ GPU ชด (shade) pixel ได้กี่ pixel ต่อวินาที เกมจะเป็น fill-rate bound เมื่อต้นทุนมาจากการรัน pixel/fragment shader บ่อยเกินไป — ปกติเกิดจาก overdraw (pixel เดียวกันบนจอถูกวาด และ shader ของมันถูกรัน หลายครั้ง เพราะมี object หลายตัวซ้อนทับกัน โดยเฉพาะที่พบบ่อยกับ transparent effect ที่ซ้อนกันแบบ particle), effect post-processing แบบเต็มจอที่แพง, หรือแค่ render ที่ resolution สูง ๆ
เกมจะเป็น vertex bound เมื่อต้นทุนมาจาก vertex shader แทน — รันหนึ่งครั้งต่อหนึ่ง vertex ไม่ใช่ต่อ pixel — ปกติเกิดจาก mesh ที่มี poly สูงมาก, skinning ที่ซับซ้อน (การเปลี่ยนรูปร่างตัวละครโดยใช้ bone) กับตัวละครหลายตัวพร้อมกัน หรือ tessellation
วิธีทดสอบแบบง่าย ๆ ไม่ต้องใช้ RenderDoc หรือ Xcode เลย: ลด render resolution ลงชั่วคราว ถ้า frame time ลดลงฮวบ แปลว่าคุณ fill-rate bound (pixel ที่ต้องชดน้อยลง) ถ้าแทบไม่ขยับ ให้ลองสลับใช้ mesh ที่ poly ต่ำกว่าหรือปิด skinning ชั่วคราวแทน — ถ้าอันนั้นทำให้ frame time ลดลงฮวบ แปลว่าคุณ vertex bound พอรู้แล้วว่าเป็นแบบไหน ค่อยคุ้มค่าที่จะเปิด RenderDoc หรือ Xcode เพื่อหา draw call หรือ shader ที่แพงตัวเป๊ะ ๆ
ทุกตัวเลขที่บทนี้แสดงมาจนถึงตอนนี้ อาจมาจาก Play Mode ของ Unity Editor เอง — และนั่นแหละคือกับดัก ตัวเลขจาก Editor แม่นยำพอสำหรับการเปรียบเทียบคร่าว ๆ ("เวอร์ชัน A เร็วกว่าเวอร์ชัน B ไหม") แต่ไม่น่าเชื่อถือสำหรับคำถามที่สำคัญจริง ๆ: "อันนี้จะรันเร็วพอบนอุปกรณ์ที่ผู้เล่นมีจริงไหม" มีเหตุผลเป็นรูปธรรมสี่ข้อ:
วิธีแก้คือ profile build จริง บนฮาร์ดแวร์จริง (หรือฮาร์ดแวร์ที่แย่ที่สุดที่คุณ support): เปิด Development Build ใน Build Settings (คง profiling hook ไว้โดยไม่ต้อง ship debug build เต็มรูปแบบ), ติ๊ก Autoconnect Profiler เพื่อให้หน้าต่าง Profiler บน PC ของคุณเชื่อมต่อกับ build ที่กำลังรันอยู่โดยอัตโนมัติ, ติดตั้งมันบนอุปกรณ์เป้าหมายจริง, แล้วเชื่อมต่อ — ควรผ่านสาย USB มากกว่า WiFi เพราะการ profile ผ่าน WiFi เพิ่ม latency ของตัวมันเองและอาจทำให้ timing ที่แม่นยำผิดเพี้ยนไป โดยเฉพาะตัวเลขฝั่ง GPU
ทุกอย่างในบทนี้รวมกันเป็น loop ที่ทำซ้ำได้หนึ่งอัน มันตั้งใจให้น่าเบื่อและเป็นกลไกตายตัว เพราะนั่นแหละคือสิ่งที่ทำให้มันเชื่อถือได้:
มีสองกฎที่ทำให้ loop นี้ไม่เสียเวลาเปล่า ข้อแรก: แก้แค่อย่างเดียวก่อนที่จะวัดใหม่ การแก้ปัญหาที่สงสัยหลาย ๆ อย่างพร้อมกันแล้วค่อยวัด หมายความว่าคุณไม่รู้จริง ๆ ว่าอันไหนที่ช่วย หรือมีอันไหนที่แอบทำให้อย่างอื่นแย่ลงโดยไม่รู้ตัว ข้อสอง: วัดซ้ำเสมอหลังจากแก้ ต่อให้คุณมั่นใจแค่ไหนก็ตาม — การแก้ต้นทุนที่ใหญ่ที่สุดมักจะเผยคอขวดตัวที่สองที่ซ่อนอยู่ข้างหลังมันพอดี และบางครั้งก็เผยว่า "การแก้" ของคุณไม่มีผลที่วัดได้เลยแม้แต่น้อย ซึ่งเป็นสิ่งที่เกิดขึ้นจริงกับการเขียน pathfinding ใหม่ในส่วนที่ 1
ลองเอา loop เต็ม ๆ มาใช้กับเรื่องเล่าในส่วนที่ 1 คราวนี้ด้วยตัวเลขจริง: การวัดแสดง spike 42 ms ประมาณทุกสองวินาที เทียบกับ budget 16.6 ms คอลัมน์ GC Alloc ใน Hierarchy view ชี้ตรงไปที่ HealthBarBad.Update() เลย วิธีแก้คือการ early-return บรรทัดเดียวจากส่วนที่ 9 พอวัดใหม่บนโทรศัพท์เครื่องเดิม spike หายไปหมดเลย worst frame time ลดลงเหลือประมาณ 15 ms เวลารวมที่ใช้หาและแก้ด้วย Profiler: ประมาณสิบนาที เทียบกับสองวันที่เสียไปกับการเดา
(a) fps = 1000 / frame time(ms) = 1000 / 11.6 -> about 86 fps
(uncapped -- with v-sync locked to 60 fps the game would just
show a steady 60, with room to spare)
(b) CPU-bound. The CPU's own cost (11.4 ms) is larger than the
GPU's cost (6.2 ms), and the measured total (11.6 ms) sits
close to the CPU number, matching the pipelining rule from
Section 3: total frame time tracks the larger of the two.
(c) Yes, headroom: 11.6 ms measured against a 16.6 ms budget
leaves about 5 ms of room. If more headroom were needed,
optimize the CPU side first -- it is the bottleneck (11.4 ms
vs 6.2 ms), so cutting GPU/shader cost would barely move the
total, the same mistake as Section 1's pathfinding rewrite.
(a) fps = 1000 / frame time(ms) = 1000 / 11.6 ประมาณ 86 fps (แบบไม่ล็อค — ถ้าล็อค v-sync ไว้ที่ 60 fps เกมก็จะแสดงที่ 60 คงที่ แบบมีที่เหลือสบาย ๆ)
(b) CPU-bound ต้นทุนของ CPU เอง (11.4 ms) ใหญ่กว่าต้นทุนของ GPU (6.2 ms) และตัวเลขรวมที่วัดได้ (11.6 ms) ใกล้เคียงกับตัวเลขของ CPU มาก ตรงกับกฎ pipelining จากส่วนที่ 3: frame time รวมจะตามค่าที่ใหญ่กว่าในสองค่านั้น
(c) ใช่ มี headroom: 11.6 ms ที่วัดได้เทียบกับ budget 16.6 ms เหลือที่ประมาณ 5 ms ถ้าต้องการ headroom เพิ่ม ควร optimize ฝั่ง CPU ก่อน — มันคือคอขวด (11.4 ms เทียบกับ 6.2 ms) การลดต้นทุน GPU/shader แทบจะไม่ขยับตัวเลขรวมเลย เป็นข้อผิดพลาดเดียวกับการเขียน pathfinding ใหม่ในส่วนที่ 1
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public class AlertNearbyEnemies : MonoBehaviour
{
void Update()
{
List<Enemy> nearby = FindObjectsOfType<Enemy>()
.Where(e => Vector3.Distance(e.transform.position, transform.position) < 10f)
.ToList();
foreach (var e in nearby)
{
e.Alert();
}
}
}
มี allocation แยกกันสามจุดเกิดขึ้นตรงนี้ ทุกเฟรมเลย: FindObjectsOfType<Enemy>() จอง array ใหม่เอี่ยมด้วยการสแกนทั้งฉากทุกครั้งที่เรียก; .Where(...) จอง closure (เพื่อ capture transform.position) กับ iterator object; .ToList() จอง List<Enemy> ใหม่เพื่อเก็บผลลัพธ์ ทั้งสามอย่างเกิดขึ้นไม่ว่าจะมีศัตรูอยู่ใกล้ ๆ จริงหรือไม่ก็ตาม
using System.Collections.Generic;
using UnityEngine;
public class AlertNearbyEnemies : MonoBehaviour
{
List<Enemy> allEnemies; // filled once, not every Update
void Start()
{
allEnemies = new List<Enemy>(FindObjectsOfType<Enemy>());
}
void Update()
{
for (int i = 0; i < allEnemies.Count; i++)
{
Enemy e = allEnemies[i];
if (Vector3.Distance(e.transform.position, transform.position) < 10f)
{
e.Alert();
}
}
}
}
วิธีแก้ย้ายการสแกนฉากที่แพง (FindObjectsOfType) ไปไว้ใน Start() เพื่อให้มันรันแค่ครั้งเดียว ไม่ใช่หกสิบครั้งต่อวินาที มันแทนที่ LINQ chain ด้วย for loop ธรรมดาที่วนบน list ที่มีอยู่แล้ว ดังนั้นจึงไม่มี closure ไม่มี iterator และไม่มี list ใหม่ถูกสร้างขึ้นทุกเฟรม ตัว distance check เองยังรันทุกเฟรมอยู่ แต่การเปรียบเทียบตัวเลขไม่ allocate อะไรเลย การปรับปรุงเพิ่มเติมในโลกจริง สำหรับจำนวนศัตรูที่เยอะมาก ๆ คือ Physics.OverlapSphereNonAlloc ซึ่งเป็น Unity API ที่สร้างมาเฉพาะเพื่อใช้ buffer array เดิมซ้ำทุกครั้งที่เรียก แทนที่จะ allocate ตัวใหม่
การทดลองที่ 1 แสดงการลดลงที่ใหญ่มาก (30 ms เป็น 14 ms) จากการลด resolution อย่างเดียว ซึ่งเป็นการทดสอบวินิจฉัยจากส่วนที่ 11 สำหรับการเป็น fill-rate bound — ต้นทุน GPU ถูกครอบงำโดยการชด pixel ไม่ใช่งาน CPU หรือจำนวน vertex เพราะ pixel ที่น้อยลงทำให้ผลต่างมหาศาลขนาดนี้
การทดลองที่ 2 แสดงว่าแทบไม่ลดลงเลย (30 ms เป็น 28 ms) จากการเอา particle ออกครึ่งหนึ่ง ซึ่งตัดความเป็นไปได้ที่ว่า particle overdraw คือต้นทุน fill-rate หลักออกไป — particle ไม่ใช่ pixel ที่แพง มีอย่างอื่นที่แพงอยู่ ในเมื่อยืนยันแล้วว่าเกมนี้ fill-rate bound แต่ไม่ได้เป็นเพราะ particle ขั้นตอนถัดไปคือเปิด Frame Debugger เพื่อหาตัวการที่หนัก pixel ตัวอื่น — effect post-processing แบบเต็มจอตัวใหญ่, UI panel ที่แพงคลุมเกือบทั้งจอ, หรือ shadow resolution ที่สูง — แล้วพอ draw call ตัวใดตัวหนึ่งดูน่าสงสัย ก็ใช้ RenderDoc หรือ Xcode's GPU Frame Capture เพื่อตรวจสอบต้นทุน shader ของ draw call ตัวนั้นเป๊ะ ๆ โดยตรง