Tekninen artikkeli

Rinnakkainen XLSX-jäsentäminen Delphissä: muistinhallinnan pullonkaula

HotXLS, natiivi Delphin ja C++Builderin Excel-kirjasto, jäsentää XLSX-laskentataulukot useissa säikeissä kolmivaiheisen latauksen kautta: taulukon XML puretaan sarjamuotoisesti, jäsennetään rinnakkain, ja pienet osat luetaan sarjamuotoisesti sen jälkeen. Tämän ominaisuuden ensimmäinen julkaisu toi vain 12–25 % hyödyn, koska Delphin oletusarvoisen muistinhallinnan lukitus pakotti työsäikeet suoriutumaan peräkkäin. Kekomuistin varausten (heap allocations) leikkaaminen noin 20 varauksesta 9,1 varaukseen solua kohden nosti rinnakkaisen nopeuskertoimen arvoon 1,90x kahdeksalla säikeellä. Tässä artikkelissa käydään läpi mittaukset, harhapolut ja kaksi korjausta, jotka todella toimivat

Käytännön opetus yleistyy taulukkolaskennan ulkopuolelle. Jos Delphin työkuorma varaa muistia raskaasti, säiemäärän nostaminen ei tee mitään ennen kuin varausmäärä laskee, ja se voi helposti pahentaa tilannetta. Ennen tätä korjausta kerroimme ParallelParseThreads-asetusta säätäville käyttäjille rehellisen totuuden: varausrajoitteisissa tiedostoissa lisäsäikeet eivät tuoneet juuri mitään hyötyä

Miten HotXLS jäsentää XLSX-laskentataulukot rinnakkain?

HotXLS jakaa Open-metodin kolmeen vaiheeseen, joista vain keskimmäinen suoritetaan työsäikeillä. Syynä on zip-säiliö: zip-arkisto on yksi jaettu syötevirta, jolla on yksi inflate-tilaohjain (inflate state machine), eikä kahta säiettä voida lukea samanaikaisesti tuosta tilaohjaimesta. Sen kääriminen lukkoon olisi turhaa, koska inflate on luonnostaan sarjamuotoinen per tietue, joten lukitus vain tuottaisi sarjamuotoisen suorituksen ylimääräisellä kuormituksella. Vaihe A purkaa siksi kunkin laskentataulukon XML-tiedot omaan TMemoryStream-virtaansa vielä yksisäikeisesti. Testitiedostossamme tämä vei noin 4 ms kahdeksalle taulukon osalle, joten se on kaukana pullonkaulasta. Vaihe B ajaa ParseWorksheetXml-kutsun jokaiselle taulukolle työsäiepoolissa, missä lähes koko latausaika kuluu. Vaihe C palaa sarjamuotoisesti zip-tiedostoon pieniä osia varten: kommentteja, piirustuksia, kaavioita ja taulukoita varten

Työsäiepooli (worker pool) itsessään on tarkoituksella yksinkertainen. Työsäikeet vetävät tehtäväindeksejä jaetusta laskurista InterlockedIncrement-metodilla, joten erikokoiset taulukot tasapainottuvat luonnollisesti ilman erillistä ajoittajaa. Säikeiden määrä on min(sheets, CPU cores), työsäikeen ensimmäinen poikkeus kaapataan AcquireExceptionObject-metodilla ja nostetaan uudelleen pääsäikeessä liitoksen (join) jälkeen, ja jakaja siirtyy tavalliseen sarjasilmukkaan, kun tehtäviä on nolla tai yksi. Kaksi TXLSXWorkbook-luokan ominaisuutta ohjaa toimintoa: ParallelParse avaa ja sulkee poolin, ja ParallelParseThreads rajoittaa säikeiden määrää (0 tarkoittaa automaattista). Monisivuiset työkirjat ovat niitä, jotka hyötyvät tästä eniten, mukaan lukien työkirjat, jotka luodaan monistamalla mallilaskentataulukkoa kymmeniä kertoja

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;

Miksi säikeiden lisääminen hidastaa XLSX-jäsentämistä Delphissä?

Koska Delphin oletusarvoinen muistinhallinta suojaa kekoaan maailmanlaajuisella lukulla, ja laskentataulukon jäsentäminen on erittäin varausintensiivistä: miljoonia soluja, Variant- ja WideString-muuttujia. Jokainen kekoon koskeva työsäie jonottaa kyseistä lukkoa, joten koodissa itsenäisiltä näyttävät säikeet suoritetaan käytännössä lähes yksitellen. Ensimmäinen testimme teki tämän kivuliaan konkreettiseksi. Kahdeksansivuisella työkirjalla, jossa on 5 000 riviä ja 4 saraketta per sivu, mitattuna i5-11600K-suorittimella (6 ydintä, 12 säiettä) Win64-alustalla, rinnakkainen Open parani vain 12–25 % verrattuna vähintään 40 % suunnitteluarvioon. Säiemäärien pyyhkäisy arvoilla 2, 3, 4, 6 ja 8 tuotti tasaisen käyrän, ja myöhemmissä mittauksissa 2 säikeen kokoonpano oli todellisuudessa 26 % hitaampi kuin sarjamuotoinen suoritus — mikä on klassinen merkki kahdesta säikeestä, jotka pompottavat kiisteltyä lukkoa keskenään

Kolme mittausta varmisti diagnoosin, ja kukin niistä kumosi aiemmat oletukset. Ensinnäkin pikkuruinen tiedosto (8 sivua, 1 rivi) avautui 1,2 ms:ssa todistaen, että jäsentäminen on käytännössä 100 % Open-metodista eikä piilokustannuksia ollut. Toiseksi pelkän varausruuhkan mikrotesti osoitti Delphin muistinhallinnan skaalautuvan taaksepäin: sama 2 miljoonan olion ja AnsiString-varauksen kokonaismäärä ajettiin 8 säikeellä 60 % hitaammin kuin yhdellä, kun taas sama ruuhka WideString-keossa (joka on COM BSTR -varaaja Delphin MM:n sijaan) skaalautui kertoimeen 3,7x. Se, että HotXLS käyttää WideStringiä kauttaaltaan, osoittautui historian sattumaksi, joka toimi eduksemme. Kolmanneksi GetProcessTimes osoitti, että rinnakkaisen Open-ajon aikana CPU-aika vastasi suurin piirtein todellista kulunutta aikaa: kahdeksan nimellistä säiettä kulutti noin 1,3 säikeen verran CPU-tehoa. Työsäikeet eivät pyörineet tyhjää, vaan ne olivat unessa muistinhallinnan kiistelypolulla — estettyinä, eivät varattuina

Mistä 20 kekovarausta solua kohden ovat peräisin?

Metodilla SetMemoryManager asennettu laskeva kääre vastasi tähän kysymykseen tarkasti: noin 20 Delphi-MM-varausta solua kohden, joista 2,87 miljoonaa oli kooltaan 32 tavua tai vähemmän. Syyllinen ei suinkaan ollut soluoliot. Metodi TXMLScaner.GetTokenValue loi uuden AnsiString-muuttujan jokaisella kutsulla, ja sitä kutsutaan noin 15–20 kertaa solua kohden: kerran elementtien nimille, attribuuttien nimille, attribuuttien arvoille ja tekstisisällölle. Tämän lisäksi RTL:n UTF8ToWideString-polku loi väliaikaisen UnicodeString-välivaiheen jokaiselle muunnokselle. Soluoliot selittivät vain 160 tuhatta varausta, noin 8 % kokonaismäärästä, mikä romutti alkuperäisen suunnitelmamme saman tien: olimme aikeissa rakentaa soluolioiden poolin, mutta luvut osoittivat, ettei se koskaan maksaisi itseään takaisin

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

Tämä kymmenen minuutin diagnostiikka on hyödyllinen missä tahansa Delphin suorituskykytutkimuksessa. Varausten laskeminen kokoluokittain ei maksa juuri mitään toteuttaa ja se kertoo, mistä muistinhallinnan paine todellisuudessa johtuu. Tapauksessamme syynä oli kaksi RTL-tason tapaa XML-skannerin sisällä, ei mikään objektimallissa. Profilointityökalut viittasivat jatkuvasti jäsennysosaan kokonaisuudessaan, mutta kääre osoitti kahteen tiettyyn riviin

Korjaus: tunnisteiden tallentaminen (interning) ja välivaiheeton UTF-8-dekooderi

Kaksi kohdistettua muutosta XML-lukijassa poisti yli puolet solukohtaisista varauksista koskematta jäsentimen rakenteeseen. Ensimmäinen on elementtien nimien tallentaminen (interning). Laskentataulukon XML toistaa pientä sanastoa loputtomasti: row, c, v, r, t, s ja muutama attribuutin nimi. Metodi InternTokenName ylläpitää 64-paikkaista välimuistia aiemmin nähdyistä nimistä ja vertaa skannerin rakennuspuskuria tallennettuun tietueeseen TokenEqualsAnsi-metodilla, joka on suora tavuvertailu ilman varauksia. Osuman sattuessa se palauttaa välimuistissa olevan AnsiString-arvon. Tässä tyypin valinnalla on merkitystä: AnsiString on viitelaskettu (reference counted), joten välimuistissa olevan esiintymän palauttaminen maksaa vain yhden viitelaskurin lisäyksen ja nolla kekovarausta. WideString-tyypillä ei ole viitelaskentaa, ja jokainen sijoitus kulkee SysAllocString-metodin kautta, joten WideString-muuttujien tallentaminen ei säästäisi mitään. Tallentaminen kannattaa tehdä vain viitelasketulle merkkijonotyypille

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;

Toinen muutos kohdistuu solujen tekstiin. Vanha polku rakensi AnsiString-tunnisteen, välitti sen UTF8ToWideString-metodille, joka rakensi UnicodeString-välivaiheen, joka lopulta muunnettiin solun tallentamaksi WideString-arvoksi: kaksi Delphi-MM-varausta tekstitunnistetta kohden ennen varsinaista varausta. Korvaava XmlUtf8ToWide(TokenPtr, TokenLen) on kaksivaiheinen, puhdas Pascal-pohjainen UTF-8-dekooderi, joka lukee suoraan skannauspuskurista: ensimmäinen vaihe mittaa UTF-16-pituuden, toinen vaihe dekoodaa kerran varattuun WideString-muuttujaan. Tekstitunnisteen nettokustannus: yksi COM-varaus, nolla Delphi-MM-varausta. Semanttinen huomautus varovaisille: virheellisissä UTF-8-sarjoissa uusi dekooderi välittää tavut sellaisenaan eteenpäin korvausmerkkien sijoittamisen sijaan (kuten RTL tekee), mikä vaikuttaa vain vioittuneiden tiedostojen heikkenemistapaan. Kelvollisella syötteellä tulos on tavulleen identtinen. XML-merkkientiteetit eivät koskaan päädy dekooderille, koska skanneri on jo selvittänyt ne UTF-8-muotoon tunnisterakenteessa

Mitä sillä saavutettiin ja missä rinnakkainen jäsentäminen ei edelleenkään auta

Nämä kaksi korjausta leikkasivat solukohtaiset varaukset noin 20 varauksesta 9,1 varaukseen, ja rinnakkaiset luvut liikkuivat teorian mukaisesti. Samassa 8-sivuisessa ja 5 000-rivisessä testissä samalla 6C12T-koneella 8 säikeen parannus nousi 14 %:sta 47,4 %:iin, mikä tarkoittaa 1,90-kertaista nopeuskerrointa sarjamuotoiseen verrattuna. Kahden säikeen tapaus kääntyi 26 % hitaammasta 23,6 % nopeammaksi, ja mitattu CPU-käyttöaste nousi kertoimesta 1,0x kertoimeen 2,2x. Sarjamuotoinen polku nopeutui bonuksena noin 3 %, sillä vähemmät varaukset auttavat myös yksittäistä säiettä. Jäljelle jäävät noin 9 varausta solua kohden koostuvat karkeasti ottaen puoliksi soluolioista ja puoliksi säiliön kasvusta. Mittasimme ne, totesimme hyötyjen vähenevän ja lopetimme. MM-kääre on valmis uusiin mittauksiin kutsuittain, jos tulevat työkuormat antavat aihetta uuteen kierrokseen

Rajat on syytä todeta yhtä selkeästi kuin voitotkin. HotXLS rinnakkaistaa laskentataulukon rakeisuudella, joten työkirja, joka on yksi jättimäinen sivu, jäsennetään yhdellä säikeellä riippumatta siitä, mitä ParallelParseThreads sanoo. Tälle rakenteelle suoratoistava suoralukija (streaming direct reader) on parempi työkalu, koska se välttää työkirjan luomisen kokonaan. Tiedostot, joiden suoritusaika kuluu vaiheen C osissa (piirustukset, kaaviot ja kommentit), näkevät vähemmän hyötyä, koska tuo vaihe pysyy sarjamuotoisena suunnittelun vuoksi. Pieniä tiedostoja ei kannata säikeistää lainkaan, minkä vuoksi jakaja ajaa hiljaa sarjasuoritusta vähäisillä tehtävämäärillä. Eikä muistinhallinnan katto ole kadonnut, se on vain siirtynyt kauemmaksi: 9,1 varauksella solua kohden maailmanlaajuinen lukko rasittaa edelleen työsäikeitä, minkä vuoksi kahdeksan säiettä tuottaa 1,90-kertaisen nopeuskertoimen 4-kertaisen sijaan. Laajemmasta työkalupakista lataus- ja tallennusaikojen leikkaamiseksi, mukaan lukien tyylit, poolit ja erärivien takaisinkutsut, katso oppaamme suurten työkirjojen suorituskyvystä Delphissä

Rinnakkainen XLSX-jäsentäminen, ParallelParse- ja ParallelParseThreads-ominaisuudet sekä tässä kuvattu vähävarauksinen XML-lukija toimitetaan vakiovarusteena HotXLS Delphi Excel Component -kirjastossa, joka lukee ja kirjoittaa XLS-, XLSX- ja ODS-tiedostoja natiivisti Delphistä ja C++Builderista ilman Excel-automaatiota