Article technique

Stockage Blocs de Lignes et Sauvegarde Streaming en HotXLS

Dans SpreadsheetML, une colonne qui porte un format monétaire n'exige qu'exactement un élément : <col min="2" max="2" style="3"/>. ECMA-376 Part 1 attache un style de cellule en trois endroits seulement : une colonne (§18.3.1.13), une ligne (§18.3.1.73) et une cellule (§18.3.1.4). Restreignez le format à B2:D500000 et SpreadsheetML n'a aucun élément unique pour cette forme : un style de colonne courrait au-delà de la ligne 500000 et un style de ligne couvrirait chaque colonne, si bien qu'un rédacteur finit par émettre des cellules de style seul, 1 499 997 d'elles

Un modèle de feuille de calcul en mémoire bute sur le même mur une étape plus tôt. Si formater signifie poser une propriété sur chaque objet cellule, un clic sur un en-tête de colonne alloue un million d'objets pour porter un petit entier, et un processus Delphi qui ne voulait qu'un format de nombre manque d'espace d'adressage sur un classeur qui fait quelques kilooctets sur disque. La vraie question de design est de savoir où vit le formatage pendant l'édition, et quand il devient des données par cellule

Comment HotXLS formate une plage sans créer de cellules

HotXLS garde le formatage de plage sous forme d'intervalles. Chaque poseur de style sur TXLSXRange (SetNumberFormat, SetNumberFormatIndex, SetFontIndex, SetFillIndex, SetBorderIndex, SetAlignmentIndex) ajoute un enregistrement à la liste StyleOverlays de la feuille : quatre coordonnées, les parties de style que l'appel possède, et les index de pool pour ces parties. Les cellules qui existent déjà dans le rectangle ramassent les nouvelles parties sur-le-champ. Les coordonnées vides restent vides, et rien n'est alloué pour elles

var
  Workbook: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Edge: TXLSXBorder;
  BorderIdx, Row: Integer;
  Stats: TXLSXCellStorageStatistics;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    Sheet := Workbook.Sheets.Add('Ledger');
    Sheet.WriteStringRowValues(1, 2, ['Net', 'Tax', 'Gross']);
    for Row := 2 to 1001 do
      Sheet.WriteFloatRowValues(Row, 2, [Row * 1.5, Row * 0.3, Row * 1.8]);

    // Colonnes B..D sur les 1 048 576 lignes : un intervalle par appel
    Sheet.RCRange[1, 2, XlsxMaxRow, 4].SetNumberFormat('#,##0.00 "EUR"');

    // Bâtir la bordure une fois, puis l'appliquer par index
    Edge := TXLSXBorder.Create;
    try
      Edge.SetAll(xlsxBorderThin);
      BorderIdx := Workbook.Borders.LookupOrAdd(Edge); // démarre à 1
    finally
      Edge.Free;
    end;
    Sheet.RCRange[1, 2, XlsxMaxRow, 4].SetBorderIndex(BorderIdx);

    Stats := Sheet.Cells.GetStorageStatistics;
    // Attendre 3 003 : trois cellules d'en-tête plus 3 000 valeurs
    WriteLn('Cells: ', Stats.CellCount);
    if Workbook.SaveAs('ledger.xlsx') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Workbook.Free;
  end;
end;

Les deux appels de formatage ont couvert 3 145 728 coordonnées et n'ont rien ajouté à ce compte. Les intervalles démarrent à la ligne 1 à dessein : un intervalle pleine colonne se sauve en un seul élément <col>, et les en-têtes de texte de la ligne 1 ne sont pas affectés par un format de nombre de toute façon. Les plages renvoyées par RCRange, Range['A1:C5'], EntireColumn et EntireRow sont possédées par la feuille, donc l'exemple ne les libère jamais

TXLSXFormatParts est ce qui empêche plusieurs intervalles de se battre. Ses membres sont xfpFont, xfpFill, xfpBorder, xfpNumberFormat, xfpAlignment, xfpProtection et xfpCellControl, et chaque poseur de TXLSXRange revendique exactement l'un d'entre eux. Quand une cellule est résolue, les intervalles qui la couvrent sont appliqués dans l'ordre où ils ont été ajoutés, chacun n'écrivant que ses propres parties, et le formatage explicite de la cellule elle-même l'emporte sur tous. Un intervalle de colonne qui pose le format de nombre et un intervalle de ligne qui pose un remplissage atteignent tous deux la cellule B5 intacts

Quand un enregistrement doit porter plusieurs parties, Sheet.StyleOverlays.Add(Row1, Col1, Row2, Col2, Parts, State) prend un TXLSXCellStyleState avec les index de pool remplis. Add borne les coordonnées à la grille de 1 048 576 × 16 384, échange les coins inversés, et renvoie nil sans rien enregistrer quand Parts est vide. Les éditions structurelles gardent les enregistrements en phase : InsertRows à l'intérieur d'un bloc formaté élargit l'intervalle du compte inséré, et insérer au-dessus décale tout l'enregistrement vers le bas

Diagramme de superposition de styles par intervalles pour HotXLS montrant une plage de formatage de colonne stockée comme un seul enregistrement avec des parties de format revendiquées au lieu d'un million d'objets de cellules vides en Delphi
Un format de colonne, de ligne ou de rectangle vit une fois comme enregistrement de superposition d'intervalle ne revendiquant que les parties qu'il possède. Le style effectif s'assemble paresseusement la première fois qu'une cellule à l'intérieur de la plage est réellement touchée

Pourquoi SetBorders crée-t-il des cellules quand SetBorderIndex n'en crée pas ?

TXLSXRange.SetBorders(Kind, Style, Color) doit regarder chaque cellule pour son compte, car la réponse dépend de la position. Avec xlsxEdgeOutline, la ligne du haut reçoit un bord haut, la colonne de gauche un bord gauche, et l'intérieur rien ; avec xlsxEdgeInside c'est à peu près l'inverse. L'appel fusionne aussi les nouveaux bords dans la bordure que la cellule a déjà, donc Outline suivi de Inside bâtit une grille complète. Cette composition par cellule passe par Cells[R, C], qui crée une cellule quand il n'en existe pas. Sur un bloc de rapport de 10 × 40, c'est exactement ce qu'il faut. Sur EntireColumn(3) avec xlsxEdgeAll, ce sont 1 048 576 nouveaux objets cellule

La scission est donc simple. Pour tout ce qui dépasse un écran de données, bâtissez la bordure une fois et appliquez-la par index, comme le fait le premier exemple : TXLSXBorder.SetAll, puis Workbook.Borders.LookupOrAdd pour un index à base 1, puis SetBorderIndex. Gardez SetBorders pour les blocs dont le contour et les lignes internes diffèrent, comme le bloc de rapport de 10 × 40 ci-dessus

Relire les formats sans défaire le travail

Le même piège attrape le code qui ne fait que lire. Cells[R, C] est un accesseur à création au taux manqué : demandez-lui le format de nombre de B500000 et vous obtenez la bonne réponse plus une nouvelle cellule vide stylée en B500000 qui sera désormais stockée et sauvegardée. Bouclez cela sur une colonne pour auditer le formatage et vous avez rebâti le million d'objets que l'intervalle voulait éviter, une lecture innocente à la fois

TXLSXWorksheet.GetCellVisibleFormat résout le style effectif d'une coordonnée sans ajouter de cellule. Il fusionne le défaut de la feuille, les intervalles de ligne et colonne, les intervalles de rectangle, les styles de tableaux et tout formatage explicite de la cellule. La surcharge avec un paramètre out ABuiltinNumberFormat rapporte un format Excel intégré comme 14 (date courte) séparément de NumberFormatIndex, qui pointe dans le pool personnalisé NumberFormats du classeur. Cells.HasCell(R, C) répond à la question de savoir si une cellule physique existe du tout

var
  Sheet: TXLSXWorksheet;
  Before, After: TXLSXCellStorageStatistics;
  State: TXLSXCellStyleState;
  Row, Builtin, Unformatted, Physical: Integer;
begin
  Sheet := Workbook.Sheets[1];  // Sheets démarre à 1
  Before := Sheet.Cells.GetStorageStatistics;
  Unformatted := 0;
  Physical := 0;
  for Row := 1 to 200000 do
  begin
    // Style effectif, résolu sans ajouter de cellule à la feuille
    State := Sheet.GetCellVisibleFormat(Row, 2, Builtin);
    if (State.NumberFormatIndex = 0) and (Builtin = 0) then
      Inc(Unformatted);
    if Sheet.Cells.HasCell(Row, 2) then
      Inc(Physical);
    // Sheet.Cells[Row, 2].NumberFormatIndex répond à la même question,
    // mais laisse un nouvel objet cellule derrière pour chaque ligne visitée
  end;
  After := Sheet.Cells.GetStorageStatistics;
  // Les deux comptes devraient être identiques avant et après la boucle
  WriteLn(Format('cells %d -> %d, styled blanks %d -> %d',
    [Before.CellCount, After.CellCount,
     Before.StyledBlankCellCount, After.StyledBlankCellCount]));
end;

Lancez un contrôle de ce genre une fois contre votre vraie charge. StyledBlankCellCount compte les cellules qui ont un style et ni valeur, ni formule, ni texte riche. S'il grimpe pendant une passe censée être en lecture seule, quelque aideur crée des cellules uniquement pour porter du style, typiquement en appelant Cells[R, C] pour décider si une cellule a besoin de formatage

Ce que les blocs de 256 lignes signifient pour votre code

Les cellules physiques vivent en blocs de 256 lignes. À l'intérieur d'un bloc, chaque ligne peuplée garde ses cellules dans une liste triée par colonne ; une ligne sans cellules n'est qu'un emplacement vide, et une étendue de 256 lignes sans cellules n'a aucun bloc du tout. Une feuille creuse avec des données aux lignes 3, 7 et 512 tient donc deux blocs. La sauvegarde parcourt la même structure en ordre ligne-majeure, l'ordre que SpreadsheetML exige pour les éléments <row> et leurs enfants <c>

Diagramme de stockage par blocs de lignes montrant les cellules de feuille HotXLS conservées en blocs fixes de 256 lignes avec des handles Delphi stables et une sérialisation ligne-majeure dans le flux deflate XLSX
Les cellules siègent dans des blocs fixes de 256 lignes qui n'allouent que les lignes occupées et se sérialisent dans le même ordre que celui où les octets quittent le processus. Les handles publics survivent à la réorganisation interne, si bien que la disposition des blocs reste invisible aux appelants

La référence TXLSXCell que vous tenez reste le même objet tandis que d'autres cellules sont ajoutées ou retirées autour ; les blocs réorganisent leur propre comptabilité, pas vos handles. Les rédacteurs en masse comme WriteFloatRowValues et WriteStringRowValues empruntent une voie plus rapide sur une feuille neuve et annexent des valeurs ligne-majeures dans un magasin compacté. Le premier accès par cellule via Cells[R, C] convertit ce magasin en objets cellule une seule fois. GetStorageStatistics montre l'état dans lequel vous êtes via PackedStorageActive et PackedCellCount, à côté de RowBlockCount et PopulatedRowCount

Les pools de styles sont rognés à la sauvegarde. Avant que styles.xml ne soit écrit, HotXLS rebâtit les pools de polices, remplissages, bordures, formats de nombre, alignements et protections pour ne tenir que les entrées encore en usage, et un classeur qu'un utilisateur a restylé pendant une heure ne livre pas chaque remplissage abandonné. Les cellules épinglées à un FormatIndex absolu gardent leur emplacement intact

Sauvegardes en flux par lignes et chiffres qui le montrent

TXLSXWorkbook.StreamingWrite vaut True par défaut. Avec lui actif, SaveAs génère chaque <row> de feuille et l'écrit droit dans le flux deflate de son entrée ZIP, si bien que le plus grand morceau de XML de feuille en mémoire à tout instant est une seule ligne. Avec lui inactif, le XML de chaque feuille est d'abord bâti en une chaîne complète puis compressé, et la mémoire de pointe suit la plus grande feuille. Les cellules de style seul que les intervalles de rectangle deviennent, le cas B2:D500000 du début, sont générées dans cette boucle par ligne aussi, donc elles ajoutent au fichier sans jamais siéger en mémoire comme un bloc de XML

Les chaînes partagées et les enregistrements de cache de tableaux croisés suivent le même motif. Chaque entrée <si> ou <r> est générée et écrite pour son compte, donc sur un classeur analytique large, la table de chaînes partagées, souvent plus grosse que toute feuille, ne doit jamais exister comme un bloc de XML

Diagramme StreamingWrite comparant le streaming deflate ligne par ligne dans HotXLS à l'assemblage de tout le XML de feuille en mémoire avant compression en Delphi
StreamingWrite activé, chaque ligne coule droit dans le flux deflate du paquet et la mémoire de pointe suit une seule ligne. Désactiver l'indicateur assemble d'abord tout le XML de la feuille, ce qui fait croître la mémoire de pointe avec la feuille entière

Rien de tout cela ne se prend sur parole. Après chaque sauvegarde, le classeur expose quatre propriétés en lecture seule : LastSaveUsedStreaming, LastSavePeakRowXmlChars (la plus longue ligne unique, en caractères), LastSaveBufferedWorksheetXmlChars (le total de XML de feuille tenu en mémoire, zéro en streaming) et LastSavePeakAuxiliaryXmlChars (la plus longue entrée de chaîne partagée ou de tableau croisé). LastSaveUsedStreaming est True pour les sorties .xlsx, .xltx et .xltm et pour le rédacteur binaire .xlsb ; la sortie OpenDocument passe par son propre rédacteur et rapporte False

procedure ReportSave(Workbook: TXLSXWorkbook; const FileName: string;
  Streaming: Boolean);
begin
  Workbook.StreamingWrite := Streaming;
  if Workbook.SaveAs(FileName) <> 1 then
    raise Exception.CreateFmt('Save failed: %s', [FileName]);
  WriteLn(Format('streaming=%s peakRow=%d buffered=%d peakAux=%d',
    [BoolToStr(Workbook.LastSaveUsedStreaming, True),
     Workbook.LastSavePeakRowXmlChars,
     Workbook.LastSaveBufferedWorksheetXmlChars,
     Workbook.LastSavePeakAuxiliaryXmlChars]));
end;

// Même classeur, les deux modes ; observez la mémoire du processus dans votre profileur
ReportSave(Workbook, 'ledger-stream.xlsx', True);
ReportSave(Workbook, 'ledger-buffered.xlsx', False);

Sur vos propres données, la ligne bufferisée montre un chiffre buffered de l'ordre du XML de toute la feuille, tandis que la ligne streaming montre zéro et un peakRow pas plus gros que votre ligne la plus large. Laissez StreamingWrite actif. Le drapeau décide seulement de savoir si le XML de feuille est assemblé en entier avant compression, et LastSaveBufferedWorksheetXmlChars rapporte la taille de cet assemblage en caractères

Où va la mémoire restante

Une fois cellules et styles compactés, les plus gros consommateurs suivants sur un gros classeur sont la table de chaînes partagées et les parties satellites que le fichier porte : caches de tableaux croisés, dessins, et XML préservé de parties que le modèle objet ne modélise pas. Ceux-là ont leurs propres leviers, et aucun réglage unique ne les couvre tous

Si l'ouverture est le goulot plutôt que la sauvegarde, le chargement sélectif est l'endroit où commencer : la visite guidée du chargement de feuilles en métadonnées seules et sélectif couvre la lecture d'un classeur sans payer pour les feuilles que vous ne toucherez pas. Pour le débit en lecture sur de très gros fichiers, voyez les notes sur l'analyse XLSX parallèle et l'allocateur mémoire, et pour des travaux en sortie seule qui n'ont jamais besoin d'un modèle objet, les écritures en streaming pour travaux par lots serveur convient mieux que n'importe quel réglage ici

HotXLS lit et écrit XLS et XLSX depuis du code Delphi et C++Builder natif sans Excel ni automation OLE, donc chaque chiffre ci-dessus vient de votre propre processus et peut y être vérifié. Formats pris en charge et versions de RAD Studio sont listés sur la page du composant tableur HotXLS Delphi