يحلل HotXLS، وهو مكتبة Excel الأصلية لدلفي و C++Builder، أوراق عمل XLSX على مؤشرات ترابط متعددة من خلال تحميل ثلاثي المراحل: يتم إلغاء ضغط XML للورقة بالتسلسل، وتحليله بالتوازي، وقراءة الأجزاء الصغيرة بالتسلسل بعد ذلك. ولم يحقق الإصدار الأول من هذه الميزة سوى تحسن بنسبة 12% إلى 25%، لأن قفل مدير الذاكرة الافتراضي في دلفي جعل مؤشرات ترابط العمال تتسلسل. وأدى خفض تخصيصات كومة الذاكرة المؤقتة (heap) من حوالي 20 إلى 9.1 لكل خلية إلى رفع التسريع المتوازي إلى 1.90 ضعفًا على ثمانية مؤشرات ترابط. ويستعرض هذا المقال القياسات، والمسارات الخاطئة، والحلين اللذين نجحا بالفعل
كيف يحلل HotXLS أوراق عمل XLSX بالتوازي؟
يقسم HotXLS الدالة Open إلى ثلاث مراحل، وتعمل المرحلة الوسطى فقط على مؤشرات ترابط العمال. والسبب هو حاوية zip: فأرشيف zip عبارة عن تدفق إدخال مشترك واحد مع آلة حالة تضخم (inflate) واحدة، ولا يمكن قراءة آلة الحالة هذه بواسطة مؤشري ترابط في نفس الوقت. ولفها في قفل سيكون غير مجدٍ، لأن التضخم متسلسل بطبيعته لكل إدخال، لذا فإن القفل سيعيد إنتاج التنفيذ المتسلسل مع عبء إضافي. وبناء على ذلك، تقوم المرحلة A بإلغاء ضغط XML الخاص بكل ورقة عمل في دفق TMemoryStream الخاص بها بينما لا تزال أحادية مؤشر الترابط؛ واستغرق هذا في ملف الاختبار الخاص بنا حوالي 4 مللي ثانية لثمانية أجزاء من الأوراق، لذا فهو ليس قريبًا من عنق الزجاجة على الإطلاق. وتعمل المرحلة B على تشغيل ParseWorksheetXml لكل ورقة على تجمع عمال، وهو المكان الذي يقضي فيه وقت التحميل بأكمله تقريبًا. وتعود المرحلة C إلى zip بالتسلسل للأجزاء الصغيرة: التعليقات، والرسومات، والمخططات، والجداول
تجمع العمال نفسه بسيط عمدًا. ويسحب العمال فهارس الوظائف من عداد مشترك باستخدام InterlockedIncrement، بحيث تتوازن الأوراق ذات الأحجام غير المتساوية بشكل طبيعي دون أي مجدول. ويكون عدد مؤشرات الترابط هو min(sheet count, CPU cores)، ويتم التقاط أول استثناء للعامل باستخدام AcquireExceptionObject وإعادة طرحه على مؤشر الترابط الرئيسي بعد الضم، ويتراجع الموزع إلى حلقة تسلسلية بسيطة عندما تكون هناك وظائف صفرية أو وظيفة واحدة. وتتحكم خاصيتان في TXLSXWorkbook في الميزة: حيث تقوم ParallelParse بفتح التجمع، وتحدد ParallelParseThreads سقفًا لعدد مؤشرات الترابط، حيث يعني الرقم 0 تلقائيًا. والمصنفات متعددة الأوراق هي الشكل الذي يستفيد، بما في ذلك النوع الذي تنتجه عن طريق نسخ ورقة عمل قالب لعشرات المرات
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.ParallelParse := True; // enable the parallel worker pool
Book.ParallelParseThreads := 0; // 0 = auto: min(sheets, CPU cores)
if Book.Open('quarterly-ledger.xlsx') <= 0 then
raise Exception.Create('open failed');
// ... read cells as usual; the workbook is fully materialized ...
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 مؤشرات ترابط منحنى مسطحًا، وفي عمليات التشغيل اللاحقة ذات الأدوات، كانت تهيئة مؤشري الترابط في الواقع أبطأ بنسبة 26% من التسلسلي، وهو التوقيع الكلاسيكي لمؤشري ترابط يتبادلان قفل متنازع عليه
ثبتت ثلاثة قياسات التشخيص، وأبطل كل منها الحدس السابق. أولاً، تم فتح ملف صغير (8 أوراق من صف واحد) في 1.2 مللي ثانية، مما يثبت أن التحليل يمثل بشكل أساسي 100% من دالة Open ولم يكن هناك تكلفة ثابتة خفية نلومها. ثانياً، أظهر اختبار قياس دقيق للاضطراب الخالص للتخصيص أن مدير ذاكرة دلفي يتوسع للخلف: فنفس الحجم الإجمالي البالغ 2 مليون تخصيص كائن وسلسلة AnsiString تم تشغيله أبطأ بنسبة 60% على 8 مؤشرات ترابط مقارنة بمؤشر ترابط واحد، في حين أن نفس الاضطراب ضد كومة WideString، وهي مخصص COM BSTR بدلاً من مدير ذاكرة دلفي، توسع بمقدار 3.7 ضعفًا. وتبين أن استخدام HotXLS لسلاسل WideString طوال الوقت كان مصادفة تاريخية تعمل لصالحنا. ثالثاً، أظهرت GetProcessTimes أنه أثناء دالة Open المتوازية، عادل وقت وحدة المعالجة CPU تقريبًا الوقت الحقيقي: حيث كانت ثمانية مؤشرات ترابط اسمية تستهلك حوالي 1.3 مؤشر ترابط من قيمة وحدة المعالجة المركزية. ولم يكن العمال يدورون؛ بل كانوا نائمين في مسار التنازع لمدير الذاكرة، محظورين بدلاً من كونهم مشغولين
يتعامل الدرس العملي إلى ما هو أبعد من جداول البيانات. فإذا كان عبء عمل دلفي يخصص بكثافة، فإن رفع عدد مؤشرات الترابط لا يفعل شيئًا حتى ينخفض معدل التخصيص، ويمكن أن يجعل الأمور أسوأ بسهولة. وقبل هذا الإصلاح، كنا نخبر المستخدمين الذين يضبطون ParallelParseThreads بالحقيقة الصادقة: في الملفات المقيدة بالتخصيص، لم تكن مؤشرات الترابط الإضافية تشتري أي شيء تقريبًا
من أين تأتي تخصيصات الكومة الـ 20 لكل خلية؟
أجاب غلاف عداد تم تثبيته مع SetMemoryManager على هذا السؤال بدقة: حوالي 20 تخصيصًا لمدير ذاكرة دلفي لكل خلية، مع وجود 2.87 مليون منها بحجم 32 بايت أو أقل. ولم يكن الجاني هو كائنات الخلايا على الإطلاق. فقد قامت الدالة TXMLScaner.GetTokenValue بتجسيد سلسلة AnsiString جديدة عند كل استدعاء، ويتم استدعاؤها حوالي 15 إلى 20 مرة لكل خلية: مرة واحدة لكل من أسماء العناصر، وأسماء السمات، وقيم السمات، ومحتوى النص. وعلاوة على ذلك، صنعت دالة RTL المسمى UTF8ToWideString وسيط UnicodeString مؤقتًا لكل تحويل. ومثلت كائنات الخلايا 160 ألف تخصيص فقط، أي حوالي 8% من الإجمالي، مما قتل خطتنا الأصلية على الفور: فقد كنا نعتزم بناء تجمع كائنات خلايا، وقالت الأرقام إنه لن يسدد تكلفته أبدًا
var
OldMM, NewMM: TMemoryManagerEx;
AllocCount, TinyCount: Int64;
function CountingGetMem(Size: NativeInt): Pointer;
begin
AtomicIncrement(AllocCount);
if Size <= 32 then
AtomicIncrement(TinyCount); // the small-object churn we care about
Result := OldMM.GetMem(Size);
end;
// install before Open, restore afterwards
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);
يستحق هذا التشخيص الذي يستغرق عشر دقائق السرقة لأي تحقيق في أداء دلفي. فعد التخصيصات حسب وعاء الحجم لا يكلف شيئًا تقريبًا في بنائه ويخبرك بمكان نشوء ضغط مدير الذاكرة فعليًا، والذي كان في حالتنا عبارة عن عادات على مستوى RTL داخل ماسح XML بدلاً من أي شيء في نموذج الكائن. واستمرت أدوات تحليل الأداء في الإشارة إلى المحلل ككل؛ وأشار الغلاف إلى سطرين محددين
الإصلاح: إيواء الرموز (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++ only, no allocation
else
begin
Result := GetTokenValue; // materialize once, then cache
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 صف ونفس معالج 6C12T، انتقل تحسن 8 مؤشرات ترابط من 14% إلى 47.4%، وهو تسريع بمقدار 1.90 ضعفًا مقارنة بالتسلسلي. وتأرجحت حالة مؤشري الترابط من 26% أبطأ إلى 23.6% أسرع، وارتفع استهلاك وحدة المعالجة CPU المقاس من 1.0 ضعف إلى 2.2 ضعف. وحصل المسار التسلسلي على سرعة أسرع بنسبة 3% تقريبًا كمكافأة، نظرًا لأن التخصيصات الأقل تساعد مؤشر ترابط واحدًا أيضًا. والتخصيصات المتبقية البالغة حوالي 9 لكل خلية هي تقريبًا نصف كائنات خلايا ونصف نمو حاوية مستهلك؛ وقمنا بقياسها، وقدرنا أن العوائد متناقصة، وتوقفنا، مع جاهزية غلاف مدير الذاكرة لإعادة أخذ العينات حسب موقع الاستدعاء إذا برر عبء عمل مستقبلي جولة أخرى
تستحق الحدود التصريح بها بنفس وضوح المكاسب. يقوم HotXLS بالتوازي على مستوى ورقة العمل، وبالتالي فإن المصنف الذي يمثل ورقة واحدة عملاقة يتم تحليله على مؤشر ترابط واحد بغض النظر عما تقوله ParallelParseThreads؛ وبالنسبة لهذا الشكل، فإن القارئ المباشر المتدفق هو الأداة الأفضل، لأنه يتجنب تجسيد المصنف على الإطلاق. والملفات التي يقضى وقتها في أجزاء المرحلة C، والرسومات والمخططات والتعليقات، ترى فائدة أقل لأن تلك المرحلة تظل متسلسلة بالتصميم. والملفات الصغيرة لا تستحق المعالجة بمؤشرات الترابط على الإطلاق، ولهذا السبب يقوم الموزع بتشغيل المسار التسلسلي بصمت لعدد الوظائف التافهة. ولم يختفِ سقف مدير الذاكرة، بل تراجع فحسب: فعند 9.1 تخصيص لكل خلية، لا يزال القفل العالمي يفرض ضريبة على العمال، ولهذا السبب تسفر ثمانية مؤشرات ترابط عن 1.90 ضعفًا بدلاً من 4 أضعاف. وللحصول على مجموعة الأدوات الأوسع لخفض أوقات التحميل والحفظ، بما في ذلك الأنماط، والتجمعات، واستدعاءات الصفوف الضخمة، راجع دليلنا حول أداء المصنفات الكبيرة في دلفي
يشحن التحليل المتوازي لـ XLSX، والخصائص ParallelParse و ParallelParseThreads، وقارئ XML النحيف التخصيص الموصوف هنا كأجزاء قياسية من مكون HotXLS لدلفي و Excel، والذي يقرأ ويكتب ملفات XLS و XLSX و ODS بشكل أصيل من دلفي و C++Builder ودون تدخل أتمتة Excel