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
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
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