Technický článek

Streamování PDF ze sítě v Delphi: slučování rozsahů HotPDF

HotPDF načítá PDF z libovolného zdroje s náhodným přístupem, který si sami implementujete, a THPDFCoalescingRandomAccessSource tento zdroj obaluje tak, že se roztroušená malá čtení parseru promění v ohraničenou sadu cachovaných rozsahů bloků s asynchronním prefetchem. U dokumentu obsluhovaného přes HTTP range requesty je to rozdíl mezi několika stovkami a několika desítkami síťových cest tam a zpět

Na parseru samotném se nic nemění. Stále voláte LoadFromRandomAccessSource, vrátí se stejný objekt dokumentu a funguje stejné API pro stránky. Co se mění, je provoz pod tím vším

Proč se stejné PDF načte lokálně okamžitě a přes síť se plazí?

Protože parser PDF soubor nečte, ale naviguje v něm. Přeskočí na konec kvůli startxref, skočí zpátky na tabulku křížových odkazů, vyřeší slovník trailer, sleduje odkaz na Catalog, pak na kořen stromu stránek, pak na uzel stránky, pak na jeho slovník zdrojů. Každý z těchto kroků přečte desítky bajtů z jiného offsetu

U lokálního souboru je tento vzorec téměř zadarmo: operační systém už má okolní 4KiB stránku v cache, takže druhé čtení stojí jen memcpy. Přes síťový přenos žádná taková lokalita neexistuje. Každé čtení je samostatný požadavek s vlastní latencí, a 300 sekvenčních požadavků po 40 ms je dvanáct sekund strávených téměř výhradně čekáním. Oprava nespočívá v tom číst méně; parser potřebuje přesně to, o co si řekne. Oprava spočívá v tom, aby každé fyzické čtení pokrylo víc z toho, co bude chtít další logické čtení

Co slučování mění

Slučovací zdroj zaokrouhlí každé čtení nahoru na celý blok a tento blok cachuje. BlockSize má výchozí hodnotu 262 144 bajtů a MaxCacheBytes 2 097 152, takže je ve výchozím stavu rezidentních osm bloků a vyřazují se v pořadí least-recently-used proti pevnému bajtovému rozpočtu. 40bajtové čtení klíče trailer u parseru natáhne 256 KiB kolem sebe a další desítka čtení v tomto sousedství, kde žijí data křížových odkazů a katalogu, se obslouží z paměti

Váš vlastní zdroj zůstává jednoduchý. Implementujte GetSize a ReadAt, přetižte ReadAtCancellable, pokud váš přenos umí přerušit za letu, a nechte obalující vrstvu, ať se postará o cachování, slučování a 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: obalující vrstva uvolní Raw spolu se sebou
  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;

Jak daleko dopředu by se mělo číst?

Adaptivní read-ahead odpovídá na tuto otázku pro každý dokument zvlášť, místo aby vás nutil hádat. Když je nastaveno AdaptiveReadAheadEnabled, okno roste přes 1, 2, 4 a 8 bloků, jak se hromadí trvalá čtení vpřed, a nikdy nepřekročí MaxReadAheadBlocks ani nastavenou kapacitu cache. V okamžiku, kdy přijde čtení, které zhruba nenavazuje na konec předchozího, okno se zhroutí a prefetch se potlačí

SequentialReadToleranceBytes, výchozí hodnota 4096, definuje ono „zhruba". Čtení, která přistanou v této vzdálenosti od konce předchozího čtení, se stále počítají jako sekvenční, což má význam, protože parser PDF procházející obsahový proud negeneruje dokonale souvislé offsety; tu přeskočí pole délky, tam vložený slovník. Nastavte toleranci příliš nízko a normální skenování vpřed se klasifikuje jako náhodné, takže se read-ahead nikdy nezapojí. Nastavte ji příliš vysoko a skutečně náhodný přístup vypadá jako sekvenční, takže natáhnete megabajty, které nikdo nechce. Výchozí hodnota je kalibrovaná pro procházení obsahových proudů a statistiky vám řeknou, pokud váš přenos funguje jinak

Tato asymetrie je záměrná: růst je postupný, zhroucení je okamžité. Přílišné stahování při zátěži s náhodným přístupem stojí skutečnou šířku pásma a skutečné peníze u zpoplatněných přenosů, takže se dává přednost levné chybě před drahou

Zrušení, které přenos skutečně zastaví

Základní třída deklaruje ReadAtCancellable a slučovací zdroj ho respektuje od začátku do konce. Když dorazí popředí čtení pro rozsah, který právě neobsluhuje prefetch za letu, tento prefetch se zruší, místo aby se nechal doběhnout, takže požadavek uživatele na stránku nečeká ve frontě za spekulativním provozem. Výchozí implementace v THPDFRandomAccessSource se vrací k obyčejnému ReadAt, což znamená, že funkce je volitelná podle typu přenosu: HTTP klienti, kteří podporují přerušení požadavku, dostanou skutečné zrušení a jednodušší zdroje fungují dál beze změny

Zkombinujte to s tokenem pro zrušení protaženým skrz vaše UI a uživatel, který zavře dokument, skutečně zastaví síťový provoz místo čekání, až doteče. Stejný model tokenu stojí i za frontou popsanou v článku o vykreslování na pozadí s frontou požadavků, takže jeden token může pokrýt celou cestu od viewportu až po socket

Čtení statistik cache rozsahů

GetStatistics naplní záznam THPDFRangeCacheStatistics, který odděluje to, co udělal váš přenos, od toho, co udělala cache. SourceReadCount a SourceBytesRead jsou fyzický provoz. CacheHitCount a CacheMissCount jsou logický provoz. SequentialReadCount a RandomReadCount ukazují, jak byl klasifikován vzorec přístupu, CurrentReadAheadBlocks a PeakReadAheadBlocks ukazují, jak daleko se okno otevřelo, a PrefetchRequestCount, PrefetchCompletedCount, PrefetchCancelledCount a SuppressedPrefetchCount ukazují, jestli se spekulace vyplatila

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;

Tři hodnoty vám řeknou, co změnit. Hodně zrušených prefetchů spolu s vysokým počtem náhodných čtení znamená, že se k dokumentu přistupuje bez pořadí, takže snižte MaxReadAheadBlocks a přestaňte platit za šířku pásma, kterou zahazujete. Hodně chybějících záznamů se špičkovým oknem stále na 1 znamená, že tolerance odmítá vzorec, který je fakticky sekvenční, takže zvyšte SequentialReadToleranceBytes. A počet přečtených bajtů výrazně přesahující velikost souboru znamená, že cache trhá, takže než se pustíte do čehokoli jiného, zvyšte MaxCacheBytes

Linearizované soubory mění celý výpočet

Pokud máte kontrolu nad producentem, linearizace dokumentu problém spíš mění, než že by ho optimalizovala. Linearizované PDF umístí objekty první stránky a tabulku nápověd na začátek souboru, takže prohlížečka může vykreslit stránku jedna z prvního megabajtu, aniž by viděla zbytek. HotPDF zpřístupňuje tuto cestu přímo přes GetProgressiveLinearizedLoadInfo a ReadProgressiveLinearizedFirstPageSection, a stranu zápisu popisuje článek o generování linearizovaných PDF s tabulkami nápověd

Obě techniky se dají kombinovat. Slučování dělá snesitelným jakýkoli dokument přes pomalé spojení; linearizace zajistí, že první stránka dorazí rychle u dokumentů, které si sami generujete. U souborů, které žijí na lokálním disku, ale jsou příliš velké na to, aby se vešly do paměti, jsou obvykle lepším nástrojem cesty přes mapovaný soubor a líné proudy popsané v článku o workflow přímého souborového API, protože tam beztak není žádná latence síťové cesty tam a zpět, kterou by bylo potřeba amortizovat

HotPDF je nativní komponenta VCL pro PDF pro Delphi a C++Builder, bez externí DLL pro parser a s dostupným kompletním zdrojovým kódem. API pro zdroj s náhodným přístupem, slučovací obalující vrstva i vstupní body progresivního načítání jsou zdokumentované na stránce HotPDF Delphi PDF component