Teknisk artikel

Optimering af IO-ydeevne for PDF-behandling i gigabyte-skala

En PDF-parsers første nyttige læsning er i den forkerte ende af filen. Formatet placerer startxref-pointeren i de sidste bytes, så behandling af et 1,8 GB arkiv begynder med en søgning (seek) til halen, en én-kilobyte læsning, derefter et hop til hvor end krydsreference-tabellen siger, at dokumentkataloget bor. Derfra er parsen en tilfældig vandring på tværs af hele byte-området. Alt hvad bufret IO er god til — sekventiel read-ahead bag fil-pointeren — er rettet mod en arbejdsbyrde, PDF ikke har

Den første version af denne artikel hævdede, at en memory-mapped fil løser den 32-bit out-of-memory-fejl, som TMemoryStream rammer på et 2 GB input. Den påstand er forkert, og den måde den er forkert på peger på den rigtige rettelse: et glidende kortlægningsvindue (a sliding mapping window). Hvad der følger er adgangsmønstret, den korrigerede 32-bit historie med en kompilerbar vinduesopdelt kortlægger (windowed mapper), og syscall-aritmetikken på en 1,8 GB testfil med 300.000 objekter

Hvorfor PDF-layout besejrer bufrede læsninger

Tre strukturelle fakta former IO-mønstret. For det første er navigation offset-drevet: krydsreferencetabellen mapper hvert objektnummer til en absolut byte-position, og intet kræver, at disse positioner skal ordnes. Efter års trinvise opdateringer kan objekt 4102 sidde ved offset 1,6 GB, mens objekt 4103 sidder ved 30 KB. En TFileStream-løkke forvandler hver hentning til et Seek plus et Read, to kerne-overgange (kernel transitions), med en buffer, der ikke bidrager med noget, fordi den næste hentning er hundreder af megabytes væk

For det andet pakker objektstrømme (ISO 32000-1 §7.5.7) snesevis eller hundredevis af små ordbøger i én deflateret container. At hente en side-ordbog (page dictionary) på 300 bytes kan betyde at læse og opblæse en klynge på 100 KB. Bagsiden: objekter skrevet sammen har en tendens til at blive læst sammen, så en buffer dimensioneret til klyngen tjener de næste snesevis hentninger gratis — den mest udnyttelige regelmæssighed i formatet

For det tredje, linearisering. En lineariseret fil frontloader den første side og en hint-tabel, så forbrugere kan læse den forfra og bagfra. Gigabyte-arkiver er næsten aldrig lineariserede: linearisering ødelægges af de samme trinvise opdateringer og fletninger, der gjorde filen stor. Planlæg for det fjendtlige tilfælde: lange hop, ingen bestilling, indgang fra halen først (tail-first entry)

Den 32-bit historie, korrigeret

En 32-bit Windows-proces har 2 GB bruger-adresseplads, og MapViewOfFile med et byte-antal på nul beder om én sammenhængende reservation på størrelse med filen. For et 2 GB input kan den reservation ikke lykkes: efter EXE, spredte DLL'er og trådstakke, sidder den største frie sammenhængende blok i en typisk 32-bit Delphi-proces et sted mellem 700 MB og 1,4 GB. Kaldet fejler med ERROR_NOT_ENOUGH_MEMORY, den samme mur TMemoryStream.LoadFromFile rammer, bare flyttet fra committet RAM til adresse-plads reservation. En fuld-fil kortlægning er ingen rettelse på 32-bit, bare den samme fejl bag bedre klingende API-navne

Rettelsen er at adskille de to ting, en kortlægning gør. CreateFileMapping opretter sektionsobjektet og koster slet ingen adresseplads, uanset filstørrelsen. Kun MapViewOfFile bruger adresseplads, og intet tvinger den til at mappe hele sektionen: den tager et 64-bit start-offset og en visningslængde (view length). Opret sektionen én gang, kortlæg en 64 til 256 MB visning (view) over den region, der parses, afmap før du glider videre: adresseplads-omkostningen er ét vindue, ikke én fil. Én begrænsning: visnings-offsets skal være multipla af SYSTEM_INFO.dwAllocationGranularity, 64 KB i praksis, så en anmodning om offset 1.000.000 bliver rundet ned til 983.040, og kalderens pointer justeres fremad med forskellen

En glidende-vindue kortlægger i Delphi

Klassen nedenfor indpakker hele disciplinen: ét sektionsobjekt, én live-visning, granularitets-omlægning, og læsninger, der krydser en vinduesgrænse, håndteres ved at lade den ene visning vokse i stedet for at sy to sammen

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 vægten. Det hurtige spor (fast path) i toppen af Map returnerer en pointer uden kerne-overgang (kernel transition), når det anmodede område allerede sidder inde i live-visningen; takket være objekt-strøm klyngedannelse er dette det almindelige tilfælde og hvor besparelserne kommer fra. Og en anmodning, der overskræver (straddles) slutningen af standardvinduet, lader MapSize vokse for den ene visning frem for at sy to sammen, hvilket holder ReadBytes til en one-liner og skåner kaldere for delvise-læse-løkker (partial-read loops)

Vinduesstørrelse er en tilgivende knap (forgiving knob): ved 64 MB er et fuldt sweep af en 1,8 GB fil 29 visninger, ved 256 MB er det 8, men hver reservation er sværere at placere i et fragmenteret 32-bit rum, og under omkring 16 MB remapper hop-tunge filer ofte nok til at bemærke. Hvor som helst i 64 til 256 MB området er kort-trafik statistisk støj

Tælle systemkaldene

Nu aritmetikken. Testfil: 1,8 GB, 300.000 indirekte objekter med et gennemsnit på ca. 600 bytes payload. En pr.-objekt parser henter hver enkelt med SetFilePointerEx plus en 4 KB ReadFile: 600.000 kerne-overgange. Et bufret læse-systemkald foretager en rundtur på cirka 1,5 μs på aktuel x64-hardware, så det er 600.000 × 1,5 μs ≈ 0,9 sekunder af rent kerne-overhead, før man parser en enkelt byte — det bedste tilfælde med varm-cache (warm-cache). Kold (cold), hvert hop er en enhedsoperation (device operation): ved den ~20 μs effektive latenstid for NVMe 4 KB tilfældige læsninger, koster 300.000 af dem omkring 6 sekunder enhedstid; på lagring af SATA-klassen, minutter

Læsningerne flytter også forkerte data: 300.000 × 4 KB skubber 1,2 GB gennem bruger-buffere for at levere omkring 180 MB payload — seks-dobbelt forstærkning, hver byte kopieret fra kerne til bruger

En read-ahead buffer dimensioneret til objekt-strøm klyngerne er den første ærlige forbedring: én 256 KB læsning pr. klynge i stedet for én pr. objekt skærer overgangs-antallet ned med en til to størrelsesordener (orders of magnitude). Det er også det rigtige værktøj, hvor kortlægning er akavet, normalt netværksdrev (network shares)

Den vinduesopdelte kortlægger (The windowed mapper) går videre. Et fuldt sweep er 29 MapViewOfFile og 29 UnmapViewOfFile kald, 58 eksplicitte overgange mod 600.000. En rigtig xref-drevet parse er ikke et rent sweep, men det hurtige spor absorberer hver hentning inde i live-vinduet; en metadata-indekseringsgennemgang (metadata-indexing pass) over testarkivet landede på et par hundrede remaps. Kortlægning fjerner ikke kerne-arbejde: det konverterer eksplicitte systemkald til sidefejl (page faults), som hukommelseshåndteringen (the memory manager) løser i multi-side klynger, direkte fra fil-cachen uden kopiering i bruger-rum, og regioner, der aldrig røres, koster intet. Fra ende til anden gik indekseringsgennemgangen fra 23 s kold og 7,1 s varm med pr.-objekt læsninger til 6,5 s kold og 1,9 s varm med kortlæggeren; det, der er tilbage, er zlib inflate, ikke IO

Hvor FILE_FLAG_NO_BUFFERING passer ind

FILE_FLAG_NO_BUFFERING omgår systemcachen til gengæld for hårde justeringsregler (hard alignment rules): offsets, længder og bufferadresser er alle sektor-justerede. Det fortjener sin plads i enkelt-gennemgangs sekventielle opgaver, der ellers ville oversvømme cachen med bytes, som ingen læser to gange — en batch re-serialisering, der omskriver hele arkivet, eller et lineariserings-pass over færdigt output. Med 4 til 8 MB justerede buffere nærmer det sig enhedens sekventielle båndbredde uden at forurene cachen

Det er helt forkert til parsing. Tilfældige xref-hop gennem et ubufret handle gør hver 300-byte ordbogs-hentning til en fuld fysisk læsning uden nogen cache til at absorbere det andet besøg — og PDF-parsing besøger regioner konstant igen, fordi forskellige sider opløses i de samme objektstrømme. Ubufret IO til den sekventielle omskrivning, kortlagt eller bufret IO til den tilfældige parse; flaget er pr.-handle, så én pipeline kan holde begge dele på samme fil

64-bit, working sets, og skrivesiden

På et 64-bit build forsvinder adresseplads-indvendingen: overdrag (pass) filstørrelsen som vinduet, og klassen ovenfor degenererer til en enkelt fuld kortlægning. Haken (The catch) i langtkørende tjenester (long-running services): read-only fil-backed sider opkræver ingen commit, så commit-tællere forbliver i ro, men hver berørt side tilslutter sig arbejdsættet (the working set); parse det meste af 1,8 GB, og arbejdsættet vokser for at matche og udelukker alt andet. Afgrænsede vinduer (Bounded windows) sætter et loft over det, så det glidende mønster forbliver den rigtige standard, selv hvor adressepladsen er fri

På skrivesiden er det billigste IO det IO, der aldrig er udstedt. PDF's trinvise opdateringsmekanisme (ISO 32000-1 §7.5.6) tilføjer de ændrede objekter og en ny krydsreferencesektion efter de originale bytes, som aldrig flytter sig. At stemple (stamping) én side på 1,8 GB arkivet tilføjer tiere af kilobytes; en fuld omskrivning flytter hele 1,8 GB, fem størrelsesordener fra hinanden, og tilføjelsen (the append) er rent sekventielt output ved halen

Hvor losLabs biblioteker passer ind

Begge losLabs PDF-biblioteker leverer denne disciplin som API-overflade. HotPDF Direct File API læser side-antal og struktur gennem et fil-handle uden at bygge objekt-træet, kopierer og dekrypterer på fil-niveau, og skriver deltas gennem BeginIncrementalUpdate — append-only strategien ovenfor, pakket. PDFlibPas tager den samme rute med sit Direct Access-lag: en streaming-læser, der går gennem krydsreferencetabellen på plads (in place), henter objekter dovent (lazily), udtrækker side-intervaller (page ranges) fra fil til fil, og fastholder (persists) ændringer som trinvise revisioner. Hvis du skriver din egen parser, er mapper-klassen din at tage; hvis du kører en dokument-pipeline, så lad biblioteket holde vinduet ærligt

Bemærk: Optimeret IO-håndtering til gigabyte-skala dokumenter er bygget direkte ind i HotPDF VCL Component til Delphi og C++Builder