مقاله فنی

تجزیه موازی XLSX در دلفی: گلگاه مدیر حافظه

کتابخانه بومی اکسل برای دلفی و C++Builder یعنی HotXLS، برگه‌های XLSX را روی چندین نخ (thread) از طریق یک بارگذاری سه مرحله‌ای تجزیه می‌کند: XML برگه به صورت متوالی فشرده‌سازی‌زدایی می‌شود، به صورت موازی تجزیه می‌گردد و بخش‌های کوچک پس از آن به صورت متوالی خوانده می‌شوند. اولین انتشار این ویژگی تنها 12 تا 25 درصد بهبود داشت، زیرا قفل پیش‌فرض مدیر حافظه دلفی اجرای نخ‌های کارگر را متوالی می‌کرد. کاهش تخصیص‌های حافظه پویا (heap allocations) از حدود 20 به 9.1 برای هر سلول، سرعت موازی را در هشت نخ به 1.90 برابر رساند. این مقاله اندازه‌گیری‌ها، مسیرهای اشتباه و دو اصلاحی را که واقعاً کارساز بودند بررسی می‌کند

خود استخر کارگر عمداً ساده طراحی شده است. کارگران اندیس‌های وظیفه را از یک شمارنده مشترک با InterlockedIncrement دریافت می‌کنند، بنابراین برگه‌های با اندازه نابرابر به طور طبیعی بدون نیاز به هیچ زمان‌بندی متعادل می‌شوند. تعداد نخ‌ها برابر با min(sheet count, CPU cores) است، اولین استثنای کارگر با AcquireExceptionObject ضبط شده و پس از پیوستن دوباره روی نخ اصلی ایجاد می‌شود، و توزیع‌کننده در صورت وجود صفر یا یک کار به یک حلقه متوالی ساده تنزل می‌یابد. دو ویژگی در TXLSXWorkbook این ویژگی را کنترل می‌کنند: ParallelParse دروازه استخر است و ParallelParseThreads تعداد نخ‌ها را محدود می‌کند که مقدار 0 به معنای خودکار است. کتاب‌های کار چندبرگه‌ای از این ویژگی بهره‌مند می‌شوند، از جمله نوعی که با شبیه‌سازی یک برگه‌کار قالب به تعداد ده‌ها بار تولید می‌کنید

چگونه HotXLS برگه‌های XLSX را به صورت موازی تجزیه می‌کند؟

کامپوننت HotXLS متد Open را به سه مرحله تقسیم می‌کند و تنها مرحله میانی روی نخ‌های کارگر اجرا می‌شود. دلیل این امر کانتینر zip است: یک آرشیو zip یک جریان ورودی مشترک با یک ماشین حالت inflate است و آن ماشین حالت نمی‌تواند توسط دو نخ به طور همزمان خوانده شود. قرار دادن آن در یک قفل بی‌فایده خواهد بود، زیرا فرآیند inflate ذاتاً برای هر ورودی متوالی است، بنابراین یک قفل فقط اجرای متوالی را با هزینه اضافی بازتولید می‌کند. بنابراین مرحله A فایل XML هر برگه را در یک TMemoryStream مخصوص به خود در حالی که هنوز تک‌نخی است، فشرده‌سازی‌زدایی می‌کند؛ در فایل بنچمارک ما این کار برای هشت بخش برگه حدود 4 میلی‌ثانیه طول کشید، بنابراین اصلاً به گلگاه نزدیک نیست. مرحله B متد ParseWorksheetXml را برای هر برگه در یک استخر کارگر (worker pool) اجرا می‌کند، که تقریباً تمام زمان بارگذاری در آنجا قرار دارد. مرحله C برای بخش‌های کوچک به صورت متوالی به zip بازمی‌گردد: کامنت‌ها، ترسیم‌ها، نمودارها و جدول‌ها

خود استخر کارگر عمداً ساده طراحی شده است. کارگران اندیس‌های وظیفه را از یک شمارنده مشترک با InterlockedIncrement دریافت می‌کنند، بنابراین برگه‌های با اندازه نابرابر به طور طبیعی بدون نیاز به هیچ زمان‌بندی متعادل می‌شوند. تعداد نخ‌ها برابر با min(sheet count, CPU cores) است، اولین استثنای کارگر با AcquireExceptionObject ضبط شده و پس از پیوستن دوباره روی نخ اصلی ایجاد می‌شود، و توزیع‌کننده در صورت وجود صفر یا یک کار به یک حلقه متوالی ساده تنزل می‌یابد. دو ویژگی در TXLSXWorkbook این ویژگی را کنترل می‌کنند: ParallelParse دروازه استخر است و ParallelParseThreads تعداد نخ‌ها را محدود می‌کند که مقدار 0 به معنای خودکار است. کتاب‌های کار چندبرگه‌ای از این ویژگی بهره‌مند می‌شوند، از جمله نوعی که با شبیه‌سازی یک برگه‌کار قالب به تعداد ده‌ها بار تولید می‌کنید

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.ParallelParse := True;      // فعال‌سازی استخر کارگر موازی
    Book.ParallelParseThreads := 0;  // 0 = خودکار: حداقل تعداد برگه‌ها و هسته‌های پردازنده
    if Book.Open('quarterly-ledger.xlsx') <= 0 then
      raise Exception.Create('open failed');
    // ... خواندن سلول‌ها مانند همیشه؛ کتاب کار کاملاً مجسم شده است ...
  finally
    Book.Free;
  end;
end;

چرا افزودن نخ‌ها تجزیه XLSX را در دلفی کندتر می‌کند؟

زیرا مدیر حافظه پیش‌فرض دلفی از حافظه پویای خود با یک قفل سراسری محافظت می‌کند، و تجزیه برگه‌کار پر از تخصیص حافظه است: سلول‌ها، متغیرها (Variants) و WideStringها به تعداد میلیون‌ها عدد. هر کارگری که با حافظه پویا کار می‌کند در صف آن قفل قرار می‌گیرد، بنابراین نخ‌هایی که در کد منبع مستقل به نظر می‌رسند، در عمل تقریباً یکی‌یکی اجرا می‌شوند. اولین بنچمارک ما این موضوع را به شکل دردناکی ملموس کرد. در یک کتاب کار 8 برگه‌ای با 5000 سطر در 4 ستون برای هر برگه, که روی یک پردازنده i5-11600K (شامل 6 هسته، 12 نخ) تحت سیستم‌عامل Win64 اندازه‌گیری شد، متد موازی Open تنها 12 تا 25 درصد نسبت به تخمین برنامه حداقل 40 درصدی بهبود یافت. بررسی تعداد نخ‌ها در 2، 3، 4، 6 و 8 نخ یک منحنی صاف ایجاد کرد و در اجراهای ابزارگذاری‌شده بعدی، پیکربندی 2 نخی در واقع 26 درصد کندتر از حالت متوالی بود، که نشانه کلاسیک رفت و برگشت دو نخ روی یک قفل پرتداخل است

سه اندازه‌گیری تشخیص را تثبیت کرد و هر یک از آن‌ها تصورات قبلی را دگرگون نمود. اول اینکه، یک فایل بسیار کوچک (8 برگه با 1 سطر) در 1.2 میلی‌ثانیه باز شد، که ثابت کرد تجزیه اساساً 100 درصد متد Open را تشکیل می‌دهد و هیچ هزینه ثابت پنهانی برای سرزنش وجود نداشت. دوم، یک میکروبنچمارک از شلوغی خالص تخصیص حافظه نشان داد که مدیر حافظه دلفی برعکس مقیاس‌گذاری می‌شود: حجم کل یکسانی از 2 میلیون تخصیص شیء و AnsiString روی 8 نخ 60 درصد کندتر از یک نخ اجرا شد، در حالی که همان حجم در برابر حافظه WideString، که تخصیص‌دهنده COM BSTR است به جای مدیر حافظه دلفی، به 3.7 برابر مقیاس‌گذاری شد. اینکه HotXLS در سرتاسر خود از WideString استفاده می‌کند، یک تصادف تاریخی بود که به نفع ما کار کرد. سوم، متد GetProcessTimes نشان داد که در طول یک Open موازی، زمان پردازنده تقریباً با زمان واقعی (wall time) برابر بود: هشت نخ اسمی در حال مصرف حدود 1.3 نخ پردازنده بودند. کارگران در حال چرخیدن نبودند؛ بلکه در مسیر تداخل مدیر حافظه خوابیده بودند، یعنی مسدود شده بودند و نه مشغول

درس عملی فراتر از صفحه‌گسترده‌ها تعمیم می‌یابد. اگر یک بار کاری دلفی به شدت تخصیص حافظه انجام دهد، افزایش تعداد نخ‌ها تا زمانی که نرخ تخصیص کاهش نیابد هیچ کاری انجام نمی‌دهد و به راحتی می‌تواند اوضاع را بدتر کند. قبل از این اصلاح، ما به کاربرانی که ParallelParseThreads را تنظیم می‌کردند واقعیت صادقانه را می‌گفتیم: در فایل‌های محدود به تخصیص حافظه، نخ‌های بیشتر تقریباً هیچ چیز را تغییر نمی‌داد

20 تخصیص حافظه پویا برای هر سلول از کجا می‌آیند؟

یک بسته‌بند شمارش نصب‌شده با SetMemoryManager به آن سوال دقیقاً پاسخ داد: حدود 20 تخصیص مدیر حافظه دلفی برای هر سلول، که 2.87 میلیون از آن‌ها 32 بایت یا کمتر بودند. مقصر اصلاً اشیاء سلول نبودند. متد TXMLScaner.GetTokenValue در هر فراخوانی یک AnsiString جدید ایجاد می‌کرد، و این متد تقریباً 15 تا 20 بار برای هر سلول فراخوانی می‌شود: یک بار برای نام عناصر، نام ویژگی‌ها، مقادیر ویژگی‌ها و محتوای متنی. علاوه بر آن، مسیر UTF8ToWideString در کتابخانه زمان اجرا (RTL) یک متغیر موقت واسط UnicodeString برای هر تبدیل تولید می‌کرد. اشیاء سلول تنها 160 هزار تخصیص را شامل می‌شدند، یعنی حدود 8 درصد از کل، که برنامه اصلی ما را درجا خراب کرد: ما قصد داشتیم یک استخر شیء سلول بسازیم، اما اعداد گفتند که هرگز هزینه‌های خود را جبران نخواهد کرد

var
  OldMM, NewMM: TMemoryManagerEx;
  AllocCount, TinyCount: Int64;

function CountingGetMem(Size: NativeInt): Pointer;
begin
  AtomicIncrement(AllocCount);
  if Size <= 32 then
    AtomicIncrement(TinyCount);   // شلوغی اشیاء کوچک که برای ما مهم است
  Result := OldMM.GetMem(Size);
end;

// قبل از Open نصب کنید، پس از آن بازیابی نمایید
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);

اصلاح: کارآموزی توکن (token interning) و یک رمزگشای UTF-8 بدون متغیر واسط

دو تغییر هدفمند در خواننده XML بیش از نیمی از تخصیص‌های هر سلول را بدون دست زدن به ساختار تجزیه‌کننده حذف کرد. اولین مورد، کارآموزی نام عنصر (element-name interning) است. فایل XML برگه‌کار یک واژگان کوچک را بی‌پایان تکرار می‌کند: row، c، v، r، t، s و تعدادی نام ویژگی. متد InternTokenName یک کش 64 خانه‌ای از نام‌های قبلاً دیده شده را نگه می‌دارد و بافر اسکنر را با یک ورودی کش‌شده با TokenEqualsAnsi، که یک مقایسه بایت مستقیم بدون تخصیص حافظه است. در صورت تطابق، AnsiString کش‌شده را برمی‌گرداند، و در اینجا انتخاب نوع داده مهم است: AnsiString شمارش مرجع دارد، بنابراین بازگرداندن یک نمونه کش‌شده هزینه یک افزایش شمارش مرجع و صفر ترافیک حافظه پویا دارد. WideString شمارش مرجع ندارد و هر تخصیصی از طریق SysAllocString انجام می‌شود، بنابراین کارآموزی WideStringها چیزی را ذخیره نخواهد کرد. کارآموزی فقط روی نوع رشته با شمارش مرجع ارزش انجام دارد

function TXMLScaner.InternTokenName: AnsiString;
var
  Slot: Integer;
begin
  Slot := TokenHash mod 64;
  if TokenEqualsAnsi(FInternNames[Slot]) then
    Result := FInternNames[Slot]    // فقط افزایش refcount، بدون تخصیص حافظه
  else
  begin
    Result := GetTokenValue;        // یک بار ایجاد و سپس کش کنید
    FInternNames[Slot] := Result;
  end;
end;

تغییر دوم به متن سلول حمله می‌کند. مسیر قدیمی یک توکن AnsiString می‌ساخت، آن را به UTF8ToWideString تحویل می‌داد، که یک متغیر واسط UnicodeString می‌ساخت، که در نهایت به WideString ذخیره‌شده در سلول تبدیل می‌شد: دو تخصیص مدیر حافظه دلفی برای هر توکن متنی قبل از توکن واقعی. جایگزین آن، یعنی XmlUtf8ToWide(TokenPtr, TokenLen), یک رمزگشای UTF-8 دو مرحله‌ای خالص پاسکال است که مستقیماً از بافر اسکن می‌خواند: مرحله اول طول UTF-16 را اندازه‌گیری می‌کند، مرحله دوم به یک WideString که یک بار تخصیص یافته رمزگشایی می‌نماید. هزینه خالص برای هر توکن متنی: یک تخصیص COM، صفر تخصیص مدیر حافظه دلفی. یک نکته معنایی برای افراد محتاط: در دنباله‌های UTF-8 خراب، رمزگشای جدید به جای جایگزین کردن نویسه‌ها به روش RTL، بایت‌ها را عبور می‌دهد، که این موضوع فقط بر نحوه خراب شدن فایل‌های آسیب‌دیده تاثیر می‌گذارد؛ در ورودی معتبر، خروجی بایت‌به‌بایت یکسان است. نهادهای نویسه XML هرگز به رمزگشا نمی‌رسند، زیرا اسکنر از قبل آن‌ها را در بافر توکن به UTF-8 حل کرده است

چه چیزی به دست آورد، و کجا تجزیه موازی هنوز کمکی نخواهد کرد

دو اصلاح انجام شده تخصیص‌ها را از حدود 20 به 9.1 برای هر سلول کاهش داد و اعداد موازی همان‌طور که تئوری می‌گفت تغییر کردند. در همان بنچمارک 8 برگه‌ای با 5000 سطر و همان دستگاه شامل 6 هسته و 12 نخ، بهبود 8 نخی از 14 درصد به 47.4 درصد رسید، یعنی سرعت 1.90 برابر نسبت به حالت متوالی. حالت 2 نخی از 26 درصد کندتر به 23.6 درصد سریع‌تر تغییر کرد، و میزان مصرف پردازنده اندازه‌گیری‌شده از 1.0 به 2.2 برابر افزایش یافت. مسیر متوالی نیز به عنوان یک امتیاز حدود 3 درصد سریع‌تر شد، زیرا تخصیص‌های کمتر به یک نخ نیز کمک می‌کند. حدود 9 تخصیص باقی‌مانده برای هر سلول تقریباً نصف اشیاء سلول و نصف رشد کانتینر است؛ ما آن‌ها را اندازه‌گیری کردیم، بازدهی را کاهشی ارزیابی نمودیم و متوقف شدیم، در حالی که بسته‌بند مدیر حافظه آماده است تا در صورت نیاز بارهای کاری آینده، دوباره از محل فراخوانی نمونه‌برداری کند

ارزش دارد مرزها را به وضوح بردها بیان کنیم. HotXLS در سطح دانه‌بندی برگه‌کار موازی‌سازی می‌کند، بنابراین کتاب کاری که شامل یک برگه غول‌پیکر است، بدون توجه به اینکه ParallelParseThreads چه می‌گوید، روی یک نخ تجزیه می‌شود؛ برای این ساختار، خواننده جریانی مستقیم ابزار بهتری است، زیرا از مجسم کردن کتاب کار در حافظه به طور کامل جلوگیری می‌کند. فایل‌هایی که زمان آن‌ها در بخش‌های مرحله C (ترسیم‌ها، نمودارها و کامنت‌ها) صرف می‌شود، سود کمتری می‌برند زیرا آن مرحله بر اساس طراحی متوالی باقی می‌ماند. فایل‌های کوچک اصلاً ارزش نخی شدن ندارند، به همین دلیل است که توزیع‌کننده برای تعداد وظایف ناچیز، به آرامی به صورت متوالی اجرا می‌شود. و سقف مدیر حافظه از بین نرفته، بلکه فقط عقب‌نشینی کرده است: در 9.1 تخصیص برای هر سلول، قفل سراسری همچنان بر کارگران هزینه تحمیل می‌کند، به همین دلیل است که هشت نخ بازدهی 1.90 برابری دارند و نه 4 برابری. برای ابزارهای گسترده‌تر جهت کاهش زمان بارگذاری و ذخیره، از جمله سبک‌ها، استخرها و فراخوانی‌های دسته‌ای سطر، به راهنمای ما در مورد عملکرد کتاب کار بزرگ در دلفی مراجعه کنید

تجزیه موازی XLSX، ویژگی‌های ParallelParse و ParallelParseThreads، و خواننده XML بهینه‌شده برای تخصیص حافظه که در اینجا توضیح داده شد، به عنوان بخش‌های استاندارد کامپوننت اکسل دلفی HotXLS ارائه می‌شوند که فایل‌های XLS، XLSX و ODS را به طور بومی از دلفی و C++Builder بدون نیاز به اتوماسیون اکسل می‌خواند و می‌نویسد