Technical Article

PDFlibPas Performance Profiling: Hash Indexes in Delphi

PDFlibPas, losLab's PDF library for Delphi and C++Builder, speeds up its rendering and content-generation paths by replacing four repeated-work patterns with amortized ones: a lazy hash index for dictionary key lookups, a precomputed sRGB gamma lookup table, first-byte bucketing for content-stream operator dispatch, and TStringBuilder in place of repeated string concatenation. None of the four came from one dramatic discovery — they came from the same unglamorous pattern in a profile: a small function called once per operator, once per pixel, or once per character, where a linear cost inside the call becomes quadratic or near-quadratic across a whole document. That is the throughline here: four small, unrelated-looking fixes that attack the same shape of problem, plus the honest limits of each one

Where a Content-Stream Renderer Actually Spends Its Time

PDFlibPas's content-stream renderer funnels almost all of its per-token cost through four narrow points: resource dictionary lookups on /Resources, /ColorSpace, /Font, and /ExtGState; gamma correction on every decoded pixel of a Lab, Indexed, or ICC-tagged image; operator-name matching on every token of every content stream; and string construction wherever the library builds output — literal string escaping on save, XFDF export, stamp and variable token expansion. Each of the four does a small amount of work on its own, and each one runs thousands or millions of times over a realistic document, which is exactly the shape of function where an O(n) or O(n²) implementation detail stops being invisible and starts being the profile's top entry

Why Do Resource Dictionary Lookups Get Slow in a Large PDF?

TPDFDictionary.FindIndexByKeyName is what the renderer calls to resolve every /Resources, /ColorSpace, /Font, and /ExtGState lookup, and it used to walk the Entries array from the front on every call — fine for a three-entry Resources dictionary, expensive for a Form XObject or an ExtGState-heavy page where the same dictionary gets probed on every operator that touches color or graphics state. PDFlibPas now builds a lazy hash index once a dictionary passes DICT_HASH_THRESHOLD (16) entries and leaves smaller dictionaries on the linear scan, since most PDF dictionaries never get that large and a hash table for three keys would cost more to build than it saves. The index is a flat open-addressing table keyed by PLAnsiStringHash, an FNV-1a hash with the canonical offset basis 2166136261 and prime 16777619, chosen to avoid pulling in System.Generics.Collections for something this size-sensitive

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;

The index is invalidated rather than incrementally maintained: every mutating call — AddEntry, DeleteEntryByKeyName, Assign, AddDict — clears the hash and lets the next lookup rebuild it from scratch. That looks wasteful until you notice that a dictionary key is a TPDFName object, and TPDFName.SetTo can rename a key already sitting in a dictionary's Entries array without going through any of the dictionary's own methods — an incremental index has no way to observe that rename, while a lazy one just rebuilds and stays correct by construction. The price of that safety is an O(n) rebuild the first time a large dictionary is queried after a write, plus the memory for the hash table itself, roughly one Integer per slot at a two-thirds load factor — a rounding error for the handful of oversized dictionaries in a typical document, and a real cost PDFlibPas avoids paying on every small one by keeping the threshold where it is

Precomputing sRGB Gamma Instead of Calling Power Per Pixel

TPDFSimpleColorManager.XYZ2RGB applies the sRGB transfer function to every decoded pixel of a Lab, Indexed, or ICC-based image — 1.055 * Power(x, 1/2.4) - 0.055 above the linear-segment threshold — and Power(x, y) for a fractional y has no cheap closed form on the Pascal RTL: it decomposes into Ln(x) then Exp(y * Ln(x)), and that pair of transcendental calls, run three times per pixel for the red, green, and blue channels, is the dominant cost of decoding a Lab or ICC image pixel by pixel. PDFlibPas replaces the three per-pixel Power calls with one lookup into GSRGBGammaLUT, a 4096-entry Double array built once via EnsureSRGBGammaLUT and indexed by rounding the clamped input to the nearest 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;

A 4096-slot table over the [0, 1] input range gives roughly sixteen times the resolution of an 8-bit output channel, so the quantization the LUT introduces sits below what the final RGB byte can represent — table lookup replaces transcendental math here without a visible precision cost. The same reasoning shows up next to it in Lab2XYZ, where Power(LMN[i], 3) became a plain LMN[i]*LMN[i]*LMN[i]: an integer power does not need Ln/Exp in the first place, so that one is not a LUT trade-off at all, just a redundant Power call removed. The LUT trick only pays off because the transfer function is a pure function of a single Double — it would not extend cleanly to a color transform that depended on several pixel values or on more state than that

How Do You Dispatch 73 Content-Stream Operators Fast?

ContentOperatorFromName is called once for every token PDFlibPas reads out of a content stream, matching it against ISO 32000-1 Table 51's full set of 73 operators — from w and q up to the rarely-seen d0 and d1 Type 3 glyph-metrics operators — and it used to walk that list linearly on every single token, so a page with a few thousand operators meant a few thousand linear scans over the same 73-entry table. PDFlibPas now buckets the table by the operator's first byte at startup, into a fixed AnsiChar-indexed array of slots, so a lookup becomes one array index plus a scan of only the handful of operators sharing that first character

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 operators are case-sensitive — w and W, f and F, sc and SC are all different operators — so GOpBuckets keys on the raw byte and the residual compare inside a bucket is a plain, case-sensitive AnsiString equality. The array is sized at 16 slots per letter, which comfortably covers today's table — the busiest bucket, T, holds thirteen operators, since almost every text-state and text-positioning operator starts with it — but EnsureOpBuckets silently stops adding to a bucket once its count reaches 16, so a bucket that ever needed a fourteenth entry would fail silently rather than loudly: the operator would resolve to coUnknown with no exception pointing at why. That is the maintenance cost of trading a data structure that degrades gracefully for one that does not — it dispatches faster because it never needs a bounds-checked grow, and it needs a human watching the one bucket close to its ceiling

Cutting O(n²) Out of String Building

Pascal's Result := Result + Fragment pattern reallocates and copies the entire accumulated string on every iteration, so building an N-character output one fragment at a time costs O(n²) instead of O(n) — easy to miss in review, since each line looks like one cheap append, and expensive in practice because PLDirectEscapeLiteralString runs on every literal PDF string written during save and XFDFXMLEscape runs on every field value exported to XFDF. PDFlibPas fixes the two with different techniques, chosen by what each function can predict in advance. PLDirectEscapeLiteralString knows its output length before writing a single byte — one pass classifies each character as plain or escaped and sums the total, SetLength allocates once, and a second pass fills the buffer by index. XFDFXMLEscape cannot cheaply predict its output length, since Unicode field text varies too much to precompute, so it appends into a TStringBuilder pre-sized to roughly the input length instead

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;

The choice between the two is really about what you know before the loop starts. Count-then-fill is the faster of the two when the output size is cheap to compute, since it does zero reallocations and no bookkeeping beyond an Integer counter, but it means writing the classification logic twice — once to count, once to emit — which is its own maintenance risk if the two copies drift apart. TStringBuilder gives up a little of that peak throughput for writing the logic once and getting amortized O(1) appends from geometric buffer growth, which is the safer default whenever the output size is not easy to know upfront

Where This Pattern Applies, and Where It Does Not

All four fixes above are instances of one idea: find the call that runs once per unit of input — per dictionary key, per pixel, per operator token, per character — and replace its linear or unpredictable cost with a precomputed table, a hash index, or a pre-sized buffer. None of it is specific to PDF; a Delphi service that resolves the same lookup key thousands of times per request, converts values in a tight loop, dispatches on a fixed vocabulary of tokens, or builds long strings a character at a time hits the same failure shapes and takes the same fixes. What none of these four changes touch is concurrency or memory footprint: a faster single-threaded dictionary lookup does nothing for two threads racing on the same TPDFlib instance, which is a structural problem covered separately in the article on thread safety in parallel page rendering, and it does nothing for a PDF too large to load into memory as an object tree at all, which is what the Direct Access layer in PDFlibPas is for, covered in the article on merging and splitting gigabyte PDFs

The dictionary, color-management, content-stream dispatch, and string-building code discussed here ships as part of the standard PDFlibPas, losLab's PDF library for Delphi and C++Builder, with no extra configuration needed to get any of it