رفتن به محتوای اصلی
یوسف فرحمند

مدیریت ۱ میلیون رکورد لیدربورد در Unity با Job System و Burst

چگونه یک لیدربورد Unity، ۱ میلیون رکورد CSV را بدون بلاک‌شدن Main Thread بارگذاری، مرتب و جستجو می‌کند؛ همراه با تصمیم‌های طراحی و نتایج Profiler.

  • Unity
  • C#
  • Job System
  • Burst
  • Performance
  • UI Virtualization
مدیریت ۱ میلیون رکورد لیدربورد در Unity با Job System و Burst

یک سیستم لیدربورد که ۱,۰۰۰,۰۰۰ رکورد را از فایل CSV می‌خواند، مرتب می‌کند و قابل جستجو نگه می‌دارد، بدون این‌که Main Thread را حتی یک فریم بلاک کند. هدف اصلی این پروژه دقیقاً همین است: نشان بدهم چطور با Unity Job System و Burst می‌شود حجم بالای داده را بدون افت فریم‌ریت و بدون فشار غیرضروری روی حافظه مدیریت کرد.

Unity Profiler و Game View لیدربورد حین جستجو

ساختار پروژه

اسکریپت‌ها در این پوشه‌ها سازمان‌دهی شده‌اند:

  • Application: فایل Manager.cs، هماهنگ‌کنندهٔ کل جریان (Load → Sort → Search → UI)
  • Core: LoadService، SortService، SearchService
  • Data: FileReader، LeaderboardEntry، FindLineOffsetsJob، ParseLineJob
  • Search: IdSearchJob، UsernameSearchJob
  • Sorting: SortJob
  • UI: LeaderboardUIManager، ItemScrollView، ItemContainer، ItemSlot، SearchInput
  • Test: ابزارهای دیباگ و اندازه‌گیری Performance، از جمله LeaderboardProfilerReport

منطق Data/Processing (Core، Data، Search، Sorting) را کاملاً از UI جدا نگه داشتم. این دو لایه فقط از طریق Manager به هم وصل می‌شوند و هرکدام را می‌شود مستقل تست کرد.

۱. بارگذاری و Parse داده‌ها

  1. FileReader.ReadAsync با File.ReadAllBytesAsync فایل را به‌صورت async می‌خواند و سپس بایت‌ها را در یک NativeArray<byte> کپی می‌کند.
  2. FindLineOffsetsJob (IJob، Burst) یک‌بار کل بافر بایت را اسکن می‌کند و ابتدا و طول هر خط را پیدا می‌کند (پشتیبانی از \n و \r\n).
  3. ParseLineJob (IJobParallelFor، Burst، batch size ۶۴) هر خط را موازی Parse می‌کند؛ مستقیم روی بایت‌ها و بدون string.Split، تا GC Allocation و سربار تبدیل رشته صفر بماند. نتیجه در یک NativeArray<LeaderboardEntry> با Allocator.Persistent ذخیره می‌شود.

چون Parse نسبتاً سنگین است، هر دو Job را با همان الگویی که برای Sort و Search استفاده کردم (پایین‌تر توضیح می‌دهم) منتظر می‌مانم: یک متد کمکی به اسم WaitForJobAsync به‌جای صدا زدن مستقیم Complete()، در طول چند فریم و با Awaitable.NextFrameAsync مقدار JobHandle.IsCompleted را چک می‌کند. به این ترتیب Main Thread هیچ‌وقت منتظر تمام‌شدن Job نمی‌ماند.

چون این انتظار ممکن است بیشتر از یک فریم طول بکشد، بافرهای میانی (lineStartOffsets و lineLengths) را با Allocator.Persistent می‌سازم، نه Allocator.TempJob (که فقط برای چند فریم معتبر است). WaitForJobAsync هم تکمیل Job را داخل یک finally انجام می‌دهد تا حتی اگر انتظار وسط راه قطع شود (مثلاً با خروج از Play Mode)، Dispose بعدی بدون خطا انجام شود.

۲. مرتب‌سازی

SortJob (IJob، Burst) از متد داخلی NativeArray<T>.Sort(IComparer<T>) استفاده می‌کند (Introsort در Unity.Collections) و روی Score نزولی مرتب می‌کند. اجرا روی یک Worker Thread است و تکمیلش با poll کردن IsCompleted در SortService.Update() چک می‌شود.

۳. جستجو و فیلتر

  • ورودی کاربر با Debounce (SearchInput، ۰.۱۵ ثانیه) کنترل می‌شود تا هر keystroke یک Job جدید نسازد.
  • اگر Query عددی باشد، IdSearchJob اجرا می‌شود (Prefix match روی ID). در غیر این‌صورت UsernameSearchJob (Prefix match به‌صورت Case-insensitive روی FixedString64Bytes).
  • هر دو Job از نوع IJobParallelFor هستند و کل ۱ میلیون رکورد را موازی اسکن می‌کنند. نتایج با NativeList<int>.ParallelWriter و بدون Resize جمع می‌شوند، چون Capacity از قبل به اندازهٔ کل رکوردها رزرو شده است.
  • SearchService هم مثل SortService با poll کردن IsCompleted کار می‌کند. اگر کاربر در حین اجرای یک جستجو Query جدید بفرستد، فقط آخرین Query نگه داشته می‌شود و بعد از اتمام جستجوی جاری اجرا می‌شود.

۴. نمایش و اسکرول (UI)

  • Object Pooling: ItemContainer فقط به‌اندازهٔ آیتم‌های قابل‌مشاهده در Viewport (به‌علاوهٔ Buffer) آبجکت می‌سازد، نه به‌اندازهٔ کل رکوردها.
  • Virtualization: ItemScrollView بر اساس موقعیت اسکرول، ایندکس اولین آیتم قابل‌نمایش را محاسبه می‌کند و فقط پول موجود را Reposition/Repopulate می‌کند.
  • نتایج فیلترشده هم از همین مسیر (SetResults) رد می‌شوند، پس رفتار برای «همهٔ رکوردها» و «نتایج جستجو» یکسان است.

سؤال‌های رایج طراحی

چرا Awaitable به‌جای Task یا UniTask؟

Awaitable بومی موتور Unity است (۲۰۲۳.۱ به بعد)، مستقیم با PlayerLoop یکپارچه است و بدون نیاز به پکیج خارجی کار می‌کند. برخلاف Task، به Thread Pool و SynchronizationContext معمول .NET وابسته نیست؛ Allocation کمتری تولید می‌کند و برای سناریوهای per-frame و per-operation در Unity سبک‌تر است. Cancellation خودکار هنگام از بین رفتن آبجکت هم built-in پشتیبانی می‌شود. تنها محدودیتش این است که فقط روی Unity 2023.1+ در دسترس است و اکوسیستمش هنوز به بلوغ UniTask نرسیده.

چرا Parse با IJobParallelFor و batch size ۶۴؟

Parse هر خط مستقل از خط‌های دیگر است (Embarrassingly Parallel)، پس موازی‌سازی انتخاب طبیعی است. batch size ۶۴ تعادلی بین سربار زمان‌بندی هر Batch و بهره‌وری از Worker Threadها برقرار می‌کند:

Batch Sizeسربار زمان‌بندیتوازن بار بین Threadهانتیجه
۳۲ یا کمتربالا: تعداد Batchهای بیشتر یعنی سربار Dispatch بیشترخوبرد شد: سربار زمان‌بندی سود موازی‌سازی را می‌خورد
۶۴پایینخوبانتخاب شد
۱۲۸ یا بیشترخیلی پایینضعیف: تعداد Batch کمتر از Coreهای موجود می‌شود و بعضی Threadها بیکار می‌مانندرد شد: Threadها به‌شکل نامتوازن مشغول می‌شوند

چرا خطوط با یک Job تک‌رشته‌ای (IJob) پیدا می‌شوند، نه Parallel؟

پیدا کردن مرز خطوط یک اسکن ترتیبی ساده روی بایت‌هاست که حتی به‌صورت تک‌رشته و Burst-compiled برای چند ده مگابایت داده در حد چند میلی‌ثانیه طول می‌کشد. موازی‌سازی‌اش نیاز به merge کردن نتایج بین Chunkها دارد؛ پیچیدگی اضافه‌ای که در این مقیاس سود محسوسی ندارد.

چرا مرتب‌سازی با NativeArray<T>.Sort (Introsort) و نه یک الگوریتم دست‌ساز؟

Introsort توکار Unity.Collections برای ۱ میلیون آیتم عملکرد O(n log n) قابل‌قبولی دارد و روی یک Worker Thread اجرا می‌شود، بدون این‌که Main Thread را بلاک کند. یک Radix Sort روی Score (چون عدد صحیح است، بالقوه O(n)) یا یک Merge Sort موازی سریع‌تر می‌بود، ولی چون Sort فقط یک‌بار در Load انجام می‌شود (نه در هر جستجو)، پیچیدگی اضافه‌اش در برابر سودش رد شد. Sort روی ۱ میلیون رکورد در پروفایل واقعی (جدول پایین‌تر) ۱۱۴.۵۱ میلی‌ثانیه روی ۳ فریم پخش شده، بدون عبور از ۵۹.۸۹ میلی‌ثانیه در بدترین فریم؛ پس Introsort توکار برای این حجم داده کافی است.

چرا جستجو Linear Scan موازی است، نه Hash Map یا Trie؟

برای Username به Prefix Match نیاز داریم. یک Hash Map معمولی فقط Exact Match را O(1) می‌کند و برای Prefix باید Trie ساخت که حافظه و پیچیدگی بیشتری دارد. چون IDها به ترتیب ورود Parse می‌شوند (نه sorted بر اساس ID)، برای Binary Search هم باید یک ایندکس اضافه نگه‌داری شود. ساخت و نگه‌داری یک ایندکس اضافه (حافظهٔ بیشتر، پیچیدگی Invalidation) در برابر سودش رد شد: با موازی‌سازی روی همهٔ Coreهای CPU، یک Scan خطی روی ۱ میلیون رکورد در پروفایل واقعی (۴۰۱ Query نمونه، جدول پایین‌تر) به‌طور میانگین ۱۲.۶۴ میلی‌ثانیه برای ID و ۱۵.۵۵ میلی‌ثانیه برای Username طول کشیده، مستقل از این‌که Query صفر Match داشته یا ۱۱۱,۱۱۲ تا. برای این مقیاس کافی است.

چرا Debounce روی ورودی جستجو، و چرا ۰.۱۵ ثانیه؟

بدون Debounce هر keystroke یک Job موازی روی ۱ میلیون رکورد می‌سازد؛ هم اتلاف منابع است، هم Race بین نتایج جستجوهای پیاپی. ۰.۱۵ ثانیه به اندازهٔ کافی کوتاه است که UI بی‌واسطه حس شود، ولی جلوی Job‌سازی برای هر حرف تایپ‌شده را می‌گیرد.

چرا Sort و Search در Update() poll می‌شوند، نه Complete() مستقیم؟

JobHandle.Complete() مستقیم بعد از Schedule() معادل بلاک کردن Main Thread تا پایان Job است. با چک کردن IsCompleted در هر فریم داخل Update()، Main Thread هیچ‌وقت منتظر نمی‌ماند و فریم‌ریت پایین نمی‌آید؛ نتیجه فقط وقتی مصرف می‌شود که Job واقعاً تمام شده باشد.

چرا لیست ۱ میلیونی UI را Freeze نمی‌کند؟

با ترکیب Object Pooling (فقط آیتم‌های قابل‌دید ساخته می‌شوند) و Virtualization (موقعیت هر آیتم پول بر اساس Scroll Offset دوباره محاسبه می‌شود، نه این‌که کل لیست دوباره رندر شود). هزینهٔ رندر مستقل از تعداد کل رکوردهاست و فقط به تعداد آیتم‌های داخل Viewport وابسته است.

محدودیت‌ها و Trade-offها

  • FixedString64Bytes برای Username ظرفیت محدودی دارد (حدود ۶۱ بایت UTF8)؛ یوزرنیم‌های طولانی‌تر Truncate یا Error می‌شوند.
  • خواندن فایل فعلاً دو کپی از داده در حافظه ایجاد می‌کند (byte[] مدیریت‌شده و NativeArray<byte>)؛ برای فایل‌های خیلی بزرگ‌تر می‌شود با خواندن مستقیم در بافر Native این هزینه را حذف کرد.
  • جستجو با هر Query یک Full Scan جدید روی کل داده انجام می‌دهد (بدون Index)؛ برای مقیاس‌های بسیار بزرگ‌تر از ۱ میلیون، ساخت ایندکس جانبی ممکن است لازم شود.
  • ارتفاع خیلی زیاد Content در ScrollRect (متناسب با ۱ میلیون آیتم) از نظر دقت float برای رکوردهای انتهای لیست تست دقیق نشده؛ ارزش دارد با اسکرول سریع تا انتها بررسی شود.
  • کلاس‌های داخل Test/ ابزار دیباگ و اندازه‌گیری دستی هستند و بخشی از جریان اصلی محصول نیستند.

Profiler و نتایج عملکرد

اسکریپت LeaderboardProfilerReport (داخل Test/) مراحل Read → FindLines → Parse → Sort → Search را یک‌بار در Editor اجرا کرد و زمان هر مرحله (Wall time)، تعداد فریم‌های طی‌شده و بدترین فریم‌تایم را اندازه گرفت. یک جدول Markdown کامل هم در Console چاپ کرد و هم در فایل leaderboard_profiler_report.md ذخیره کرد؛ همان فایلی که پیوست مخزن است و جزئیات هر ۴۰۱ Query نمونه (ID و Username، هرکدام شامل داده‌ی واقعی، نامعتبر و پارشال) را جداگانه دارد. جدول پایین خلاصهٔ همان فایل است.

اسکرول با این ابزار اندازه‌گیری نشد، چون به شبیه‌سازی واقعی لمس یا درگ نیاز دارد. عدد اسکرول از تصویر و ویدیوی زیر (Unity Profiler، PlayerLoop، داخل Editor، حین اسکرول واقعی لیست) گرفته شده است.

Unity Profiler و Game View لیدربورد حین جستجو

ویدیوی Profiler + Search زنده فریم‌تایم Profiler را همزمان با تایپ در فیلد جستجو نشان می‌دهد. هیچ Spike محسوسی روی CPU Usage در لحظهٔ Search دیده نمی‌شود، چون Job موازی روی Worker Threadهاست، نه Main Thread.

ویدیوی Profiler + Search زنده
StageWall time (ms)Frames elapsedWorst single frame (ms)توضیحات
File read (I/O)44.97144.38۳۷٬۱۳۷٬۸۱۵ بایت
Line-offset scan112.331156.96۱٬۰۰۰٬۰۰۱ خط
Parse (۱M رکورد)72.66196.15۱٬۰۰۰٬۰۰۰ رکورد
Sort (۱M رکورد)114.51359.89نزولی بر اساس Score
Search ID (۴۰۱ Query)میانگین 12.64 / بیشینه 112.04۱ به ازای هر Queryمیانگین 12.95 / بیشینه 119.18نتایج هر Query بین ۰ تا ۱۱۱٬۱۱۲ رکورد
Search Username (۴۰۱ Query)میانگین 15.55 / بیشینه 44.81۱ به ازای هر Queryمیانگین 15.87 / بیشینه 45.26نتایج هر Query بین ۰ تا ۱۴٬۲۷۰ رکورد
اسکرول در حالت پایدار--10.62بدترین فریم حین اسکرول سریع تا انتهای لیست (ردیف PlayerLoop در تصویر Profiler بالا)

این اعداد داخل Editor گرفته شده‌اند و شامل هزینهٔ EditorLoop نیستند، چون جدا در Hierarchy گزارش می‌شود و در Build واقعی اصلاً وجود ندارد؛ یعنی روی یک Development Build این اعداد پایین‌ترند، نه بالاتر.

مقایسهٔ Search روی ID و Username

معیارID SearchUsername Search
میانگین Wall time12.64ms15.55ms
بیشینه Wall time112.04ms44.81ms
میانگین بدترین فریم12.95ms15.87ms
بیشینه بدترین فریم119.18ms45.26ms
بازهٔ تعداد Match۰ تا ۱۱۱٬۱۱۲۰ تا ۱۴٬۲۷۰

Username Search به‌طور میانگین حدود ۳ میلی‌ثانیه از ID Search کندتر است، چون FixedString64Bytes به مقایسهٔ Case-insensitive بایت‌به‌بایت نیاز دارد، در حالی که ID Search یک مقایسهٔ عددی ساده است. بیشینهٔ بالاتر برای ID (۱۱۲.۰۴ms در برابر ۴۴.۸۱ms) مربوط به یک Query تکی است، نه یک الگوی پایدار؛ بیشینهٔ دوم و سوم ID Search هم در همان بازهٔ ۲۰ تا ۳۲ میلی‌ثانیهٔ Username Search قرار دارند. Queryهایی که Overhead بالاتری نشان می‌دهند (بالای ۲۰ms) عمدتاً در نیمهٔ دوم اجرا اتفاق افتاده‌اند؛ این ناشی از تجمع بیش از ۸۰۰ خط Debug.Log در همین اسکریپت تشخیصی است، نه از خود Job Search. روی Search واقعی UI (بدون Log اضافه) این افزایش وجود ندارد.

پیک حافظهٔ Native برای آرایهٔ اصلی: ۸۳.۹۲ مگابایت (۱٬۰۰۰٬۰۰۰ رکورد × ۸۸ بایت).

مصرف حافظهٔ Managed گزارش‌شده در این اجرا افت −۵۱۷,۶۸۲ کیلوبایت بود. عدد منفی نشان‌دهندهٔ یک پاس Garbage Collection حین اجراست، نه یک نشت حافظه. این عدد به Pipeline اصلی ربطی ندارد: Profiler.GetTotalAllocatedMemoryLong() مجموع تجمعی Allocation کل Session را می‌دهد، نه مصرف فعلی، و بخش بزرگی از نوسان از خود اسکریپت تشخیصی می‌آید (بیش از ۸۰۰ خط Log فرمت‌شده). برای عدد دقیق مصرف واقعی Pipeline باید یک Snapshot جدا با Memory Profiler از یک اجرای عادی (بدون این ابزار تست) گرفته شود.

نتیجه‌گیری کلی از پروفایل: هزینهٔ هر Search عملاً مستقل از تعداد Matchهاست (چه ۰ رکورد چه ۱۱۱٬۱۱۲ رکورد، زمان اجرا در همان بازهٔ چند-میلی‌ثانیه‌ای می‌ماند)؛ دقیقاً همان رفتاری که از یک Scan موازی روی کل آرایه انتظار می‌رود. Sort با ۱۱۴.۵۱ms روی ۳ فریم پخش شده، بدون این‌که هیچ فریمی بیشتر از ۶۰ms طول بکشد، یعنی Hitch محسوسی تولید نمی‌کند. اسکرول هم در بدترین لحظه فقط ۱۰.۶۲ms طول کشیده؛ یعنی Object Pooling و Virtualization طبق انتظار کار می‌کنند و هیچ‌کدام از عملیات‌های سنگین (Load، Sort، Search، Scroll) فریم‌ریت را به‌شکل محسوسی پایین نمی‌آورند.

نحوهٔ اجرا و تست

  1. مسیر فایل CSV را در فیلد path روی Manager (یا TestParse، SortJobTests، LeaderboardProfilerReport برای تست جدا) ست کن.
  2. Play بزن؛ ترتیب اجرا: Read → Find Lines → Parse → Sort → نمایش اولیه → آماده‌سازی Search.
  3. برای تست جستجو، داخل UI عدد (ID) یا بخشی از نام کاربری را تایپ کن.

سورس کد

لینک‌ها