기술 문서

Delphi에서 원격 PDF 스트리밍: HotPDF 레인지 코얼레싱

HotPDF는 여러분이 구현한 임의의 랜덤 액세스 소스로부터 PDF를 로드하며, THPDFCoalescingRandomAccessSource는 그 소스를 감싸서 파서가 흩뿌리는 자잘한 읽기들을 비동기 프리페치가 딸린, 개수가 제한된 캐시 블록 레인지 집합으로 바꿔줍니다. HTTP 레인지 요청으로 제공되는 문서에서는 이것이 수백 번의 왕복과 수십 번의 왕복 사이의 차이가 됩니다

파서 쪽은 아무것도 달라지지 않습니다. 여전히 LoadFromRandomAccessSource를 호출하고, 같은 문서 객체가 돌아오며, 같은 페이지 API가 작동합니다. 달라지는 것은 그 아래에서 오가는 트래픽뿐입니다

같은 PDF가 로컬에서는 즉시 열리는데 네트워크에서는 왜 기어가는가?

PDF 파서는 파일을 읽는 것이 아니라 파일 안을 탐색하기 때문입니다. startxref를 찾기 위해 끝으로 이동했다가, 상호 참조 테이블로 다시 뛰어가고, 트레일러 딕셔너리를 해석한 뒤, Catalog로 참조를 따라가고, 다시 페이지 트리 루트로, 그다음 페이지 노드로, 그다음 리소스 딕셔너리로 이동합니다. 이 단계들 각각은 서로 다른 오프셋에서 수십 바이트를 읽습니다

로컬 파일에서는 이런 패턴이 거의 공짜입니다. 운영체제가 이미 주변 4KiB 페이지를 캐시해 두었으므로 두 번째 읽기는 memcpy 한 번의 비용에 불과합니다. 네트워크 전송에는 그런 지역성이 없습니다. 읽기마다 각자의 지연 시간을 가진 별개의 요청이며, 40ms짜리 순차 요청 300번은 거의 전부 대기하는 데 쓰이는 12초입니다. 해법은 덜 읽는 것이 아닙니다. 파서는 자신이 요청한 것을 정확히 필요로 합니다. 해법은 물리적 읽기 한 번이 다음 논리적 읽기가 원할 내용을 더 많이 담도록 만드는 것입니다

코얼레싱이 바꾸는 것

코얼레싱 소스는 모든 읽기를 블록 단위로 올림하여 그 블록을 캐시합니다. BlockSize는 기본값이 262,144바이트이고 MaxCacheBytes는 2,097,152바이트이므로, 기본적으로 여덟 개의 블록이 상주하며 고정된 바이트 예산에 대해 LRU(최근 최소 사용) 순서로 제거됩니다. 트레일러 키를 읽는 파서의 40바이트짜리 읽기는 그 주변 256KiB를 함께 끌어오고, 상호 참조와 카탈로그 데이터가 있는 그 근방의 다음 십여 번의 읽기는 메모리에서 처리됩니다

여러분이 만드는 소스는 단순한 채로 남습니다. GetSizeReadAt을 구현하고, 전송 계층이 진행 중인 요청을 중단할 수 있다면 ReadAtCancellable을 오버라이드한 다음, 캐싱과 코얼레싱, 프리페치는 래퍼에 맡기십시오

type
  THttpRangeSource = class(THPDFRandomAccessSource)
  private
    FClient: TMyHttpClient;
    FUrl: string;
    FSize: Int64;
  public
    function GetSize: Int64; override;
    function ReadAt(Offset: Int64; var Buffer; Count: Longint): Longint; override;
    function ReadAtCancellable(Offset: Int64; var Buffer; Count: Longint;
      CancellationToken: THPDFCancellationToken): Longint; override;
  end;

var
  Raw: THttpRangeSource;
  Cached: THPDFCoalescingRandomAccessSource;
  Pdf: THotPDF;
begin
  Raw := THttpRangeSource.Create('https://files.example.com/contract.pdf');
  // OwnsSource=True: 래퍼가 자신과 함께 Raw를 해제함
  Cached := THPDFCoalescingRandomAccessSource.Create(Raw, True, 262144, 8388608);
  Pdf := THotPDF.Create(nil);
  try
    Cached.AsyncPrefetchEnabled := True;
    Cached.AdaptiveReadAheadEnabled := True;
    Cached.MaxReadAheadBlocks := 8;

    if Pdf.LoadFromRandomAccessSource(Cached, True) = 1 then
      RenderFirstPage(Pdf);
  finally
    Pdf.Free;
  end;
end;

얼마나 앞서 읽어야 하는가?

적응형 리드어헤드는 이 질문에 대해 추측을 강요하는 대신 문서마다 답을 정합니다. AdaptiveReadAheadEnabled를 설정하면 지속적인 순방향 읽기가 누적됨에 따라 창(window)이 1, 2, 4, 8블록으로 커지며, MaxReadAheadBlocks나 설정된 캐시 용량을 절대 넘지 않습니다. 이전 읽기가 끝난 지점과 대략 일치하지 않는 읽기가 도착하는 순간, 창은 붕괴하고 프리페치는 억제됩니다

기본값이 4,096인 SequentialReadToleranceBytes가 그 "대략"을 정의합니다. 이전 읽기의 끝에서 그 거리 이내에 도착하는 읽기는 여전히 순차적인 것으로 취급되는데, 이것이 중요한 이유는 콘텐츠 스트림을 따라가는 PDF 파서가 완벽하게 연속된 오프셋을 만들어내지 않기 때문입니다. 여기서는 길이 필드 하나를 건너뛰고, 저기서는 인라인 딕셔너리를 건너뜁니다. 허용치를 너무 낮게 설정하면 정상적인 순방향 스캔이 임의적인 것으로 분류되어 리드어헤드가 아예 작동하지 않습니다. 너무 높게 설정하면 진짜 임의 접근이 순차적으로 보여서 아무도 원치 않는 메가바이트를 가져오게 됩니다. 기본값은 콘텐츠 스트림 순회에 맞춰 조정되어 있으며, 여러분의 전송 계층이 이와 다르게 동작한다면 통계가 알려줄 것입니다

이 비대칭성은 의도적입니다. 성장은 점진적이고 붕괴는 즉각적입니다. 임의 접근 워크로드에서 과도하게 가져오면 종량제 전송에서 실제 대역폭과 실제 비용이 들기 때문에, 비싼 실수보다 값싼 실수를 선호하는 것입니다

실제로 전송을 멈추는 취소

기반 클래스는 ReadAtCancellable을 선언하고, 코얼레싱 소스는 이를 처음부터 끝까지 존중합니다. 진행 중인 프리페치가 처리하지 않는 레인지에 대해 포그라운드 읽기가 도착하면, 프리페치는 끝까지 진행되도록 두는 대신 취소되므로 사용자의 페이지 요청이 투기적 트래픽 뒤에 대기하지 않습니다. THPDFRandomAccessSource의 기본 구현은 평범한 ReadAt으로 폴백하는데, 이는 이 기능이 전송 계층별 선택 사항임을 의미합니다. 요청 중단을 지원하는 HTTP 클라이언트는 진짜 취소를 얻고, 더 단순한 소스는 변경 없이 그대로 작동합니다

이를 UI 전체에 걸쳐 전달되는 취소 토큰과 결합하면, 사용자가 문서를 닫을 때 트래픽이 다 빠질 때까지 기다리는 대신 실제로 네트워크 트래픽이 멈춥니다. 같은 토큰 모델이 요청 큐를 이용한 백그라운드 렌더링에서 설명하는 큐잉을 뒷받침하므로, 토큰 하나로 뷰포트에서 소켓까지 전체 경로를 아우를 수 있습니다

레인지 캐시 통계 읽기

GetStatistics는 여러분의 전송 계층이 한 일과 캐시가 한 일을 구분하는 THPDFRangeCacheStatistics 레코드를 채웁니다. SourceReadCountSourceBytesRead는 물리적 트래픽입니다. CacheHitCountCacheMissCount는 논리적 트래픽입니다. SequentialReadCountRandomReadCount는 접근 패턴이 어떻게 분류되었는지 보여주고, CurrentReadAheadBlocksPeakReadAheadBlocks는 창이 얼마나 넓게 열렸는지 보여주며, PrefetchRequestCount, PrefetchCompletedCount, PrefetchCancelledCount, SuppressedPrefetchCount는 투기적 읽기가 성과를 냈는지 보여줍니다

var
  S: THPDFRangeCacheStatistics;
begin
  Cached.GetStatistics(S);
  Log(Format('physical %d reads / %d bytes, hits %d, misses %d',
    [S.SourceReadCount, S.SourceBytesRead, S.CacheHitCount, S.CacheMissCount]));
  Log(Format('pattern: %d sequential, %d random, peak window %d blocks',
    [S.SequentialReadCount, S.RandomReadCount, S.PeakReadAheadBlocks]));
  Log(Format('prefetch: %d issued, %d completed, %d cancelled, %d suppressed',
    [S.PrefetchRequestCount, S.PrefetchCompletedCount,
     S.PrefetchCancelledCount, S.SuppressedPrefetchCount]));
end;

세 가지 지표가 무엇을 바꿔야 할지 알려줍니다. 취소된 프리페치가 많고 무작위 읽기 수도 높다면 문서가 순서 없이 접근되고 있다는 뜻이므로 MaxReadAheadBlocks를 낮춰 버려지는 대역폭에 비용을 지불하는 일을 멈추십시오. 미스가 많은데도 최대 창 크기가 여전히 1이라면 허용치가 사실상 순차적인 패턴을 거부하고 있다는 뜻이므로 SequentialReadToleranceBytes를 올리십시오. 그리고 읽은 바이트 수가 파일 크기를 훨씬 초과한다면 캐시가 스래싱 중이라는 뜻이므로 다른 어떤 것을 건드리기 전에 MaxCacheBytes부터 올리십시오

선형화된 파일은 셈법을 바꾼다

생성기를 여러분이 직접 통제한다면, 문서를 선형화하는 것은 문제를 최적화하는 것이 아니라 문제 자체를 바꿔버립니다. 선형화된 PDF는 첫 페이지의 객체들과 힌트 테이블을 파일 앞부분에 배치하므로, 뷰어는 나머지를 보지 않고도 시작 부분의 1메가바이트만으로 첫 페이지를 렌더링할 수 있습니다. HotPDF는 GetProgressiveLinearizedLoadInfoReadProgressiveLinearizedFirstPageSection을 통해 그 경로를 직접 노출하며, 작성 쪽은 힌트 테이블을 갖춘 선형화 PDF 생성하기에서 다룹니다

두 기법은 함께 조합됩니다. 코얼레싱은 느린 회선에서도 어떤 문서든 견딜 만하게 만들고, 선형화는 여러분이 직접 생성하는 문서에서 첫 페이지가 빠르게 도착하도록 만듭니다. 로컬 디스크에 있지만 메모리에 담기에는 너무 큰 파일이라면, 직접 파일 API 워크플로에서 설명하는 매핑 파일과 지연 스트림 경로가 대개 더 나은 도구입니다. 애초에 상환할 왕복 지연이 존재하지 않기 때문입니다

HotPDF는 Delphi와 C++Builder용 네이티브 VCL PDF 컴포넌트로, 파서에 외부 DLL이 필요 없고 전체 소스를 제공합니다. 랜덤 액세스 소스 API, 코얼레싱 래퍼, 프로그레시브 로딩 진입점은 HotPDF Delphi PDF 컴포넌트 페이지에 문서화되어 있습니다