HotXLS, de native Delphi- en C++Builder Excel-bibliotheek, parseert XLSX-tabbladen (worksheets) op meerdere threads via een laadproces in drie fasen: tabblad-XML wordt serieel gedecomprimeerd, parallel geparseerd en kleine onderdelen worden daarna serieel ingelezen. De eerste release van die functie leverde slechts een winst op van 12–25%, omdat de lock van Delphi's standaard geheugenbeheer (memory manager) de worker-threads in feite serieel liet uitvoeren. Door de heapallocaties terug te brengen van ruwweg 20 naar 9,1 per cel, steeg de parallelle versnelling naar ×1,90 op acht threads. Dit artikel doorloopt de metingen, de verkeerde afslagen en de twee oplossingen die daadwerkelijk werkten
Hoe parseert HotXLS XLSX-tabbladen in parallel?
HotXLS splitst Open op in drie fasen, en alleen de middelste draait op worker-threads. De reden hiervoor is de zip-container: een zip-archief is één gedeelde invoerstream met één inflate-statusmachine, en dat statusmachine kan niet door twee threads tegelijk worden gelezen. Het inpakken in een lock zou zinloos zijn, omdat inflate inherent serieel is per item, dus een lock zou gewoon de seriële uitvoering reproduceren met extra overhead. Fase A decomprimeert daarom de XML van elk tabblad in een eigen TMemoryStream terwijl deze nog single-threaded is; in ons benchmarkbestand nam dit ongeveer 4 ms in beslag voor acht tabbladonderdelen, dus dit is verre van het knelpunt. Fase B voert ParseWorksheetXml uit voor elk tabblad op een worker-pool, en dit is waar vrijwel alle laadtijd in zit. Fase C keert serieel terug naar de zip voor de kleine onderdelen: commentaren, tekeningen, grafieken en tabellen
De worker-pool zelf is bewust eenvoudig gehouden. Workers halen taakindices op uit een gedeelde teller via InterlockedIncrement, zodat tabbladen van ongelijke grootte zich van nature balanceren zonder dat er een scheduler nodig is. Het aantal threads is min(sheet count, CPU cores), de eerste uitzondering (exception) bij een worker wordt vastgelegd via AcquireExceptionObject en opnieuw gegenereerd op de hoofdthread na de join, en de dispatcher valt terug naar een eenvoudige seriële lus als er nul of één taken zijn. Twee properties op TXLSXWorkbook besturen deze functie: ParallelParse schakelt de pool in, en ParallelParseThreads stelt een limiet aan het aantal threads, waarbij 0 staat voor automatisch. Werkmappen met meerdere tabbladen profiteren hier het meeste van, inclusief het soort dat u produceert door een sjabloontabblad tientallen keren te dupliceren
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;
Waarom maakt het toevoegen van threads het parseren van XLSX trager in Delphi?
Omdat Delphi's standaard geheugenbeheer (memory manager) zijn heap beschermt met een globale lock, en het parseren van tabbladen toewijzingsintensief (allocation-dense) is: cellen, Variants en WideStrings bij de miljoenen. Elke worker die de heap aanraakt, sluit achteraan in de rij voor die lock, zodat threads die er in de broncode onafhankelijk uitzien, in de praktijk bijna een voor een worden uitgevoerd. Onze eerste benchmark maakte dit pijnlijk concreet. Op een werkmap met 8 tabbladen van elk 5.000 rijen bij 4 kolommen, gemeten op een i5-11600K (6 cores, 12 threads) onder Win64, verbeterde de parallelle Open met slechts 12–25% ten opzichte van een planningsschatting van ten minste 40%. Een thread-count-meting over 2, 3, 4, 6 en 8 threads leverde een vlakke curve op, en in latere geïnstrumenteerde runs was de 2-thread configuratie zelfs 26% trager dan serieel — de klassieke handtekening van twee threads die pingpongen op een zwaar belaste lock
Drie metingen bevestigden de diagnose, en elk ervan ontkrachtte de eerdere intuïtie. Ten eerste opende een heel klein bestand (8 tabbladen van 1 rij) in 1,2 ms, wat bewees dat parseren in feite 100% van Open is en er geen verborgen vaste kosten de schuld kregen. Ten tweede toonde een microbenchmark van pure allocatie-churn aan dat het geheugenbeheer van Delphi achteruit schaalde: hetzelfde totale volume van 2 miljoen object- en AnsiString-allocaties draaide 60% trager op 8 threads dan op één thread, terwijl dezelfde churn op de WideString-heap (de COM BSTR-allocator in plaats van de Delphi MM) schaalde naar ×3,7. Dat HotXLS overal WideString gebruikt bleek een historisch toeval te zijn dat in ons voordeel werkte. Ten derde toonde GetProcessTimes aan dat tijdens een parallelle Open de CPU-tijd ruwweg gelijk was aan de verstreken tijd (wall time): acht nominale threads verbruikten ongeveer 1,3 thread aan CPU. De workers waren niet aan het spinnen; they sliepen in het blokkeringspad van het geheugenbeheer, geblokkeerd in plaats van bezig
De praktische les reikt verder dan spreadsheets. Als een Delphi-werklast zwaar alloceert, doet het verhogen van het aantal threads niets totdat de allocatiesnelheid daalt, en het kan de situatie gemakkelijk verergeren. Vóór deze oplossing vertelden we gebruikers die ParallelParseThreads afstemden de eerlijke waarheid: bij bestanden die allocatie-gebonden zijn, leverden meer threads bijna niets op
Waar komen 20 heapallocaties per cel vandaan?
Een tel-wrapper geïnstalleerd via SetMemoryManager gaf exact antwoord op die vraag: ongeveer 20 Delphi-MM-allocaties per cel, waarvan 2,87 million van 32 bytes of minder. De schuldige was helemaal niet de celobjecten. TXMLScaner.GetTokenValue creëerde bij elke aanroep een verse AnsiString, en deze wordt ongeveer 15–20 times per cel aangeroepen: telkens één keer voor elementnamen, attribuutnamen, attribuutwaarden en tekstinhoud. Bovendien fabriceerde de UTF8ToWideString-route van de RTL een tijdelijke tussenstap (intermediate UnicodeString) voor elke conversie. Celobjecten waren verantwoordelijk voor slechts 160 duizend allocaties, ongeveer 8% van het totaal, wat ons oorspronkelijke plan direct de das omdeed: we wilden een celobject-pool bouwen, maar de cijfers zeiden dat dit zichzelf nooit zou terugverdienen
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);
Die diagnostische methode van tien minuten is het waard om over te nemen voor elk prestatieonderzoek in Delphi. Het tellen van allocaties per grootte-categorie kost bijna niets om te bouwen en vertelt u waar de druk op het geheugenbeheer daadwerkelijk vandaan komt. In ons geval was dat afkomstig van twee gewoonten op RTL-niveau binnen de XML-scanner, in plaats van iets in het objectmodel. Profilers bleven wijzen naar de parser als geheel; de wrapper wees naar twee specifieke regels
De oplossing: token interning en een UTF-8 decoder zonder tussenstappen
Twee gerichte wijzigingen in de XML-lezer elimineerden meer dan de helft van de allocaties per cel zonder de structuur van de parser aan te tasten. De eerste is het internen (interning) van elementnamen. Tabblad-XML herhaalt eindeloos een kleine vocabulaire: row, c, v, r, t, s, en een handvol attribuutnamen. InternTokenName houdt een 64-slot cache bij van eerder geziene namen en vergelijkt de builder-buffer van de scanner met een gecacht item via TokenEqualsAnsi, een directe bytevergelijking die niets alloceert. Bij een hit retourneert het de gecachte AnsiString, en hier is de typekeuze belangrijk: AnsiString is reference-counted, dus het retourneren van een gecachte instantie kost één refcount-verhoging en nul heap-verkeer. WideString heeft geen reference-count en elke toewijzing gaat via SysAllocString, dus het internen van WideStrings zou niets opleveren. Internen is alleen de moeite waard op het reference-counted stringtype
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;
De tweede wijziging pakt de celtekst aan. Het oude pad bouwde een AnsiString-token, gaf deze door aan UTF8ToWideString, die een tussenliggende UnicodeString bouwde, die ten slotte werd geconverteerd naar de WideString die de cel opslaat: twee Delphi-MM-allocaties per tekst-token vóór de eigenlijke allocatie. De vervanging, XmlUtf8ToWide(TokenPtr, TokenLen), is een two-pass pure Pascal UTF-8 decoder die rechtstreeks uit de scanbuffer leest: de eerste stap meet de UTF-16-lengte, de tweede stap decodeert in een eenmalig gealloceerde WideString. Netto kosten per tekst-token: één COM-allocatie, nul Delphi-MM-allocaties. Een semantische opmerking voor de voorzichtigen: bij ongeldige UTF-8-reeksen geeft de nieuwe decoder de bytes gewoon door in plaats van ze te vervangen door vervangende tekens zoals de RTL dat doet, wat alleen invloed heeft op hoe corrupte bestanden degraderen; op geldige invoer is de uitvoer byte-identiek. XML-karakterentiteiten bereiken de decoder nooit, omdat de scanner deze al heeft opgelost in UTF-8 in de tokenbuffer
Wat leverde het op, en waar parallel parseren nog steeds niet helpt
De twee oplossingen brachten de allocaties per cel terug van ongeveer 20 naar 9,1, en de parallelle cijfers bewogen zoals de theorie voorschreef. Op dezelfde benchmark met 8 tabbladen en 5.000 rijen en dezelfde 6C12T-machine steeg de verbetering met 8 threads van 14% naar 47,4%, een versnelling van ×1,90 ten opzichte van serieel. Het geval met 2 threads sloeg om van 26% trager naar 23,6% sneller, en het gemeten CPU-verbruik steeg van ×1,0 naar ×2,2. Het seriële pad werd als bonus ongeveer 3% sneller, omdat minder allocaties ook één enkele thread ten goede komen. De resterende ~9 allocaties per cel bestaan ruwweg voor de helft uit celobjecten en voor de helft uit geamortiseerde containergroei; we hebben ze gemeten, vonden de winst marginaal worden en zijn gestopt, waarbij de MM-wrapper klaarstaat om opnieuw te samplen per aanroeplocatie als een toekomstige werklast een nieuwe ronde rechtvaardigt
De grenzen zijn het waard om net zo duidelijk te worden vermeld als de winsten. HotXLS paralleliseert op tabbladniveau, dus een werkmap die uit één gigantisch tabblad bestaat, parseert op één thread, ongeacht wat ParallelParseThreads aangeeft; voor die vorm is de streaming direct reader het betere gereedschap, omdat deze het materialiseren van de werkmap geheel vermijdt. Bestanden waarvan de tijd opgaat aan Fase C-onderdelen — tekeningen, grafieken en commentaren — zien minder voordeel omdat die fase inherent serieel is ontworpen. Kleine bestanden zijn het helemaal niet waard om te threaden, en daarom draait de dispatcher stilletjes serieel bij triviale aantallen taken. En het plafond van het geheugenbeheer is niet verdwenen, maar enkel verschoven: bij 9,1 allocaties per cel belast de globale lock de workers nog steeds, wat de reden is dat acht threads ×1,90 opleveren in plaats van ×4. Voor een breder overzicht van het verkorten van laad- en opslagtijden, inclusief stijlen, pools en bulk-rijcallbacks, zie onze gids voor prestaties van grote werkmappen in Delphi
Parallelle XLSX-parsing, de properties ParallelParse en ParallelParseThreads, en de allocatie-arme XML-lezer die hier worden beschreven, worden standaard geleverd als onderdeel van het HotXLS Delphi Excel Component, dat XLS, XLSX en ODS native leest en schrijft vanuit Delphi en C++Builder zonder dat er Excel-automatisering bij betrokken is