Техническа статия

Стриймвайте отдалечени PDF в Delphi: HotPDF Coalescing

HotPDF зарежда PDF от всякакъв random-access източник, който имплементирате, а THPDFCoalescingRandomAccessSource обвива този източник, така че разпръснатите малки четения на parser-а се превръщат в ограничен набор от кеширани блокови диапазони с асинхронен prefetch. При документ, обслужван през HTTP range заявки, това е разликата между няколкостотин round trip-а и няколко дузини

В parser-а нищо не се променя. Все така извиквате LoadFromRandomAccessSource, връща се същият обект документ и работи същото API за страници. Онова, което се променя, е трафикът отдолу

Защо един и същ PDF се зарежда моментално локално и пълзи през мрежата?

Защото PDF parser не чете файл, той го навигира. Прескача до края за startxref, скача назад до cross-reference таблицата, разрешава trailer речника, следва референция до Catalog, после до корена на дървото на страниците, после до възел на страница, после до неговия resource dictionary. Всяка от тези стъпки чете десетки байтове от различен offset

В локален файл този образец е почти безплатен: операционната система вече има съседните 4 KiB кеширани, така че второто четене струва memcpy. През мрежов транспорт няма такава локалност. Всяко четене е заявка със собствена латентност, а 300 последователни заявки по 40 ms всяка са дванайсет секунди, изразходвани почти изцяло в чакане. Поправката не е да четете по-малко; parser-ът се нуждае точно от онова, което иска. Поправката е да накарате всяко физическо четене да покрива повече от онова, което следващото логическо четене ще поиска

Какво променя coalescing-ът

Coalescing източникът закръгля всяко четене нагоре до блок и кешира блока. BlockSize е по подразбиране 262 144 байта, а MaxCacheBytes — 2 097 152, така че по подразбиране осем блока стоят резидентни и се изхвърлят по ред least-recently-used срещу твърд бюджет от байтове. 40-байтовото четене на trailer ключ от parser-а изтегля 256-те KiB около него, а следващата дузина четения в тази околност, където живеят данните за cross-reference и catalog, се обслужват от паметта

Собственият ви източник остава прост. Имплементирайте GetSize и ReadAt, override-нете ReadAtCancellable, ако транспортът ви може да прекъсне в движение, и оставете обвивката да се справи с кеширането, coalescing-а и prefetch-а

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;

Колко напред трябва да чете?

Адаптивният read-ahead отговаря на този въпрос за всеки документ поотделно, вместо да ви кара да гадаете. С включен AdaptiveReadAheadEnabled прозорецът расте през 1, 2, 4 и 8 блока, докато се натрупват последователни четения напред, и никога не надхвърля MaxReadAheadBlocks или конфигурирания капацитет на кеша. В момента, в който пристигне четене, което не е приблизително там, където е свършило предишното, прозорецът се срутва и prefetch-ът се потиска

SequentialReadToleranceBytes, по подразбиране 4096, дефинира „приблизително“. Четения, попадащи в тази дистанция от края на предишното четене, все още се броят за последователни, което има значение, защото PDF parser, обхождащ content stream, не произвежда перфектно съседни offset-и; той прескача поле за дължина тук, вграден речник там. Задайте толеранса твърде нисък и обичайно сканиране напред се класифицира като случайно, така че read-ahead никога не се задейства. Задайте го твърде висок и истински случаен достъп изглежда последователен, така че извличате мегабайти, които никой не иска. Подразбирането е калибрирано за обхождане на content stream, а статистиката ще ви каже дали транспортът ви не е съгласен

Тази асиметрия е съзнателна: растежът е постепенен, срутването е моментално. Прекомерното извличане при workload със случаен достъп струва реален трафик и реални пари при таксувани транспорти, така че евтината грешка е предпочетена пред скъпата

Cancellation, който наистина спира трансфера

Базовият клас декларира ReadAtCancellable, а coalescing източникът го спазва докрай. Когато пристигне четене на преден план за диапазон, който не се обслужва от активен prefetch, prefetch-ът се отменя, вместо да бъде оставен да завърши, така че заявката на потребителя за страница не е опашкирана зад спекулативен трафик. Подразбиращата се имплементация на THPDFRandomAccessSource се връща към обикновено ReadAt, което означава, че функцията е opt-in за всеки транспорт: HTTP клиенти, поддържащи прекъсване на заявка, получават истинско cancellation, а по-простите източници продължават да работят непроменени

Комбинирайте това с cancellation token, прекаран през вашия UI, и потребител, затварящ документ, наистина спира мрежовия трафик, вместо да чака да се изцеди. Същият модел на token стои и зад опашкирането, описано в фоновото рендиране с опашка от заявки, така че един token може да покрие целия път от viewport-а до сокета

Четене на статистиката на range кеша

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;

Три показания ви казват какво да смените. Много отменени prefetch-и с висок брой случайни четения означава, че документът се достъпва извън ред, така че намалете MaxReadAheadBlocks и спрете да плащате за трафик, който изхвърляте. Много пропуски с прозорец на върха, все още равен на 1, означава, че толерансът отхвърля образец, който на практика е последователен, така че повишете SequentialReadToleranceBytes. А байтове, прочетени далеч над размера на файла, означават, че кешът тресе, така че повишете MaxCacheBytes, преди да пипате нещо друго

Линеаризираните файлове променят аритметиката

Ако контролирате производителя, линеаризирането на документа променя проблема, вместо да го оптимизира. Линеаризиран PDF поставя обектите на първата страница и хинт таблица в началото на файла, така че зрител може да рендира страница едно от отварящия мегабайт, без да вижда останалото. HotPDF излага този път директно през GetProgressiveLinearizedLoadInfo и ReadProgressiveLinearizedFirstPageSection, а страната на писането е разгледана в генерирането на линеаризирани PDF с хинт таблици

Двете техники се комбинират. Coalescing-ът прави всеки документ поносим през бавна връзка; линеаризацията прави първата страница да пристигне бързо при документи, които произвеждате сами. За файлове, живеещи на локален диск, но твърде големи, за да се поберат в паметта, пътищата с mapped-файл и мързелив stream, описани в работния процес на Direct File API, обикновено са по-добрият инструмент, тъй като там изобщо няма round-trip латентност за амортизиране

HotPDF е native VCL PDF компонент за Delphi и C++Builder, без външна DLL за parser-а и с пълен изходен код. API-то за random-access източник, coalescing обвивката и точките за прогресивно зареждане са документирани на страницата на HotPDF PDF компонента за Delphi