Technical Article

Performanse izdvajanja stranica pomoću HotPDF-a u Delphiju

Dvije minute za kopiranje tri stranice iz PDF-a od 40 stranica nije problem ugađanja performansi. To je jasan znak da se koristi pogrešan API put. Kada sam prvi put vidio ovo vrijeme na primjeru kopiranja stranica pomoću komponente HotPDF, moj instinkt je bio da najprije pogledam strukturu dokumenta, a tek onda kod. Pokazalo se da je taj redoslijed bio ključan.

Što je zapravo bilo sporo

Predmetni PDF bio je referentni dokument od 40 stranica s netrivijalnim stablom stranica: više posrednih čvorova /Pages umjesto jednog ravnog niza. Izvorni kod primjera pozivao je LoadFromFile, zatim gradio novi dokument pomoću BeginDoc, prolazio u petlji kroz odabrane brojeve stranica te u svakoj iteraciji ponovno učitavao izvorni dokument s diska kako bi izvukao stranicu. To znači da se trošak potpunog parsiranja množi s brojem stranica koje želite izvući. Datoteka od 12 MB pristupila je disku šest puta radi izdvajanja tri stranice jer nitko nije provjerio treba li datoteka ostati otvorena tijekom iteracija.

Drugi uzrok bio je nevidljiv u kodu: HotPDF-ova metoda LoadFromFile pri učitavanju razrješava cijelu tablicu unakrsnih referenci i dekomprimira svaki tok objekata. To je ispravno ponašanje za dokument koji namjeravate mijenjati, ali je previše posla ako samo trebate broj stranica i podskup stranica. Za pristup strukturi samo za čitanje, metoda DAOpenFileReadOnly izbjegava deserijalizaciju cijelog stabla objekata, što je važno kod komprimiranih datoteka s velikim slikovnim resursima.

Nijedno od toga nije bug u biblioteci. U oba slučaja pozivatelji biraju API dizajniran za jedan zadatak i koriste ga za sasvim drugi.

Korištenje metode InsertPagesFromDocument za izdvajanje stranica

Ispravan način za kopiranje raspona stranica iz jednog HotPDF dokumenta u drugi jest InsertPagesFromDocument, koji se poziva nakon LoadFromFile na izvornom dokumentu. Izvornik učitate jednom, odredište učitate ili kreirate jednom, prenesete stranice i spremite. Izvornik ostaje u memoriji tijekom svih umetanja stranica:

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;

Parametar PageRange prihvaća isti format kao i primjer iz naredbenog retka: popis brojeva stranica ili raspona odvojenih zarezima, kao što su '1-3' ili '1,5,7-9'. Stranice počinju od 1. Metoda InsertPagesFromDocument kopira tokove sadržaja, rječnike resursa i geometriju stranice bez dodirivanja metapodataka, oznaka ili ugrađenih privitaka datoteka, osim ako se na njih ne referira s kopiranih stranica. Za izdvajanje tri stranice iz dokumenta od 40 stranica, to je vrlo mali skup podataka za obradu.

Vrijeme izvršavanja na istoj datoteci od 12 MB, koja je prethodno trajala dvije minute: manje od 1,5 sekundi s ovim obrascem. Većina tog vremena odlazi na jedan poziv metode LoadFromFile. Struktura dokumenta postaje nevažna nakon što se tablica objekata prvi put razriješi.

Kada je LoadFromFile previše: Direct File API

Ako trebate samo prebrojati stranice, pregledati informacije o dokumentu ili kopirati datoteku bez mijenjanja njezinog sadržaja, Direct File API u potpunosti izbjegava cjelokupno parsiranje. Metoda DAOpenFileReadOnly mapira tablicu unakrsnih referenci bez dekompresije tokova objekata, pa je izračun broja stranica vremenske složenosti O(xref size) umjesto 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;

Napomena: DAOpenFileReadOnly prihvaća parametar lozinke, ali se vraća na potpuno parsiranje za šifrirane unose, jer dešifriranje zahtijeva stablo objekata za razrješavanje rječnika šifriranja. Ako su vaše izvorne datoteke šifrirane, najprije ih dešifrirajte pomoću metode DecryptFile kako biste dobili nešifriranu kopiju, a zatim je otvorite pomoću Direct File API-ja. Funkcija DecryptFile na razini datoteke koristi izravan put prepisivanja AES-256 za standardno šifriranje i brža je od kombinacije LoadFromFile i SaveLoadedDocument za velike datoteke, jer ne gradi cijeli objektni model u memoriji.

Memorija tijekom masovne obrade datoteka

Masovni poslovi (batch jobs) koji obrađuju desetke datoteka u petlji imaju obrazac koji izgleda ispravno, ali troši sve više memorije: kreiranje objekta THotPDF unutar petlje, pozivanje LoadFromFile, obavljanje posla, pozivanje Free. To je strukturno u redu. Problem nastaje kada unutarnji rad alocira privremene objekte, hvata iznimke i ostavlja te privremene objekte aktivnima na putanjama pogrešaka. Delphi-jev upravitelj memorije ne radi sažimanje (compaction), pa stotinjak curenja na putanjama pogrešaka tijekom masovnog pokretanja može podići potrošnju memorije dovoljno da uspori alokaciju za sve ostalo.

Rješenje nije komplicirano. Svaki objekt THotPDF i svaki privremeni TStream ili TBitmap koji sudjeluje u radu s PDF-om pripada bloku try/finally u kojem je Free zadnja naredba. Postavite lokalne pokazivače (pointers) na nil prije bloka try kako bi grana finally mogla sigurno koristiti if Assigned(x) then x.Free ako inicijalizacija ne uspije na pola puta. To je standardna Delphi-jeva disciplina vlasništva nad objektima i u potpunosti rješava ovu klasu problema.

Još jedna stvar koju treba provjeriti u masovnom kontekstu: metoda AddImage registrira slike u internom popisu koji traje tijekom cijelog životnog vijeka instance THotPDF. Ako ponovno koristite jednu instancu za više dokumenata uzastopnim pozivanjem LoadFromFile, registracije slika iz prethodnih dokumenata ostaju na popisu. Stoga ili kreirajte novu instancu za svaki dokument ili očistite popis slika između dokumenata.

Mjerenje prije bilo kakvih izmjena

Prije nego što posegnete za bilo kojim od ovih obrazaca, izmjerite performanse. Delphi-jeva struktura TStopwatch iz jedinice System.Diagnostics omotava QueryPerformanceCounter i dovoljno je precizna za profilaciju ulazno-izlaznih operacija s datotekama. Izmjerite samostalno trajanje poziva LoadFromFile i pogledajte koliki postotak vremena on zauzima. Ako iznosi 90% ukupnog vremena, rješenje je Direct File API ili smanjenje broja parsiranja iste datoteke. Ako je ispod 20%, usko grlo je negdje drugdje i rješavate pogrešan problem.

Izdvajanje koje je trajalo dvije minute, a koje je potaknulo pisanje ovog teksta, pokazalo se isključivo kao posljedica ponovljenog učitavanja. Struktura dokumenta nije imala nikakav utjecaj; ravno stablo stranica ponašalo bi se jednako. Prelazak na jedno učitavanje LoadFromFile nakon kojeg slijedi jedan poziv InsertPagesFromDocument skratio je vrijeme na 1,3 sekunde na istom hardveru, bez ikakvih drugih izmjena.

API za manipulaciju stranicama prikazan ovdje dio je komponente HotPDF za Delphi i C++Builder.