Artigo Técnico

Otimizar o Desempenho de IO para Processamento de PDF à Escala de Gigabytes

A primeira leitura útil de um analisador de PDF fica na ponta errada do ficheiro. O formato coloca o ponteiro startxref nos bytes finais, de modo que processar um ficheiro de 1,8 GB começa com uma busca até o final, uma leitura de um kilobyte e, em seguida, um salto para onde quer que a tabela de referência cruzada diga que o catálogo do documento reside. A partir daí, a análise é um passeio aleatório por todo o intervalo de bytes. Tudo em que a E/S em buffer é boa — leitura antecipada sequencial (read-ahead) atrás do ponteiro de ficheiro — é direcionado a uma carga de trabalho que o PDF não possui

A primeira versão deste artigo afirmava que um ficheiro mapeado em memória resolve a falha de falta de memória (out-of-memory) de 32 bits que o TMemoryStream atinge em uma entrada de 2 GB. Essa afirmação está errada, e a forma como ela está errada aponta para a verdadeira correção: uma janela de mapeamento deslizante. O que se segue é o padrão de acesso, a história corrigida de 32 bits com um mapeador em janelas compilável e a aritmética de chamadas de sistema (syscall) em um ficheiro de teste de 1,8 GB e 300.000 objetos

Por que o layout do PDF derrota as leituras em buffer

Três fatos estruturais moldam o padrão de E/S. Primeiro, a navegação é baseada em deslocamento: a tabela de referência cruzada mapeia cada número de objeto para uma posição absoluta de bytes, e nada exige que essas posições sejam ordenadas. Após anos de atualizações incrementais, o objeto 4102 pode estar no deslocamento 1,6 GB, enquanto o objeto 4103 está em 30 KB. Um laço TFileStream transforma cada busca em um Seek mais um Read, duas transições de kernel, com um buffer que não contribui com nada porque a próxima busca está a centenas de megabytes de distância

Segundo, os streams de objetos (ISO 32000-1 §7.5.7) empacotam dezenas ou centenas de pequenos dicionários em um contêiner desinflado. Buscar um dicionário de página de 300 bytes pode significar ler e inflar um cluster de 100 KB. O outro lado da moeda: objetos escritos juntos tendem a ser lidos juntos, portanto, um buffer dimensionado para o cluster atende à próxima dúzia de buscas gratuitamente — a regularidade mais explorável no formato

Terceiro, linearização. Um ficheiro linearizado carrega inicialmente a primeira página e uma tabela de dicas (hint table) para que os consumidores possam lê-lo do início ao fim. Arquivos de gigabytes quase nunca são linearizados: a linearização é destruída pelas mesmas atualizações incrementais e mesclagens que tornaram o ficheiro grande. Planeje-se para o caso hostil: saltos longos, sem ordenação, entrada pelo final primeiro

A história de 32 bits, corrigida

Um processo Windows de 32 bits tem 2 GB de espaço de endereço de utilizador, e o MapViewOfFile com uma contagem de bytes zero solicita uma reserva contígua do tamanho do ficheiro. Para uma entrada de 2 GB, essa reserva não pode ter sucesso: após o EXE, DLLs espalhadas e pilhas de threads, o maior bloco contíguo livre em um processo Delphi típico de 32 bits fica em algum lugar entre 700 MB e 1,4 GB. A chamada falha com ERROR_NOT_ENOUGH_MEMORY, a mesma barreira em que o TMemoryStream.LoadFromFile esbarra, apenas movida de RAM confirmada (committed RAM) para reserva de espaço de endereço. Um mapeamento de ficheiro completo não é solução em 32 bits, apenas a mesma falha por trás de nomes de API que soam melhor

A correção é separar as duas coisas que um mapeamento faz. CreateFileMapping cria o objeto de seção e não custa nenhum espaço de endereço, independentemente do tamanho do ficheiro. Apenas o MapViewOfFile gasta espaço de endereço, e nada o obriga a mapear a seção inteira: ele recebe um deslocamento inicial de 64 bits e um tamanho de visualização (view length). Crie a seção uma vez, mapeie uma visualização de 64 a 256 MB sobre a região sendo analisada, desmapeie antes de deslizar adiante: o custo no espaço de endereço é uma janela, não um ficheiro. Uma restrição: os deslocamentos de visualização devem ser múltiplos de SYSTEM_INFO.dwAllocationGranularity, 64 KB na prática, portanto, uma solicitação de deslocamento 1.000.000 é arredondada para baixo, para 983.040, e o ponteiro do chamador ajustado para frente pela diferença

Um mapeador de janela deslizante em Delphi

A classe abaixo envolve toda a disciplina: um objeto de seção, uma visualização ao vivo (live view), realinhamento de granularidade e leituras que cruzam um limite de janela manipuladas pelo aumento dessa única visualização em vez de unir duas

__CODE_BLOCK_

Dois detalhes carregam o peso. O caminho rápido no topo do Map retorna um ponteiro sem transição de kernel quando o intervalo solicitado já se encontra dentro da visualização ao vivo; graças ao agrupamento (clustering) de stream de objetos, esse é o caso comum e é de onde vêm as economias. E uma solicitação que transpõe o final da janela padrão aumenta o MapSize para essa visualização, em vez de costurar duas, o que mantém o ReadBytes em apenas uma linha e libera quem faz a chamada de laços de leitura parcial

O tamanho da janela é um ajuste que perdoa: em 64 MB, uma varredura completa de um ficheiro de 1,8 GB equivale a 29 visualizações; em 256 MB são 8, mas cada reserva é mais difícil de encaixar num espaço de 32 bits fragmentado; e, abaixo de cerca de 16 MB, ficheiros pesados em saltos são remapeados com frequência suficiente para serem notados. Em qualquer lugar na faixa de 64 a 256 MB, o tráfego de mapas é um ruído estatístico

Contando as chamadas de sistema (syscalls)

Agora a aritmética. Arquivo de teste: 1,8 GB, 300.000 objetos indiretos com média de cerca de 600 bytes de dados úteis (payload). Um analisador por objeto busca cada um com SetFilePointerEx mais um ReadFile de 4 KB: 600.000 transições de kernel. Uma chamada de sistema de leitura em cache dá a volta em aproximadamente 1,5 μs no hardware x64 atual, logo são 600.000 × 1,5 μs ≈ 0,9 segundos de pura sobrecarga (overhead) de kernel antes de analisar um único byte — o melhor cenário do cache quente. Frio (cold), cada salto é uma operação de dispositivo: em uma latência efetiva de ~20 μs de leituras aleatórias NVMe de 4 KB, 300.000 deles custam cerca de 6 segundos de tempo de dispositivo; em armazenamento classe SATA, minutos

As leituras também movem os dados errados: 300.000 × 4 KB empurram 1,2 GB através de buffers de utilizador para entregar cerca de 180 MB de payload — amplificação de seis vezes, com cada byte copiado do kernel para o utilizador

Um buffer read-ahead dimensionado para os clusters do stream de objetos é a primeira melhoria honesta: uma leitura de 256 KB por cluster em vez de uma por objeto reduz a contagem de transições em uma a duas ordens de grandeza. É também a ferramenta certa onde o mapeamento é complicado, geralmente em compartilhamentos de rede

O mapeador em janelas vai mais longe. Uma varredura completa corresponde a 29 chamadas MapViewOfFile e 29 chamadas UnmapViewOfFile, 58 transições explícitas contra 600.000. Uma análise real impulsionada por xref não é uma varredura limpa, mas o caminho rápido absorve cada busca dentro da janela ao vivo; uma passagem de indexação de metadados sobre o ficheiro de teste acomodou-se em poucas centenas de remapeamentos. O mapeamento não remove o trabalho do kernel: ele converte chamadas de sistema explícitas em falhas de página (page faults) que o gerenciador de memória resolve em clusters de várias páginas, diretamente do cache de ficheiros sem cópia de espaço de utilizador, e regiões nunca tocadas não custam nada. De ponta a ponta, a passagem de indexação passou de 23 s com cache frio e 7,1 s quente com leituras por objeto para 6,5 s com cache frio e 1,9 s quente com o mapeador; o que resta é o inflate do zlib, não E/S

Onde o FILE_FLAG_NO_BUFFERING se encaixa

O FILE_FLAG_NO_BUFFERING ignora o cache do sistema em troca de regras estritas de alinhamento: deslocamentos, comprimentos e endereços de buffer todos alinhados por setor. Ele ganha o seu valor em trabalhos sequenciais de passagem única que de outra forma inundariam o cache com bytes que ninguém lê duas vezes — uma re-serialização em lote que reescreve todo o ficheiro, ou uma passagem de linearização sobre a saída finalizada. Com buffers alinhados de 4 a 8 MB, aproxima-se da largura de banda sequencial do dispositivo sem poluir o cache

Isso é exatamente errado para análise. Saltos aleatórios na xref através de um identificador (handle) sem buffer transformam cada busca de dicionário de 300 bytes em uma leitura física completa sem cache para absorver a segunda visita — e a análise de PDF revisita regiões constantemente, porque diferentes páginas são resolvidas nos mesmos streams de objetos. E/S sem buffer para a reescrita sequencial, E/S mapeada ou com cache para a análise aleatória; o flag é definido por identificador, portanto, um pipeline pode conter ambos no mesmo ficheiro

64 bits, working sets e o lado da escrita

Em uma compilação de 64 bits a objeção ao espaço de endereço desaparece: passe o tamanho do ficheiro como a janela e a classe acima degenera em um único mapeamento completo. O problema (catch) em serviços de longa duração: páginas lastreadas por ficheiros e somente de leitura não cobram uso (commit), portanto, os contadores de uso permanecem calmos, mas toda página tocada entra para o working set; analise a maior parte dos 1,8 GB e o conjunto de trabalho crescerá para corresponder, removendo todo o resto. Janelas limitadas impõem um teto a isso, portanto, o padrão deslizante continua sendo o padrão correto, mesmo onde o espaço de endereço é gratuito

No lado da escrita, a E/S mais barata é aquela que nunca é emitida. O mecanismo de atualização incremental do PDF (ISO 32000-1 §7.5.6) anexa os objetos modificados e uma nova seção de referência cruzada após os bytes originais, que nunca se movem. Adicionar um carimbo (stamp) em uma página no ficheiro de 1,8 GB anexa dezenas de kilobytes; uma reescrita completa move todos os 1,8 GB, cinco ordens de grandeza de diferença, e o acréscimo é uma saída sequencial pura no final

Onde as bibliotecas da losLab se encaixam

Ambas as bibliotecas de PDF da losLab oferecem essa disciplina como superfície de API. A API Direta de Arquivo do HotPDF lê contagens de páginas e a estrutura por meio de um identificador de ficheiro sem construir a árvore de objetos, copia e descriptografa em nível de ficheiro e escreve deltas usando BeginIncrementalUpdate — a estratégia somente para anexar (append-only) descrita acima, já empacotada. A PDFlibPas segue a mesma rota com a sua camada de Acesso Direto: um leitor de streaming que percorre a tabela de referência cruzada de forma contínua, busca objetos de forma preguiçosa (lazily), extrai os intervalos da página ficheiro a ficheiro e persiste edições como revisões incrementais. Se você está escrevendo seu próprio analisador, a classe do mapeador está livre para ser pega; se você está a executar um pipeline de documentos, deixe a biblioteca manter a janela de maneira honesta

Nota: O manuseio otimizado de E/S para documentos na escala de gigabytes é integrado diretamente no Componente VCL HotPDF para Delphi e C++Builder