بهینهسازی عملکرد در برنامهنویسی NET.: چالش یک میلیارد سطر
نویسنده: وحید نصیری
تاریخ: ۱۴۰۴/۰۲/۱۳ ۰۸:۵۳
آدرس: www.dntips.ir
| مطالب | ۳۶۹۴ |
| نویسندگان | ۲۷۶ |
| گروههای مطالب | ۱۰۲۴ |
| نقشههای راه | ۱۱۹ |
| دورهها | ۱۴ |
| اشتراکها | ۱۷۹۱۴ |
StreamReader.ReadLineAsync()، سپس تجزیه (Parse) هر خط به اجزای تشکیلدهنده آن با استفاده از متد string.Split() برای جدا کردن نام ایستگاه و مقدار دما، و در نهایت تبدیل رشته دما به یک عدد اعشاری با استفاده از double.Parse() باشد. دادههای پردازش شده نیز میتوانند در یک ساختار دادهای نظیر دیکشنری (Dictionary<string, Measurement>) ذخیره شوند، که در آن نام ایستگاه به عنوان کلید و یک شیء سفارشی (Measurement) حاوی اطلاعات مربوط به دما (مجموع، کمینه، بیشینه و تعداد) به عنوان مقدار در نظر گرفته میشود. قطعه کد زیر نمونهای از این رویکرد ساده را نشان میدهد:using var reader = new StreamReader(file.FullName);
var data = new Dictionary<string, Measurement>();
while (!reader.EndOfStream)
{
var stationParts = (await reader.ReadLineAsync())?.Split(';');
var measurement = data.TryGetValue(stationParts[0], out var m) ? m : new Measurement();
var value = double.Parse(stationParts[1]);
measurement.Sum += value;
measurement.Min = Math.Min(measurement.Min, value);
measurement.Max = Math.Max(measurement.Max, value);
measurement.Count++;
data[stationParts[0]] = measurement;
}ReadLineAsync() به طور ذاتی شامل تخصیص و مدیریت رشتهها است که میتواند در پردازش حجم عظیمی از دادهها پرهزینه باشد. به منظور رفع این محدودیت، یک راهکار مؤثر، کار کردن مستقیم با جریان بایت (Byte Stream) فایل ورودی است. در این روش، فایل به صورت بلوکهای بزرگتری از بایتها خوانده میشود و سپس با استفاده از نوع Span<byte>، که نمایشی بدون تخصیص حافظه از یک محدوده پیوسته از حافظه است، میتوان به طور مستقیم به بخشهای مختلف هر خط دسترسی پیدا کرد. این امر از تخصیص حافظه غیرضروری برای ایجاد رشتههای میانی جلوگیری میکند و کارایی را به طور قابل توجهی بهبود میبخشد. برای یافتن انتهای هر خط و جداکننده بین نام ایستگاه و دما، میتوان از متدهایی نظیر IndexOf() بر روی Span<byte> استفاده کرد. سپس، نام ایستگاه و مقدار دما میتوانند به طور مستقیم از این بازههای بایتی استخراج و در صورت نیاز، با استفاده از System.Text.Encoding.UTF8.GetString() به رشته تبدیل شوند. قطعه کد زیر نمونهای از این بهبود را نشان میدهد:Span<byte> buffer = new byte[1024 * 512];
var data = new Dictionary<string, Measurement>();
using var reader = file.OpenRead();
int bufferOffsetStart = 0;
while (reader.Read(buffer) is int numberRead)
{
if(numberRead == 0)
break;
if(numberRead < buffer.Length)
buffer = buffer[..numberRead];
if (buffer[bufferOffsetStart] == 239)
bufferOffsetStart+=3; //SKIP BOM
while(buffer.Slice(bufferOffsetStart).IndexOf((byte)'\n') is int newLineIndex and > -1)
{
var line = buffer.Slice(bufferOffsetStart, newLineIndex);
bufferOffsetStart += newLineIndex + 1;
var seperatorIndex = line.IndexOf((byte)';');
var name = System.Text.Encoding.UTF8.GetString(line[..seperatorIndex]);
var measurement = data.TryGetValue(name, out var m) ? m : new Measurement();
var value = double.Parse(System.Text.Encoding.UTF8.GetString(line[(seperatorIndex + 1)..]));
measurement.Sum += value;
measurement.Min = measurement.Min < value ? measurement.Min : value;
measurement.Max = measurement.Max > value ? measurement.Max : value;
measurement.Count++;
data[name] = measurement;
}
reader.Seek(bufferOffsetStart - buffer.Length, SeekOrigin.Current);
bufferOffsetStart = 0;
}double.Parse() نیز میتواند یک عملیات نسبتاً پرهزینه باشد، به ویژه هنگامی که میلیونها یا میلیاردها بار تکرار شود. با توجه به ساختار خاص دادههای دما در چالش یک میلیارد سطر (اعداد اعشاری با دقت یک رقم بعد از ممیز، مانند "12.3" یا "-5.0")، میتوان یک روش تجزیه سفارشی و بسیار سریعتر را پیادهسازی نمود. این روش شامل تفسیر مستقیم بایتهای مربوط به دما به عنوان یک عدد صحیح (Integer) است. با استفاده از این واقعیت که ارقام '0' تا '9'، علامت منفی '-' و ممیز '.' در جدول کدگذاری UTF-8 دارای کدهای متوالی هستند، میتوان با انجام عملیاتهای حسابی ساده بر روی این بایتها، مقدار دما را به صورت یک عدد صحیح (که در آن مقدار واقعی دما در 10 ضرب شده است) به دست آورد. برای مثال، رشته "-12.3" میتواند به عدد صحیح -123 تبدیل شود. سپس، در هنگام نمایش یا انجام محاسبات نهایی، این عدد صحیح بر 10 تقسیم میشود تا مقدار اعشاری اصلی به دست آید. این رویکرد به طور قابل توجهی سربار ناشی از تجزیه اعداد اعشاری پیچیده را کاهش میدهد. قطعه کد زیر نمونهای از این تجزیهگر سریع را نشان میدهد:public static class FastParser
{
public const byte sign = (byte)'-';
public const byte dot = (byte)'.';
public const byte digitOffset = (byte)'0';
public static int TempAsInt(ReadOnlySpan<byte> chunk)
{
bool negative = chunk[0] == sign;
int off = negative ? 1 : 0;
int temp = chunk[off++] - digitOffset;
if (chunk[off] != dot)
temp = 10 * temp + chunk[off++] - digitOffset;
off++; //Skip the '.'
temp = 10 * temp + chunk[off] - digitOffset;
return negative ? -temp : temp;
}
}double.Parse() میتواند به طور چشمگیری سرعت پردازش را افزایش دهد.Dictionary<string, Measurement>) برای نگهداری مقادیر تجمیعی مربوط به هر ایستگاه استفاده میشود. برای بهبود کارایی عملیاتهای مربوط به این دیکشنری، به ویژه در هنگام بهروزرسانی مقادیر، میتوان از متد CollectionsMarshal.GetValueRefOrAddDefault<TKey,TValue> استفاده کرد. این متد، در صورت وجود کلید مورد نظر در دیکشنری، یک ارجاع (Reference) مستقیم به مقدار متناظر با آن را برمیگرداند. اگر کلید وجود نداشته باشد، یک مقدار پیشفرض ایجاد کرده و ارجاع آن را برمیگرداند. این امر از انجام یک جستجوی ثانویه برای تنظیم مقدار پس از ایجاد یا بازیابی آن جلوگیری میکند و به ویژه در سناریوهایی که تعداد زیادی بهروزرسانی روی دیکشنری انجام میشود، میتواند کارایی را بهبود بخشد. قطعه کد زیر نحوه استفاده از این متد را نشان میدهد:ref var measurement = ref CollectionsMarshal.GetValueRefOrAddDefault(data, stationName, out bool exists);
if(!exists)
measurement = new Measurement(); // مقداردهی اولیه در صورت عدم وجود
var value = FastParser.TempAsInt(temperatureSpan);
measurement.Sum += value;
measurement.Min = Math.Min(measurement.Min, value);
measurement.Max = Math.Max(measurement.Max, value);
measurement.Count++;long، را بر اساس محتوای نام ایستگاه تولید کرد. یک روش ساده برای این کار، در نظر گرفتن چند کاراکتر اول نام ایستگاه به همراه طول آن به عنوان مبنایی برای تولید یک عدد منحصر به فرد است. اگرچه این روش ممکن است در حالت کلی تضمین کننده یکتا بودن کلیدها نباشد، اما با در نظر گرفتن محدودیتهای موجود در مجموعه داده چالش، میتواند به عنوان یک بهینهسازی مؤثر عمل کند و هزینه مقایسه و هش کردن رشتهها را کاهش دهد. قطعه کد زیر نمونهای از نحوه تولید چنین کلیدی را نشان میدهد:public static long GenerateKey(ReadOnlySpan<byte> chunk)
{
long key = chunk.Length << 64;
for (int i = 0, l = chunk.Length; i < l && i < 7; i++)
key += chunk[i] << (56 - i * 8);
return key;
}long به عنوان کلید در دیکشنری استفاده میشود.Parallel.ForEach در کتابخانه TPL (.NET Task Parallel Library) میتواند پیادهسازی پردازش موازی را تسهیل نماید و به برنامه اجازه دهد تا از تمام هستههای پردازشی موجود در سیستم به طور مؤثر استفاده کند. اگرچه کد دقیق پیادهسازی پردازش موازی در اینجا ارائه نشده است، اما مفهوم اصلی، تقسیم کار بین چندین هسته پردازنده برای تسریع کل فرآیند است.Span<byte> و روشهای تجزیه سفارشی، تا بهبود کارایی ساختارهای داده کلیدی مانند دیکشنری با استفاده از CollectionsMarshal و کلیدهای عددی، و در نهایت بهرهگیری از قدرت پردازش موازی، هر یک از این رویکردها میتواند نقش بسزایی در ارتقای کارایی برنامههای کاربردی .NET ایفا کند. درسهای آموخته شده از این چالش، نه تنها در حل مسائل مشابه با حجم بالای داده مفید هستند، بلکه میتوانند به عنوان اصولی راهنما در توسعه هر نوع برنامه کاربردی .NET که نیازمند عملکرد بالا و پاسخگویی سریع است، مورد استفاده قرار گیرند و به خلق نرمافزاری کارآمدتر و با کیفیتتر منجر شوند.