Teknisk artikkel

HotPDF Sideuttrekksytelse i Delphi

To minutter på å kopiere tre sider ut av en 40-siders PDF er ikke et problem med ytelsesjustering. Det er et signal om at feil API-vei blir brukt. Første gang jeg så denne tidsbruken på et HotPDF Component sidekopieringseksempel, var instinktet mitt å se på dokumentstrukturen først og koden etterpå. Den rekkefølgen viste seg å spille en rolle

Hva som faktisk var tregt

PDF-en det var snakk om, var et referansedokument på 40 sider med et ikke-trivielt sidetre: flere mellomliggende /Pages-noder i stedet for en enkel, flat array (liste). Den opprinnelige eksempelkoden kalte LoadFromFile, bygget deretter et nytt dokument med BeginDoc, loopet over valgte sidetall, og på hver iterasjon lastet den kildedokumentet fra disk igjen for å hente én side. Det betyr den fulle parse-kostnaden multiplisert med hvor mange sider du vil ha. En fil på 12 MB traff disken seks ganger for et tre-siders uttrekk, fordi ingen så på hvorvidt filen behøvde å forbli åpen på tvers av iterasjonene

Den andre bidragsyteren var usynlig i koden: HotPDFs LoadFromFile løser opp (resolves) hele kryssreferansetabellen, og dekomprimerer hvert objekt-strøm ved innlasting. Det er riktig atferd for et dokument du er i ferd med å endre, men det er mer arbeid enn du trenger hvis du kun er ute etter sideantall og et delsett av sider. For skrivebeskyttet (read-only) tilgang til struktur unngår DAOpenFileReadOnly å deserialisere (deserialize) hele objekt-treet, noe som utgjør en stor forskjell på komprimerte filer med store bilderessurser

Ingen av disse er en bibliotekfeil. Begge er kallere (callers) som velger API-et designet for én jobb og bruker det til en annen

Bruke InsertPagesFromDocument for sideuttrekk

Den rette veien for å kopiere en rekke sider fra ett HotPDF-dokument og inn i et annet, er InsertPagesFromDocument, som kalles etter LoadFromFile på kilden. Du laster kilden én gang, laster eller oppretter destinasjonen én gang, flytter sidene, og lagrer. Kilden forblir i minnet gjennom alle side-innsettingene:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    // Load source once: full parse happens here and only here
    Source.LoadFromFile(SourceFile);

    // Build a minimal destination document
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // Copy the requested range; '1-3' inserts pages 1 through 3
    // starting at position 1 in the destination
    Dest.InsertPagesFromDocument(Source, PageRange, 1);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

PageRange-parameteren godtar samme format som kommandolinjeeksempelet: en kommadelt liste med sidenumre eller serier som '1-3' eller '1,5,7-9'. Sider er 1-basert. InsertPagesFromDocument kopierer innholdsstrømmer, ressursordbøker og sidegeometri uten å røre metadata, bokmerker eller innbygde filvedlegg, med mindre de er referert fra de kopierte sidene. For et tre-siders uttrekk fra et 40-siders dokument er det et ganske lite arbeidssett

Tidtaking på den samme 12 MB-filen som tidligere kjørte i to minutter: under 1,5 sekunder med dette mønsteret. Det meste av den tiden er selve LoadFromFile-kallet. Dokumentstrukturen er irrelevant når først objekttabellen er løst opp (resolved) første gang

Når LoadFromFile er for mye: Direct File API-et

Hvis du bare trenger å telle sider, inspisere dokumentinformasjon, eller kopiere en fil uten å røre innholdet, unngår Direct File API-et det fulle parset i sin helhet. DAOpenFileReadOnly kartlegger (maps) kryssreferansetabellen uten å dekomprimere objekt-strømmer, slik at sidetelling er O(xref size) fremfor O(file size):

procedure InspectPDF(const FileName: string);
var
  Pdf: THotPDF;
  Handle, PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Handle := Pdf.DAOpenFileReadOnly(FileName, '');
    if Handle <= 0 then
      Exit;
    try
      PageCount := Pdf.DAGetPageCount(Handle);
      Writeln('Pages: ', PageCount);

      // DACopyFile is a byte-preserving copy, no re-serialization
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

Haken (the caveat): DAOpenFileReadOnly godtar en passordparameter, men faller tilbake til en full parse for krypterte inndata (inputs), fordi dekryptering krever at objekttreet løser (resolves) krypteringsordboken. Hvis kildefilene dine er kryptert, dekrypter dem først med DecryptFile for å få en ukryptert kopi, og åpne deretter den med Direct File API-et. DecryptFile-funksjonen på filnivå tar en direkte AES-256 omskrivingsvei for standard kryptering og er raskere enn LoadFromFile fulgt av SaveLoadedDocument for store filer, fordi den ikke bygger den fulle minnebaserte objektmodellen (in-memory object model)

Minne under batchbehandling i stor skala

Batchjobber som behandler dusinvis av filer i en løkke (loop) har et mønster som ser korrekt ut, men akkumulerer minne: man oppretter THotPDF inne i løkken, kaller LoadFromFile, gjør arbeid, og kaller Free. Strukturelt sett er det helt fint. Problemet oppstår når det indre arbeidet allokerer midlertidige (scratch) objekter, fanger opp unntak (exceptions), og lar de midlertidige objektene leve på feil-veier (error paths). Delphis minnehåndterer (memory manager) komprimerer ikke (compacts), så hundre feil-vei lekkasjer på tvers av en batch-kjøring kan skyve minnet høyt nok til å bremse allokering for alt mulig annet

Løsningen er ikke eksotisk. Hver THotPDF og enhver mellomliggende TStream eller TBitmap som tar del i PDF-arbeid, hører hjemme i en try/finally-blokk, hvor Free er siste instruksjon. Sett lokale pekere til nil før try, slik at finally-grenen kan bruke if Assigned(x) then x.Free trygt, når initialisering feiler underveis. Dette er standard eierskapsdisiplin i Delphi, og det er hele historien for denne klassen av problemer

En ting til å sjekke i batch-sammenhenger: AddImage registrerer bilder i en intern liste som vedvarer gjennom levetiden til THotPDF-instansen. Hvis du gjenbruker én enkelt instans på tvers av mange dokumenter ved å kalle LoadFromFile gjentatte ganger, blir bilderegistreringer fra tidligere dokumenter værende i listen. Enten må du opprette en fersk instans per dokument, eller kalle ryddeprosedyren for bildelisten mellom dokumentene

Måle før man endrer noe

Før du griper etter noen av disse mønstrene, mål opp. Delphis TStopwatch fra System.Diagnostics pakker inn QueryPerformanceCounter, og er nøyaktig nok for vegg-klokke-profilering (wall-clock profiling) av fil-I/O. Legg den rundt LoadFromFile alene, og se hvor lang tid dette tar. Hvis det utgjør 90% av totaltiden, er løsningen Direct File API-et eller å redusere hvor mange ganger du parser den samme filen. Hvis det er under 20%, er flaskehalsen et annet sted, og du jakter på feil ting

De to minuttene med uttrekk som startet dette innlegget, viste seg utelukkende å være det gjentatte laste-mønsteret (repeated-load pattern). Dokumentstrukturen bidro ikke med noe; et flatt sidetre ville kjørt på samme måte. Å bytte til én enkelt LoadFromFile etterfulgt av ett InsertPagesFromDocument-kall tok det ned til 1,3 sekunder på den samme maskinvaren uten å røre noe annet

Siden-manipulerings API-et vist her er en del av HotPDF Component for Delphi og C++Builder