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

Параллельный рендеринг страниц PDF в Delphi без OOM

HotPDF рендерит множество страниц PDF параллельно через две точки входа: RenderLoadedPagesParallel, которая возвращает массив растровых изображений, и RenderLoadedPagesParallelOrdered, которая передаёт каждую страницу в обратный вызов в порядке входных данных, как только та готова. Именно упорядоченная форма масштабируется, потому что она никогда не держит в памяти больше ограниченного числа растровых изображений, независимо от того, сколько страниц вы запросили

В этом различии — вся суть статьи. Рендеринг 10 000 страниц с разрешением 300 DPI в массив означает 10 000 живых растровых изображений одновременно — это не проблема пропускной способности, а аварийное завершение из-за нехватки памяти. Рендеринг тех же страниц через упорядоченный обратный вызов с глубиной очереди два означает два живых растровых изображения, и задание выполняется с фиксированным объёмом памяти независимо от длины документа

Почему параллельному рендерингу нужны два этапа?

Рендеринг страницы PDF — это две разные задачи под одним именем. Сначала поток содержимого токенизируется и компилируется в дисплейный список, что упирается в процессор и затрагивает общие кэши. Затем дисплейный список воспроизводится в растровое изображение — тоже упирается в процессор, но при этом активно выделяет память. Если считать это одной неделимой единицей, приходится выбирать между сериализацией той половины, что трогает кэш, и дублированием работы

HotPDF разделяет их. Блокировка кэша дисплейных списков защищает только учёт частоты, порядок LRU, счётчики использования и решения о допуске; сама компиляция выполняется вне блокировки. После компиляции рабочий поток повторно запрашивает кэш: если другой рабочий поток за это время уже опубликовал ту же страницу, он освобождает собственную копию и берёт счётчик использования на опубликованную запись. Это ограничивает дублирование работы и предотвращает появление двух записей для одной страницы, ни разу не сериализуя компиляцию между страницами

Измеренный эффект на сложном тестовом наборе из 32 страниц стоит запомнить. Общее время на Win64 упало с 1565 мс при одном рабочем потоке до 696 мс при четырёх — примерно в 2,25 раза. Время до первого результата упало с 1255 мс до 63 мс, то есть примерно на 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 МиБ выполнялось тремя рабочими потоками вместо шести, с пиком резервирования 504 583 296 байт

Каждый рабочий поток резервирует память после получения страницы и перед компиляцией, и резерв покрывает компиляцию и воспроизведение вместе. Резервирование только вокруг этапа растрирования упустило бы дисплейный список и рабочий набор потока содержимого, а на страницах с обилием графики это большая половина. Здесь два API расходятся: форма с массивом освобождает свой резерв, как только растровое изображение готово, потому что результатом теперь владеет вызывающий код и планировщик больше не может его учитывать, тогда как упорядоченная форма переносит резерв вместе с растровым изображением в выходной слот и удерживает его до возврата из обратного вызова. Это ещё одна причина предпочитать упорядоченный API, когда выходной набор велик

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

Отмена без тупика

Установка Cancel в обратном вызове останавливает новую работу и будит любой рабочий поток, ожидающий из-за обратного давления. Страницы, уже находящиеся внутри рендерера, завершаются и очищаются, а не бросаются на полпути, и сохраняется только первая диагностика сбоя, которая выбрасывается после присоединения всех рабочих потоков

Порядок здесь несущий. Сначала устанавливается состояние отмены, затем освобождаются неопубликованные резервы вывода, и только потом происходит присоединение рабочих потоков. Перестановка местами двух последних шагов создаёт замкнутый цикл: рабочий поток ждёт допуска к бюджету, который не освободится, пока не завершится потребитель, а потребитель ждёт выхода этого рабочего потока

Интерактивным приложениям также стоит знать о вытеснении переднего плана. Предзагрузка страниц работает на собственных токенах отмены, и вызовы рендеринга переднего плана отменяют и присоединяют рабочие потоки предзагрузки перед тем, как выполнять собственную работу, поэтому фоновая пропускная способность никогда не добавляет задержку странице, которую ждёт пользователь. Отмена проверяется с интервалами, ограниченными 4 КиБ сканирования байтов или 256 токенами, хотя атомарный вызов кодека или ОС нельзя прервать на полпути — гарантия отзывчивости распространяется на циклы, принадлежащие библиотеке, а не на сторонние декодеры. Подход к интерактивному рендерингу на основе очереди описан в статье фоновый рендеринг с очередью запросов

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

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

Для настройки наиболее полезны два показателя: BackpressureWaitCount и MemorySchedulerWaitMilliseconds. Высокое число ожиданий из-за обратного давления означает, что узкое место — ваш обратный вызов, поэтому либо ускорьте его, либо увеличьте глубину очереди, если позволяет память. Высокое число ожиданий планировщика означает, что узкое место — бюджет, поэтому снизьте DPI, снизьте параллельность или увеличьте бюджет. В обоих случаях увеличение числа рабочих потоков ухудшает пропускную способность — это и есть контринтуитивная часть

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

Параллельный рендеринг, предзагрузка и кэш дисплейных списков — части одного и того же стека рендеринга для Delphi и C++Builder; полный список возможностей — на странице компонента HotPDF Delphi PDF Component