سمولیشن کا کچرا 73% کم، اور ایک بھی ری پلے نہیں بدلا
جون 2026 میں میں نے CandyRush کی سمولیشن پر کارکردگی کے دو دور چلائے، ایک ناقابلِ سمجھوتہ اصول کے ساتھ: جو کچھ بھی سمولیشن کو چھوئے، اسے بٹ بہ بٹ یکساں رہنا ہے۔ "کھیلنے میں ایک جیسا" کافی نہیں۔ ری پلے ڈائجسٹس کا مطابقت رکھنا لازم تھا۔
نتائج، سب ایک ہی مشین پر ایڈیٹر batchmode بینچ مارک رنز سے:
| منظرنامہ | پہلے | بعد میں | تبدیلی |
|---|---|---|---|
| 60 سیکنڈ چلنا، کل کچرا (دور 1، PR #300) | 119.3 MB | 32.7 MB | -73% |
| چلنا، فی tick اوسط (دور 1) | 33,156 B | 9,525 B | -71% |
| خاموش لڑائی، 3,600 ticks، فی tick اوسط (دور 2، PR #302) | 8,976 B | 487 B | -95% |
| دشمن 0 سے 246 تک، فی tick اوسط (دور 2) | 71,901 B | 2,927 B | -96% |
| 500 دشمنوں کا دباؤ ٹیسٹ، P95 فریم ٹائم (دور 2) | 9.21 ms | 7.23 ms | -22% |
دونوں ادوار میں کوئی ری پلے fixture نہیں بدلا۔ راستے میں اس کام نے ایک pool بگ بھی ڈھونڈا جو جنوری سے ٹول کٹ میں موجود تھا، اور ایک ایسا ٹیسٹ جو اسی بگ کی توقع کے لیے لکھا گیا تھا۔
یہاں کچرا کیوں اہم ہے
CandyRush، Unity کے اندر 60 Hz کی مقررہ سمولیشن چلاتی ہے۔ کچرا بنتے وقت کم قیمت ہوتا ہے؛ قیمت تب ادا ہوتی ہے جب collector چلتا ہے، ایک ہموار فریم میں اچانک رکاوٹ کی صورت میں۔ اسکرین پر سیکڑوں دشمن ہوں تو کھلاڑی اسے محسوس کرتے ہیں۔
اس کام کو عام ایلوکیشن صفائی سے مشکل بنانے والی چیز یہی شرط تھی۔ ڈیٹرمنسٹک سمولیشن میں کسی فہرست کی "بے ضرر" ترتیبِ نو یہ بدل سکتی ہے کہ کون سا دشمن پہلے ضرب کھاتا ہے، اور وہاں سے پورا رن۔ ہر اصلاح کو وہی قدریں، اسی ترتیب میں، برقرار رکھنی تھیں۔
دور 1: پہلے ناپیں، پھر 15 tick کا پیٹرن تلاش کریں
PR #300 گرم راستوں (hot paths) کی صفائی کے طور پر شروع ہوا، یعنی وہ کوڈ جو Simulate()
کے اندر ہر tick چلتا ہے۔ ایک agent نے میری ہدایت پر یہ صفائی کی؛ بٹ بہ بٹ یکسانیت کا
اصول میں نے طے کیا اور ہر دور کا جائزہ لیا۔ ابتدائی اصلاحات جانی پہچانی تھیں:
- ہر tick والے کوڈ میں LINQ نہیں۔ دو aura ایبلٹیز ہر tick
OrderBy(...).ToList()چلاتی تھیں، چاہے آس پاس کوئی دشمن نہ ہو۔ اب وہ ترتیب شدہ buffers دوبارہ استعمال کرتی ہیں اور خالی ہونے پر جلد واپس آ جاتی ہیں۔ - buffer بھرنے والی queries۔ "رداس کے اندر دشمن" صرف ایک ایسے method کے طور پر
موجود تھا جو نئی فہرست لوٹاتا تھا۔ اب ایک دوسرا overload کال کرنے والے کی اپنی فہرست
بھرتا ہے، بالکل Unity کے
Physics2D.GetContactsکے طریقے کی طرح، اور ایلوکیٹ کرنے والا ورژن اسی کو استعمال کرتا ہے، اس لیے اسکین کا نفاذ اب بھی ایک ہی ہے۔ - مشترکہ comparers۔ چھ ڈیٹرمنسٹک sort مقامات ہر ایک lambda دیتے تھے، جو Mono پر ہر
کال پر ایک comparer wrapper ایلوکیٹ کرتا ہے۔ اب وہ ایک
DeterministicOrderکلاس کے singleton comparers استعمال کرتے ہیں، اور یہی کلاس اب اینٹیٹیز کی ترتیب کی واحد اتھارٹی ہے۔
سب سے بڑی کامیابی ایک بہتر پیمائشی آلے سے آئی۔ موجودہ بینچ مارکس کھلاڑی کو کبھی حرکت نہیں دیتے تھے، اس لیے PR کے اگلے دور میں فی tick ایلوکیشن کا ایک probe شامل کیا گیا، دو منظرناموں کے ساتھ: اصل waves کے ساتھ خاموش لڑائی، اور ایک "حرکت کی میراتھن" جو 60 سیکنڈ تک مشرق کی طرف چلتی ہے۔
ایک احتیاط: Unity کے Boehm collector کے تحت
GC.GetAllocatedBytesForCurrentThread صفر پڑھتا تھا، اس لیے probe
GC.GetTotalMemory(false) کے فی tick فرق ریکارڈ کرتا ہے۔ یہ heap کی بڑھوتری کا تخمینہ
ہے، اس لیے نتائج کا موازنہ صرف ایک ہی منظرنامے، ایک ہی seed اور ایک ہی مشین پر کیا
جاتا ہے۔
چلتے ہوئے فی tick ایلوکیشن کھڑے رہنے سے تقریباً پانچ گنا تھی، اور spikes باقاعدہ تھے: ہر ایک تقریباً 370 KB، اور بڑے spikes کے درمیان 409 وقفوں میں سے 184 ٹھیک 15 ticks کے تھے۔ 15 ticks ہی flow field کے دوبارہ حساب کا وقفہ ہے۔
وجہ: دشمنوں کو راستہ دکھانے والا flow field ایک grid ہے جو کیمرے کے ساتھ چلتا ہے۔ جب کیمرے کی کھڑکی ہلتی، تو سروس ایک بالکل نیا grid ایلوکیٹ کرتی، حالانکہ اس کے ابعاد نہیں بدلے تھے۔ کھلاڑی کے چلتے ہوئے یہ ہر دوبارہ حساب پر ہوتا تھا۔
اصلاح مشترکہ ٹول کٹ میں گئی، تاکہ اسے استعمال کرنے والے ہر گیم کو فائدہ ہو:
// Moves the grid window to a new origin, reusing the existing cell array.
// The result is state-identical to constructing a fresh grid of the same
// dimensions at the new origin.
public void Recenter(FixedVector2 newOrigin)
{
Origin = newOrigin;
ResetAll(); // the same reset the constructor now calls
}
constructor اور Recenter ایک ہی reset method استعمال کرتے ہیں، اس لیے "نئے grid کے
بالکل برابر" ہونا ساخت کی وجہ سے سچ ہے، احتیاط سے نقل کرنے کی وجہ سے نہیں۔ دوبارہ رن،
وہی منظرنامہ، وہی seed، وہی مشین:
| حرکت کی میراتھن | فی tick اوسط | p99 | 32 KB یا زیادہ کے spikes | فی 60 سیکنڈ کل |
|---|---|---|---|---|
| پہلے | 33,156 B | 401,408 B | 409 | 119.3 MB |
| بعد میں | 9,525 B | 40,960 B | 165 | 32.7 MB |
15 tick کا پیٹرن غائب ہو گیا۔ باقی بڑے spikes چلنے کے دوران chunk کی تبدیلیوں سے ایک ایک کر کے مطابقت رکھتے تھے، جس نے اگلے دور کی نشاندہی کی۔
دور 2: ایک لکھا ہوا پروٹوکول
دوسرے دور کے لیے، کوئی کوڈ بدلنے سے پہلے، میں نے پروٹوکول issue (#301) میں لکھ دیا:
- ایسے dev سرے سے برانچ بنائیں جس پر کوئی معلوم ٹیسٹ ناکامی نہ ہو۔
- برانچ پر پہلا کمٹ "پہلے" کی پیمائش ہے: ہر آلہ اَن چھوئے کوڈ پر ایک بار چلایا جائے، اور خام فی tick CSVs ریپوزٹری میں محفوظ کی جائیں۔
- اصلاحات درجوں میں ہوں۔ درجہ 1 زیادہ اثر والا اور ڈیٹرمنزم کے صفر خطرے والا ہے۔ جس چیز کو ڈائجسٹ کی تصدیق چاہیے، اس پر واضح نشان ہو۔
- "بعد" کی پیمائش بالکل وہی منظرنامے، seeds اور کیسز کی ترتیب استعمال کرے۔
"پہلے" والی پیمائش پہلے کمٹ کرنا اہم ہے: جو اعداد صرف پل ریکوئسٹ میں لکھے ہوں، انہیں دوبارہ جانچا نہیں جا سکتا۔ ان کو جانچا جا سکتا ہے۔ اس تحریر کے لیے میں نے خاموش لڑائی اور حرکت کی اوسطیں محفوظ CSVs سے دوبارہ نکالیں، اور وہ PR سے بالکل مطابقت رکھتی ہیں۔
سب سے بڑی واحد دریافت ایک spatial index تھا جو ہر tick دوبارہ بنتا تھا۔ اس کا
Clear() ہر bucket فہرست کو پھینک دیتا تھا، اور دوبارہ تعمیر ہر مقبوضہ خانے کے لیے نئی
فہرست ایلوکیٹ کرتی تھی: issue کے تخمینے کے مطابق 200 دشمنوں پر ہر tick سیکڑوں۔ اصلاح
فہرستوں کو زندہ رکھتی ہے اور ٹریک کرتی ہے کہ کون سی استعمال میں ہیں:
// Reset only the occupied lists, in place: O(occupied), no allocation.
for (int i = 0; i < occupiedKeys.Count; i++)
{
buckets[occupiedKeys[i]].Clear();
}
occupiedKeys.Clear();
ہر خانے میں اندراج کی ترتیب برقرار رہتی ہے، جو اہم ہے، کیونکہ collision push-out انہی فہرستوں پر چلتا ہے۔ اس دور سے پہلے بغیر ایبلٹی والا بنیادی منظرنامہ بھی فی tick 1,143 بائٹس ایلوکیٹ کرتا تھا، اور PR اس یکساں نچلی سطح کا زیادہ تر حصہ اسی دوبارہ تعمیر سے منسوب کرتا ہے۔ بعد میں اسی منظرنامے نے 64 ناپا۔
درجہ 1 کی دوسری تبدیلیاں، ہر ایک اس لیے چنی گئی کہ وہ نتائج کو یکساں رکھتی ہے:
- سستا راستہ پہلے۔ ہر دشمن پیچھا کرنے کی ایک normalized سمت نکالتا تھا (ایک fixed-point جذر اور دو تقسیمیں)، اور پھر عموماً اسے flow field کی سمت سے بدل دیتا تھا۔ اب وہ پہلے flow field سے پوچھتا ہے اور متبادل صرف ضرورت پر نکالتا ہے۔ نتیجے کے بٹس وہی ہیں۔
- ایک ہی compaction pass۔ سمولیشن لوپ سے ختم شدہ اینٹیٹیز ہٹاتے وقت ہر آئٹم پر
List.Removeکال ہوتا تھا، جو ایک دھماکے میں 30 دشمن مرنے پر quadratic ہو جاتا تھا۔ اب ایک ہی read/write pass بچ جانے والوں کو ان کی اصل ترتیب میں رکھتا ہے۔ - صرف تبدیلی پر لکھیں۔ دو aura ایبلٹیز ہر فریم اپنے collider کا رداس سیٹ کرتی تھیں۔ issue کے تجزیے کے مطابق 2D circle collider کا رداس سیٹ کرنا، چاہے وہی قدر ہو، Box2D کو shape دوبارہ بنانے پر مجبور کرتا ہے، اس لیے اب وہ صرف سائز بدلنے پر لکھتی ہیں۔
- tick سے باہر pool کی بڑھوتری۔ دشمنوں اور drops کے pools ہر Unity فریم میں ایک شے
سے بھرے جاتے ہیں،
Simulate()سے باہر۔ pool سے کون سی شے ملتی ہے، یہ سمولیشن کی حالت نہیں، کیونکہ اینٹیٹی IDs spawn کے وقت لگتے ہیں۔
pool کا بگ، اور وہ ٹیسٹ جو اس کی توقع کرتا تھا
دوسرے دور کے اسکین نے ٹول کٹ کا pool constructor پڑھا اور چاروں constructor overloads میں یہ پایا:
for (int i = 0; i <= startingSize; i++)
گیم کا ہر pool مانگے گئے سائز سے ایک شے بڑا بنتا رہا تھا۔ یہ لوپ جنوری 2026 میں ٹول کٹ کے ابتدائی کمٹس سے چلا آ رہا تھا۔
زیادہ سبق آموز حصہ ٹیسٹ تھا۔ جب لوپ ٹھیک کیا گیا تو pool کا ایک ٹیسٹ ناکام ہوا، کیونکہ وہ بگ ہی کو assert کرتا تھا:
// startingSize=3 creates 4 entities (loop is <=)
Assert.AreEqual(4, childCount, ...);
کسی موقع پر اضافی شے نوٹ کی گئی تھی، اور ایک ٹیسٹ لکھا گیا جس نے اسے پکا کر دیا۔ اب ٹیسٹ کو بالکل درست سائز پر دوبارہ مقرر کیا گیا ہے۔ جب کسی ٹیسٹ کا تبصرہ یہ سمجھائے کہ واضح جواب غلط کیوں ہے، تو وہ دوبارہ دیکھنے کے لائق ہوتا ہے۔
ری پلے نے کیسے ثابت کیا کہ کچھ نہیں بدلا
ثبوت مئی کے ری پلے ہارنس پر قائم ہے: اینٹیٹی اسٹیٹ ڈائجسٹس، ہر سمولیٹڈ سیکنڈ کا ایک ہیش، جن کا موازنہ کمٹ شدہ fixtures سے ہوتا ہے۔
- دور 1: اصلاحات کو یوں جانچا گیا کہ diff کو stash کیا، اَن چھوئے dev سرے پر چلایا،
اور تازہ ڈائجسٹس کا برانچ سے موازنہ کیا۔ وہ بالکل یکساں نکلے (مثلاً tick 60 کا ہیش
دونوں جگہ
9D039073D0F5338Aتھا)۔ پھر flow-field اصلاح کو اس fixture سے جانچا گیا جو اصلاح سے پہلے کمٹ ہوا تھا: وہ پھر بھی پاس ہوئی۔ - دور 2: گیٹ زیادہ سخت تھا۔ رن بہ رن، self-baseline اور peer-baseline ٹیسٹ، اینٹیٹی اور props دونوں منظرناموں کے لیے، برانچ کے نقطۂ آغاز والے fixtures کے خلاف پاس ہونے لازم تھے۔ پل ریکوئسٹ نے کوئی fixture فائل نہیں بدلی؛ میں نے اس کے diff سے اس کی تصدیق کی۔ (تمام fixtures Windows کے ہیں، اس لیے یہاں peer ٹیسٹ ڈیزائن کے مطابق پاس ہو جاتا ہے؛ اصل کام self-baseline ٹیسٹ کرتا ہے۔)
یہ گیٹ صرف اس کام کی وجہ سے قابلِ اعتماد تھا جو دور 1 سے عین پہلے آیا۔ PR #297 میں ٹیسٹ سویٹ کی تنظیمِ نو نے سویٹس سے ہر suppression ہٹا دی، اور پہلے یہ تصدیق کرنے کے بعد کہ سمولیشن الگ الگ Unity پروسیسز میں ڈیٹرمنسٹک ہے، دو پرانے PlayMode fixtures ٹھیک کیے۔ اس کے پہلے اور بعد کے رنز میں PlayMode کا کل وقت بھی 34.3 سے 20.3 منٹ پر آیا، جبکہ 1,703 سطریں حذف اور 1,220 شامل ہوئیں۔ جو ڈائجسٹ گیٹ پہلے ہی سرخ ہو، وہ آپ کو کوئی نئی بات نہیں بتا سکتا۔
دور 2 کی آخری تصدیق: EditMode میں 3,268 پاس، 0 ناکام؛ PlayMode میں 457 پاس، 0 ناکام۔
جان بوجھ کر جو نہیں چھیڑا گیا
- درجہ 3 کے وہ آئٹمز جو سمولیشن کے نتائج بدل سکتے تھے، نہیں لیے گئے، تاکہ اس دور کا ڈائجسٹ گیٹ خالص "کچھ نہیں بدلا" کا دعویٰ رہے۔ ہر ایک کو اپنا الگ ڈائجسٹ رن چاہیے۔
- دور 1 کی ایک اصلاح (خالی فریمز پر دشمنوں کے pool کو بھرنا) fast-forward بینچ مارک میں ظاہر نہیں ہو سکتی تھی، جو چند فریمز میں ہزاروں ticks چلا دیتا ہے۔ PR نے کوئی عدد دعوے کرنے کے بجائے یہی بات کہی۔
فریم ٹائمز کے بارے میں: یہ ایڈیٹر batchmode کی پیمائشیں ہیں، پلیئر بلڈز کی نہیں۔ میرے تجربے میں ڈیپلائے کیے گئے پلیئر بلڈز عموماً ایڈیٹر سے 2-3 گنا زیادہ فریم ریٹ پر چلتے ہیں، مگر ان رنز کے لیے میں نے یہ نہیں ناپا۔
جو بات عام طور پر لاگو ہوتی ہے
ایسا آلہ بنائیں جو کھلاڑیوں کے عمل سے میل کھائے۔ کچرے کا سب سے بڑا ذریعہ تب تک نظر نہیں آیا جب تک کسی بینچ مارک نے واقعی چلنا شروع نہیں کیا۔ اس سے پہلے کا ہر بینچ مارک ایک جگہ کھڑا رہتا تھا۔
"پہلے" والی پیمائش کمٹ کریں۔ محفوظ کیا گیا خام ڈیٹا ایک دعوے کردہ بہتری کو ایسی بہتری بنا دیتا ہے جسے کوئی بھی دوبارہ حساب کر سکے۔
"یکساں" کو ساخت سے سچ بنائیں۔ constructor اور Recenter کا ایک ہی reset method
بانٹنا، اور دوبارہ استعمال ہونے والی فہرستوں میں اندراج کی ترتیب برقرار رکھنا، اس کا
مطلب تھا کہ ڈائجسٹس نے وہی تصدیق کی جس کی ضمانت ڈیزائن پہلے ہی دیتا تھا۔
ان ٹیسٹوں کو غور سے پڑھیں جو کسی حیرت کو محفوظ کرتے ہیں۔ n + 1 کی توقع کے لیے
لکھا گیا ٹیسٹ ایک بگ کو درج کر رہا تھا، کسی ضرورت کو نہیں۔