HotPDF renderiza muchas páginas PDF de forma concurrente a través de dos puntos de entrada: RenderLoadedPagesParallel, que devuelve un array de mapas de bits, y RenderLoadedPagesParallelOrdered, que entrega cada página a un callback en el orden de entrada en cuanto está lista. La forma ordenada es la que escala, porque nunca mantiene en memoria más de un número acotado de mapas de bits, sin importar cuántas páginas hayas solicitado
Esta distinción es todo el artículo. Renderizar 10.000 páginas a 300 DPI en un array significa 10.000 mapas de bits vivos a la vez, lo cual no es un problema de rendimiento sino un fallo por falta de memoria. Renderizarlas mediante un callback ordenado con una profundidad de cola de dos significa dos mapas de bits vivos, y el trabajo se ejecuta con una huella de memoria fija sin importar lo largo que sea el documento
¿Por qué el renderizado paralelo necesita dos etapas?
Renderizar una página PDF son en realidad dos trabajos distintos bajo un mismo nombre. Primero el flujo de contenido se tokeniza y se compila en una display list, una tarea limitada por CPU que toca cachés compartidas. Después la display list se reproduce sobre un mapa de bits, algo también limitado por CPU pero que reserva memoria intensivamente. Tratarlas como una unidad indivisible obliga a elegir entre serializar la mitad que toca la caché o duplicar trabajo
HotPDF las separa. El bloqueo de la caché de display lists protege únicamente el conteo de frecuencia, el orden LRU, los contadores de uso y las decisiones de admisión; la compilación en sí se ejecuta fuera del bloqueo. Tras compilar, el worker vuelve a consultar la caché: si otro worker publicó mientras tanto la misma página, libera su propia copia y toma un contador de uso sobre la entrada publicada. Eso mantiene acotado el trabajo duplicado y evita dos entradas para la misma página, sin serializar nunca la compilación entre páginas
El efecto medido en un fixture complejo de 32 páginas es la parte que vale la pena recordar. El tiempo total en Win64 bajó de 1.565 ms con un worker a 696 ms con cuatro, aproximadamente 2,25 veces. El tiempo hasta el primer resultado bajó de 1.255 ms a 63 ms, alrededor de un 95 %, porque los workers empiezan a reproducir las display lists ya terminadas mientras otros siguen compilando, en lugar de esperar en una barrera de compilación completa
Entrega ordenada y el slot reservado
La API ordenada garantiza que tu callback ve las páginas en el orden en que las solicitaste, aunque los workers terminen en el orden que sea. Los mapas de bits completados se publican en un slot de salida indexado por la posición de entrada, y el callback se ejecuta de forma serial en el hilo que hizo la llamada
Dos reglas hacen que esto esté acotado y no simplemente ordenado. El recuento de la cola de salida incluye los mapas de bits que el callback está usando en ese momento, de modo que un callback lento no puede dejar que los productores se salten la profundidad de cola. Y las páginas posteriores pueden ocupar como máximo queueDepth - 1 slots, porque un slot queda reservado permanentemente para la siguiente página pendiente de entrega. Sin esa reserva, una primera página lenta podría quedar bloqueada por páginas posteriores ya terminadas que llenan la cola, y el pipeline entraría en interbloqueo justo al principio de la fila, con todos los workers inactivos
uses
HPDFDoc;
procedure TExportJob.PageReady(Sender: TObject; InputIndex,
PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
// El mapa de bits es prestado: válido solo para esta llamada. Consúmelo
// aquí (escríbelo a disco, codifícalo, calcula su hash) y no conserves
// la referencia
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;
El contrato de mapa de bits prestado es la razón por la que la huella de memoria se mantiene plana. La biblioteca libera el mapa de bits en cuanto tu callback retorna, de modo que un fixture de 10.000 páginas con profundidad de cola 2 alcanza un pico de exactamente dos mapas de bits, tanto en Win32 como en Win64. Si necesitas el mapa de bits después del callback, cópialo, y a partir de ahí la memoria y su contabilidad son responsabilidad tuya
Cómo elige el presupuesto de memoria un número de workers
Más workers no siempre es más rápido, y a menudo resulta fatal. Seis workers renderizando páginas A3 a 600 DPI necesitan varios cientos de megabytes de mapas de bits vivos sin importar cuántos núcleos haya disponibles, y la máquina que tiene ocho núcleos puede no disponer de tanto espacio de direcciones libre
Por eso HotPDF normaliza el número de workers solicitado en función del tamaño de la petición, el número de páginas y un límite máximo fijo, y luego divide el presupuesto global disponible en ese momento entre la estimación conservadora más grande para una sola página dentro de la petición. Siempre sobrevive al menos un worker, de modo que una página sobredimensionada se sigue renderizando, aunque en exclusiva. En la medición de referencia, un trabajo de seis páginas a 600 DPI bajo un presupuesto de 512 MiB se ejecutó con tres workers en lugar de seis, con un pico de reserva de 504.583.296 bytes
Cada worker toma una reserva de memoria después de tomar una página y antes de compilar, y la reserva cubre juntas la compilación y la reproducción. Reservar únicamente alrededor de la etapa de rasterizado dejaría fuera la display list y el conjunto de trabajo del flujo de contenido, que en páginas cargadas de gráficos es la mitad mayor. Las dos API difieren entonces: la forma de array libera su reserva en cuanto el mapa de bits está completo, porque el resultado pasa a ser propiedad del llamador y el planificador ya no puede contabilizarlo, mientras que la forma ordenada transfiere la reserva junto con el mapa de bits al slot de salida y la retiene hasta que el callback retorna. Esa es otra razón para preferir la API ordenada cuando el conjunto de salida es grande
Cancelación sin interbloqueo
Establecer Cancel en el callback detiene el trabajo nuevo y despierta a cualquier worker que esté esperando por contrapresión. Las páginas que ya están dentro del renderizador terminan y se limpian en lugar de abandonarse, y solo se conserva el primer diagnóstico de fallo, que se lanza después de que todos los workers se hayan unido
El orden aquí es estructural, no anecdótico. El estado de cancelación se establece primero, las reservas de salida no publicadas se liberan en segundo lugar, y solo entonces se unen los workers. Invertir los dos últimos pasos crea un ciclo cerrado: un worker espera admisión a un presupuesto que no se liberará hasta que el consumidor termine, mientras el consumidor espera a que ese worker salga
Las aplicaciones interactivas también deben conocer la prioridad de primer plano. La precarga de páginas se ejecuta con sus propios tokens de cancelación, y las llamadas de renderizado en primer plano cancelan y esperan a los workers de precarga antes de hacer su propio trabajo, de modo que el throughput en segundo plano nunca añade latencia a la página que un usuario está esperando. La cancelación se comprueba en intervalos acotados por 4 KiB de bytes analizados o 256 tokens, aunque una llamada atómica a un códec o al sistema operativo no puede interrumpirse a mitad de vuelo — la garantía de capacidad de respuesta cubre los bucles propios de la biblioteca, no los decodificadores de terceros. El enfoque basado en cola para el renderizado interactivo se describe en renderizado en segundo plano con una cola de peticiones
Leer las estadísticas del pipeline
THPDFParallelRenderPipelineInfo separa números que resulta fácil confundir. Comparar RequestedWorkerCount con WorkerCount y MemoryBudgetWorkerLimit te dice si el presupuesto redujo tu concurrencia. Comparar CompiledPageCount con CacheHitPageCount te dice cuánto ahorró la caché de display lists. CompiledPagesAtFirstResult muestra si el pipeline realmente solapó la compilación y la reproducción, o si degeneró en una barrera
Para ajustar el rendimiento, las dos más útiles son BackpressureWaitCount y MemorySchedulerWaitMilliseconds. Muchas esperas por contrapresión significan que tu callback es el cuello de botella, así que hazlo más rápido o aumenta la profundidad de cola si la memoria lo permite. Muchas esperas del planificador significan que el presupuesto es el cuello de botella, así que reduce el DPI, reduce la concurrencia o aumenta el presupuesto. Aumentar el número de workers en cualquiera de los dos casos empeora el throughput, que es la parte contraintuitiva
Un punto de partida práctico para exportación por lotes: concurrencia igual al número de núcleos físicos menos uno, profundidad de cola de dos o tres, y un DPI elegido según el requisito real de salida y no por costumbre. Luego lee las estadísticas de un documento real y ajusta una sola vez, en lugar de adivinar dos veces. Para trabajos en los que el propio documento es demasiado grande para mantenerlo en memoria, combina esto con el modelo de acceso en streaming de la API de archivo directo para flujos de trabajo con PDF grandes
El renderizado paralelo, la precarga y la caché de display lists forman parte de la misma pila de renderizado para Delphi y C++Builder; la lista completa de funciones está en la página del componente HotPDF Delphi PDF