Articol tehnic

Streaming PDF-uri de la distanță în Delphi cu HotPDF

HotPDF încarcă un PDF din orice sursă cu acces aleatoriu pe care o implementezi, iar THPDFCoalescingRandomAccessSource încapsulează acea sursă astfel încât citirile mici și împrăștiate ale parserului devin un set limitat de intervale de blocuri cache cu prefetch asincron. Pe un document servit prin cereri HTTP range, aceasta este diferența dintre câteva sute de round trip-uri și câteva zeci

Nimic din parser nu se schimbă. Tot apelezi LoadFromRandomAccessSource, se întoarce același obiect document, iar același API de pagini funcționează. Ce se schimbă este traficul de dedesubt

De ce același PDF se încarcă instant local și se târăște pe rețea?

Pentru că un parser PDF nu citește un fișier, ci îl navighează. Sare la sfârșit pentru startxref, revine la tabela cross-reference, rezolvă dicționarul trailer, urmează o referință către Catalog, apoi către rădăcina arborelui de pagini, apoi către un nod de pagină, apoi către dicționarul lui de resurse. Fiecare dintre acești pași citește zeci de bytes de la un offset diferit

Pe un fișier local acest tipar este aproape gratuit: sistemul de operare are deja pagina de 4 KiB din jur în cache, așa că a doua citire costă un memcpy. Printr-un transport de rețea nu există o astfel de localitate. Fiecare citire este o cerere cu propria latență, iar 300 de cereri secvențiale la 40 ms fiecare înseamnă douăsprezece secunde petrecute aproape în întregime așteptând. Soluția nu este să citești mai puțin; parserul are nevoie exact de ce cere. Soluția este ca fiecare citire fizică să acopere mai mult din ce va cere următoarea citire logică

Ce schimbă coalescing-ul

Sursa de coalescing rotunjește fiecare citire în sus până la un bloc și pune blocul în cache. BlockSize are implicit 262.144 bytes, iar MaxCacheBytes are implicit 2.097.152, astfel încât opt blocuri sunt rezidente implicit și sunt evacuate în ordine least-recently-used față de un buget strict de bytes. Citirea de 40 de bytes a parserului pentru o cheie trailer aduce cu ea cei 256 KiB din jur, iar următoarele câteva zeci de citiri din acea vecinătate, unde locuiesc datele cross-reference și catalog, sunt servite din memorie

Propria ta sursă rămâne simplă. Implementează GetSize și ReadAt, suprascrie ReadAtCancellable dacă transportul tău poate abandona în timp ce citirea este în curs, și lasă wrapper-ul să gestioneze cache-ul, coalescing-ul și prefetch-ul

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: wrapper-ul eliberează Raw odată cu el însuși
  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;

Cât de mult înainte ar trebui să citească?

Read-ahead-ul adaptiv răspunde la această întrebare pentru fiecare document, în loc să te oblige să ghicești. Cu AdaptiveReadAheadEnabled activat, fereastra crește prin 1, 2, 4 și 8 blocuri pe măsură ce se acumulează citiri înainte susținute, și nu depășește niciodată MaxReadAheadBlocks sau capacitatea de cache configurată. În momentul în care sosește o citire care nu se află aproximativ acolo unde s-a terminat cea anterioară, fereastra se prăbușește și prefetch-ul este suprimat

SequentialReadToleranceBytes, implicit 4.096, definește „aproximativ”. Citirile care ajung în acea distanță de sfârșitul citirii anterioare încă mai contează ca secvențiale, ceea ce contează pentru că un parser PDF care parcurge un content stream nu produce offset-uri perfect contigue; sare peste un câmp de lungime ici, un dicționar inline colo. Setează toleranța prea jos și o baleiere normală înainte este clasificată ca aleatorie, astfel încât read-ahead-ul nu se activează niciodată. Setează-o prea sus și accesul cu adevărat aleatoriu pare secvențial, astfel încât aduci megabytes pe care nimeni nu îi vrea. Valoarea implicită este calibrată pentru parcurgerea content stream-urilor, iar statisticile îți vor spune dacă transportul tău nu este de acord

Această asimetrie este deliberată: creșterea este graduală, prăbușirea este imediată. Supra-aducerea pe o încărcare de lucru cu acces aleatoriu costă lățime de bandă reală și bani reali pe transporturi taxate la volum, așa că greșeala ieftină este preferată în locul celei scumpe

Anularea care chiar oprește transferul

Clasa de bază declară ReadAtCancellable, iar sursa de coalescing o respectă de la un capăt la altul. Când sosește o citire în prim-plan pentru un interval pe care un prefetch aflat în desfășurare nu îl deservește, prefetch-ul este anulat în loc să fie lăsat să se termine, astfel încât cererea de pagină a utilizatorului nu stă la coadă în spatele traficului speculativ. Implementarea implicită din THPDFRandomAccessSource revine la un simplu ReadAt, ceea ce înseamnă că funcționalitatea este opt-in per transport: clienții HTTP care suportă abandonarea cererii primesc o anulare autentică, iar sursele mai simple continuă să funcționeze neschimbate

Combină asta cu un token de anulare propagat prin UI-ul tău, iar utilizatorul care închide un document chiar oprește traficul de rețea, în loc să aștepte să se scurgă. Același model de token stă la baza cozii descrise în randarea în fundal cu o coadă de cereri, astfel încât un singur token poate acoperi întregul traseu de la viewport până la socket

Interpretarea statisticilor cache-ului de interval

GetStatistics completează o înregistrare THPDFRangeCacheStatistics care separă ce a făcut transportul tău de ce a făcut cache-ul. SourceReadCount și SourceBytesRead sunt trafic fizic. CacheHitCount și CacheMissCount sunt trafic logic. SequentialReadCount și RandomReadCount arată cum a fost clasificat tiparul de acces, CurrentReadAheadBlocks și PeakReadAheadBlocks arată cât de mult s-a deschis fereastra, iar PrefetchRequestCount, PrefetchCompletedCount, PrefetchCancelledCount și SuppressedPrefetchCount arată dacă specularea a meritat

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;

Trei citiri îți spun ce să schimbi. Multe prefetch-uri anulate cu un număr mare de citiri aleatorii înseamnă că documentul este accesat dezordonat, așa că scade MaxReadAheadBlocks și oprește-te din plătit pentru lățime de bandă pe care o arunci. Multe rateuri cu o fereastră de vârf încă la 1 înseamnă că toleranța respinge un tipar care este efectiv secvențial, așa că crește SequentialReadToleranceBytes. Iar bytes citiți depășind cu mult dimensiunea fișierului înseamnă că cache-ul se zbate, așa că crește MaxCacheBytes înainte de a atinge orice altceva

Fișierele liniarizate schimbă aritmetica

Dacă controlezi producătorul, liniarizarea documentului schimbă problema, nu doar o optimizează. Un PDF liniarizat plasează obiectele primei pagini și o tabelă hint la începutul fișierului, astfel încât un viewer poate randa pagina unu din primul megabyte fără să vadă restul. HotPDF expune acest traseu direct prin GetProgressiveLinearizedLoadInfo și ReadProgressiveLinearizedFirstPageSection, iar partea de scriere este acoperită în generarea de PDF-uri liniarizate cu tabele hint

Ambele tehnici se combină. Coalescing-ul face orice document tolerabil pe o legătură lentă; liniarizarea face ca prima pagină să sosească rapid pentru documentele pe care le produci chiar tu. Pentru fișierele care trăiesc pe un disc local, dar sunt prea mari pentru a încăpea în memorie, traseele mapped-file și lazy-stream descrise în workflow-ul API-ului de fișiere direct sunt de obicei instrumentul mai bun, întrucât oricum nu există latență de round-trip de amortizat

HotPDF este o componentă VCL nativă pentru Delphi și C++Builder, fără niciun DLL extern pentru parser și cu sursă completă disponibilă. API-ul sursei cu acces aleatoriu, wrapper-ul de coalescing și punctele de intrare pentru încărcarea progresivă sunt documentate pe pagina componentei HotPDF Delphi PDF