La première lecture utile d'un analyseur (parser) PDF se trouve du mauvais côté du fichier. Le format place le pointeur startxref dans les derniers octets, de sorte que le traitement d'une archive de 1,8 Go commence par une recherche (seek) vers la queue (tail), une lecture d'un kilo-octet, puis un saut vers l'endroit où la table de références croisées indique que se trouve le catalogue de documents. À partir de là, l'analyse est une marche aléatoire (random walk) sur toute la plage d'octets. Tout ce à quoi les E/S mises en mémoire tampon (buffered IO) sont bonnes — lecture anticipée (read-ahead) séquentielle derrière le pointeur de fichier — vise une charge de travail que le PDF n'a pas
La première version de cet article affirmait qu'un fichier mappé en mémoire (memory-mapped file) résout la défaillance de mémoire insuffisante (out-of-memory failure) 32 bits que TMemoryStream rencontre sur une entrée de 2 Go. Cette affirmation est fausse, et la façon dont elle est fausse pointe vers la vraie solution : une fenêtre de mappage glissante (sliding mapping window). Ce qui suit est le modèle d'accès (access pattern), l'histoire corrigée du 32 bits avec un mappeur fenêtré compilable, et l'arithmétique des appels système (syscall arithmetic) sur un fichier de test de 1,8 Go contenant 300 000 objets
Pourquoi la disposition (layout) PDF met en échec les lectures mises en mémoire tampon
Trois faits structurels façonnent le modèle d'E/S (IO pattern). Premièrement, la navigation est pilotée par le décalage (offset-driven) : la table de références croisées mappe chaque numéro d'objet à une position d'octet absolue, et rien n'exige que ces positions soient ordonnées. Après des années de mises à jour incrémentielles, l'objet 4102 peut se trouver à un décalage de 1,6 Go tandis que l'objet 4103 se trouve à 30 Ko. Une boucle TFileStream transforme chaque récupération en un Seek plus un Read, deux transitions du noyau (kernel transitions), avec un tampon qui ne contribue en rien car la récupération suivante se trouve à des centaines de mégaoctets
Deuxièmement, les flux d'objets (object streams) (ISO 32000-1 §7.5.7) emballent des dizaines ou des centaines de petits dictionnaires dans un seul conteneur dégonflé (deflated container). La récupération d'un dictionnaire de page de 300 octets peut signifier la lecture et le gonflage d'un cluster de 100 Ko. L'autre facette : les objets écrits ensemble ont tendance à être lus ensemble, de sorte qu'un tampon dimensionné au cluster sert la douzaine de récupérations suivantes gratuitement — la régularité la plus exploitable du format
Troisièmement, la linéarisation. Un fichier linéarisé charge en premier (front-loads) la première page et une table d'indications (hint table) afin que les consommateurs puissent le lire d'avant en arrière (front to back). Les archives d'un gigaoctet ne sont presque jamais linéarisées : la linéarisation est détruite par les mêmes mises à jour incrémentielles et fusions qui ont rendu le fichier volumineux. Prévoyez le cas hostile : sauts longs (long hops), pas d'ordre, entrée par la queue d'abord (tail-first entry)
L'histoire du 32 bits, corrigée
Un processus Windows 32 bits dispose de 2 Go d'espace d'adressage utilisateur, et MapViewOfFile avec un nombre d'octets de zéro demande une réservation contiguë de la taille du fichier. Pour une entrée de 2 Go, cette réservation ne peut pas réussir : après l'EXE, les DLL dispersées et les piles de threads (thread stacks), le plus grand bloc contigu libre dans un processus Delphi 32 bits typique se situe quelque part entre 700 Mo et 1,4 Go. L'appel échoue avec ERROR_NOT_ENOUGH_MEMORY, le même mur que TMemoryStream.LoadFromFile heurte, simplement déplacé de la RAM validée (committed RAM) à la réservation d'espace d'adressage. Un mappage de fichier complet (full-file mapping) n'est pas une solution en 32 bits, juste la même défaillance derrière des noms d'API qui sonnent mieux
La solution consiste à séparer les deux choses qu'un mappage fait. CreateFileMapping crée l'objet de section et ne coûte aucun espace d'adressage du tout, quelle que soit la taille du fichier. Seul MapViewOfFile dépense de l'espace d'adressage, et rien ne l'oblige à mapper la section entière : il prend un décalage de départ de 64 bits et une longueur de vue (view length). Créez la section une fois, mappez une vue de 64 à 256 Mo sur la région en cours d'analyse, démappez avant de glisser (sliding on) : le coût de l'espace d'adressage est d'une fenêtre, pas d'un fichier. Une contrainte : les décalages de vue doivent être des multiples de SYSTEM_INFO.dwAllocationGranularity, 64 Ko en pratique, de sorte qu'une requête pour le décalage 1 000 000 est arrondie à la baisse (rounded down) à 983 040 et le pointeur de l'appelant ajusté vers l'avant de la différence
Un mappeur à fenêtre glissante dans Delphi
La classe ci-dessous enveloppe (wraps) l'ensemble de la discipline : un objet de section, une vue en direct (live view), un réalignement de granularité, et les lectures qui traversent une limite de fenêtre gérées en agrandissant (growing) cette seule vue au lieu d'en coudre (stitching) deux
uses
Winapi.Windows, System.SysUtils;
type
TWindowedFileMapper = class
private
FFile: THandle;
FMapping: THandle;
FFileSize: Int64;
FGranularity: DWORD; // SYSTEM_INFO.dwAllocationGranularity
FWindowSize: NativeUInt; // default view size
FViewBase: PByte; // base of the current view (aligned)
FViewOffset: Int64; // file offset FViewBase corresponds to
FViewSize: NativeUInt; // bytes mapped in the current view
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;
// The section object reserves no address space, whatever the file size
FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
if FMapping = 0 then
RaiseLastOSError;
GetSystemInfo(Info);
FGranularity := Info.dwAllocationGranularity; // 64 KB in practice
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)]);
// Fast path: the requested range already sits inside the live view
if (FViewBase <> nil) and (Offset >= FViewOffset) and
(Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
Exit(FViewBase + NativeInt(Offset - FViewOffset));
Unmap; // slide: never hold two views at once
// Views must start on an allocation-granularity boundary
AlignedOffset := Offset - (Offset mod FGranularity);
Delta := NativeUInt(Offset - AlignedOffset);
MapSize := FWindowSize;
if MapSize < Size + Delta then // request straddles the window end:
MapSize := Size + Delta; // grow this one view to cover it
if AlignedOffset + Int64(MapSize) > FFileSize then
MapSize := NativeUInt(FFileSize - AlignedOffset); // clamp at 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;
Deux détails portent le poids. Le chemin rapide (fast path) en haut de Map renvoie un pointeur sans transition de noyau lorsque la plage demandée se trouve déjà à l'intérieur de la vue en direct ; grâce au regroupement de flux d'objets (object-stream clustering), c'est le cas courant et c'est de là que proviennent les économies. Et une demande qui chevauche la fin de la fenêtre par défaut augmente MapSize pour cette seule vue plutôt que d'en coudre deux, ce qui garde ReadBytes une seule ligne (one-liner) et libère les appelants des boucles de lecture partielle
La taille de la fenêtre est un bouton indulgent (forgiving knob) : à 64 Mo, un balayage complet (full sweep) d'un fichier de 1,8 Go est de 29 vues, à 256 Mo, il est de 8 mais chaque réservation est plus difficile à placer dans un espace 32 bits fragmenté, et en dessous d'environ 16 Mo, les fichiers lourds en sauts (hop-heavy files) sont remappés assez souvent pour être remarqués. N'importe où dans la plage de 64 à 256 Mo, le trafic de mappage est un bruit statistique
Comptage des appels système (syscalls)
Maintenant l'arithmétique. Fichier de test : 1,8 Go, 300 000 objets indirects (indirect objects) d'une moyenne d'environ 600 octets de charge utile. Un analyseur par objet (per-object parser) récupère chacun d'eux avec SetFilePointerEx plus un ReadFile de 4 Ko : 600 000 transitions de noyau. Un appel système de lecture en cache fait un aller-retour en environ 1,5 μs sur le matériel x64 actuel, c'est-à-dire 600 000 × 1,5 μs ≈ 0,9 seconde de surcharge pure du noyau avant l'analyse d'un seul octet — le meilleur cas avec cache chaud (warm-cache). Froid, chaque saut est une opération de périphérique : à la latence effective de ~20 μs des lectures aléatoires (random reads) NVMe de 4 Ko, 300 000 d'entre elles coûtent environ 6 secondes de temps de périphérique ; sur un stockage de classe SATA, des minutes
Les lectures déplacent également les mauvaises données : 300 000 × 4 Ko poussent 1,2 Go à travers les tampons utilisateur pour livrer environ 180 Mo de charge utile — amplification par six, chaque octet étant copié du noyau vers l'utilisateur
Un tampon de lecture anticipée (read-ahead buffer) dimensionné aux clusters de flux d'objets est la première amélioration honnête : une lecture de 256 Ko par cluster au lieu d'une par objet réduit le nombre de transitions d'un à deux ordres de grandeur. C'est également le bon outil là où le mappage est maladroit, généralement les partages réseau (network shares)
Le mappeur fenêtré (windowed mapper) va plus loin. Un balayage complet (full sweep) représente 29 appels MapViewOfFile et 29 UnmapViewOfFile, 58 transitions explicites contre 600 000. Une véritable analyse pilotée par xref n'est pas un balayage propre (clean sweep), mais le chemin rapide absorbe chaque récupération à l'intérieur de la fenêtre en direct ; une passe d'indexation de métadonnées sur l'archive de test s'est stabilisée à quelques centaines de remappages. Le mappage ne supprime pas le travail du noyau : il convertit les appels système explicites en défauts de page (page faults) que le gestionnaire de mémoire (memory manager) résout dans des clusters de plusieurs pages, directement à partir du cache de fichiers (file cache) sans copie dans l'espace utilisateur, et les régions jamais touchées ne coûtent rien. De bout en bout, la passe d'indexation est passée de 23 s à froid et 7,1 s à chaud avec des lectures par objet à 6,5 s à froid et 1,9 s à chaud avec le mappeur ; ce qui reste est le gonflage (inflate) zlib, pas les E/S
Où FILE_FLAG_NO_BUFFERING s'intègre
FILE_FLAG_NO_BUFFERING contourne (bypasses) le cache système en échange de règles d'alignement strictes : décalages, longueurs et adresses de tampon tous alignés sur le secteur. Il gagne sa vie sur les travaux séquentiels en un seul passage (single-pass sequential jobs) qui inonderaient autrement le cache d'octets que personne ne lit deux fois — une resérialisation par lots qui réécrit toute l'archive, ou une passe de linéarisation sur une sortie terminée. Avec des tampons alignés de 4 à 8 Mo, il s'approche de la bande passante séquentielle du périphérique (device sequential bandwidth) sans polluer le cache
C'est exactement faux pour l'analyse. Des sauts (hops) xref aléatoires à travers une poignée non mise en mémoire tampon (unbuffered handle) transforment chaque récupération de dictionnaire de 300 octets en une lecture physique complète sans cache pour absorber la deuxième visite — et l'analyse PDF revisite constamment des régions, car différentes pages se résolvent dans les mêmes flux d'objets. E/S non mises en mémoire tampon pour la réécriture séquentielle, E/S mappées ou mises en cache pour l'analyse aléatoire ; le drapeau (flag) est par poignée (per-handle), de sorte qu'un pipeline peut contenir les deux sur le même fichier
64 bits, ensembles de travail (working sets) et le côté écriture
Sur une version (build) 64 bits, l'objection de l'espace d'adressage disparaît : passez la taille du fichier comme fenêtre et la classe ci-dessus dégénère en un seul mappage complet. Le piège dans les services à exécution longue (long-running services) : les pages adossées à un fichier (file-backed pages) en lecture seule ne facturent aucun engagement (commit), de sorte que les compteurs d'engagement restent calmes, mais chaque page touchée rejoint l'ensemble de travail (working set) ; analysez la majeure partie de 1,8 Go et l'ensemble de travail s'agrandit pour correspondre, expulsant tout le reste. Les fenêtres limitées (Bounded windows) fixent un plafond à cela, de sorte que le modèle glissant reste la bonne valeur par défaut même lorsque l'espace d'adressage est libre
Du côté de l'écriture, les E/S les moins chères sont les E/S jamais émises. Le mécanisme de mise à jour incrémentielle de PDF (ISO 32000-1 §7.5.6) ajoute (appends) les objets modifiés et une nouvelle section de références croisées après les octets d'origine, qui ne bougent jamais. L'estampillage d'une page sur l'archive de 1,8 Go ajoute des dizaines de kilo-octets ; une réécriture complète déplace l'ensemble des 1,8 Go, cinq ordres de grandeur d'écart, et l'ajout est une sortie séquentielle pure à la queue
Où s'intègrent les bibliothèques losLab
Les deux bibliothèques PDF losLab livrent cette discipline sous forme de surface d'API. L'API de fichier direct HotPDF lit le nombre de pages et la structure via une poignée de fichier (file handle) sans construire l'arborescence d'objets, copie et déchiffre au niveau du fichier et écrit les deltas via BeginIncrementalUpdate — la stratégie d'ajout uniquement (append-only) ci-dessus, packagée. PDFlibPas emprunte la même voie avec sa couche Direct Access : un lecteur de flux (streaming reader) qui parcourt la table de références croisées en place, récupère les objets paresseusement (lazily), extrait des plages de pages de fichier à fichier et conserve les modifications (persists edits) en tant que révisions incrémentielles. Si vous écrivez votre propre analyseur, la classe de mappeur est à vous ; si vous exécutez un pipeline de documents, laissez la bibliothèque garder la fenêtre honnête (honest)
Remarque : La gestion optimisée des E/S pour les documents à l'échelle du gigaoctet est intégrée directement dans le Composant HotPDF VCL pour Delphi et C++Builder