PDFlibPas, knihovna PDF od losLab pro Delphi a C++Builder, zrychluje své cesty vykreslování a generování obsahu nahrazením čtyř vzorů opakované práce amortizovanými: líným hashovacím indexem pro vyhledávání klíčů slovníku, předpočítanou vyhledávací tabulkou gama sRGB, kbelíkováním podle prvního bajtu pro dispatch operátorů proudu obsahu, a TStringBuilder místo opakovaného spojování řetězců. Žádná ze čtyř nevzešla z jednoho dramatického objevu — vzešly ze stejného neokázalého vzoru v profilu: malá funkce volaná jednou na klíč slovníku, jednou na pixel, nebo jednou na znak, kde se lineární náklad uvnitř volání promění na kvadratický nebo skoro kvadratický přes celý dokument. To je společné vlákno zde: čtyři malé, na první pohled nesouvisející opravy, které útočí na stejný tvar problému, plus poctivé limity každé z nich
Kde vykreslovač proudu obsahu skutečně tráví svůj čas
Vykreslovač proudu obsahu PDFlibPas svádí téměř celý svůj náklad na token přes čtyři úzká místa: vyhledávání ve slovníku zdrojů na /Resources, /ColorSpace, /Font a /ExtGState; gama korekci na každém dekódovaném pixelu obrázku Lab, Indexed, nebo označeného ICC; porovnávání jmen operátorů na každém tokenu každého proudu obsahu; a stavbu řetězců kdekoli, kde knihovna staví výstup — escapování literálních řetězců při uložení, export XFDF, expanzi razítek a tokenů proměnných při ukládání. Každá z těchto čtyř dělá malé množství práce sama o sobě, a každá běží tisíckrát nebo milionkrát přes realistický dokument, což je přesně tvar funkce, kde detail implementace O(n) nebo O(n²) přestává být neviditelný a stává se vrchním záznamem profilu
Proč vyhledávání ve slovníku zdrojů zpomaluje ve velkém PDF?
TPDFDictionary.FindIndexByKeyName je to, co vykreslovač volá k rozřešení každého vyhledávání /Resources, /ColorSpace, /Font a /ExtGState, a dřív procházel pole Entries od začátku při každém volání — v pořádku pro tříprvkový slovník Resources, drahé pro Form XObject nebo stránku hustou na ExtGState, kde se stejný slovník prozkoumá při každém operátoru, který se dotkne barvy nebo stavu grafiky. PDFlibPas teď staví líný hashovací index, jakmile slovník překročí DICT_HASH_THRESHOLD (16) záznamů, a ponechává menší slovníky na lineárním prohledávání, protože většina slovníků PDF nikdy tak nenaroste a hashovací tabulka pro tři klíče by na postavení stála víc, než ušetří. Index je plochá tabulka s otevřeným adresováním klíčovaná přes PLAnsiStringHash, hash FNV-1a s kanonickou offsetovou bází 2166136261 a prvočíslem 16777619, zvolený tak, aby se vyhnul zatáhnutí System.Generics.Collections pro něco takto citlivého na velikost
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;
Index se invaliduje místo toho, aby se udržoval přírůstkově: každé měnící volání — AddEntry, DeleteEntryByKeyName, Assign, AddDict — vyčistí hash a nechá další vyhledávání přestavět jej od nuly. To vypadá jako plýtvání, dokud si nevšimnete, že klíč slovníku je objekt TPDFName, a TPDFName.SetTo dokáže přejmenovat klíč už sedící v poli Entries slovníku, aniž by prošel kteroukoli z vlastních metod slovníku — přírůstkový index nemá způsob, jak toto přejmenování pozorovat, zatímco líný jednoduše přestaví a zůstane správný konstrukčně. Cenou za tuto bezpečnost je přestavba O(n) při první dotazu velkého slovníku po zápisu, plus paměť pro samotnou hashovací tabulku, zhruba jedno Integer na slot při zatížení dvou třetin — chyba zaokrouhlení pro hrstku předimenzovaných slovníků v typickém dokumentu, a skutečný náklad, kterému se PDFlibPas vyhýbá u každého malého tím, že drží práh tam, kde je
Předpočítání gama sRGB místo volání Power na pixel
TPDFSimpleColorManager.XYZ2RGB aplikuje přenosovou funkci sRGB na každý dekódovaný pixel obrázku Lab, Indexed, nebo založeného na ICC — 1.055 * Power(x, 1/2.4) - 0.055 nad prahem lineárního segmentu — a Power(x, y) pro zlomkové y nemá v RTL Pascalu žádný levný uzavřený tvar: rozloží se na Ln(x) pak Exp(y * Ln(x)), a tato dvojice transcendentálních volání, spuštěná třikrát na pixel pro červený, zelený a modrý kanál, je dominantní náklad dekódování pixelu obrázku Lab nebo ICC po pixelu. PDFlibPas nahrazuje tři volání Power na pixel jedním vyhledáním do GSRGBGammaLUT, pole Double se 4096 záznamy postaveném jednou přes EnsureSRGBGammaLUT a indexovaném zaokrouhlením ořezaného vstupu na nejbližší slot
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;
Tabulka se 4096 sloty přes vstupní rozsah [0, 1] dává zhruba šestnáctkrát vyšší rozlišení, než jaké má 8bitový výstupní kanál, takže kvantizace, kterou LUT zavádí, sedí pod tím, co dokáže reprezentovat finální bajt RGB — vyhledání v tabulce tady nahrazuje transcendentální matematiku bez viditelného nákladu na přesnost. Stejná úvaha se objevuje vedle toho v Lab2XYZ, kde se Power(LMN[i], 3) stalo obyčejným LMN[i]*LMN[i]*LMN[i]: celočíselná mocnina vůbec nepotřebuje Ln/Exp na prvním místě, takže tohle vůbec není kompromis LUT, jen odstraněné nadbytečné volání Power. Trik s LUT se vyplatí jen proto, že je přenosová funkce čistá funkce jednoho Double — nerozšířilo by se to čistě na transformaci barvy, která by závisela na několika hodnotách pixelu nebo na víc stavu než tomto
Jak rychle dispatchovat 73 operátorů proudu obsahu?
ContentOperatorFromName se volá jednou pro každý token, který PDFlibPas přečte z proudu obsahu, porovnává jej proti plné sadě 73 operátorů z tabulky 51 ISO 32000-1 — od w a q až po vzácně vídané operátory metrik glyfů Typu 3 d0 a d1 — a dřív tento seznam procházel lineárně při každém jednotlivém tokenu, takže stránka s pár tisíci operátory znamenala pár tisíc lineárních průchodů přes stejnou 73záznamovou tabulku. PDFlibPas teď kbelíkuje tabulku podle prvního bajtu operátoru při startu, do pevného pole slotů indexovaného AnsiChar, takže se vyhledání stane jedním indexem pole plus průchodem jen hrstky operátorů sdílejících tento první 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;
Operátory PDF rozlišují velikost písmen — w a W, f a F, sc a SC jsou všechny odlišné operátory — takže GOpBuckets klíčuje na surový bajt a zbytkové porovnání uvnitř kbelíku je obyčejná, na velikost citlivá rovnost AnsiString. Pole je velikostně dimenzované na 16 slotů na písmeno, což pohodlně pokrývá dnešní tabulku — nejrušnější kbelík, T, drží třináct operátorů, protože skoro každý operátor stavu textu a pozicování textu jím začíná — ale EnsureOpBuckets tiše přestane přidávat do kbelíku, jakmile jeho počet dosáhne 16, takže kbelík, který by kdy potřeboval čtrnáctý záznam, by selhal tiše místo hlasitě: operátor by se rozřešil na coUnknown bez jakékoli výjimky ukazující proč. To je náklad na údržbu za výměnu datové struktury, která se elegantně degraduje, za tu, která to nedělá — dispatchuje rychleji, protože nikdy nepotřebuje růst s kontrolou mezí, a potřebuje člověka sledujícího ten jeden kbelík blízko svého stropu
Odříznutí O(n²) ze stavby řetězců
Vzor Pascalu Result := Result + Fragment při každé iteraci znovu alokuje a kopíruje celý nahromaděný řetězec, takže stavba N-znakového výstupu po jednom fragmentu stojí O(n²) místo O(n) — snadné přehlédnout při revizi, protože každý řádek vypadá jako jedno levné připojení, a drahé v praxi, protože PLDirectEscapeLiteralString běží na každém literálním řetězci PDF zapsaném během ukládání a XFDFXMLEscape běží na každé hodnotě pole exportované do XFDF. PDFlibPas opravuje tyto dvě odlišnými technikami, zvolenými podle toho, co každá funkce dokáže předem odhadnout. PLDirectEscapeLiteralString zná svou výstupní délku, ještě než zapíše jediný bajt — jeden průchod klasifikuje každý znak jako obyčejný nebo escapovaný a sečte celkovou délku, SetLength alokuje jednou, a druhý průchod naplní buffer podle indexu. XFDFXMLEscape nedokáže levně předpovědět svou výstupní délku, protože unikódový text pole se liší příliš na to, aby se to dalo předpočítat, takže místo toho připojuje do TStringBuilder předdimenzovaného zhruba na délku vstupu
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('&');
'<': Builder.Append('<');
'>': Builder.Append('>');
// ...'"', tab, CR and LF cases follow the same shape
Else
Builder.Append(W[I]);
End;
End;
Result:= Builder.ToString;
Finally
Builder.Free;
End;
End;
Volba mezi oběma je skutečně o tom, co víte ještě před začátkem smyčky. Napočítej-pak-naplň je rychlejší z obou, když je výstupní velikost levné spočítat, protože nedělá žádné realokace a žádné účetnictví nad rámec počítadla Integer, ale znamená to napsat klasifikační logiku dvakrát — jednou na počítání, jednou na výstup — což je vlastní riziko údržby, pokud se obě kopie rozejdou. TStringBuilder vzdává trochu z této špičkové propustnosti za napsání logiky jednou a získání amortizovaných O(1) připojení z geometrického růstu bufferu, což je bezpečnější výchozí volba, kdykoli velikost výstupu není snadné znát předem
Kde se tento vzor uplatní, a kde ne
Všechny čtyři opravy výše jsou instance jedné myšlenky: najít volání, které běží jednou na jednotku vstupu — na klíč slovníku, na pixel, na token operátoru, na znak — a nahradit jeho lineární nebo nepředvídatelný náklad předpočítanou tabulkou, hashovacím indexem, nebo předdimenzovaným bufferem. Nic z toho není specifické pro PDF; služba v Delphi, která rozřešuje stejný klíč vyhledání tisíckrát na požadavek, převádí hodnoty v těsné smyčce, dispatchuje na pevném slovníku tokenů, nebo staví dlouhé řetězce po jednom znaku, naráží na stejné tvary selhání a bere stejné opravy. Co žádná z těchto čtyř změn nezasahuje, je souběžnost nebo paměťová stopa: rychlejší jednovláknové vyhledávání ve slovníku nedělá nic pro dvě vlákna soupeřící o stejnou instanci TPDFlib, což je strukturální problém popsaný samostatně v článku o bezpečnosti vláken v paralelním vykreslování stránek, a nedělá nic pro PDF příliš velké na to, aby se vůbec dalo načíst do paměti jako strom objektů, což je k čemu slouží vrstva Direct Access v PDFlibPas, popsaná v článku o slučování a rozdělování gigabajtových PDF
Kód slovníku, správy barev, dispatche proudu obsahu a stavby řetězců popsaný zde se dodává jako součást standardní PDFlibPas, knihovny PDF od losLab pro Delphi a C++Builder, bez potřeby jakékoli dodatečné konfigurace, aby cokoli z toho fungovalo