O HotPDF renderiza muitas páginas PDF em simultâneo através de dois pontos de entrada: RenderLoadedPagesParallel, que devolve um array de bitmaps, e RenderLoadedPagesParallelOrdered, que entrega cada página a um callback pela ordem de entrada, assim que estiver pronta. A forma ordenada é a que escala, porque nunca mantém em memória mais do que um número limitado de bitmaps, independentemente de quantas páginas tiver pedido
Esta distinção é, no fundo, todo o artigo. Renderizar 10 000 páginas a 300 DPI para um array significa 10 000 bitmaps ativos em simultâneo, o que não é um problema de débito, mas um esgotamento de memória. Renderizá-las através de um callback ordenado com uma profundidade de fila de dois significa dois bitmaps ativos, e a tarefa executa com uma ocupação fixa, seja qual for a extensão do documento
Porque é que a renderização paralela precisa de duas fases?
Renderizar uma página PDF é, na prática, dois trabalhos diferentes com um único nome. Primeiro, o fluxo de conteúdo é tokenizado e compilado numa display list, o que é limitado pelo CPU e acede a caches partilhadas. Depois, a display list é reproduzida para um bitmap, o que também é limitado pelo CPU, mas com fortes alocações de memória. Tratá-los como uma unidade indivisível obriga a escolher entre serializar a metade que acede à cache e duplicar trabalho
O HotPDF separa-os. O bloqueio da cache de display lists protege apenas a contabilização de frequência, a ordem LRU, as contagens de utilização e as decisões de admissão; a compilação em si executa fora do bloqueio. Depois de compilar, o worker volta a consultar a cache: se outro worker já tiver publicado a mesma página entretanto, liberta a sua própria cópia e regista uma contagem de utilização na entrada publicada. Isto mantém o trabalho duplicado limitado e evita duas entradas para a mesma página, sem nunca serializar a compilação entre páginas
O efeito medido num conjunto de teste complexo de 32 páginas é a parte que vale a pena reter. O tempo total no Win64 baixou de 1565 ms com um worker para 696 ms com quatro, cerca de 2,25 vezes. O tempo até ao primeiro resultado baixou de 1255 ms para 63 ms, cerca de 95 por cento, porque os workers começam a reproduzir display lists já concluídas enquanto outros ainda estão a compilar, em vez de esperarem numa barreira de compilação completa
Entrega ordenada e o slot reservado
A API ordenada garante que o seu callback vê as páginas na ordem em que as pediu, enquanto os workers terminam pela ordem que lhes calhar. Os bitmaps concluídos são publicados num slot de saída indexado pela posição de entrada, e o callback executa de forma serial na thread que fez a chamada
Duas regras tornam isto limitado, e não apenas ordenado. A contagem da fila de saída inclui os bitmaps que estão atualmente a ser usados pelo callback, pelo que um callback lento não permite que os produtores ultrapassem a profundidade da fila. E as páginas posteriores só podem ocupar, no máximo, queueDepth - 1 slots, porque um slot está permanentemente reservado para a próxima página a entregar. Sem essa reserva, uma primeira página lenta pode ficar bloqueada por páginas posteriores já concluídas a encher a fila, e o pipeline entra em impasse à cabeça da fila, com todos os workers inativos
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, calcule o 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 a ocupação se mantém constante. A biblioteca liberta o bitmap assim que o seu callback termina, pelo que um conjunto de teste de 10 000 páginas com profundidade de fila 2 atinge um pico de exatamente dois bitmaps, tanto no Win32 como no Win64. Se precisar do bitmap depois do callback, copie-o, e nesse caso a memória e a contabilização passam a ser da sua responsabilidade
Como o orçamento de memória escolhe o número de workers
Mais workers nem sempre é mais rápido e é, frequentemente, fatal. Seis workers a renderizar páginas A3 a 600 DPI precisam de várias centenas de megabytes de bitmap ativo, independentemente do número de núcleos disponíveis, e a máquina que tem oito núcleos pode não ter esse espaço de endereçamento livre
O HotPDF normaliza, por isso, o número de workers pedido em função do tamanho do pedido, do número de páginas e de um limite máximo rígido, dividindo depois o orçamento global atualmente disponível pela maior estimativa conservadora para uma única página no pedido. Sobrevive sempre pelo menos um worker, pelo que uma página sobredimensionada continua a ser renderizada, ainda que sozinha. Na medição de referência, uma tarefa de seis páginas a 600 DPI, sob um orçamento de 512 MiB, executou 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 reclamar uma página e antes de compilar, e essa reserva cobre em conjunto a compilação e a reprodução. Reservar apenas em torno da fase 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 diferem então neste ponto: a forma em array liberta a sua reserva assim que o bitmap fica concluído, porque quem chamou passa a ser o dono do resultado e o escalonador deixa de o poder contabilizar, enquanto a forma ordenada transfere a reserva juntamente com o bitmap para o slot de saída e mantém-na até o callback terminar. Esta é outra razão para preferir a API ordenada quando o conjunto de saída é grande
Cancelamento sem impasse
Definir Cancel no callback interrompe novo trabalho e desperta qualquer worker que esteja à espera devido a contrapressão. As páginas já dentro do renderizador terminam e são limpas, em vez de abandonadas, e apenas o primeiro diagnóstico de falha é retido, sendo lançado depois de todos os workers terem terminado
A ordem aqui é estrutural. O estado de cancelamento é definido primeiro, as reservas de saída não publicadas são libertadas em segundo lugar, e só depois é que se aguarda pelo fim dos workers. Inverter os dois últimos passos cria um ciclo fechado: um worker espera admissão num orçamento que não será libertado até o consumidor terminar, enquanto o consumidor espera que esse worker saia
As aplicações interativas devem também conhecer a preempção em primeiro plano. O pré-carregamento de páginas executa com os seus próprios tokens de cancelamento, e as chamadas de renderização em primeiro plano cancelam e aguardam pelos workers de pré-carregamento antes de fazerem o seu próprio trabalho, pelo que o débito em segundo plano nunca acrescenta latência à página que um utilizador está a aguardar. O cancelamento é verificado em intervalos limitados a 4 KiB de leitura de bytes ou 256 tokens, embora um codec atómico ou uma chamada ao sistema operativo não possam ser interrompidos a meio da execução — a garantia de capacidade de resposta cobre os ciclos pertencentes à biblioteca, não os descodificadores de terceiros. A abordagem baseada em fila para renderização interativa é descrita em renderização em segundo plano com uma fila de pedidos
Ler as estatísticas do pipeline
THPDFParallelRenderPipelineInfo separa números que são fáceis de confundir. Comparar RequestedWorkerCount com WorkerCount e MemoryBudgetWorkerLimit diz-lhe se o orçamento reduziu a sua concorrência. Comparar CompiledPageCount com CacheHitPageCount diz-lhe quanto poupou a cache de display lists. CompiledPagesAtFirstResult mostra se o pipeline sobrepôs mesmo a compilação e a reprodução, ou se degenerou numa barreira
Para ajuste fino, os dois mais úteis são BackpressureWaitCount e MemorySchedulerWaitMilliseconds. Esperas de contrapressão elevadas significam que o seu callback é o gargalo, pelo que deve torná-lo mais rápido ou aumentar a profundidade da fila, se a memória o permitir. Esperas do escalonador elevadas significam que o orçamento é o gargalo, pelo que deve reduzir o DPI, reduzir a concorrência ou aumentar o orçamento. Aumentar o número de workers em qualquer um dos casos piora o débito, o 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, em vez de por hábito. Depois, leia as estatísticas de um documento real e ajuste uma vez, em vez de adivinhar duas. Para tarefas em que o próprio documento é demasiado grande para caber em memória, combine isto com o modelo de acesso em streaming de a API de ficheiro direto para fluxos de trabalho com PDF grandes
A renderização paralela, o pré-carregamento e a cache de display lists fazem todos parte da mesma pilha de renderização para Delphi e C++Builder; a lista completa de funcionalidades está disponível na página do componente PDF HotPDF para Delphi