عنوان:

‫تخصیص شی روی استک در دات نت 10 (Object Stack Allocation)


نویسنده: امیر مکارچی
تاریخ: ۱۴۰۴/۰۶/۲۵ ۲۱:۰۵
آدرس: www.dntips.ir
یکی از هیجان‌انگیزترین زمینه‌های پیشرفت در deabstraction در دات نت 10، استفاده گسترده‌تر از تحلیل فرار (escape analysis) برای فعال‌کردن تخصیص روی استک است.
تخصیص شی روی استک یعنی JIT تشخیص بدهد که یک شی (یا آرایه) فقط داخل یک متد استفاده می‌شود و پس از خروج از متد به بیرون «فرار» (escape) نمی‌کند؛ در این صورت به‌جای ساختن آن روی heap، آن را در استک می‌سازد.

مفهوم escape analysis
فرض کنید JIT بررسی می‌کند که یک آبجکت جدید (با new) کجا ساخته شود:
  • روی heap: اگر احتمال داشته باشد آبجکت بعد از خروج از متد هنوز زنده باشد.
  • روی stack: اگر مطمئن باشد عمر آبجکت فقط در همین متد و همین call-frame هست (یعنی "فرار نمی‌کند" = no escape).

سناریوهای فرار (escape)
JIT دنبال این است ببیند آبجکت به بیرون می‌رود یا خیر. چند حالت خطرناک وجود دارد:
برگرداندن (return)
Foo MakeFoo() {
    return new Foo();
}
اینجا Foo از متد بیرون میرود بنابراین باید روی heap باشد.

ذخیره در فیلد یا متغیر استاتیک (field/store)
class Bar { public Foo F; }
void Save(Bar b) {
    b.F = new Foo();
}
اینجا Foo داخل آبجکت دیگر ذخیره میشود (که عمرش نامعلوم است) پس escape می شود.

ارسال به جایی که JIT نمی‌داند چه میشود
void Print(object o) => Console.WriteLine(o);
void Test() {
    Print(new Foo());
}
اینجا Foo به متدی که JIT نمی‌تواند آنالیز کند داده میشود. ممکن است نگه‌داری شود پس escape می شود.

چه زمانی no-escape است
اگر JIT بفهمد آبجکت فقط در همین متد استفاده میشود (نه return میشود، نه داخل فیلد ذخیره میشود، نه به جایی داده میشود که ناشناخته است) آن وقت می‌تواند به جای heap، روی stack قرار دهد.
void Test() {
    var foo = new Foo();
    foo.DoSomething();
}

چرا "field-sensitive" و "inlining" مهم هستند؟

Field-sensitive:
باید دقیقاً بفهمد آیا چیزی داخل struct یا Span باعث escape میشود یا خیر.
Span<int> s = stackalloc int[10];
s[0] = 42;
اینجا safe است، چون داده فقط داخل استک خودش است.

Inlining:
بیشتر اوقات JIT باید متد رو inline کند تا جریان داده را دنبال کند.
void Wrapper() {
    var f = MakeFoo();
    f.DoSomething();
}
Foo MakeFoo() => new Foo();
اگر inline نشود، JIT نمی‌فهمد که Foo فقط داخل Wrapper استفاده میشود.
ولی اگر inline باشد می‌فهمد no-escape است بنابراین روی stack قرار می دهد.

Escape Analysis روی Delegate
به‌طور خاص متوجه شود که متد Invoke در delegate (که توسط runtime پیاده‌سازی می‌شود) رفرنس this را ذخیره نمی‌کند.
پس اگر escape analysis بتواند ثابت کند که آبجکت delegate هم جایی فرار نکرده است، عملاً می‌توان تخصیص delegate را حذف کرد.
در متد Sum این کد داریم:
public int Sum(int y)
{
    Func<int, int> addY = x => x + y;
    return DoubleResult(addY, y);
}
اینجا ما یک delegate (Func<int,int>) می‌سازیم که یک lambda هست و از متغیر y استفاده می‌کند. چون delegate باید به y دسترسی داشته باشد، کامپایلر سی شارپ پشت صحنه یک آبجکت جدید درست می‌کند (به اسم closure یا display class) که مقدار y داخلش ذخیره میشود. بعد delegate به یک متد در روی اون آبجکت اشاره می‌کند.پس دو تا تخصیص اتفاق میفته:
  • یک closure برای نگهداری y
  • یک delegate که به متد closure اشاره می‌کنه

در دات نت 9 هر بار اجرای این متد دو آبجکت ساخته میشود پس یعنی هزینه حافظه و سرعت پایین‌تر. اما در دات نت 10، JIT خیلی باهوش‌تر شده است:
  • می‌بیند delegate فقط به DoubleResult پاس داده میشود.
  • DoubleResult هم inline میشود (یعنی کد آن مستقیم داخل Sum کپی میشود).
  • بعد JIT بررسی می‌کند: "آیا این delegate جایی خارج از Sum یا DoubleResult ذخیره میشود یا برمی‌گردد؟"جواب: خیر!
پس نتیجه می‌گیرد: این delegate اصلاً لازم نیست ساخته شود. یعنی دامین آبجکت (delegate) کامل حذف میشود و فقط closure باقی میماند.
نتیجه:
  • تخصیص حافظه نصف میشود (از 88 بایت به 24 بایت).
  • سرعت اجرای متد تقریباً ۳ برابر بهتر میشود.
به زبان خیلی ساده:
دات نت 9: "برای این lambda باید دو تا آبجکت بسازم"
دات نت 10 :"صبر کن، delegate رو لازم نیست بسازم، چون فقط همین‌جا استفاده میشود. حذفش می‌کنم!"

با یک مثال این موضوع رو بررسی کنیم فرض کن می‌خوای به دوستت بگی:
"هر وقت عددی بهت دادم، اون رو با عدد ۴۲ جمع کن و نتیجه رو بده."

دات نت 9 – حالت سنتی
  • تو اول یه دفترچه برمی‌داری و عدد ۴۲ رو داخلش می‌نویسی (این میشه closure).
  • بعد یه برگه کاغذ جدا درست می‌کنی که روش نوشته: "برو از دفترچه عدد رو بخون، بعد با ورودی جمع کن" (این میشه delegate).
  • هر بار دوستت بخواد این کار رو انجام بده، باید هم دفترچه رو داشته باشه هم برگه رو.
  • دو تا وسیله باید همراهت باشه (۲ تا allocation).

دات نت 10 – حالت هوشمند
دوستت میگه: "خب چرا انقدر پیچیده؟! خودت که همین‌جایی. من مستقیم عدد ۴۲ رو ازت می‌گیرم و حساب می‌کنم. برگه کاغذ لازم نیست!"
  • اینجا هنوز دفترچه لازمه (چون y باید یه جایی ذخیره بشه).
  • ولی برگه کاغذ (delegate) کاملاً حذف میشه.
  • فقط یک وسیله همراهته (۱ allocation).


Escape Analysis روی آرایه
وقتی شما می‌نویسید:
Process(new string[] { "a", "b", "c" });

  • کامپایلر C# یک آرایه string[] روی heap می‌سازد (new string[3]).
  • این آرایه حاوی "a", "b", "c" است.
  • چون روی heap است، باید توسط GC مدیریت شود.
  • حتی اگر Process فقط داخل همان متد اجرا شود و هیچ ارجاعی به بیرون نرود، باز هم آن allocation روی heap انجام می‌شود.
  • نتیجه: هزینه‌ی ساخت و هزینه‌ی GC وجود دارد.

سناریوی آرایه در دات نت 10
در دات نت 10، JIT escape analysis انجام می‌دهد:
  • بررسی می‌کند آیا این آرایه‌ی string از متد خارج می‌شود یا نه (مثلاً return شود، یا در فیلد ذخیره شود، یا به متدی ناشناخته پاس داده شود).
  • اگر ببیند این آرایه فقط در همین scope مصرف می‌شود، نتیجه می‌گیرد که این آرایه escape ندارد.

حالا JIT تصمیم می‌گیرد:
  • به جای heap allocation، آن را روی stack بسازد (مثل stackalloc) ولی شفاف برای برنامه‌نویس.
  • وقتی متد تمام شد، stack frame آزاد می‌شود و هیچ اثری از آن آرایه باقی نمی‌ماند.

یعنی دقیقاً همان تأثیری که در مثال Delegate دیدیم، اینجا هم رخ می‌دهد:
  • در Delegate، JIT فهمید شی delegate لازم نیست روی heap باشد پس حذف شد.
  • در Array، JIT فهمید آرایه از scope خارج نمی‌شود پس از heap رفت به stack.

با یک مثال این موضوع رو بررسی کنیم فرض کن می‌خوای برای دوستت سه تا کلمه ("a", "b", "c") بفرستی تا اون با هر کلمه یه کاری بکنه.


دات نت 9 – حالت قدیمی
  • تو باید این سه تا کلمه رو توی یک جعبه مقوایی بزرگ بذاری (یعنی آرایه روی heap).
  • جعبه رو میدی دست دوستت پس اون کلمات رو یکی یکی برمی‌داره.
مشکل:
  • جعبه باید از قبل ساخته بشه.
  • وقتی کارت تموم شد، هنوز باید جعبه رو بریزی توی سطل آشغال (اینجا GC باید جمعش کنه).
  • یعنی هزینه بیشتر + فشار روی سیستم.

دات نت 10 – حالت هوشمند
  • JIT نگاه می‌کنه می‌بینه: "این سه تا کلمه فقط همین‌جا مصرف میشن، هیچ‌جاشون ذخیره یا نگه‌داری نمی‌شن."
  • میگه: "خب، چرا باید جعبه بزرگ بسازم؟ همین‌جا روی میز (یعنی stack) می‌چینمشون."
  • وقتی کار متد تموم میشه، میز خالی میشه پس بدون اینکه لازم باشه جعبه بسازی یا GC جمعش کنه.
  • سریع‌تر + بدون فشار به GC.


Escape Analysis روی آرایه / Span

ما داریم:
private byte[] _buffer = new byte[3];
[Benchmark]
public void Test() => Copy3Bytes(0x12345678, _buffer);
[MethodImpl(MethodImplOptions.NoInlining)]
private static void Copy3Bytes(int value, Span<byte> dest) =>
    BitConverter.GetBytes(value).AsSpan(0, 3).CopyTo(dest);

در دات نت 9
  • BitConverter.GetBytes(int) همیشه یک آرایه‌ی 4 بایتی روی heap می‌سازد.
  • این آرایه شامل بایت‌های عدد ورودی است.
  • بعد با AsSpan(0,3) سه بایت اول آن انتخاب می‌شود.
  • سپس با CopyTo(dest) این بایت‌ها کپی می‌شوند.
  • حتی اگر این آرایه فقط موقت استفاده شود، چون روی heap ساخته شده، باید توسط GC مدیریت شود.
  • نتیجه: 32 بایت allocation (24 بایت سربار object header + 4 بایت length + 4 بایت داده، گرد شده به 32).

در دات نت 10
JIT چند بهبود مهم دارد:
  • Inlining: متد BitConverter.GetBytes و AsSpan را inline می‌کند تا رفتار داخلی دیده شود.
  • Escape analysis: می‌بیند آرایه‌ای که GetBytes می‌سازد: فقط همان‌جا به Span تبدیل می‌شود. هیچ جایی ذخیره نمی‌شود یا return نمی‌شود. یعنی escape ندارد.
در نتیجه:
  • به جای ساخت آرایه روی heap، JIT آن 4 بایت را روی stack می‌سازد.
  • بعد همان داده‌ها به dest کپی می‌شوند.
  • وقتی متد تمام شد، stack frame آزاد می‌شود پس بدون GC است.

نکته جالب
این بهبود فقط وقتی ممکن است که JIT مطمئن شود Span یا آرایه از scope خارج نمی‌شوند. برای همین نیاز به field-sensitive analysis و شناخت دقیق متدهایی مثل Memmove در CopyTo داشت (چون اگر JIT نداند CopyTo چه می‌کند، مجبور است بدبینانه فرض کند آرایه escape کرده).

فرض کن می‌خوای عدد 0x12345678 رو به سه بایت تقسیم کنی و تو یه برگه کوچک (buffer) بریزی:
byte[] buffer = new byte[3];
Copy3Bytes(0x12345678, buffer);

در دات نت 9 – حالت قدیمی
  • BitConverter.GetBytes(value) یه جعبه بزرگ 4 بایتی روی heap می‌سازه.
  • بعد از اون، سه بایت اول رو برمی‌داری و روی buffer کپی می‌کنی.
  • وقتی کار تموم شد، GC باید جعبه‌ی ۴ بایتی رو جمع کنه.
  • یعنی هزینه‌ی اضافی حافظه و سرعت پایین‌تر.

در دات نت 10 – حالت هوشمند
  • JIT نگاه می‌کنه می‌فهمه: این ۴ بایت فقط همین‌جا استفاده میشه و فرار نمی‌کنه.
  • پس به جای ساختن جعبه روی heap، اون ۴ بایت رو مستقیم روی میز کار (stack) می‌چینه.
  • بعد از کپی به buffer، وقتی متد تموم شد، میز خالی میشه → هیچ چیزی روی heap نمی‌ره.