Due minuti per copiare tre pagine da un PDF di 40 pagine non è un problema di tuning delle prestazioni. È il segnale che si sta usando il percorso API sbagliato. La prima volta che ho visto questa tempistica su codice di copia pagine costruito sul HotPDF Delphi Component, il mio istinto è stato guardare prima la struttura del documento e dopo il codice. Quell'ordine si è rivelato capovolto
Cosa era davvero lento
Il PDF in questione era un documento di riferimento da 40 pagine con un albero di pagine non banale: più nodi /Pages intermedi invece di un singolo array piatto. Il codice in questione chiamava LoadFromFile, poi costruiva un nuovo documento con BeginDoc, ciclava sui numeri di pagina selezionati, e a ogni iterazione ricaricava da disco il documento sorgente per tirare una pagina. È il costo completo del parse moltiplicato per quante pagine vuoi. Un file da 12 MB ha colpito il disco sei volte per un'estrazione di tre pagine, perché nessuno ha verificato se il file dovesse restare aperto tra le iterazioni
Il secondo contributore era invisibile nel codice: la LoadFromFile di HotPDF costruisce il modello a oggetti in memoria su cui lavora l'API di editing. È il comportamento giusto per un documento che state per modificare, ma è più lavoro di quanto serva se volete solo il numero di pagine e un sottoinsieme di pagine. Per l'accesso in sola lettura alla struttura, DAOpenFileReadOnly evita di deserializzare l'intero albero di oggetti, il che conta su file compressi con grandi risorse immagine
Entrambi i problemi si riducono a scegliere l'API progettata per un lavoro e usarla per uno diverso
Usare ExtractPagesToFile per l'estrazione delle pagine
La strada giusta per copiare un intervallo di pagine da un documento HotPDF caricato verso un nuovo file è ExtractPagesToFile, chiamata dopo LoadFromFile sulla sorgente. Caricate la sorgente una volta sola, e la chiamata costruisce il documento di destinazione, vi copia le pagine selezionate e lo scrive su disco, tutto dalla copia già in memoria:
procedure ExtractPages(const SourceFile, DestFile: string;
const PageRange: string);
var
Source: THotPDF;
begin
Source := THotPDF.Create(nil);
try
// Caricate la sorgente una volta: il parse completo avviene qui e solo qui
if Source.LoadFromFile(SourceFile) <= 0 then
raise Exception.Create('Source PDF has no pages');
// Scrivete l'intervallo richiesto ('1-3', '1,5,7-9') dritto su un nuovo file
Source.ExtractPagesToFile(DestFile, PageRange);
finally
Source.Free;
end;
end;
Il parametro PageRange accetta lo stesso formato dell'esempio da riga di comando: una lista separata da virgole di numeri di pagina o intervalli come '1-3' o '1,5,7-9'. Le pagine sono in base 1. ExtractPagesToFile copia ogni pagina selezionata insieme agli stream di contenuto e alle risorse che referenzia, così per un'estrazione di tre pagine da un documento di 40 pagine il working set resta piccolo
Tempistiche sullo stesso file da 12 MB che prima girava per due minuti: circa 1.3 secondi con questo schema. La maggior parte di quel tempo è la singola chiamata LoadFromFile. La struttura del documento è irrilevante una volta che la tabella degli oggetti è risolta la prima volta
Quando LoadFromFile è troppo: la Direct File API
Se vi serve solo contare le pagine, ispezionare le informazioni del documento o copiare un file senza toccarne i contenuti, la Direct File API evita del tutto il parsing completo. DAOpenFileReadOnly mappa la tabella dei riferimenti incrociati senza decomprimere gli object stream, quindi il conteggio delle pagine è O(dimensione xref) anziché O(dimensione file):
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 è una copia che preserva i byte, nessuna riserializzazione
Pdf.DACopyFile(FileName, 'archive-copy.pdf');
finally
Pdf.DACloseFile(Handle);
end;
finally
Pdf.Free;
end;
end;
L'avvertenza: DAOpenFileReadOnly accetta un parametro password ma ripiega su un parsing completo per gli input cifrati, perché la decifratura richiede l'albero degli oggetti per risolvere il dizionario di cifratura. Se i vostri file sorgente sono cifrati, decifrateli prima con DecryptFile per ottenere una copia non cifrata, poi apritela con la Direct File API. La funzione a livello di file DecryptFile segue un percorso di riscrittura AES-256 diretto per la cifratura standard ed è più veloce di LoadFromFile seguito da SaveLoadedDocument per i file grandi, perché non costruisce il modello a oggetti completo in memoria
La memoria durante l'elaborazione di grandi batch
I job batch che elaborano decine di file in un ciclo hanno un pattern che sembra corretto ma accumula memoria: creare THotPDF dentro il ciclo, chiamare LoadFromFile, fare il lavoro, chiamare Free. Strutturalmente va bene. Il problema è quando il lavoro interno alloca oggetti temporanei, cattura eccezioni e lascia quegli oggetti temporanei vivi sui percorsi di errore. Il memory manager di Delphi non compatta, quindi un centinaio di leak sui percorsi di errore lungo un'esecuzione batch può spingere la memoria abbastanza in alto da rallentare l'allocazione per tutto il resto
La soluzione non è esotica. Ogni THotPDF e ogni TStream o TBitmap intermedio che partecipa al lavoro PDF appartiene a un blocco try/finally in cui Free è l'ultima istruzione. Impostate i puntatori locali a nil prima del try, in modo che il ramo finally possa usare if Assigned(x) then x.Free in sicurezza quando l'inizializzazione fallisce a metà. Questa è la normale disciplina di ownership di Delphi ed è tutta la storia per questa classe di problemi
Un'altra cosa da sapere nei contesti batch: AddImage registra ogni immagine presso il documento in costruzione, e HotPDF rilascia quelle registrazioni quando EndDoc termina quel file. Il registro segue il ciclo BeginDoc/EndDoc, non LoadFromFile, così lo schema pulito per un batch è un'istanza di THotPDF per documento: la create, fate il lavoro, salvate, la liberate
Misurare prima di cambiare qualsiasi cosa
Prima di ricorrere a uno qualsiasi di questi pattern, misurate. Il TStopwatch di Delphi in System.Diagnostics incapsula QueryPerformanceCounter ed è abbastanza preciso per profilare a orologio l'I/O su file. Cronometrate LoadFromFile da solo e vedete quanto pesa. Se è il 90% del tempo totale, la soluzione è la Direct File API oppure ridurre quante volte fate il parsing dello stesso file. Se è sotto il 20%, il collo di bottiglia è altrove e state inseguendo la cosa sbagliata
L'estrazione da due minuti che ha dato origine a questo post si è rivelata interamente lo schema del carico ripetuto. La struttura del documento non contribuiva nulla; un albero di pagine piatto avrebbe girato allo stesso modo. Passare a una singola LoadFromFile seguita da una chiamata ExtractPagesToFile l'ha portata a circa 1.3 secondi sullo stesso hardware senza toccare nulla altro
L'API di manipolazione delle pagine mostrata qui fa parte del HotPDF Delphi Component per Delphi e C++Builder