مقال تقني

رسم صفحات PDF بالتوازي في Delphi دون نفاد الذاكرة

يرسم HotPDF صفحات PDF عديدة بشكل متزامن عبر نقطتي دخول: RenderLoadedPagesParallel، التي تُعيد مصفوفة من الصور النقطية، و RenderLoadedPagesParallelOrdered، التي تُسلّم كل صفحة إلى رد نداء بترتيب الإدخال بمجرد جاهزيتها. والصيغة المرتّبة هي التي تتوسّع فعليًا، لأنها لا تحتفظ أبدًا بأكثر من عدد محدود من الصور النقطية في الذاكرة بصرف النظر عن عدد الصفحات المطلوبة

هذا الفرق هو جوهر المقال بأكمله. فرسم 10,000 صفحة بدقة 300 نقطة في البوصة إلى مصفوفة يعني 10,000 صورة نقطية حيّة في آن واحد، وهذه ليست مشكلة إنتاجية بل انهيار بسبب نفاد الذاكرة. أما رسمها عبر رد نداء مرتّب بعمق طابور يبلغ اثنين فيعني صورتين نقطيتين حيّتين فقط، وتعمل المهمة بحيّز ثابت من الذاكرة مهما طال المستند

لماذا يحتاج الرسم المتوازي إلى مرحلتين؟

رسم صفحة PDF هو في الواقع مهمتان مختلفتان ترتديان اسمًا واحدًا. أولاً يُرمّز دفق المحتوى ويُترجم إلى قائمة عرض، وهذه عملية تعتمد على المعالج وتلامس ذاكرات تخزين مؤقت مشتركة. ثم تُعاد قائمة العرض إلى صورة نقطية، وهذه أيضًا عملية تعتمد على المعالج لكنها تخصّص الذاكرة بكثافة. ومعاملتهما كوحدة واحدة غير قابلة للتجزئة يفرض اختيارًا بين تسلسل النصف الذي يلامس ذاكرة التخزين المؤقت وتكرار العمل

يفصل HotPDF بينهما. فقفل ذاكرة تخزين قائمة العرض المؤقتة يحمي فقط محاسبة التكرار، وترتيب LRU، وعدادات الاستخدام، وقرارات القبول؛ أما الترجمة نفسها فتعمل خارج القفل. وبعد الترجمة، يستعلم العامل عن ذاكرة التخزين المؤقت مجددًا: فإذا نشر عامل آخر الصفحة نفسها في تلك الأثناء، يُطلق نسخته الخاصة ويأخذ عداد استخدام على المدخلة المنشورة. وهذا يُبقي العمل المكرر محدودًا ويمنع وجود مدخلتين للصفحة نفسها، دون تسلسل عملية الترجمة عبر الصفحات على الإطلاق

والأثر المقاس على عيّنة معقّدة من 32 صفحة هو الجزء الجدير بالتذكر. فقد انخفض الزمن الإجمالي على Win64 من 1,565 مللي ثانية بعامل واحد إلى 696 مللي ثانية بأربعة عمّال، أي بمعدل 2.25 مرة تقريبًا. وانخفض زمن ظهور أول نتيجة من 1,255 مللي ثانية إلى 63 مللي ثانية، أي بنحو 95 بالمئة، لأن العمّال يبدؤون في إعادة تشغيل قوائم عرض مكتملة بينما لا يزال آخرون في طور الترجمة، بدلاً من الانتظار عند حاجز ترجمة كامل

رسم تخطيطي لخط أنابيب الرسم المتوازي ذي المرحلتين في HotPDF في Delphi، حيث تغذّي مرحلة الترجمة ذاكرة تخزين مؤقتة مشتركة لقوائم العرض قبل إعادة تشغيلها كصورة نقطية
يترجم HotPDF كل صفحة إلى قائمة عرض مخزَّنة مؤقتًا ويعيد تشغيلها على أي عامل حر، بحيث يتداخل التشغيل مع الترجمة بدلاً من الانتظار خلف حاجز

التسليم المرتّب والفتحة المحجوزة

تضمن واجهة برمجة التطبيقات المرتّبة أن يرى رد النداء الخاص بك الصفحات بالترتيب الذي طلبته، بينما ينتهي العمّال بأي ترتيب ينتهون فيه. وتُنشر الصور النقطية المكتملة في فتحة مخرجات مفهرسة بموضع الإدخال، ويعمل رد النداء بشكل تسلسلي على الخيط المستدعي

وقاعدتان تجعلان هذا محدودًا لا مرتّبًا فحسب. فعدد طابور المخرجات يشمل الصور النقطية التي يستخدمها رد النداء حاليًا، بحيث لا يمكن لرد نداء بطيء أن يسمح للمنتجين بتجاوز عمق الطابور. كما أن الصفحات اللاحقة قد تشغل على الأكثر queueDepth - 1 فتحات، لأن فتحة واحدة محجوزة دائمًا للصفحة التالية المستحقة للتسليم. فبدون هذا الحجز يمكن لصفحة أولى بطيئة أن تُحجب بواسطة صفحات لاحقة منتهية تملأ الطابور، فيتجمد خط الأنابيب عند رأس الصف بينما يظل كل عامل خاملاً

رسم تخطيطي للتسليم المرتّب في HotPDF حيث تملأ نتائج العمّال غير المرتّبة فتحات المخرجات، وتُبقي فتحة محجوزة الصفحة التالية المستحقة متدفقة
تصل الصفحات المكتملة إلى فتحات مُعنونة بموضع الإدخال وتُستنزف عبر رد نداء تسلسلي بصورة نقطية مُستعارة، مع حجز فتحة واحدة بحيث لا يمكن أبدًا حجب الصفحة الأولى
uses
  HPDFDoc;

procedure TExportJob.PageReady(Sender: TObject; InputIndex,
  PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
  // الصورة النقطية مُستعارة: صالحة لهذا الاستدعاء فقط. استهلكها هنا
  // (اكتبها إلى القرص، رمّزها، احسب تجزئتها) ولا تحتفظ بالمرجع
  Bitmap.SaveToFile(Format('page-%.4d.bmp', [InputIndex + 1]));
  Cancel := FUserCancelled;
end;

procedure TExportJob.Run;
var
  Pdf: THotPDF;
  Pages: array of Integer;
  Info: THPDFParallelRenderPipelineInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('manual.pdf') <= 0 then
      Exit;
    SetLength(Pages, Pdf.LoadedPageCount);
    for I := 0 to High(Pages) do
      Pages[I] := I;

    Pdf.RenderLoadedPagesParallelOrdered(Pages, 200, 4, 2,
      PageReady, Info);

    Writeln(Format('workers=%d peak queued=%d peak bytes=%d',
      [Info.WorkerCount, Info.PeakQueuedPageCount, Info.PeakQueuedBytes]));
    Writeln(Format('first result=%d ms  total=%d ms  backpressure waits=%d',
      [Info.FirstResultMilliseconds, Info.TotalMilliseconds,
       Info.BackpressureWaitCount]));
  finally
    Pdf.Free;
  end;
end;

عقد "الصورة النقطية المُستعارة" هو سبب بقاء الحيّز الذاكري ثابتًا. فالمكتبة تُطلق الصورة النقطية بمجرد عودة رد النداء الخاص بك، بحيث تصل عيّنة من 10,000 صفحة بعمق طابور يبلغ 2 إلى ذروة صورتين نقطيتين فقط بالضبط، سواء على Win32 أو Win64. وإذا احتجت إلى الصورة النقطية بعد رد النداء، انسخها، وعندها تمتلك أنت الذاكرة والمحاسبة الخاصة بها

كيف تختار ميزانية الذاكرة عدد العمّال

المزيد من العمّال ليس دائمًا أسرع، وكثيرًا ما يكون قاتلاً. فستة عمّال يرسمون صفحات A3 بدقة 600 نقطة في البوصة يحتاجون إلى مئات الميغابايتات من الصور النقطية الحيّة بصرف النظر عن عدد الأنوية المتاحة، والجهاز الذي يملك ثمانية أنوية قد لا يملك هذا القدر من حيّز العناوين الحر

لذلك يُطبِّع HotPDF عدد العمّال المطلوب وفق حجم الطلب، وعدد الصفحات، وسقف صارم، ثم يقسّم الميزانية العامة المتاحة حاليًا على أكبر تقدير متحفظ لصفحة واحدة في الطلب. ويبقى عامل واحد على الأقل حيًّا دائمًا، بحيث تُرسم أي صفحة أكبر من المعتاد رغم ذلك، على انفراد. وفي القياس المرجعي، عملت مهمة من ست صفحات بدقة 600 نقطة في البوصة ضمن ميزانية 512 ميبيبايت بثلاثة عمّال بدلاً من ستة، بذروة حجز بلغت 504,583,296 بايت

يأخذ كل عامل حجز ذاكرة بعد المطالبة بصفحة وقبل الترجمة، ويغطي الحجز الترجمة والتشغيل معًا. فحجز حيّز حول مرحلة الترسيب النقطي فقط كان سيغفل قائمة العرض ومجموعة العمل الخاصة بدفق المحتوى، وهي في الصفحات الغنية بالرسوميات النصف الأكبر. ثم تختلف الواجهتان: فالصيغة القائمة على المصفوفة تُطلق حجزها بمجرد اكتمال الصورة النقطية، لأن المستدعي يملك النتيجة ولم يعد بإمكان المجدول محاسبتها، بينما تنقل الصيغة المرتّبة الحجز مع الصورة النقطية إلى فتحة المخرجات وتحتفظ به حتى يعود رد النداء. وهذا سبب إضافي لتفضيل الواجهة المرتّبة عندما تكون مجموعة المخرجات كبيرة

رسم تخطيطي لميزانية الذاكرة في HotPDF وهي تقسم 512 ميبيبايت على أكبر تقدير لصفحة واحدة لقبول ثلاثة عمّال بدلاً من ستة
تقبل الميزانية العمّال بناءً على أكبر تقدير لصفحة واحدة، ويغطي كل حجز الترجمة والتشغيل معًا، وتُبقي الواجهة المرتّبة الحجز حيًّا حتى يعود رد النداء

الإلغاء دون تجمد

ضبط Cancel داخل رد النداء يوقف العمل الجديد ويوقظ أي عامل ينتظر ضغطًا عكسيًا. وتُنهى الصفحات الموجودة بالفعل داخل الراسم وتُنظَّف بدلاً من التخلي عنها، ولا يُحتفظ إلا بأول تشخيص فشل، وهو ما يُطلَق بعد انضمام كل عامل

الترتيب هنا حاسم. فتُضبط حالة الإلغاء أولاً، ثم تُطلق حجوزات المخرجات غير المنشورة ثانيًا، ولا يتم الانضمام إلى العمّال إلا بعد ذلك. وعكس الخطوتين الأخيرتين يخلق حلقة مغلقة: عامل ينتظر القبول في ميزانية لن تُطلَق حتى ينتهي المستهلك، بينما ينتظر المستهلك خروج ذلك العامل

ويجب على التطبيقات التفاعلية أن تعرف أيضًا عن الاستباق في المقدمة. فالجلب المسبق للصفحات يعمل برموز إلغاء خاصة به، وتقوم استدعاءات الرسم في المقدمة بإلغاء عمّال الجلب المسبق والانضمام إليهم قبل تنفيذ عملها الخاص، بحيث لا تضيف الإنتاجية في الخلفية أبدًا أي زمن انتظار إلى الصفحة التي ينتظرها المستخدم. ويُتحقق من الإلغاء على فترات محدودة بـ 4 كيبيبايت من مسح البايتات أو 256 رمزًا، وإن كانت عملية ترميز ذرية أو استدعاء نظام تشغيل لا يمكن مقاطعته أثناء تنفيذه — فضمان الاستجابة يغطي الحلقات المملوكة للمكتبة، لا مفكِّكات الترميز التابعة لجهات خارجية. والأسلوب القائم على طابور الطلبات للرسم التفاعلي موصوف في الرسم في الخلفية بطابور طلبات

قراءة إحصاءات خط الأنابيب

يفصل THPDFParallelRenderPipelineInfo بين أرقام يسهل الخلط بينها. فمقارنة RequestedWorkerCount بـ WorkerCount و MemoryBudgetWorkerLimit تخبرك إن كانت الميزانية قد خفّضت مستوى التوازي لديك. ومقارنة CompiledPageCount بـ CacheHitPageCount تخبرك بمقدار ما وفّرته ذاكرة تخزين قائمة العرض المؤقتة. ويُظهر CompiledPagesAtFirstResult ما إذا كان خط الأنابيب قد تداخل فعليًا بين الترجمة والتشغيل أم تدهور إلى حاجز

وللضبط الدقيق، أكثر حقلين فائدة هما BackpressureWaitCount و MemorySchedulerWaitMilliseconds. فارتفاع انتظارات الضغط العكسي يعني أن رد النداء الخاص بك هو عنق الزجاجة، فإما تُسرّعه أو تزيد عمق الطابور إن سمحت الذاكرة بذلك. وارتفاع انتظارات المجدول يعني أن الميزانية هي عنق الزجاجة، فقلّل الدقة، أو قلّل التوازي، أو ارفع الميزانية. وزيادة عدد العمّال في كلتا الحالتين يجعل الإنتاجية أسوأ، وهذا هو الجزء غير البديهي في الأمر

نقطة انطلاق عملية للتصدير الدُفعي: توازٍ يساوي عدد الأنوية الفعلية ناقص واحد، وعمق طابور بين اثنين وثلاثة، ودقة مختارة وفق متطلب المخرجات الفعلي لا وفق العادة. ثم اقرأ الإحصاءات من مستند حقيقي وعدّل مرة واحدة، بدلاً من التخمين مرتين. وللمهام التي يكون فيها المستند نفسه أكبر من أن يُحفظ في الذاكرة، اجمع هذا مع نموذج الوصول التدفقي في واجهة الملفات المباشرة لسير عمل PDF الكبيرة

الرسم المتوازي والجلب المسبق وذاكرة تخزين قائمة العرض المؤقتة جميعها جزء من مكدس الرسم نفسه الخاص بـ Delphi و C++Builder؛ وتجد قائمة الميزات الكاملة على صفحة مكوّن HotPDF لـ PDF في Delphi