HotXLS, gimtoji Delphi ir C++Builder Excel biblioteka, XLSX darbalapius analizuoja keliomis gijomis per trijų fazių įkėlimą: lapo XML išskleidžiamas nuosekliai, analizuojamas lygiagrečiai, o smulkios dalys po to nuskaitomos nuosekliai. Pirmoji šios funkcijos laida davė vos 12–25%, nes Delphi numatytojo atminties valdiklio užraktas darbines gijas surikiavo į eilę. Krūvos išskyrimų sumažinimas nuo maždaug 20 iki 9,1 vienam langeliui pakėlė lygiagretų pagreitį iki ×1,90 su aštuoniomis gijomis. Šiame straipsnyje aprašomi matavimai, klaidingi posūkiai ir du pataisymai, kurie iš tikrųjų suveikė
Kaip HotXLS lygiagrečiai analizuoja XLSX darbalapius?
HotXLS padalija Open į tris fazes, ir tik vidurinė iš jų vyksta darbinėse gijose. Priežastis yra zip konteineris: zip archyvas yra vienas bendras įvesties srautas su viena išskleidimo būsenų mašina, o tos mašinos negali skaityti dvi gijos vienu metu. Apvynioti ją užraktu būtų beprasmiška, nes išskleidimas kiekvienam įrašui iš prigimties nuoseklus, todėl užraktas tik atkurtų nuoseklų vykdymą su papildomomis sąnaudomis. Todėl A fazė kiekvieno darbalapio XML išskleidžia į atskirą TMemoryStream dar viena gija; mūsų etaloniniame faile tai užtruko apie 4 ms aštuonioms lapų dalims, taigi tai anaiptol ne siaurumas. B fazė kiekvienam lapui paleidžia ParseWorksheetXml darbinių gijų telkinyje, ir būtent čia praleidžiamas beveik visas įkėlimo laikas. C fazė nuosekliai grįžta prie zip dėl smulkių dalių: komentarų, brėžinių, diagramų ir lentelių
Pats gijų telkinys yra sąmoningai paprastas. Darbinės gijos traukia užduočių indeksus iš bendro skaitiklio su InterlockedIncrement, todėl nevienodo dydžio lapai pasiskirsto natūraliai be jokio planuoklio. Gijų skaičius yra min(lapų skaičius, procesoriaus branduoliai), pirmoji darbinės gijos išimtis pagaunama su AcquireExceptionObject ir po sujungimo iš naujo iškeliama pagrindinėje gijoje, o dispečeris nusileidžia iki paprasto nuoseklaus ciklo, kai užduočių yra nulis arba viena. Funkciją valdo dvi TXLSXWorkbook savybės: ParallelParse įjungia telkinį, o ParallelParseThreads riboja gijų skaičių, kur 0 reiškia automatiškai. Naudos gauna daugialapės darbo knygos, įskaitant tokias, kurias pagaminate dubliuodami šabloninį darbalapį keliasdešimt kartų
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.ParallelParse := True; // įjungti lygiagretų darbinių gijų telkinį
Book.ParallelParseThreads := 0; // 0 = auto: min(lapai, procesoriaus branduoliai)
if Book.Open('quarterly-ledger.xlsx') <= 0 then
raise Exception.Create('open failed');
// ... skaitykite langelius kaip įprasta; darbo knyga jau visiškai sukurta ...
finally
Book.Free;
end;
end;
Kodėl gijų pridėjimas Delphi aplinkoje sulėtina XLSX analizavimą?
Todėl, kad Delphi numatytasis atminties valdiklis savo krūvą saugo globaliu užraktu, o darbalapių analizavimas yra tankus išskyrimais: milijonai langelių, Variant ir WideString reikšmių. Kiekviena krūvą liečianti darbinė gija atsistoja į eilę prie to užrakto, todėl gijos, kurios pirminiame kode atrodo nepriklausomos, praktiškai vykdomos beveik po vieną. Pirmasis mūsų etalonas tai padarė skaudžiai konkretų. Su 8 lapų darbo knyga, kurioje kiekviename lape 5 000 eilučių ir 4 stulpeliai, matuojant i5-11600K procesoriuje (6 branduoliai, 12 gijų) su Win64, lygiagretus Open pagerėjo vos 12–25%, nors plane buvo tikimasi bent 40%. Gijų skaičiaus perrinkimas per 2, 3, 4, 6 ir 8 davė plokščią kreivę, o vėlesniuose matavimuose 2 gijų konfigūracija buvo net 26% lėtesnė už nuoseklią — klasikinis dviejų gijų, mušinėjančių varžomą užraktą, parašas
Diagnozę prismeigė trys matavimai, ir kiekvienas jų apvertė ankstesnę nuojautą. Pirma, mažytis failas (8 lapai po 1 eilutę) atsivėrė per 1,2 ms, įrodydamas, kad analizavimas sudaro iš esmės 100% Open laiko ir kad nėra jokių paslėptų fiksuotų sąnaudų, kurias būtų galima kaltinti. Antra, grynų išskyrimų srauto mikroetalonas parodė, kad Delphi atminties valdiklis mastelėja atbulai: tas pats bendras 2 milijonų objektų ir AnsiString išskyrimų kiekis aštuoniomis gijomis vyko 60% lėčiau nei viena, o tas pats srautas prieš WideString krūvą, kuri yra COM BSTR paskirstytojas, o ne Delphi MM, mastelėjo iki ×3,7. Tai, kad HotXLS visur naudoja WideString, pasirodė esąs mums palankus istorijos atsitiktinumas. Trečia, GetProcessTimes parodė, kad lygiagretaus Open metu procesoriaus laikas maždaug prilygo realiam laikui: aštuonios nominalios gijos suvartojo apie 1,3 gijos procesoriaus laiko. Darbinės gijos nesisuko tuščiai; jos miegojo atminties valdiklio varžymosi kelyje, buvo užblokuotos, o ne užimtos
Praktinė pamoka apibendrinama toli už skaičiuoklių ribų. Jei Delphi darbo krūvis daug išskirsto, gijų skaičiaus didinimas nieko neduoda, kol nesumažėja išskyrimų dažnis, ir gali lengvai viską pabloginti. Iki šio pataisymo naudotojams, derinantiems ParallelParseThreads, sakydavome sąžiningą tiesą: išskyrimais ribojamuose failuose daugiau gijų beveik nieko neduoda
Iš kur atsiranda 20 krūvos išskyrimų vienam langeliui?
Skaičiavimo apvalkalas, įdiegtas su SetMemoryManager, į šį klausimą atsakė tiksliai: apie 20 Delphi MM išskyrimų vienam langeliui, iš kurių 2,87 milijono buvo 32 baitų ar mažesni. Kaltininkai buvo visai ne langelių objektai. TXMLScaner.GetTokenValue kiekvieno iškvietimo metu sukurdavo naują AnsiString, o jis kviečiamas maždaug 15–20 kartų vienam langeliui: po kartą elementų vardams, atributų vardams, atributų reikšmėms ir teksto turiniui. Be to, RTL kelias UTF8ToWideString kiekvienam konvertavimui pagamindavo laikiną tarpinį UnicodeString. Langelių objektams teko vos 160 tūkstančių išskyrimų, apie 8% viso kiekio, ir tai vietoje nužudė mūsų pirminį planą: buvome ketinę statyti langelių objektų telkinį, o skaičiai pasakė, kad jis niekada neatsipirktų
var
OldMM, NewMM: TMemoryManagerEx;
AllocCount, TinyCount: Int64;
function CountingGetMem(Size: NativeInt): Pointer;
begin
AtomicIncrement(AllocCount);
if Size <= 32 then
AtomicIncrement(TinyCount); // mus dominantis smulkių objektų srautas
Result := OldMM.GetMem(Size);
end;
// įdiegti prieš Open, po to atstatyti
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);
Šią dešimties minučių diagnostiką verta pasiskolinti bet kuriam Delphi našumo tyrimui. Išskyrimų skaičiavimas pagal dydžio grupes kainuoja beveik nieko ir pasako, iš kur atminties valdiklio spaudimas iš tikrųjų kyla, o mūsų atveju tai buvo du RTL lygio įpročiai XML skaitytuvo viduje, o ne kas nors objektų modelyje. Profiliuokliai vis rodė į analizatorių kaip visumą; apvalkalas parodė į dvi konkrečias eilutes
Pataisymas: žymių internavimas ir UTF-8 dekoderis be tarpinių žingsnių
Du taikliai pataikyti pakeitimai XML skaitytuve pašalino daugiau nei pusę išskyrimų vienam langeliui, nė nepaliesdami analizatoriaus struktūros. Pirmasis yra elementų vardų internavimas. Darbalapio XML be perstojo kartoja mažytį žodyną: row, c, v, r, t, s ir sauja atributų vardų. InternTokenName laiko 64 vietų anksčiau matytų vardų podėlį ir lygina skaitytuvo statybinį buferį su podėlio įrašu naudodamas TokenEqualsAnsi — tiesioginį baitų palyginimą, kuris nieko neišskiria. Pataikius, grąžinamas podėlyje esantis AnsiString, ir čia tipo pasirinkimas svarbus: AnsiString yra skaičiuojamų nuorodų tipas, todėl podėlio egzemplioriaus grąžinimas kainuoja vieną nuorodų skaitiklio padidinimą ir nulį krūvos judesių. WideString nuorodų skaitiklio neturi, ir kiekvienas priskyrimas eina per SysAllocString, todėl WideString internavimas nieko nesutaupytų. Internuoti verta tik tą eilučių tipą, kuris turi nuorodų skaitiklį
function TXMLScaner.InternTokenName: AnsiString;
var
Slot: Integer;
begin
Slot := TokenHash mod 64;
if TokenEqualsAnsi(FInternNames[Slot]) then
Result := FInternNames[Slot] // tik nuorodų skaitiklis++, jokio išskyrimo
else
begin
Result := GetTokenValue; // sukurti kartą, tada įsiminti podėlyje
FInternNames[Slot] := Result;
end;
end;
Antrasis pakeitimas puola langelių tekstą. Senasis kelias sukurdavo AnsiString žymę, paduodavo ją į UTF8ToWideString, kuris sukurdavo tarpinį UnicodeString, o šis galiausiai būdavo verčiamas į WideString, kurį saugo langelis: du Delphi MM išskyrimai vienai teksto žymei dar prieš tikrąjį. Pakaitalas XmlUtf8ToWide(TokenPtr, TokenLen) yra dviejų praėjimų grynas Pascal UTF-8 dekoderis, skaitantis tiesiai iš skenavimo buferio: pirmas praėjimas išmatuoja UTF-16 ilgį, antras dekoduoja į vieną kartą išskirtą WideString. Grynosios sąnaudos vienai teksto žymei: vienas COM išskyrimas, nulis Delphi MM išskyrimų. Viena semantinė pastaba atsargiesiems: sugadintose UTF-8 sekose naujasis dekoderis baitus praleidžia pro save, užuot keitęs juos pakaitiniais simboliais, kaip daro RTL, o tai paveikia tik tai, kaip nusileidžia sugadinti failai; su taisyklinga įvestimi išvestis yra identiška baitas į baitą. XML simbolių esybės dekoderio niekada nepasiekia, nes skaitytuvas jas jau yra pavertęs UTF-8 žymių buferyje
Ką tai davė ir kur lygiagretus analizavimas vis tiek nepadės
Du pataisymai sumažino išskyrimus vienam langeliui nuo maždaug 20 iki 9,1, o lygiagretūs skaičiai pajudėjo taip, kaip teorija ir žadėjo. Su tuo pačiu 8 lapų ir 5 000 eilučių etalonu bei ta pačia 6C12T mašina 8 gijų pagerėjimas šoktelėjo nuo 14% iki 47,4%, o tai yra ×1,90 pagreitis prieš nuoseklų kelią. 2 gijų atvejis iš 26% lėtesnio virto 23,6% greitesniu, o išmatuotas procesoriaus panaudojimas pakilo nuo ×1,0 iki ×2,2. Nuoseklus kelias kaip premiją tapo apie 3% greitesnis, nes mažiau išskyrimų padeda ir vienai gijai. Likę maždaug 9 išskyrimai vienam langeliui yra maždaug pusė langelių objektų ir pusė amortizuoto konteinerių augimo; mes juos išmatavome, nusprendėme, kad grąža mažėja, ir sustojome, palikdami MM apvalkalą paruoštą perimti mėginius pagal iškvietimo vietą, jei ateities darbo krūvis pateisins dar vieną ratą
Ribas verta įvardyti taip pat aiškiai kaip laimėjimus. HotXLS lygiagretina darbalapio smulkumu, todėl darbo knyga, kurią sudaro vienas milžiniškas lapas, analizuojama viena gija, kad ir ką sakytų ParallelParseThreads; tokiai formai geresnis įrankis yra srautinis tiesioginis skaitytuvas, nes jis apskritai išvengia darbo knygos sukūrimo atmintyje. Failai, kurių laikas išeina C fazės dalims — brėžiniams, diagramoms ir komentarams — gauna mažiau naudos, nes ta fazė pagal sumanymą lieka nuosekli. Mažų failų apskritai neverta skaidyti gijomis, todėl dispečeris menkiems užduočių kiekiams tyliai dirba nuosekliai. O atminties valdiklio lubos nedingo, tik atsitraukė: prie 9,1 išskyrimo vienam langeliui globalus užraktas darbines gijas vis dar apmokestina, ir būtent todėl aštuonios gijos duoda ×1,90, o ne ×4. Platesnį įkėlimo ir išsaugojimo laiko mažinimo įrankyną, įskaitant stilius, telkinius ir masinius eilučių atgalinius kvietimus, rasite mūsų vadove apie didelių darbo knygų našumą Delphi aplinkoje
Lygiagretus XLSX analizavimas, savybės ParallelParse bei ParallelParseThreads ir čia aprašytas išskyrimų atžvilgiu taupus XML skaitytuvas pristatomi kaip standartinės HotXLS Delphi Excel komponento dalys; jis gimtai skaito ir rašo XLS, XLSX bei ODS iš Delphi ir C++Builder be jokios Excel automatizacijos