Teknisk artikel

Parallel XLSX-fortolkning i Delphi: Flaskehals i hukommelsesstyringen

HotXLS, det indfødte Excel-bibliotek til Delphi og C++Builder, fortolker XLSX-regneark på flere tråde via en tre-faset indlæsning: Ark-XML dekomprimeres serielt, fortolkes parallelt, og mindre dele læses serielt bagefter. Den første udgivelse af denne funktion gav kun 12–25% forbedring, fordi Delphis standardlås til hukommelsesstyringen (memory manager lock) serialiserede arbejdetrådene. Reducering af heap-allokeringer fra ca. 20 til 9,1 pr. celle øgede den parallelle hastighedsforøgelse til ×1,90 på otte tråde. Denne artikel gennemgår målingerne, de forkerte valg og de to rettelser, der rent faktisk fungerede

Det scenarie, der motiverer alle tre, er deprimerende almindeligt. En faktureringstjeneste indlæser en skabelon, som kunden har designet i Excel — virksomhedens farvetema, sparklines i en KPI-kolonne, en betinget formateringsregel tilføjet af en nyere Excel-version — skriver et enkelt fakturabeløb i celle B3 og gemmer. Kunden åbner resultatet, og brand-farverne er vendt tilbage to standard Office-blå, sparklines er væk, og Excel tilbyder at "reparere" filen. Intet i koden rørte ved nogen af disse funktioner. Det gjorde biblioteket blot ved at gemme

Hvordan fortolker HotXLS XLSX-regneark parallelt?

HotXLS opdeler Open i tre faser, og kun den midterste kører på arbejdetråde. Årsagen er zip-beholderen: Et zip-arkiv er en enkelt delt inputstrøm med én inflate-tilstandsmaskine (state machine), og denne tilstandsmaskine kan ikke læses af to tråde samtidigt. At pakke den ind i en lås ville være meningsløst, fordi inflate i sin natur er seriel pr. post, så en lås ville blot genskabe seriel afvikling med ekstra overhead. Fase A dekomprimerer derfor hvert regnearks XML til sit eget TMemoryStream, mens der stadig køres enkelttrådet; i vores benchmark-fil tog dette ca. 4 ms for otte arkdele, så det er slet ikke i nærheden af flaskehalsen. Fase B kører ParseWorksheetXml for hvert ark på en pulje af arbejdetråde, hvilket er der, hvor næsten hele indlæsningstiden ligger. Fase C vender serielt tilbage til zip-filen for de mindre dele: Kommentarer, tegninger, diagrammer og tabeller

Selve puljen af arbejdetråde er bevidst enkel. Trådene trækker jobindekser fra en fælles tæller med InterlockedIncrement, så ark af uens størrelse balancerer naturligt uden nogen planlægning. Trådantallet er min(arkantal, CPU-kerner), den første undtagelse (exception) fra en arbejdetråd indfanges med AcquireExceptionObject og genudløses på hovedtråden efter join, og afsenderen (dispatcher) nedgraderes til en simpel seriel løkke, når der er nul eller et job. Two egenskaber på TXLSXWorkbook styrer funktionen: ParallelParse åbner/lukker for puljen, og ParallelParseThreads sætter loft over trådantallet, hvor 0 betyder automatisk. Arbejdsbøger med flere ark er den type, der drager fordel af dette, herunder den slags, du producerer ved at duplikere et skabelonark snesevis af gange

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;

Hvorfor gør tilføjelse af tråde XLSX-fortolkning langsommere i Delphi?

Fordi Delphis standardhukommelsesstyring beskytter sin heap med en global lås, og regnearksfortolkning er allokeringsintensiv: Millioner af celler, Varianter og WideStrings. Hver arbejdetråd, der berører heapen, stiller sig i kø på denne lås, så tråde, der ser uafhængige ud i kildekoden, i praksis afvikles næsten én ad gangen. Vores første benchmark gjorde dette fejlfrit. På en arbejdsbog med 8 ark med 5.000 rækker og 4 kolonner pr. ark, målt på en i5-11600K (6 kerner, 12 tråde) under Win64, forbedredes parallel Open kun med 12–25% mod et planlagt estimat på mindst 40%. En undersøgelse af trådantal på tværs af 2, 3, 4, 6 og 8 tråde producerede en flad kurve, og i senere instrumenterede kørsler var konfigurationen med 2 tråde faktisk 26% langsommere end den serielle, hvilket er den klassiske signatur for to tråde, der ping-ponger om en omstridt lås

Tre målinger fastslog diagnosen, og hver af dem omstødte den hidtidige intuition. For det første åbnede en lille fil (8 ark med 1 række) på 1,2 ms, hvilket beviser, at fortolkning reelt udgør 100% af Open, og at der ikke var nogen skjult fast omkostning at bebrejde. For det andet viste en mikro-benchmark med ren allokeringsaktivitet, at Delphis hukommelsesstyring skalerede baglæns: Det samme samlede volumen på 2 millioner objekt- og AnsiString-allokeringer kørte 60% langsommere på 8 tråde end på én, mens den samme aktivitet mod WideString-heapen — som er COM BSTR-allokatoren frem for Delphis MM — skalerede til ×3,7. At HotXLS bruger WideString overalt, viste sig at være et historisk tilfælde, der fungerede til vores fordel. For det tredje viste GetProcessTimes, at CPU-tiden under en parallel Open stort set var lig med den faktiske tid (wall time): Otte nominelle tråde forbrugte ca. 1,3 trådes værdi af CPU. Arbejdetrådene spandt ikke; de sov i hukommelsesstyringens kø, blokerede frem for aktive

Den praktiske lære rækker ud over regneark. Hvis en Delphi-arbejdsbyrde allokerer meget, gør en forøgelse af trådantallet intet, før allokeringshastigheden falder, og det kan nemt gøre tingene værre. Før denne rettelse fortalte vi sandheden til brugere, der justerede ParallelParseThreads: På filer, der er begrænset af allokering, gav flere tråde næsten intet

Hvor kommer 20 heap-allokeringer pr. celle fra?

En tælle-wrapper installeret med SetMemoryManager besvarede netop det spørgsmål: Ca. 20 Delphi-MM-allokeringer pr. celle, hvor 2,87 millioner af dem var på 32 bytes eller derunder. Synderen var overhovedet ikke celleobjekterne. TXMLScaner.GetTokenValue materialiserede en frisk AnsiString ved hvert kald, og den kaldes ca. 15–20 gange pr. celle: Én gang hver for elementnavne, attributnavne, attributværdier og tekstindhold. Oven i det producerede RTL'ens UTF8ToWideString-sti en midlertidig UnicodeString-mellemting for hver konvertering. Celleobjekter udgjorde kun 160 tusind allokeringer, ca. 8% af totalen, hvilket omgående bremsede vores oprindelige plan: Vi havde til hensigt at bygge en celleobjekt-pulje (cell object pool), men tallene sagde, at det aldrig ville kunne betale sig

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);

Dette ti-minutters fejlfindingsværktøj er værd at kopiere til enhver Delphi-ydeevneundersøgelse. At tælle allokeringer efter størrelseskategori koster næsten intet at bygge og fortæller dig, hvor presset på hukommelsesstyringen rent faktisk stammer fra, hvilket i vores tilfælde var to vaner på RTL-niveau i XML-scanneren frem for noget i objektmodellen. Profileringsværktøjer pegede fortsat på fortolkeren som helhed; wrapperen pegede på to specifikke linjer

Rettelsen: Token-internering og en UTF-8-dekoder uden mellemled

To målrettede ændringer i XML-læseren fjernede mere end halvdelen af allokeringerne pr. celle uden at røre ved fortolkerens struktur. Den første er internering af elementnavne. Regnearks-XML gentager et meget lille ordforråd i det uendelige: row, c, v, r, t, s og en håndfuld attributnavne. InternTokenName vedligeholder en cache med 64 pladser til tidligere sete navne og sammenligner scannerens buffer med en cachet post via TokenEqualsAnsi — en direkte byte-sammenligning, der intet allokerer. Ved et hit returnerer den den cachede AnsiString, og her er typevalget afgørende: AnsiString er referencetalt, så returnering af en cachet instans koster én forøgelse af referencetælleren og nul heap-trafik. WideString har ingen referencetæller, og hver tildeling går gennem SysAllocString, så internering af WideStrings ville intet spare. Internering er kun værd at gøre på den referencetalte strengtype

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;

Den anden ændring angriber celleteksten. Den gamle sti opbyggede et AnsiString-token, overleverede det til UTF8ToWideString, som opbyggede en UnicodeString-mellemting, der til sidst blev konverteret to den WideString, cellen gemmer: To Delphi-MM-allokeringer pr. teksttoken før den rigtige. Erstatningen, XmlUtf8ToWide(TokenPtr, TokenLen), is en to-trins ren Pascal UTF-8-dekoder, der læser direkte fra scanningsbufferen: Trin et måler UTF-16-længden, trin to dekoder til en WideString allokeret én gang. Nettoomkostning pr. teksttoken: Én COM-allokering, nul Delphi-MM-allokeringer. En semantisk bemærkning til den forsigtige: Ved fejlbehæftede UTF-8-sekvenser sender den nye dekoder bytes direkte igennem i stedet for at indsætte erstatningstegn på den måde, som RTL'en gør, hvilket kun påvirker, hvordan beskadigede filer forringes; på gyldigt input er outputtet byte-identisk. XML-tegnenheder når aldrig frem til dekoderen, fordi scanneren allerede har opløst dem til UTF-8 i tokenbufferen

Hvad gav det af resultater, og hvor parallel fortolkning stadig ikke hjælper

De to rettelser skar allokeringerne pr. celle ned fra ca. 20 til 9,1, og de parallelle tal bevægede sig på den måde, som teorien sagde, de ville. På det samme 8-arks, 5.000-rækkers benchmark og den samme 6C12T-maskine gik forbedringen med 8 tråde fra 14% til 47,4%, hvilket er en ×1,90 hastighedsforøgelse over den serielle. Tilfældet med 2 tråde svingede fra 26% langsommere til 23,6% hurtigere, og den målte CPU-udnyttelse steg fra ×1,0 til ×2,2. Den serielle sti blev ca. 3% hurtigere som en bonus, da færre allokeringer også hjælper en enkelt tråd. De resterende ~9 allokeringer pr. celle er groft sagt halvt celleobjekter og halvt afskrevet beholderudvidelse; vi målte dem, vurderede, at afkastet var aftagende, og stoppede, med MM-wrapperen klar til at måle igen pr. kaldested, hvis en fremtidig arbejdsbyrde berettiger en ny runde

Grænserne er værd at nævne lige så tydeligt som gevinsterne. HotXLS paralleliserer på regnearksniveau, så en arbejdsbog, der består af ét gigantisk ark, fortolkes på én tråd uanset, hvad ParallelParseThreads angiver; til denne struktur er den streamede direkte læser det bedste værktøj, da den helt undgår at materialisere arbejdsbogen. Filer, hvis tid går med fase C-dele — tegninger, diagrammer og kommentarer — ser mindre fordel, fordi denne fase forbliver seriel af design. Små filer er slet ikke værd at tråde, hvilket er grunden til, at afsenderen (dispatcher) stiltiende kører serielt ved ubetydelige jobantal. Og loftet for hukommelsesstyringen er ikke forsvundet, kun rykket tilbage: Ved 9,1 allokeringer pr. celle belaster den globale lås stadig arbejdetrådene, hvilket er grunden til, at otte tråde giver ×1,90 frem for ×4. For det bredere værktøjssæt til at reducere indlæsnings- og lagringstider, herunder typografier, puljer og masse-række-callbacks, se vores guide til ydeevne for store arbejdsbøger i Delphi

Parallel XLSX-fortolkning, egenskaberne ParallelParse og ParallelParseThreads, og den allokerings-lette XML-læser, der er beskrevet her, leveres som standarddele af HotXLS Delphi Excel Component, som læser og skriver XLS, XLSX og ODS indfødt fra Delphi og C++Builder uden involvering af Excel-automatisering