Twee minuten om drie pagina's uit een PDF van 40 pagina's te kopiëren is geen probleem voor prestatieafstemming (performance tuning). Het is een signaal dat het verkeerde API-pad wordt gebruikt. Toen ik deze timing voor het eerst zag in een voorbeeld van een pagina-kopie van de HotPDF Component, was mijn eerste instinct om naar de documentstructuur te kijken en daarna pas naar de code. Die volgorde bleek er toe te doen
Wat er daadwerkelijk traag was
De PDF in kwestie was een referentiedocument van 40 pagina's met een niet-triviale paginaboom (page tree): meerdere tussenliggende /Pages-knooppunten in plaats van een enkele platte array. De originele voorbeeldcode riep LoadFromFile aan, bouwde vervolgens een nieuw document op met BeginDoc, liep in een lus over geselecteerde paginanummers en laadde bij elke iteratie het brondocument opnieuw vanaf schijf om een pagina op te halen. Dat is de volledige parseerkost vermenigvuldigd met het aantal pagina's dat je wilt. Een bestand van 12 MB raakte zes keer de schijf voor een extractie van drie pagina's, omdat niemand had gekeken of het bestand tussen de iteraties door open moest blijven
De tweede bijdragende factor was onzichtbaar in de code: LoadFromFile van HotPDF lost de volledige kruisverwijzingstabel op en decompileert elke objectstream tijdens het laden. Dat is het juiste gedrag voor een document dat je wilt gaan wijzigen, maar het is meer werk dan je nodig hebt als je alleen het aantal pagina's en een subset van pagina's wilt weten. Voor alleen-lezen toegang tot de structuur, vermijdt DAOpenFileReadOnly de deserialisatie van de volledige objectboom, wat uitmaakt bij gecomprimeerde bestanden met grote afbeeldingsbronnen
Geen van beide is een bibliotheek-bug (library bug). Het zijn beide aanroepers (callers) die de API kiezen die is ontworpen voor de ene taak, en deze gebruiken voor een andere
InsertPagesFromDocument gebruiken voor pagina-extractie
Het juiste pad voor het kopiëren van een reeks pagina's van het ene HotPDF-document naar het andere is InsertPagesFromDocument, aangeroepen na LoadFromFile op de bron. Je laadt de bron eenmaal in, laadt of creëert de bestemming eenmaal in, verplaatst de pagina's en slaat op. De bron blijft in het geheugen gedurende alle pagina-invoegingen:
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;
De parameter PageRange accepteert hetzelfde formaat als het opdrachtregelvoorbeeld: een door komma's gescheiden lijst van paginanummers of -bereiken, zoals '1-3' of '1,5,7-9'. Pagina's zijn op 1 gebaseerd (1-based). InsertPagesFromDocument kopieert inhoudsstreams, brondictionary's (resource dictionaries) en paginageometrie zonder metagegevens (metadata), bladwijzers (bookmarks) of ingesloten bestandsbijlagen aan te raken, tenzij ernaar wordt verwezen vanaf de gekopieerde pagina's. Voor een extractie van drie pagina's uit een document van 40 pagina's, is dat een kleine werkset (working set)
Timing op hetzelfde 12 MB-bestand dat eerder twee minuten duurde: minder dan 1,5 seconde met dit patroon. Het merendeel van die tijd is de eenmalige LoadFromFile-aanroep. De documentstructuur is irrelevant zodra de objectentabel de eerste keer is opgelost
Wanneer LoadFromFile te veel is: de Direct File API
Als je alleen pagina's hoeft te tellen, documentinformatie wilt inspecteren of een bestand wilt kopiëren zonder de inhoud aan te raken, dan vermijdt de Direct File API de volledige parse volledig. DAOpenFileReadOnly brengt de kruisverwijzingstabel in kaart (maps) zonder objectstreams te decomprimeren, dus het tellen van pagina's is O(xref-grootte) in plaats van O(bestandsgrootte):
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;
Het voorbehoud: DAOpenFileReadOnly accepteert een wachtwoordparameter maar valt terug op een volledige parse voor versleutelde (encrypted) invoer, omdat decodering vereist dat de objectboom de versleutelingsdictionary oplost. Als je bronbestanden zijn versleuteld, ontsleutel (decrypt) ze dan eerst met DecryptFile om een niet-versleutelde kopie te krijgen, en open die vervolgens met de Direct File API. De functie DecryptFile op bestandsniveau (file-level) neemt een direct AES-256-herschrijfpad voor standaardversleuteling en is bij grote bestanden sneller dan LoadFromFile gevolgd door SaveLoadedDocument, omdat het niet het volledige in-memory objectmodel opbouwt
Geheugen tijdens verwerking in grote batches
Batch-taken die tientallen bestanden in een lus verwerken, hebben een patroon dat er correct uitziet, maar geheugen ophoopt: THotPDF aanmaken in de lus, LoadFromFile aanroepen, werk verrichten, Free aanroepen. Dat is structureel in orde. Het probleem ontstaat wanneer de interne taken 'scratch objects' (tijdelijke objecten) toewijzen, uitzonderingen opvangen en die 'scratch objects' in leven laten op foutpaden (error paths). Het geheugenbeheer van Delphi wordt niet gecomprimeerd (does not compact), dus honderd lekken via foutpaden in een batchuitvoering kunnen het geheugen zodanig opstuwen dat de toewijzing voor al het andere vertraagt
De oplossing is niet exotisch. Elke THotPDF en elke tussentijdse TStream of TBitmap die deelneemt aan PDF-werk hoort thuis in een try/finally-blok, waarbij Free de laatste statement is. Zet lokale pointers op nil vóór de try, zodat de finally-tak if Assigned(x) then x.Free veilig kan gebruiken wanneer de initialisatie halverwege mislukt. Dit is de standaard eigendomsdiscipline in Delphi en dit vormt het volledige verhaal voor deze categorie van problemen
Nog iets om te controleren in batch-contexten: AddImage registreert afbeeldingen in een interne lijst die blijft bestaan zolang de THotPDF-instantie bestaat. Als je één instantie hergebruikt voor veel documenten door herhaaldelijk LoadFromFile aan te roepen, blijven afbeeldingsregistraties van eerdere documenten in de lijst staan. Maak voor elk document een nieuwe instantie aan, of roep het pad voor het wissen van de afbeeldingslijst aan tussen documenten door
Meten voordat je iets verandert
Voordat je naar een van deze patronen grijpt: meten. Delphi's TStopwatch uit System.Diagnostics wikkelt QueryPerformanceCounter in en is nauwkeurig genoeg voor wandklokprofilering (wall-clock profiling) van bestands-I/O. Wikkel LoadFromFile afzonderlijk in en kijk hoeveel tijd dit in beslag neemt. Als het 90% van de totale tijd beslaat, dan is de oplossing de Direct File API of het verminderen van het aantal keren dat je hetzelfde bestand parseert. Zit het onder de 20%, dan bevindt de bottleneck zich ergens anders en ben je op het verkeerde spoor
De extractie van twee minuten waar deze post mee begon, bleek geheel toe te schrijven aan het herhaalde-laadpatroon (repeated-load pattern). De documentstructuur droeg daar niets aan bij; een platte paginaboom (flat page tree) zou op dezelfde manier gelopen hebben. Overschakelen naar een eenmalige LoadFromFile gevolgd door één InsertPagesFromDocument-aanroep bracht de tijd terug naar 1,3 seconden op dezelfde hardware, zonder ook maar iets anders aan te raken
De API voor paginamanipulatie die hier getoond wordt, maakt deel uit van de HotPDF Component voor Delphi en C++Builder