PDFlibPas, бібліотека PDF від losLab для Delphi та C++Builder, прискорює свої шляхи рендерингу та генерації вмісту, замінюючи чотири шаблони повторюваної роботи амортизованими: лінивий хеш-індекс для пошуку ключів словника, попередньо обчислена таблиця пошуку гама sRGB, розподіл по першому байту для диспетчеризації операторів потоку вмісту та TStringBuilder замість повторної конкатенації рядків. Жодне з чотирьох не з'явилося з одного драматичного відкриття — усі вони з'явилися з того самого неефектного шаблону в профілі: невелика функція, викликана один раз на ключ словника, один раз на піксель чи один раз на символ, де лінійна вартість усередині виклику стає квадратичною чи майже квадратичною по всьому документу. Ось наскрізна тема тут: чотири маленькі, на вигляд непов'язані виправлення, що атакують ту саму форму проблеми, плюс чесні межі кожного з них
Куди насправді витрачає час рендерер потоку вмісту
Рендерер потоку вмісту в PDFlibPas спрямовує майже всю свою вартість на токен через чотири вузькі точки: пошуки словника ресурсів на /Resources, /ColorSpace, /Font та /ExtGState; корекцію гами на кожному декодованому пікселі зображення Lab, Indexed чи з тегом ICC; зіставлення імен операторів на кожному токені кожного потоку вмісту; та побудову рядків будь-де, де бібліотека будує вивід — екранування буквального рядка при збереженні, експорт XFDF, розгортання токенів штампів та змінних. Кожне з чотирьох виконує невелику кількість роботи саме по собі, і кожне виконується тисячі чи мільйони разів над реалістичним документом, і це саме та форма функції, де деталь реалізації O(n) чи O(n²) перестає бути невидимою і починає бути найвищим записом профілю
Чому пошуки словника ресурсів стають повільними у великому PDF?
TPDFDictionary.FindIndexByKeyName — те, що викликає рендерер для розв'язання кожного пошуку /Resources, /ColorSpace, /Font та /ExtGState, і раніше він обходив масив Entries спереду при кожному виклику — нормально для трьохзаписного словника Resources, дорого для Form XObject чи сторінки, багатої на ExtGState, де той самий словник опитується при кожному операторі, що торкається кольору чи графічного стану. PDFlibPas тепер будує лінивий хеш-індекс, щойно словник перевищує поріг DICT_HASH_THRESHOLD (16) записів, і залишає менші словники на лінійному скануванні, оскільки більшість словників PDF ніколи не досягають такого розміру, а хеш-таблиця для трьох ключів коштувала б дорожче побудувати, ніж заощаджує. Індекс — пласка таблиця з відкритою адресацією, ключем якої є PLAnsiStringHash, хеш FNV-1a з канонічною зсувною основою 2166136261 та простим числом 16777619, обраний, щоб уникнути підтягування 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;
Індекс інвалідується, а не підтримується інкрементально: кожен мутуючий виклик — AddEntry, DeleteEntryByKeyName, Assign, AddDict — очищає хеш і дозволяє наступному пошуку перебудувати його з нуля. Це виглядає марнотратно, доки не помітите, що ключ словника — об'єкт TPDFName, а TPDFName.SetTo може перейменувати ключ, що вже сидить у масиві Entries словника, не проходячи через жоден із власних методів словника, — інкрементальний індекс не має способу спостерігати це перейменування, тоді як лінивий просто перебудовується і залишається коректним за побудовою. Ціна цієї безпеки — перебудова O(n) перший раз, коли до великого словника надходить запит після запису, плюс пам'ять для самої хеш-таблиці, приблизно один Integer на слот при коефіцієнті заповнення дві третини, — похибка округлення для жменьки надмірних словників у типовому документі, і реальна вартість, якої PDFlibPas уникає платити на кожному маленькому, тримаючи поріг там, де він є
Попереднє обчислення гами sRGB замість виклику Power на піксель
TPDFSimpleColorManager.XYZ2RGB застосовує передавальну функцію sRGB до кожного декодованого пікселя зображення Lab, Indexed чи на основі ICC — 1.055 * Power(x, 1/2.4) - 0.055 вище лінійного порогового сегмента, — а Power(x, y) для дробового y не має дешевої замкнутої форми в RTL Pascal: вона розкладається на Ln(x), потім Exp(y * Ln(x)), і ця пара трансцендентних викликів, виконана тричі на піксель для червоного, зеленого та синього каналів, — домінантна вартість декодування пікселя зображення Lab чи ICC по одному. PDFlibPas замінює три виклики Power на піксель на один пошук у GSRGBGammaLUT, масиві Double на 4096 записів, побудованому один раз через EnsureSRGBGammaLUT та індексованому округленням обмеженого входу до найближчого слоту
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 слотів над діапазоном входу [0, 1] дає приблизно у шістнадцять разів більшу роздільну здатність, ніж 8-бітний вихідний канал, тож квантування, яке вносить таблиця пошуку, сидить нижче того, що може представити фінальний байт RGB, — пошук у таблиці замінює трансцендентну математику тут без видимої втрати точності. Те саме міркування з'являється поряд у Lab2XYZ, де Power(LMN[i], 3) став простим LMN[i]*LMN[i]*LMN[i]: цілочисельний степінь узагалі не потребує Ln/Exp, тож це взагалі не компроміс таблиці пошуку, просто прибраний зайвий виклик Power. Трюк із таблицею пошуку окупається лише тому, що передавальна функція — чиста функція одного Double; вона не поширилася б чисто на перетворення кольору, що залежить від кількох значень пікселя чи від більшого стану, ніж це
Як швидко диспетчеризувати 73 оператори потоку вмісту?
ContentOperatorFromName викликається один раз для кожного токена, який PDFlibPas читає з потоку вмісту, зіставляючи його з повним набором 73 операторів таблиці 51 ISO 32000-1 — від w та q до рідко бачених операторів метрик гліфів типу 3 d0 та d1, — і раніше він обходив цей список лінійно на кожному окремому токені, тож сторінка з кількома тисячами операторів означала кілька тисяч лінійних сканувань того самого 73-записного словника. PDFlibPas тепер розподіляє таблицю за першим байтом оператора під час запуску в фіксований масив слотів, індексований AnsiChar, тож пошук стає одним індексом масиву плюс сканування лише жменьки операторів, що поділяють цей перший символ
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 чутливі до регістру — w та W, f та F, sc та SC — усі різні оператори, — тож GOpBuckets ключується на сирий байт, а залишкове порівняння всередині відра — просте, чутливе до регістру рівняння AnsiString. Масив розрахований на 16 слотів на літеру, що зручно покриває сьогоднішню таблицю — найзавантаженіше відро, T, тримає тринадцять операторів, оскільки майже кожен оператор стану тексту та позиціювання тексту починається з нього, — але EnsureOpBuckets тихо припиняє додавати до відра, щойно його підрахунок сягає 16, тож відро, якому колись знадобився б чотирнадцятий запис, провалилося б тихо, а не гучно: оператор розв'язався б у coUnknown без жодного винятку, що вказував би чому. Це вартість утримання структури даних, що трейдить те, що деградує грайливо, на те, що ні, — вона диспетчеризує швидше, бо ніколи не потребує зростання з перевіркою меж, і їй потрібна людина, що спостерігає за одним відром, близьким до своєї стелі
Вирізання O(n²) з побудови рядків
Шаблон Pascal Result := Result + Fragment перевиділяє й копіює весь накопичений рядок при кожній ітерації, тож побудова N-символьного виводу по одному фрагменту за раз коштує O(n²) замість O(n) — легко пропустити при перегляді, бо кожен рядок виглядає як один дешевий додаток, і дорого на практиці, бо PLDirectEscapeLiteralString виконується на кожному буквальному рядку PDF, записаному під час збереження, а XFDFXMLEscape виконується на кожному значенні поля, експортованому в XFDF. PDFlibPas виправляє обидва різними техніками, обраними тим, що кожна функція може передбачити заздалегідь. PLDirectEscapeLiteralString знає довжину свого виводу до запису хоч одного байта — один прохід класифікує кожен символ як звичайний чи екранований і підсумовує загальну кількість, SetLength виділяє один раз, а другий прохід заповнює буфер за індексом. XFDFXMLEscape не може дешево передбачити довжину свого виводу, оскільки текст поля Unicode варіюється надто сильно для попереднього обчислення, тож натомість він додає в 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('&');
'<': 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;
Вибір між двома насправді про те, що ви знаєте перед початком циклу. Підрахувати-потім-заповнити швидше з цих двох, коли розмір виводу дешево обчислити, бо він робить нуль перевиділень і жодного обліку понад лічильник Integer, але це означає написання логіки класифікації двічі — один раз для підрахунку, один раз для видачі, — що власний ризик супроводу, якщо дві копії розійдуться. TStringBuilder жертвує невеликою частиною цієї пікової пропускної здатності заради написання логіки один раз і отримання амортизованих O(1) додатків через геометричне зростання буфера, що безпечніший за замовчуванням варіант, коли розмір виводу нелегко знати наперед
Де цей шаблон застосовується, а де ні
Усі чотири виправлення вище — приклади однієї ідеї: знайти виклик, що виконується один раз на одиницю входу — на ключ словника, на піксель, на токен оператора, на символ, — і замінити його лінійну чи непередбачувану вартість попередньо обчисленою таблицею, хеш-індексом чи буфером попереднього розміру. Ніщо з цього не специфічне для PDF; служба Delphi, що розв'язує той самий ключ пошуку тисячі разів на запит, перетворює значення в щільному циклі, диспетчеризує на фіксованому словнику токенів чи будує довгі рядки по символу за раз, наштовхується на ті самі форми збою й отримує ті самі виправлення. Що жодна з цих чотирьох змін не торкається, так це паралелізм чи слід пам'яті: швидший однопотоковий пошук словника нічого не робить для двох потоків, що змагаються за той самий екземпляр TPDFlib, — структурна проблема, розглянута окремо в статті про безпеку потоків у паралельному рендерингу сторінок, і нічого не робить для PDF, надто великого, щоб узагалі завантажити в пам'ять як дерево об'єктів, — для цього призначений шар прямого доступу в PDFlibPas, розглянутий у статті про об'єднання й розділення гігабайтних PDF
Код словника, керування кольором, диспетчеризація потоку вмісту та побудова рядків, обговорені тут, постачаються як частина стандартного PDFlibPas, бібліотеки PDF від losLab для Delphi та C++Builder, без потреби в жодному додатковому налаштуванні для отримання будь-чого з цього