HotPDF предоставляет профиль памяти загруженного PDF в виде графа, который можно запрашивать: BuildLoadedObjectDependencyGraph возвращает по одному узлу на каждый косвенный объект с оценённым поверхностным размером, доминаторным retained-размером и флагом того, достижим ли объект по-прежнему из каталога документа. Это превращает утверждение «этот файл занимает 800 МБ» в утверждение «объект 4173, XObject изображения, эксклюзивно удерживает 612 МБ» — а это уже факт, на основании которого можно действовать
Различие между этими двумя утверждениями и есть вся суть. Поверхностный размер сообщает, насколько велик один объект. Retained-размер сообщает, сколько памяти реально освободится, если этот объект исчезнет, — и именно это число решает, поможет ли исправление
Почему общий объём используемой памяти — не факт, на основе которого можно действовать?
Потому что в PDF почти ничто не принадлежит ровно одной сущности. Один встроенный CID-шрифт упоминается в словаре ресурсов каждой страницы, которая его использует. Поток ICC-профиля обслуживает цветовое пространство, которое разделяют десять разных потоков содержимого. Form XObject, используемый как штамп, появляется на всех 400 страницах. Если наивно суммировать размеры объектов по страницам, этот шрифт будет посчитан 400 раз, и вы решите, что каждая страница огромна; если разделить на 400, вы решите, что ничего дорогого нет, а память ушла куда-то ещё
Анализ доминаторов снимает эту неоднозначность единственным способом, который выдерживает столкновение с реальными документами. Объект X приписывается ближайшему объекту, который эксклюзивно его удерживает, то есть каждый путь ссылок от каталога к X проходит через этот доминатор. Шрифт, разделяемый всеми страницами, не приписывается ни одной странице — он приписывается ближайшему узлу, через который проходят все эти пути, обычно это сам каталог. Шрифт, используемый ровно одной страницей, приписывается этой странице. В результате 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 записями и 4 MiB, поэтому враждебный документ, генерирующий миллион уникальных имён, не может превратить оптимизацию памяти в её усилитель; как только предел достигнут, дальнейшие строки обходят пул, и 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 и посмотрите на их ObjectType. XObject-ы изображений наверху означают, что файл насыщен сканами, и решением будет понижение разрешения. Дескрипторы шрифтов наверху означают, что были встроены полные начертания там, где хватило бы подмножеств. Потоки содержимого наверху обычно означают сгенерированную векторную графику, часто карты или экспорт из CAD. Только после этого имеет смысл смотреть на недостижимые объекты и поведение аллокатора. Работая в таком порядке, вы обычно найдёте ответ уже на первых двух шагах, а для очень крупных документов потоковый подход, описанный в статье о рабочем процессе с прямым файловым API, зачастую является структурным решением, а не какой-либо поэтобъектной оптимизацией
Все эти диагностические средства — это обычные вызовы Pascal, возвращающие обычные записи, поэтому они встраиваются прямо в существующий путь логирования или телеметрии. HotPDF — это нативный VCL-компонент PDF для Delphi и C++Builder с полными исходными текстами; справочник по API и пробная сборка доступны на странице HotPDF Delphi PDF component