HotPDF загружает PDF из любого источника с произвольным доступом, который вы реализуете, а THPDFCoalescingRandomAccessSource оборачивает этот источник так, что разрозненные мелкие чтения парсера превращаются в ограниченный набор кэшированных блочных диапазонов с асинхронной предзагрузкой. Для документа, отдаваемого через HTTP range-запросы, это разница между несколькими сотнями обменов и несколькими десятками
В самом парсере ничего не меняется. Вы по-прежнему вызываете LoadFromRandomAccessSource, возвращается тот же объект документа, и работает тот же API страниц. Меняется лишь трафик под капотом
Почему один и тот же PDF загружается мгновенно локально и еле ползёт по сети?
Потому что парсер PDF не читает файл, а перемещается по нему. Он переходит в конец файла за startxref, прыгает обратно к таблице перекрёстных ссылок, разрешает словарь трейлера, следует по ссылке к каталогу, затем к корню дерева страниц, затем к узлу страницы, затем к её словарю ресурсов. Каждый из этих шагов читает десятки байт с другого смещения
На локальном файле такой паттерн почти бесплатен: операционная система уже закэшировала окружающую 4-килобайтную страницу, поэтому второе чтение обходится в memcpy. При сетевом транспорте такой локальности нет. Каждое чтение — это запрос со своей собственной задержкой, а 300 последовательных запросов по 40 мс каждый — это двенадцать секунд, потраченных почти целиком на ожидание. Решение не в том, чтобы читать меньше — парсеру нужно именно то, что он запрашивает. Решение в том, чтобы каждое физическое чтение покрывало больше из того, что понадобится следующему логическому чтению
Что меняет объединение (coalescing)
Объединяющий источник округляет каждое чтение вверх до блока и кэширует этот блок. BlockSize по умолчанию равен 262 144 байтам, а MaxCacheBytes — 2 097 152, поэтому по умолчанию в памяти находится восемь блоков, вытесняемых в порядке давности использования (LRU) в пределах жёсткого байтового бюджета. 40-байтовое чтение ключа трейлера парсером подтягивает окружающие его 256 KiB, и следующий десяток чтений в этой же окрестности, где как раз и живут данные перекрёстных ссылок и каталога, обслуживаются уже из памяти
Ваш собственный источник остаётся простым. Реализуйте GetSize и ReadAt, переопределите 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 окно растёт через 1, 2, 4 и 8 блоков по мере накопления устойчивых чтений вперёд и никогда не превышает MaxReadAheadBlocks или настроенную ёмкость кэша. В момент, когда приходит чтение, которое начинается не примерно там, где закончилось предыдущее, окно схлопывается, а предзагрузка подавляется
SequentialReadToleranceBytes, по умолчанию 4096, как раз и определяет это «примерно». Чтения, попадающие в этот диапазон от конца предыдущего чтения, всё ещё считаются последовательными, что важно, поскольку парсер PDF, обходящий поток содержимого, не производит идеально смежных смещений — он то пропускает поле длины, то встроенный словарь. Установите допуск слишком низким — и обычное последовательное сканирование будет классифицировано как случайное, так что упреждающее чтение никогда не включится. Установите его слишком высоким — и настоящий случайный доступ будет выглядеть последовательным, так что вы будете забирать мегабайты, которые никому не нужны. Значение по умолчанию откалибровано для обхода потока содержимого, а статистика подскажет, не расходится ли с этим ваш транспорт
Эта асимметрия намеренна: рост постепенный, схлопывание — мгновенное. Избыточная загрузка при нагрузке со случайным доступом стоит реальной полосы пропускания и реальных денег на тарифицируемых каналах, поэтому дешёвая ошибка предпочтительнее дорогой
Отмена, которая действительно останавливает передачу
Базовый класс объявляет ReadAtCancellable, а объединяющий источник соблюдает его от начала и до конца. Когда приходит приоритетное чтение для диапазона, который не обслуживается текущей предзагрузкой в полёте, эта предзагрузка отменяется, а не доводится до конца, поэтому запрос пользователя на страницу не встаёт в очередь позади спекулятивного трафика. Реализация по умолчанию в THPDFRandomAccessSource откатывается к обычному ReadAt, а значит, функция включается по желанию для каждого транспорта: HTTP-клиенты с поддержкой прерывания запроса получают настоящую отмену, а более простые источники продолжают работать без изменений
Совместите это с токеном отмены, пронизывающим ваш интерфейс, — и пользователь, закрывающий документ, действительно останавливает сетевой трафик вместо того, чтобы ждать его завершения. Та же модель токенов лежит в основе очереди, описанной в статье о фоновом рендеринге с очередью запросов, поэтому один токен может покрыть весь путь от области просмотра до сокета
Чтение статистики кэша диапазонов
GetStatistics заполняет запись THPDFRangeCacheStatistics, которая отделяет действия вашего транспорта от действий кэша. SourceReadCount и SourceBytesRead — это физический трафик. CacheHitCount и CacheMissCount — это логический трафик. SequentialReadCount и RandomReadCount показывают, как был классифицирован паттерн доступа, CurrentReadAheadBlocks и PeakReadAheadBlocks показывают, насколько широко открывалось окно, а 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 размещает объекты первой страницы и таблицу подсказок в начале файла, поэтому программа просмотра может отрисовать первую страницу по первому же мегабайту, не видя остального. HotPDF предоставляет этот путь напрямую через GetProgressiveLinearizedLoadInfo и ReadProgressiveLinearizedFirstPageSection, а сторона записи рассмотрена в статье о генерации линеаризованных PDF с таблицами подсказок
Обе техники сочетаются друг с другом. Объединение делает любой документ терпимым по медленному каналу; линеаризация ускоряет доставку первой страницы для документов, которые вы генерируете сами. Для файлов, лежащих на локальном диске, но слишком крупных, чтобы поместиться в память, пути через отображённый файл и ленивые потоки, описанные в статье о рабочем процессе с прямым файловым API, обычно оказываются лучшим инструментом, поскольку там изначально нет задержки обмена, которую нужно амортизировать
HotPDF — это нативный VCL-компонент PDF для Delphi и C++Builder, без внешней DLL для парсера и с полным доступом к исходным текстам. API источника с произвольным доступом, объединяющая обёртка и точки входа прогрессивной загрузки задокументированы на странице HotPDF Delphi PDF component