기술 문서

OOM 없이 Delphi에서 PDF 페이지 병렬 렌더링하기

HotPDF는 두 가지 진입점을 통해 여러 PDF 페이지를 동시에 렌더링합니다: 비트맵 배열을 반환하는 RenderLoadedPagesParallel과, 각 페이지가 준비되는 즉시 입력 순서대로 콜백에 전달하는 RenderLoadedPagesParallelOrdered입니다. 확장성을 갖춘 쪽은 순서 보장 형태인데, 요청한 페이지 수와 무관하게 메모리에 유지되는 비트맵 수가 항상 제한되기 때문입니다

이 구분이 이 글의 전부입니다. 300 DPI로 10,000페이지를 배열에 렌더링하면 10,000개의 살아 있는 비트맵이 생기는데, 이는 처리량 문제가 아니라 메모리 부족 크래시입니다. 큐 깊이 2인 순서 보장 콜백을 통해 렌더링하면 살아 있는 비트맵은 두 개뿐이며, 작업은 문서 길이와 무관하게 고정된 메모리 사용량으로 실행됩니다

병렬 렌더링에 왜 두 단계가 필요한가?

PDF 페이지를 렌더링하는 것은 하나의 이름을 쓰는 서로 다른 두 작업입니다. 먼저 콘텐츠 스트림이 토큰화되어 디스플레이 리스트로 컴파일되는데, 이는 CPU 바운드이며 공유 캐시를 건드립니다. 그다음 디스플레이 리스트가 비트맵으로 재생되는데, 이 역시 CPU 바운드지만 메모리 할당이 큽니다. 이 둘을 하나의 나눌 수 없는 단위로 취급하면 캐시를 건드리는 절반을 직렬화할지, 작업을 중복시킬지 사이에서 선택해야만 합니다

HotPDF는 이 둘을 분리합니다. 디스플레이 리스트 캐시 락은 빈도 집계, LRU 순서, 사용 횟수, 승인 결정만을 보호하며, 컴파일 자체는 락 바깥에서 실행됩니다. 컴파일 후 워커는 캐시를 다시 조회합니다: 그동안 다른 워커가 같은 페이지를 이미 게시했다면, 자신의 사본을 해제하고 게시된 항목의 사용 횟수를 가져갑니다. 이렇게 하면 컴파일을 페이지 간에 전혀 직렬화하지 않으면서도 중복 작업을 제한하고 같은 페이지에 대해 두 개의 항목이 생기는 것을 막을 수 있습니다

32페이지짜리 복잡한 픽스처에서 측정한 효과는 기억해 둘 만합니다. Win64에서 전체 시간은 워커 1개일 때 1,565ms에서 워커 4개일 때 696ms로, 약 2.25배 줄었습니다. 첫 결과까지의 시간은 1,255ms에서 63ms로, 약 95퍼센트 줄었는데, 이는 워커들이 완전한 컴파일 배리어에서 대기하는 대신 다른 워커가 아직 컴파일 중일 때 완료된 디스플레이 리스트의 재생을 시작하기 때문입니다

Delphi에서 컴파일 단계가 공유 디스플레이 리스트 캐시에 전달한 다음 비트맵 재생이 이루어지는 HotPDF 2단계 병렬 렌더링 파이프라인을 보여주는 다이어그램
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;

메모리 사용량이 일정하게 유지되는 이유는 바로 이 대여된 비트맵 계약 때문입니다. 라이브러리는 콜백이 반환되는 즉시 비트맵을 해제하므로, 큐 깊이 2로 10,000페이지짜리 픽스처를 처리해도 Win32와 Win64 모두에서 정확히 비트맵 두 개가 최대치입니다. 콜백 이후에도 비트맵이 필요하다면 복사하면 되고, 그러면 메모리와 그 관리 책임은 호출자에게 넘어갑니다

메모리 예산은 워커 수를 어떻게 정하는가

워커가 많다고 항상 빠른 것은 아니며 오히려 치명적일 때가 많습니다. A3 페이지를 600 DPI로 렌더링하는 워커 6개는 사용 가능한 코어 수와 무관하게 수백 메가바이트의 살아 있는 비트맵을 필요로 하며, 코어가 8개인 머신이라 해도 그만큼의 여유 주소 공간을 갖고 있지 않을 수 있습니다

따라서 HotPDF는 요청 크기, 페이지 수, 하드 상한선에 맞춰 요청된 워커 수를 정규화한 다음, 현재 사용 가능한 전역 예산을 요청 내에서 가장 보수적으로 추정한 단일 페이지 최대치로 나눕니다. 최소 워커 1개는 항상 살아남으므로, 지나치게 큰 페이지라도 단독으로나마 렌더링됩니다. 참조 측정에서, 512MiB 예산 아래에서 실행된 6페이지짜리 600 DPI 작업은 워커 6개가 아니라 3개로 실행되었고, 예약량의 최고치는 504,583,296바이트였습니다

각 워커는 페이지를 확보한 후 컴파일 전에 메모리를 예약하며, 이 예약은 컴파일과 재생을 함께 커버합니다. 래스터 단계 주변만 예약한다면 디스플레이 리스트와 콘텐츠 스트림의 작업 세트를 놓치게 되는데, 그래픽이 많은 페이지에서는 이쪽이 더 큰 절반입니다. 이후 두 API는 서로 달라집니다: 배열 형태는 비트맵이 완성되면 예약을 해제하는데, 호출자가 결과를 소유하게 되어 스케줄러가 더 이상 그것을 관리할 수 없기 때문입니다. 반면 순서 보장 형태는 예약을 비트맵과 함께 출력 슬롯으로 넘기고 콜백이 반환될 때까지 보유합니다. 출력 세트가 클 때 순서 보장 API를 선호해야 하는 또 다른 이유가 여기에 있습니다

HotPDF 메모리 예산이 512MiB를 가장 큰 페이지 추정치로 나누어 워커 6개 대신 3개만 승인하는 과정을 보여주는 다이어그램
예산은 가장 큰 단일 페이지 추정치를 기준으로 워커를 승인하며, 각 예약은 컴파일과 재생을 모두 아우르고, 순서 보장 API는 콜백이 반환될 때까지 예약을 유지합니다

교착 상태 없는 취소

콜백에서 Cancel을 설정하면 새 작업이 중단되고 백프레셔를 기다리던 워커가 깨어납니다. 이미 렌더러 안에 있는 페이지는 버려지지 않고 완료되어 정리되며, 첫 번째 실패 진단만 보관되었다가 모든 워커가 조인된 후에 발생합니다

여기서 순서는 매우 중요합니다. 취소 상태가 먼저 설정되고, 그다음 게시되지 않은 출력 예약이 해제되며, 그 이후에야 워커들이 조인됩니다. 마지막 두 단계를 뒤바꾸면 닫힌 루프가 생깁니다: 워커는 소비자가 끝나야 해제될 예산에 대한 승인을 기다리고, 소비자는 그 워커가 종료되기를 기다리게 됩니다

대화형 애플리케이션이라면 포그라운드 선점에 대해서도 알아 두어야 합니다. 페이지 프리페치는 자체 취소 토큰으로 실행되며, 포그라운드 렌더링 호출은 자신의 작업을 하기 전에 프리페치 워커들을 취소하고 조인하므로, 백그라운드 처리량이 사용자가 기다리는 페이지에 지연을 더하는 일은 없습니다. 취소 여부는 4KiB 바이트 스캔 또는 256개 토큰 단위로 확인되지만, 원자적인 코덱이나 OS 호출은 진행 중간에 중단될 수 없습니다. 이 응답성 보장은 라이브러리 소유의 루프에 적용되는 것이지 서드파티 디코더에는 적용되지 않습니다. 대화형 렌더링에 대한 큐 기반 접근 방식은 요청 큐를 이용한 백그라운드 렌더링에서 설명합니다

파이프라인 통계 읽기

THPDFParallelRenderPipelineInfo는 혼동하기 쉬운 수치들을 구분해 줍니다. RequestedWorkerCount와 WorkerCount, MemoryBudgetWorkerLimit을 비교하면 예산이 동시성을 줄였는지 알 수 있습니다. CompiledPageCount와 CacheHitPageCount를 비교하면 디스플레이 리스트 캐시가 얼마나 절약해 주었는지 알 수 있습니다. CompiledPagesAtFirstResult는 파이프라인이 실제로 컴파일과 재생을 겹쳐 처리했는지, 아니면 배리어로 퇴화했는지를 보여줍니다

튜닝에 가장 유용한 두 지표는 BackpressureWaitCount와 MemorySchedulerWaitMilliseconds입니다. 백프레셔 대기가 많다는 것은 콜백이 병목이라는 뜻이므로, 콜백을 더 빠르게 만들거나 메모리가 허용하는 한 큐 깊이를 늘리세요. 스케줄러 대기가 많다는 것은 예산이 병목이라는 뜻이므로, DPI를 낮추거나 동시성을 줄이거나 예산을 늘리세요. 어느 경우든 워커 수를 늘리면 처리량이 오히려 나빠지는데, 이 부분이 직관에 반합니다

배치 내보내기의 실용적인 출발점은 다음과 같습니다: 동시성은 물리 코어 수에서 하나를 뺀 값, 큐 깊이는 2 또는 3, DPI는 습관이 아니라 실제 출력 요구사항에서 정합니다. 그런 다음 실제 문서에서 통계를 읽고 한 번 조정하는 편이, 두 번 추측하는 것보다 낫습니다. 문서 자체가 메모리에 담기에 너무 큰 작업이라면, 대용량 PDF 워크플로를 위한 다이렉트 파일 API의 스트리밍 접근 모델과 결합하세요

병렬 렌더링, 프리페치, 디스플레이 리스트 캐시는 모두 Delphi와 C++Builder를 위한 동일한 렌더링 스택의 일부입니다. 전체 기능 목록은 HotPDF Delphi PDF 컴포넌트 페이지에서 확인할 수 있습니다