← تمام مضامین پر واپس

500 دشمن: ایک ڈیٹرمنسٹک سمولیشن میں 27 سے 42 FPS تک

شائع کردہ:
candyrushperformancedeterminismunityevidence

26 اکتوبر کو، CandyRush کے Steam پر لانچ سے پانچ دن پہلے، Unity ایڈیٹر میں arc 3 کی depth 5 کا ایک playtest تقریباً ناقابلِ کھیل تھا: 15 سے 30 FPS۔ دن کے آخر تک اسی مرحلے نے عروج پر 42.4 FPS ناپا، اور replay digests بالکل یکساں تھے، چاہے نیا کوڈ 1 worker thread پر چلے یا 23 پر۔

نیچے دیے گئے تمام اعداد arc 3 کے "Horde" سیگمنٹ سے ہیں، depth 5 پر، جب دشمن 500 کی حد تک پہنچ چکے ہوں:

پہلےبعد میں
عروج پر FPS (میڈین)27.442.4
فریم ٹائم36.4 ms23.5 ms
فی فریم سمولیشن ticks2.141.42
فی tick دشمنوں کے پڑوسیوں کا کامتقریباً 3.0 ms0.86 ms
فی tick hazard props1.80 ms0.57 ms

hazard props والی قطار حتمی کوڈ کے ایک بعد کے کیپچر سے ہے، جس کا عروج 41.4 FPS تھا، جو 42.4 سے ایک رن سے دوسرے رن کے شور کی حد کے اندر ہے۔

یہ Unity ایڈیٹر کے کیپچرز ہیں، Ryzen 9 7900X اور RTX 4090 پر، 1599x894 کے Game view کے ساتھ، اور "بعد میں" والی قطار کے لیے ایڈیٹر میں Burst compilation آن تھی۔ میرے تجربے میں، ڈیپلائے کیے گئے player builds عام طور پر ایڈیٹر سے 2-3 گنا زیادہ FPS پر چلتے ہیں؛ یہ میرا تجربہ ہے، اس تحریر کے لیے ناپا گیا نتیجہ نہیں۔ ایک ہی کیس پر رن سے رن کا شور تقریباً جمع یا منفی 1.5 ms فی فریم تھا (تین رنز میں 41.4، 42.4 اور 43.1 FPS)۔

یہ کام 26 اکتوبر کو مرج ہونے والی دو پل ریکوئسٹس میں آیا: پہلے UTools #179، پھر CandyRush #1079۔ میں نے ایک AI کوڈنگ ایجنٹ کو اس کام میں ہدایت دی: پابندیاں اور سوالات میں نے طے کیے، اور ہر اگلی تبدیلی سے پہلے پچھلی پیمائش کا جائزہ لیا۔ یہ تحریر اس بارے میں ہے کہ فیصلے کیسے کیے گئے۔

پابندی

CandyRush ایک ڈیٹرمنسٹک، fixed-timestep سمولیشن ہے۔ ہفتہ وار لیڈر بورڈ کا ہر اسکور سرور پر کھلاڑی کے ریکارڈ شدہ inputs کو دوبارہ چلا کر تصدیق کیا جاتا ہے، اور یہ تبھی کام کرتا ہے جب ایک جیسے inputs Windows، macOS، Linux اور ویب پر bit for bit ایک جیسی حالت پیدا کریں۔ اسی وجہ سے سمولیشن floats کے بجائے fixed-point ریاضی (Fixed64) استعمال کرتی ہے۔

اس سے عام فوری حل خارج ہو جاتے ہیں۔ float والا تیز راستہ پہلے آزمایا جا چکا تھا: دشمنوں کی separation، 9 اگست تک float ریاضی استعمال کرتی تھی، جب اسے اس لیے بند کیا گیا کہ ویب اور ڈیسک ٹاپ کے replays الگ الگ نتائج دینے لگے تھے۔ میں نے ایک آپشن کے طور پر یہ تجویز رکھی کہ ہفتہ وار لیڈر بورڈ رن کے علاوہ ڈیٹرمنزم میں نرمی کی جائے۔ تحریری تجزیے نے دو موڈ والی سمولیشن کے خلاف سفارش کی، اور جو چیز ریلیز ہوئی وہ ایک ہی ڈیٹرمنسٹک سمولیشن رکھتی ہے اور صرف presentation میں نرمی کرتی ہے۔

پہلا قدم: مسئلے کو 90 سیکنڈ میں دہرایا جا سکنے والا بنانا

15 منٹ کا playthrough پیمائش نہیں ہوتا۔ پہلی ڈیلیوری ایک PlayMode کیپچر ٹیسٹ تھا، SegmentDensityCaptureTests، جو شپ شدہ سیگمنٹ ڈیٹا سے منتخب depth پر ایک حقیقی رن بناتا ہے اور ہر سیکنڈ ایک قطار ریکارڈ کرتا ہے: فریم ٹائم، اس میں سمولیشن کا حصہ، فی فریم ticks، زندہ دشمنوں کی تعداد، اور ہجوم کا overlap۔ یہ انجن کے خرچ کی تفصیل کے لیے Unity کے اپنے profiler markers بھی پڑھتا ہے، اور A/B ویریئنٹس کو سپورٹ کرتا ہے (فزکس بند، Animators بند، آڈیو بند، وغیرہ)۔

ٹیسٹ نے رپورٹ کو دہرا دیا: depth 5 پر Horde تقریباً 35 سیکنڈ میں 500 دشمنوں کی حد بھر لیتا ہے اور پھر 25 سے 29 FPS پر رکا رہتا ہے۔ ایک رن 15 منٹ کے بجائے تقریباً 90 سیکنڈ لیتا ہے۔ اسے [Explicit] نشان زد کیا گیا ہے تاکہ یہ کبھی CI میں نہ چلے۔

بیس لائن نے یہ بھی بتا دیا کہ کہاں دیکھنا ہے:

عروج پر، depth 5دشمنFPSفریم ms
Horde50027.436.4
Combat30960.916.4
Horde، depth 025474.913.3

500 دشمنوں پر سمولیشن فریم کا تقریباً 54% تھی: فی فریم 2.14 ticks، ہر ایک 9.1 ms کا۔ یہ catch-up spiral کی شروعات ہے: ایک سست فریم کا مطلب ہے اگلے فریم میں زیادہ fixed ticks، جو اس فریم کو اور سست کر دیتے ہیں۔ tick کا خرچ کم کرنے سے ticks کی تعداد بھی کم ہوتی ہے۔

کیپچر نے ایسا شور بھی دکھایا جسے ہٹانا ضروری تھا۔ اگست کا ایک عارضی debug probe اب بھی ہر arc 3 رن میں stack traces کے ساتھ تقریباً 6,400 لاگ لائنیں لکھ رہا تھا، دشمنوں کی موت، wand کے شاٹس اور gate میں داخلے کے لیے۔ اسے حذف کر دیا گیا۔

دوسرا قدم: Burst kernels جو ڈیٹرمنسٹک رہیں

دشمنوں کی separation (پڑوسیوں سے نرمی سے ہٹنا) اور overlap pushout (ایک دوسرے میں گھسنے کو سختی سے ختم کرنا) فی tick سب سے بڑے خرچ تھے۔ اس تبدیلی نے دونوں کو Burst سے compile ہونے والے kernels میں منتقل کیا:

  • EnemyNeighbourGrid ہر دشمن کی پوزیشن، رداس اور id کا snapshot ہے، جو tick کے آغاز پر لیا جاتا ہے اور counting sort سے buckets میں بانٹا جاتا ہے۔
  • SeparationForceJob پورے ہجوم کی steering force ایک parallel job میں نکالتا ہے۔
  • overlap pushout ایک Burst direct call کے طور پر چلتا ہے۔

دو تفصیلات نے اسے ڈیٹرمنسٹک رکھا:

Jacobi، نہ کہ Gauss-Seidel۔ ہر دشمن اپنے پڑوسی snapshot سے پڑھتا ہے، کبھی ان پوزیشنز سے نہیں جنہیں دوسرے دشمن اسی tick میں اپ ڈیٹ کر چکے ہوں۔ نتیجہ اب پروسیسنگ کی ترتیب پر منحصر نہیں، اس لیے 1 worker thread اور 23 پر ایک جیسا ہے۔ پہلے ورژن میں حرکت کر چکے دشمن کو snapshot کے پڑوسیوں سے ناپا گیا، تو ایک دوسرے کے بالکل اوپر رکھے دو دشمن کبھی الگ نہیں ہوئے۔ موجودہ EnemySeparationIntegrationTests نے اسے پکڑ لیا، اور ایک نیا kernel ٹیسٹ اس fix کو مستقل بناتا ہے۔

Burst ایک static constructor میں double ریاضی پر انحصار نہیں کر سکتا۔ Fixed64 اپنے زاویوں کے constants کو FromDouble(Math.PI ...) سے بناتا تھا۔ UTools #179 نے انہیں ان خام 64-bit قدروں سے بدل دیا جو وہ expression پہلے ہی پیدا کرتا تھا، اور ایک ٹیسٹ یہ تصدیق کرتا ہے کہ ہر literal اپنے پرانے ماخذ کے برابر ہے۔ اس تبدیلی سے کوئی digest نہیں بدلا۔

500 دشمنوں پر فی tick پیمائش:

خرچپہلے (ms)بعد میں (ms)
Separation1.260.15
Overlap1.420.48
Grid کی دوبارہ تعمیر0.340.23

ایک دریافت کوڈ کی تھی ہی نہیں: dev مشین کے ایڈیٹر میں Burst compilation بند تھی، اور اس حالت میں انہی kernels نے 42.4 کے بجائے 30.9 FPS ناپا۔ کیپچر کو یہ سیٹنگ ریکارڈ کرنی چاہیے۔

تیسرا قدم: ایک غلط اندازہ، ناپا گیا اور واپس لیا گیا

profile میں اگلا نمبر HazardPropBehavior کا تھا: hazard zones جیسے area props کے لیے فی tick 1.80 ms۔ کوڈ ہر دشمن کو ایک ممبر لسٹ میں linear search سے ڈھونڈتا تھا، اس لیے واضح اندازہ یہی تھا کہ lookup مہنگا ہے۔ ایجنٹ نے اسے HashSet سے بدل دیا۔

کوئی فرق نہیں پڑا۔ چنانچہ دوبارہ اندازہ لگانے کے بجائے اس نے 135,045 area-prop ticks پر عارضی timers لگائے:

فی prop، فی tickوقت
پورا membership scan19.0 µs
تمام تقریباً 500 دشمنوں پر loop17.2 µs
shape کی دوبارہ تعمیر1.2 µs
اثر لاگو کرنا0.45 µs

zones میں اوسطاً 0.2 دشمن تھے۔ خرچ ممبر ڈھونڈنے میں نہیں تھا؛ خرچ یہ تھا کہ ہر prop، ہر tick، تمام 500 دشمنوں کو دیکھا جاتا تھا اور تقریباً کوئی نہیں ملتا تھا۔ HashSet والی تبدیلی timers سمیت واپس لے لی گئی۔

اصل حل نے snapshot grid کو دوبارہ استعمال کیا: area props اب grid سے اپنی حدود کے اندر موجود دشمن پوچھتے ہیں۔ HazardPropBehavior فی tick 1.80 سے 0.57 ms پر آ گیا، اور عروج پر پورا tick 6.65 سے 5.73 ms پر۔ اس رن میں عروج کا FPS 41.4 تھا، جو پچھلے 42.4 سے شور کی حد کے اندر ہے۔

کیا ہجوم کا رویہ درست رہا؟

tick کے آغاز کے snapshot پر منتقل ہونے سے سمولیشن کے نتائج بدلتے ہیں۔ اس لیے ہجوم کو آنکھ سے نہیں، پیمائش سے جانچا گیا:

Horde depth 5، عروج پرفی دشمن overlap والے جوڑےسب سے گہرا overlap
نیا snapshot راستہ0.4650.36
پرانا live-read راستہ0.5680.81

ہجوم زیادہ گھنا نہیں ہوا؛ overlap والے جوڑے کم ہیں اور بدترین صورت کم گہری ہے۔ موازنے والی قطار float-math آپشن استعمال کرتی ہے، کیونکہ پرانا fixed-point live-read راستہ اب چلانے کے لیے موجود ہی نہیں۔

ڈیٹرمنزم: نئی بیس لائن، پھر ثبوت

چونکہ نتائج بدلے، اس لیے replay digests بھی بدلے۔ یہ "کچھ نہیں بدلا" والی optimization نہیں تھی، اور پل ریکوئسٹ یہ صاف لکھتی ہے۔ طریقہ کار:

  1. پہلے تصدیق کہ مشین اپنے ہی digests دہراتی ہے (ایک ہی OS پر رن سے رن والا ٹیسٹ سبز تھا)۔
  2. جو دو PlayMode replay بیس لائنز بدلیں انہیں دوبارہ بنانا، اور دونوں canary replays پر نئی مہر لگانا۔ EditMode، props اور boomerang کی بیس لائنز نہیں بدلیں۔
  3. سب کچھ دوبارہ چلانا: PlayMode digests 18 میں سے 18، EditMode digests 9 میں سے 9، دونوں canaries، اور multi-segment اور چھ boss والے matrix ٹیسٹس۔
  4. ثابت کرنا کہ نیا کوڈ ترتیب سے آزاد ہے: digests، 1 job worker اور 23 کے ساتھ، اور Burst آن اور آف دونوں میں، بالکل یکساں تھے۔

replay fixtures صرف Windows کے لیے موجود ہیں، اس لیے بیس لائنز وہیں دوبارہ بنائی گئیں۔ سمولیشن ورژن 1 ہی رہا، جیسا کہ ریلیز سے پہلے کی سمولیشن تبدیلیوں کے لیے پروجیکٹ کا اصول ہے۔

Presentation: دشمنوں کے لیے فزکس انجن نہیں

A/B ویریئنٹس سے presentation کی دو تبدیلیاں نکلیں:

  • دشمن Box2D سے نکل گئے۔ ان کا Rigidbody2D اب simulate نہیں ہوتا۔ اس کے لیے پہلے ہر trigger اور collision callback کا جائزہ لیا گیا، یہ تصدیق کرنے کے لیے کہ ہر ایک یا تو جلدی واپس آ جاتا ہے کیونکہ ایک ڈیٹرمنسٹک scan پہلے ہی اسے سنبھال رہا ہے، یا صرف کھلاڑی سے غرض رکھتا ہے۔ A/B: حد پر Box2D کا خرچ فی فریم تقریباً 2.6 ms تھا، اور آن اور آف دونوں میں kills بالکل یکساں تھے۔
  • اسکرین سے باہر عام دشمن animate ہونا بند کر دیتے ہیں۔ Boss rigs اپنا طے شدہ موڈ برقرار رکھتے ہیں۔

دشمنوں کی فزکس اور Animators دونوں مکمل بند کر کے کیے گئے ایک A/B نے 61.1 FPS ناپا۔ جو ریلیز ہوا وہ تھوڑا مختلف ہے (Animators اسکرین سے باہر cull ہوتے ہیں، ہٹائے نہیں جاتے)، اور میں نے یہ مخصوص امتزاج دوبارہ نہیں ناپا، اس لیے جس عدد پر میں قائم ہوں وہ 42.4 ہے۔

جائزہ

دو آزاد adversarial جائزوں نے، ایک ڈیٹرمنزم کے لیے اور ایک native/انجن کوڈ کے لیے، مرج سے پہلے سات مسائل پکڑے۔ ان میں شامل تھے: snapshot کے بعد teleport ہونے والے دشمن پرانا slot استعمال کر رہے تھے؛ grid کے کنارے سے باہر کے area props دشمنوں کو چھوڑ سکتے تھے (اب وہ پرانے scan پر واپس چلے جاتے ہیں)؛ اور snapshot کی دوبارہ تعمیر کے دوران ایک exception سمولیشن کو منجمد کر دیتی۔ ساتوں مسائل اسی پل ریکوئسٹ میں ٹھیک کیے گئے۔

کیا باقی ہے

  • flow-field sampling اور obstacle pushout، جو 500 دشمنوں پر تقریباً 0.7 ms لیتے ہیں، اب بھی فی دشمن managed کوڈ ہیں۔
  • رن سے رن کا شور تقریباً جمع یا منفی 1.5 ms ہے۔ کیپچر ٹیسٹ کی وجہ سے ایک پُرسکون مشین پر دوبارہ پیمائش ایک لائن کی کمانڈ ہے۔

جو بات عمومی طور پر کام آتی ہے

optimize کرنے سے پہلے دہرائیں۔ ایک 90 سیکنڈ کا ٹیسٹ جو شکایت کو دوبارہ پیدا کرے، کسی بھی ایک fix سے زیادہ قیمتی ہے، کیونکہ بعد کا ہر دعویٰ اسی پر پرکھا جاتا ہے۔

جب اندازہ عدد کو نہ ہلائے تو دوبارہ اندازہ لگانے کے بجائے ناپیں۔ HashSet ایک معقول خیال تھا، مگر غلط خرچ کے بارے میں۔ timers نے ایک ہی رن میں اصل خرچ دکھا دیا۔

ڈیٹرمنزم ایک پابندی ہے جس کے اندر ڈیزائن کیا جاتا ہے، رکنے کی وجہ نہیں۔ snapshot سے Jacobi طرز کی reads کو parallel کرنا بھی آسان ہے اور یہ ترتیب سے آزاد بھی ہیں۔ ثبوت 1 اور 23 workers پر digests کا یکساں ہونا ہے۔