مقاله فنی

رندر موازی صفحات PDF در دلفی بدون OOM

HotPDF بسیاری از صفحات PDF را به‌طور همزمان از طریق دو نقطه ورود رندر می‌کند: RenderLoadedPagesParallel، که آرایه‌ای از بیت‌مپ‌ها را برمی‌گرداند، و RenderLoadedPagesParallelOrdered، که هر صفحه را به محض آماده شدن، به ترتیب ورودی به یک callback تحویل می‌دهد. فرم مرتب همان فرمی است که مقیاس‌پذیر است، زیرا صرف‌نظر از تعداد صفحاتی که درخواست کرده‌اید، هرگز بیش از یک تعداد محدود بیت‌مپ را در حافظه نگه نمی‌دارد

این تمایز کل مقاله است. رندر ۱۰٬۰۰۰ صفحه با ۳۰۰ DPI در یک آرایه یعنی ۱۰٬۰۰۰ بیت‌مپ زنده، که یک مسئله توان عملیاتی نیست بلکه یک کرش تمام‌شدن حافظه است. رندر آن‌ها از طریق یک callback مرتب با عمق صف دو، یعنی دو بیت‌مپ زنده، و کار در یک ردپای ثابت اجرا می‌شود، فارغ از این‌که سند چقدر طولانی باشد

چرا رندر موازی به دو مرحله نیاز دارد؟

نموداری از خط لوله رندر موازی دو مرحله‌ای HotPDF در دلفی که در آن یک مرحله کامپایل، یک کش لیست‌نمایش مشترک را پیش از پخش بیت‌مپ تغذیه می‌کند
HotPDF هر صفحه را در یک لیست‌نمایش کش‌شده کامپایل می‌کند و آن را روی هر کارگر آزادی پخش می‌کند، بنابراین پخش با کامپایل هم‌پوشانی دارد نه این‌که پشت یک مانع در صف بماند

رندر یک صفحه PDF دو کار متفاوت است که یک نام می‌پوشند. ابتدا جریان محتوا توکنیزه و به یک لیست نمایش کامپایل می‌شود، که محدود به CPU است و کش‌های مشترک را لمس می‌کند. سپس لیست نمایش در یک بیت‌مپ بازپخش می‌شود، که این هم محدود به CPU است اما تخصیص حافظه سنگینی دارد. رفتار با آن‌ها به‌عنوان یک واحد تفکیک‌ناپذیر، انتخاب میان سریال‌سازی نیمه‌ی لمس‌کننده کش و تکرار کار را تحمیل می‌کند

HotPDF آن‌ها را جدا می‌کند. قفل کش لیست نمایش فقط از حسابداری فرکانس، ترتیب LRU، شمار استفاده و تصمیم‌های پذیرش محافظت می‌کند؛ خودِ کامپایل بیرون از قفل اجرا می‌شود. پس از کامپایل، کارگر مجدداً از کش پرس‌وجو می‌کند: اگر کارگر دیگری در همین اثنا همان صفحه را منتشر کرده باشد، کارگر نسخه خودش را آزاد می‌کند و یک شمار استفاده روی ورودی منتشرشده می‌گیرد. این کار مانع محدود ماندن کار تکراری و جلوگیری از دو ورودی برای یک صفحه می‌شود، بدون این‌که هرگز کامپایل را در سراسر صفحات سریال‌سازی کند

اثر اندازه‌گیری‌شده روی یک نمونه پیچیده ۳۲ صفحه‌ای بخشی است که ارزش به‌خاطر سپردن دارد. زمان کل روی Win64 از ۱٬۵۶۵ میلی‌ثانیه با یک کارگر به ۶۹۶ میلی‌ثانیه با چهار کارگر کاهش یافت، تقریباً ۲٫۲۵ برابر. زمان تا اولین نتیجه از ۱٬۲۵۵ میلی‌ثانیه به ۶۳ میلی‌ثانیه کاهش یافت، حدود ۹۵ درصد، زیرا کارگران شروع به بازپخش لیست‌های نمایش تمام‌شده می‌کنند در حالی که دیگران هنوز در حال کامپایل هستند، به‌جای انتظار در یک سد کامپایل کامل

تحویل مرتب و اسلات رزرو‌شده

نموداری از تحویل مرتب در HotPDF که در آن نتایج نامرتب کارگرها اسلات‌های خروجی را پر می‌کنند و یک اسلات رزروشده جریان صفحه بعدیِ سررسیده را حفظ می‌کند
صفحات تمام‌شده در اسلات‌هایی می‌نشینند که با موقعیت ورودی آدرس‌دهی شده‌اند و از طریق یک callback بیت‌مپ-قرضی سریال تخلیه می‌شوند، با یک اسلات رزروشده تا صفحه ابتدایی هرگز از دسترس خارج نشود

API مرتب تضمین می‌کند که callback شما صفحات را به همان ترتیبی که درخواست کرده‌اید می‌بیند، در حالی که کارگران به هر ترتیبی که تمام می‌شوند، تمام می‌کنند. بیت‌مپ‌های تکمیل‌شده در یک اسلات خروجی که با موقعیت ورودی اندیس‌گذاری شده منتشر می‌شوند، و callback به‌صورت سریال روی نخ فراخوان اجرا می‌شود

دو قاعده آن را محدود می‌کند نه صرفاً مرتب. شمار صف خروجی شامل بیت‌مپ‌هایی می‌شود که در حال حاضر توسط callback استفاده می‌شوند، بنابراین یک callback کند نمی‌تواند اجازه دهد تولیدکنندگان از عمق صف عبور کنند. و صفحات بعدی حداکثر می‌توانند queueDepth - 1 اسلات را اشغال کنند، زیرا یک اسلات همیشه برای صفحه بعدی که موعد تحویلش رسیده رزرو شده است. بدون آن رزرو، یک صفحه اول کند می‌تواند توسط صفحات بعدیِ تمام‌شده که صف را پر می‌کنند، مسدود شود، و خط لوله در سر صف با هر کارگر بیکار، بن‌بست می‌کند

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;

قرارداد بیت‌مپ قرضی دلیلی است که ردپا صاف می‌ماند. کتابخانه به محض بازگشت callback شما، بیت‌مپ را آزاد می‌کند، بنابراین یک نمونه ۱۰٬۰۰۰ صفحه‌ای با عمق صف ۲ روی هر دوی Win32 و Win64 دقیقاً در دو بیت‌مپ به اوج می‌رسد. اگر پس از callback به بیت‌مپ نیاز دارید، آن را کپی کنید، و در آن صورت شما مالک حافظه و حسابداری آن هستید

بودجه حافظه چگونه یک تعداد کارگر انتخاب می‌کند

نموداری از بودجه حافظه HotPDF که ۵۱۲ مگابایت را بر بزرگ‌ترین برآورد صفحه تقسیم می‌کند تا سه کارگر را به‌جای شش کارگر بپذیرد
بودجه کارگرها را بر اساس بزرگ‌ترین برآورد تک‌صفحه‌ای می‌پذیرد، هر رزرو کامپایل و پخش را در بر می‌گیرد، و API مرتب رزرو را تا بازگشت callback زنده نگه می‌دارد

کارگران بیشتر همیشه سریع‌تر نیست و اغلب کشنده است. شش کارگر که صفحات A3 را با ۶۰۰ DPI رندر می‌کنند، فارغ از تعداد هسته‌های موجود، به چند صد مگابایت بیت‌مپ زنده نیاز دارند، و ماشینی که هشت هسته دارد ممکن است آن مقدار فضای آدرس آزاد را نداشته باشد

بنابراین HotPDF تعداد کارگر درخواستی را در برابر اندازه درخواست، تعداد صفحات و یک سقف سخت هنجار می‌کند، سپس بودجه سراسری در دسترس فعلی را بر بزرگ‌ترین برآورد محافظه‌کارانه تک‌صفحه‌ای در درخواست تقسیم می‌کند. حداقل یک کارگر همیشه باقی می‌ماند، بنابراین یک صفحه بیش‌ازحد بزرگ همچنان، به‌طور انحصاری، رندر می‌شود. در اندازه‌گیری مرجع، یک کار شش‌صفحه‌ای ۶۰۰ DPI تحت یک بودجه ۵۱۲ مگابایتی با سه کارگر به‌جای شش کارگر اجرا شد، با اوج رزرو ۵۰۴٬۵۸۳٬۲۹۶ بایت

هر کارگر پس از ادعای یک صفحه و پیش از کامپایل، یک رزرو حافظه می‌گیرد، و رزرو کامپایل و بازپخش را با هم پوشش می‌دهد. رزرو کردن فقط اطراف مرحله رستر، لیست نمایش و مجموعه کاری جریان محتوا را از قلم می‌انداخت، که در صفحات پرگرافیک نیمه بزرگ‌تر است. دو API سپس تفاوت می‌کنند: فرم آرایه‌ای به محض تکمیل بیت‌مپ رزرو خود را آزاد می‌کند، زیرا فراخوان مالک نتیجه است و زمان‌بند دیگر نمی‌تواند برای آن حسابداری کند، در حالی که فرم مرتب رزرو را همراه با بیت‌مپ به اسلات خروجی منتقل می‌کند و آن را تا بازگشت callback نگه می‌دارد. این دلیل دیگری برای ترجیح API مرتب زمانی است که مجموعه خروجی بزرگ است

لغو بدون بن‌بست

تنظیم Cancel در callback، کار جدید را متوقف می‌کند و هر کارگری را که منتظر فشار برگشتی است بیدار می‌کند. صفحاتی که از قبل درون رندرکننده هستند، تمام می‌شوند و به‌جای رهاشدن پاک‌سازی می‌شوند، و فقط اولین تشخیص شکست نگه‌داشته می‌شود، که پس از پیوستن هر کارگر مطرح می‌شود

ترتیب در اینجا حیاتی است. حالت لغو ابتدا تنظیم می‌شود، رزروهای خروجی منتشرنشده دوم آزاد می‌شوند، و فقط پس از آن کارگران پیوسته می‌شوند. برعکس کردن دو گام آخر یک حلقه بسته ایجاد می‌کند: یک کارگر منتظر پذیرش به یک بودجه‌ای می‌ماند که تا پایان کار مصرف‌کننده آزاد نمی‌شود، در حالی که مصرف‌کننده منتظر خروج آن کارگر است

برنامه‌های تعاملی همچنین باید از پیش‌دستی پیش‌زمینه آگاه باشند. پیش‌واکشی صفحه روی توکن‌های لغو خودش اجرا می‌شود و فراخوانی‌های رندر پیش‌زمینه، کارگران پیش‌واکشی را پیش از انجام کار خودشان لغو و به آن‌ها می‌پیوندند، بنابراین توان عملیاتی پس‌زمینه هرگز به تأخیر صفحه‌ای که کاربر منتظر آن است اضافه نمی‌کند. لغو در فواصلی که به ۴ کیلوبایت پویش بایت یا ۲۵۶ توکن محدود شده بررسی می‌شود، هرچند یک کدک اتمیک یا فراخوانی سیستم‌عامل را نمی‌توان در میانه پرواز قطع کرد — تضمین پاسخگویی حلقه‌های متعلق به کتابخانه را پوشش می‌دهد، نه رمزگشاهای شخص ثالث را. رویکرد مبتنی بر صف برای رندر تعاملی در رندر پس‌زمینه با صف درخواست شرح داده شده است

خواندن آمار خط لوله

THPDFParallelRenderPipelineInfo اعدادی را که به‌راحتی با هم اشتباه گرفته می‌شوند از هم جدا می‌کند. RequestedWorkerCount در برابر WorkerCount و MemoryBudgetWorkerLimit به شما می‌گوید که آیا بودجه همزمانی شما را کاهش داده است. CompiledPageCount در برابر CacheHitPageCount به شما می‌گوید که کش لیست نمایش چقدر صرفه‌جویی کرده است. CompiledPagesAtFirstResult نشان می‌دهد که آیا خط لوله واقعاً کامپایل و بازپخش را همپوشانی کرده یا به یک سد تنزل کرده است

برای تنظیم دقیق، دو مفیدترین مورد BackpressureWaitCount و MemorySchedulerWaitMilliseconds هستند. انتظارهای فشار برگشتی بالا به این معناست که callback شما گلوگاه است، بنابراین آن را سریع‌تر کنید یا اگر حافظه اجازه می‌دهد عمق صف را بالا ببرید. انتظارهای زمان‌بند بالا به این معناست که بودجه گلوگاه است، بنابراین DPI را کاهش دهید، همزمانی را کاهش دهید، یا بودجه را بالا ببرید. بالا بردن تعداد کارگر در هر دو حالت توان عملیاتی را بدتر می‌کند، که بخش غیرشهودی ماجراست

یک نقطه شروع عملی برای صادرات دسته‌ای: همزمانی برابر با هسته‌های فیزیکی منهای یک، عمق صف دو یا سه، و DPI انتخاب‌شده از نیاز واقعی خروجی نه از عادت. سپس آمار را از یک سند واقعی بخوانید و یک‌بار تنظیم کنید، به‌جای این‌که دو بار حدس بزنید. برای کارهایی که خودِ سند برای نگه‌داشتن در حافظه بیش‌ازحد بزرگ است، این را با مدل دسترسی جریانی در API فایل مستقیم برای گردش‌کارهای PDF بزرگ ترکیب کنید

رندر موازی، پیش‌واکشی و کش لیست نمایش همگی بخشی از همان پشته رندر برای Delphi و C++Builder هستند؛ فهرست کامل ویژگی‌ها در صفحه مؤلفه PDF دلفی HotPDF موجود است