PDF 파서의 첫 번째 유용한 읽기는 파일의 잘못된 끝에서 이루어집니다. 이 형식은 startxref 포인터를 마지막 바이트에 넣기 때문에 1.8GB 아카이브를 처리하는 것은 끝으로의 탐색, 1KB 읽기, 그런 다음 상호 참조 테이블이 문서 카탈로그가 있는 곳이라고 말하는 곳으로 이동하는 것으로 시작됩니다. 거기서부터 파싱은 전체 바이트 범위에 걸친 무작위 탐색(random walk)입니다. 버퍼링된 IO가 잘하는 것—파일 포인터 뒤의 순차적 미리 읽기—은 PDF에는 없는 작업 부하를 겨냥한 것입니다
이 기사의 첫 번째 버전은 메모리 맵 파일(memory-mapped file)이 TMemoryStream이 2GB 입력에서 겪는 32비트 메모리 부족 오류를 해결한다고 주장했습니다. 그 주장은 틀렸으며, 그것이 어떻게 틀렸는지가 진정한 해결책을 가리킵니다: 슬라이딩 매핑 창(sliding mapping window)입니다. 다음은 접근 패턴, 컴파일 가능한 창 매퍼(windowed mapper)를 통한 수정된 32비트 이야기, 그리고 1.8GB 크기의 300,000개 객체를 가진 테스트 파일에 대한 시스템 호출 연산입니다
PDF 레이아웃이 버퍼링된 읽기를 무력화하는 이유
세 가지 구조적 사실이 IO 패턴을 형성합니다. 첫째, 탐색은 오프셋 중심(offset-driven)입니다: 상호 참조 테이블은 모든 객체 번호를 절대 바이트 위치에 매핑하며, 이 위치들이 순서대로 정렬되어야 할 필요는 없습니다. 수년간의 점진적 업데이트 후, 객체 4102는 1.6GB 오프셋에 있을 수 있고 객체 4103은 30KB에 있을 수 있습니다. TFileStream 루프는 모든 가져오기를 Seek과 Read라는 두 가지 커널 전환으로 변환하며, 다음 가져오기가 수백 메가바이트 떨어져 있기 때문에 버퍼는 아무런 기여를 하지 못합니다
둘째, 객체 스트림(ISO 32000-1 §7.5.7)은 수십 또는 수백 개의 작은 딕셔너리를 하나의 압축된 컨테이너로 묶습니다. 300바이트의 페이지 딕셔너리 하나를 가져온다는 것은 100KB 클러스터를 읽고 압축 해제한다는 것을 의미할 수 있습니다. 반대로, 함께 쓰인 객체들은 함께 읽히는 경향이 있으므로, 클러스터 크기에 맞춰진 버퍼는 다음 수십 번의 가져오기를 무료로 제공합니다 — 이 형식에서 가장 악용하기 좋은 규칙성입니다
셋째, 선형화(linearization)입니다. 선형화된 파일은 소비자가 처음부터 끝까지 읽을 수 있도록 첫 페이지와 힌트 테이블을 앞쪽에 배치합니다. 기가바이트 아카이브는 거의 선형화되지 않습니다: 선형화는 파일을 크게 만든 동일한 점진적 업데이트와 병합에 의해 파괴됩니다. 따라서 긴 도약, 순서 없음, 꼬리부터 먼저 진입하는 등의 가장 가혹한 경우를 대비해야 합니다
수정된 32비트 이야기
32비트 Windows 프로세스는 2GB의 사용자 주소 공간을 가지며, 바이트 수를 0으로 한 MapViewOfFile은 파일 크기만큼의 연속된 예약을 요청합니다. 2GB 입력의 경우 해당 예약은 성공할 수 없습니다: EXE, 흩어져 있는 DLL, 스레드 스택을 제외하면, 전형적인 32비트 델파이 프로세스에서 가장 큰 연속된 여유 블록은 약 700MB에서 1.4GB 사이에 위치합니다. 이 호출은 TMemoryStream.LoadFromFile이 부딪히는 것과 동일한 벽인 ERROR_NOT_ENOUGH_MEMORY로 실패하며, 단지 커밋된 RAM에서 주소 공간 예약으로 이동했을 뿐입니다. 전체 파일 매핑은 32비트에서 해결책이 아니며, 그럴듯해 보이는 API 이름 뒤에 숨은 동일한 실패일 뿐입니다
해결책은 매핑이 수행하는 두 가지 작업을 분리하는 것입니다. CreateFileMapping은 섹션 객체를 생성하며 파일 크기에 관계없이 주소 공간을 소모하지 않습니다. MapViewOfFile만이 주소 공간을 소모하며, 전체 섹션을 매핑할 필요는 없습니다: 64비트 시작 오프셋과 뷰 길이를 사용합니다. 섹션을 한 번 생성하고, 파싱되는 영역 위에 64에서 256MB의 뷰를 매핑한 다음, 다음으로 넘어가기 전에 매핑을 해제하십시오: 주소 공간 비용은 파일 하나가 아니라 창 하나입니다. 한 가지 제약: 뷰 오프셋은 SYSTEM_INFO.dwAllocationGranularity의 배수여야 하며 실제로는 64KB이므로, 오프셋 1,000,000에 대한 요청은 983,040으로 내림되고 호출자의 포인터는 그 차이만큼 앞으로 조정됩니다
델파이에서의 슬라이딩 창 매퍼
아래 클래스는 전체 원칙을 포함합니다: 하나의 섹션 객체, 하나의 라이브 뷰, 세분성 재조정, 그리고 창 경계를 넘는 읽기는 두 뷰를 잇는 대신 이 뷰를 늘림으로써 처리됩니다
uses
Winapi.Windows, System.SysUtils;
type
TWindowedFileMapper = class
private
FFile: THandle;
FMapping: THandle;
FFileSize: Int64;
FGranularity: DWORD; // SYSTEM_INFO.dwAllocationGranularity
FWindowSize: NativeUInt; // default view size
FViewBase: PByte; // base of the current view (aligned)
FViewOffset: Int64; // file offset FViewBase corresponds to
FViewSize: NativeUInt; // bytes mapped in the current view
procedure Unmap;
public
constructor Create(const FileName: string;
WindowSize: NativeUInt = 64 * 1024 * 1024);
destructor Destroy; override;
function Map(Offset: Int64; Size: NativeUInt): PByte;
procedure ReadBytes(Offset: Int64; var Buffer; Count: NativeUInt);
property FileSize: Int64 read FFileSize;
end;
constructor TWindowedFileMapper.Create(const FileName: string;
WindowSize: NativeUInt);
var
Info: TSystemInfo;
begin
inherited Create;
FFile := CreateFile(PChar(FileName), GENERIC_READ, FILE_SHARE_READ, nil,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
if FFile = INVALID_HANDLE_VALUE then
RaiseLastOSError;
if not GetFileSizeEx(FFile, FFileSize) then
RaiseLastOSError;
// The section object reserves no address space, whatever the file size
FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
if FMapping = 0 then
RaiseLastOSError;
GetSystemInfo(Info);
FGranularity := Info.dwAllocationGranularity; // 64 KB in practice
FWindowSize := WindowSize;
end;
destructor TWindowedFileMapper.Destroy;
begin
Unmap;
if FMapping <> 0 then CloseHandle(FMapping);
if FFile <> INVALID_HANDLE_VALUE then CloseHandle(FFile);
inherited;
end;
procedure TWindowedFileMapper.Unmap;
begin
if FViewBase <> nil then
begin
UnmapViewOfFile(FViewBase);
FViewBase := nil;
FViewSize := 0;
end;
end;
function TWindowedFileMapper.Map(Offset: Int64; Size: NativeUInt): PByte;
var
AlignedOffset: Int64;
Delta, MapSize: NativeUInt;
begin
if (Offset < 0) or (Offset + Int64(Size) > FFileSize) then
raise ERangeError.CreateFmt(
'Map request at %d for %d bytes is outside the file',
[Offset, Int64(Size)]);
// Fast path: the requested range already sits inside the live view
if (FViewBase <> nil) and (Offset >= FViewOffset) and
(Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
Exit(FViewBase + NativeInt(Offset - FViewOffset));
Unmap; // slide: never hold two views at once
// Views must start on an allocation-granularity boundary
AlignedOffset := Offset - (Offset mod FGranularity);
Delta := NativeUInt(Offset - AlignedOffset);
MapSize := FWindowSize;
if MapSize < Size + Delta then // request straddles the window end:
MapSize := Size + Delta; // grow this one view to cover it
if AlignedOffset + Int64(MapSize) > FFileSize then
MapSize := NativeUInt(FFileSize - AlignedOffset); // clamp at EOF
FViewBase := MapViewOfFile(FMapping, FILE_MAP_READ,
DWORD(AlignedOffset shr 32), DWORD(AlignedOffset and $FFFFFFFF),
MapSize);
if FViewBase = nil then
RaiseLastOSError;
FViewOffset := AlignedOffset;
FViewSize := MapSize;
Result := FViewBase + NativeInt(Delta);
end;
procedure TWindowedFileMapper.ReadBytes(Offset: Int64; var Buffer;
Count: NativeUInt);
begin
Move(Map(Offset, Count)^, Buffer, Count);
end;
두 가지 세부 사항이 핵심입니다. Map 최상단의 빠른 경로는 요청된 범위가 이미 라이브 뷰 안에 있을 때 커널 전환 없이 포인터를 반환합니다. 객체 스트림 클러스터링 덕분에 이것이 일반적인 경우이며 절약의 원천입니다. 그리고 기본 창의 끝을 걸치는 요청은 두 뷰를 잇는 대신 이 한 뷰에 대해 MapSize를 늘려 ReadBytes를 한 줄로 유지하고 호출자가 부분 읽기 루프에서 자유롭게 합니다
창 크기는 너그러운 설정(knob)입니다: 64MB에서 1.8GB 파일의 전체 탐색은 29개의 뷰이고, 256MB에서는 8개이지만 각 예약은 단편화된 32비트 공간에 배치하기 더 어려우며, 약 16MB 미만에서는 도약이 잦은 파일이 눈에 띌 만큼 자주 재매핑됩니다. 64에서 256MB 범위 어디든 매핑 트래픽은 통계적 노이즈(statistical noise)에 불과합니다
시스템 호출 계산하기
이제 연산을 해보겠습니다. 테스트 파일: 1.8GB, 평균 페이로드가 약 600바이트인 300,000개의 간접 객체입니다. 객체별 파서는 SetFilePointerEx와 4KB ReadFile로 각각을 가져옵니다: 600,000번의 커널 전환입니다. 캐시된 읽기 시스템 호출은 현재 x64 하드웨어에서 대략 1.5μs에 왕복하므로, 파싱을 시작하기도 전에 순수 커널 오버헤드만 600,000 × 1.5μs ≈ 0.9초가 걸립니다 — 이것은 웜 캐시(warm-cache) 최상의 경우입니다. 콜드 상태(cold)에서는 각 도약이 기기 작업입니다: NVMe 4KB 무작위 읽기의 유효 지연 시간인 ~20μs에서, 300,000번의 작업은 약 6초의 기기 시간을 소비합니다. SATA급 스토리지에서는 몇 분이 걸립니다
읽기 역시 잘못된 데이터를 이동시킵니다: 300,000 × 4KB는 1.2GB를 사용자 버퍼로 밀어넣어 약 180MB의 페이로드를 전달합니다 — 6배의 증폭이며 모든 바이트가 커널에서 사용자로 복사됩니다
객체 스트림 클러스터 크기에 맞춘 미리 읽기 버퍼는 첫 번째로 정직한 개선입니다: 객체당 하나 대신 클러스터당 한 번의 256KB 읽기는 전환 횟수를 1~2단계 줄입니다. 이것은 매핑이 어색한 상황(보통 네트워크 공유)에서도 올바른 도구입니다
창 매퍼는 한 걸음 더 나아갑니다. 전체 탐색은 29개의 MapViewOfFile과 29개의 UnmapViewOfFile 호출이며, 600,000개에 비해 단 58개의 명시적 전환에 불과합니다. 실제 xref 기반 파싱은 깔끔한 전체 탐색이 아니지만, 빠른 경로는 라이브 창 안의 모든 가져오기를 흡수합니다. 테스트 아카이브에 대한 메타데이터 인덱싱 패스는 수백 번의 재매핑으로 안정화되었습니다. 매핑은 커널 작업을 제거하지 않습니다: 그것은 명시적 시스템 호출을 메모리 관리자가 다중 페이지 클러스터로 해결하는 페이지 폴트(page fault)로 변환하며, 사용자 공간 복사 없이 파일 캐시에서 직접 처리하고 건드리지 않은 영역은 비용이 들지 않습니다. 처음부터 끝까지, 인덱싱 패스는 객체별 읽기에서 콜드 23초, 웜 7.1초에서 매퍼를 사용하여 콜드 6.5초, 웜 1.9초로 단축되었습니다. 남은 것은 IO가 아니라 zlib 압축 해제뿐입니다
FILE_FLAG_NO_BUFFERING이 맞는 곳
FILE_FLAG_NO_BUFFERING은 오프셋, 길이, 버퍼 주소 모두 섹터 정렬이어야 한다는 엄격한 정렬 규칙의 대가로 시스템 캐시를 우회합니다. 이것은 아카이브 전체를 다시 쓰는 일괄 재직렬화나 완료된 출력에 대한 선형화 패스와 같이 아무도 두 번 읽지 않을 바이트로 캐시를 채우게 될 단일 패스 순차 작업에서 그 가치를 증명합니다. 4~8MB 정렬된 버퍼를 사용하면 캐시를 오염시키지 않고 기기 순차 대역폭에 접근할 수 있습니다
이것은 파싱에는 완전히 잘못되었습니다. 버퍼링되지 않은 핸들을 통한 무작위 xref 도약은 매 300바이트 딕셔너리 가져오기를 두 번째 방문을 흡수할 캐시가 없는 전체 물리적 읽기로 변환합니다 — 그리고 PDF 파싱은 서로 다른 페이지가 동일한 객체 스트림으로 해결되기 때문에 지속적으로 영역을 재방문합니다. 순차적 재작성에는 버퍼링 없는 IO를, 무작위 파싱에는 매핑되거나 캐시된 IO를 사용하십시오. 플래그는 핸들별로 설정되므로 하나의 파이프라인에서 같은 파일에 둘 다를 유지할 수 있습니다
64비트, 워킹 셋, 그리고 쓰기 측면
64비트 빌드에서는 주소 공간에 대한 이의 제기가 사라집니다: 파일 크기를 창으로 전달하면 위의 클래스는 단일 전체 매핑으로 퇴보합니다. 장기 실행 서비스의 주의점: 읽기 전용 파일 기반 페이지는 커밋을 청구하지 않으므로 커밋 카운터는 조용하지만 만져진 모든 페이지는 워킹 셋(working set)에 합류합니다. 1.8GB의 대부분을 파싱하면 워킹 셋도 이에 맞게 커져서 다른 모든 것을 축출합니다. 제한된 창은 이에 대한 상한선을 두므로, 주소 공간이 무료인 곳에서도 슬라이딩 패턴이 올바른 기본값으로 유지됩니다
쓰기 측면에서 가장 저렴한 IO는 발행되지 않은 IO입니다. PDF의 점진적 업데이트 메커니즘(ISO 32000-1 §7.5.6)은 변경된 객체와 새 상호 참조 섹션을 절대 움직이지 않는 원래 바이트 뒤에 추가합니다. 1.8GB 아카이브에 한 페이지를 스탬프 찍는 것은 수십 킬로바이트를 추가합니다; 전체를 다시 쓰면 크기가 5단계나 차이나는 전체 1.8GB를 이동시키지만, 추가는 꼬리 부분에서의 순수한 순차적 출력입니다
losLab 라이브러리가 적합한 곳
두 losLab PDF 라이브러리 모두 이러한 원칙을 API 표면으로 제공합니다. HotPDF 직접 파일 API (Direct File API)는 객체 트리를 구축하지 않고 파일 핸들을 통해 페이지 수와 구조를 읽고, 파일 수준에서 복사 및 암호 해독을 수행하며, BeginIncrementalUpdate를 통해 델타를 작성합니다 — 패키지화된 위의 추가 전용 전략입니다. PDFlibPas는 직접 액세스 계층을 통해 동일한 경로를 취합니다: 제자리에서 상호 참조 테이블을 탐색하고, 객체를 지연 져오기하며, 파일에서 파일로 페이지 범위를 추출하고, 점진적 개정으로 편집 내용을 유지하는 스트리밍 리더입니다. 자체 파서를 작성 중이라면 위의 매퍼 클래스를 가져다 쓰십시오; 문서 파이프라인을 실행 중이라면 라이브러리가 창을 정직하게 유지하게 하십시오
참고: 기가바이트 규모의 문서를 위한 최적화된 IO 처리는 델파이 및 C++Builder용 HotPDF VCL 컴포넌트에 직접 내장되어 있습니다