HotPDF는 로드된 PDF의 메모리 프로파일을 질의할 수 있는 그래프로 노출합니다. BuildLoadedObjectDependencyGraph는 간접 객체마다 노드를 하나씩 반환하며, 각 노드는 추정 얕은 크기(shallow size), 지배자(dominator) 기반의 유지 크기(retained size), 그리고 그 객체가 문서 Catalog에서 여전히 도달 가능한지를 나타내는 플래그를 담고 있습니다. 이것이 "이 파일은 800MB를 사용한다"는 말을 "객체 4173번, 즉 이미지 XObject 하나가 612MB를 독점적으로 붙잡고 있다"는, 여러분이 조치할 수 있는 사실로 바꿔줍니다
이 두 문장의 차이가 핵심 전부입니다. 얕은 크기는 객체 하나가 얼마나 큰지 알려줍니다. 유지 크기는 그 객체가 사라졌을 때 실제로 얼마나 많은 메모리가 해제될지 알려주는데, 바로 이 숫자가 어떤 수정이 도움이 되는지를 결정합니다
전체 메모리 사용량은 왜 조치 가능한 사실이 아닌가?
PDF에서는 정확히 하나에게만 소유된 것이 거의 없기 때문입니다. 임베드된 CID 폰트 하나는 그것을 사용하는 모든 페이지의 리소스 딕셔너리에서 참조됩니다. ICC 프로필 스트림 하나는 열 개의 서로 다른 콘텐츠 스트림이 공유하는 색공간을 뒷받침합니다. 스탬프로 쓰이는 Form XObject 하나는 400페이지 전부에 나타납니다. 페이지별 객체 크기를 순진하게 합산하면 그 폰트를 400번 세어서 모든 페이지가 거대하다고 결론짓게 되고, 400으로 나누면 비싼 것은 아무것도 없고 메모리는 다른 어딘가에서 왔다고 결론짓게 됩니다
지배자 분석은 실제 문서와 부딪혀도 살아남는 유일한 방식으로 이 모호함을 해결합니다. 객체 X는 그것을 독점적으로 붙잡고 있는 가장 가까운 객체에 귀속되는데, 이는 Catalog에서 X로 가는 모든 참조 경로가 그 지배자를 거쳐 간다는 뜻입니다. 모든 페이지가 공유하는 폰트는 어느 한 페이지에도 귀속되지 않습니다. 그 모든 경로가 지나가는 가장 가까운 노드, 대개는 Catalog 자체에 귀속됩니다. 페이지 하나에서만 쓰이는 폰트는 그 페이지에 귀속됩니다. 그 결과 EstimatedRetainedBytes는 중복 계산 없이 올바르게 합산되며, 목록 맨 위에 있는 객체는 실제로 제거하면 메모리가 해제되는 객체입니다
그래프가 실제로 담고 있는 것
각 THPDFObjectDependencyNode는 ObjectNumber와 GenerationNumber로 표현되는 객체 신원과, ObjectType, LifecycleState, EstimatedShallowBytes, EstimatedRetainedBytes, IncomingReferenceCount, OutgoingReferenceCount, 직접 지배자의 신원, ReachableFromCatalog를 담고 있습니다. 각 THPDFObjectDependencyEdge는 출발지, 목적지, 그 참조가 발견된 딕셔너리 Path, 그리고 Resolved 여부를 기록합니다
그 Path 필드는 사람들이 제대로 활용하지 못하는 필드입니다. 객체 91이 객체 4173을 가리킨다는 것을 아는 것과, 그것이 /Resources/XObject/Im3를 통해 그렇게 한다는 것을 아는 것 사이의 차이입니다. 후자는 여러분이 보고 있는 것이 페이지 콘텐츠인지, 주석 외관 스트림인지, 아니면 아무도 렌더링하지 않는 선택적 콘텐츠 그룹인지를 즉시 알려줍니다
var
Pdf: THotPDF;
Nodes: THPDFObjectDependencyNodeArray;
Edges: THPDFObjectDependencyEdgeArray;
Info: THPDFObjectDependencyGraphInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('report-800mb.pdf') <> 1 then Exit;
if not Pdf.BuildLoadedObjectDependencyGraph(Nodes, Edges, Info) then Exit;
if Info.LimitExceeded then
Log('Graph truncated: raise MaxObjects / MaxEdges');
Log(Format('%d objects, %d edges, %d reachable, catalog retains %d bytes',
[Info.ObjectCount, Info.EdgeCount, Info.ReachableObjectCount,
Info.CatalogRetainedBytes]));
SortByRetainedDescending(Nodes);
for I := 0 to Min(9, High(Nodes)) do
Log(Format('%d %d obj: shallow %d, retained %d, dominator %d',
[Nodes[I].ObjectNumber, Nodes[I].GenerationNumber,
Nodes[I].EstimatedShallowBytes, Nodes[I].EstimatedRetainedBytes,
Nodes[I].ImmediateDominatorObjectNumber]));
finally
Pdf.Free;
end;
end;
두 상한 모두 기본값이 각각 250,000개 객체와 2,000,000개 엣지인 명시적 매개변수입니다. 문서가 둘 중 하나를 넘으면 LimitExceeded가 설정되고, 반환되는 그래프는 거짓말이 아니라 잘린 앞부분이 됩니다. 플래그가 붙은 부분적인 결과일 뿐, 조용히 틀린 총계가 아닙니다. 포렌식 분석을 위해서라면 의도적으로 이 상한들을 올리십시오. 다만 이 분석이 전체 객체 그래프를 순회한다는 점을 기억하십시오. 그러므로 이는 여러분의 렌더 루프가 아니라 진단 경로에 속합니다
도달 불가능한 객체는 무엇을 말해주는가?
ReachableFromCatalog가 False로 설정된 객체는 문서가 지니고는 있지만 뷰어가 결코 보여주지 않을 메모리입니다. 실무에서는 세 곳에서 이런 일이 생깁니다. 객체의 이전 버전을 대체하고 원본을 남겨둔 증분 업데이트, 객체를 써놓고서는 연결에 실패한 생성기, 또는 상호 참조 테이블이 재구성되면서 아무도 참조하지 않는 정의를 주워 담은 손상된 파일입니다
첫 번째 경우는 정상적이고 예상된 것이며, 증분 업데이트와 객체 스트림이 정확히 그렇게 하도록 설계되어 있습니다. 두 번째와 세 번째는 조사해 볼 가치가 있습니다. 유지 바이트의 상당 부분이 도달 불가능한 노드에 있다면, 파일에 계속 덧붙이는 대신 다시 쓰는 편이 낫다는 구체적인 근거를 찾은 것이며, 누가 묻더라도 추가 처리 시간을 정당화할 바이트 수치도 갖게 됩니다
먼저 읽어볼 가치가 있는 두 가지 할당자 카운터
문서가 본래 크다고 결론짓기 전에, 비용의 원인이 파서 자체는 아닌지 확인하십시오. HotPDF는 문서가 담고 있는 내용이 아니라 로딩이 어떻게 진행되었는지를 서술하는 카운터 두 개를 노출합니다
GetLastParserArenaStatistics는 단명하는 파서 토큰과 버퍼에 쓰이는 유지 블록 아레나를 보고합니다. RetainedBytes, PeakUsedBytes, AllocationCount, ReusedAllocationCount, TokenCount, TemporaryObjectElisionCount입니다. 마지막 필드는 임시 이름 객체를 거치지 않고 아레나에 직접 파싱된 딕셔너리 키의 개수를 세는데, 이는 딕셔너리가 많은 파일의 파싱을 예전에 지배했던 할당입니다
GetDocumentStringInternStatistics는 반복되는 PDF 이름, 콘텐츠 스트림 연산자, 64바이트 이하의 불변 문자열을 중복 제거하는 문서 범위 인턴 풀을 보고합니다. RequestCount, HitCount, MissCount, BypassCount, RetainedBytes, ReusedBytes를 적용 중인 상한과 함께 알려줍니다. 이 풀은 의도적으로 65,536개 항목과 4MiB로 제한되어 있어서, 백만 개의 고유한 이름을 생성하는 악의적인 문서라도 메모리 최적화를 메모리 증폭기로 바꿔놓을 수 없습니다. 상한에 도달하면 이후의 문자열은 풀을 우회하고 BypassCount가 올라갑니다
var
Arena: THPDFParserArenaStatistics;
Intern: THPDFDocumentStringInternStatistics;
begin
if Pdf.GetLastParserArenaStatistics(Arena) then
Log(Format('arena: peak %d, reused %d of %d allocations, %d tokens',
[Arena.PeakUsedBytes, Arena.ReusedAllocationCount,
Arena.AllocationCount, Arena.TokenCount]));
if Pdf.GetDocumentStringInternStatistics(Intern) then
Log(Format('intern: %d/%d hits, %d bypassed, %d bytes reused',
[Intern.HitCount, Intern.RequestCount, Intern.BypassCount,
Intern.ReusedBytes]));
end;
ReusedBytes 값이 높고 BypassCount가 낮다면, 문서가 대부분의 실제 PDF가 가진 반복적인 어휘를 가지고 있고 풀이 제 몫을 하고 있다는 뜻입니다. BypassCount가 RequestCount에 근접한다면 무언가 이례적입니다. 정말로 거대한 문서이거나, 의도적으로 고유한 이름을 생성하는 문서이며, 이는 신뢰할 수 없는 수집 경로에서 로그로 남길 만한 경미한 신호입니다
효과적인 트리아지 순서
그래프 정보의 CatalogRetainedBytes부터 시작하십시오. 그 숫자가 여러분 프로세스의 증가량에 가깝다면, 메모리는 문서 안에 있으며 그래프가 어디인지 보여줄 것입니다. 그보다 한참 낮다면, 메모리는 여러분 자신의 캐시나 렌더링된 비트맵, 또는 파서 안에 있으며, 아레나 카운터가 어느 쪽인지 알려줄 것입니다
그런 다음 EstimatedRetainedBytes 기준 상위 10개 노드를 골라 그 ObjectType을 살펴보십시오. 이미지 XObject가 맨 위에 있다면 파일이 스캔 위주라는 뜻이며 다운샘플링이 해법입니다. 폰트 디스크립터가 맨 위에 있다면 서브셋으로 충분한 곳에 전체 서체가 임베드되어 있다는 뜻입니다. 콘텐츠 스트림이 맨 위에 있다면 대개 생성된 벡터 그래픽, 흔히 지도나 CAD 내보내기를 뜻합니다. 그 이후에야 도달 불가능한 객체와 할당자 동작을 살펴보는 것이 가치가 있습니다. 이 순서대로 작업하면 대개 처음 두 단계에서 답을 찾게 되며, 매우 큰 문서라면 직접 파일 API 워크플로에서 설명하는 스트리밍 방식이 개별 객체 최적화보다 구조적인 해법인 경우가 잦습니다
이 모든 진단 기능은 평범한 레코드를 반환하는 평범한 Pascal 호출이므로, 기존의 로깅이나 텔레메트리 경로에 그대로 끼워 넣을 수 있습니다. HotPDF는 전체 소스를 제공하는 Delphi와 C++Builder용 네이티브 VCL PDF 컴포넌트입니다. API 레퍼런스와 평가판은 HotPDF Delphi PDF 컴포넌트 페이지에 있습니다