Articolo tecnico

Rendering parallelo di pagine PDF in Delphi senza OOM

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

Diagramma della pipeline di rendering parallelo a due fasi di HotPDF in Delphi, dove una fase di compilazione alimenta una cache condivisa di display list prima della riproduzione in bitmap
HotPDF compila ogni pagina in una display list nella cache e la riproduce sul primo worker libero, così la riproduzione si sovrappone alla compilazione invece di mettersi in coda dietro una barriera

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

Diagramma della consegna ordinata in HotPDF, in cui i risultati dei worker fuori ordine riempiono gli slot di output e uno slot riservato mantiene fluido il flusso della prossima pagina in attesa
Le pagine terminate finiscono in slot indirizzati dalla posizione di input e vengono svuotate tramite un callback seriale con bitmap in prestito, con uno slot riservato in modo che la pagina in testa non possa mai restare bloccata
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

Diagramma del budget di memoria di HotPDF che divide 512 MiB per la stima più grande per singola pagina per ammettere tre worker invece di sei
Il budget ammette i worker in base alla stima più grande per singola pagina, ogni riserva copre compilazione e riproduzione, e l'API ordinata mantiene attiva la riserva finché il callback non ritorna

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