Teknisk artikel

Stream fjern-PDF'er i Delphi: HotPDF range-coalescing

HotPDF indlæser en PDF fra enhver random-access-kilde, du selv implementerer, og THPDFCoalescingRandomAccessSource wrapper den kilde, så parserens spredte, små læsninger bliver til et afgrænset sæt af cachede blokintervaller med asynkron prefetch. På et dokument, der leveres over HTTP range-anmodninger, er det forskellen mellem nogle hundrede rundture og et par dusin

Intet ved parseren ændrer sig. Du kalder stadig LoadFromRandomAccessSource, det samme dokumentobjekt kommer tilbage, og den samme side-API virker. Det, der ændrer sig, er trafikken nedenunder

Hvorfor indlæses den samme PDF øjeblikkeligt lokalt, men kravler over netværket?

Fordi en PDF-parser ikke læser en fil, den navigerer i den. Den søger til slutningen efter startxref, hopper tilbage til krydsreferencetabellen, løser trailer-ordbogen, følger en reference til Catalog'et, derefter til sidetræets rod, derefter til en sideknude, derefter til dens ressourceordbog. Hvert af disse trin læser titalls bytes fra et andet offset

På en lokal fil er det mønster næsten gratis: operativsystemet har allerede den omgivende 4 KiB-side cachet, så den anden læsning koster en memcpy. Over en netværkstransport er der ingen sådan lokalitet. Hver læsning er en anmodning med sin egen ventetid, og 300 sekventielle anmodninger a 40 ms hver er tolv sekunder brugt næsten udelukkende på venten. Løsningen er ikke at læse mindre; parseren har brug for præcis det, den beder om. Løsningen er at få hver fysisk læsning til at dække mere af det, den næste logiske læsning vil have brug for

Hvad ændrer coalescing?

Coalescing-kilden runder hver læsning op til en blok og cacher blokken. BlockSize er som standard 262.144 byte og MaxCacheBytes 2.097.152, så otte blokke er residente som standard og udsættes for least-recently-used-udsmidning mod et hårdt bytebudget. Parserens 40-byte-læsning af en trailer-nøgle trækker den 256 KiB omkring den ind, og den næste snes læsninger i det nabolag, hvor krydsreference- og catalog-data bor, betjenes fra hukommelsen

Din egen kilde forbliver simpel. Implementér GetSize og ReadAt, overstyr ReadAtCancellable, hvis din transport kan afbryde undervejs, og lad wrapperen håndtere caching, coalescing og 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: wrapperen frigør Raw sammen med sig selv
  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;

Hvor langt frem bør den læse?

Adaptiv read-ahead besvarer det spørgsmål pr. dokument i stedet for at tvinge dig til at gætte. Med AdaptiveReadAheadEnabled sat vokser vinduet gennem 1, 2, 4 og 8 blokke, efterhånden som vedvarende fremadrettede læsninger akkumuleres, og det overstiger aldrig MaxReadAheadBlocks eller den konfigurerede cachekapacitet. I det øjeblik en læsning ankommer, der ikke nogenlunde er der, hvor den forrige sluttede, kollapser vinduet, og prefetch undertrykkes

SequentialReadToleranceBytes, standard 4.096, definerer "nogenlunde". Læsninger, der lander inden for den afstand fra den forrige læsnings slutning, tæller stadig som sekventielle, hvilket betyder noget, fordi en PDF-parser, der bevæger sig gennem en indholdsstrøm, ikke producerer perfekt sammenhængende offsets; den springer et længdefelt her, en indlejret ordbog der. Sæt tolerancen for lavt, og en normal fremadrettet scanning klassificeres som tilfældig, så read-ahead aldrig aktiveres. Sæt den for højt, og reel tilfældig adgang ser sekventiel ud, så du henter megabyte, ingen ønsker. Standarden er kalibreret til gennemløb af indholdsstrømme, og statistikken vil fortælle dig, om din transport er uenig

Denne asymmetri er bevidst: vækst er gradvis, kollaps er øjeblikkeligt. Overforbrug på en tilfældig-adgangs-arbejdsbyrde koster reel båndbredde og rigtige penge på målte transporter, så den billige fejl foretrækkes frem for den dyre

Annullering, der faktisk stopper overførslen

Basisklassen erklærer ReadAtCancellable, og coalescing-kilden overholder den ende til ende. Når en forgrundslæsning ankommer for et interval, som en igangværende prefetch ikke betjener, annulleres prefetchen frem for at blive ladt færdiggøre, så brugerens sideanmodning ikke stilles i kø bag spekulativ trafik. Standardimplementeringen på THPDFRandomAccessSource falder tilbage til en almindelig ReadAt, hvilket betyder, at funktionen er opt-in pr. transport: HTTP-klienter, der understøtter anmodningsafbrydelse, får reel annullering, og simplere kilder fortsætter uændret

Kombinér det med et annulleringstoken, der føres gennem din UI, og en bruger, der lukker et dokument, stopper faktisk netværkstrafikken i stedet for at vente på, at den drænes. Den samme token-model ligger bag den kø, der beskrives i baggrundsgengivelse med en anmodningskø, så ét token kan dække hele stien fra viewporten til socket'en

Læsning af range-cache-statistikken

GetStatistics udfylder en THPDFRangeCacheStatistics-record, der adskiller, hvad din transport gjorde, fra hvad cachen gjorde. SourceReadCount og SourceBytesRead er fysisk trafik. CacheHitCount og CacheMissCount er logisk trafik. SequentialReadCount og RandomReadCount viser, hvordan adgangsmønsteret blev klassificeret, CurrentReadAheadBlocks og PeakReadAheadBlocks viser, hvor langt vinduet åbnede sig, og PrefetchRequestCount, PrefetchCompletedCount, PrefetchCancelledCount og SuppressedPrefetchCount viser, om spekulationen betalte sig

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;

Tre aflæsninger fortæller dig, hvad du skal ændre. Mange annullerede prefetch med et højt antal tilfældige læsninger betyder, at dokumentet tilgås ude af rækkefølge, så sænk MaxReadAheadBlocks, og stop med at betale for båndbredde, du kasserer. Mange misses med et spidsvindue stadig på 1 betyder, at tolerancen afviser et mønster, der reelt er sekventielt, så hæv SequentialReadToleranceBytes. Og bytes læst langt ud over filstørrelsen betyder, at cachen tærsker, så hæv MaxCacheBytes, før du rører ved noget andet

Lineariserede filer ændrer regnestykket

Hvis du kontrollerer producenten, ændrer linearisering af dokumentet problemet frem for at optimere det. En lineariseret PDF placerer den første sides objekter og en hint-tabel forrest i filen, så en viewer kan gengive side ét fra den indledende megabyte uden at se resten. HotPDF eksponerer den sti direkte gennem GetProgressiveLinearizedLoadInfo og ReadProgressiveLinearizedFirstPageSection, og skrivesiden dækkes i generering af lineariserede PDF'er med hint-tabeller

Begge teknikker kombinerer. Coalescing gør ethvert dokument tåleligt over en langsom forbindelse; linearisering gør, at den første side ankommer hurtigt på dokumenter, du selv producerer. For filer, der ligger på en lokal disk, men er for store til at holde i hukommelsen, er de mappede fil- og lazy-stream-stier, beskrevet i den direkte fil-API-workflow, normalt det bedre værktøj, da der slet ikke er nogen rundtursventetid at afskrive

HotPDF er en native VCL-PDF-komponent til Delphi og C++Builder, uden ekstern DLL til parseren og med fuld kildekode tilgængelig. Random-access-kilde-API'en, coalescing-wrapperen og de progressive indlæsningsindgangspunkter er dokumenteret på HotPDF's produktside for Delphi PDF-komponenten