„HotXLS“ – vietinė Delphi ir C++Builder Excel biblioteka – analizuoja XLSX darbinius lapus keliose gijose per trijų etapų įkėlimą: lapo XML išpakuojamas nuosekliai, analizuojamas lygiagrečiai, o mažos dalys po to skaitomos nuosekliai. Pirmasis šios funkcijos leidimas padidino našumą tik 12–25 %, nes numatytasis Delphi atminties tvarkyklės užraktas nuoseklino darbines gijas. Sumažinus krūvos (heap) paskirstymą nuo maždaug 20 iki 9,1 vienam langeliui, lygiagretus pagreitėjimas aštuoniose gijose pakilo iki 1,90 karto. Šiame straipsnyje aptariami matavimai, klaidingi žingsniai ir du pataisymai, kurie iš tikrųjų padėjo
Kaip „HotXLS“ lygiagrečiai analizuoja XLSX darbinius lapus?
„HotXLS“ padalija Open į tris etapus, ir tik vidurinis veikia darbinėse gijose. Priežastis yra ZIP konteineris: ZIP archyvas yra vienas bendrinamas įvesties srautas su viena išpakavimo būsenos mašina, o šios būsenos mašinos negali skaityti dvi gijos vienu metu. Apvynioti ją užraktu būtų beprasmiška, nes išpakavimas iš esmės yra nuoseklus kiekvienam įrašui, todėl užraktas tiesiog atkartotų nuoseklų vykdymą su papildomomis sąnaudomis. Todėl A etape kiekvieno darbinio lapo XML išpakuojamas į jo paties TMemoryStream, kol vis dar dirbama vienoje gijoje; mūsų palyginamajame faile tai užtruko apie 4 ms aštuonioms lapo dalims, todėl tai toli gražu nėra butelio kaklelis. B etape paleidžiamas ParseWorksheetXml kiekvienam lapui darbinių gijų telkinyje – būtent čia praleidžiama beveik visa įkėlimo dalis. C etape nuosekliai grįžtama prie ZIP failo dėl mažų dalių: komentarų, piešinių, diagramų ir lentelių
Darbinių gijų telkinys pats savaime yra sąmoningai paprastas. Gijos paima užduočių indeksus iš bendro skaitiklio naudodamos InterlockedIncrement, do lapai subalansuojami natūraliai be jokio planuoklio. Gijų skaičius yra min(sheet count, CPU cores), pirmoji darbinės gijos išimtis užfiksuojama su AcquireExceptionObject ir vėl sukeliama pagrindinėje gijoje po sujungimo, o dispečeris pereina prie paprasto nuoseklaus ciklo, kai yra nulis arba viena užduotis. Dvi TXLSXWorkbook savybės valdo šią funkciją: ParallelParse įjungia telkinį, o ParallelParseThreads riboja gijų skaičių, kur 0 reiškia automatinį nustatymą. Darbo knygos su keliais lapais yra ta forma, kuri gauna daugiausiai naudos, įskaitant tą, kurią sukuriate dešimtis kartų dubliuodami šablono darbinį lapą
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;
Kodėl gijų pridėjimas sulėtino XLSX analizavimą Delphi aplinkoje?
Kadangi numatytoji Delphi atminties tvarkyklė apsaugo savo krūvą globaliu užraktu, o darbinio lapo analizavimas reikalauja itin daug paskirstymų: milijonai langelių, variantų (Variants) bei WideStrings. Kiekviena darbinė gija, kuri paliečia krūvą, stoja į eilę prie šio užrakto, todėl gijos, kurios kodo šaltinyje atrodo nepriklausomos, praktiškai vykdomos po vieną. Mūsų pirmasis testas tai parodė skaudžiai konkrečiai. Darbo knygoje su 8 lapais po 5 000 eilučių ir 4 stulpelius lape, matuojant i5-11600K procesoriumi (6 branduoliai, 12 gijų) Win64 aplinkoje, lygiagretus Open pagerėjo tik 12–25 %, palyginti su planuotu bent 40 % įverčiu. Gijų skaičiaus kitimas per 2, 3, 4, 6 ir 8 gijas davė plokščią kreivę, o vėlesniuose matavimuose 2 gijų konfigūracija buvo net 26 % lėtesnė nei nuosekli – tai klasikinė profesinė situacija, kai dvi gijos varžosi dėl užrakto
Trys matavimai patvirtino diagnozę, ir kiekvienas iš jų paneigė ankstesnę nuojautą. Pirma, mažytis failas (8 lapai po 1 eilutę) atsidarė per 1,2 ms, įrodydamas, kad analizavimas iš esmės sudaro 100 % Open laiko ir nėra jokių paslėptų fiksuotų išlaidų. Antra, gryno atminties paskirstymo mikroskopinis testas parodė, kad Delphi atminties tvarkyklė skaluojasi atgal: tas pats bendras 2 milijonų objektų ir AnsiString paskirstymų tūris aštuoniose gijose veikė 60 % lėčiau nei vienoje gijoje, tuo tarpu tas pats paskirstymas prieš WideString krūvą, kuri yra COM BSTR paskirstytojas, o ne Delphi MM, išsiplėtė iki 3,7 karto. Tai, kad „HotXLS“ visur naudoja WideString, pasirodė esąs istorinis atsitiktinumas, suveikęs mūsų naudai. Trečia, GetProcessTimes parodė, kad lygiagretaus Open metu procesoriaus laikas (CPU time) buvo maždaug lygus realiam laikui (wall time): aštuonios nominalios gijos sunaudojo maždaug 1,3 gijos verto procesoriaus pajėgumo. Darbinės gijos nesisuko tuščiai – jos miegojo atminties tvarkyklės ginčų kelyje (contention path), buvo užblokuotos, o ne užimtos
Praktinė pamoka tinka ne tik lentelėms. Jei Delphi užduotis atlieka daug paskirstymų, gijų skaičiaus didinimas neduoda nieko, kol nesumažėja paskirstymo greitis, ir tai gali lengvai pabloginti situaciją. Prieš šį pataisymą vartotojams, derinantiems ParallelParseThreads, sakydavome tiesą: failuose, kurie apriboti atminties paskirstymais, papildomos gijos nedavė beveik nieko
Iš kur atsiranda 20 krūvos paskirstymų vienam langeliui?
Skaičiavimo apvalkalas (wrapper), įdiegtas su SetMemoryManager, tiksliai atsakė į šį klausimą: maždaug 20 Delphi-MM paskirstymų vienam langeliui, iš kurių 2,87 milijono buvo 32 baitų ar mažesni. Kaltininkas buvo visai ne langelių objektai. TXMLScaner.GetTokenValue sukurdavo naują AnsiString su kiekvienu iškvietimu, o jis iškviečiamas maždaug 15–20 kartų vienam langeliui: po kartą elementų pavadinimams, atributų pavadinimams, atributų reikšmėms ir tekstiniam turiniui. Be to, RTL UTF8ToWideString kelias kūrė laikiną tarpinį UnicodeString kiekvienai konversijai. Langelių objektai sudarė tik 160 tūkstančių paskirstymų, t. y. apie 8 % visų paskirstymų, kas iškart sugriovė mūsų pradinį planą: ketinome sukurti langelių objektų telkinį (pool), tačiau skaičiai parodė, kad tai niekada neatsipirks
Šią dešimties minučių diagnostiką verta perimti bet kuriam Delphi našumo tyrimui. Paskirstymų skaičiavimas pagal dydžio grupes beveik nieko nekainuoja, tačiau parodo, iš kur iš tikrųjų kyla spaudimas atminties tvarkyklei, kas mūsų atveju buvo du RTL lygio įpročiai XML skaitytuve, o ne kas nors objektų modelyje. Profiliavimo įrankiai rodė į analizatorių kaip visumą, o apvalkalas parodė į dvi konkrečias eilutes
Sprendimas: žymų internavimas ir tarpinių konversijų neturintis UTF-8 dekoderis
Du tiksliniai pakeitimai XML skaitytuve pašalino daugiau nei pusę paskirstymų langeliui, nepakeičiant analizatoriaus struktūros. Pirmasis yra elementų pavadinimų internavimas (interning). Darbinio lapo XML nuolat kartoja tą patį trumpą žodyną: row, c, v, r, t, s ir keletą atributų pavadinimų. InternTokenName palaiko 64 vietų anksčiau matytų pavadinimų talpyklą ir palygina skaitytuvo buferį su talpyklos įrašu per TokenEqualsAnsi – tiesioginį baitų palyginimą, kuris nieko nepaskirsto. Esant sutapimui, grąžinamas talpykloje saugomas AnsiString, ir čia svarbus tipo pasirinkimas: AnsiString turi nuorodų skaičių (reference counted), todėl grąžinant talpykloje saugomą egzempliorių reikia tik padidinti nuorodų skaičių ir tai nesukelia jokio srauto krūvoje. WideString neturi nuorodų skaičiaus, ir kiekvienas priskyrimas eina per SysAllocString, todėl WideString internavimas nieko nesutaupytų. Internuoti verta tik nuorodų skaičių turintį eilutės tipą
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;
Antrasis pakeitimas skirtas langelio tekstui. Senasis kelias kūrė AnsiString žymą, perdavė ją UTF8ToWideString, kuri sukūrė tarpinį UnicodeString, o šis galiausiai buvo konvertuotas į langelio saugomą WideString: du Delphi-MM paskirstymai vienai teksto žymai prieš tikrąjį. Pakeitimas – XmlUtf8ToWide(TokenPtr, TokenLen) – yra dviejų žingsnių grynas Pascal UTF-8 dekoderis, kuris skaito tiesiai iš skaitymo buferio: pirmas žingsnis išmatuoja UTF-16 ilgį, antrasis dekoduoja į vieną kartą paskirstytą WideString. Grynasis santykis teksto žymai: vienas COM paskirstymas, nulis Delphi-MM paskirstymų. Viena semantinė pastaba atsargiems: esant netinkamoms UTF-8 sekoms, naujasis dekoderis praleidžia baitus, užuot pakeitęs juos pakaitalais kaip RTL, kas turi įtakos tik tam, kaip nukenčia sugadinti failai; su teisinga įvestimi rezultatas yra visiškai identiškas. XML simbolių esybės (entities) niekada nepasiekia dekoderio, nes skaitytuvas jau išsprendė jas į UTF-8 žymų buferyje
Ką tai davė ir kur lygiagretus analizavimas vis tiek nepadės
Du pataisymai sumažino paskirstymus vienam langeliui nuo maždaug 20 iki 9,1, ir lygiagretaus darbo skaičiai pajudėjo taip, kaip turėjo pagal teoriją. Tame pačiame 8 lapų, 5 000 eilučių teste su tuo pačiu 6C12T procesoriumi, 8 gijų našumo padidėjimas pakilo nuo 14 % iki 47,4 % – tai yra 1,90 karto didesnis greitis nei nuoseklaus darbo. 2 gijų atvejis pasikeitė nuo 26 % lėtesnio iki 23,6 % greitesnio, o išmatuotas CPU panaudojimas išaugo nuo 1,0 iki 2,2 karto. Nuoseklus kelias papildomai tapo apie 3 % greitesnis, nes mažiau paskirstymų padeda ir vienai gijai. Likę ~9 paskirstymai vienam langeliui yra maždaug pusė langelių objektų ir pusė amortizuoto konteinerio augimo; išmatavome juos, nusprendėme, kad grąža mažėja, ir sustojome, o MM apvalkalas yra paruoštas pakartotiniam matavimui pagal iškvietimo vietą, jei būsimas darbo krūvis pateisins dar vieną etapą
Ribas verta įvardyti taip pat aiškiai kaip ir laimėjimus. „HotXLS“ lygiagretina darbinio lapo granularumu, todėl darbo knyga, kurią sudaro vienas milžiniškas lapas, analizuojama vienoje gijoje, nepriklausomai nuo to, ką nurodo ParallelParseThreads; tokiai formai geresnis įrankis yra srautinis tiesioginis skaitytuvas, nes jis išvis išvengia darbo knygos materializavimo. Failai, kurių laikas sunaudojamas C etapo dalims – piešiniams, diagramoms ir komentarams – mato mažesnę naudą, nes šis etapas pagal dizainą išlieka nuoseklus. Mažų failų išvis neverta skaidyti gijomis, todėl dispečeris tyliai vykdo nuoseklų darbą, kai užduočių skaičius yra nereikšmingas. Be to, atminties tvarkyklės lubos neišnyko, tik nutolo: esant 9,1 paskirstymui vienam langeliui, globalus užraktas vis tiek apkrauna darbuotojus, zodžiu, todėl aštuonios gijos duoda 1,90 karto, o ne 4 kartus didesnį našumą. Apie platesnį įrankių rinkinį, skirtą įkėlimo ir išsaugojimo laikui sutrumpinti, įskaitant stilius, telkinius bei masinius eilučių atgalinius iškvietimus, skaitykite mūsų Delphi vadove apie didelių darbo knygų našumą
Lygiagretus XLSX analizavimas, savybės ParallelParse bei ParallelParseThreads ir čia aprašytas mažai atminties paskirstantis XML skaitytuvas pateikiami kaip standartinės „HotXLS Delphi Excel Component“ dalys, kuri nuskaito ir rašo XLS, XLSX bei ODS formatus vietiniu būdu Delphi ir C++Builder aplinkose, nenaudojant „Excel“ automatizavimo