Техническа статия

Паралелно рендиране на PDF страници в Delphi без OOM

HotPDF рендира много PDF страници едновременно чрез две входни точки: RenderLoadedPagesParallel, която връща масив от растерни изображения, и RenderLoadedPagesParallelOrdered, която доставя всяка страница на обратно извикване във входния ред веднага щом е готова. Подредената форма е тази, която мащабира, защото никога не задържа в паметта повече от ограничен брой растерни изображения, независимо колко страници сте заявили

Тази разлика е цялата статия. Рендирането на 10 000 страници при 300 DPI в масив означава 10 000 живи растерни изображения, което не е проблем с пропускателната способност, а срив поради недостиг на памет. Рендирането им чрез подредено обратно извикване с дълбочина на опашката две означава две живи растерни изображения, и задачата се изпълнява с фиксиран отпечатък, независимо колко дълъг е документът

Защо паралелното рендиране се нуждае от два етапа?

Рендирането на PDF страница са две различни задачи с едно име. Първо потокът от съдържание се токенизира и компилира в списък за визуализация, което е обвързано с процесора и докосва споделени кешове. После списъкът за визуализация се възпроизвежда в растерно изображение, което също е обвързано с процесора, но заделя памет интензивно. Третирането им като една неделима единица налага избор между сериализиране на половината, докосваща кеша, и дублиране на работа

HotPDF ги разделя. Заключването на кеша за списъка за визуализация защитава само отчитането на честотата, LRU реда, броячите на употреба и решенията за допускане; самото компилиране се изпълнява извън заключването. След компилирането работникът отново проверява кеша: ако друг работник е публикувал същата страница междувременно, той освобождава собственото си копие и взема брояч на употреба върху публикувания запис. Това ограничава дублираната работа и предотвратява два записа за една и съща страница, без изобщо да сериализира компилирането между страниците

Измереният ефект върху комплексна тестова извадка от 32 страници е частта, която си струва да се запомни. Общото време на Win64 спадна от 1565 ms с един работник до 696 ms с четирима, приблизително 2,25 пъти. Времето до първия резултат спадна от 1255 ms до 63 ms, около 95 процента, защото работниците започват да възпроизвеждат готови списъци за визуализация, докато други все още компилират, вместо да чакат на бариера за пълно компилиране

Диаграма на двуетапния паралелен конвейер за рендиране на HotPDF в Delphi, при който етап на компилиране подава споделен кеш за списъци за визуализация преди възпроизвеждането в растерно изображение
HotPDF компилира всяка страница в кеширан списък за визуализация и я възпроизвежда на всеки свободен работник, така че възпроизвеждането се застъпва с компилирането, вместо да се нарежда зад бариера

Подредена доставка и запазеният слот

Подреденото API гарантира, че обратното ви извикване вижда страниците в реда, в който сте ги заявили, докато работниците завършват в произволен ред. Завършените растерни изображения се публикуват в изходен слот, индексиран по входна позиция, а обратното извикване се изпълнява последователно в извикващата нишка

Две правила правят това ограничено, а не просто подредено. Броят на изходната опашка включва растерните изображения, които в момента се използват от обратното извикване, така че бавно обратно извикване не може да позволи на производителите да изпреварят дълбочината на опашката. И по-късните страници могат да заемат най-много 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 DPI, се нуждаят от няколкостотин мегабайта живи растерни изображения, независимо колко ядра са налични, а машината с осем ядра може да няма толкова свободно адресно пространство

Затова HotPDF нормализира заявения брой работници спрямо размера на заявката, броя страници и твърд таван, след което разделя текущо наличния глобален бюджет на най-голямата консервативна оценка за отделна страница в заявката. Винаги оцелява поне един работник, така че прекалено голяма страница все пак се рендира, изключително. При референтното измерване, задача от шест страници при 600 DPI под бюджет от 512 MiB се изпълни с три работника вместо шест, с пиково резервиране от 504 583 296 байта

Всеки работник прави резервация на памет след като поеме страница и преди да компилира, а резервацията покрива компилирането и възпроизвеждането заедно. Резервирането само около растеризиращия етап би пропуснало списъка за визуализация и работния набор на потока от съдържание, който при страници с много графика е по-голямата половина. Двете API се различават след това: масивната форма освобождава резервацията си веднага щом растерното изображение е завършено, защото извикващият притежава резултата и планировчикът вече не може да го отчита, докато подредената форма прехвърля резервацията заедно с растерното изображение в изходния слот и я задържа, докато обратното извикване не приключи. Това е още една причина да предпочитате подреденото API, когато изходният набор е голям

Диаграма на бюджета за памет на HotPDF, който разделя 512 MiB на най-голямата оценка за страница, за да допусне трима работника вместо шест
Бюджетът допуска работници въз основа на най-голямата оценка за отделна страница, всяка резервация обхваща компилирането и възпроизвеждането, а подреденото API поддържа резервацията активна, докато обратното извикване не приключи

Отмяна без блокиране

Задаването на Cancel в обратното извикване спира новата работа и събужда всеки работник, чакащ на обратен натиск. Страниците, вече навлезли в рендерера, завършват и се почистват, вместо да бъдат изоставени, и се запазва само първата диагностика за грешка, повдигната след като всички работници се присъединят обратно

Редът тук е от значение за коректността. Състоянието на отмяна се задава първо, непубликуваните резервации на изхода се освобождават второ, и едва тогава работниците се присъединяват обратно. Разменянето на последните две стъпки създава затворен цикъл: работник чака за допускане до бюджет, който няма да бъде освободен, докато консуматорът не приключи, докато консуматорът чака точно този работник да излезе

Интерактивните приложения трябва да знаят и за изпреварването на преден план. Предварителното зареждане на страници работи със собствени токени за отмяна, а извикванията за рендиране на преден план отменят и присъединяват работниците за предварително зареждане, преди да извършат собствената си работа, така че фоновата пропускателна способност никога не добавя закъснение към страницата, която потребителят чака. Отмяната се проверява през интервали, ограничени до 4 KiB сканирани байтове или 256 токена, макар че атомен кодек или системно извикване не могат да бъдат прекъснати по средата — гаранцията за отзивчивост покрива циклите, притежавани от библиотеката, а не декодери на трети страни. Подходът, базиран на опашка от заявки за интерактивно рендиране, е описан в фоново рендиране с опашка от заявки

Четене на статистиката на конвейера

THPDFParallelRenderPipelineInfo разделя числа, които лесно се бъркат едно с друго. RequestedWorkerCount спрямо WorkerCount и MemoryBudgetWorkerLimit ви казва дали бюджетът е намалил успоредността ви. CompiledPageCount спрямо CacheHitPageCount ви казва колко е спестил кешът за списъка за визуализация. CompiledPagesAtFirstResult показва дали конвейерът наистина е застъпил компилирането и възпроизвеждането, или се е изродил в бариера

За настройка, двете най-полезни са BackpressureWaitCount и MemorySchedulerWaitMilliseconds. Високи стойности на чакане поради обратен натиск означават, че тясното място е обратното ви извикване, така че или го направете по-бързо, или увеличете дълбочината на опашката, ако паметта позволява. Високи стойности на чакане на планировчика означават, че тясното място е бюджетът, така че намалете DPI, намалете успоредността, или увеличете бюджета. Увеличаването на броя работници в двата случая влошава пропускателната способност, което е контраинтуитивната част

Практическа отправна точка за пакетен експорт: успоредност, равна на физическите ядра минус едно, дълбочина на опашката две или три, и DPI, избрано според реалното изискване за изхода, а не по навик. После прочетете статистиката от реален документ и коригирайте веднъж, вместо да гадаете два пъти. За задачи, при които самият документ е твърде голям, за да се събере в паметта, комбинирайте това с модела за поточен достъп в директното файлово API за работни потоци с големи PDF документи

Паралелното рендиране, предварителното зареждане и кешът за списъка за визуализация са част от един и същ стек за рендиране за Delphi и C++Builder; пълният списък с функции е на страницата на HotPDF Delphi PDF компонента