Műszaki cikk

Párhuzamos XLSX elemzés Delphi-ben: A memóriakezelő szűk keresztmetszete

A HotXLS, a natív Delphi és C++Builder Excel könyvtár, háromfázisú betöltéssel értelmezi az XLSX munkalapokat több szálon: a munkalap XML-je sorosan kicsomagolódik, párhuzamosan elemződik, a kisebb részek pedig ezután sorosan olvasódnak be; a funkció első kiadása mindössze 12–25% javulást hozott, mivel a Delphi alapértelmezett memóriakezelő zárolása sorosította a munkaszálakat; a kupaclefoglalások cellánkénti durván 20-ról 9,1-re történő csökkentése nyolc szálon ×1,90-es párhuzamos gyorsulást eredményezett; ez a cikk bemutatja a méréseket, a tévutakat és a két megoldást, amely valóban működött

Hogyan elemzi a HotXLS az XLSX munkalapokat párhuzamosan?

A HotXLS három fázisra osztja fel az Open-t, és csak a középső fut munkaszálakon; ennek oka a zip tároló: a zip archívum egyetlen megosztott bemeneti adatfolyam egyetlen kicsomagoló (inflate) állapotgéppel, és ezt az állapotgépet nem olvashatja egyszerre két szál; zárolásba csomagolni felesleges lenne, mert a kicsomagolás természeténél fogva soros bejegyzésenként, így a zárolás csak a soros végrehajtást reprodukálná extra rezsivel; az A fázis ezért még egyszálú üzemmódban kicsomagolja az egyes munkalapok XML-je a saját TMemoryStream-jébe — a benchmark fájlunkban ez kb. 4 ms-ot vett igénybe nyolc munkalaprészre, így messze nincs a szűk keresztmetszet közelében; a B fázis lefuttatja a ParseWorksheetXml-t minden lapra a munkamenet-készleten, itt lakik a betöltési idő szinte egésze; a C fázis sorosan visszatér a zip-hez a kisebb részekért: megjegyzésekért, rajzokért, diagramokért és táblázatokért

Maga a munkaszál-készlet szándékosan egyszerű; a munkaszálak az InterlockedIncrement segítségével húzzák le a feladatindexeket egy megosztott számlálóból, így az egyenlőtlen méretű munkalapok természetes módon kiegyensúlyozódnak ütemező nélkül is; a szálak száma min(lapok száma, CPU magok száma), az első munkaszál-kivételt az AcquireExceptionObject segítségével rögzítik, és a szálak összevárása után újra kiváltják a főszálon, a vezérlő pedig egyszerű soros ciklusra esik vissza, ha nulla vagy egy feladat van; a TXLSXWorkbook két tulajdonsága vezérli ezt a funkciót: a ParallelParse nyitja meg a szálkészletet, a ParallelParseThreads pedig maximalizálja a szálak számát, ahol a 0 az automatikus működést jelenti; a több munkalapot tartalmazó munkafüzetek élvezik ennek előnyét, beleértve azt a típust is, amelyet egy sablon-munkalap többszöri duplikálásával hoz létre

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.ParallelParse := True;      // párhuzamos munkaszál-készlet engedélyezése
    Book.ParallelParseThreads := 0;  // 0 = auto: min(lapok, CPU magok)
    if Book.Open('quarterly-ledger.xlsx') <= 0 then
      raise Exception.Create('open failed');
    // ... cellák olvasása a szokásos módon; a munkafüzet teljesen létrejött ...
  finally
    Book.Free;
  end;
end;

Miért teszi a szálak hozzáadása lassabbá az XLSX elemzést Delphi-ben?

Mivel a Delphi alapértelmezett memóriakezelője egy globális zárolással védi a kupacát, a munkalapok elemzése pedig lefoglalás-intenzív: cellák, Variantok és WideStringek millióit érinti; minden munkaszál, amely hozzáér a kupachoz, sorban áll azon a zároláson, így a forráskódban függetlennek tűnő szálak a gyakorlatban szinte egymás után hajtódnak végre; az első benchmarkunk ezt fájdalmasan konkréttá tette: egy 8 lapból álló, laponként 5000 sort és 4 oszlopot tartalmazó munkafüzeten mérve, Win64 alatt egy i5-11600K processzoron (6 mag, 12 szál), a párhuzamos Open mindössze 12–25%-ot javult a legalább 40%-os tervbecsléssel szemben; a szálak számának 2, 3, 4, 6 és 8 szálra történő tesztelése lapos görbét eredményezett, a későbbi műszerezett futások során pedig a 2-szálas konfiguráció valójában 26%-kal lassabb volt, mint a soros, ami a két szál közötti zárolásért folyó harc klasszikus jele

Három mérés pontosította a diagnózist, és mindegyik megdöntötte a korábbi megérzéseket; először is, egy apró fájl (8 lap, 1 sor) 1,2 ms alatt nyílt meg, ami bizonyítja, hogy az elemzés lényegében az Open 100%-át teszi ki, és nincs rejtett fix költség, amit hibáztatni lehetne; másodszor, a tiszta lefoglalási fluktuáció mikromérése azt mutatta, hogy a Delphi memóriakezelője visszafelé skálázódik: ugyanaz a 2 millió objektum- és AnsiString-lefoglalás 8 szálon 60%-kal lassabban futott, mint egy szálon, miközben a WideString kupaccal szembeni ugyanolyan fluktuáció — amely a COM BSTR lefoglalója, nem pedig a Delphi MM — ×3,7-re skálázódott; az, hogy a HotXLS mindenhol WideStringet használ, a történelemnek a mi javunkra szolgáló véletlenének bizonyult; harmadszor, a GetProcessTimes megmutatta, hogy a párhuzamos Open során a CPU-idő nagyjából megegyezett a valós idővel: a nyolc névleges szál körülbelül 1,3 szálnak megfelelő CPU-t fogyasztott; a munkaszálak nem pörögtek; a memóriakezelő várakozási útvonalán aludtak, inkább blokkolva voltak, mint elfoglalva

A gyakorlati tanulság általánosítható a táblázatokon túl is; ha egy Delphi munkafolyamat erősen lefoglal memory-t, a szálak számának növelése semmit sem ér, amíg a lefoglalási arány le nem csökken, és könnyen ronthat a helyzeten; a javítás előtt a ParallelParseThreads-et hangoló felhasználóknak a tiszta igazságot mondtuk: a lefoglaláshoz kötött fájloknál a több szál szinte semmit sem jelentett

Where do 20 heap allocations per cell come from?

Honnan származik a cellánkénti 20 kupaclefoglalás?

A SetMemoryManager segítségével telepített számláló wrapper pontosan megválaszolta ezt a kérdést: körülbelül 20 Delphi-MM lefoglalás cellánként, amelyekből 2,87 millió 32 bájtos vagy az alatti volt; a bűnös egyáltalán nem a cellaobjektumok voltak; a TXMLScaner.GetTokenValue minden híváskor egy új AnsiStringet hozott létre, és ez cellánként nagyjából 15–20 alkalommal hívódik meg: egyszer-egyszer az elemnevekre, attribútumnevekre, attribútumértékekre és a szöveges tartalomra; ráadásul az RTL UTF8ToWideString útvonala egy ideiglenes UnicodeString köztes elemet gyártott minden konverziónál; a cellaobjektumok mindössze 160 ezer lefoglalásért feleltek, a teljes mennyiség körülbelül 8%-áért, ami azonnal megölte az eredeti tervünket: cellaobjektum-készletet akartunk építeni, de a számok azt mondták, hogy ez soha nem térül meg

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

function CountingGetMem(Size: NativeInt): Pointer;
begin
  AtomicIncrement(AllocCount);
  if Size <= 32 then
    AtomicIncrement(TinyCount);   // az apró objektumok fluktuációja, amivel törődünk
  Result := OldMM.GetMem(Size);
end;

// telepítés az Open előtt, visszaállítás utána
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);

A megoldás: token-internálás és köztes elem nélküli UTF-8 dekóder

Az XML olvasón végrehajtott két célzott változtatás a cellánkénti lefoglalások több mint felét megszüntette anélkül, hogy az elemző szerkezetét érintette volna; az első az elemnevek internálása (interning); a munkalap XML végtelenül ismétel egy apró szókincset: row, c, v, r, t, s és néhány attribútumnevet; az InternTokenName egy 64 helyes gyorsítótárat tart fenn a korábban látott nevekből, és összehasonlítja a szkenner pufferét a gyorsítótárazott bejegyzéssel a TokenEqualsAnsi segítségével, ami egy közvetlen bájthasonlítás lefoglalás nélkül; találat esetén a gyorsítótárazott AnsiStringet adja vissza, és itt a típusválasztás számít: az AnsiString referenciapos-számlált, így a gyorsítótárazott példány visszaadása egyetlen referenciapont-növelésbe kerül, kupac-forgalom nélkül; a WideString nem referenciapos-számlált, és minden hozzárendelés a SysAllocString-en megy keresztül, így a WideStringek internálása semmit sem takarítana meg; az internálást csak a referenciapos-számlált karakterlánc típuson érdemes elvégezni

function TXMLScaner.InternTokenName: AnsiString;
var
  Slot: Integer;
begin
  Slot := TokenHash mod 64;
  if TokenEqualsAnsi(FInternNames[Slot]) then
    Result := FInternNames[Slot]    // csak refcount++, nincs lefoglalás
  else
  begin
    Result := GetTokenValue;        // létrehozás egyszer, majd gyorsítótárazás
    FInternNames[Slot] := Result;
  end;
end;

A második változtatás a cellaszöveget érinti; a régi útvonal egy AnsiString tokent épített fel, átadta az UTF8ToWideString-nek, amely egy köztes UnicodeStringet készített, amelyet végül a cella által tárolt WideStringre konvertált: két Delphi-MM lefoglalás szöveges tokenenként a tényleges előtt; a helyettesítő XmlUtf8ToWide(TokenPtr, TokenLen) egy kétlépéses, tiszta Pascal UTF-8 dekóder, amely közvetlenül a vizsgálati pufferből olvas: az első lépés méri a UTF-16 hosszt, a második lépés pedig egy egyszer lefoglalt WideStringbe dekódol; a nettó költség szöveges tokenenként: egy COM lefoglalás, nulla Delphi-MM lefoglalás; egy szemantikai megjegyzés az óvatosaknak: a hibás UTF-8 sorozatoknál az új dekóder átengedi a bájtokat ahelyett, hogy helyettesítő karakterekkel pótolná őket, mint az RTL, ami csak a sérült fájlok leépülési módját befolyásolja; érvényes bemenet esetén a kimenet bájt-azonos; az XML karakterentitások soha nem érik el a dekódert, mert a szkenner már feloldotta őket UTF-8-ra a token-pufferben

Mit hozott ez, és hol nem segít továbbra sem a párhuzamos elemzés?

A két javítás a cellánkénti lefoglalásokat körülbelül 20-ról 9,1-re csökkentette, a párhuzamos számok pedig a várt irányba mozdultak el; ugyanazon a 8 lapos, 5000 soros benchmarkon és ugyanazon a 6C12T gépen a 8-szálas javulás 14%-ról 47,4%-ra emelkedett, ami ×1,90-es gyorsulást jelent a soroshoz képest; a 2-szálas eset a 26%-kal lassabbról 23,6%-kal gyorsabbra váltott, a mért CPU-kihasználtság pedig ×1,0-ről ×2,2-re nőtt; a soros útvonal bónuszként kb. 3%-kal gyorsabb lett, mivel a kevesebb lefoglalás egyetlen szálnak is segít; a megmaradó ~9 lefoglalás cellánként nagyjából fele cellaobjektum és fele amortizált tárolónövekedés; megmértük őket, a megtérülést csökkenőnek ítéltük, és megálltunk, a memóriakezelő-figyelő készen áll arra, hogy újra mintát vegyen a hívás helye szerint, ha egy jövőbeli terhelés indokolna egy újabb kört

A határokat érdemes ugyanolyan egyértelműen kimondani, mint a győzelmeket; a HotXLS munkalap-granularitásban párhuzamosít, így az egyetlen óriási lapot tartalmazó munkafüzet egyetlen szálon értelmeződik, függetlenül attól, hogy mit mond a ParallelParseThreads; ehhez a formához a streaming közvetlen olvasó a jobb eszköz, mivel egyáltalán elkerüli a munkafüzet felépítését; azok a fájlok, amelyeknél az idő a C fázis részeire — rajzokra, diagramokra és megjegyzésekre — megy el, kevesebb előnyt látnak, mert ez a fázis tervezés szerint soros marad; a kis fájlokat egyáltalán nem érdemes szálasítani, ezért a vezérlő csendben sorosan fut a jelentéktelen feladatszámoknál; és a memóriakezelő korlátja sem tűnt el, csak hátrébb szorult: cellánkénti 9,1 lefoglalásnál a globális zárolás továbbra is terheli a munkaszálakat, ezért nyújt nyolc szál ×1,90-es gyorsulást ×4 helyett; a betöltési és mentési idők csökkentésének tágabb eszközkészletéről, beleértve a stílusokat, készleteket és tömeges sor-visszahívásokat, olvassa el a nagy munkafüzetek teljesítményéről Delphi-ben szóló útmutatót

A párhuzamos XLSX elemzés, a ParallelParse és a ParallelParseThreads tulajdonságok, valamint az itt leírt lefoglalásszegény XML olvasó a HotXLS Delphi Excel Component szabványos részei, amely natívan olvassa és írja az XLS, XLSX és ODS formátumokat Delphi és C++Builder környezetben, Excel automatizáció használata nélkül