ความมันส์ของ C# Jobs กับ NativeArray<T>

เมื่อ Unity เอาตัวแปร unmanaged มาให้เล่นในโลก managed ของ C# ความมันส์จึงบังเกิดขึ้น ตัวแปรตัวนี้มาพร้อมกับ C# Jobs + ECS ใน namespace…

ความมันส์ของ C# Jobs กับ NativeArray<T>

เมื่อ Unity เอาตัวแปร unmanaged มาให้เล่นในโลก managed ของ C# ความมันส์จึงบังเกิดขึ้น ตัวแปรตัวนี้มาพร้อมกับ C# Jobs + ECS ใน namespace Unity.Collections ใหม่ เป็นตัวแปรสำคัญที่จะเชื่อมต่อเมโมรี่ข้าม thread กับระบบ jobs

ไหนๆก็ไหนๆแล้วแนะนำระบบ C# Jobs คร่าวๆให้รู้จักด้วยเลย เป็นตัวเต็มแล้วใน Unity 2018.1 ที่เพิ่งออกมา

ตัวอย่างเช่นคลาสนี้

Travel<T> ของผม ความสามารถคือ Add ตำแหน่งกับเวลารัวๆ แล้วทีนี้สามารถถามมันได้ว่า “ณ เวลานี้ฉันอยู่ไหน” หรือผูกข้อมูลพิเศษ (T) ไว้ที่แต่ละตำแหน่งก็ได้ แล้วก็สั่งให้ไปหาข้อมูลพิเศษล่าสุดจากตำแหน่งที่ต้องการตรงไหนก็ได้ โดยถ้าอยู่ช่วงไหนจะวิ่งถอยหลังไปหาข้อมูลล่าสุดให้เลย ถ้าคิดว่าใช้เป็นเอาไปใช้ได้ฟรี

https://github.com/5argon/E7Unity/tree/master/Travel

5argon/E7Unity
E7Unity - Common Unity resources Exceed7 Experiments uses in projects.github.com

เนื่องจากโค้ดในเกมใช้คำสั่งหาข้อมูลที่ว่านี้รัวๆทุกเฟรมตลอดเวลาก็เลยคิดว่าถ้า jobify คำสั่งบางคำสั่งน่าจะดี รุมกันหาทีละหลายๆที่จะได้หาไปพร้อมกันได้

ทำไมต้องใช้ NativeArray

เพื่อ jobify คลาสนี้ก็เลยมีการ allocate NativeArray ไว้ใน constructor เก็บไว้เป็นตัวแปรคลาสไว้ reuse ใช้ในกิจกรรมต่างๆของคลาสนี้

ใช้คุยกับ C# Jobs

ระบบ C# Jobs ของ Unity เป็นการประกาศ struct ขึ้นมาแล้วเอาของให้มันให้ครบก่อนสั่ง .Execute() เพื่อเริ่มงาน เช่นอันนี้ ตัวอย่างอัลกอริทึมหาของอย่างรวดเร็วของผม ใช้ท่าเกือบๆ binary search แบบมีจำจุดที่เจอไว้ได้ด้วยแล้วครั้งต่อไปเริ่มหาจากจุดเดิม เพิ่มโอกาสเจอของอย่างรวดเร็วเป็น cache เล็กๆ

math. ต่างๆจาก Unity.Mathematics ใหม่ที่แทนเครื่องหมายธรรมดาๆ <, >, != เพิ่งไปถามมาในบอร์ดว่าที่จริงไม่ต้องใช้ก็ได้ ผลออกมาเหมือนกัน แต่อย่าง math.select ใช้แทน if ได้ควรใช้ ผล performance Burst Compiler ต่างกัน

ข้างบนจะเห็นว่ามีข้อมูลที่จะใช้ประกาศกร้าวไว้อยู่ ซึ่งเราก็ต้องมาเติมตอนสร้าง job แบบนี้

EventList เป็นข้อมูลทั้งหมดที่จะเอาไปวิ่งหา ตั้งเป็น [ReadOnly] ไว้เพื่อเพิ่มพลัง optimize ส่วน rememberAndOutput ทำไว้เป็นที่รับผลคำนวณจาก Jobs ไม่รู้มีวิธีดีกว่านี้มั้ย แต่สมมติจะเอาคำตอบเดียวก็คงประกาศ NativeArray(1) มารับ… ดูแปลกๆ ทั้งสองตัวคือ NativeArray ที่ทำไว้ที่ constructor สังเกตุว่าบอก Allocator ได้ด้วยว่าตั้งใจจะใช้ยาวๆ หรือแปปเดียวเป็น temp ทำให้โค้ดที่ gen ออกมาทำงานได้ดี (ถ้าเลือกใช้ Temp แล้วไม่ใช่ตัวแปรเฉพาะ scope มันอาจจะ dealloc ทิ้งให้เรา ไม่แน่ใจ)

https://forum.unity.com/threads/recommended-way-of-getting-a-single-result-out-of-a-job.531406/

EventList นี่อย่าลืมว่า NativeArray มันขยายไม่ได้ไม่ใช่ List แต่คลาส Travel มี definition ว่าสามารถ “Add” ได้ แบบนี้เราจะทำไงดี ที่ผมทำคือมี List อีกตัว แล้ว Add เมื่อไหร่ก็ .Dispose NativeArray ทิ้งแล้วสร้างใหม่ให้ใหญ่ขึ้นกับมีข้อมูลใหม่ ทำให้ Add costly ขึ้นมาก แต่ NativeArray ตอนใช้งานจะเร็วมาก รวมทั้ง compiler ของ Unity สามารถ optimize ต่อได้อีก ทำ iteration ได้รวดเร็ว และแยก thread ได้อย่างฉลาดรุมยำได้โดยไม่เกิด race condition เป็นต้น พอดีตอน Add ผมอยู่หน้า Now Loading ก็เลยโอเค ยอมเสียเวลานิดหน่อย

เมธอดนี้จริงๆดูใช้ Jobs ไม่คุ้มเลยเพราะเพิ่งสั่ง schedule บรรทัดต่อไปก็สั่ง .Complete ดักรอผลเป็น blocking call ซะแล้ว แล้วแบบนี้จะย้าย thread ไปทำแตงอะไร.. แต่เขาว่าถ้า Burst Compiler ปรับแต่งโค้ด Jobs เราได้ก็มีสิทธิ์เร็วขึ้นนะ (ต้องใช้ [ComputeJobOptimization] แปะไว้ตรงหัว IJob ) อีกอย่างเดี๋ยวจะทำเวอร์ชั่นที่คืน JobHandle ไว้ให้คนข้างนอกเอาไปรอกันหรือไป dependency กันตามสบาย

ใช้หลบ GC ก็ได้

อย่างที่รู้ว่า C# ถ้าข้อมูลหลุด scope แล้วไม่มีใครใช้อีกแล้วเมื่อไหร่ซักพัก GC จะมาเก็บ อะไรที่เป็น NativeArray มันจะไม่เก็บ ต้อง .Dispose เอง ซึ่งจะเห็นว่าผมเก็บไว้ reuse ทุกครั้งที่ใช้ jobs นี้เลยไม่ต้อง .Dispose

แล้วก็มีฟังก์ชั่นที่ว่าถ้าเราเผลอปล่อยตัวนี้หลุดลอยไป Unity จะขึ้น error มาเตือนเราได้ด้วย ไอ้ตรงนี้แหละที่อยากพูด

NativeArray เป็นข้อมูลตำแหน่ง physical เดียวกัน

สมมติทำเมธอดที่คืนค่าเป็น JobHandler เสร็จแล้ว ทีนี้ข้างนอกมีคนสองคนมารุมสั่งงาน Travel<T> ตัวนี้ให้หาข้อมูลจากคนละตำแหน่งกันพร้อมกัน แบบนี้จะตะชึ่มทันทีเพราะว่า “คำตอบ” ของทั้ง 2 jobs เขียนลงบนพื้นที่ NativeArray เดียวกัน ต้องตั้งสติเองด้วยว่าเอางานออกไปรอได้ แต่ห้ามมีคนอื่นมาสั่งอีกระหว่างงานนั้นยังไม่เสร็จ

ลืม Dispose ก็ตะชึ่ม

ในคลาสนี้กะจะใช้ reuse ไปเรื่อยๆก็เลยไม่ dispose อันนี้โอเค แต่สมมติว่า “ข้างนอก” คือตัวแปร Travel ที่มี NativeArray ทั้งหลายอยู่นั่นแหละ ถ้ามันหลุด scope ไปจะเกิดอะไรขึ้น เช่น new ขึ้นมาในเมธอดเล็กๆ ใช้จนพอใจแล้วก็ทิ้ง

ไอ้ทิ้งนี่แหละตัวพาซวย หลังจาก 3–4 เฟรมให้หลัง GC ก็จะโผล่มาเก็บ Travel นั่นไป NativeArray ข้างในก็จะ dangling แล้ว Unity ก็จะด่าเราว่า memory leak

สมมติให้ Travel เป็น class field ไว้ก็ไม่ต้องกลัวใช่มั้ย ก็ไม่ เพราะว่าก็จะมี “ตัวข้างนอก” ตัวต่อไปอีกที่ถ้าหลุดจน GC เก็บจะลามมาถึงข้างใน ไม่จบสิ้น ก็ต้องมีจุดระวังอยู่ดี

วิธีแก้คือ IDisposable

จะเห็นว่า Travel : IDisposable ซึ่งก็ทำอะไรไม่ได้นอกจากบังคับให้เราทำ Dispose() ขึ้นมา ยังไงก็ต้องเรียกเองอยู่ดี

ทีนี้เสก Travel เมื่อไหร่ก็อย่าลืม .Dispose ทุกอย่างก็จะเรียบร้อยดี?

ถ้า Travel ไปอยู่กับ MonoBehaviour

ถามว่า MonoBehaviour จะหายไปตอนไหน เราจะทำลาย game object เมื่อไหร่จะมาคอย Dispose ได้ไง โชคดีที่ MonoBehaviour มี ​ OnDestroy ให้ใช้ เกิดอะไรก็ช่างให้มีอันเป็นไปรับรองเรียก อันนี้ง่ายเลย

ถ้า Travel ไปอยู่กับคลาสธรรมดา

อันนี้ยิ่งตะชึ่ม เพราะเชื้อ IDisposable จะค่อยๆแพร่กระจายจนเต็มเกม 555 ตัวอย่างเช่นผมมีคลาสนี้ที่มี Travel ใช้เต็มเลย

ก็เหมือนเดิม เติม IDisposable ให้ไม่ลืม ว่าอย่าลืม .Dispose มาทำลายไอ้ข้างในทั้งหมดด้วย

แล้วไงต่อ ความชิบหายอยู่ที่ เกมผม Chart มีไอ้ CommandTravels นี้คนละอัน แล้ว Simfile มีหลาย Chart แล้ว โน่นนี่นั่นก็เอา Simfile นี่ไปใช้ สรุปคือ ต้องทำ IDisposable ทั้งเกมรึเปล่า 555 เมื่อถลำลึกไปเรื่อยๆอาจจะพบว่าเกมเรากลายเป็นภาษา C++…

ปัญหาอยู่ที่ว่าเกมผมดันใช้ไอ้คลาส Chart กับ Simfile แบบสบายๆสไตล์มยุรา ตามแบบฉบับ C# มาหลายจุดมากในเกม เช่นตรงนี้

สร้าง SimfileManager ขึ้นมา เพื่อให้ข้างในมี Simfile ยัดไส้ทีนี้จะได้สั่งให้มันเซฟได้ จบ แต่ถ้ามีพยาธิร้ายอยู่ข้างในล่ะ ก็คือใน SimfileManager มี Simfile ใน Simfile มี Chart ใน Chart มี… จนถึง Travel

ผลก็คือตัวแปร ​ sm ที่เห็นออกนอก scope try นั่นเมื่อไหร่ GC ก็จะดุ่ยโผล่มาเก็บ แล้วก็ตะชึ่ม ก็เลยกลายเป็นว่าทุกตรงที่ทำแบบนี้ ต้อง sm.Dispose() ก่อนที่จะหลุดออกนอก scope แล้วไม่มีอะไรยื้อมันอีก (เพราะประกาศใน scope จะมีอะไรอีก)

แล้วก็อย่าลืมว่าคลาสข้างในทุกตัวทั้งยวง ถ้าดันทำอะไรลวกๆ (แล้วหวังว่า GC จะตามมาเช็ดให้ 555) ทำนองนี้อีกแล้วหลุด scope ก็ตะชึ่ม ทั้งหมด 555 เป็นไงล่ะ เมื่อ C# มาเจอกับอารมณ์ C++ มันส์เลย

using to the rescue! รึเปล่า

C# มีท่านึงที่ทำมาไว้ใช้กับ IDisposable โดยเฉพาะคือ using มันบอกว่าสร้างเขตอาคมได้ แล้วหลุดเขตเสร็จเรียบร้อยเมื่อไหร่มันจะ .Dispose ให้เรา

ก็เลยแก้โค้ดเป็นแบบนี้ได้

ความวิเศษคือใช้ครอบ return ก็เวิคนะ ถ้ามา .Dispose เองก็จะลำบาก สมมติจะทำอะไรซักอย่างก่อน return

แต่ไปไล่ครอบ using ทุกทีก็ตะชึ่มเหมือนกัน บางตรงยิ่งครอบยากเพราะอาจจะไม่ได้จบแค่นั้น แต่เดี๋ยวซักพักโดดไปโดดมาถึงจบ ก็ไม่พ้นต้องมา .Dispose เอง

ใช้ finalizer ล่ะ

ต่อมาผมก็คิดได้ว่า ทำไมเราไม่ปล่อยให้ GC มาเก็บเหมือนเดิม แล้วไปเติม deallocation ที่ finalizer แทน แบบนี้เราสามารถเอา IDisposable ออกได้หมดเลย ยกเว้นที่ต้นตอแห่งความชั่วร้าย

finalizer คืออะไร คือเมธอดที่เรียกเสมอเมื่อ object จะถูกทำลายไงล่ะ ใครไม่เคยใช้ มันจะหน้าตาแบบนี้

Finalizers (C# Programming Guide)
The programmer has no control over when the finalizer is called because this is determined by the garbage collector…docs.microsoft.com

เราไม่สามารถเติม public, private หรืออะไรทั้งสิ้นไว้ข้างหน้าได้ ต้องพิมพ์แบบนั้นถึงจะเป็น finalizer ซึ่งดูกี่ทีก็รู้สึกแปลกๆ เหมือนมีขน… ติดอยู่ข้างหน้าเมธอด แต่ยังไงก็ช่าง แผนนี้ก็ตะชึ่มเหมือนกัน เพราะผมลองแล้วไอ้เมธอดนี้มันก็เรียกจริงๆนะ แต่ Unity ก็ด่าเราอยู่ดี คือ มันชิงด่าก่อนจะเรียก finalizer 55555 กลายเป็น Dispose ได้ แต่ต้องทนโดนด่า 555 ซึ่งในเกมจริงมันน่าจะเป็น crash ก็เลยทำไม่ได้

แล้วก็จากการคุยกันในบอร์ด finalizer ไม่ deterministic แล้วก็หนำซ้ำยังมีโอกาสโดยเรียกจาก thread อื่นที่ไม่ใช่ main thread ด้วย คิดว่านี่แหละเหตุผลที่ Unity ด่าก่อนเข้า finalizer

https://forum.unity.com/threads/why-disposing-nativearray-in-a-finalizer-is-unacceptable.531494/

สรุปทำไงดีถึงจะไม่ตะชึ่ม

  • ถ้าอยู่กับ MonoBehaviour ได้จะดีมากเพราะ OnDestroy ค่อนข้างกันลืมให้เราได้
  • ถ้าไม่อยู่ ทำเป็น static แทน field ของ class ได้จะดีขึ้นเพราะไม่มีวันหลุด scope และไม่มีวันโดน GC เสร็จแล้วจะเลิกใช้เมื่อไหร่ (เช่น เปลี่ยน scene) ก็จำๆว่ามี static ตรงไหนบ้างแล้วไล่สั่ง .Dispose ทีเดียว (ถึงไม่ทำ ก็เสีย mem แบบนั้นไปตลอด แต่ไม่ได้ leak เพิ่มขึ้นเรื่อยๆ) ของผมจริงๆพอมาถึงตรงนี้กลายเป็น optimization ซะงั้น ได้คิดว่า Chart ไม่ต้องมี Travel ประจำตัวก็ได้ แบ่งๆสลับกันใช้ก็ได้นี่หว่า แค่เขียน logic สลับเพิ่มนิดนึงก็เวิคแล้วไม่มีทางโดนด่า

โอย กว่าจะ jobify ได้ทำไมมันลำบากแบบนี้ ปล. ถ้าใช้ ECS จนครบระบบแล้วสบายกว่านี้เพราะข้อมูลอยู่ใน IComponentData หมดแล้วเชื่อมถึงกันได้สบายไม่ต้องมา NativeArray