Performance و Span
در کد پرفشار، هر شیء کوچک روی Heap کار GC را بیشتر میکند. اول اندازه بگیر، بعد فقط نقطه داغ را درست کن. ابزارهایی مثل Span و ArrayPool کمک میکنند بدون کپی و بدون شیء تازه کار کنی.
نویسنده: bezzad
مشکل: مکثهای کوتاه در سرویس قیمت
تأمینکنندهها قیمت و موجودی کالا را برای فروشگاه ما میفرستند. هر پیام یک خط متن است: شناسه کالا، قیمت و موجودی، جدا شده با ویرگول. سرویس ما در هر ثانیه دهها هزار خط را اینطور parse میکند:
public static PriceUpdate ParseWithSplit(string line)
{
var parts = line.Split(',');
return new PriceUpdate(
parts[0],
decimal.Parse(parts[1], CultureInfo.InvariantCulture),
int.Parse(parts[2], CultureInfo.InvariantCulture));
}
بیشتر وقتها سرعت خوب است. ولی هر چند ثانیه یک بار، همه چیز برای مدت کوتاهی میایستد. ابزار dotnet-counters نشان میدهد تعداد GC خیلی زیاد است.
چرا allocation زیاد کند است؟
قدم به قدم:
- متد Split برای هر خط یک آرایه و سه string تازه روی Heap میسازد.
- دهها هزار خط در ثانیه یعنی صدها هزار شیء در ثانیه. همه چند لحظه بعد زباله هستند.
- هر شیء تازه در نسل ۰ ساخته میشود. نسل ۰ با این سرعت خیلی زود پر میشود.
- هر بار که پر شود، GC اجرا میشود. GC نسل ۰ ارزان است، ولی وقتی هزاران بار اجرا شود، جمع زمانش زیاد است.
- چند شیء هم که هنگام GC زندهاند، به نسلهای بالاتر میروند. گاهی هم یک GC کامل و گران لازم میشود.
نتیجه: هر چه زباله کمتری بسازیم، GC کمتر کار میکند.
اول اندازه بگیر
مهمترین قانون Performance این است: حدس نزن. بیشتر وقتها جایی که فکر میکنیم کند است، کند نیست.
- با ابزار dotnet-counters وضعیت کلی را ببین: تعداد GC، اندازه Heap، مصرف CPU.
- با ابزار dotnet-trace یا PerfView پیدا کن بیشتر زمان یا بیشتر allocation در کدام متد است. به آن نقطه داغ (Hot Path) میگوییم.
- فقط همان نقطه را تغییر بده. بقیه کد را ساده نگه دار.
- با کتابخانه BenchmarkDotNet قبل و بعد را مقایسه کن.
کتابخانه BenchmarkDotNet
این کتابخانه کد را بارها اجرا میکند، گرم کردن (warm-up) را انجام میدهد و نتیجه آماری میدهد. ویژگی MemoryDiagnoser مقدار allocation را هم نشان میدهد:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<ParseBenchmarks>();
[MemoryDiagnoser]
public class ParseBenchmarks
{
private const string Line = "P-907,129.50,12";
[Benchmark(Baseline = true)]
public PriceUpdate WithSplit() => PriceParser.ParseWithSplit(Line);
[Benchmark]
public PriceUpdate WithSpan() => PriceParser.ParseWithSpan(Line);
}
برنامه را همیشه در حالت Release اجرا کن:
dotnet run -c Release
Span چیست؟
نوع Span یک پنجره روی یک تکه حافظه است. حافظه میتواند یک آرایه، یک string یا حتی حافظه روی Stack باشد. Span فقط دو چیز را نگه میدارد: کجا شروع میشود و چقدر طول دارد.
- نوع ReadOnlySpan فقط خواندنی است. برای string از این نوع استفاده میکنیم.
- برش یک Span (با محدوده، مثل دو نقطه پشت سر هم) هیچ چیزی کپی نمیکند. فقط یک پنجره کوچکتر میسازد.
- بیشتر متدهای Parse در .NET یک نسخه دارند که ReadOnlySpan میگیرد.
حالا همان parse، بدون Split:
public readonly record struct PriceUpdate(string Sku, decimal Price, int Stock);
public static PriceUpdate ParseWithSpan(ReadOnlySpan<char> line)
{
int first = line.IndexOf(',');
var sku = line[..first];
var rest = line[(first + 1)..];
int second = rest.IndexOf(',');
var price = decimal.Parse(rest[..second], CultureInfo.InvariantCulture);
var stock = int.Parse(rest[(second + 1)..], CultureInfo.InvariantCulture);
return new PriceUpdate(sku.ToString(), price, stock);
}
در این نسخه آرایهای ساخته نمیشود و string قیمت و موجودی هم ساخته نمیشود. فقط یک string برای شناسه کالا میسازیم، چون واقعاً آن را لازم داریم.
محدودیتهای Span
نوع Span یک ref struct است. یعنی فقط میتواند روی Stack باشد. دلیلش این است که ممکن است به حافظه Stack اشاره کند و آن حافظه بعد از تمام شدن متد دیگر معتبر نیست. پس:
- نمیتواند فیلد یک class باشد.
- یک lambda نمیتواند آن را بگیرد (capture کند).
- نمیتواند از یک await عبور کند. متغیرهای یک متد async در طول await روی Heap نگه داشته میشوند.
- برای این حالتها، از Memory استفاده کن. نوع Memory روی Heap میماند و هر وقت لازم شد، با خاصیت Span آن یک Span میگیری.
استفاده دوباره از بافر با ArrayPool
سرویس ما فایلهای بزرگ قیمت را هم از تأمینکنندهها دانلود میکند. اگر برای هر فایل یک آرایه ۶۴ کیلوبایتی تازه بسازیم، این آرایهها هم زباله میشوند. آرایههای ۸۵ هزار بایت و بزرگتر حتی بدتر هستند: مستقیم به Large Object Heap میروند و فقط با GC کامل پاک میشوند.
public static async Task CopyAsync(Stream input, Stream output, CancellationToken ct)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
int read;
while ((read = await input.ReadAsync(buffer, ct)) > 0)
await output.WriteAsync(buffer.AsMemory(0, read), ct);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
stackalloc برای بافرهای کوچک
برای یک بافر کوچک و موقت، میشود حافظه را روی Stack گرفت. هیچ چیزی روی Heap ساخته نمیشود:
Span<char> buffer = stackalloc char[32];
if (price.TryFormat(buffer, out int written, "N0", CultureInfo.InvariantCulture))
writer.Write(buffer[..written]);
فقط برای اندازههای کوچک و ثابت. Stack کوچک است و اگر پر شود، کل process از کار میافتد.
منابع رایج دیگر allocation
- چسباندن string در حلقه. هر بار یک string تازه ساخته میشود. از StringBuilder استفاده کن.
- ساختن List بدون ظرفیت اولیه. وقتی تعداد را میدانی، ظرفیت را بده. وگرنه List چند بار آرایه داخلیاش را بزرگ و کپی میکند.
- تبدیل struct به object (Boxing). مثلاً گذاشتن int در یک متغیر از نوع object یا یک اینترفیس.
- استفاده از LINQ و lambda در نقطه داغ. هر مرحله و هر lambda که متغیری را میگیرد، شیء کوچکی میسازد. در کد عادی مهم نیست. در نقطه داغ، یک حلقه ساده بهتر است.
- متد async که بیشتر وقتها بدون انتظار تمام میشود. مثلاً ۹۹ درصد وقتها از کش جواب میدهد. اینجا ValueTask allocation نوع Task را حذف میکند. ولی ValueTask را فقط یک بار await کن.
قانونهای مهم
- اول اندازه بگیر. بدون عدد، بهینهسازی نکن.
- فقط نقطه داغ را بهینه کن. کد Span خواناتر از کد ساده نیست. بقیه کد را ساده نگه دار.
- قبل و بعد را با BenchmarkDotNet مقایسه کن. در حالت Release و با MemoryDiagnoser.
- بافر قرضی را همیشه در finally پس بده. و بعد از پس دادن، از آن استفاده نکن.
- از stackalloc فقط برای اندازه کوچک و ثابت استفاده کن. هیچ وقت با اندازهای که از کاربر میآید.
- تنظیمات GC را آخر نگاه کن. اول کد را درست کن، بعد تنظیمات.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| بهینهسازی بدون اندازهگیری | کد پیچیده، بدون سود واقعی. | اول پیدا کردن نقطه داغ. |
| اندازهگیری با Stopwatch در حالت Debug | عددهای گمراهکننده. | کتابخانه BenchmarkDotNet در حالت Release. |
| استفاده از Span در همه جای کد | کد سختخوان، بدون سود. | فقط در نقطه داغ. |
| کار با کل آرایه Rent شده | داده اضافه و قدیمی خوانده میشود. | فقط تا تعداد واقعی. |
| استفاده از آرایه بعد از Return | خراب شدن داده کد دیگر. | پس دادن در finally، بعد دیگر نه. |
| استفاده از stackalloc با اندازه بزرگ | پر شدن Stack و از کار افتادن process. | اندازه کوچک و ثابت، یا ArrayPool. |
چه وقت این ابزارها؟
مناسب
- کدی که در هر ثانیه هزاران بار اجرا میشود.
- اندازهگیری نشان داده allocation یا GC مشکل است.
- خواندن و parse متن، کار با فایل و شبکه، کتابخانههای پایه.
نامناسب
- یک endpoint معمولی که بیشتر زمانش منتظر دیتابیس است.
- کدی که در روز چند بار اجرا میشود.
- وقتی هنوز چیزی اندازه نگرفتهای.
خلاصه در شش خط
- هر شیء تازه روی Heap کار GC را بیشتر میکند. در کد پرفشار این جمع میشود.
- اول اندازه بگیر و نقطه داغ را پیدا کن. بعد فقط همان را تغییر بده.
- با BenchmarkDotNet و MemoryDiagnoser، قبل و بعد را در حالت Release مقایسه کن.
- نوع Span یک پنجره روی حافظه است. برش آن هیچ چیزی کپی نمیکند.
- نوع Span فقط روی Stack است. برای فیلد و کد async از Memory استفاده کن.
- بافرهای بزرگ را با ArrayPool قرض بگیر و در finally پس بده.