Article technique

Analyse XLSX en parallèle en Delphi : le goulot d'étranglement du gestionnaire de mémoire

HotXLS, la bibliothèque Excel native pour Delphi et C++Builder, analyse les feuilles de calcul XLSX sur plusieurs threads grâce à un chargement en trois phases : le XML de la feuille est décompressé en série, analysé en parallèle, puis les petites sections sont lues en série par la suite. La première version de cette fonctionnalité n'avait permis de gagner que 12 à 25 %, car le verrouillage du gestionnaire de mémoire par défaut de Delphi sérialisait les threads de travail. La réduction des allocations de tas d'environ 20 à 9,1 par cellule a porté l'accélération parallèle à 1,90x sur huit threads. Cet article détaille les mesures, les fausses pistes et les deux correctifs qui ont réellement fonctionné

HotXLS sépare Open en trois phases, et seule celle du milieu s'exécute sur des threads de travail. La raison en est le conteneur ZIP : une archive ZIP est un flux d'entrée partagé unique avec un automate d'état de décompression unique, qui ne peut pas être lu par deux threads en même temps. Entourer cela d'un verrou (lock) serait inutile, car la décompression est intrinsèquement sérielle par entrée, de sorte qu'un verrou ne ferait que reproduire l'exécution sérielle avec un coût supplémentaire. La phase A décompresse donc le XML de chaque feuille de calcul dans son propre TMemoryStream tout en restant sur un seul thread ; dans notre fichier de référence, cela prenait environ 4 ms pour huit composants de feuille, ce qui est très loin d'être le goulot d'étranglement. La phase B exécute ParseWorksheetXml pour chaque feuille sur un pool de threads, ce qui correspond à la majeure partie du temps de chargement. La phase C revient au ZIP en série pour les petites sections : commentaires, dessins, graphiques et tableaux

Le pool de threads lui-même est volontairement simple. Les threads de travail récupèrent les index des tâches à partir d'un compteur partagé avec InterlockedIncrement, de sorte que les feuilles de tailles différentes s'équilibrent naturellement sans avoir besoin d'un planificateur. Le nombre de threads est égal à min(nombre de feuilles, cœurs du processeur), la première exception levée par un thread est capturée avec AcquireExceptionObject et levée à nouveau sur le thread principal après la jonction, et le répartiteur repasse à une boucle sérielle classique lorsqu'il n'y a qu'une seule tâche ou aucune. Deux propriétés de TXLSXWorkbook contrôlent cette fonctionnalité : ParallelParse active le pool, et ParallelParseThreads limite le nombre de threads, la valeur 0 correspondant à un réglage automatique. Les classeurs multi-feuilles sont la structure qui en profite le plus, y compris ceux générés en dupliquant une feuille de modèle des dizaines de fois

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.ParallelParse := True;      // enable the parallel worker pool
    Book.ParallelParseThreads := 0;  // 0 = auto: min(sheets, CPU cores)
    if Book.Open('quarterly-ledger.xlsx') <= 0 then
      raise Exception.Create('open failed');
    // ... read cells as usual; the workbook is fully materialized ...
  finally
    Book.Free;
  end;
end;

Pourquoi l'ajout de threads ralentit-il l'analyse XLSX en Delphi ?

Parce que le gestionnaire de mémoire par défaut de Delphi protège son tas avec un verrou global, et que l'analyse des feuilles est très dense en allocations : cellules, Variants et WideStrings par millions. Chaque thread de travail qui accède au tas fait la queue sur ce verrou, de sorte que des threads qui semblent indépendants dans le code s'exécutent en réalité presque un par un. Notre premier test a rendu cela douloureusement concret. Sur un classeur de 8 feuilles avec 5 000 lignes par 4 colonnes par feuille, mesuré sur un i5-11600K (6 cœurs, 12 threads) sous Win64, l'appel parallèle à Open ne s'est amélioré que de 12 à 25 % par rapport à une estimation initiale d'au moins 40 %. Un test faisant varier le nombre de threads (2, 3, 4, 6 et 8 threads) a produit une courbe plate, et lors de mesures ultérieures, la configuration à 2 threads était en fait 26 % plus lente que la méthode sérielle — la signature classique de deux threads se renvoyant un verrou disputé

Trois mesures ont permis d'affiner le diagnostic, chacune contredisant l'intuition précédente. Premièrement, un fichier minuscule (8 feuilles de 1 ligne) s'ouvrait en 1,2 ms, prouvant que l'analyse représente pratiquement 100 % du temps d'appel à Open et qu'aucun coût fixe caché n'était en cause. Deuxièmement, un micro-test d'allocations pures a montré que le gestionnaire de mémoire de Delphi passait à l'échelle à l'envers : le même volume total de 2 millions d'allocations d'objets et d'AnsiString s'exécutait 60 % plus lentement sur 8 threads que sur un seul, tandis que le même travail sur le tas WideString (qui utilise l'allocateur COM BSTR et non le gestionnaire de mémoire de Delphi) s'accélérait d'un facteur 3,7x. Le fait que HotXLS utilise des WideString s'est révélé être un heureux hasard de l'histoire. Troisièmement, GetProcessTimes a montré que pendant un appel Open parallèle, le temps processeur était à peu près égal au temps réel : huit threads nominaux consommaient l'équivalent de 1,3 thread de CPU. Les threads de travail ne tournaient pas à vide ; ils étaient en veille dans la file d'attente du gestionnaire de mémoire, bloqués plutôt qu'actifs

La leçon pratique dépasse le cadre des feuilles de calcul. Si une charge de travail en Delphi réalise de nombreuses allocations, augmenter le nombre de threads ne sert à rien tant que le taux d'allocations ne diminue pas, et cela peut même aggraver les choses. Avant ce correctif, nous disions aux utilisateurs réglant ParallelParseThreads la vérité : sur des fichiers limités par les allocations, plus de threads n'apportaient pratiquement rien

D'où proviennent les 20 allocations de tas par cellule ?

Une structure de comptage installée via SetMemoryManager a répondu précisément à cette question : environ 20 allocations du gestionnaire de mémoire de Delphi par cellule, dont 2,87 millions de 32 octets ou moins. Le coupable n'était pas du tout les objets cellules eux-mêmes. TXMLScaner.GetTokenValue créait une nouvelle chaîne AnsiString à chaque appel, et il est appelé environ 15 à 20 fois par cellule : pour le nom des éléments, le nom des attributs, la valeur des attributs et le contenu textuel. De plus, la fonction UTF8ToWideString de la bibliothèque d'exécution (RTL) générait une chaîne intermédiaire temporaire UnicodeString pour chaque conversion. Les objets cellules ne représentaient que 160 000 allocations, soit environ 8 % du total, ce qui a instantanément invalidé notre projet initial : nous envisagions de créer un pool d'objets cellules, mais les chiffres montraient que cela ne serait jamais rentable

var
  OldMM, NewMM: TMemoryManagerEx;
  AllocCount, TinyCount: Int64;

function CountingGetMem(Size: NativeInt): Pointer;
begin
  AtomicIncrement(AllocCount);
  if Size <= 32 then
    AtomicIncrement(TinyCount);   // the small-object churn we care about
  Result := OldMM.GetMem(Size);
end;

// install before Open, restore afterwards
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);

Ce diagnostic de dix minutes mérite d'être repris pour toute étude de performance en Delphi. Compter les allocations par taille ne coûte presque rien à mettre en place et vous indique où s'exerce réellement la pression sur le gestionnaire de mémoire, ce qui, dans notre cas, provenait de deux habitudes au niveau de la RTL au sein du lecteur XML plutôt que du modèle d'objet. Les profileurs continuaient de désigner l'analyseur dans son ensemble ; notre outil de comptage a ciblé deux lignes précises

Le correctif : la mise en cache des jetons et un décodeur UTF-8 sans intermédiaire

Deux modifications ciblées dans le lecteur XML ont supprimé plus de la moitié des allocations par cellule sans toucher à la structure de l'analyseur. La première est la mise en cache des noms d'éléments (interning). Le XML des feuilles de calcul répète indéfiniment un vocabulaire restreint : row, c, v, r, t, s et quelques noms d'attributs. InternTokenName conserve un cache à 64 entrées des noms déjà rencontrés et compare le tampon du lecteur avec une entrée du cache via TokenEqualsAnsi, une comparaison directe d'octets qui ne réalise aucune allocation. En cas de correspondance, la fonction renvoie l'AnsiString du cache, et le choix du type est capital : les AnsiString gèrent un compteur de références, donc renvoyer une instance du cache ne coûte qu'une incrémentation de référence sans aucune allocation sur le tas. Les WideString n'ont pas de compteur de références, et chaque affectation appelle SysAllocString, de sorte que la mise en cache de WideStrings n'apporterait rien. Cette mise en cache n'a de sens que sur les types de chaînes gérant les références

function TXMLScaner.InternTokenName: AnsiString;
var
  Slot: Integer;
begin
  Slot := TokenHash mod 64;
  if TokenEqualsAnsi(FInternNames[Slot]) then
    Result := FInternNames[Slot]    // refcount++ only, no allocation
  else
  begin
    Result := GetTokenValue;        // materialize once, then cache
    FInternNames[Slot] := Result;
  end;
end;

La deuxième modification concerne le texte des cellules. L'ancien parcours créait un jeton AnsiString, le transmettait à UTF8ToWideString, qui créait une chaîne intermédiaire UnicodeString, convertie ensuite en la WideString stockée par la cellule : soit deux allocations du gestionnaire de mémoire de Delphi par jeton de texte avant le stockage final. Le code de remplacement, XmlUtf8ToWide(TokenPtr, TokenLen), is a two-pass pure Pascal UTF-8 decoder that reads straight from the scan buffer: pass one measures the UTF-16 length, pass two decodes into a WideString allocated once. Coût net par jeton de texte : une allocation COM, aucune allocation du gestionnaire de mémoire de Delphi. Remarque sémantique importante : sur des séquences UTF-8 incorrectes, le nouveau décodeur transmet les octets tels quels au lieu de substituer des caractères de remplacement comme le fait la RTL, ce qui n'affecte que le traitement des fichiers corrompus ; sur une entrée valide, la sortie est identique à l'octet près. Les entités de caractères XML n'atteignent jamais le décodeur, car le lecteur les a déjà résolues en UTF-8 dans le tampon

Quel a été le gain, et dans quels cas l'analyse en parallèle reste inutile

Ces deux correctifs ont réduit les allocations par cellule d'environ 20 à 9,1, et les résultats parallèles ont évolué conformément à la théorie. Sur le même benchmark de 8 feuilles et 5 000 lignes et avec la même machine à 6 cœurs et 12 threads, le gain sur 8 threads est passé de 14 % à 47,4 %, soit une accélération de 1,90x par rapport à la méthode sérielle. Le cas à 2 threads est passé de 26 % plus lent à 23,6 % plus rapide, et l'utilisation mesurée du processeur est passée d'un facteur 1,0x à 2,2x. Le parcours sériel a gagné avant tout environ 3 % de performances en prime, la réduction des allocations profitant aussi à un thread unique. Les quelque 9 allocations restantes par cellule correspondent à peu près pour moitié aux objets cellules et pour moitié à la croissance amortie du conteneur ; nous les avons mesurées, avons estimé que les gains seraient marginaux et nous nous sommes arrêtés là, notre wrapper de gestion de mémoire restant prêt à être activé si une charge de travail future justifiait une nouvelle analyse

Les limites méritent d'être énoncées aussi clairement que les réussites. HotXLS parallélise à l'échelle de la feuille de calcul, ainsi un classeur constitué d'une seule feuille géante s'analysera sur un seul thread, peu importe le réglage de ParallelParseThreads ; pour cette configuration, le lecteur direct en flux est un meilleur outil, car il évite complètement de matérialiser le classeur. Les fichiers dont le temps de traitement est consommé par les composants de la phase C (dessins, graphiques, commentaires) tirent moins de profit du parallélisme car cette phase reste sérielle par conception. Les petits fichiers ne justifient pas du tout l'utilisation de threads, c'est pourquoi le répartiteur repasse silencieusement en mode sériel pour les petits volumes. Enfin, la limite imposée par le gestionnaire de mémoire n'a pas disparu, elle a seulement reculé : à 9,1 allocations par cellule, le verrou global sollicite encore les threads de travail, expliquant pourquoi huit threads n'apportent qu'un gain de 1,90x au lieu de 4x. Pour un ensemble d'outils plus vaste permettant de réduire les temps de chargement et d'enregistrement, y compris les styles, les pools et les rappels de lignes en masse, consultez notre guide sur les performances des grands classeurs en Delphi

L'analyse XLSX en parallèle, les propriétés ParallelParse et ParallelParseThreads ainsi que le lecteur XML économe en allocations présentés ici sont intégrés au composant HotXLS Delphi Excel Component, qui lit et écrit les formats XLS, XLSX et ODS de façon native sous Delphi et C++Builder, sans nécessiter d'automatisation d'Excel