Article technique

Rendu de page PDF parallèle dans Delphi : sécurité des threads

Le rendu de pages PDF en parallèle depuis Delphi se résume à une règle : donner à chaque thread de travail son propre moteur de rendu. La bibliothèque PDF losLab expose RenderPagesToFilesParallel précisément pour cette tâche, répartissant une plage de pages sur un pool TTask avec une instance TPDFlib par travailleur, de sorte qu'une machine multi-cœurs transforme un travail de tramage par lots en un débit proportionnel au nombre de cœurs. Partager une seule instance entre plusieurs threads n'entraîne pas un simple ralentissement progressif, cela corrompt la mémoire et provoque des plantages

C'est l'article auquel vous vous référez lorsqu'une tâche nocturne doit transformer un PDF de 500 pages en 500 images PNG, que la machine dispose de 16 cœurs inactifs, et que votre première tentative sérieuse de multithreading a échoué au sein de GDI+. En résumé, la sécurité des threads est ici une propriété structurelle, et non un indicateur à activer, et la suite de cet article explique pourquoi la structure sécurisée se présente ainsi et où se situe réellement le plafond d'accélération

TPDFlib est-il sécurisé pour les threads lors d'un rendu parallèle ?

Non, et la raison mérite d'être comprise avant de concevoir votre architecture. Une instance TPDFlib unique est déclarée pour un usage monothread, et le point sensible est TPDFPageTree.GetPage : elle écrit dans un champ partagé FPagePointer sur l'instance comme effet secondaire de la sélection d'une page. Deux threads appelant la même instance entrent en concurrence sur ce champ, ainsi le travailleur A peut être à la moitié de la page 3 lorsque le travailleur B redirige l'arborescence de pages vers la page 40. Rien dans l'API ne vous empêche d'écrire le code ci-dessous, et il s'exécutera même sur quelques pages avant de planter, ce qui est la pire façon de se manifester pour un bug de ce type

// DO NOT do this: one shared instance, many threads
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // every thread reenters the same instance -> data race on FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

L'échec n'est pas déterministe, c'est précisément pourquoi il survit à un test rapide et se manifeste sur une machine client dotée d'un nombre de cœurs différent et d'un document plus lourd. Il n'existe pas non plus de verrou simple pour entourer RenderPageToFile, car maintenir un mutex sur l'ensemble de l'appel de rendu sérialise le travail et annule le parallélisme recherché

Pourquoi chaque travailleur de rendu a-t-il besoin de sa propre instance TPDFlib ?

Car l'instance est l'unité d'isolation. Dès lors que chaque travailleur possède sa propre instance privée TPDFlib ayant chargé le fichier de manière indépendante, chacun dispose de son arborescence de pages, de son propre FPagePointer et de son propre état de rendu, évitant ainsi toute concurrence sur des données partagées. Cette sécurité a un prix à évaluer au préalable : chaque travailleur analyse l'intégralité du document en mémoire, de sorte que l'empreinte mémoire maximale correspond environ à N fois le coût d'une instance unique. Huit travailleurs sur un PDF de 300 Mo représentent huit analyses complètes résidant simultanément en mémoire, et sur des volumes très importants, c'est cette contrainte qui détermine le nombre de vos travailleurs, et non le processeur. Lorsque le document est volumineux et que vous êtes limité par la mémoire plutôt que par le processeur, la méthode d'accès direct présentée dans le document sur le traitement des grands PDF sans analyse complète du document constitue souvent un meilleur levier que l'ajout de threads de rendu

L'API en un seul appel : RenderPagesToFilesParallel

La bibliothèque PDF losLab encapsule tout ce modèle sécurisé dans une seule méthode, ce qui vous évite de devoir concevoir la solution vous-même dans la plupart des cas. RenderPagesToFilesParallel prend le nom du fichier et son mot de passe, une résolution PPP (DPI), une plage de pages inclusive de début et de fin, une valeur Options transmise directement au traitement de trame par page, un modèle de sortie où %p is remplacé par le numéro de page, et une limite de travailleurs où toute valeur inférieure ou égale à zéro signifie automatique. Elle renvoie le nombre de pages rendues avec succès, et il s'agit d'un traitement réservé à Windows car il s'appuie sur CoInitialize et GDI+

var
  Pdf: TPDFlib;
  Rendered: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    // FileName, Password, DPI, StartPage, EndPage, Options, Pattern, MaxWorkers
    Rendered := Pdf.RenderPagesToFilesParallel(
      'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
    // MaxWorkers = 0 -> auto: min(page count, CPU cores)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

Pourquoi exécuter CoInitialize sur chaque thread de travail ?

GDI+ est le tramage sous-jacent au rendu des pages, et GDI+ est à thread d'appartement (apartment-threaded) : il s'attend à ce que COM soit initialisé sur le thread qui l'appelle. Le thread principal d'une application VCL possède généralement déjà cette configuration, mais ce n'est pas le cas d'un thread de travail TTask fraîchement créé, et appeler le chemin de rendu depuis un thread non initialisé est un moyen sûr de provoquer un plantage. Ainsi, chaque travailleur associe un CoInitialize(nil) en entrée et un CoUninitialize en sortie, encadrant sa durée de vie complète. Il s'agit de la même rigueur requise pour tout travail GDI+ ou COM en dehors du thread principal, et c'est le second aspect garantissant la viabilité de l'isolation par travailleur, le premier étant l'instance privée. Ce même chemin de tramage GDI+ pilote les moteurs monothreads décrits dans le choix d'un moteur de rendu pour la sortie PDF

Répartition statique par rapport à la récupération dynamique des pages

Le moyen évident de répartir 500 pages entre 8 travailleurs consiste à attribuer à chacun une tranche fixe d'environ 62 pages. La bibliothèque PDF losLab ne procède pas ainsi, pour des raisons d'équilibrage de charge. Le coût de traitement d'une page varie considérablement : une page de texte simple s'affiche en quelques millisecondes, tandis qu'une page de cartes vectorielles denses ou une image numérisée pleine page peut nécessiter cinquante fois plus de temps. Si vous divisez le travail en tranches fixes, le travailleur qui hérite de la tranche la plus lourde continuera à s'exécuter bien après que les autres se seront arrêtés, de sorte que le temps de traitement global dépendra de la tranche la plus défavorable, et non de la moyenne. Au lieu de cela, chaque travailleur récupère la page suivante d'un compteur partagé au sein d'une section critique rapide, en effectue le rendu, puis revient pour une autre, maintenant ainsi chaque cœur actif jusqu'à ce que toute la plage soit traitée

// What each worker does inside the pool (simplified)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ is apartment-threaded
    try
      LocalLib := TPDFlib.Create;   // one private instance per worker
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // claim the next page atomically
          try
            PageNum := NextPage;
            Inc(NextPage);
          finally
            IdxLock.Leave;
          end;
          if PageNum > EndPage then Break;
          LocalLib.RenderPageToFile(DPI, PageNum, 0,
            Format('page_%d.png', [PageNum]));
        end;
      finally
        LocalLib.Free;
      end;
    finally
      CoUninitialize;
    end;
  end;

Journalisation structurée entre les threads de travail

Déboguer un traitement par lots qui échoue à la page 213 sur 500 s'avère fastidieux sans journal, et un journal rudimentaire constitue en soi un problème de concurrence. La bibliothèque PDF losLab intègre TPDFlibLogger, associé via la propriété TPDFlib.Logger et configuré sur nil par défaut afin que le chemin sans journalisation n'engendre aucun surcoût. Il fonctionne en priorité par rappel : vous configurez OnLog et acheminez les enregistrements vers la destination souhaitée par l'application hôte, filtrés selon un niveau llDebug / llInfo / llWarn / llError, et PDFlibErrorMessage convertit les codes numériques bruts en texte explicite pour qu'une erreur contienne davantage qu'un simple entier. Le récepteur de fichier optionnel est la seule ressource partagée, et il est protégé par une TCriticalSection précisément pour que plusieurs travailleurs puissent écrire dans un même fichier journal en toute sécurité. Notez la limite réelle : seul ce récepteur de fichier est synchronisé, de sorte que si vous partagez un journal sur un pool personnalisé et que votre méthode OnLog interagit avec l'interface graphique, vous devez toujours gérer le renvoi vers le thread principal vous-même

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // optional shared sink (lock-guarded)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // marshal to the UI thread yourself; OnLog fires on worker threads
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // nil by default; zero-cost when unset
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // an Error now carries text, e.g. 401 -> "Wrong password or permission denied"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

À quel gain de vitesse devez-vous réellement vous attendre ?

Soyez réaliste quant à la répartition du temps de traitement, car le rendu parallèle n'est avantageux que si le travail dépend réellement du processeur. Une sortie haute résolution (DPI) et des pages contenant des graphiques vectoriels complexes ou des ombrages sont exigeantes en calculs, et leur traitement s'accélère de manière quasi linéaire avec le nombre de cœurs jusqu'à saturation du processeur. Il en va autrement pour les pages simples : dans ce cas, le surcoût de LoadFromFile par travailleur, cumulé au temps d'écriture des fichiers de sortie sur disque, peut l'emporter sur le rendu lui-même, et huit travailleurs sollicitant un disque lent peuvent se révéler plus lents qu'une simple boucle sérielle. Définissez MaxWorkers sur le nombre de vos cœurs physiques plutôt que sur une valeur théorique, surveillez la mémoire lorsque le PDF source est volumineux, et si un traitement s'avère limité par les entrées/sorties (IO-bound), la solution consiste à utiliser un stockage plus rapide ou à réduire le nombre de travailleurs, et non à augmenter le nombre de threads. Employé pour les tâches pour lesquelles il a été conçu, le traitement de rendu par lots présenté ici fait partie de la bibliothèque PDF losLab standard pour Delphi et C++Builder, et il permet de tirer parti des cœurs inactifs sans les écueils liés à la sécurité des threads que vous devriez sinon résoudre vous-même