Knihovna HotXLS, nativní Excel knihovna pro Delphi a C++Builder, analyzuje listy XLSX na více vláknech prostřednictvím třífázového načítání: XML listu se dekomprimuje sériově, analyzuje paralelně a malé části se poté čtou sériově. První verze této funkce přinesla zrychlení pouze o 12–25 %, protože výchozí zámek správce paměti v Delphi serializoval práci pracovních vláken. Snížení alokací na haldě (heap allocations) ze zhruba 20 na 9,1 na buňku zvýšilo paralelní zrychlení na 1,90násobek při osmi vláknech. Tento článek vás provede měřeními, chybnými kroky a dvěma opravami, které skutečně fungovaly
Jak HotXLS analyzuje listy XLSX paralelně?
HotXLS rozděluje metodu Open do tří fází a pouze ta prostřední běží na pracovních vláknech. Důvodem je zip kontejner: zip archiv je jeden sdílený vstupní stream s jedním stavovým automatem pro dekompresi (inflate) a tento stavový automat nemohou číst dvě vlákna současně. Obalit jej zámkem by bylo zbytečné, protože dekomprese je z principu sériová pro každou položku, takže by zámek pouze zopakoval sériové vykonávání s dodatečnou režijní zátěží. Fáze A proto dekomprimuje XML každého listu do jeho vlastního TMemoryStream, dokud běží jednovláknově; v našem benchmarkovém souboru to trvalo asi 4 ms pro osm částí listů, což se zdaleka neblíží úzkému hrdlu. Fáze B spouští ParseWorksheetXml pro každý list na fondu pracovních vláken (worker pool), což je místo, kde leží téměř veškerý čas načítání. Fáze C se vrací k zipu sériově kvůli malým částem: komentářům, kresbám, grafům a tabulkám
Samotný fond pracovních vláken je záměrně jednoduchý. Pracovníci stahují indexy úloh ze sdíleného počítadla pomocí InterlockedIncrement, takže listy nerovnoměrné velikosti se přirozeně vyvažují bez jakéhokoli plánovače. Počet vláken je roven min(počet listů, jádra CPU), první výjimka pracovníka je zachycena pomocí AcquireExceptionObject a znovu vyvolána na hlavním vlákně po dokončení a dispečer degraduje na obyčejnou sériovou smyčku, pokud je úloh nula nebo jedna. Funkci řídí dvě vlastnosti na TXLSXWorkbook: ParallelParse spouští fond a ParallelParseThreads omezuje počet vláken, přičemž 0 znamená automaticky. Výhodu přinášejí sešity s více listy, včetně těch, které vytvoříte duplikováním šablonového listu desítkykrát
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;
Proč přidání vláken zpomaluje analýzu XLSX v Delphi?
Protože výchozí správce paměti v Delphi chrání svou haldu (heap) globálním zámkem a analýza listu je extrémně náročná na alokace: buňky, Varianty a WideStringy po milionech. Každý pracovník, který se dotkne haldy, se zařadí do fronty na tomto zámku, takže vlákna, která v kódu vypadají nezávisle, se v praxi vykonávají téměř po jednom. Náš první benchmark to ukázal bolestně konkrétně. Na sešitu s 8 listy o 5 000 řádcích a 4 sloupcích na list, měřeno na procesoru i5-11600K (6 jader, 12 vláken) pod Win64, se paralelní Open zlepšil pouze o 12–25 % oproti odhadu plánu ve výši nejméně 40 %. Testování počtu vláken napříč 2, 3, 4, 6 a 8 vlákny vytvořilo plochou křivku a v pozdějších měřených bězích byla konfigurace se 2 vlákny dokonce o 26 % pomalejší než sériová, což je klasický podpis dvou vláken přetahujících se o zatížený zámek
Tři měření potvrdila tuto diagnózu a každé z nich vyvrátilo předchozí domněnky. Za prvé, maličký soubor (8 listů o 1 řádku) se otevřel za 1,2 ms, což dokazuje, že analýza tvoří v podstatě 100 % času Open a na vině nebyly žádné skryté fixní náklady. Za druhé, mikrobenchmark čistého alokačního shonu ukázal, že správce paměti Delphi škáluje obráceně: stejný celkový objem 2 milionů alokací objektů a AnsiStringů běžel na 8 vláknech o 60 % pomaleji než na jednom, zatímco stejný shon na haldě WideStringů, což je alokátor COM BSTR namísto Delphi MM, škáloval na 3,7násobek. To, že HotXLS všude používá WideString, se ukázalo jako historická náhoda pracující v náš prospěch. Za třetí, GetProcessTimes ukázal, že během paralelního Open čas procesoru zhruba odpovídal reálnému času: osm nominálních vláken spotřebovávalo výkon odpovídající přibližně 1,3 vlákna. Pracovníci neběželi naprázdno v aktivním čekání; spali v cestě kolizí správce paměti, zablokovaní spíše než vytížení
Praktické poučení přesahuje rámec tabulek. Pokud je zátěž v Delphi silně závislá na alokacích, zvýšení počtu vláken nic nepřinese, dokud neklesne míra alokací, a může situaci snadno zhoršit. Před touto opravou jsme uživatelům ladícím vlastnost ParallelParseThreads říkali upřímnou pravdu: u souborů limitovaných alokacemi nepřinášelo více vláken téměř nic
Odkod se bere 20 alokací na haldě na buňku?
Počítací obal nainstalovaný pomocí SetMemoryManager na tuto otázku odpověděl přesně: přibližně 20 alokací Delphi-MM na buňku, přičemž 2,87 milionu z nich mělo velikost 32 bajtů nebo méně. Viníkem vůbec nebyly objekty buněk. Metoda TXMLScaner.GetTokenValue vytvářela nový AnsiString při každém volání a volá se zhruba 15–20krát na buňku: po jednom pro názvy prvků, názvy atributů, hodnoty atributů a textový obsah. Navíc cesta RTL UTF8ToWideString vytvářela dočasný UnicodeString jako mezistupeň při každém převodu. Objekty buněk tvořily pouze 160 tisíc alokací, asi 8 % z celkového počtu, což okamžitě zmařilo náš původní plán: měli jsme v úmyslu vytvořit fond objektů buněk, ale čísla říkala, že se to nikdy nevyplatí
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);
Tento desetiminutový diagnostický postup stojí za to převzít pro jakékoli vyšetřování výkonu v Delphi. Počítání alokací podle velikostních košů nestojí téměř nic a ukáže vám, odkud tlak na správce paměti skutečně pochází, což v našem případě byly dva návyky na úrovni RTL uvnitř XML skeneru spíše než cokoli v objektovém modelu. Profilery neustále ukazovaly na parser jako celek; tento wrapper ukázal na dva konkrétní řádky
Oprava: internování tokenů a UTF-8 dekodér bez mezistupňů
Dvě cílené změny v XML čtečce odstranily více než polovinu alokací na buňku, aniž by se dotkly struktury parseru. První z nich je internování názvů prvků. XML listu neustále opakuje malou slovní zásobu: row, c, v, r, t, s a hrstku názvů atributů. Metoda InternTokenName udržuje cache o 64 pozicích pro dříve viděné názvy a porovnává buffer skeneru s nacachovanou položkou pomocí TokenEqualsAnsi, což je přímé porovnání bajtů, které nic nealokuje. Při shodě vrátí nacachovaný AnsiString, přičemž zde záleží na volbě typu: AnsiString má počítadlo odkazů (reference counted), takže vrácení nacachované instance stojí jedno zvýšení počítadla odkazů a nulový provoz na haldě. WideString nemá počítadlo odkazů a každé přiřazení prochází přes SysAllocString, takže internování WideStringů by nic neušetřilo. Internování má smysl provádět pouze u typů řetězců s počítáním odkazů
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;
Druhá změna se zaměřuje na text buněk. Stará cesta sestavila token AnsiString, předala jej do UTF8ToWideString, který sestavil mezistupeň UnicodeString, jenž byl nakonec převeden na WideString, který buňka ukládá: dvě alokace Delphi-MM na jeden textový token před tím skutečným. Náhrada, XmlUtf8ToWide(TokenPtr, TokenLen), je dvouprůchodový čistě pascalovský UTF-8 dekodér, který čte přímo ze skenovacího bufferu: první průchod změří délku UTF-16, druhý průchod dekóduje do WideStringu alokovaného pouze jednou. Čisté náklady na textový token: jedna COM alokace, nula alokací Delphi-MM. Jedna sémantická poznámka pro opatrné: u poškozených sekvencí UTF-8 nový dekodér propouští bajty dál, namísto nahrazování znaků způsobem, jakým to dělá RTL, což ovlivňuje pouze to, jak degradují poškozené soubory; na platném vstupu je výstup bajtově identický. Znakové entity XML se k dekodéru vůbec nedostanou, protože skener je již vyřešil do UTF-8 v bufferu tokenů
Co to přineslo a kde paralelní analýza stále nepomůže
Dvě opravy snížily alokace na buňku z přibližně 20 na 9,1 a paralelní čísla se posunula tak, jak teorie předpovídala. Na stejném benchmarku s 8 listy a 5 000 řádky a stejném stroji 6C12T se zlepšení na 8 vláknech se zvýšilo ze 14 % na 47,4 %, což představuje 1,90násobné zrychlení oproti sériovému běhu. Případ se 2 vlákny se otočil z 26 % pomalejšího na o 23,6 % rychlejší a naměřené využití CPU vzrostlo z 1,0násobku na 2,2násobek. Sériová cesta se jako bonus zrychlila o cca 3 %, protože méně alokací pomáhá i jednomu vláknu. Zbývajících ~9 alokací na buňku tvoří zhruba z poloviny objekty buněk a z poloviny amortizovaný růst kontejneru; změřili jsme je, vyhodnotili výnosy jako klesající a zastavili se, přičemž MM wrapper je připraven na nové vzorkování podle místa volání, pokud budoucí zatížení ospravedlní další kolo
Limity stojí za to uvést stejně jasně jako úspěchy. HotXLS paralelizuje na úrovni granulity listů, takže sešit, který tvoří jeden obří list, se analyzuje na jednom vlákně bez ohledu na to, co říká vlastnost ParallelParseThreads; pro tento tvar je lepším nástrojem streamovací přímá čtečka, protože se zcela vyhne materializaci sešitu. Soubory, jejichž čas se spotřebovává v částech Fáze C — kresbách, grafech a komentářích — zaznamenávají menší přínos, protože tato fáze zůstává z návrhu sériová. Malé soubory nestojí za to vůbec paralelizovat, a proto dispečer u triviálních počtů úloh potichu běží sériově. A strop správce paměti nezmizel, pouze ustoupil: při 9,1 alokacích na buňku globální zámek pracovníky stále zatěžuje, a proto osm vláken přináší 1,90násobné zrychlení namísto 4násobného. Pro širší sadu nástrojů na zkrácení doby načítání a ukládání, včetně stylů, fondů a hromadných zpětných volání řádků, se podívejte do našeho průvodce výkonem velkých sešitů v Delphi
Paralelní analýza XLSX, vlastnosti ParallelParse a ParallelParseThreads a na alokace nenáročná čtečka XML popsaná v tomto článku jsou dodávány jako standardní součásti komponenty HotXLS Delphi Excel Component, která čte a zapisuje formáty XLS, XLSX a ODS nativně v Delphi a C++Builderu bez jakékoli automatizace Excelu