Artigo Técnico

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

A primeira leitura útil de um parser de PDF está na ponta errada do ficheiro. O formato coloca o ponteiro startxref nos bytes finais, pelo que processar um arquivo de 1,8 GB começa com um seek até ao final, uma leitura de um kilobyte, e depois um salto para onde quer que a tabela de referência cruzada diga que reside o catálogo do documento. A partir daí, a análise é um percurso aleatório por todo o intervalo de bytes. Tudo aquilo em que a E/S com buffer é boa — leitura antecipada sequencial atrás do ponteiro de ficheiro — está orientado para uma carga de trabalho que o PDF não tem

A primeira versão deste artigo afirmava que um ficheiro mapeado em memória resolve a falha de memória insuficiente em 32 bits que o TMemoryStream atinge numa entrada de 2 GB. Essa afirmação está errada, e o modo como está errada aponta para a correção verdadeira: uma janela de mapeamento deslizante. O que se segue é o padrão de acesso, a história de 32 bits corrigida com um mapeador em janelas compilável, e a aritmética de syscalls num ficheiro de teste de 1,8 GB e 300.000 objetos

Porque é que o layout do PDF derrota as leituras com buffer

Três factos estruturais moldam o padrão de E/S. Primeiro, a navegação é orientada por deslocamento: a tabela de referência cruzada mapeia cada número de objeto para uma posição absoluta de bytes, e nada obriga a que essas posições estejam 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 ciclo TFileStream transforma cada acesso num Seek mais um Read, duas transições de kernel, com um buffer que não contribui em nada porque o próximo acesso está a centenas de megabytes de distância

Segundo, as streams de objeto (ISO 32000-1 §7.5.7) empacotam dezenas ou centenas de pequenos dicionários num único contentor deflated. Aceder a um dicionário de página de 300 bytes pode significar ler e inflar um cluster de 100 KB. O reverso da moeda: objetos escritos juntos tendem a ser lidos juntos, pelo que um buffer dimensionado para o cluster serve gratuitamente a próxima dúzia de acessos — a regularidade mais explorável do formato

Terceiro, a linearização. Um ficheiro linearizado coloca a primeira página e uma hint table logo no início, para que os consumidores o possam ler do princípio ao fim. Arquivos da ordem do gigabyte quase nunca estão linearizados: a linearização é destruída pelas mesmas atualizações incrementais e fusões que tornaram o ficheiro grande. Deve planear-se para o caso hostil: saltos longos, sem ordenação, entrada pelo final

PDF: Padrão de acesso hop-by-hop do processamento de PDFs à escala de gigabytes, entrando no startxref da cauda do ficheiro e depois percorrendo offsets de referências cruzadas dispersos
A navegação PDF entra pela cauda e depois salta para onde a tabela de referências cruzadas apontar, o que derrota a leitura antecipada sequencial

A história de 32 bits, corrigida

Um processo Windows de 32 bits tem 2 GB de espaço de endereçamento de utilizador, e o MapViewOfFile com uma contagem de bytes igual a zero pede uma reserva contígua do tamanho do ficheiro inteiro. Para uma entrada de 2 GB, essa reserva não pode ter sucesso: depois do EXE, das DLLs dispersas e das pilhas de threads, o maior bloco contíguo livre num processo Delphi de 32 bits típico situa-se algures 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 deslocada de RAM confirmada para reserva de espaço de endereçamento. Um mapeamento do ficheiro completo não resolve nada em 32 bits, é apenas a mesma falha por trás de nomes de API com melhor aparência

A correção passa por separar as duas coisas que um mapeamento faz. O CreateFileMapping cria o objeto de secção e não custa espaço de endereçamento nenhum, seja qual for o tamanho do ficheiro. Só o MapViewOfFile gasta espaço de endereçamento, e nada o obriga a mapear a secção inteira: recebe um deslocamento inicial de 64 bits e um comprimento de vista. Deve criar-se a secção uma vez, mapear uma vista de 64 a 256 MB sobre a região a analisar, e desfazer o mapeamento antes de avançar: o custo em espaço de endereçamento é o de uma janela, não o de um ficheiro. Uma restrição: os deslocamentos de vista têm de ser múltiplos de SYSTEM_INFO.dwAllocationGranularity, na prática 64 KB, pelo que um pedido para o deslocamento 1.000.000 é arredondado por defeito para 983.040, e o ponteiro do chamador é ajustado para a frente pela diferença

Um mapeador de janela deslizante em Delphi

A classe abaixo encapsula toda esta disciplina: um objeto de secção, uma vista ativa, o realinhamento de granularidade, e leituras que atravessam um limite de janela tratadas fazendo crescer essa mesma vista 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;  // tamanho de vista predefinido
    FViewBase: PByte;         // base da vista atual (alinhada)
    FViewOffset: Int64;       // deslocamento no ficheiro a que FViewBase corresponde
    FViewSize: NativeUInt;    // bytes mapeados na vista atual
    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;
  // O objeto de secção não reserva espaço de endereçamento, seja qual for o tamanho do ficheiro
  FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
  if FMapping = 0 then
    RaiseLastOSError;
  GetSystemInfo(Info);
  FGranularity := Info.dwAllocationGranularity;  // na prática, 64 KB
  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)]);

  // Caminho rápido: o intervalo pedido já está dentro da vista ativa
  if (FViewBase <> nil) and (Offset >= FViewOffset) and
     (Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
    Exit(FViewBase + NativeInt(Offset - FViewOffset));

  Unmap;  // deslizar: nunca manter duas vistas ao mesmo tempo

  // As vistas têm de começar num limite de granularidade de alocação
  AlignedOffset := Offset - (Offset mod FGranularity);
  Delta := NativeUInt(Offset - AlignedOffset);

  MapSize := FWindowSize;
  if MapSize < Size + Delta then   // o pedido ultrapassa o final da janela:
    MapSize := Size + Delta;       // aumenta-se esta vista para o cobrir
  if AlignedOffset + Int64(MapSize) > FFileSize then
    MapSize := NativeUInt(FFileSize - AlignedOffset);  // limitar ao 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 é que fazem a diferença. O caminho rápido no topo de Map devolve um ponteiro sem qualquer transição de kernel quando o intervalo pedido já está dentro da vista ativa; graças ao agrupamento em streams de objeto, este é o caso comum e é daí que vêm as poupanças. E um pedido que ultrapassa o final da janela predefinida faz crescer o MapSize para essa única vista, em vez de unir duas, o que mantém o ReadBytes numa única linha e liberta quem chama de ciclos de leitura parcial

O tamanho da janela é um parâmetro tolerante: a 64 MB, uma varredura completa de um ficheiro de 1,8 GB corresponde a 29 vistas; a 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 com muitos saltos remapeiam com frequência suficiente para se notar. Em qualquer ponto do intervalo entre 64 e 256 MB, o tráfego de mapeamento é ruído estatístico

Contar as syscalls

Agora, a aritmética. Ficheiro de teste: 1,8 GB, 300.000 objetos indiretos com uma média de cerca de 600 bytes de payload. Um parser por objeto acede a cada um com SetFilePointerEx mais um ReadFile de 4 KB: 600.000 transições de kernel. Uma syscall de leitura em cache tem um round-trip de aproximadamente 1,5 μs no hardware x64 atual, o que dá 600.000 × 1,5 μs ≈ 0,9 segundos de puro overhead de kernel antes de analisar um único byte — o melhor cenário possível, com a cache quente. A frio, cada salto é uma operação de dispositivo: com a latência efetiva de ~20 μs das leituras aleatórias de 4 KB em NVMe, 300.000 delas custam cerca de 6 segundos de tempo de dispositivo; em armazenamento de 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 apenas cerca de 180 MB de payload — uma amplificação de seis vezes, com cada byte copiado do kernel para o utilizador

Um buffer de leitura antecipada dimensionado para os clusters de streams de objeto é 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 é incómodo, normalmente em partilhas 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 orientada por xref não é uma varredura limpa, mas o caminho rápido absorve todos os acessos dentro da janela ativa; uma passagem de indexação de metadados sobre o arquivo de teste estabilizou em algumas centenas de remapeamentos. O mapeamento não elimina o trabalho de kernel: converte syscalls explícitas em page faults que o gestor de memória resolve em clusters de várias páginas, diretamente a partir da cache de ficheiros, sem cópia para 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 a frio e 7,1 s a quente com leituras por objeto, para 6,5 s a frio e 1,9 s a quente com o mapeador; o que resta é o inflate do zlib, não a E/S

PDF: Espaço de endereçamento de 32 bits fragmentado a rejeitar um MapViewOfFile do ficheiro inteiro, enquanto uma secção CreateFileMapping e uma janela de mapeamento deslizante de 64 MB têm êxito
Um objeto de secção mais uma vista ativa mantêm um PDF de 1,8 GB legível dentro do espaço de endereçamento de 2 GB de um processo Delphi de 32 bits

Onde encaixa o FILE_FLAG_NO_BUFFERING

O FILE_FLAG_NO_BUFFERING ignora a cache do sistema em troca de regras rígidas de alinhamento: deslocamentos, comprimentos e endereços de buffer todos alinhados por setor. Compensa em trabalhos sequenciais de passagem única que, de outro modo, inundariam a cache com bytes que ninguém volta a ler — uma reserialização em lote que reescreve todo o arquivo, ou uma passagem de linearização sobre a saída já concluída. Com buffers alinhados de 4 a 8 MB, aproxima-se da largura de banda sequencial do dispositivo sem poluir a cache

É exatamente o errado para a análise. Saltos aleatórios na xref através de um handle sem buffer transformam cada acesso a um dicionário de 300 bytes numa leitura física completa, sem cache para absorver a segunda visita — e a análise de PDF revisita regiões constantemente, porque páginas diferentes resolvem para as mesmas streams de objeto. E/S sem buffer para a reescrita sequencial, E/S mapeada ou em cache para a análise aleatória; a flag é definida por handle, pelo que um mesmo pipeline pode ter as duas no mesmo ficheiro

64 bits, working sets e o lado da escrita

Numa compilação de 64 bits, a objeção do espaço de endereçamento desaparece: basta passar o tamanho do ficheiro como janela, e a classe acima degenera num único mapeamento completo. O senão nos serviços de longa duração: páginas só de leitura, apoiadas em ficheiro, não consomem commit, pelo que os contadores de commit permanecem calmos, mas toda a página tocada entra para o working set; ao analisar a maior parte de 1,8 GB, o working set cresce na mesma proporção, expulsando tudo o resto. Janelas limitadas impõem um teto a isso, pelo que o padrão deslizante continua a ser a opção correta por defeito, mesmo onde o espaço de endereçamento é gratuito

Do 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 alterados e uma nova secção de referência cruzada depois dos bytes originais, que nunca se movem. Carimbar 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 a anexação é saída sequencial pura no final

Onde encaixam as bibliotecas da losLab

Ambas as bibliotecas de PDF da losLab oferecem esta disciplina como superfície de API. A HotPDF Direct File API lê contagens de páginas e estrutura através de um handle de ficheiro sem construir a árvore de objetos, copia e desencripta ao nível do ficheiro, e escreve deltas através de BeginIncrementalUpdate — a estratégia apenas de anexação descrita acima, já empacotada. A PDF Library for Delphi segue o mesmo caminho com a sua camada de Direct Access: um leitor em streaming que percorre a tabela de referência cruzada no próprio local, acede a objetos de forma preguiçosa, extrai intervalos de páginas de ficheiro para ficheiro, e persiste edições como revisões incrementais. Quem estiver a escrever o seu próprio parser pode ficar com a classe do mapeador; quem estiver a correr um pipeline de documentos pode deixar que seja a biblioteca a manter a janela honesta

Nota: o tratamento otimizado de E/S para documentos à escala do gigabyte está integrado diretamente no HotPDF Delphi VCL Component para Delphi e C++Builder

PDF: Gráfico de colunas de 600000 syscalls ReadFile por objeto versus read-ahead por clusters e 58 transições do mapper por janela ao indexar um PDF de 1,8 GB com 300000 objetos
As leituras por objeto queimam 600 000 syscalls no arquivo de teste, enquanto o mapeador com janela reduz o varrimento completo a 58 transições