Technický článek

Optimalizace IO výkonu pro zpracování PDF v gigabajtovém měřítku

První užitečné čtení PDF parseru je na špatném konci souboru. Formát umisťuje ukazatel startxref do posledních bajtů, takže zpracování archivu o velikosti 1,8 GB začíná přesunem na konec, přečtením jednoho kilobajtu a následným skokem tam, kde podle tabulky křížových odkazů sídlí katalog dokumentu. Odtud je parsování náhodnou procházkou celým rozsahem bajtů. Všechno, v čem je bufferované IO dobré — sekvenční čtení dopředu za ukazatelem souboru — je zaměřeno na zátěž, kterou PDF nemá

První verze tohoto článku tvrdila, že soubor namapovaný do paměti řeší 32bitové selhání nedostatku paměti (out-of-memory), na které naráží TMemoryStream při 2GB vstupu. Toto tvrzení je chybné a to, jak je chybné, ukazuje na skutečnou opravu: posuvné okno mapování. Následuje vzor přístupu, opravený 32bitový příběh s kompilovatelným mapperem s okny a aritmetika systémových volání na testovacím souboru o velikosti 1,8 GB s 300 000 objekty

Proč rozvržení PDF poráží bufferovaná čtení

Vzor IO utvářejí tři strukturální fakta. Za prvé, navigace je řízena offsetem: tabulka křížových odkazů mapuje každé číslo objektu na absolutní bajtovou pozici a nic nevyžaduje, aby tyto pozice byly seřazeny. Po letech inkrementálních aktualizací může objekt 4102 ležet na offsetu 1,6 GB, zatímco objekt 4103 leží na 30 KB. Smyčka TFileStream mění každé načtení na Seek plus Read, dva přechody jádra, s bufferem, který ničím nepřispívá, protože další načtení je stovky megabajtů daleko

Za druhé, proudy objektů (ISO 32000-1 §7.5.7) balí desítky nebo stovky malých slovníků do jednoho deflatovaného kontejneru. Načtení jednoho 300bajtového slovníku stránky může znamenat přečtení a rozbalení (inflate) 100KB clusteru. Odvrácená strana: objekty zapsané společně se obvykle čtou společně, takže buffer dimenzovaný na cluster obslouží další tucet načtení zdarma — to je nejvíce využitelná pravidelnost ve formátu

Za třetí, linearizace. Linearizovaný soubor umisťuje první stránku a tabulku nápověd na začátek, aby jej konzumenti mohli číst zepředu dozadu. Gigabajtové archivy nejsou téměř nikdy linearizovány: linearizace je zničena stejnými inkrementálními aktualizacemi a slučováními, které soubor zvětšily. Plánujte na nejhorší (hostile) případ: dlouhé skoky, žádné pořadí, vstup od konce

Opravený 32bitový příběh

32bitový proces Windows má 2 GB uživatelského adresního prostoru a MapViewOfFile s počtem bajtů nula žádá o jednu souvislou rezervaci o velikosti souboru. Pro 2GB vstup tato rezervace nemůže uspět: po EXE souboru, rozptýlených knihovnách DLL a zásobnících vláken leží největší volný souvislý blok v typickém 32bitovém Delphi procesu někde mezi 700 MB a 1,4 GB. Volání selže s chybou ERROR_NOT_ENOUGH_MEMORY, stejnou zdí, do které narazí TMemoryStream.LoadFromFile, pouze přesunutou z potvrzené paměti RAM do rezervace adresního prostoru. Celosouborové mapování není v 32bitovém prostředí žádným řešením, je to jen stejné selhání pod lépe znějícími názvy API

Oprava spočívá v oddělení dvou věcí, které mapování dělá. CreateFileMapping vytvoří sekční objekt a nestojí vůbec žádný adresní prostor, bez ohledu na velikost souboru. Pouze MapViewOfFile spotřebovává adresní prostor a nic jej nenutí mapovat celou sekci: přebírá 64bitový počáteční offset a délku pohledu (view). Vytvořte sekci jednou, namapujte pohled 64 až 256 MB přes právě parsovanou oblast a před posunutím odmapujte: cena za adresní prostor je jedno okno, ne jeden soubor. Jedno omezení: offsety pohledu musí být násobky SYSTEM_INFO.dwAllocationGranularity, v praxi 64 KB, takže požadavek na offset 1 000 000 se zaokrouhlí dolů na 983 040 a ukazatel volajícího se posune vpřed o tento rozdíl

Mapper s posuvným oknem v Delphi

Níže uvedená třída obaluje celou disciplínu: jeden objekt sekce, jeden živý pohled, přeřazení granularity a čtení, která překračují hranici okna, jsou řešena zvětšením tohoto jednoho pohledu místo spojování dvou

uses
  Winapi.Windows, System.SysUtils;

type
  TWindowedFileMapper = class
  private
    FFile: THandle;
    FMapping: THandle;
    FFileSize: Int64;
    FGranularity: DWORD;      // SYSTEM_INFO.dwAllocationGranularity
    FWindowSize: NativeUInt;  // výchozí velikost pohledu
    FViewBase: PByte;         // základna aktuálního pohledu (zarovnaná)
    FViewOffset: Int64;       // offset souboru, kterému odpovídá FViewBase
    FViewSize: NativeUInt;    // bajty namapované v aktuálním pohledu
    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;
  // Objekt sekce nerezervuje žádný adresní prostor, ať je velikost souboru jakákoli
  FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
  if FMapping = 0 then
    RaiseLastOSError;
  GetSystemInfo(Info);
  FGranularity := Info.dwAllocationGranularity;  // 64 KB v praxi
  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(
      'Požadavek na mapování na %d pro %d bajtů je mimo soubor',
      [Offset, Int64(Size)]);

  // Rychlá cesta: požadovaný rozsah již leží v živém pohledu
  if (FViewBase <> nil) and (Offset >= FViewOffset) and
     (Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
    Exit(FViewBase + NativeInt(Offset - FViewOffset));

  Unmap;  // posuv: nikdy nedržte dva pohledy najednou

  // Pohledy musí začínat na hranici granularity alokace
  AlignedOffset := Offset - (Offset mod FGranularity);
  Delta := NativeUInt(Offset - AlignedOffset);

  MapSize := FWindowSize;
  if MapSize < Size + Delta then   // požadavek přesahuje konec okna:
    MapSize := Size + Delta;       // zvětšete tento jeden pohled tak, aby jej pokryl
  if AlignedOffset + Int64(MapSize) > FFileSize then
    MapSize := NativeUInt(FFileSize - AlignedOffset);  // omezení (clamp) na konci souboru (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;

Dva detaily nesou váhu. Rychlá cesta na začátku metody Map vrací ukazatel bez přechodu jádra, když se požadovaný rozsah již nachází uvnitř živého pohledu; díky shlukování do proudů objektů je to běžný případ a odtud plynou úspory. A požadavek, který přesahuje konec výchozího okna, zvětší MapSize pro tento jeden pohled, místo aby sešíval dva, což udržuje ReadBytes jako jednořádkový kód a ušetří volajícím smyčky pro částečné čtení

Velikost okna je odpouštějící parametr (knob): při 64 MB představuje kompletní průchod souborem o velikosti 1,8 GB celkem 29 pohledů, při 256 MB je to 8, ale každou rezervaci je obtížnější umístit v roztříštěném 32bitovém prostoru, a pod zhruba 16 MB se soubory s častými přesuny mapují dostatečně často na to, aby to bylo patrné. Kdekoli v rozmezí 64 až 256 MB představuje provoz mapování jen statistický šum

Počítání systémových volání

Nyní aritmetika. Testovací soubor: 1,8 GB, 300 000 nepřímých objektů s průměrnou velikostí asi 600 bajtů (payload). Parser pro každý objekt načte objekt pomocí SetFilePointerEx plus 4 KB ReadFile: 600 000 přechodů jádra (kernel transitions). Čas běhu (round-trip) kešovaného systémového volání čtení trvá na současném x64 hardwaru přibližně 1,5 μs, takže to představuje 600 000 × 1,5 μs ≈ 0,9 sekundy čisté režie jádra před zpracováním (parsingem) jediného bajtu — nejlepší možný případ s teplou (warm) pamětí cache. S prázdnou (cold) cache je každý skok operací zařízení: při efektivní latenci náhodného 4KB čtení NVMe ~20 μs jich 300 000 stojí asi 6 sekund času zařízení; u úložišť třídy SATA jde o minuty

Čtení také přesouvají nesprávná data: 300 000 × 4 KB protlačí přes uživatelské buffery 1,2 GB, aby doručily zhruba 180 MB užitečných dat (payload) — to je šestinásobné zvětšení, přičemž každý bajt je kopírován z jádra uživateli

Vyrovnávací paměť (buffer) pro čtení dopředu, jejíž velikost odpovídá velikosti clusterů proudů objektů, je prvním poctivým vylepšením: jedno 256KB čtení na cluster namísto jednoho na objekt zkrátí počet přechodů o jeden až dva řády. Je to také správný nástroj tam, kde je mapování nepohodlné, typicky u síťových sdílení

Mapper s okny jde ještě dále. Úplný průběh je 29 volání MapViewOfFile a 29 volání UnmapViewOfFile, což představuje 58 explicitních přechodů oproti 600 000. Skutečné zpracování řízené křížovými odkazy (xref) není čistý průběh, ale rychlá cesta pokryje (absorbuje) každé načtení v živém okně; proces indexování metadat přes testovací archiv se ustálil na několika stovkách přemapování. Mapování neodstraní práci jádra: převede explicitní systémová volání na chyby stránek, které správce paměti vyřeší ve vícestránkových clusterech, přímo ze souborové paměti cache (file cache) bez jakéhokoli kopírování z uživatelského prostoru, a regiony, jichž se proces nikdy nedotkne, nestojí nic. Z hlediska celého procesu (end to end) se průchod indexování změnil z 23 s (cold) a 7,1 s (warm) při čtení na objekt na 6,5 s (cold) a 1,9 s (warm) při použití mapperu; zbývá pouze zlib rozbalení (inflate), a nikoliv I/O (IO)

Kam se hodí FILE_FLAG_NO_BUFFERING

FILE_FLAG_NO_BUFFERING obchází systémovou mezipaměť (cache) výměnou za přísná pravidla zarovnání: offsety, délky a adresy vyrovnávací paměti musí být zarovnány na sektory. Své si odpracuje (earns its keep) na jednoprůchodových sekvenčních úlohách, které by jinak zaplavily mezipaměť bajty, jež nikdo nebude číst dvakrát — jako např. dávková opětovná serializace (re-serialization), která přepíše celý archiv, nebo průchod linearizací nad dokončeným výstupem. S vyrovnávacími pamětmi o velikosti 4 až 8 MB zarovnanými na sektory se tak blíží sekvenční šířce pásma zařízení, a to bez znečištění vyrovnávací paměti

Toto je pro parsing absolutně nevhodné. Náhodné (random) xref skoky přes nebufferovaný identifikátor (handle) promění každé načtení slovníku (dictionary fetch) o velikosti 300 bajtů ve zbytečně úplné fyzické čtení, při němž není k dispozici žádná cache, aby pokryla případnou druhou návštěvu — a parser (rozebírač) PDF tyto návštěvy opakuje v oblastech poměrně často, protože se různé stránky přeloží na tytéž streamy objektů. Pro postupné přepisování proto využijeme nepodporované (unbuffered) rozhraní IO; avšak pro náhodný (random) parser, pak se doporučuje zmapované (mapped) nebo kešované (cached) řešení IO; příznak (flag) platí na jeden konkrétní ukazatel, takže u daného jednoho potrubí (pipeline) mohou působit na jeden stejný soubor naráz oba jevy

64 bitů, pracovní sady (working sets) a strana pro zápis (write side)

V 64bitovém buildu (sestavení) námitka ohledně adresního prostoru (address-space) odpadá: postačí předat hodnotu velikosti souboru jako parametr (window), pak se třída v příkladu zvrhne v pouhé ucelené úplné zmapování. Problém dlouho běžících služeb však bývá zrada (catch): tyto stránky v souborech určených pouze ke čtení (read-only file-backed) si nevyžadují přidělení vyhrazené paměti (commit), takže počítadla potvrzení zůstávají stabilní, ovšem každá strana, jíž jsme se dotkli, přibude do aktuální vyčleněné pracovní sady (working set); v případě parsování převážné části z hodnoty 1,8 GB získá pracovní sada srovnatelný rozsah, takže ze sestavy všechno nevyhovující vyřadí ven. V této souvislosti nasadí omezená mapovací okénka jasný stop-strop, kvůli čemuž by pak varianta v podobě plynulého překreslování měla navzdory bohatému a svobodnému rozsahu pro prostor datové sítě trvale setrvat zafixována jako volitelný základ

Při zápisu je však vůbec nejschůdnějším formátem provedení ten krok IO (vstup/výstupní interakce), s nímž ani nevznikla práce. Mechanismus aktualizací obsažený v protokolu PDF u přírůstků (podle normy ISO 32000-1 § 7.5.6) se provádí obohacením struktury zařazením dříve nedefinovaných uzlů společně tak i u objevujících se tabulek vzájemných linků a návazností za dříve zavedenými byte (jenž u sebe stálou pozici dál bez narušení drží). Kdybyste chtěli opatřit (stamp) jednu stránku vůči celému archivu o velikosti 1,8 GB, pak by došlo pouze na prozaický zisk nějaké desítky kilobajtů; zatímco kompletní celkové překreslení dat k témuž se ovšem odrazí tím, že budeme nuceni přesouvat masivní objem v rozsahu celých úhrnných hodnot odpovídajícím parametru z velikosti 1,8 GB, tedy hovoříme rovnou dokonce o skocích napříč takříkajíc přes celkem pětice řádů navzájem, naproti čemuž výstup ve variantě s pouhým zavedením za sebou skládá sekvenční povely spočívající přímo u koncové paty s naprostou přehledností a klidem

Kam knihovny losLab zapadají

Obě dostupné knihovny od společnosti losLab určené k práci v prostředích zabývajících se zpracováním obsahu z rodiny systémů souborového balení PDF ztělesňují uvedené principy jako v rámci kontextů příslušného návrhového povrchu aplikačních programátorských (API) rozhraní. Varianta řešení spadající pod takzvaný rozsah aplikačních postupů pro nástroj s označením v prostředí od HotPDF jako Direct File API zajišťuje funkce pro zpracování čtených počtů stránkových relací společně tak i zavedené struktury dat prostřednictvím souborového ukazatele (file handle) zcela se vyhýbajíc zbytečné tvorbě jakéhokoliv balastního stromu s entitami jednotlivě propojených složkových objektů, provádí nezbytné postupy při kopírování, případně se podílí rovněž i při dekódovacích dešifracích prováděných přesně na míru podle vrstev z logických celků dat o konkrétním typu zpracovávaného nosného souboru, navíc s vlastností a dopadem spočívajícím při vepisování delty odkazující se právě po boku z povelů pomocí instrukce určené k přebírání nastavení spouštění pod instrukcemi ohledně přírůstků – (BeginIncrementalUpdate) — kde se prakticky implementuje výše zvažovaná technika určená coby balíčkový set naváděný logikou při doplňování na chvostě struktury obohacované sestavy. Knihovna s označením coby systém pro PDFlibPas razí takovýto cestovní směr podle stejných zásad díky kódování se složkou s orientací nazvanou coby postupy pod obhospodařovanou úrovní k přímému způsobu o přistupování do částí pod vrstvou Direct Access: zde nacházíme proudový (streaming) snímač uzlů z takovýchto relací na platformu, z níž se bez odskoků přesouvá navazováním směru skrz vzájemně strukturované odkazové tabulky xref s místními propojeními (in place), získává tak objektové typy pohodlně způsobem líně se zdráhající nutnosti otevírání velkých dat navíc navázaně (lazily), těží a extrahuje rozsahy pro stánkové návaznosti při operacích mezi soubory do ucelených souborů (file to file), v případech se snahami po editaci pak prosazuje udržitelnost na úrovni postupné inkrementace při budování a začleňování revizních podkladových zpráv. Když se tedy jako programátor pasujete s vizí na zhotovení takových logických skriptových modelů vlastních nástrojů obsluhujících parsing, potom vám jako doporučení slouží plné propůjčení vzorů definující celou mapper class ze stávajícího kontextu na využití podle zvyků s možností k adaptaci bez vymezení překážek dle přání k vlastnímu užití plně podle možností dle dostupného nastavení programátorského řemesla, kdežto když už se rovnou stihnete angažovat se zastřešováním pracovních kroků uvnitř struktury přes pipeline (potrubí) se zadáním spravovat objemy s dokumentovým formátem pod dokumenty, poté doporučujeme se v této situaci raději rovnou obrátit ku spolehnutí pro oporu směrem ku knihovně pro obsloužení správy funkčního celku nastavení s okny okny k zajištění obsluhy chodu při zachování takzvané udržitelné poctivosti v řízení o spravovaných operacích z řešených procesů

Poznámka: Optimalizované řešení obsluhy parametrů typu s dopadem přesahujících IO objemů gigabajtů nad spravovanými rozsáhlými typy dokumentů získáte odladěnou podporou naváděnou jako přímo zabudovaný nástroj směrem pro platformu prostředí přes prvek od HotPDF VCL Component a to bez překážek se zřetelem na platformy z povelů z Delphi tak pochopitelně pro C++Builder