Tehnički članak

Paralelno XLSX analiziranje u Delphiju: usko grlo upravitelja memorije

HotXLS, izvorna Excel knjižnica za Delphi i C++Builder, analizira XLSX radne listove na više niti kroz trofazno učitavanje: XML radnog lista dekomprimira se serijski, analizira paralelno, a mali dijelovi se nakon toga čitaju serijski. Prvo izdanje te značajke donijelo je ubrzanje od samo 12–25%, jer je zadano zaključavanje upravitelja memorije u Delphiju serijaliziralo radne niti. Smanjenjem alokacija na hrpi s približno 20 na 9.1 po ćeliji podiglo je paralelno ubrzanje na ×1.90 na osam niti. Ovaj članak prolazi kroz mjerenja, pogrešne smjerove i dva ispravka koja su stvarno uspjela

Kako HotXLS paralelno analizira XLSX radne listove?

HotXLS dijeli poziv Open na tri faze, a samo se srednja izvodi na radnim nitima (worker threads). Razlog je ZIP spremnik: ZIP arhiva je jedan zajednički ulazni tok s jednim inflate strojem stanja, a taj stroj stanja ne mogu čitati dvije niti istovremeno. Zamatanje u blokadu (lock) bilo bi besmisleno, jer je inflate po svojoj prirodi serijski po stavci, pa bi blokada samo reproducirala serijsko izvršavanje uz dodatne troškove. Faza A stoga dekomprimira XML svakog lista u vlastiti TMemoryStream dok je još uvijek jednonitna; u našoj testnoj datoteci to je trajalo oko 4 ms za osam dijelova lista, pa to uopće nije usko grlo. Faza B pokreće ParseWorksheetXml za svaki list na skupu radnih niti (worker pool), gdje se nalazi gotovo svo vrijeme učitavanja. Faza C se serijski vraća na ZIP radi manjih dijelova: komentara, crteža, grafikona i tablica

Sam skup radnih niti namjerno je jednostavan. Radnici povlače indekse poslova iz zajedničkog brojača pomoću InterlockedIncrement, tako da se listovi nejednake veličine prirodno uravnotežuju bez ikakvog raspoređivača. Broj niti je min(broj listova, procesorske jezgre), prva iznimka radne niti hvata se s AcquireExceptionObject i ponovno pokreće na glavnoj niti nakon spajanja (join), a dispečer se degradira na običnu serijsku petlju kada ima nula ili jedan posao. Dva svojstva klase TXLSXWorkbook upravljaju ovom značajkom: ParallelParse otvara skup niti, a ParallelParseThreads ograničava broj niti, pri čemu 0 označava automatski odabir. Radne knjige s više listova su oblik koji ima najviše koristi, uključujući i one koje proizvedete dupliciranjem predloška lista desecima puta

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;

Zašto dodavanje niti usporava XLSX analiziranje u Delphiju?

Zato što zadani upravitelj memorije u Delphiju štiti svoju hrpu (heap) globalnom blokadom, a analiziranje listova je gusto ispunjeno alokacijama: ćelije, varijante i široki nizovi znakova (WideStrings) broje se u milijunima. Svaka radna nit koja dodirne hrpu čeka u redu za tu blokadu, pa se niti koje u izvornom kodu izgledaju neovisno u praksi izvršavaju gotovo jedna po jedna. Naš prvi test učinio je to bolno konkretnim. Na radnoj knjizi s 8 listova i 5000 redaka puta 4 stupca po listu, izmjereno na procesoru i5-11600K (6 jezgri, 12 niti) pod Win64, paralelni Open poboljšao se za samo 12–25% u usporedbi s planiranom procjenom od najmanje 40%. Ispitivanje broja niti kroz 2, 3, 4, 6 i 8 niti proizvelo je ravnu krivulju, a u kasnijim mjerenjima konfiguracija s 2 niti bila je zapravo 26% sporija od serijske, što je klasični potpis dviju niti koje se izmjenjuju na spornoj blokadi

Tri mjerenja potvrdila su dijagnozu, a svako od njih preokrenulo je prethodnu intuiciju. Prvo, sićušna datoteka (8 listova od po 1 retka) otvorila se za 1.2 ms, dokazujući da analiziranje čini praktički 100% vremena Open i da nema skrivenih fiksnih troškova. Drugo, mikrotest čistog kruženja alokacija pokazao je da upravitelj memorije Delphija skalira unatrag: isti ukupni volumen od 2 milijuna alokacija objekata i AnsiStringova radio je 60% sporije na 8 niti nego na jednoj, dok je isto kruženje na WideString hrpi, koja koristi COM BSTR alokator umjesto Delphi MM-a, skaliralo na ×3.7. To što HotXLS posvuda koristi WideString pokazalo se kao povijesna slučajnost koja radi u našu korist. Treće, GetProcessTimes pokazao je da je tijekom paralelnog poziva Open procesorsko vrijeme bilo približno oznaci stvarnog vremena: osam nominalnih niti trošilo je oko 1.3 procesorske niti. Radne niti nisu se vrtjele u prazno; spavale su u stazi sukoba upravitelja memorije, blokirane umjesto zaposlene

Praktična pouka se generalizira i izvan proračunskih tablica. Ako radno opterećenje Delphija intenzivno alocira memoriju, povećanje broja niti ne pomaže sve dok se stopa alokacija ne smanji, a lako može pogoršati stvari. Prije ovog ispravka, korisnicima koji su podešavali ParallelParseThreads govorili smo poštenu istinu: na datotekama s gustim alokacijama, više niti nije donosilo gotovo ništa

Odakle dolazi 20 alokacija na hrpi po ćeliji?

Brojački omotač (counting wrapper) instaliran pomoću SetMemoryManager precizno je odgovorio na to pitanje: oko 20 alokacija Delphi MM-a po ćeliji, od kojih je 2.87 milijuna bilo od 32 bajta ili manje. Krivac uopće nisu bili objekti ćelija. TXMLScaner.GetTokenValue stvarao je novi AnsiString pri svakom pozivu, a poziva se otprilike 15–20 puta po ćeliji: po jednom za nazive elemenata, nazive atributa, vrijednosti atributa i tekstualni sadržaj. Povrh toga, RTL-ova funkcija UTF8ToWideString proizvodila je privremeni UnicodeString kao međuproizvod za svaku pretvorbu. Objekti ćelija činili su samo 160 tisuća alokacija, oko 8% ukupnog broja, što je odmah uništilo naš prvotni plan: namjeravali smo izgraditi skup objekata ćelija (pool), a brojevi su rekli da se to nikada ne bi isplatilo

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

Tu desetominutnu dijagnostiku vrijedi primijeniti za svako istraživanje performansi u Delphiju. Brojanje alokacija po veličinama ne košta gotovo ništa za implementaciju, a govori vam odakle točno potječe pritisak na upravitelja memorije, što su u našem slučaju bile dvije navike na razini RTL-a unutar XML skenera, a ne bilo što u modelima objekata. Profileri su stalno upirali prstom u parser u cjelini; omotač je ukazao na dva specifična retka

Ispravak: interniranje tokena i UTF-8 dekoder bez međukoraka

Dvije ciljane promjene u XML čitaču uklonile su više od polovice alokacija po ćeliji bez mijenjanja strukture parsera. Prva je interniranje naziva elemenata. XML radnog lista beskonačno ponavlja mali skup riječi: row, c, v, r, t, s i nekoliko naziva atributa. InternTokenName održava predmemoriju od 64 mjesta za prethodno viđene nazive i uspoređuje međuspremnik skenera s predmemoriranim unosom pomoću TokenEqualsAnsi, što je izravna usporedba bajtova koja ne alocira ništa. U slučaju podudaranja, vraća predmemorirani AnsiString, i ovdje je izbor tipa važan: AnsiString koristi brojanje referenci (reference counted), pa vraćanje predmemorirane instance košta jedno povećanje broja referenci i nula prometa na hrpi. WideString nema brojanje referenci i svako dodjeljivanje prolazi kroz SysAllocString, pa interniranje WideStringova ne bi uštedjelo ništa. Interniranje se isplati raditi samo na tipu niza koji koristi brojanje referenci

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 promjena odnosi se na tekst ćelija. Stara staza gradila je AnsiString token, predavala ga funkciji UTF8ToWideString koja je gradila privremeni UnicodeString, koji se konačno pretvarao u WideString koji ćelija sprema: dvije alokacije Delphi MM-a po tekstualnom tokenu prije one prave. Zamjena, XmlUtf8ToWide(TokenPtr, TokenLen), je UTF-8 dekoder napisan u čistom Pascalu koji radi u dva prolaza i čita izravno iz skenerovog međuspremnika: prvi prolaz mjeri duljinu UTF-16, a drugi dekodira u WideString alociran samo jednom. Neto trošak po tekstualnom tokenu: jedna COM alokacija, nula alokacija Delphi MM-a. Jedna semantička napomena za oprezne: na neispravnim UTF-8 sekvencama novi dekoder propušta bajtove umjesto zamjene znakovima zamjene kao što to radi RTL, što utječe samo na degradaciju oštećenih datoteka; na ispravnom unosu izlaz je identičan u bajt. XML entiteti znakova nikada ne dolaze do dekodera jer ih je skener već razriješio u UTF-8 u međuspremniku tokena

Što smo time dobili, i gdje paralelno analiziranje i dalje neće pomoći

Dva ispravka smanjila su alokacije po ćeliji s oko 20 na 9.1, a paralelni brojevi pomaknuli su se onako kako je teorija predviđala. Na istom testu s 8 listova i 5000 redaka te na istom 6C12T stroju, poboljšanje s 8 niti skočilo je s 14% na 47.4%, što je ×1.90 ubrzanje u odnosu na serijsko. Slučaj s 2 niti prešao je s 26% sporijeg na 23.6% brži, a izmjereno iskorištenje procesora poraslo je s ×1.0 na ×2.2. Serijska staza postala je oko 3% brža kao bonus, budući da manje alokacija pomaže i jednoj niti. Preostalih ~9 alokacija po ćeliji otprilike su pola objekti ćelija, a pola amortizirani rast spremnika; izmjerili smo ih, procijenili da su povrati sve manji i zaustavili se, pri čemu je MM omotač spreman za ponovno uzorkovanje prema mjestu poziva ako buduće opterećenje opravda još jednu rundu

Granice vrijedi navesti jednako jasno kao i pobjede. HotXLS se paralelizira na razini radnog lista, tako da se radna knjiga koja je jedan golemi list analizira na jednoj niti bez obzira na to što kaže ParallelParseThreads; za taj oblik, strujni izravni čitač je bolji alat, jer uopće izbjegava materijalizaciju radne knjige. Datoteke čije vrijeme odlazi na dijelove Faze C — crteže, grafikone i komentare — vide manje koristi jer ta faza po dizajnu ostaje serijska. Male datoteke uopće ne vrijedi paralelizirati, zbog čega dispečer tiho pokreće serijski rad za zanemariv broj poslova. Gornja granica upravitelja memorije nije nestala, samo se povukla: pri 9.1 alokacija po ćeliji, globalna blokada i dalje opterećuje radne niti, zbog čega osam niti daje ×1.90 umjesto ×4. Za širi skup alata za smanjenje vremena učitavanja i spremanja, uključujući stilove, poolove i skupne povratne pozive redaka, pogledajte naš vodič za performanse velikih radnih knjiga u Delphiju

Paralelno analiziranje XLSX-a, svojstva ParallelParse i ParallelParseThreads te XML čitač s niskim brojem alokacija opisan ovdje dolaze kao standardni dijelovi komponente HotXLS Delphi Excel Component, koja izvorno čita i piše XLS, XLSX i ODS iz Delphija i C++Buildera bez ikakve automatizacije Excela