Artículo técnico

Renderizado paralelo de páginas PDF en Delphi sin quedarse sin memoria

HotPDF renderiza muchas páginas PDF de forma concurrente mediante dos puntos de entrada: RenderLoadedPagesParallel, que devuelve un arreglo de mapas de bits, y RenderLoadedPagesParallelOrdered, que entrega cada página a un callback en el orden de entrada tan pronto como está lista. La forma ordenada es la que escala, porque nunca mantiene en memoria más que una cantidad acotada de mapas de bits, sin importar cuántas páginas haya solicitado

Esa distinción es todo el artículo. Renderizar 10,000 páginas a 300 DPI en un arreglo 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 un uso de memoria fijo sin importar cuán largo sea el documento

¿Por qué el renderizado paralelo necesita dos etapas?

Renderizar una página PDF son en realidad dos trabajos distintos bajo un solo nombre. Primero, el flujo de contenido se tokeniza y se compila en una lista de despliegue, algo limitado por CPU que toca cachés compartidas. Después, la lista de despliegue se reproduce hacia un mapa de bits, también limitado por CPU pero con asignación intensiva de memoria. Tratarlos como una sola unidad indivisible obliga a elegir entre serializar la mitad que toca la caché o duplicar trabajo

HotPDF los separa. El bloqueo de la caché de listas de despliegue protege únicamente el conteo de frecuencia, el orden LRU, los conteos de uso y las decisiones de admisión; la compilación en sí se ejecuta fuera del bloqueo. Después de compilar, el worker vuelve a consultar la caché: si otro worker publicó la misma página mientras tanto, libera su propia copia y toma un conteo 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, cerca del 95 por ciento, porque los workers comienzan a reproducir listas de despliegue terminadas mientras otros todavía están compilando, en lugar de esperar en una barrera de compilación completa

Diagrama del pipeline de renderizado paralelo de dos etapas de HotPDF en Delphi, donde una etapa de compilación alimenta una caché de listas de despliegue compartida antes de la reproducción en mapa de bits
HotPDF compila cada página en una lista de despliegue almacenada en caché y la reproduce en cualquier worker libre, de modo que la reproducción se superpone con la compilación en lugar de encolarse detrás de una barrera

Entrega ordenada y la ranura reservada

La API ordenada garantiza que su callback vea las páginas en el orden en que las solicitó, aunque los workers terminen en cualquier orden. Los mapas de bits completados se publican en una ranura de salida indexada 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 sea acotado y no solo ordenado. El conteo 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 permite que los productores rebasen la profundidad de la cola. Y las páginas posteriores pueden ocupar como máximo queueDepth - 1 ranuras, porque una ranura queda permanentemente reservada para la siguiente página que corresponde entregar. 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 entra en un interbloqueo justo al frente de la fila, con todos los workers inactivos

Diagrama de la entrega ordenada en HotPDF, donde los resultados de los workers, que llegan fuera de orden, llenan ranuras de salida y una ranura reservada mantiene fluyendo la siguiente página que corresponde entregar
Las páginas terminadas caen en ranuras direccionadas por la posición de entrada y se drenan mediante un callback serial de mapa de bits prestado, con una ranura reservada para que la página al frente de la fila nunca quede bloqueada
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úmalo
  // aquí (escríbalo en disco, codifíquelo, calcule su hash) y no conserve
  // 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 el uso de memoria se mantiene plano. La biblioteca libera el mapa de bits en cuanto su 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 necesita el mapa de bits después del callback, cópielo, y entonces usted es dueño de la memoria y de su contabilidad

Cómo el presupuesto de memoria elige la cantidad 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 podría no tener tanto espacio de direcciones libre

Por eso HotPDF normaliza la cantidad de workers solicitada contra el tamaño de la solicitud, el número de páginas y un límite máximo estricto, y luego divide el presupuesto global disponible en ese momento entre la estimación conservadora más grande de una sola página dentro de la solicitud. Al menos un worker siempre sobrevive, de modo que una página de tamaño excesivo igual se renderiza, 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 tanto la compilación como la reproducción. Reservar solo alrededor de la etapa de rasterización dejaría fuera la lista de despliegue y el conjunto de trabajo del flujo de contenido, que en páginas con mucho gráfico es la mitad más grande. Las dos API difieren en este punto: la forma en arreglo libera su reserva en cuanto el mapa de bits está completo, porque el llamador pasa a ser dueño del resultado y el planificador ya no puede contabilizarlo, mientras que la forma ordenada transfiere la reserva junto con el mapa de bits hacia la ranura de salida y la mantiene hasta que el callback retorna. Esa es otra razón para preferir la API ordenada cuando el conjunto de salida es grande

Diagrama del presupuesto de memoria de HotPDF dividiendo 512 MiB entre la estimación de página más grande para admitir tres workers en lugar de seis
El presupuesto admite workers a partir de la estimación de la página más grande, cada reserva abarca la compilación y la reproducción, y la API ordenada mantiene la reserva activa hasta que el callback retorna

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, el cual se lanza después de que todos los workers se hayan unido

El orden aquí es estructural. Primero se establece el estado de cancelación, después se liberan las reservas de salida no publicadas, 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 preferencia de primer plano (foreground preemption). 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 rendimiento en segundo plano nunca agrega latencia a la página que un usuario está esperando. La cancelación se revisa en intervalos acotados por 4 KiB de exploración de bytes o 256 tokens, aunque una llamada atómica de códec o del sistema operativo no puede interrumpirse a mitad de ejecución — 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 solicitudes

Cómo leer las estadísticas del pipeline

THPDFParallelRenderPipelineInfo separa números que es fácil confundir. Comparar RequestedWorkerCount con WorkerCount y MemoryBudgetWorkerLimit le indica si el presupuesto redujo su concurrencia. Comparar CompiledPageCount con CacheHitPageCount le indica cuánto ahorró la caché de listas de despliegue. CompiledPagesAtFirstResult muestra si el pipeline realmente superpuso compilación y reproducción, o si degeneró en una barrera

Para ajustar el rendimiento, las dos métricas más útiles son BackpressureWaitCount y MemorySchedulerWaitMilliseconds. Muchas esperas por contrapresión significan que su callback es el cuello de botella, así que hágalo más rápido o aumente la profundidad de la cola si la memoria lo permite. Muchas esperas del planificador significan que el presupuesto es el cuello de botella, así que reduzca el DPI, reduzca la concurrencia o aumente el presupuesto. Aumentar la cantidad de workers en cualquiera de los dos casos empeora el rendimiento, que es la parte contraintuitiva

Un punto de partida práctico para exportación por lotes: concurrencia igual a los 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. Después lea las estadísticas de un documento real y ajuste una vez, en lugar de adivinar dos veces. Para trabajos donde el propio documento es demasiado grande para mantenerlo en memoria, combine 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 listas de despliegue 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 PDF para Delphi de HotPDF