Tehnički članak

Profiliranje PDF Library for Delphi-a: hash indeksi i optimizacija

PDF Library for Delphi, losLabova PDF biblioteka za Delphi i C++Builder, ubrzava putanje prikazivanja i generiranja sadržaja zamjenom četiri obrasca ponavljanog rada amortiziranim oblicima: lijeni hash indeks za traženje ključeva rječnika, unaprijed izračunata sRGB tablica gama, grupiranje operatora toka sadržaja prema prvom bajtu i TStringBuilder umjesto ponovljenog spajanja nizova. Nijedan od ta četiri zahvata nije proizašao iz jednog dramatičnog otkrića — svi su proizašli iz istog neuglednog obrasca u profilu: mala funkcija pozvana jednom po operatoru, pikselu ili znaku, gdje linearni trošak unutar poziva postaje kvadratni ili gotovo kvadratni preko cijelog dokumenta. To je nit koja ih povezuje: četiri mala, naizgled nepovezana popravka koja napadaju isti oblik problema, uz pošteno navedena ograničenja svakog od njih

Gdje program za iscrtavanje toka sadržaja zapravo troši vrijeme

Program za iscrtavanje toka sadržaja PDF Library for Delphi-a gotovo sav trošak po tokenu usmjerava kroz četiri uska mjesta: traženja u rječnicima resursa za /Resources, /ColorSpace, /Font i /ExtGState; korekciju game na svakom dekodiranom pikselu slike Lab, Indexed ili s ICC oznakom; podudaranje imena operatora na svakom tokenu svakog toka sadržaja; i izgradnju nizova gdje god biblioteka stvara izlaz — escapiranje literalnih nizova pri spremanju, izvoz XFDF-a te proširivanje oznaka pečata i varijabli. Svaki od ta četiri postupka zasebno radi malo, a svaki se na realnom dokumentu pokrene tisućama ili milijunima puta, što je upravo oblik funkcije kod kojeg pojedinost implementacije O(n) ili O(n²) prestaje biti nevidljiva i postaje vodeća stavka profila

Zašto traženja u rječniku resursa postaju spora u velikom PDF-u

TPDFDictionary.FindIndexByKeyName program za iscrtavanje poziva za razrješavanje svakog traženja /Resources, /ColorSpace, /Font i /ExtGState, a ranije je pri svakom pozivu prolazio kroz polje Entries od početka — dobro za rječnik Resources s tri stavke, ali skupo za Form XObject ili stranicu s mnogo ExtGState stavki na kojoj se isti rječnik ispituje pri svakom operatoru koji dotiče boju ili grafičko stanje. PDF Library for Delphi sada jednom gradi lijeni hash indeks kada rječnik prijeđe DICT_HASH_THRESHOLD od 16 stavki, a manje rječnike ostavlja na linearnom prolazu jer većina PDF rječnika nikada ne naraste toliko i izgradnja hash tablice za tri ključa koštala bi više nego što bi uštedjela. Indeks je ravna tablica s otvorenim adresiranjem, ključana funkcijom PLAnsiStringHash, FNV-1a hashom s kanonskom osnovom pomaka 2166136261 i prostim brojem 16777619, odabranim kako se ne bi uvodio System.Generics.Collections za nešto tako osjetljivo na veličinu

PDF Library for Delphi: dijagram usporedbe linearnog prolaza Entries poljem koji treba ponovljene usporedbe i lijenog FNV-1a hash indeksa koji razrješava ključ s jednim čitanjem polja
FindIndexByKeyName prelazi Entries polje pri svakom pozivu dok šesnaest unosa ne okida lijeni FNV-1a indeks, a svaki poziv koji mijenja jednostavno briše tablicu za ponovnu izgradnju
Const
  DICT_HASH_THRESHOLD = 16;

Function TPDFDictionary.LookupKeyIndex(Const Key: AnsiString): Integer;
Var
  H, Probe: Integer;
Begin
  Result:= -1;
  If FKeyHashMask= 0 Then
  Begin
    // Još nije izgrađeno; mali rječnici ostaju linearni jer se
    // trošak izgradnje ne bi amortizirao za šačicu unosa.
    If Length(Entries)> DICT_HASH_THRESHOLD Then
      BuildKeyHash
    Else
      Exit;
  End;
  H:= PLAnsiStringHash(Key) And FKeyHashMask;
  Probe:= 1;
  While FKeyHash[H]<> -1 Do
  Begin
    If Entries[FKeyHash[H]].Key.Name= Key Then
    Begin
      Result:= FKeyHash[H];
      Exit;
    End;
    H:= (H+ Probe) And FKeyHashMask;
    Inc(Probe);
  End;
End;

Indeks se poništava umjesto da se održava inkrementalno: svaki poziv koji mijenja stanje — AddEntry, DeleteEntryByKeyName, Assign, AddDict — briše hash i dopušta da ga sljedeće traženje ponovno izgradi od početka. To izgleda rasipno dok ne primijetite da je ključ rječnika objekt TPDFName, a TPDFName.SetTo može preimenovati ključ koji već sjedi u polju Entries rječnika bez prolaska kroz njegove vlastite metode — inkrementalni indeks nema načina uočiti to preimenovanje, dok lijeni indeks jednostavno ponovno izgradi tablicu i po konstrukciji ostaje ispravan. Cijena te sigurnosti je ponovna izgradnja O(n) pri prvom traženju velikog rječnika nakon upisa, uz memoriju za samu hash tablicu, približno jedan Integer po pretincu pri faktoru popunjenosti od dvije trećine — zanemariv trošak za nekoliko prevelikih rječnika u tipičnom dokumentu, a stvaran trošak koji PDF Library for Delphi izbjegava plaćati pri svakom malom traženju tako što prag ostavlja na ovoj vrijednosti

Unaprijed izračunata sRGB gama umjesto poziva Power za svaki piksel

TPDFSimpleColorManager.XYZ2RGB primjenjuje sRGB prijenosnu funkciju na svaki dekodirani piksel slike Lab, Indexed ili utemeljene na ICC-u — 1.055 * Power(x, 1/2.4) - 0.055 iznad praga linearnog segmenta — a Power(x, y) za razlomljeni y nema jeftin zatvoren oblik u Pascal RTL-u: rastavlja se na Ln(x), zatim Exp(y * Ln(x)), a taj par transcendentnih poziva, izvršen tri puta po pikselu za crveni, zeleni i plavi kanal, dominantan je trošak dekodiranja Lab ili ICC slike piksel po piksel. PDF Library for Delphi zamjenjuje tri poziva Power po pikselu jednim dohvatom iz GSRGBGammaLUT, polja od 4096 vrijednosti tipa Double koje se jednom izgradi kroz EnsureSRGBGammaLUT i indeksira zaokruživanjem ograničenog ulaza na najbliži pretinac

PDF Library for Delphi dijagram koji suprotstavlja tri Power poziva po pikselu, od kojih se svaki rastavlja na Ln i Exp, s jednim učitavanjem iz sRGB gama tablice pretraživanja od 4096 unosa
EnsureSRGBGammaLUT jednom unaprijed izračuna sRGB prijenosnu funkciju u 4096 double vrijednosti, zamjenjujući par Ln + Exp po kanalu zaobljenim indeksom polja čija se kvantizacija krije ispod 8-bitnog izlaza
Const
  SRGB_GAMMA_LUT_SIZE = 4096;
Var
  GSRGBGammaLUT: Array [0..SRGB_GAMMA_LUT_SIZE- 1] Of Double;
  GSRGBGammaLUTReady: Boolean= False;

Procedure EnsureSRGBGammaLUT;
Var
  I: Integer;
  X: Double;
Begin
  If GSRGBGammaLUTReady Then
    Exit;
  For I:= 0 To SRGB_GAMMA_LUT_SIZE- 1 Do
  Begin
    X:= I/ SRGB_GAMMA_LUT_SIZE;
    If X> 0.0031308 Then
      GSRGBGammaLUT[I]:= 1.055* Power(X, 1/ 2.4)- 0.055
    Else
      GSRGBGammaLUT[I]:= 12.92* X;
  End;
  GSRGBGammaLUTReady:= True;
End;

Function SRGBGamma(X: Double): Double;
Var
  Idx: Integer;
Begin
  If X<= 0 Then
    Result:= 0
  Else If X>= 1 Then
    Result:= 1
  Else
  Begin
    Idx:= Round(X* SRGB_GAMMA_LUT_SIZE);
    If Idx> SRGB_GAMMA_LUT_SIZE- 1 Then
      Idx:= SRGB_GAMMA_LUT_SIZE- 1;
    Result:= GSRGBGammaLUT[Idx];
  End;
End;

Tablica s 4096 pretinaca preko ulaznog raspona [0, 1] daje približno šesnaest puta veću razlučivost od 8-bitnog izlaznog kanala, pa kvantizacija koju LUT uvodi ostaje ispod onoga što završni RGB bajt može prikazati — dohvat iz tablice ovdje zamjenjuje transcendentnu matematiku bez vidljivog gubitka preciznosti. Isto se obrazloženje pojavljuje odmah uz nju u Lab2XYZ, gdje je Power(LMN[i], 3) postao običan LMN[i]*LMN[i]*LMN[i]: cjelobrojna potencija uopće ne treba Ln/Exp, pa to nije kompromis s LUT-om nego samo uklanjanje suvišnog poziva Power. Trik s LUT-om isplati se samo zato što je prijenosna funkcija čista funkcija jedne vrijednosti tipa Double — ne bi se uredno proširio na transformaciju boje koja ovisi o više vrijednosti piksela ili o većem stanju

Kako brzo rasporediti 73 operatora toka sadržaja

ContentOperatorFromName poziva se jednom za svaki token koji PDF Library for Delphi pročita iz toka sadržaja i uspoređuje ga s potpunim skupom od 73 operatora iz ISO 32000-1 Tablice 51 — od w i q do rijetko viđenih operatora metrike glifova tipa 3 d0 i d1 — a ranije je taj popis pri svakom pojedinom tokenu prolazio linearno, pa je stranica s nekoliko tisuća operatora značila nekoliko tisuća linearnih prolaza kroz istu tablicu od 73 stavke. PDF Library for Delphi sada pri pokretanju grupira tablicu prema prvom bajtu operatora u fiksno polje pretinaca indeksirano pomoću AnsiChar, pa traženje postaje jedan indeks polja i prolaz samo kroz nekoliko operatora koji dijele isti početni znak

Type
  TOpSlot= Record
    Count: Integer;
    Ops: Array [0..15] Of TPDFContentOperator;
  End;

Var
  GOpBuckets: Array [AnsiChar] Of TOpSlot;
  GBucketsReady: Boolean= False;

Function ContentOperatorFromName(Const Name: AnsiString): TPDFContentOperator;
Var
  Ch: AnsiChar;
  Slot: ^TOpSlot;
  I: Integer;
  Op: TPDFContentOperator;
Begin
  Result:= coUnknown;
  If (Name= '') Then
    Exit;
  EnsureOpBuckets;
  Ch:= Name[1];
  Slot:= @GOpBuckets[Ch];
  If Slot^.Count= 0 Then
    Exit;
  For I:= 0 To Slot^.Count- 1 Do
  Begin
    Op:= Slot^.Ops[I];
    If (PDFContentOpInfo[Op].Name= Name) Then
    Begin
      Result:= Op;
      Exit;
    End;
  End;
End;

PDF operatori razlikuju velika i mala slova — w i W, f i F, sc i SC svi su različiti operatori — pa GOpBuckets koristi izvorni bajt kao ključ, a preostala usporedba unutar pretinca obična je usporedba AnsiString osjetljiva na velika i mala slova. Polje je veličine 16 mjesta po slovu, što bez problema pokriva današnju tablicu — najopterećeniji pretinac, T, sadrži trinaest operatora jer gotovo svaki operator stanja teksta i pozicioniranja teksta počinje slovom T — ali EnsureOpBuckets tiho prestaje dodavati u pretinac kada broj dosegne 16, pa bi pretinac kojem bi ikada trebao četrnaesti unos tiho zakazao umjesto da glasno prijavi problem: operator bi se razriješio u coUnknown bez iznimke koja bi pokazala zašto. To je trošak održavanja zamjene podatkovne strukture koja se postupno pogoršava onom koja to ne čini — raspoređivanje je brže jer nikada ne treba rast provjeren granicama, ali čovjek mora pratiti jedan pretinac koji se približava gornjoj granici

Uklanjanje O(n²) iz izgradnje nizova

Pascalov obrazac Result := Result + Fragment pri svakoj iteraciji ponovno alocira i kopira cijeli već izgrađeni niz, pa izgradnja izlaza od N znakova dio po dio košta O(n²) umjesto O(n) — lako ga je previdjeti pri pregledu jer svaki redak izgleda kao jedno jeftino dodavanje, a u praksi je skup jer se PLDirectEscapeLiteralString izvršava za svaki literalni PDF niz zapisan tijekom spremanja, dok se XFDFXMLEscape izvršava za svaku vrijednost polja izvezenu u XFDF. PDF Library for Delphi to rješava na dva različita načina, odabrana prema tome što svaka funkcija može unaprijed predvidjeti. PLDirectEscapeLiteralString zna duljinu izlaza prije zapisivanja ijednog bajta — jednim prolazom razvrstava svaki znak kao običan ili escapiran i zbraja ukupnu duljinu, SetLength jednom alocira prostor, a drugi prolaz popunjava međuspremnik prema indeksu. XFDFXMLEscape ne može jeftino predvidjeti duljinu izlaza jer se tekst polja Unicode previše razlikuje da bi se unaprijed izračunao, pa umjesto toga dodaje u TStringBuilder unaprijed veličine približno jednake duljini ulaza

PDF Library for Delphi dijagram kvadratnog nadovezivanja na Result koje ponovno alocira cijeli međuspremnik nasuprot SetLength prvo izbroji pa ispuni te unaprijed dimenzioniranom TStringBuilderu
Escape rutine mijenjaju O(n²) nadodavanje za predalocirane međuspremnike, birajući SetLength prvo-broj-pa-popuni kad je veličina predvidiva i TStringBuilder kad nije
Function XFDFXMLEscape(Const W: WideString): WideString;
Var
  I: Integer;
  Builder: TStringBuilder;
Begin
  // TStringBuilder izbjegava O(n^2) spajanje WideString nizova koje je
  // XFDF izvoz nekad pogađao na svakoj vrijednosti polja
  Builder:= TStringBuilder.Create(Length(W)+ 16);
  Try
    For I:= 1 To Length(W) Do
    Begin
      Case W[I] Of
        '&':  Builder.Append('&amp;');
        '<':  Builder.Append('&lt;');
        '>':  Builder.Append('&gt;');
        // ...'"', slučajevi tabulatora, CR i LF slijede isti oblik
      Else
        Builder.Append(W[I]);
      End;
    End;
    Result:= Builder.ToString;
  Finally
    Builder.Free;
  End;
End;

Izbor između ta dva pristupa zapravo ovisi o tome što znate prije početka petlje. Brojanje pa popunjavanje brže je kada je veličinu izlaza lako izračunati jer nema ponovnih alokacija ni knjigovodstva osim brojača tipa Integer, ali znači pisanje logike razvrstavanja dvaput — jednom za brojanje i jednom za ispis — što je vlastiti rizik održavanja ako se dvije kopije raziđu. TStringBuilder odustaje od dijela vršnog protoka podataka kako bi se logika napisala jednom i dobila amortizirana dodavanja O(1) uz geometrijski rast međuspremnika, što je sigurnija zadana vrijednost kada veličinu izlaza nije lako unaprijed znati

Gdje se ovaj obrazac primjenjuje, a gdje ne

Sva četiri navedena popravka primjeri su iste ideje: pronađite poziv koji se izvršava jednom po jedinici ulaza — po ključu rječnika, pikselu, tokenu operatora ili znaku — i zamijenite njegov linearni ili nepredvidivi trošak unaprijed izračunatom tablicom, hash indeksom ili unaprijed dimenzioniranim međuspremnikom. Ništa od toga nije specifično za PDF; Delphi servis koji tisućama puta po zahtjevu razrješava isti ključ traženja, pretvara vrijednosti u tijesnoj petlji, raspoređuje prema fiksnom rječniku tokena ili gradi duge nizove znak po znak nailazi na iste oblike kvara i dobiva ista rješenja. Ono što nijedna od te četiri promjene ne dotiče jest konkurentnost ili memorijski otisak: brže traženje u rječniku u jednoj niti ne pomaže dvjema nitima koje se nadmeću za istu instancu TPDFlib, što je strukturni problem zasebno obrađen u članku o sigurnosti niti pri paralelnom prikazivanju stranica, a ne pomaže ni PDF-u toliko velikom da ga uopće nije moguće učitati u memoriju kao stablo objekata, za što služi sloj Direct Access u PDF Library for Delphi-u i što je obrađeno u članku o spajanju i dijeljenju PDF-ova veličine gigabajta

Rječnik, upravljanje bojama, raspoređivanje toka sadržaja i kod za izgradnju nizova o kojem se ovdje govori isporučuju se kao dio standardnog PDF Library for Delphi-a, losLabove PDF biblioteke za Delphi i C++Builder, bez potrebe za dodatnom konfiguracijom kako bi se išta od toga koristilo