Teknisk artikkel

Optimalisere IO-ytelse for gigabyte-skala PDF-behandling

En PDF-parsers første nyttige lesing er i feil ende av filen. Formatet plasserer startxref-pekeren i de aller siste bytene, så å behandle et 1,8 GB arkiv begynner med et søk (seek) til halen, en en-kilobytes lesing, etterfulgt av et hopp til hvor enn kryssreferansetabellen sier at dokumentkatalogen ligger. Derfra og ut er parsingen en tilfeldig vandring (random walk) over hele bytereferansen. Alt det bufret I/O er god på — sekvensiell forhåndslesing bak filpekeren (read-ahead) — er rettet mot en arbeidsmengde PDF rett og slett ikke har

Den første versjonen av denne artikkelen påsto at en memory-mapped fil løser 32-bit out-of-memory-feilen som TMemoryStream møter på et 2 GB inndata. Den påstanden er feil, og måten den er feil på peker mot den virkelige løsningen: et glidende kartleggingsvindu (sliding mapping window). Det som følger er tilgangsmønsteret, den korrigerte 32-bit historien med en kompilerbar vindu-mapper, og systemkall-aritmetikken (syscall arithmetic) på en 1,8 GB testfil med 300 000 objekter

Hvorfor PDF-layout overvinner bufrede lesinger

Tre strukturelle fakta former I/O-mønsteret. For det første er navigasjonen forskyvnings-drevet (offset-driven): kryssreferansetabellen tilordner hvert objektnummer til en absolutt byte-posisjon, og ingenting krever at disse posisjonene er sortert. Etter årevis med inkrementelle oppdateringer, kan objekt 4102 sitte på forskyvning 1,6 GB, mens objekt 4103 sitter på 30 KB. En TFileStream-løkke gjør ethvert oppslag til et Seek pluss et Read, altså to kjerneoverganger (kernel transitions), med en buffer som ikke bidrar med noe, fordi neste oppslag er hundrevis av megabyte unna

For det andre pakker objektstrømmer (ISO 32000-1 §7.5.7) titalls eller hundrevis av små ordbøker inn i én deflatert (deflated) container. Å hente én sideordbok på 300 byte kan bety å lese og fylle opp (inflate) en klynge (cluster) på 100 KB. Den andre siden av saken: objekter som er skrevet sammen, pleier å bli lest sammen, så en buffer dimensjonert til klyngen betjener de neste dusin oppslagene gratis — den mest utnyttbare regelmessigheten i formatet

For det tredje, linearisering (linearization). En linearisert fil laster den første siden og en tipstabell (hint table) i starten, slik at konsumenter kan lese den forfra og bakover. Gigabyte-arkiver er nesten aldri lineariserte: lineariseringen ødelegges av de samme inkrementelle oppdateringene og sammenslåingene (merges) som gjorde filen stor. Planlegg for det vanskeligste (hostile) tilfellet: lange hopp, ingen sortering, hale-først (tail-first) inngang

Den 32-bit historien, korrigert

En 32-bits Windows-prosess har 2 GB med bruker-adresseområde (user address space), og MapViewOfFile med en bytetelling på null ber om én sammenhengende (contiguous) reservasjon på størrelse med filen. For et inndata på 2 GB kan ikke den reservasjonen lykkes: etter EXE-en, spredte DLL-er og trådstakker (thread stacks), ligger den største ledige, sammenhengende blokken i en typisk 32-bit Delphi-prosess et sted mellom 700 MB og 1,4 GB. Kallet feiler med ERROR_NOT_ENOUGH_MEMORY, den samme veggen TMemoryStream.LoadFromFile treffer, bare flyttet fra forpliktet RAM (committed RAM) til adresse-område reservasjon. En full-fil kartlegging er ingen løsning på 32-bit, bare den samme feilen bak bedre klingende API-navn

Løsningen er å skille de to tingene en kartlegging (mapping) gjør. CreateFileMapping oppretter seksjonsobjektet (section object) og koster overhodet ikke noe adresseområde, uansett filstørrelse. Bare MapViewOfFile bruker adresseområde, og ingenting tvinger den til å kartlegge hele seksjonen: den tar en 64-bit startforskyvning og en visningslengde. Opprett seksjonen én gang, kartlegg en 64 til 256 MB visning over regionen som for øyeblikket blir parset, og opphev (unmap) før du glir videre: kostnaden for adresseområde er ett vindu, ikke én fil. Én begrensning: visningsforskyvninger (view offsets) må være multipler av SYSTEM_INFO.dwAllocationGranularity, 64 KB i praksis, så en forespørsel for forskyvning 1 000 000 avrundes ned til 983 040, og kallers (caller's) peker justeres fremover med differansen

En glidende-vindu mapper i Delphi

Klassen nedenfor pakker inn hele disiplinen: ett seksjonsobjekt, én levende visning (live view), omjustering for granularitet, og lesinger som krysser en vindusgrense håndteres ved å la denne ene visningen vokse, i stedet for å sy sammen to (stitching two)

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;

To detaljer bærer vekten. Hurtigsporet (fast path) øverst i Map returnerer en peker uten kjerneovergang (kernel transition) når det forespurte området allerede befinner seg inne i den levende visningen; takket være objekt-strøm klynger (object-stream clustering) er dette det vanligste tilfellet, og det er her besparelsene kommer fra. Og en forespørsel som strekker seg forbi slutten av standardvinduet (straddles the end), øker MapSize for denne ene visningen fremfor å sy sammen to, noe som holder ReadBytes til en ett-linjes (one-liner) og frigjør de som kaller (callers) for delvis-lese løkker (partial-read loops)

Vindusstørrelse (Window size) er et tilgivende ratt: ved 64 MB utgjør et fullt sveip av en 1,8 GB fil 29 visninger, ved 256 MB er det 8, men hver reservasjon er vanskeligere å plassere i et fragmentert 32-bit område, og under omtrent 16 MB vil hopp-tunge (hop-heavy) filer endre kartleggingen (remap) ofte nok til å merkes. Hvor som helst i 64 til 256 MB-området, er kartleggingstrafikk (map traffic) som statistisk støy

Telle systemkallene

Så, regnestykket. Testfil: 1,8 GB, 300 000 indirekte objekter med et snitt på ca. 600 byte med nyttelast. En parser per objekt henter hvert av dem med SetFilePointerEx pluss et 4 KB ReadFile: 600 000 kjerneoverganger (kernel transitions). Et bufret lese-systemkall (cached read syscall) tur-retur tar omtrent 1,5 μs på nåværende x64 maskinvare, så det blir 600 000 × 1,5 μs ≈ 0,9 sekunder med rent kjerne-overhodet (kernel overhead) før en eneste byte parses — det beste tilfellet med varm cache (warm-cache best case). Kald (cold), er hvert hopp en enhetsoperasjon (device operation): ved en effektiv latens for NVMe 4 KB tilfeldige lesninger (random reads) på ~20 μs, koster 300 000 av dem ca. 6 sekunder med enhetstid; på SATA-klasse lagring, minutter

Disse lesingene flytter også feil data: 300 000 × 4 KB dytter 1,2 GB gjennom bruker-buffere (user buffers) for å levere omtrent 180 MB med nyttelast (payload) — seksdobbelt forsterkning, der hver byte kopieres fra kjernen (kernel) til bruker (user)

En lese-fremover buffer (read-ahead buffer) dimensjonert til objektstrøm-klyngene er den første reelle forbedringen: én 256 KB lesing per klynge i stedet for én per objekt kutter overgangs-tallet (transition count) med én til to størrelsesordener. Det er også riktig verktøy der kartlegging (mapping) er klønete, oftest på nettverksdelinger (network shares)

Den vindusbaserte mapperen (windowed mapper) går enda lenger. Et fullt sveip er 29 MapViewOfFile og 29 UnmapViewOfFile kall, 58 eksplisitte overganger (transitions) opp mot 600 000. En reell xref-drevet parse er ikke et rent sveip (clean sweep), men hurtigsporet (fast path) absorberer alle hentingene innenfor det levende vinduet; en passering for å indeksere metadata (metadata-indexing pass) over testarkivet endte opp på et par hundre omkartlegginger (remaps). Kartlegging (mapping) fjerner ikke kjernearbeidet: den konverterer eksplisitte systemkall til sidefeil (page faults) minnehåndtereren løser i multi-side klynger (multi-page clusters), rett fra filcachen (file cache) uten noen brukervei-kopi (user-space copy), og regioner som aldri røres koster ingenting. Fra ende til ende gikk indekseringspasset (indexing pass) fra 23 s kaldt og 7,1 s varmt med lesinger per objekt (per-object reads) til 6,5 s kaldt og 1,9 s varmt med mapperen; det som gjenstår er zlib inflate, ikke I/O

Hvor FILE_FLAG_NO_BUFFERING passer inn

FILE_FLAG_NO_BUFFERING forbigår (bypasses) systemcachen i bytte mot strenge justeringsregler (alignment rules): forskyvninger (offsets), lengder og bufferadresser må alle være sektor-justert (sector-aligned). Den fortjener plassen sin på en-pass sekvensielle jobber som ellers ville fylt cachen med byts ingen leser to ganger — en batch re-serialisering som skriver om hele arkivet, eller en lineariseringspassering over ferdige utdata. Med 4 til 8 MB justerte buffere nærmer den seg enhetens sekvensielle båndbredde uten å forurense cachen

Den er nøyaktig feil for parsing (parsing). Tilfeldige (random) xref-hopp gjennom et ubuffret håndtak gjør hvert 300-byte ordbok-oppslag (dictionary fetch) til en full fysisk lesing uten noen cache for å ta imot det andre besøket — og PDF-parsing besøker regioner på nytt hele tiden, fordi ulike sider peker tilbake (resolve) på de samme objektstrømmene. Ubufret I/O for den sekvensielle omskrivingen, kartlagt (mapped) eller bufret I/O for den tilfeldige parsingen; flagget er satt per håndtak, så én arbeidsflyt kan ha begge på samme fil

64-bit, arbeidssett, og skrivesiden

På en 64-bits build forsvinner motforestillingen mot adresseområdet (address-space objection): send filstørrelsen inn som vinduet, og klassen over degenererer til en enkelt full kartlegging (mapping). Haken (catch) i tjenester som kjører lenge: fil-backet sider som er read-only belaster ingen forpliktelse (commit), så forpliktelsestellere (commit counters) holder seg rolige, men hver side som røres blir en del av arbeidssettet (working set); hvis du parser mesteparten av 1,8 GB, vil arbeidssettet vokse for å matche det, og kaste ut alt annet (evicting). Begrensede vinduer setter et tak på det, så glidende mønsteret (sliding pattern) forblir den riktige standarden (default), også der adresseområde er gratis

På skrivesiden (write side), den billigste I/O-en er den I/O-en som aldri utstedes (issued). PDF-ens inkrementelle oppdateringsmekanisme (ISO 32000-1 §7.5.6) legger til (appends) de endrede objektene og en ny kryssreferanseseksjon etter de opprinnelige bytene, som aldri flytter på seg. Å stemple (stamping) én side inn på et 1,8 GB arkiv, legger til noen titalls kilobyte; en full omskriving (rewrite) flytter alle 1,8 GB, fem størrelsesordener i forskjell, og tillegget (append) er ren sekvensiell utdata på enden (at the tail)

Hvor losLab-bibliotekene passer inn

Begge losLab PDF-bibliotekene leverer denne disiplinen som en API-overflate. HotPDF Direct File API leser sidetellinger og struktur gjennom et filhåndtak uten å bygge objekt-treet, kopierer og dekrypterer på filnivå, og skriver deltaer via BeginIncrementalUpdate — selve strategien ovenfor, ferdig pakket (packaged). PDFlibPas tar den samme ruten med sitt Direct Access-lag: en strømmende leser som går gjennom kryssreferansetabellen der den er (in place), henter objekter lat (lazily), trekker ut sideintervaller fil til fil, og bevarer endringer som inkrementelle revisjoner. Hvis du skriver din egen parser (parser), er kartleggings-klassen (mapper class) din til å ta; hvis du kjører en dokumentpipeline (document pipeline), la biblioteket holde vinduet ærlig (honest)

Merk: Optimalisert IO-håndtering for gigabyte-skala dokumenter er innebygd direkte i HotPDF VCL Component for Delphi og C++Builder