Articol tehnic

Analiza sintactică paralelă XLSX în Delphi: blocajul managerului de memorie

HotXLS, biblioteca nativă Excel pentru Delphi și C++Builder, analizează sintactic foile de calcul XLSX pe fire de execuție multiple printr-o încărcare în trei etape: fișierul XML al foii este decomprimat în serie, analizat în paralel, iar componentele mici sunt citite ulterior în serie. Prima versiune a acestei caracteristici a obținut o îmbunătățire de doar 12–25%, deoarece blocarea managerului de memorie implicit din Delphi a serializat firele de execuție secundare. Reducerea alocărilor de stivă de la aproximativ 20 la 9.1 per celulă a crescut accelerarea paralelă la ×1.90 pe opt fire de execuție. Acest articol prezintă măsurătorile, abordările greșite și cele două remedieri care au funcționat în realitate

HotXLS împarte procesul Open în trei etape, și doar cea de-a doua rulează pe firele de execuție secundare. Motivul este containerul zip: o arhivă zip este un flux de intrare partajat cu o singură mașină de stări pentru expandare (inflate), iar acea mașină de stări nu poate fi citită de două fire în același timp. Blocarea ei ar fi inutilă, deoarece expandarea este în mod inerent serială pentru fiecare înregistrare, astfel încât o blocare ar reproduce pur și simplu execuția serială cu costuri suplimentare. Prin urmare, Etapa A decomprimă XML-ul fiecărei foi de calcul în propriul flux TMemoryStream în timp ce rulează pe un singur fir; în fișierul nostru de test, acest lucru a durat aproximativ 4 ms pentru opt componente de foi, deci nu reprezintă deloc blocajul principal. Etapa B rulează ParseWorksheetXml pentru fiecare foaie pe un grup de fire de execuție secundare, unde se concentrează aproape tot timpul de încărcare. Etapa C revine la arhiva zip în serie pentru componentele mici: comentarii, desene, grafice și tabele

De ce utilizarea firelor de execuție încetinește analiza XLSX în Delphi?

Deoarece managerul de memorie implicit din Delphi își protejează stiva (heap) cu o blocare globală, iar analiza foilor de calcul generează un volum mare de alocări: milioane de celule, tipuri Variant și WideString. Fiecare fir de execuție secundar care accesează stiva așteaptă la acea blocare, astfel încât firele care par independente în codul sursă rulează în practică aproape pe rând. Primul nostru test de performanță a evidențiat acest lucru în mod clar: pe un registru cu 8 foi de 5.000 de rânduri pe 4 coloane per foaie, măsurat pe un procesor i5-11600K (6 nuclee, 12 fire de execuție) sub Win64, metoda paralelă Open s-a îmbunătățit cu doar 12–25% față de o estimare inițială de cel puțin 40%. O analiză a numărului de fire pe 2, 3, 4, 6 și 8 fire a produs o curbă plată, iar în testele ulterioare configurația cu 2 fire a fost de fapt cu 26% mai lentă decât cea serială, semnul clasic a două fire care blochează alternativ aceeași resursă

Trei măsurători au confirmat diagnosticul, fiecare dintre ele contrazicând presupunerile anterioare. În primul rând, un fișier foarte mic (8 foi de câte 1 rând) s-a deschis în 1.2 ms, dovedind că analiza reprezintă practic 100% din Open și nu a existat un cost fix ascuns. În al doilea rând, un test de performanță al alocărilor a arătat că managerul de memorie din Delphi își reduce eficiența la adăugarea de fire: același volum total de 2 milioane de alocări de obiecte și AnsiString a rulat cu 60% mai lent pe 8 fire decât pe unul singur, în timp ce alocările pentru stiva WideString — care utilizează alocatorul COM BSTR, nu managerul Delphi — s-au accelerat de ×3.7 ori. Faptul că HotXLS folosește WideString s-a dovedit a fi un detaliu istoric în avantajul nostru. În al treilea rând, funcția GetProcessTimes a arătat că, în timpul unui apel paralel Open, timpul CPU a fost aproximativ egal cu timpul real: opt fire nominale consumau aproximativ resursele a 1.3 fire. Firele secundare nu funcționau activ, ci se aflau în stare de așteptare în calea de blocare a managerului de memorie

Lecția practică este valabilă și dincolo de foile de calcul. Dacă o sarcină Delphi realizează multe alocări, creșterea numărului de fire nu are niciun efect până când rata de alocare nu scade, putând chiar să înrăutățească performanța. Înainte de această remediere, le spuneam utilizatorilor care reglat ParallelParseThreads adevărul: pe fișierele limitate de alocări, firele suplimentare nu aduceau aproape niciun beneficiu

De unde provin cele 20 de alocări de stivă per celulă?

Un wrapper de contorizare instalat prin SetMemoryManager a oferit răspunsul exact: aproximativ 20 de alocări Delphi-MM per celulă, dintre care 2.87 milioane au fost de 32 de octeți sau mai puțin. Problema nu o reprezentau deloc obiectele celulelor. Metoda TXMLScaner.GetTokenValue genera un AnsiString nou la fiecare apel, fiind apelată de aproximativ 15–20 de ori per celulă: câte o dată pentru numele elementelor, numele atributelor, valorile atributelor și conținutul text. În plus, funcția RTL UTF8ToWideString genera un obiect intermediar temporar UnicodeString pentru fiecare conversie. Obiectele celulelor reprezentau doar 160 de mii de alocări, aproximativ 8% din total, ceea ce a anulat planul nostru inițial: intenționam să construim un pool de obiecte celule, însă cifrele arătau că nu va fi rentabil

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

Această metodă de diagnosticare de zece minute merită utilizată în orice investigație de performanță Delphi. Contorizarea alocărilor pe categorii de dimensiuni este simplu de realizat și indică sursa presiunii asupra managerului de memorie, care în cazul nostru a fost reprezentată de două rutine la nivel RTL din scanerul XML, nu de elemente din modelul de obiecte. Profilerele continuau să indice analizatorul în ansamblu; wrapperul a indicat două linii specifice de cod

Remedierea: stocarea unică a token-urilor și un decodificator UTF-8 fără obiecte intermediare

Două modificări specifice în cititorul XML au eliminat mai mult de jumătate din alocările per celulă, fără a afecta structura analizatorului. Prima este stocarea unică (interning) a numelor de elemente. Fișierul XML al foii repetă un vocabular restrâns la nesfârșit: row, c, v, r, t, s și câteva nume de atribute. Metoda InternTokenName menține o memorie cache cu 64 de poziții pentru numele întâlnite anterior și compară bufferul scanerului cu o înregistrare din cache prin TokenEqualsAnsi, o comparație directă de octeți care nu generează alocări. În caz de potrivire, returnează instanța AnsiString din cache, iar aici tipul de date contează: AnsiString folosește contorizarea referințelor, așa că returnarea unei instanțe stocate costă o incrementare a contorului și zero activitate pe stivă. Tipul WideString nu folosește contorizarea referințelor, iar fiecare atribuire trece prin SysAllocString, deci stocarea unică a WideString-urilor nu ar fi economisit nimic. Stocarea unică merită realizată doar pentru tipurile de șiruri cu contorizarea referințelor

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;

A doua modificare vizează textul celulelor. Calea veche construia un token AnsiString, îl transmitea către UTF8ToWideString, care genera un intermediar UnicodeString, convertit în final în WideString-ul pe care îl stochează celula: două alocări Delphi-MM per token de text înainte de cea reală. Înlocuitorul, XmlUtf8ToWide(TokenPtr, TokenLen), este un decodificator UTF-8 în Pascal pur care citește direct din bufferul de scanare: prima trecere măsoară lungimea UTF-16, a doua decodifică într-un WideString alocat o singură dată. Costul net per token: o alocare COM, zero alocări Delphi-MM. O mențiune semantică: în cazul secvențelor UTF-8 incorecte, noul decodificator transmite octeții mai departe în loc să introducă caractere de înlocuire așa cum face RTL, ceea ce afectează doar modul în care se degradează fișierele corupte; pe datele corecte, rezultatul este identic. Entitățile de caractere XML nu ajung niciodată la decodificator, deoarece scanerul le-a convertit deja în UTF-8 în bufferul de token-uri

Care au fost rezultatele și unde analiza paralelă nu va fi de ajutor

Cele două remedieri au redus alocările per celulă de la aproximativ 20 la 9.1, iar rezultatele paralele au evoluat conform teoriei. Pe același test cu 8 foi și 5.000 de rânduri, pe aceeași mașină 6C12T, îmbunătățirea pe 8 fire a crescut de la 14% la 47.4%, o accelerare de ×1.90 față de varianta serială. Cazul cu 2 fire a evoluat de la o încetinire de 26% la o accelerare de 23.6%, iar utilizarea CPU a crescut de la ×1.0 la ×2.2. Calea serială a devenit cu aproximativ 3% mai rapidă, deoarece mai puține alocări ajută și un singur fir. Cele ~9 alocări rămase per celulă reprezintă aproximativ jumătate obiectele celulelor și jumătate creșterea amortizată a containerului; le-am măsurat, am considerat că beneficiile suplimentare sunt reduse și ne-am oprit, wrapperul MM fiind pregătit pentru noi măsurători dacă o sarcină viitoare va justifica acest lucru

Limitele merită menționate la fel de clar ca și reușitele. HotXLS realizează paralelizarea la nivel de foaie de calcul, astfel încât un registru format dintr-o singură foaie uriașă este analizat pe un singur fir, indiferent de setarea ParallelParseThreads; pentru această structură, cititorul direct în flux (streaming direct reader) este instrumentul mai potrivit, deoarece evită complet stocarea registrului în memorie. Fișierele al căror timp este consumat de componentele din Etapa C — desene, grafice și comentarii — înregistrează beneficii mai reduse, deoarece acea etapă rămâne serială prin proiectare. Fișierele mici nu justifică utilizarea firelor de execuție, motiv pentru care dispecerul rulează în serie pentru volume reduse. Iar limita managerului de memorie nu a dispărut, ci doar a fost redusă: la 9.1 alocări per celulă, blocarea globală afectează în continuare performanța, motiv pentru care opt fire generează o accelerare de ×1.90, nu de ×4. Pentru setul extins de instrumente destinate reducerii timpilor de încărcare și salvare, inclusiv stiluri, pool-uri și apeluri de rânduri, consultați ghidul nostru despre performanța registrelor de lucru mari în Delphi

Analiza paralelă XLSX, proprietățile ParallelParse și ParallelParseThreads, precum și cititorul XML cu alocare redusă descrise aici sunt livrate ca componente standard în HotXLS Delphi Excel Component, care citește și scrie în mod nativ fișiere XLS, XLSX și ODS din Delphi și C++Builder fără a necesita automatizarea Excel