HotPDF renderizza molte pagine PDF in modo concorrente tramite due punti di ingresso: RenderLoadedPagesParallel, che restituisce un array di bitmap, e RenderLoadedPagesParallelOrdered, che consegna ogni pagina a un callback nell'ordine di input non appena è pronta. La forma ordinata è quella che scala, perché non mantiene mai in memoria più di un numero limitato di bitmap, indipendentemente da quante pagine avete richiesto
Questa distinzione è tutto l'articolo. Renderizzare 10.000 pagine a 300 DPI in un array significa 10.000 bitmap vive contemporaneamente, il che non è un problema di throughput ma un crash per esaurimento della memoria. Renderizzarle tramite un callback ordinato con una profondità di coda di due significa due bitmap vive, e il job viene eseguito con un footprint fisso indipendentemente dalla lunghezza del documento
Perché il rendering parallelo richiede due fasi?
Renderizzare una pagina PDF sono in realtà due lavori diversi sotto un unico nome. Prima il content stream viene tokenizzato e compilato in una display list, operazione CPU-bound che tocca cache condivise. Poi la display list viene riprodotta in un bitmap, operazione anch'essa CPU-bound ma che alloca pesantemente. Trattarle come un'unica unità indivisibile costringe a scegliere tra serializzare la metà che tocca la cache e duplicare il lavoro
HotPDF le separa. Il lock della cache della display list protegge solo il conteggio delle frequenze, l'ordine LRU, i conteggi d'uso e le decisioni di ammissione; la compilazione in sé viene eseguita fuori dal lock. Dopo la compilazione, il worker interroga di nuovo la cache: se un altro worker ha pubblicato nel frattempo la stessa pagina, rilascia la propria copia e prende un conteggio d'uso sulla voce pubblicata. Questo mantiene limitato il lavoro duplicato ed evita due voci per la stessa pagina, senza mai serializzare la compilazione tra pagine diverse
L'effetto misurato su un fixture complesso di 32 pagine è la parte che vale la pena ricordare. Il tempo totale su Win64 è sceso da 1.565 ms con un worker a 696 ms con quattro, circa 2,25 volte. Il tempo per il primo risultato è sceso da 1.255 ms a 63 ms, circa il 95 percento, perché i worker iniziano a riprodurre le display list terminate mentre altri stanno ancora compilando, invece di attendere a una barriera di compilazione completa
Consegna ordinata e lo slot riservato
L'API ordinata garantisce che il vostro callback veda le pagine nell'ordine in cui le avete richieste, mentre i worker terminano nell'ordine in cui finiscono. I bitmap completati vengono pubblicati in uno slot di output indicizzato dalla posizione di input, e il callback viene eseguito in modo seriale sul thread chiamante
Due regole rendono questo comportamento limitato e non solo ordinato. Il conteggio della coda di output include i bitmap attualmente in uso dal callback, quindi un callback lento non può permettere ai produttori di superare la profondità della coda. E le pagine successive possono occupare al massimo queueDepth - 1 slot, perché uno slot è permanentemente riservato per la prossima pagina in attesa di consegna. Senza questa riserva, una prima pagina lenta potrebbe rimanere bloccata da pagine successive già terminate che riempiono la coda, e la pipeline si blocca in stallo in testa alla fila con ogni worker inattivo
uses
HPDFDoc;
procedure TExportJob.PageReady(Sender: TObject; InputIndex,
PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
// Il bitmap è preso in prestito: valido solo per questa chiamata. Consumatelo qui
// (scrivetelo su disco, codificatelo, calcolatene l'hash) e non conservate il riferimento
Bitmap.SaveToFile(Format('page-%.4d.bmp', [InputIndex + 1]));
Cancel := FUserCancelled;
end;
procedure TExportJob.Run;
var
Pdf: THotPDF;
Pages: array of Integer;
Info: THPDFParallelRenderPipelineInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('manual.pdf') <= 0 then
Exit;
SetLength(Pages, Pdf.LoadedPageCount);
for I := 0 to High(Pages) do
Pages[I] := I;
Pdf.RenderLoadedPagesParallelOrdered(Pages, 200, 4, 2,
PageReady, Info);
Writeln(Format('workers=%d peak queued=%d peak bytes=%d',
[Info.WorkerCount, Info.PeakQueuedPageCount, Info.PeakQueuedBytes]));
Writeln(Format('first result=%d ms total=%d ms backpressure waits=%d',
[Info.FirstResultMilliseconds, Info.TotalMilliseconds,
Info.BackpressureWaitCount]));
finally
Pdf.Free;
end;
end;
Il contratto del bitmap in prestito è la ragione per cui il footprint resta piatto. La libreria rilascia il bitmap non appena il vostro callback ritorna, quindi un fixture di 10.000 pagine con profondità di coda 2 raggiunge un picco di esattamente due bitmap sia su Win32 sia su Win64. Se avete bisogno del bitmap dopo il callback, copiatelo, e a quel punto la memoria e la relativa contabilità diventano vostre
Come il budget di memoria sceglie il numero di worker
Più worker non significa sempre più velocità, e spesso è fatale. Sei worker che renderizzano pagine A3 a 600 DPI necessitano di centinaia di megabyte di bitmap vive indipendentemente da quanti core sono disponibili, e la macchina che ha otto core potrebbe non avere così tanto spazio di indirizzamento libero
HotPDF quindi normalizza il numero di worker richiesto rispetto alla dimensione della richiesta, al numero di pagine e a un limite massimo rigido, poi divide il budget globale attualmente disponibile per la stima conservativa più grande per singola pagina nella richiesta. Almeno un worker sopravvive sempre, quindi una pagina sovradimensionata viene comunque renderizzata, in esclusiva. Nella misurazione di riferimento, un job di sei pagine a 600 DPI con un budget di 512 MiB è stato eseguito con tre worker anziché sei, con un picco di riserva di 504.583.296 byte
Ogni worker effettua una riserva di memoria dopo aver preso in carico una pagina e prima di compilare, e la riserva copre insieme compilazione e riproduzione. Riservare solo intorno alla fase di rasterizzazione trascurerebbe la display list e il working set del content stream, che nelle pagine ricche di grafica costituisce la metà maggiore. Le due API differiscono poi: la forma ad array rilascia la propria riserva non appena il bitmap è completo, perché il chiamante possiede il risultato e lo scheduler non può più contabilizzarlo, mentre la forma ordinata trasferisce la riserva insieme al bitmap nello slot di output e la mantiene finché il callback non ritorna. Questo è un altro motivo per preferire l'API ordinata quando il set di output è grande
Cancellazione senza deadlock
Impostare Cancel nel callback interrompe il nuovo lavoro e risveglia qualsiasi worker in attesa per backpressure. Le pagine già all'interno del renderer terminano e vengono ripulite anziché abbandonate, e viene conservata solo la prima diagnostica di errore, sollevata dopo che ogni worker si è unito
L'ordine qui è strutturale. Lo stato di cancellazione viene impostato per primo, le riserve di output non pubblicate vengono rilasciate per seconde, e solo dopo i worker vengono uniti. Invertire gli ultimi due passaggi crea un ciclo chiuso: un worker attende l'ammissione a un budget che non verrà rilasciato finché il consumatore non termina, mentre il consumatore attende che quel worker esca
Le applicazioni interattive dovrebbero anche conoscere la prelazione in foreground. Il prefetching delle pagine gira su propri token di cancellazione, e le chiamate di rendering in foreground cancellano e uniscono i worker di prefetch prima di eseguire il proprio lavoro, così il throughput in background non aggiunge mai latenza alla pagina che un utente sta aspettando. La cancellazione viene verificata a intervalli limitati da 4 KiB di scansione di byte o 256 token, sebbene un codec atomico o una chiamata di sistema operativo non possano essere interrotti a metà — la garanzia di reattività copre i cicli di proprietà della libreria, non i decoder di terze parti. L'approccio basato su coda per il rendering interattivo è descritto in rendering in background con una coda di richieste
Leggere le statistiche della pipeline
THPDFParallelRenderPipelineInfo separa numeri che è facile confondere. RequestedWorkerCount rispetto a WorkerCount e MemoryBudgetWorkerLimit indica se il budget ha ridotto la vostra concorrenza. CompiledPageCount rispetto a CacheHitPageCount indica quanto ha risparmiato la cache della display list. CompiledPagesAtFirstResult mostra se la pipeline ha davvero sovrapposto compilazione e riproduzione oppure è degenerata in una barriera
Per la messa a punto, le due più utili sono BackpressureWaitCount e MemorySchedulerWaitMilliseconds. Attese di backpressure elevate indicano che il vostro callback è il collo di bottiglia, quindi rendetelo più veloce oppure aumentate la profondità della coda se la memoria lo consente. Attese dello scheduler elevate indicano che il budget è il collo di bottiglia, quindi riducete il DPI, riducete la concorrenza o aumentate il budget. Aumentare il numero di worker in entrambi i casi peggiora il throughput, il che è la parte controintuitiva
Un punto di partenza pratico per l'export batch: concorrenza pari ai core fisici meno uno, profondità di coda due o tre, e DPI scelto in base al reale requisito di output anziché per abitudine. Poi leggete le statistiche da un documento reale e regolate una volta sola, invece di indovinare due volte. Per i job in cui il documento stesso è troppo grande per stare in memoria, combinate questo con il modello di accesso in streaming descritto in l'API a file diretto per flussi di lavoro su PDF di grandi dimensioni
Il rendering parallelo, il prefetching e la cache della display list fanno tutti parte dello stesso stack di rendering per Delphi e C++Builder; l'elenco completo delle funzionalità è disponibile sulla pagina del componente PDF HotPDF Delphi