HotXLS, det inbyggda Excel-biblioteket för Delphi och C++Builder, tolkar XLSX-arbetsblad på flera trådar genom en inläsning i tre faser: blad-XML dekomprimeras seriellt, tolkas parallellt och mindre delar läses seriellt efteråt. Den första versionen av den funktionen gav endast 12–25 % vinst, eftersom Delphis standardlås för minneshanteraren serialiserade arbetstrådarna. Att minska heap-allokeringarna från ungefär 20 till 9,1 per cell ökade den parallella hastighetsökningen till ×1,90 på åtta trådar. Den här artikeln går igenom mätningarna, felstegen och de två korrigeringarna som faktiskt fungerade
Hur tolkar HotXLS XLSX-arbetsblad parallellt?
HotXLS delar upp Open i tre faser, och endast den mellersta körs på arbetstrådar. Anledningen är zip-behållaren: ett zip-arkiv är en gemensam indataström med en inflate-tillståndsmaskin, och den tillståndsmaskinen kan inte läsas av två trådar samtidigt. Att slå in den i ett lås vore meningslöst, eftersom inflate är naturligt seriellt per post, så ett lås skulle bara återskapa seriell exekvering med extra overhead. Fas A dekomprimerar därför varje arbetsblads XML till sin egen TMemoryStream medan den fortfarande är enkeltrådad; i vår benchmarkfil tog detta cirka 4 ms för åtta bladsdelar, så det är långt ifrån flaskhalsen. Fas B kör ParseWorksheetXml för varje blad på en arbetspool, vilket är där nästan all inläsningstid ligger. Fas C återvänder till zip-filen seriellt för de mindre delarna: kommentarer, ritningar, diagram och tabeller
Arbetspoolen i sig är medvetet enkel. Arbetare drar jobbindex från en delad räknare med InterlockedIncrement, så att blad av ojämn storlek balanseras naturligt utan någon schemaläggare. Trådantalet är min(sheet count, CPU cores), det första arbetarundantaget fångas upp med AcquireExceptionObject och utlöses igen på huvudtråden efter sammanslagningen (join), och avsändaren (dispatcher) faller tillbaka till en vanlig seriell loop när det finns noll eller ett jobb. Två egenskaper på TXLSXWorkbook styr funktionen: ParallelParse aktiverar poolen och ParallelParseThreads sätter ett tak för trådantalet, där 0 betyder automatiskt. Arbetsböcker med flera blad är den form som drar nytta av detta, inklusive den typ du skapar genom att duplicera ett kalkylblad som mall dussintals gånger
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.ParallelParse := True; // aktivera den parallella arbetspoolen
Book.ParallelParseThreads := 0; // 0 = auto: min(blad, CPU-kärnor)
if Book.Open('quarterly-ledger.xlsx') <= 0 then
raise Exception.Create('open failed');
// ... läs celler som vanligt; arbetsboken är verifierat fullt materialiserad ...
finally
Book.Free;
end;
end;
Varför gör fler trådar XLSX-tolkningen långsammare i Delphi?
Eftersom Delphis standardminneshanterare skyddar sin heap med ett globalt lås, och kalkylbladstolkning är allokeringsintensiv: celler, varianter och WideStrings i miljonklass. Varje arbetare som rör heapen köar på det låset, så trådar som ser oberoende ut i källkoden körs i praktiken nästan en i taget. Vårt första benchmarktest gjorde detta smärtsamt konkret. På en arbetsbok med 8 blad med 5 000 rader och 4 kolumner per blad, mätt på en i5-11600K (6 kärnor, 12 trådar) under Win64, förbättrades parallell Open med endast 12–25 % mot en planerad uppskattning på minst 40 %. En svepning av trådantalet över 2, 3, 4, 6 och 8 trådar gav en platt kurva, och i senare instrumenterade körningar var 2-trådskonfigurationen faktiskt 26 % långsammare än den seriella, det klassiska tecknet på att två trådar skickar ett konkurrerande lås fram och tillbaka
Tre mätningar fastställde diagnosen, och var och en kullkastade den tidigare intuitionen. För det första öppnades en minimal fil (8 blad med 1 rad) på 1,2 ms, vilket bevisade att tolkningen i huvudsak är 100 % av Open och att det inte fanns någon dold fast kostnad att beskylla. För det andra visade ett mikro-benchmarktest av ren allokeringsintensitet att Delphis minneshanterare skalade baklänges: samma totala volym av 2 miljoner objekt- och AnsiString-allokeringar kördes 60 % långsammare på 8 trådar än på en, medan samma körning mot WideString-heapen, som är COM BSTR-allokeraren snarare än Delphis MM, skalade till ×3,7. Att HotXLS använder WideString rakt igenom visade sig vara en lycklig slump. För det tredje visade GetProcessTimes utanför loopen att CPU-tiden under en parallell Open ungefär motsvarade den faktiska tiden: åtta nominella trådar förbrukade cirka 1,3 trådars CPU. Arbetarna spann inte; de sov i minneshanterarens konfliktsökväg, blockerade snarare än upptagna
Den praktiska lärdomen kan generaliseras bortom kalkylblad. Om en Delphi-arbetsbelastning allokerar kraftigt gör ett högre trådantal ingenting förrän allokeringshastigheten sjunker, och det kan lätt göra saken värre. Innan den här korrigeringen berättade vi den ärliga sanningen för användare som justerade ParallelParseThreads: på allokeringsbundna filer gav fler trådar nästan ingenting
Varifrån kommer 20 heap-allokeringar per cell?
En räknande wrapper installerad med SetMemoryManager besvarade den frågan exakt: cirka 20 Delphi-MM-allokeringar per cell, varav 2,87 miljoner av dem var på 32 byte eller mindre. Den skyldiga var inte alls cellobjekten. TXMLScaner.GetTokenValue materialiserade en ny AnsiString vid varje anrop, och den anropas ungefär 15–20 gånger per cell: en gång vardera för elementnamn, attributnamn, attributvärden och textinnehåll. Dessutom skapade RTL:s UTF8ToWideString-väg en tillfällig UnicodeString-mellanhand för varje konvertering. Cellobjekt stod för endast 160 tusen allokeringar, cirka 8 % av det totala antalet, vilket grusade vår ursprungliga plan direkt: vi hade tänkt bygga en cellobjektspool, men siffrorna visade att det aldrig skulle betala sig självt
var
OldMM, NewMM: TMemoryManagerEx;
AllocCount, TinyCount: Int64;
function CountingGetMem(Size: NativeInt): Pointer;
begin
AtomicIncrement(AllocCount);
if Size <= 32 then
AtomicIncrement(TinyCount); // den allokering av små objekt vi bryr oss om
Result := OldMM.GetMem(Size);
end;
// installera före Open, återställ efteråt
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);
Den där tio minuters diagnosen är värd att kopiera för alla prestanda-utredningar i Delphi. Att räkna allokeringar efter storlekskategori kostar nästan ingenting att bygga och visar var trycket på minneshanteraren faktiskt uppstår, vilket i vårt fall var två vanor på RTL-nivå inuti XML-skannern snarare än något i objektmodellen. Profilverktygen fortsatte att peka på tolken som helhet; wrappern pekade på två specifika rader
Lösningen: token-internering och en UTF-8-avkodare utan mellanhänder
Två riktade ändringar i XML-läsaren tog bort mer än hälften av allokeringarna per cell utan att röra tolkens struktur. Den första är internering av elementnamn. Kalkylblads-XML upprepar ett litet ordförråd i all oändlighet: row, c, v, r, t, s och en handfull attributnamn. InternTokenName har en cache med 64 platser för tidigare visade namn och jämför skannerns buffert med en cachad post med hjälp av TokenEqualsAnsi, en direkt byte-jämförelse som inte allokerar någonting. Vid en träff returnerar den den cachade AnsiString, och här spelar typvalet roll: AnsiString är referensräknad, så att returnera en cachad instans kostar en refcount-ökning och noll heap-trafik. WideString har ingen referensräkning och varje tilldelning går via SysAllocString, så att internera WideStrings skulle inte spara någonting. Internering är bara värt att göra på den referensräknade strängtypen
function TXMLScaner.InternTokenName: AnsiString;
var
Slot: Integer;
begin
Slot := TokenHash mod 64;
if TokenEqualsAnsi(FInternNames[Slot]) then
Result := FInternNames[Slot] // endast refcount++, ingen allokering
else
begin
Result := GetTokenValue; // materialisera en gång, cacha sedan
FInternNames[Slot] := Result;
end;
end;
Den andra ändringen angriper celltext. Den gamla vägen byggde en AnsiString-token, skickade den till UTF8ToWideString, som byggde en UnicodeString-mellanhand, som slutligen konverterades till den WideString som cellen lagrar: två Delphi-MM-allokeringar per text-token före den verkliga. Ersättningen, XmlUtf8ToWide(TokenPtr, TokenLen), är en ren Pascal UTF-8-avkodare i två steg som läser direkt från skanningsbufferten: steg ett mäter UTF-16-längden, steg två avkodar till en WideString som allokeras en gång. Nettokostnad per text-token: en COM-allokering, noll Delphi-MM-allokeringar. En semantisk anmärkning för den försiktige: vid felaktiga UTF-8-sekvenser släpper den nya avkodaren igenom byte istället för att ersätta med ersättningstecken som RTL gör, vilket endast påverkar hur skadade filer degraderas; vid giltig indata är utdata byte-identisk. XML-teckenentiteter når aldrig avkodaren, eftersom skannern redan har löst upp dem till UTF-8 i token-bufferten
Vad gav det, och var parallelltolkning fortfarande inte hjälper
De två korrigeringarna minskade allokeringarna per cell från cirka 20 till 9,1, och de parallella siffrorna rörde sig som teorin förutsade. På samma 8-blads, 5 000-raders benchmarktest och samma 6C12T-maskin gick förbättringen med 8 trådar från 14 % till 47,4 %, en ×1,90 hastighetsökning jämfört med seriell. Fallet med 2 trådar svängde från 26 % långsammare till 23,6 % snabbare, och den uppmätta CPU-användningen ökade från ×1,0 till ×2,2. Den seriella sökvägen blev cirka 3 % snabbare som en bonus, eftersom färre allokeringar hjälper en enskild tråd också. De återstående ~9 allokeringarna per cell är ungefär hälften cellobjekt och hälften amorterad tillväxt av behållare; vi mätte dem, bedömde att vinsterna avtog och slutade, med MM-wrappern redo att mäta igen per anropsställe om en framtida arbetsbelastning motiverar en ny omgång
Gränserna är värda att anges lika tydligt som vinsterna. HotXLS parallelliserar på kalkylbladsnivå, så en arbetsbok som består av ett enda gigantiskt blad tolkas på en tråd oavsett vad ParallelParseThreads anger; för den formen är den strömmande direktläsaren det bättre verktyget, eftersom den helt undviker att materialisera kalkylboken. Filer där tiden går till delar i fas C — ritningar, diagram och kommentarer — ser mindre nytta eftersom den fasen förblir seriell av design. Små filer är inte värda att tråda alls, vilket är anledningen till att avsändaren tyst kör seriellt för triviala antal jobb. Och minneshanterarens tak har inte försvunnit, bara flyttats upp: vid 9,1 allokeringar per cell belastar det globala låset fortfarande arbetarna, vilket är anledningen till att åtta trådar ger ×1,90 snarare än ×4. För det bredare verktygspaketet för att korta inläsnings- och sparningstider, inklusive stilar, pooler och bulkrads-återanrop, se vår guide till prestanda för stora kalkylböcker i Delphi
Parallell XLSX-tolkning, egenskaperna ParallelParse och ParallelParseThreads samt den allokeringssnåla XML-läsaren som beskrivs här levereras som standarddelar i HotXLS Delphi Excel Component, som läser och skriver XLS, XLSX och ODS inbyggt från Delphi och C++Builder utan att Excel-automatisering är inblandad