A primeira leitura útil de um analisador de PDF fica na ponta errada do arquivo. O formato coloca o ponteiro startxref nos bytes finais, de modo que processar um arquivo 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 arquivo — é direcionado a uma carga de trabalho que o PDF não possui
A primeira versão deste artigo afirmava que um arquivo 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 arquivo 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 arquivo 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 arquivo 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 usuário, e o MapViewOfFile com uma contagem de bytes zero solicita uma reserva contígua do tamanho do arquivo. 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 arquivo 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 arquivo. 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 arquivo. 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
uses
Winapi.Windows, System.SysUtils;
type
TWindowedFileMapper = class
private
FFile: THandle;
FMapping: THandle;
FFileSize: Int64;
FGranularity: DWORD; // SYSTEM_INFO.dwAllocationGranularity
FWindowSize: NativeUInt; // default view size
FViewBase: PByte; // base of the current view (aligned)
FViewOffset: Int64; // file offset FViewBase corresponds to
FViewSize: NativeUInt; // bytes mapped in the current view
procedure Unmap;
public
constructor Create(const FileName: string;
WindowSize: NativeUInt = 64 * 1024 * 1024);
destructor Destroy; override;
function Map(Offset: Int64; Size: NativeUInt): PByte;
procedure ReadBytes(Offset: Int64; var Buffer; Count: NativeUInt);
property FileSize: Int64 read FFileSize;
end;
constructor TWindowedFileMapper.Create(const FileName: string;
WindowSize: NativeUInt);
var
Info: TSystemInfo;
begin
inherited Create;
FFile := CreateFile(PChar(FileName), GENERIC_READ, FILE_SHARE_READ, nil,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
if FFile = INVALID_HANDLE_VALUE then
RaiseLastOSError;
if not GetFileSizeEx(FFile, FFileSize) then
RaiseLastOSError;
// The section object reserves no address space, whatever the file size
FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
if FMapping = 0 then
RaiseLastOSError;
GetSystemInfo(Info);
FGranularity := Info.dwAllocationGranularity; // 64 KB in practice
FWindowSize := WindowSize;
end;
destructor TWindowedFileMapper.Destroy;
begin
Unmap;
if FMapping <> 0 then CloseHandle(FMapping);
if FFile <> INVALID_HANDLE_VALUE then CloseHandle(FFile);
inherited;
end;
procedure TWindowedFileMapper.Unmap;
begin
if FViewBase <> nil then
begin
UnmapViewOfFile(FViewBase);
FViewBase := nil;
FViewSize := 0;
end;
end;
function TWindowedFileMapper.Map(Offset: Int64; Size: NativeUInt): PByte;
var
AlignedOffset: Int64;
Delta, MapSize: NativeUInt;
begin
if (Offset < 0) or (Offset + Int64(Size) > FFileSize) then
raise ERangeError.CreateFmt(
'Map request at %d for %d bytes is outside the file',
[Offset, Int64(Size)]);
// Fast path: the requested range already sits inside the live view
if (FViewBase <> nil) and (Offset >= FViewOffset) and
(Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
Exit(FViewBase + NativeInt(Offset - FViewOffset));
Unmap; // slide: never hold two views at once
// Views must start on an allocation-granularity boundary
AlignedOffset := Offset - (Offset mod FGranularity);
Delta := NativeUInt(Offset - AlignedOffset);
MapSize := FWindowSize;
if MapSize < Size + Delta then // request straddles the window end:
MapSize := Size + Delta; // grow this one view to cover it
if AlignedOffset + Int64(MapSize) > FFileSize then
MapSize := NativeUInt(FFileSize - AlignedOffset); // clamp at EOF
FViewBase := MapViewOfFile(FMapping, FILE_MAP_READ,
DWORD(AlignedOffset shr 32), DWORD(AlignedOffset and $FFFFFFFF),
MapSize);
if FViewBase = nil then
RaiseLastOSError;
FViewOffset := AlignedOffset;
FViewSize := MapSize;
Result := FViewBase + NativeInt(Delta);
end;
procedure TWindowedFileMapper.ReadBytes(Offset: Int64; var Buffer;
Count: NativeUInt);
begin
Move(Map(Offset, Count)^, Buffer, Count);
end;
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 arquivo 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, arquivos 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 usuário para entregar cerca de 180 MB de payload — amplificação de seis vezes, com cada byte copiado do kernel para o usuário
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 arquivo 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 arquivos sem cópia de espaço de usuário, 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 arquivo, 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 arquivo
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 arquivo 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 arquivos 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 arquivo 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 arquivo sem construir a árvore de objetos, copia e descriptografa em nível de arquivo 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 arquivo a arquivo 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á executando 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