Technischer Artikel

Paralleles XLSX-Parsing in Delphi: Flaschenhals Speicher-Manager

HotXLS, die native Excel-Bibliothek für Delphi und C++Builder, parst XLSX-Arbeitsblätter über einen dreiphasigen Ladevorgang auf mehreren Threads: Das XML der Blätter wird seriell dekomprimiert, parallel geparst und kleinere XML-Teile werden im Anschluss wieder seriell eingelesen. Die erste Version dieses Features erzielte lediglich einen Geschwindigkeitsvorteil von 12 bis 25 %, da der globale Lock des Delphi-Standard-Speichermanagers die Worker-Threads blockierte. Durch die Reduzierung der Heap-Allokationen von etwa 20 auf 9,1 pro Zelle stieg der parallele Speedup auf das 1,90-fache bei acht Threads. Dieser Artikel beschreibt die Messungen, die Sackgassen der Entwicklung und die beiden Optimierungen, die letztendlich zum Erfolg führten

Wie parst HotXLS XLSX-Arbeitsblätter parallel?

HotXLS teilt die Methode Open in drei Phasen auf, von denen nur die mittlere auf Arbeitsthreads (Worker-Threads) ausgeführt wird. Der Grund dafür liegt im ZIP-Container: Ein ZIP-Archiv ist ein gemeinsam genutzter Eingabestrom mit einem einzigen Inflate-Zustandsautomaten. Dieser Zustandsautomat kann nicht von zwei Threads gleichzeitig gelesen werden. Ihn mit einem Lock zu schützen, wäre sinnlos, da das Entpacken (Inflate) pro Eintrag inhärent seriell abläuft — ein Lock würde die serielle Ausführung lediglich mit zusätzlichem Overhead reproduzieren. Phase A dekomprimiert daher das XML jedes Arbeitsblatts in einen eigenen TMemoryStream, während das Programm noch einthreadig läuft. Bei unserer Benchmark-Datei dauerte dies für acht Arbeitsblatt-Teile etwa 4 ms und lag somit weit entfernt vom eigentlichen Flaschenhals. Phase B führt ParseWorksheetXml für jedes Blatt auf einem Worker-Pool aus, wo fast die gesamte Ladezeit verbraucht wird. Phase C greift schließlich wieder seriell auf das ZIP-Archiv zu, um kleinere Teile wie Kommentare, Zeichnungen, Diagramme und Tabellen einzulesen

Der Worker-Pool selbst ist bewusst einfach gehalten. Die Arbeitsthreads holen sich die Job-Indizes über einen gemeinsamen Zähler mittels InterlockedIncrement, sodass sich Arbeitsblätter unterschiedlicher Größe ohne Scheduler natürlich aufteilen. Die Thread-Anzahl entspricht min(Blattanzahl, CPU-Kerne). Die erste auftretende Exception eines Threads wird über AcquireExceptionObject erfasst und nach dem Zusammenführen (Join) der Threads auf dem Hauptthread erneut ausgelöst. Bei null oder einem Job fällt der Dispatcher auf eine einfache serielle Schleife zurück. Zwei Eigenschaften von TXLSXWorkbook steuern dieses Verhalten: ParallelParse aktiviert den Worker-Pool und ParallelParseThreads begrenzt die Thread-Zahl, wobei 0 für die automatische Bestimmung steht. Arbeitsmappen mit mehreren Blättern profitieren von dieser Struktur, einschließlich der Dokumente, die Sie durch das mehrfache Duplizieren eines Vorlagen-Arbeitsblatts erstellen

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.ParallelParse := True;      // Den parallelen Worker-Pool aktivieren
    Book.ParallelParseThreads := 0;  // 0 = Auto: min(Blätter, CPU-Kerne)
    if Book.Open('quarterly-ledger.xlsx') <= 0 then
      raise Exception.Create('Öffnen fehlgeschlagen');
    // ... Zellen wie gewohnt auslesen. Die Arbeitsmappe ist vollständig geladen ...
  finally
    Book.Free;
  end;
end;

Warum verlangsamt das Hinzufügen von Threads das XLSX-Parsing in Delphi?

Weil der Delphi-Standard-Speichermanager seinen Heap mit einem globalen Lock schützt und das Parsen von Arbeitsblättern allokationsintensiv ist: Millionen von Zellen, Variants und WideStrings werden erzeugt. Jeder Arbeitsthread, der den Heap berührt, reiht sich in die Warteschlange dieses Locks ein, sodass unabhängig im Code definierte Threads in der Praxis fast nacheinander ausgeführt werden. Unser erster Benchmark zeigte dies deutlich: Bei einer Arbeitsmappe mit 8 Blättern und 5.000 Zeilen mal 4 Spalten pro Blatt (gemessen auf einem i5-11600K mit 6 Kernen und 12 Threads unter Win64) verbesserte sich die parallele Ausführung von Open im Vergleich zur geschätzten Planung von mindestens 40 % nur um 12 bis 25 %. Eine Messung mit 2, 3, 4, 6 und 8 Threads lieferte eine flache Kurve. In späteren Testläufen war die Konfiguration mit 2 Threads sogar um 26 % langsamer als die serielle Ausführung — das typische Zeichen dafür, dass sich zwei Threads um einen umkämpften Lock streiten

Drei Messungen sicherten die Diagnose, wobei jede einzelne die vorherige Vermutung widerlegte. Erstens: Eine winzige Datei (8 Blätter mit je 1 Zeile) öffnete sich in 1,2 ms, was bewies, dass das Parsen im Wesentlichen 100 % der Ladezeit ausmacht und kein versteckter Fixkostenanteil schuld war. Zweitens: Ein Micro-Benchmark mit reinem Allokationsaufwand zeigte, dass der Delphi-Speichermanager rückwärts skaliert. Das gleiche Gesamtvolumen von 2 Millionen Objekt- und AnsiString-Allokationen lief auf 8 Threads um 60 % langsamer als auf einem einzelnen, während derselbe Aufwand auf dem WideString-Heap (der den COM-BSTR-Allokator anstelle des Delphi-Speichermanagers nutzt) auf das 3,7-fache skalierte. Dass HotXLS durchgehend WideStrings verwendet, stellte sich als glücklicher Zufall heraus. Drittens: GetProcessTimes zeigte, dass während eines parallelen Ladevorgangs die CPU-Zeit ungefähr der realen Zeit entsprach — acht nominale Threads verbrauchten nur die Leistung von etwa 1,3 Threads. Die Worker blockierten sich gegenseitig im Konfliktpfad des Speichermanagers, anstatt produktiv zu arbeiten

Diese Erkenntnis lässt sich auf alle Delphi-Workloads übertragen. Wenn eine Anwendung intensiv Speicher allokiert, bringt das Erhöhen der Thread-Anzahl nichts, solange die Allokationsrate nicht sinkt — und es kann die Performance sogar verschlechtern. Vor dieser Optimierung mussten wir den Benutzern bei der Konfiguration von ParallelParseThreads die Wahrheit sagen: Bei allokationslastigen Dateien brachten mehr Threads fast keinen Gewinn

Woher stammen 20 Heap-Allokationen pro Zelle?

Ein mit SetMemoryManager installierter Zähl-Hook lieferte die genaue Antwort: Etwa 20 Allokationen des Delphi-Speichermanagers pro Zelle, davon 2,87 Millionen mit einer Größe von 32 Byte oder weniger. Die Ursache lag nicht an den Zellobjekten selbst. Die Methode TXMLScaner.GetTokenValue erzeugte bei jedem Aufruf einen neuen AnsiString und wird pro Zelle etwa 15 bis 20 Mal aufgerufen — jeweils einmal für Elementnamen, Attributnamen, Attributwerte und Textinhalte. Darüber hinaus erzeugte die RTL-Methode UTF8ToWideString für jede Konvertierung ein temporäres UnicodeString-Zwischenobjekt. Zellobjekte machten nur 160.000 Allokationen aus, etwa 8 % des Gesamtwerts. Dies verwarf unseren ursprünglichen Plan, einen Objekt-Pool für Zellen zu bauen — ein solcher Pool hätte sich niemals rentiert

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

function CountingGetMem(Size: NativeInt): Pointer;
begin
  AtomicIncrement(AllocCount);
  if Size <= 32 then
    AtomicIncrement(TinyCount);   // Die kleinen Allokationen, die uns interessieren
  Result := OldMM.GetMem(Size);
end;

// Vor Open installieren, danach wiederherstellen
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);

Diese einfache Diagnosemethode ist für jede Performance-Untersuchung in Delphi wertvoll. Das Zählen von Allokationen nach Größenklassen erfordert kaum Aufwand und zeigt sofort, wo der Druck auf den Speichermanager entsteht. In unserem Fall lag es an zwei typischen RTL-Verfahren im XML-Scanner und nicht am Objektmodell selbst. Profiler deuteten auf den Parser als Ganzes, der Hook auf zwei konkrete Zeilen

Die Optimierung: Token-Internierung und ein direktes UTF-8-Dekodierungsprogramm

Zwei gezielte Änderungen im XML-Reader reduzierten die Allokationen pro Zelle um mehr als die Hälfte, ohne die Struktur des Parsers zu verändern. Die erste Änderung betrifft die Internierung von Elementnamen. XLSX-XML wiederholt ständig ein kleines Vokabular: row, c, v, r, t, s und einige Attributnamen. Die Methode InternTokenName verwaltet einen Cache mit 64 Einträgen für bereits gesehene Namen und vergleicht den Puffer des Scanners über TokenEqualsAnsi direkt mit einem Cache-Eintrag — ein direkter Bytevergleich, der keinen Speicher allokiert. Bei einem Treffer wird der gecachte AnsiString zurückgegeben. Die Typenwahl ist hier entscheidend: AnsiString ist referenzgezählt. Die Rückgabe einer gecachten Instanz kostet nur ein Erhöhen des Referenzzählers und verursacht keine Heap-Aktivität. WideString besitzt keine Referenzzählung, sodass jede Zuweisung über SysAllocString läuft — die Internierung von WideStrings hätte nichts eingespart. Die Internierung lohnt sich nur bei referenzgezählten String-Typen

function TXMLScaner.InternTokenName: AnsiString;
var
  Slot: Integer;
begin
  Slot := TokenHash mod 64;
  if TokenEqualsAnsi(FInternNames[Slot]) then
    Result := FInternNames[Slot]    // Nur Referenzzähler erhöhen, keine Allokation
  else
  begin
    Result := GetTokenValue;        // Einmal erzeugen, dann cachen
    FInternNames[Slot] := Result;
  end;
end;

Die zweite Änderung betrifft den Zelltext. Der alte Pfad erzeugte ein AnsiString-Token, übergab es an UTF8ToWideString, das ein temporäres UnicodeString-Zwischenobjekt baute, welches schließlich in den vom Zellobjekt genutzten WideString konvertiert wurde. Das bedeutete zwei Speicherallokationen vor der eigentlichen Speicherung. Der Ersatz XmlUtf8ToWide(TokenPtr, TokenLen) ist ein in zwei Durchgängen arbeitender UTF-8-Decoder in reinem Pascal, der direkt aus dem Scan-Puffer liest: Der erste Durchgang misst die UTF-16-Länge, der zweite dekodiert direkt in einen einmalig allokierten WideString. Die Nettokosten pro Text-Token betragen nun eine COM-Allokation und null Allokationen im Delphi-Speichermanager. Ein wichtiger Hinweis zur Semantik: Bei fehlerhaften UTF-8-Sequenzen reicht der neue Decoder die Bytes unverändert weiter, anstatt Ersatzzeichen einzufügen. Dies betrifft jedoch nur das Verhalten bei beschädigten Dateien. Bei gültigen Eingaben ist die Ausgabe Byte-identisch. XML-Zeichenentitäten erreichen den Decoder nicht, da der Scanner sie bereits im Token-Puffer zu UTF-8 aufgelöst hat

Das Ergebnis und die Grenzen der Parallelisierung

Die beiden Korrekturen senkten die Allokationen pro Zelle von etwa 20 auf 9,1. Die Zahlen der parallelen Ausführung entwickelten sich wie theoretisch erwartet: Auf der Benchmark-Datei mit 8 Blättern und 5.000 Zeilen stieg der Geschwindigkeitsvorteil bei 8 Threads von 14 % auf 47,4 %, was einem Speedup auf das 1,90-fache im Vergleich zur seriellen Ausführung entspricht. Der Fall mit 2 Threads wandelte sich von einer Verlangsamung um 26 % zu einer Beschleunigung um 23,6 %. Die gemessene CPU-Auslastung stieg von 1,0 auf 2,2. Auch der serielle Pfad profitierte und wurde durch die geringere Zahl an Allokationen um etwa 3 % schneller. Die verbleibenden rund 9 Allokationen pro Zelle entfallen etwa zur Hälfte auf die Zellobjekte und zur Hälfte auf das amortisierte Wachstum des Containers. Wir haben uns entschieden, an dieser Stelle aufzuhören, da weitere Optimierungen nur geringen Mehrwert versprachen

Die Grenzen der Technologie seien klar benannt. HotXLS parallelisiert auf Arbeitsblattebene. Eine Arbeitsmappe, die nur aus einem einzigen riesigen Blatt besteht, wird daher immer auf einem Thread geparst, ungeachtet des Werts von ParallelParseThreads. Für diesen Anwendungsfall ist das direkte Streaming-Leseprogramm (Streaming Direct Reader) das bessere Werkzeug, da es das Dokument nicht vollständig im Speicher aufbaut. Dateien, deren Ladezeit primär durch Elemente der Phase C (Zeichnungen, Diagramme, Kommentare) bestimmt wird, profitieren weniger, da diese Phase programmbedingt seriell bleibt. Kleine Dateien lohnen den Thread-Aufwand nicht, weshalb der Dispatcher bei wenigen Jobs automatisch seriell arbeitet. Zudem ist die Speichergrenze nicht verschwunden, sondern nur verschoben: Bei 9,1 Allokationen pro Zelle belastet der globale Lock die Threads weiterhin, weshalb acht Threads eine Skalierung auf das 1,90-fache und nicht auf das 4-fache liefern. Weitere Hinweise zur Optimierung von Lade- und Speicherzeiten finden Sie in unserem Leitfaden für große Arbeitsmappen in Delphi

Das parallele XLSX-Parsing, die Eigenschaften ParallelParse und ParallelParseThreads sowie der allokationsarme XML-Reader sind Standardbestandteile von HotXLS Delphi Excel Component, die XLS-, XLSX- und ODS-Dateien nativ und ohne Excel-Automatisierung liest und schreibt