O HotPDF renderiza muitas páginas PDF simultaneamente por meio de dois pontos de entrada: RenderLoadedPagesParallel, que retorna um array de bitmaps, e RenderLoadedPagesParallelOrdered, que entrega cada página a um callback na ordem de entrada assim que fica pronta. A forma ordenada é a que escala, porque nunca mantém mais do que um número limitado de bitmaps em memória, independentemente de quantas páginas você pediu
Essa distinção é o artigo inteiro. Renderizar 10.000 páginas a 300 DPI em um array significa 10.000 bitmaps vivos ao mesmo tempo, o que não é um problema de throughput, mas uma falha por falta de memória. Renderizá-las por meio de um callback ordenado com profundidade de fila igual a dois significa apenas dois bitmaps vivos, e o trabalho roda em um consumo de memória fixo, não importa quão longo seja o documento
Por que a renderização paralela precisa de dois estágios?
Renderizar uma página PDF são, na verdade, dois trabalhos diferentes sob um único nome. Primeiro o fluxo de conteúdo é tokenizado e compilado em uma display list, o que é limitado por CPU e toca caches compartilhados. Depois a display list é reproduzida em um bitmap, o que também é limitado por CPU, mas aloca pesadamente. Tratá-los como uma unidade indivisível força uma escolha entre serializar a metade que toca o cache e duplicar trabalho
O HotPDF os separa. O lock do cache da display list protege apenas a contabilidade de frequência, a ordem LRU, as contagens de uso e as decisões de admissão; a própria compilação roda fora do lock. Depois de compilar, o worker consulta o cache novamente: se outro worker publicou a mesma página nesse meio-tempo, ele libera sua própria cópia e assume uma contagem de uso na entrada publicada. Isso mantém o trabalho duplicado limitado e evita duas entradas para a mesma página, sem jamais serializar a compilação entre páginas
O efeito medido em um fixture complexo de 32 páginas é a parte que vale a pena lembrar. O tempo total no Win64 caiu de 1.565 ms com um worker para 696 ms com quatro, aproximadamente 2,25 vezes. O tempo até o primeiro resultado caiu de 1.255 ms para 63 ms, cerca de 95 por cento, porque os workers começam a reproduzir display lists concluídas enquanto outros ainda estão compilando, em vez de esperar em uma barreira de compilação completa
Entrega ordenada e o slot reservado
A API ordenada garante que seu callback veja as páginas na ordem em que você as solicitou, enquanto os workers terminam na ordem em que terminarem. Bitmaps concluídos são publicados em um slot de saída indexado pela posição de entrada, e o callback roda de forma serial na thread chamadora
Duas regras tornam isso limitado, e não apenas ordenado. A contagem da fila de saída inclui os bitmaps atualmente em uso pelo callback, de modo que um callback lento não pode deixar os produtores ultrapassarem a profundidade da fila. E páginas posteriores podem ocupar no máximo queueDepth - 1 slots, porque um slot é permanentemente reservado para a próxima página a ser entregue. Sem essa reserva, uma primeira página lenta pode ficar bloqueada por páginas posteriores já concluídas preenchendo a fila, e o pipeline entra em deadlock na cabeça da fila com todos os workers ociosos
uses
HPDFDoc;
procedure TExportJob.PageReady(Sender: TObject; InputIndex,
PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
// O bitmap é emprestado: válido apenas para esta chamada. Consuma-o aqui
// (grave em disco, codifique, faça hash) e não guarde a referência
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;
O contrato de bitmap emprestado é a razão pela qual o consumo de memória permanece estável. A biblioteca libera o bitmap assim que seu callback retorna, de modo que um fixture de 10.000 páginas com profundidade de fila 2 atinge o pico de exatamente dois bitmaps, tanto no Win32 quanto no Win64. Se você precisar do bitmap depois do callback, copie-o, e então você passa a ser dono da memória e da contabilidade
Como o orçamento de memória escolhe uma contagem de workers
Mais workers nem sempre é mais rápido e, muitas vezes, é fatal. Seis workers renderizando páginas A3 a 600 DPI precisam de várias centenas de megabytes de bitmap vivo, independentemente de quantos núcleos estejam disponíveis, e a máquina que tem oito núcleos pode não ter tanto espaço de endereçamento livre assim
O HotPDF, portanto, normaliza a contagem de workers solicitada em relação ao tamanho da requisição, à contagem de páginas e a um teto rígido, e depois divide o orçamento global atualmente disponível pela maior estimativa conservadora de página única na requisição. Ao menos um worker sempre sobrevive, de modo que uma página grande demais ainda é renderizada, de forma exclusiva. Na medição de referência, um trabalho de seis páginas a 600 DPI sob um orçamento de 512 MiB rodou com três workers em vez de seis, com um pico de reserva de 504.583.296 bytes
Cada worker faz uma reserva de memória depois de reivindicar uma página e antes de compilar, e a reserva cobre a compilação e a reprodução juntas. Reservar apenas em torno do estágio de rasterização deixaria de fora a display list e o conjunto de trabalho do fluxo de conteúdo, que em páginas com muitos gráficos é a metade maior. As duas APIs então diferem: a forma em array libera sua reserva assim que o bitmap fica pronto, porque o chamador passa a ser dono do resultado e o agendador não pode mais contabilizá-lo, enquanto a forma ordenada transfere a reserva junto com o bitmap para o slot de saída e a mantém até o callback retornar. Essa é outra razão para preferir a API ordenada quando o conjunto de saída é grande
Cancelamento sem deadlock
Definir Cancel no callback interrompe novo trabalho e desperta qualquer worker que esteja esperando por contrapressão. Páginas já dentro do renderizador terminam e são limpas, em vez de abandonadas, e apenas o primeiro diagnóstico de falha é retido, disparado depois que todos os workers tiverem se juntado
A ordem aqui é estrutural. O estado de cancelamento é definido primeiro, as reservas de saída não publicadas são liberadas em segundo lugar, e só então os workers são unidos. Inverter os dois últimos passos cria um laço fechado: um worker espera pela admissão em um orçamento que não será liberado até o consumidor terminar, enquanto o consumidor espera esse worker sair
Aplicativos interativos também devem saber sobre a preempção de primeiro plano. O pré-carregamento de páginas roda com seus próprios tokens de cancelamento, e as chamadas de renderização em primeiro plano cancelam e se juntam aos workers de pré-carregamento antes de fazer seu próprio trabalho, de modo que o throughput em segundo plano nunca adiciona latência à página que um usuário está esperando. O cancelamento é verificado em intervalos limitados por 4 KiB de varredura de bytes ou 256 tokens, embora um codec atômico ou uma chamada de sistema operacional não possa ser interrompida no meio da execução — a garantia de responsividade cobre laços de propriedade da biblioteca, não decodificadores de terceiros. A abordagem baseada em fila para renderização interativa é descrita em renderização em segundo plano com uma fila de requisições
Lendo as estatísticas do pipeline
THPDFParallelRenderPipelineInfo separa números que são fáceis de confundir. RequestedWorkerCount comparado a WorkerCount e MemoryBudgetWorkerLimit diz se o orçamento reduziu sua concorrência. CompiledPageCount comparado a CacheHitPageCount diz quanto o cache da display list economizou. CompiledPagesAtFirstResult mostra se o pipeline realmente sobrepôs compilação e reprodução ou degenerou em uma barreira
Para ajuste fino, os dois mais úteis são BackpressureWaitCount e MemorySchedulerWaitMilliseconds. Esperas altas de contrapressão significam que seu callback é o gargalo, então torne-o mais rápido ou aumente a profundidade da fila se a memória permitir. Esperas altas do agendador significam que o orçamento é o gargalo, então reduza o DPI, reduza a concorrência ou aumente o orçamento. Aumentar a contagem de workers em qualquer um dos dois casos piora o throughput, que é a parte contraintuitiva
Um ponto de partida prático para exportação em lote: concorrência igual ao número de núcleos físicos menos um, profundidade de fila de dois ou três, e DPI escolhido a partir do requisito real de saída, não por hábito. Depois leia as estatísticas de um documento real e ajuste uma vez, em vez de tentar adivinhar duas vezes. Para trabalhos em que o próprio documento é grande demais para caber em memória, combine isso com o modelo de acesso em streaming em a API de arquivo direto para fluxos de trabalho com PDFs grandes
A renderização paralela, o pré-carregamento e o cache da display list fazem parte da mesma pilha de renderização para Delphi e C++Builder; a lista completa de recursos está na página do componente PDF Delphi HotPDF