Tehnični članak

Vzporedno analiziranje XLSX v Delphiju: Ozko grlo upravljalnika pomnilnika

HotXLS, izvorna knjižnica Excel za Delphi in C++Builder, analizira delovne liste XLSX na več nitih prek treh faz nalaganja: XML delovnega lista se stisne zaporedno, analizira vzporedno, majhni deli pa se nato preberejo zaporedno. Prva različica te funkcije je prinesla le 12–25 % izboljšanje, ker je privzeta zaklenitev upravljalnika pomnilnika Delphi serializirala delovne niti. Zmanjšanje števila dodelitev kopice (heap allocations) s približno 20 na 9,1 na celico je dvignilo vzporedni pospešek na 1,90-kratnik na osmih nitih. Ta članek opisuje meritve, napačne odločitve in dva popravka, ki sta dejansko delovala

Kako HotXLS vzporedno analizira delovne liste XLSX?

HotXLS razdeli metodo Open na tri faze, pri čemer na delovnih nitih poteka le srednja faza. Razlog za to je vsebnik ZIP: arhiv ZIP je en skupni vhodni tok z eno inflacijsko stanjem (inflate state machine), tega stanja pa ne moreta hkrati brati dve niti. Uporaba zaklepanja bi bila nesmiselna, saj je inflacija po svoji naravi zaporedna na vnos, zato bi zaklepanje le poustvarilo zaporedno izvajanje z dodatnimi stroški. Faza A zato dekomprimira XML vsakega delovnega lista v lasten TMemoryStream, medtem ko se še vedno izvaja v eni niti; v naši testni datoteki je to trajalo približno 4 ms za osem delov delovnih listov, tako da to še zdaleč ni ozko grlo. Faza B zažene ParseWorksheetXml za vsak list v naboru delovnih niti, kjer se odvija skoraj ves čas nalaganja. Faza C se zaporedno vrne k ZIP vsebniku za majhne dele: komentarje, risbe, grafikone in tabele

Sam nabor delovnih niti je namerno preprost. Delovne niti prevzamejo indekse opravil iz skupnega števca z uporabo InterlockedIncrement, tako da se listi neenakomernih velikosti naravno uravnotežijo brez kakršnega koli planerja. Število niti je določeno kot min(sheet count, CPU cores), prva izjema delovne niti pa se zajame s klicem AcquireExceptionObject in ponovno sproži v glavni niti po pridružitvi, razpošiljalnik pa preklopi na navadno zaporedno zanko, ko ni opravil ali pa je le eno. Delovanje nadzorujeta dve lastnosti razreda TXLSXWorkbook: ParallelParse omogoča nabor delovnih niti, ParallelParseThreads pa omejuje število niti, pri čemer 0 pomeni samodejno izbiro. Delovni zvezki z več listi so tisti, ki imajo od tega korist, vključno s tistimi, ki jih ustvarite s večkratnim podvajanjem predloge delovnega lista

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;

Zakaj dodajanje niti upočasni analiziranje XLSX v Delphiju?

Zato, ker privzeti upravljalnik pomnilnika Delphi ščiti svojo kopico z globalnim zaklepanjem, analiziranje delovnih listov pa zahteva ogromno alokacij: milijone celic, variant (Variants) in širokih nizov (WideStrings). Vsaka delovna nit, ki se dotakne kopice, se postavi v vrsto za to zaklepanje, tako da se niti, ki so v izvorni kodi videti neodvisne, v praksi izvajajo skoraj ena za drugo. Naš prvi preizkus je to naredil boleče očitno. Na delovnem zvezku z 8 listi, ki je imel 5.000 vrstic in 4 stolpce na list, izmerjen na procesorju i5-11600K (6 jeder, 12 niti) pod Win64, se je vzporedni Open izboljšal le za 12–25 % v primerjavi s pričakovanimi vsaj 40 %. Preizkus s številom niti 2, 3, 4, 6 in 8 je dal ravno krivuljo, v kasnejših merjenih zagonih pa je bila konfiguracija z 2 nitma celo za 26 % počasnejša od zaporedne, kar je klasičen znak dveh niti, ki se borita za isto zaklenjeno mesto

Tri meritve so potrdile diagnozo in vsaka od njih je ovrgla prejšnje predvidevanje. Prvič, drobna datoteka (8 listov po 1 vrstico) se je odprla v 1,2 ms, kar dokazuje, da analiziranje predstavlja skoraj 100 % časa Open in ni bilo skritega fiksnega stroška, ki bi ga lahko krivili. Drugič, test čistega kroženja alokacij je pokazal, da se Delphi upravljalnik pomnilnika meri nazaj: enak skupni obseg 2 milijonov alokacij objektov in nizov AnsiString je na 8 nitih potekal 60 % počasneje kot na eni, medtem ko se je enako kroženje na kopici WideString, ki je COM BSTR alokator in ne Delphi MM, povečalo na 3,7-kratnik. To, da HotXLS uporablja WideString v celotnem obsegu, se je izkazalo za naključje v našo korist. Tretjič, GetProcessTimes je pokazal, da je med vzporednim Open čas procesorja približno ustrezal dejanskemu času: osem nominalnih niti je porabilo približno 1,3 niti vredno moči procesorja. Delovne niti se niso vrttele, ampak so spale v poti prepira upravljalnika pomnilnika, torej so bile blokirane in ne zaposlene

Splošna lekcija velja tudi izven preglednic. Če delovna obremenitev v Delphiju intenzivno alocira pomnilnik, dodajanje niti ne prinese ničesar, dokler se stopnja alokacij ne zniža, in lahko zadevo zlahka poslabša. Pred tem popravkom smo uporabnikom, ki so nastavljali ParallelParseThreads, povedali pošteno resnico: na datotekah, omejenih z alokacijami, več niti ni prineslo skoraj ničesar

Od kod prihaja 20 alokacij kopice na celico?

Števni ovijalnik, nameščen s SetMemoryManager, je natančno odgovoril na to vprašanje: približno 20 alokacij Delphi-MM na celico, od tega 2,87 milijona alokacij velikosti 32 bajtov ali manj. Krivec sploh niso bili objekti celic. Klic TXMLScaner.GetTokenValue je ustvaril nov AnsiString ob vsakem klicu, ta pa se pokliče približno 15–20-krat na celico: enkrat za imena elementov, imena atributov, vrednosti atributov in vsebino besedila. Poleg tega je RTL funkcija UTF8ToWideString ustvarila začasni vmesni UnicodeString za vsako pretvorbo. Objekti celic so predstavljali le 160 tisoč alokacij, približno 8 % vseh, kar je takoj pokopalo naš prvotni načrt: nameravali smo zgraditi nabor objektov celic (cell object pool), številke pa so pokazale, da se to nikoli ne bi povrnilo

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

To desetminutno diagnostiko je vredno prevzeti za katero koli preiskavo delovanja v Delphiju. Štetje alokacij po velikostnih razredih ne stane skoraj nič, pove pa vam, od kod dejansko izvira pritisk na upravljalnik pomnilnika, kar sta bili v našem primeru dve navadi na ravni RTL znotraj bralnika XML in ne v objektnem modelu. Profilerji so še naprej kazali na parser kot celoto, ovijalnik pa je pokazal na dve specifični vrstici

Popravek: interniranje žetonov in UTF-8 dekoder brez vmesnih alokacij

Dve ciljni spremembi v bralniku XML sta odstranili več kot polovico alokacij na celico, ne da bi vplivali na strukturo parserja. Prva je interniranje imen elementov (element-name interning). XML delovnega lista neskončno ponavlja majhen nabor besed: row, c, v, r, t, s in nekaj imen atributov. Klic InternTokenName ohranja predpomnilnik s 64 mesti za prej videna imena in primerja bralni odložišče s shranjenim vnosom prek TokenEqualsAnsi, kar je neposredna primerjava bajtov, ki ne alocira ničesar. Ob zadetku vrne predpomnjeni AnsiString, tukaj pa je izbira tipa pomembna: AnsiString ima števec sklicev, zato vračanje predpomnjenega primerka stane en prirastek števca sklicev in nič alokacij kopice. WideString nima števca sklicev in vsaka dodelitev poteka prek SysAllocString, zato interniranje WideStringov ne bi prineslo ničesar. Interniranje je smiselno izvajati le na tipu nizov s števcem sklicev

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;

Druga sprememba se nanaša na besedilo celice. Stara pot je zgradila žeton AnsiString, ga predala UTF8ToWideString, ki je zgradil vmesni UnicodeString, ta pa je bil končno pretvorjen v WideString, ki ga celica shranjuje: dve alokaciji Delphi-MM na žeton besedila pred tisto pravo. Zamenjava, XmlUtf8ToWide(TokenPtr, TokenLen), je dvofazni čisti dekoder UTF-8 v Pascalu, ki bere neposredno iz bralnega odložišča: prva faza izmeri dolžino UTF-16, druga faza pa dekodira v enkrat alociran WideString. Neto strošek na žeton besedila: ena alokacija COM, nič alokacij Delphi-MM. Ena semantična opomba za previdne: pri napačnih zaporedjih UTF-8 novi dekoder prepusti bajte naprej, namesto da bi vstavil nadomestne znake, kot to počne RTL, kar vpliva le na to, kako se poškodovane datoteke degradirajo; pri veljavnem vhodu je izhod bajtno enak. Entitete znakov XML nikoli ne dosežejo dekoderja, ker jih je bralnik že razrešil v UTF-8 v odložišču žetonov

Kaj je to prineslo in kje vzporedno analiziranje še vedno ne bo pomagalo

Dva popravka sta zmanjšala alokacije na celico s približno 20 na 9,1, vzporedne številke pa so se premaknile tako, kot je predvidevala teorija. Na istem preizkusu z 8 listi in 5.000 vrsticami ter na istem stroju 6C12T se je izboljšanje z 8 nitmi dvignilo s 14 % na 47,4 %, kar je 1,90-kratni pospešek v primerjavi z zaporednim delovanjem. Primer z 2 nitma se je obrnil s 26 % počasnejšega na 23,6 % hitrejšega, izmerjena poraba procesorja pa se je dvignila z 1,0-kratnika na 2,2-kratnik. Zaporedna pot je kot bonus postala za približno 3 % hitrejša, saj manj alokacij pomaga tudi eni niti. Preostalih ~9 alokacij na celico predstavlja približno polovico objektov celic in polovico amortizirane rasti vsebnika; izmerili smo jih, ocenili, da so donosi padajoči, in se ustavili, pri čemer je MM ovijalnik pripravljen za ponovno vzorčenje, če bo prihodnja delovna obremenitev upravičila nov krog

Meje je treba zapisati tako jasno kot zmage. HotXLS vzporedno deluje na ravni delovnih listov, zato se delovni zvezek, ki je en sam velikanski list, analizira na eni niti, ne glede na to, kaj določa ParallelParseThreads; za to obliko je pretočni neposredni bralnik boljše orodje, saj se izogne materializaciji delovnega zvezka. Datoteke, katerih čas gre v dele Faze C, kot so risbe, grafikoni in komentarji, vidijo manj koristi, ker ta faza po zasnovi ostaja zaporedna. Majhnih datotek sploh ni smiselno niti povezovati, zato razpošiljalnik tiho deluje zaporedno za nepomembno število opravil. Tudi strop upravljalnika pomnilnika ni izginil, le umaknil se je: pri 9,1 alokacijah na celico globalno zaklepanje še vedno obremenjuje delovne niti, zato osem niti prinaša 1,90-kratni in ne 4-kratni pospešek. Za širši nabor orodij za zmanjšanje časa nalaganja in shranjevanja glejte naš vodnik o delovanju velikih delovnih zvezkov v Delphiju

Vzporedno analiziranje XLSX, lastnosti ParallelParse in ParallelParseThreads ter na alokacijah varčen bralnik XML, opisani tukaj, so del standardnih delov komponente HotXLS Delphi Excel Component, ki bere in piše XLS, XLSX in ODS v Delphiju in C++Builderju brez avtomatizacije Excela