Techninis straipsnis

PDFlibPas našumo profiliavimas: maišos indeksai Delphi aplinkoje

PDFlibPas, losLab PDF biblioteka, skirta Delphi ir C++Builder, paspartina atvaizdavimo ir turinio generavimo kelius, pakeisdama keturis pasikartojančio darbo modelius amortizuotais: tingų maišos indeksą žodyno raktų paieškai, iš anksto apskaičiuotą sRGB gama paieškos lentelę, turinio srauto operatorių parinkimą pagal pirmą baitą ir TStringBuilder vietoj pasikartojančio eilučių sujungimo. Nė vienas iš šių sprendimų neatsirado dėl vieno dramatiško atradimo — juos atskleidė tas pats neįspūdingas profilio modelis: maža funkcija iškviečiama kartą kiekvienam operatoriui, pikseliui arba simboliui, o linijinė operacijos kaina visame dokumente tampa kvadratinė arba beveik kvadratinė. Tai pagrindinė šio straipsnio mintis: keturi maži, iš pirmo žvilgsnio nesusiję pataisymai, sprendžiantys tos pačios formos problemą, ir sąžiningai nurodytos kiekvieno jų ribos

Kur turinio srauto atvaizdavimo priemonė iš tikrųjų praleidžia laiką

PDFlibPas turinio srauto atvaizdavimo priemonė beveik visas vieno žetono sąnaudas sutelkia keturiuose siauruose taškuose: išteklių žodyno paieškose pagal /Resources, /ColorSpace, /Font ir /ExtGState; gama korekcijoje kiekvienam iškoduotam Lab, Indexed arba su ICC žyma susieto vaizdo pikseliui; operatoriaus pavadinimo atitikmens paieškoje kiekvienam kiekvieno turinio srauto žetonui; ir eilučių kūrime visur, kur biblioteka generuoja išvestį — pažodinių eilučių ekranavime išsaugojimo metu, XFDF eksportavime, antspaudo ir kintamųjų žetonų išplėtime. Kiekvienas iš šių veiksmų atskirai atlieka nedaug darbo, tačiau realiame dokumente kiekvienas vykdomas tūkstančius ar milijonus kartų, ir būtent tokios funkcijos atveju O(n) arba O(n²) įgyvendinimo detalė nustoja būti nepastebima ir tampa svarbiausiu profilio įrašu

Kodėl dideliame PDF išteklių žodynų paieškos sulėtėja

TPDFDictionary.FindIndexByKeyName yra funkcija, kurią atvaizdavimo priemonė kviečia kiekvienai /Resources, /ColorSpace, /Font ir /ExtGState paieškai išspręsti, o anksčiau ji kiekvieno iškvietimo metu nuo pradžios pereidavo per Entries masyvą — tai tinkama trijų įrašų Resources žodynui, bet brangu Form XObject arba ExtGState gausiam puslapiui, kuriame tas pats žodynas tikrinamas kiekvienam operatoriui, liečiančiam spalvą ar grafinę būseną. Dabar PDFlibPas tingų maišos indeksą sukuria, kai žodynas viršija DICT_HASH_THRESHOLD (16) įrašų, o mažesniems žodynams palieka linijinę paiešką, nes dauguma PDF žodynų niekada nebūna tokie dideli, o maišos lentelės sukūrimas trims raktams kainuotų daugiau, nei sutaupytų. Indeksas yra plokščia atviro adresavimo lentelė, kurios raktas yra PLAnsiStringHash — FNV-1a maiša su kanoniniu poslinkio pagrindu 2166136261 ir pirminiu skaičiumi 16777619, pasirinkta tam, kad dėl tokio dydžio jautrios užduoties nereikėtų įtraukti System.Generics.Collections

Const
  DICT_HASH_THRESHOLD = 16;

Function TPDFDictionary.LookupKeyIndex(Const Key: AnsiString): Integer;
Var
  H, Probe: Integer;
Begin
  Result:= -1;
  If FKeyHashMask= 0 Then
  Begin
    // Not built yet; small dictionaries stay linear since the
    // build cost would not amortize over a handful of entries.
    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;

Indeksas ne atnaujinamas inkrementaliai, o invaliduojamas: kiekvienas keičiantis iškvietimas — AddEntry, DeleteEntryByKeyName, Assign, AddDict — išvalo maišą ir leidžia kitai paieškai ją sukurti iš naujo. Tai atrodo švaistoma, kol nepastebime, kad žodyno raktas yra TPDFName objektas, o TPDFName.SetTo gali pervadinti raktą, jau esantį žodyno Entries masyve, nepasinaudodamas jokiais paties žodyno metodais — inkrementalus indeksas tokio pervadinimo negali pastebėti, o tingus indeksas tiesiog atkuriamas ir pagal konstrukciją išlieka teisingas. Šio saugumo kaina yra O(n) atkūrimas pirmą kartą po įrašymo užklausiant didelį žodyną, taip pat pačios maišos lentelės atmintis, maždaug po vieną Integer kiekvienai vietai esant dviejų trečdalių užpildymui — įprastame dokumente tai nereikšminga kelių didelių žodynų kaina, kurios PDFlibPas išvengia mažesniems žodynams, palikdama esamą ribą

Išankstinis sRGB gamos skaičiavimas vietoj Power kvietimo kiekvienam pikseliui

TPDFSimpleColorManager.XYZ2RGB sRGB perdavimo funkciją taiko kiekvienam iškoduotam Lab, Indexed arba ICC pagrindu veikiančio vaizdo pikseliui — virš linijinio segmento ribos naudojama 1.055 * Power(x, 1/2.4) - 0.055, o trupmeniniam y dydžiui skirta Power(x, y) Pascal RTL neturi pigaus uždaro pavidalo: ji išskaidoma į Ln(x), o tada į Exp(y * Ln(x)), ir šios dvi transcendentinės operacijos, vykdomos tris kartus kiekvienam pikseliui raudonam, žaliam ir mėlynam kanalui, sudaro didžiausią Lab arba ICC vaizdo dekodavimo po vieną pikselį kainą. PDFlibPas tris kiekvieno pikselio Power kvietimus pakeičia viena paieška GSRGBGammaLUT masyve — tai 4096 elementų Double masyvas, vieną kartą sukurtas per EnsureSRGBGammaLUT ir indeksuojamas suapvalinus apribotą įvestį iki artimiausios vietos

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;

4096 vietų lentelė įvesties intervale [0, 1] suteikia maždaug šešiolika kartų didesnę skiriamąją gebą nei 8 bitų išvesties kanalas, todėl LUT įvedamas kvantavimas yra mažesnis už tai, ką gali atvaizduoti galutinis RGB baitas — čia lentelės paieška pakeičia transcendentinę matematiką be matomos tikslumo kainos. Tas pats samprotavimas matomas šalia esančioje Lab2XYZ, kur Power(LMN[i], 3) tapo paprastu LMN[i]*LMN[i]*LMN[i]: sveikasis laipsnis iš pradžių nereikalauja Ln/Exp, todėl tai apskritai nėra LUT kompromisas, tik pašalintas perteklinis Power kvietimas. LUT sprendimas apsimoka tik todėl, kad perdavimo funkcija priklauso nuo vieno Double — jis nebūtų lengvai pritaikomas spalvų transformacijai, priklausančiai nuo kelių pikselio reikšmių ar nuo didesnės būsenos

Kaip greitai parinkti iš 73 turinio srauto operatorių

ContentOperatorFromName iškviečiama kiekvienam žetonui, kurį PDFlibPas perskaito iš turinio srauto, ir tikrina jį pagal visą ISO 32000-1 51 lentelės 73 operatorių rinkinį — nuo w ir q iki retai matomų d0 ir d1 Type 3 glifo metrikos operatorių — tačiau anksčiau kiekvieno žetono metu ji linijiškai pereidavo visą sąrašą, todėl kelių tūkstančių operatorių puslapis reikšdavo kelis tūkstančius linijinių tos pačios 73 įrašų lentelės perėjimų. Dabar PDFlibPas paleidimo metu suskirsto lentelę pagal pirmąjį operatoriaus baitą į fiksuotą AnsiChar indeksuojamą vietų masyvą, todėl paieška tampa vienu masyvo indeksu ir tik kelių tuo pačiu pirmuoju simboliu prasidedančių operatorių patikra

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 operatoriai skiria didžiąsias ir mažąsias raides — w ir W, f ir F, sc ir SC yra skirtingi operatoriai — todėl GOpBuckets naudoja neapdorotą baitą kaip raktą, o likęs palyginimas vietų grupėje yra paprastas, didžiąsias ir mažąsias raides skiriantis AnsiString lygybės tikrinimas. Masyvas turi po 16 vietų kiekvienai raidei, o to šiandienos lentelei patogiai pakanka — daugiausia naudojama grupė, T, turi trylika operatorių, nes beveik kiekvienas teksto būsenos ir teksto pozicionavimo operatorius prasideda šia raide — tačiau EnsureOpBuckets tyliai nustoja pridėti elementus, kai grupės skaičius pasiekia 16, todėl grupei kada nors prireikus keturiolikto įrašo klaida įvyktų tyliai, o ne aiškiai: operatorius būtų išspręstas kaip coUnknown, be išimties, paaiškinančios priežastį. Tai duomenų struktūros, kuri išlaiko veikimą didėjant apkrovai, pakeitimo tokia, kuri to nedaro, priežiūros kaina — parinkimas greitesnis, nes nereikia auginti masyvo tikrinant ribas, tačiau žmogus turi stebėti vienintelę grupę, artėjančią prie savo ribos

O(n²) pašalinimas iš eilučių kūrimo

Pascal Result := Result + Fragment šablonas kiekvienos iteracijos metu iš naujo paskirsto ir nukopijuoja visą sukauptą eilutę, todėl N simbolių išvesties kūrimas po vieną fragmentą kainuoja O(n²), o ne O(n) — tai lengva praleisti peržiūroje, nes kiekviena eilutė atrodo kaip vienas pigus pridėjimas, tačiau praktiškai brangu, nes PLDirectEscapeLiteralString vykdoma kiekvienai išsaugojimo metu įrašomai pažodinei PDF eilutei, o XFDFXMLEscape — kiekvienai į XFDF eksportuojamai lauko reikšmei. PDFlibPas šias dvi vietas sutvarko skirtingais būdais, parinktais pagal tai, ką kiekviena funkcija gali iš anksto numatyti. PLDirectEscapeLiteralString išvesties ilgį žino dar prieš įrašydama vieną baitą — vienu praėjimu kiekvienas simbolis priskiriamas paprastam arba ekranizuojamam ir susumuojamas bendras ilgis, SetLength paskiria atmintį vieną kartą, o antras praėjimas užpildo buferį pagal indeksą. XFDFXMLEscape negali nebrangiai numatyti išvesties ilgio, nes Unicode lauko tekstas per daug kinta, kad jį būtų galima iš anksto apskaičiuoti, todėl vietoj to funkcija prideda tekstą į maždaug įvesties ilgiui paruoštą TStringBuilder

Function XFDFXMLEscape(Const W: WideString): WideString;
Var
  I: Integer;
  Builder: TStringBuilder;
Begin
  // TStringBuilder avoids the O(n^2) WideString concatenation that
  // XFDF export used to hit on every field value
  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;');
        // ...'"', tab, CR and LF cases follow the same shape
      Else
        Builder.Append(W[I]);
      End;
    End;
    Result:= Builder.ToString;
  Finally
    Builder.Free;
  End;
End;

Pasirinkimas tarp šių dviejų būdų iš esmės priklauso nuo to, ką žinote prieš prasidedant ciklui. Skaičiuoti ir tada užpildyti yra greičiau, kai išvesties dydį nebrangu apskaičiuoti, nes nereikia nė vieno perskirstymo ir jokios papildomos apskaitos, išskyrus Integer skaitiklį, tačiau klasifikavimo logiką reikia parašyti du kartus — vieną kartą skaičiuojant, kitą išvedant — o tai savaime kelia priežiūros riziką, jei abi kopijos pradeda skirtis. TStringBuilder atsisako nedidelės didžiausios spartos dalies, tačiau leidžia logiką parašyti vieną kartą ir dėl geometrinio buferio augimo gauti amortizuotus O(1) pridėjimus, todėl tai saugesnis numatytasis pasirinkimas, kai išvesties dydį iš anksto nustatyti sunku

Kur šis modelis taikomas, o kur netaikomas

Visi keturi pirmiau aprašyti pataisymai yra vienos idėjos pavyzdžiai: rasti iškvietimą, vykdomą kartą kiekvienam įvesties vienetui — žodyno raktui, pikseliui, operatoriaus žetonui ar simboliui — ir jo linijinę arba nenuspėjamą kainą pakeisti iš anksto apskaičiuota lentele, maišos indeksu arba iš anksto paruoštu buferiu. Tai nėra būdinga tik PDF: Delphi tarnyba, kuri tūkstančius kartų per užklausą sprendžia tą patį paieškos raktą, verčia reikšmes glaudžiame cikle, parenka operatorių iš fiksuoto žetonų žodyno arba po vieną simbolį kuria ilgas eilutes, susiduria su tokiais pačiais gedimo modeliais ir taiko tokius pačius pataisymus. Tačiau nė vienas iš šių keturių pakeitimų neliečia lygiagretumo ar atminties pėdsako: greitesnė vienos gijos žodyno paieška nieko nepadeda dviem gijoms varžantis dėl to paties TPDFlib egzemplioriaus, o tai struktūrinė problema, atskirai aptarta straipsnyje apie gijų saugą lygiagrečiai atvaizduojant puslapius, ir nieko nepadeda PDF, kuris per didelis, kad jį būtų galima įkelti į atmintį kaip objektų medį, nes tam PDFlibPas turi Direct Access sluoksnį, aptartą straipsnyje apie gigabaitų PDF sujungimą ir skaidymą

Čia aptartas žodyno, spalvų valdymo, turinio srauto parinkimo ir eilučių kūrimo kodas pateikiamas kaip standartinio PDFlibPas, losLab PDF bibliotekos, skirtos Delphi ir C++Builder, dalis, todėl norint visa tai naudoti nereikia jokios papildomos konfigūracijos