Teknisk artikel

Optimerar IO-prestanda för gigabyte-skala PDF-bearbetning

En PDF-tolks första användbara läsning är vid fel ände av filen. Formatet sätter startxref-pekaren i de allra sista byten, så att bearbeta ett arkiv på 1,8 GB börjar med en sökning (seek) till slutet, en kilobyte-läsning, sedan ett hopp till var än korsreferenstabellen säger att dokumentkatalogen lever. Därifrån är tolkningen en slumpmässig vandring (random walk) över hela byte-omfånget. Allt som buffrad IO är bra på — sekventiell framåtläsning (read-ahead) bakom filpekaren — är riktat mot en arbetsbelastning PDF inte har

Den första versionen av denna artikel hävdade att en minnesmappad fil (memory-mapped file) löser 32-bitars slut-på-minne-felet som TMemoryStream träffar på en 2 GB inmatning. Det påståendet är fel, och sättet det är fel på pekar på den verkliga lösningen: ett glidande mappningsfönster. Vad som följer är åtkomstmönstret, den korrigerade 32-bitars historien med en kompilerbar fönsterförsedd mappning, och syscall-aritmetiken på en 1,8 GB stor testfil med 300 000 objekt

Varför PDF-layouten besegrar buffrade läsningar

Tre strukturella fakta formar IO-mönstret. För det första är navigeringen offset-driven: korsreferenstabellen mappar varje objektnummer till en absolut byteposition, och inget kräver att de positionerna är ordnade. Efter åratal av inkrementella uppdateringar kan objekt 4102 sitta vid offset 1,6 GB medan objekt 4103 sitter vid 30 KB. En TFileStream-loop gör varje hämtning till en Seek plus en Read, två kärnövergångar (kernel transitions), med en buffert som inte bidrar alls eftersom nästa hämtning är hundratals megabyte bort

För det andra, objektströmmar (ISO 32000-1 §7.5.7) packar dussintals eller hundratals små ordböcker i en deflaterad (deflated) behållare. Att hämta en 300-bytes sidordbok kan innebära att läsa och blåsa upp (inflate) ett 100 KB kluster. Baksidan: objekt som skrivs tillsammans tenderar att läsas tillsammans, så en buffert anpassad till klustret betjänar nästa dussin hämtningar gratis — den mest exploaterbara regelbundenheten i formatet

För det tredje, linearisering (linearization). En lineariserad fil frontladdar första sidan och en hint-tabell så att konsumenter kan läsa den framifrån och bakåt. Gigabyte-arkiv är nästan aldrig lineariserade: linearisering förstörs av exakt samma inkrementella uppdateringar och sammanslagningar som gjorde filen stor. Planera för det fientliga fallet: långa hopp, ingen ordning, bakvänd inläsning (tail-first entry)

32-bitars historien, korrigerad

En 32-bitars Windows-process har 2 GB användaradressutrymme, och MapViewOfFile med en byteräknare på noll ber om en enda sammanhängande reservering (reservation) i storlek av filen. För en inmatning på 2 GB kan den reserveringen inte lyckas: efter EXE:n, spridda DLL:er och tråd-stackar, sitter det största lediga sammanhängande blocket i en typisk 32-bitars Delphi-process någonstans mellan 700 MB och 1,4 GB. Anropet misslyckas med ERROR_NOT_ENOUGH_MEMORY, samma vägg som TMemoryStream.LoadFromFile träffar, bara flyttad från bundet RAM till reservering i adressutrymmet. En mappning av hela filen är ingen lösning på 32-bitar, bara samma fel bakom API-namn som låter bättre

Lösningen är att separera de två saker en mappning gör. CreateFileMapping skapar sektionsobjektet (section object) och kostar inget adressutrymme alls, oavsett filstorlek. Endast MapViewOfFile förbrukar adressutrymme, och inget tvingar det att mappa hela sektionen: det tar en 64-bitars startförskjutning (offset) och en vylängd (view length). Skapa sektionen en gång, mappa en 64 till 256 MB vy över regionen som tolkas, avmappa (unmap) innan du glider vidare: adressutrymmeskostnaden är ett fönster, inte en fil. En begränsning: vy-förskjutningar måste vara multiplar av SYSTEM_INFO.dwAllocationGranularity, 64 KB i praktiken, så en begäran om offset 1 000 000 avrundas nedåt till 983 040 och anroparens pekare justeras framåt med skillnaden

En fönsterförsedd filmappning i Delphi

Klassen nedan omsluter hela disciplinen: ett sektionsobjekt, en aktiv vy, granularitets-omjustering (realignment), och läsningar som korsar en fönstergräns hanteras genom att låta den enda vyn växa istället för att sy ihop två

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;

Två detaljer bär tyngden. Snabbvägen i början av Map returnerar en pekare utan någon kärnövergång (kernel transition) när den begärda regionen redan sitter inuti den levande vyn; tack vare klustring av objektströmmar är detta det vanligaste fallet och varifrån besparingarna kommer. Och en begäran som gränslar (straddles) slutet av standardfönstret utökar MapSize för den enda vyn istället för att sy ihop två, vilket behåller ReadBytes som en enradare och skonar anropare från partiella läslooper

Fönsterstorlek är ett förlåtande reglage: vid 64 MB innebär en fullständig genomgång av en 1,8 GB-fil 29 vyer, vid 256 MB är det 8 men varje reservation är svårare att placera i ett fragmenterat 32-bitarsutrymme, och under cirka 16 MB omappas (remap) hopp-tunga filer tillräckligt ofta för att märkas. Någonstans inom 64 till 256 MB-området är mappningstrafiken (map traffic) statistiskt brus

Att räkna syscalls

Nu till aritmetiken. Testfil: 1,8 GB, 300 000 indirekta objekt i snitt på cirka 600 byte nyttolast. En tolkning objekt för objekt hämtar var och en med SetFilePointerEx plus en 4 KB ReadFile: 600 000 kärnövergångar. En cachad läsning gör ett tur-retur-anrop i runda slängar på 1,5 μs på aktuell x64-hårdvara, så det blir 600 000 × 1,5 μs ≈ 0,9 sekunder ren omkostnad i kärnan (kernel overhead) innan man överhuvudtaget tolkar en enda byte — det bästa fallet med varm cache. Kallt (cold) är varje hopp en enhetsoperation (device operation): vid den effektiva latensen på ~20 μs för NVMe 4 KB slumpmässiga läsningar kostar 300 000 av dem ungefär 6 sekunder enhetstid; på SATA-klass lagring, minuter

Läsningarna flyttar dessutom fel data: 300 000 × 4 KB driver 1,2 GB genom användarbuffertar för att leverera ungefär 180 MB nyttolast — sexfaldig förstärkning, varje byte kopieras från kärna till användare

En framåtläsningsbuffert anpassad till objektström-klustren är den första ärliga förbättringen: en 256 KB läsning per kluster istället för en per objekt skär ner övergångsantalet med en eller två storleksordningar. Det är också det rätta verktyget där mappning är opraktisk, vanligtvis nätverksresurser (network shares)

Den fönsterförsedda mappningen går längre. En komplett genomgång är 29 anrop till MapViewOfFile och 29 till UnmapViewOfFile, 58 explicita övergångar jämfört med 600 000. En riktig xref-driven tolkning är inte en ren genomgång (sweep), men den snabba vägen absorberar varje hämtning inuti det levande fönstret; en metadata-indexeringsrunda över testarkivet landade på några hundra omappningar. Mappning tar inte bort arbetet från kärnan: det konverterar explicita syscalls till sidfel (page faults) som minneshanteraren löser i flersidiga kluster, rakt från filcachen utan användarutrymmes-kopia, och regioner som aldrig berörs kostar inget. Ände till ände gick indexeringsrundan från 23 s kall och 7,1 s varm med per-objekt läsningar till 6,5 s kall och 1,9 s varm med mapparen; det som återstår är zlib inflate, inte IO

Där FILE_FLAG_NO_BUFFERING passar in

FILE_FLAG_NO_BUFFERING förbigår (bypasses) systemets cache i utbyte mot hårda inriktningsregler (alignment rules): förskjutningar, längder och buffertadresser alla sektorinriktade. Det gör skäl för sig i sekventiella engångsjobb som annars skulle översvämma cachen med byte som ingen läser två gånger — en batch-omserialisering (batch re-serialization) som skriver om hela arkivet, eller en lineariseringsrunda över färdiga utdata. Med 4 till 8 MB inriktade buffertar närmar det sig enhetens sekventiella bandbredd utan att förorena cachen

Det är helt fel för tolkning (parsing). Slumpmässiga xref-hopp genom ett obuffrat handtag förvandlar varje 300-bytes ordbokshämtning till en fullständig fysisk läsning utan cache att absorbera det andra besöket — och PDF-tolkning återbesöker regioner konstant, eftersom olika sidor löser ut (resolves) till samma objektströmmar. Obuffrad IO för den sekventiella omskrivningen, mappad eller cachad IO för den slumpmässiga tolkningen; flaggan är per-handtag, så en och samma pipeline kan hålla båda på samma fil

64-bitars, working sets och skrivsidan

På ett 64-bitars bygge försvinner invändningen om adressutrymme: skicka in filstorleken som fönstret och klassen ovan urartar (degenerates) till en enda fullständig mappning. Haken i tjänster som körs länge (long-running services): skrivskyddade (read-only) filstödda sidor debiterar (charges) ingen bindning (commit), så commit-räknare förblir lugna, men varje berörd sida ansluter sig till working setet; tolkar du det mesta av 1,8 GB så växer working setet för att matcha det, och vräker (evicting) allt annat. Begränsade fönster sätter ett tak för det, så glidmönstret (sliding pattern) förblir det rätta standardvalet även där adressutrymmet är fritt

På skrivsidan är den billigaste IO:n den IO som aldrig utfärdas (issued). PDF:s inkrementella uppdateringsmekanism (ISO 32000-1 §7.5.6) lägger till (appends) de ändrade objekten och en ny korsreferens-sektion efter de ursprungliga byten, som aldrig flyttar sig. Att stämpla en sida på ett arkiv på 1,8 GB bifogar tiotals kilobyte; en fullständig omskrivning flyttar hela 1,8 GB, fem storleksordningar ifrån varandra, och tillägget (append) är rent sekventiell utdata vid slutet (tail)

Där losLab bibliotek passar in

Båda losLab PDF-biblioteken levererar denna disciplin som API-yta. HotPDF Direct File API läser sidantal och struktur genom ett filhandtag utan att bygga objektträdet, kopierar och dekrypterar på filnivå, och skriver deltan genom BeginIncrementalUpdate — lägg-bara-till-strategin (append-only) ovan, paketerad. PDFlibPas tar samma rutt med sitt Direct Access-lager: en strömmande läsare som går igenom korsreferenstabellen på plats, hämtar objekt lat (lazily), extraherar sidintervaller från fil till fil, och sparar redigeringar (edits) som inkrementella revisioner. Om du skriver din egen tolk, är mappar-klassen (mapper class) din att ta; om du kör en dokument-pipeline, låt biblioteket hålla fönstret ärligt

Notera: Optimerad IO-hantering för gigabyte-stora dokument är inbyggt direkt i HotPDF VCL Component för Delphi och C++Builder