Artículo técnico

Optimización del rendimiento de E/S para el procesamiento de archivos PDF a escala de gigabytes

La primera lectura útil de un analizador de PDF ocurre en el extremo equivocado del archivo. El formato coloca el puntero startxref en los últimos bytes, así que procesar un archivo de 1.8 GB comienza con un salto a la cola, una lectura de un kilobyte, y luego un salto a donde sea que la tabla de referencias cruzadas indique que vive el catálogo del documento. A partir de ahí, el análisis es un recorrido aleatorio por todo el rango de bytes. Todo aquello en lo que la E/S con búfer es buena, la lectura anticipada secuencial detrás del puntero de archivo, apunta a una carga de trabajo que PDF no tiene

La primera versión de este artículo afirmaba que un archivo mapeado en memoria resuelve el fallo de falta de memoria en 32 bits que TMemoryStream produce con una entrada de 2 GB. Esa afirmación es incorrecta, y la forma en que lo es señala la solución real: una ventana de mapeo deslizante. Lo que sigue es el patrón de acceso, la historia corregida de 32 bits con un mapeador de ventana compilable, y la aritmética de llamadas al sistema sobre un archivo de prueba de 1.8 GB con 300,000 objetos

Por qué el formato de PDF vence a las lecturas con búfer

Tres hechos estructurales moldean el patrón de E/S. Primero, la navegación se basa en desplazamientos: la tabla de referencias cruzadas asigna a cada número de objeto una posición absoluta en bytes, y nada exige que esas posiciones estén ordenadas. Después de años de actualizaciones incrementales, el objeto 4102 puede estar en el desplazamiento 1.6 GB mientras el objeto 4103 está en 30 KB. Un bucle con TFileStream convierte cada búsqueda en un Seek más un Read, dos transiciones de núcleo, con un búfer que no aporta nada porque la siguiente búsqueda está a cientos de megabytes de distancia

Segundo, los flujos de objetos (ISO 32000-1 §7.5.7) empaquetan decenas o cientos de diccionarios pequeños en un solo contenedor comprimido con deflate. Obtener un diccionario de página de 300 bytes puede implicar leer y descomprimir un clúster de 100 KB. La contrapartida: los objetos escritos juntos tienden a leerse juntos, así que un búfer del tamaño del clúster atiende gratis a la siguiente decena de búsquedas, la regularidad más explotable del formato

Tercero, la linealización. Un archivo linealizado adelanta la primera página y una tabla de sugerencias para que los consumidores puedan leerlo de principio a fin. Los archivos de escala de gigabytes casi nunca están linealizados: la linealización se destruye por las mismas actualizaciones incrementales y fusiones que hicieron grande al archivo. Planifique para el caso hostil: saltos largos, sin orden, entrada desde el final

PDF: patrón de acceso de salto en salto del procesamiento PDF a escala de gigabytes, entrando por startxref en la cola del archivo y luego recorriendo offsets dispersos de referencias cruzadas
La navegación PDF entra por la cola y luego salta a donde apunte la tabla de referencias cruzadas, lo que derrota la lectura anticipada secuencial

La historia de 32 bits, corregida

Un proceso de Windows de 32 bits tiene 2 GB de espacio de direcciones de usuario, y MapViewOfFile con un conteo de bytes en cero solicita una reserva contigua del tamaño del archivo. Para una entrada de 2 GB, esa reserva no puede tener éxito: después del EXE, las DLL dispersas y las pilas de hilos, el bloque contiguo libre más grande en un proceso típico de Delphi de 32 bits está entre 700 MB y 1.4 GB. La llamada falla con ERROR_NOT_ENOUGH_MEMORY, el mismo muro contra el que choca TMemoryStream.LoadFromFile, solo que trasladado de la RAM comprometida a la reserva de espacio de direcciones. Un mapeo de archivo completo no es una solución en 32 bits, es el mismo fallo detrás de nombres de API que suenan mejor

La solución consiste en separar las dos cosas que hace un mapeo. CreateFileMapping crea el objeto de sección y no cuesta espacio de direcciones en absoluto, sin importar el tamaño del archivo. Solo MapViewOfFile gasta espacio de direcciones, y nada obliga a mapear toda la sección: recibe un desplazamiento inicial de 64 bits y una longitud de vista. Cree la sección una sola vez, mapee una vista de 64 a 256 MB sobre la región que se está analizando, desmapee antes de deslizarse a la siguiente: el costo de espacio de direcciones es de una ventana, no de un archivo completo. Una restricción: los desplazamientos de la vista deben ser múltiplos de SYSTEM_INFO.dwAllocationGranularity, 64 KB en la práctica, así que una solicitud del desplazamiento 1,000,000 se redondea hacia abajo a 983,040 y el puntero del llamador se ajusta hacia adelante por la diferencia

Un mapeador de ventana deslizante en Delphi

La clase siguiente envuelve toda la disciplina: un objeto de sección, una vista activa, realineación por granularidad, y lecturas que cruzan el límite de una ventana se manejan haciendo crecer esa única vista en lugar de coser dos

uses
  Winapi.Windows, System.SysUtils;

type
  TWindowedFileMapper = class
  private
    FFile: THandle;
    FMapping: THandle;
    FFileSize: Int64;
    FGranularity: DWORD;      // SYSTEM_INFO.dwAllocationGranularity
    FWindowSize: NativeUInt;  // tamaño de vista predeterminado
    FViewBase: PByte;         // base de la vista actual (alineada)
    FViewOffset: Int64;       // desplazamiento de archivo al que corresponde FViewBase
    FViewSize: NativeUInt;    // bytes mapeados en la vista actual
    procedure Unmap;
  public
    constructor Create(const FileName: string;
      WindowSize: NativeUInt = 64 * 1024 * 1024);
    destructor Destroy; override;
    function Map(Offset: Int64; Size: NativeUInt): PByte;
    procedure ReadBytes(Offset: Int64; var Buffer; Count: NativeUInt);
    property FileSize: Int64 read FFileSize;
  end;

constructor TWindowedFileMapper.Create(const FileName: string;
  WindowSize: NativeUInt);
var
  Info: TSystemInfo;
begin
  inherited Create;
  FFile := CreateFile(PChar(FileName), GENERIC_READ, FILE_SHARE_READ, nil,
    OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
  if FFile = INVALID_HANDLE_VALUE then
    RaiseLastOSError;
  if not GetFileSizeEx(FFile, FFileSize) then
    RaiseLastOSError;
  // El objeto de sección no reserva espacio de direcciones, sin importar el tamaño del archivo
  FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
  if FMapping = 0 then
    RaiseLastOSError;
  GetSystemInfo(Info);
  FGranularity := Info.dwAllocationGranularity;  // 64 KB en la práctica
  FWindowSize := WindowSize;
end;

destructor TWindowedFileMapper.Destroy;
begin
  Unmap;
  if FMapping <> 0 then CloseHandle(FMapping);
  if FFile <> INVALID_HANDLE_VALUE then CloseHandle(FFile);
  inherited;
end;

procedure TWindowedFileMapper.Unmap;
begin
  if FViewBase <> nil then
  begin
    UnmapViewOfFile(FViewBase);
    FViewBase := nil;
    FViewSize := 0;
  end;
end;

function TWindowedFileMapper.Map(Offset: Int64; Size: NativeUInt): PByte;
var
  AlignedOffset: Int64;
  Delta, MapSize: NativeUInt;
begin
  if (Offset < 0) or (Offset + Int64(Size) > FFileSize) then
    raise ERangeError.CreateFmt(
      'Map request at %d for %d bytes is outside the file',
      [Offset, Int64(Size)]);

  // Ruta rápida: el rango solicitado ya está dentro de la vista activa
  if (FViewBase <> nil) and (Offset >= FViewOffset) and
     (Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
    Exit(FViewBase + NativeInt(Offset - FViewOffset));

  Unmap;  // deslizar: nunca mantener dos vistas a la vez

  // Las vistas deben comenzar en un límite de granularidad de asignación
  AlignedOffset := Offset - (Offset mod FGranularity);
  Delta := NativeUInt(Offset - AlignedOffset);

  MapSize := FWindowSize;
  if MapSize < Size + Delta then   // la solicitud se extiende más allá del final de la ventana:
    MapSize := Size + Delta;       // hacer crecer esta única vista para cubrirla
  if AlignedOffset + Int64(MapSize) > FFileSize then
    MapSize := NativeUInt(FFileSize - AlignedOffset);  // limitar en EOF

  FViewBase := MapViewOfFile(FMapping, FILE_MAP_READ,
    DWORD(AlignedOffset shr 32), DWORD(AlignedOffset and $FFFFFFFF),
    MapSize);
  if FViewBase = nil then
    RaiseLastOSError;

  FViewOffset := AlignedOffset;
  FViewSize := MapSize;
  Result := FViewBase + NativeInt(Delta);
end;

procedure TWindowedFileMapper.ReadBytes(Offset: Int64; var Buffer;
  Count: NativeUInt);
begin
  Move(Map(Offset, Count)^, Buffer, Count);
end;

Dos detalles cargan con el peso. La ruta rápida al inicio de Map devuelve un puntero sin transición de núcleo cuando el rango solicitado ya está dentro de la vista activa; gracias a la agrupación de los flujos de objetos, este es el caso común y de donde provienen los ahorros. Y una solicitud que se extiende más allá del final de la ventana predeterminada hace crecer MapSize para esa única vista en lugar de coser dos, lo que mantiene a ReadBytes como una sola línea y libera a los llamadores de bucles de lectura parcial

El tamaño de la ventana es un parámetro tolerante: con 64 MB, un barrido completo de un archivo de 1.8 GB son 29 vistas; con 256 MB son 8, pero cada reserva es más difícil de ubicar en un espacio de 32 bits fragmentado; y por debajo de unos 16 MB, los archivos con muchos saltos vuelven a mapear con la frecuencia suficiente como para notarlo. En cualquier punto del rango de 64 a 256 MB, el tráfico de mapeo es ruido estadístico

Contando las llamadas al sistema

Ahora la aritmética. Archivo de prueba: 1.8 GB, 300,000 objetos indirectos con un promedio de unos 600 bytes de carga útil. Un analizador por objeto obtiene cada uno con SetFilePointerEx más un ReadFile de 4 KB: 600,000 transiciones de núcleo. Una llamada al sistema de lectura en caché tiene un tiempo de ida y vuelta de aproximadamente 1.5 μs en hardware x64 actual, así que eso son 600,000 × 1.5 μs ≈ 0.9 segundos de pura sobrecarga de núcleo antes de analizar un solo byte, el mejor caso con caché caliente. En frío, cada salto es una operación de dispositivo: con la latencia efectiva de ~20 μs de las lecturas aleatorias de 4 KB en NVMe, 300,000 de ellas cuestan alrededor de 6 segundos de tiempo de dispositivo; en almacenamiento de clase SATA, minutos

Las lecturas también mueven los datos equivocados: 300,000 × 4 KB empuja 1.2 GB a través de búferes de usuario para entregar apenas unos 180 MB de carga útil, una amplificación de seis veces, con cada byte copiado del núcleo al usuario

Un búfer de lectura anticipada del tamaño de los clústeres de flujos de objetos es la primera mejora honesta: una lectura de 256 KB por clúster en lugar de una por objeto reduce el número de transiciones entre uno y dos órdenes de magnitud. También es la herramienta correcta donde el mapeo resulta incómodo, generalmente en recursos compartidos de red

El mapeador con ventanas va más allá. Un barrido completo son 29 llamadas a MapViewOfFile y 29 a UnmapViewOfFile, 58 transiciones explícitas frente a 600,000. Un análisis real guiado por xref no es un barrido limpio, pero la ruta rápida absorbe cada búsqueda dentro de la ventana activa; un paso de indexación de metadatos sobre el archivo de prueba se estabilizó en unos pocos cientos de remapeos. El mapeo no elimina el trabajo del núcleo: convierte las llamadas al sistema explícitas en fallos de página que el administrador de memoria resuelve en clústeres de varias páginas, directamente desde la caché de archivos sin copia al espacio de usuario, y las regiones nunca tocadas no cuestan nada. De extremo a extremo, el paso de indexación pasó de 23 s en frío y 7.1 s en caliente con lecturas por objeto a 6.5 s en frío y 1.9 s en caliente con el mapeador; lo que queda es la descompresión zlib, no la E/S

PDF: espacio de direcciones de 32 bits fragmentado que rechaza un MapViewOfFile de todo el archivo mientras una sección CreateFileMapping y una ventana de mapeo deslizante de 64 MB tienen éxito
Un objeto section más una vista viva mantienen un PDF de 1.8 GB legible dentro del espacio de direcciones de 2 GB de un proceso Delphi de 32 bits

Dónde encaja FILE_FLAG_NO_BUFFERING

FILE_FLAG_NO_BUFFERING evita la caché del sistema a cambio de reglas estrictas de alineación: desplazamientos, longitudes y direcciones de búfer, todos alineados a sector. Se gana su lugar en trabajos secuenciales de una sola pasada que de otro modo inundarían la caché con bytes que nadie vuelve a leer, como una reserialización por lotes que reescribe todo el archivo, o una pasada de linealización sobre la salida terminada. Con búferes alineados de 4 a 8 MB se acerca al ancho de banda secuencial del dispositivo sin contaminar la caché

Es exactamente lo equivocado para el análisis. Los saltos aleatorios por xref a través de un identificador sin búfer convierten cada búsqueda de un diccionario de 300 bytes en una lectura física completa sin caché que absorba la segunda visita, y el análisis de PDF revisita regiones constantemente, porque distintas páginas resuelven en los mismos flujos de objetos. E/S sin búfer para la reescritura secuencial, E/S mapeada o en caché para el análisis aleatorio; la bandera es por identificador, así que una misma canalización puede sostener ambas sobre el mismo archivo

64 bits, conjuntos de trabajo y el lado de la escritura

En una compilación de 64 bits la objeción del espacio de direcciones desaparece: pase el tamaño del archivo como ventana y la clase anterior degenera en un único mapeo completo. La trampa en servicios de larga duración: las páginas de solo lectura respaldadas por archivo no cargan compromiso de memoria, así que los contadores de compromiso se mantienen tranquilos, pero cada página tocada se une al conjunto de trabajo; analice la mayor parte de 1.8 GB y el conjunto de trabajo crece para igualarlo, desalojando todo lo demás. Las ventanas acotadas ponen un tope a eso, así que el patrón deslizante sigue siendo la opción predeterminada correcta incluso donde el espacio de direcciones es gratuito

En el lado de la escritura, la E/S más barata es la que nunca se emite. El mecanismo de actualización incremental de PDF (ISO 32000-1 §7.5.6) agrega los objetos modificados y una nueva sección de referencias cruzadas después de los bytes originales, que nunca se mueven. Estampar una página sobre el archivo de 1.8 GB agrega decenas de kilobytes; una reescritura completa mueve los 1.8 GB enteros, una diferencia de cinco órdenes de magnitud, y el agregado es salida secuencial pura al final

Dónde encajan las bibliotecas de losLab

Ambas bibliotecas de PDF de losLab ofrecen esta disciplina como superficie de API. La API de archivo directo de HotPDF lee recuentos de páginas y estructura a través de un identificador de archivo sin construir el árbol de objetos, copia y descifra a nivel de archivo, y escribe deltas mediante BeginIncrementalUpdate, la estrategia de solo agregar descrita arriba, ya empaquetada. PDF Library for Delphi sigue la misma ruta con su capa de acceso directo: un lector de flujo que recorre la tabla de referencias cruzadas en el lugar, obtiene objetos de forma perezosa, extrae rangos de páginas de archivo a archivo, y conserva las ediciones como revisiones incrementales. Si usted está escribiendo su propio analizador, la clase de mapeador es suya para tomarla; si está operando una canalización de documentos, deje que la biblioteca mantenga la ventana honesta

Nota: El manejo optimizado de E/S para documentos a escala de gigabytes está integrado directamente en el HotPDF Delphi VCL Component para Delphi y C++Builder

PDF: gráfico de columnas de 600000 syscalls ReadFile por objeto frente a lectura anticipada por clústeres y 58 transiciones del mapeador por ventanas al indexar un PDF de 1.8 GB que contiene 300000 objetos
Las lecturas por objeto queman 600,000 syscalls en el archivo de prueba, mientras que el mapeador con ventana reduce el barrido completo a 58 transiciones