Artículo técnico

Perfilado de rendimiento de PDFlibPas: índices hash en Delphi

PDFlibPas, la biblioteca PDF de losLab para Delphi y C++Builder, acelera sus rutas de renderizado y generación de contenido reemplazando cuatro patrones de trabajo repetido por otros amortizados: un índice hash perezoso para búsquedas de clave de diccionario, una tabla de búsqueda de gamma sRGB precalculada, agrupamiento por primer byte para el despacho de operador de flujo de contenido, y TStringBuilder en lugar de concatenación de cadenas repetida. Ninguno de los cuatro vino de un descubrimiento dramático único —vinieron del mismo patrón poco glamoroso en un perfil: una función pequeña llamada una vez por operador, una vez por píxel, o una vez por carácter, donde un costo lineal dentro de la llamada se vuelve cuadrático o casi cuadrático a través de todo un documento. Ese es el hilo conductor aquí: cuatro correcciones pequeñas, de apariencia no relacionada, que atacan la misma forma de problema, más los límites honestos de cada una

Dónde gasta realmente su tiempo un renderizador de flujo de contenido

El renderizador de flujo de contenido de PDFlibPas canaliza casi todo su costo por token a través de cuatro puntos estrechos: búsquedas de diccionario de recursos en /Resources, /ColorSpace, /Font, y /ExtGState; corrección de gamma en cada píxel decodificado de una imagen Lab, Indexed, o etiquetada con ICC; comparación de nombre de operador en cada token de cada flujo de contenido; y construcción de cadenas dondequiera que la biblioteca construya salida —escapado de cadena literal al guardar, exportación XFDF, expansión de token de sello y variable. Cada uno de los cuatro hace una pequeña cantidad de trabajo por sí solo, y cada uno se ejecuta miles o millones de veces sobre un documento realista, que es exactamente la forma de función donde un detalle de implementación O(n) u O(n²) deja de ser invisible y empieza a ser la entrada superior del perfil

¿Por qué se vuelven lentas las búsquedas de diccionario de recursos en un PDF grande?

TPDFDictionary.FindIndexByKeyName es lo que llama el renderizador para resolver cada búsqueda de /Resources, /ColorSpace, /Font, y /ExtGState, y solía recorrer el arreglo Entries desde el frente en cada llamada —bien para un diccionario Resources de tres entradas, costoso para un Form XObject o una página con mucho ExtGState donde se sondea el mismo diccionario en cada operador que toca color o estado gráfico. PDFlibPas ahora construye un índice hash perezoso en cuanto un diccionario supera DICT_HASH_THRESHOLD (16) entradas y deja los diccionarios más pequeños en el escaneo lineal, ya que la mayoría de los diccionarios PDF nunca llegan a ser tan grandes y una tabla hash para tres claves costaría más construirla de lo que ahorra. El índice es una tabla plana de direccionamiento abierto indexada por PLAnsiStringHash, un hash FNV-1a con la base de desplazamiento canónica 2166136261 y el primo 16777619, elegido para evitar traer System.Generics.Collections para algo tan sensible al tamaño

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;

El índice se invalida en lugar de mantenerse incrementalmente: cada llamada que muta —AddEntry, DeleteEntryByKeyName, Assign, AddDict— limpia el hash y deja que la siguiente búsqueda lo reconstruya desde cero. Eso se ve derrochador hasta que se nota que una clave de diccionario es un objeto TPDFName, y TPDFName.SetTo puede renombrar una clave ya sentada en el arreglo Entries de un diccionario sin pasar por ninguno de los propios métodos del diccionario —un índice incremental no tiene forma de observar ese renombrado, mientras que uno perezoso simplemente se reconstruye y permanece correcto por construcción. El precio de esa seguridad es una reconstrucción O(n) la primera vez que se consulta un diccionario grande después de una escritura, más la memoria para la propia tabla hash, aproximadamente un Integer por ranura a un factor de carga de dos tercios —un error de redondeo para el puñado de diccionarios sobredimensionados en un documento típico, y un costo real que PDFlibPas evita pagar en cada diccionario pequeño manteniendo el umbral donde está

Precalcular gamma sRGB en lugar de llamar a Power por píxel

TPDFSimpleColorManager.XYZ2RGB aplica la función de transferencia sRGB a cada píxel decodificado de una imagen Lab, Indexed, o basada en ICC —1.055 * Power(x, 1/2.4) - 0.055 por encima del umbral de segmento lineal— y Power(x, y) para un y fraccionario no tiene una forma cerrada barata en el RTL de Pascal: se descompone en Ln(x) y luego Exp(y * Ln(x)), y ese par de llamadas trascendentales, ejecutado tres veces por píxel para los canales rojo, verde, y azul, es el costo dominante de decodificar un píxel de imagen Lab o ICC uno por uno. PDFlibPas reemplaza las tres llamadas Power por píxel con una búsqueda en GSRGBGammaLUT, un arreglo Double de 4096 entradas construido una vez mediante EnsureSRGBGammaLUT e indexado redondeando la entrada acotada a la ranura más cercana

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;

Una tabla de 4096 ranuras sobre el rango de entrada [0, 1] da aproximadamente dieciséis veces la resolución de un canal de salida de 8 bits, así que la cuantización que introduce la LUT se sitúa por debajo de lo que puede representar el byte RGB final —la búsqueda en tabla reemplaza aquí la matemática trascendental sin un costo de precisión visible. El mismo razonamiento aparece justo al lado en Lab2XYZ, donde Power(LMN[i], 3) se convirtió en un simple LMN[i]*LMN[i]*LMN[i]: una potencia entera de entrada no necesita Ln/Exp en primer lugar, así que esa no es en absoluto una compensación de tipo LUT, solo una llamada Power redundante eliminada. El truco de la LUT solo rinde porque la función de transferencia es una función pura de un único Double —no se extendería limpiamente a una transformación de color que dependiera de varios valores de píxel o de más estado que ese

¿Cómo se despachan rápido 73 operadores de flujo de contenido?

ContentOperatorFromName se llama una vez por cada token que lee PDFlibPas de un flujo de contenido, comparándolo contra el conjunto completo de 73 operadores de la Tabla 51 de ISO 32000-1 —desde w y q hasta los raramente vistos operadores de métrica de glifo Tipo 3 d0 y d1— y solía recorrer esa lista linealmente en cada token individual, así que una página con unos pocos miles de operadores significaba unos pocos miles de escaneos lineales sobre la misma tabla de 73 entradas. PDFlibPas ahora agrupa la tabla por el primer byte del operador al inicio, en un arreglo fijo indexado por AnsiChar de ranuras, así que una búsqueda se convierte en un índice de arreglo más un escaneo de solo el puñado de operadores que comparten ese primer carácter

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;

Los operadores PDF distinguen mayúsculas de minúsculas —w y W, f y F, sc y SC son todos operadores distintos— así que GOpBuckets indexa por el byte crudo y la comparación residual dentro de un bucket es una simple igualdad de AnsiString que distingue mayúsculas de minúsculas. El arreglo está dimensionado en 16 ranuras por letra, que cubre cómodamente la tabla actual —el bucket más ocupado, T, contiene trece operadores, ya que casi cada operador de estado de texto y posicionamiento de texto empieza con ella— pero EnsureOpBuckets deja silenciosamente de agregar a un bucket en cuanto su conteo llega a 16, así que un bucket que alguna vez necesitara una decimocuarta entrada fallaría silenciosamente en lugar de ruidosamente: el operador se resolvería a coUnknown sin ninguna excepción que apunte a por qué. Ese es el costo de mantenimiento de cambiar una estructura de datos que se degrada con gracia por una que no lo hace —despacha más rápido porque nunca necesita un crecimiento con comprobación de límites, y necesita a un humano vigilando el único bucket cerca de su techo

Eliminar O(n²) de la construcción de cadenas

El patrón Result := Result + Fragment de Pascal reasigna y copia toda la cadena acumulada en cada iteración, así que construir una salida de N caracteres un fragmento a la vez cuesta O(n²) en lugar de O(n) —fácil de pasar por alto en revisión, ya que cada línea parece un anexado barato, y costoso en la práctica porque PLDirectEscapeLiteralString se ejecuta en cada cadena literal de PDF escrita durante el guardado y XFDFXMLEscape se ejecuta en cada valor de campo exportado a XFDF. PDFlibPas corrige los dos con técnicas distintas, elegidas según lo que cada función puede predecir de antemano. PLDirectEscapeLiteralString conoce su longitud de salida antes de escribir un solo byte —una pasada clasifica cada carácter como plano o escapado y suma el total, SetLength asigna una vez, y una segunda pasada llena el búfer por índice. XFDFXMLEscape no puede predecir barato su longitud de salida, ya que el texto de campo Unicode varía demasiado como para precalcularse, así que en su lugar anexa a un TStringBuilder predimensionado aproximadamente a la longitud de entrada

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;

La elección entre los dos en realidad es sobre qué se sabe antes de que empiece el bucle. Contar-y-luego-llenar es el más rápido de los dos cuando el tamaño de salida es barato de calcular, ya que hace cero reasignaciones y ninguna contabilidad más allá de un contador Integer, pero significa escribir la lógica de clasificación dos veces —una para contar, una para emitir— que es su propio riesgo de mantenimiento si las dos copias se desvían entre sí. TStringBuilder renuncia a un poco de ese rendimiento máximo a cambio de escribir la lógica una vez y obtener anexados O(1) amortizados por el crecimiento geométrico del búfer, que es el valor predeterminado más seguro cada vez que el tamaño de salida no es fácil de conocer de antemano

Dónde aplica este patrón, y dónde no

Las cuatro correcciones de arriba son instancias de una idea: encontrar la llamada que se ejecuta una vez por unidad de entrada —por clave de diccionario, por píxel, por token de operador, por carácter— y reemplazar su costo lineal o impredecible por una tabla precalculada, un índice hash, o un búfer predimensionado. Nada de esto es específico de PDF; un servicio Delphi que resuelve la misma clave de búsqueda miles de veces por solicitud, convierte valores en un bucle cerrado, despacha sobre un vocabulario fijo de tokens, o construye cadenas largas un carácter a la vez se topa con las mismas formas de fallo y toma las mismas correcciones. Lo que ninguno de estos cuatro cambios toca es la concurrencia o la huella de memoria: una búsqueda de diccionario de un solo hilo más rápida no hace nada por dos hilos compitiendo en la misma instancia de TPDFlib, que es un problema estructural cubierto por separado en el artículo sobre seguridad de hilos en el renderizado paralelo de páginas, y no hace nada por un PDF demasiado grande para cargarlo en memoria como un árbol de objetos en absoluto, que es para lo que sirve la capa de Acceso Directo en PDFlibPas, cubierta en el artículo sobre fusionar y dividir PDF de varios gigabytes

El código de diccionario, gestión de color, despacho de flujo de contenido, y construcción de cadenas discutido aquí se incluye como parte del PDFlibPas estándar, la biblioteca PDF de losLab para Delphi y C++Builder, sin necesidad de configuración adicional para obtener nada de esto