Техническа статия

Паралелно анализиране (parsing) на XLSX в Delphi: Трудности с мениджъра на паметта

HotXLS, собствената библиотека за Excel за Delphi и C++Builder, анализира XLSX работни листове в множество нишки чрез трифазно зареждане: XML на листа се декомпресира последователно, анализира се паралелно, а малките части се четат последователно след това; Първата версия на тази функция донесе само 12–25% подобрение, тъй като заключването по подразбиране на мениджъра на паметта в Delphi сериализира работните нишки; Намаляването на заделянията от хийпа (heap allocations) от около 20 на 9,1 на клетка увеличи паралелното ускорение до 1,90 пъти при осем нишки; Тази статия описва измерванията, грешните стъпки и двете корекции, които действително проработиха

Как HotXLS анализира XLSX работни листове паралелно? HotXLS разделя Open на три фази, като само средната се изпълнява в работни нишки; Причината е zip контейнерът: zip архивът представлява един споделен входящ поток с един краен автомат за декомпресиране (inflate), и този автомат не може да се чете от две нишки едновременно; Опаковането му в заключване (lock) би било безсмислено, тъй като inflate по природа е последователен за всеки запис, така че заключването просто би възпроизвело последователно изпълнение с допълнителни разходи; Поради това Фаза A декомпресира XML на всеки работен лист в негов собствен TMemoryStream, докато все още е еднонишкова; в нашия бенчмарк файл това отне около 4 ms за осем части на листове, така че изобщо не е пречка; Фаза B стартира ParseWorksheetXml за всеки лист в пул от работни нишки, където се намира почти цялото време за зареждане; Фаза C се връща към zip архива последователно за малките части: коментари, графики, диаграми и таблици

Самият пул от работни нишки (worker pool) е умишлено прост; Работните нишки извличат индекси на задачи от споделен брояч с InterlockedIncrement, така че листове с различен размер се балансират естествено без необходимост от планировчик; Броят на нишките е min(sheet count, CPU cores), първото изключение в работна нишка се улавя с AcquireExceptionObject и се предизвиква отново в основната нишка след присъединяването им, а диспечерът преминава към обикновен последователен цикъл, когато има нула или една задачи; Две свойства на TXLSXWorkbook управляват функцията: ParallelParse активира пула, а ParallelParseThreads ограничава броя на нишките, като 0 означава автоматично; Работните книги с множество листове са формата, който печели от това, включително тези, които създавате чрез дублиране на шаблон на работен лист десетки пъти

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.ParallelParse := True;      // активира паралелния пул от работни нишки
    Book.ParallelParseThreads := 0;  // 0 = авто: min(sheets, CPU cores)
    if Book.Open('quarterly-ledger.xlsx') <= 0 then
      raise Exception.Create('open failed');
    // ... четете клетките както обикновено; работната книга е напълно материализирана ...
  finally
    Book.Free;
  end;
end;

Защо добавянето на нишки прави анализирането на XLSX по-бавно в Delphi?

Защото мениджърът на паметта по подразбиране в Delphi защитава своя хийп с глобално заключване, а анализирането на работни листове изисква интензивно заделяне на памет: клетки, Variants и WideStrings с милиони; Всяка работна нишка, която докосва хийпа, се нарежда на опашка за това заключване, така че нишки, които изглеждат независими в изходния код, на практика се изпълняват почти една по една; Първият ни бенчмарк направи това болезнено ясно; Върху работна книга с 8 листа и 5000 реда по 4 колони на лист, измерена на i5-11600K (6 ядра, 12 нишки) под Win64, паралелното Open се подобри само с 12–25% спрямо прогноза от поне 40%; Измерване при 2, 3, 4, 6 и 8 нишки даде плоска крива, а при по-късни инструментални тестове конфигурацията с 2 нишки всъщност беше с 26% по-бавна от последователната — класическият сигнал за две нишки, борещи се за едно заключване

Три измервания потвърдиха диагнозата, като всяко от тях преобърна предишното предположение; Първо, малък файл (8 листа по 1 ред) се отвори за 1,2 ms, което доказва, че анализирането заема почти 100% от Open и няма скрити фиксирани разходи; Второ, микротест на чисто заделяне на памет показа обратно мащабиране на мениджъра на паметта в Delphi: същият общ обем от 2 милиона заделяния на обекти и AnsiString се изпълни с 60% по-бавно на 8 нишки, отколкото на една, докато същото натоварване срещу WideString хийпа, който е COM BSTR алокаторът, а не Delphi MM, се ускори 3,7 пъти; Това, че HotXLS използва WideString навсякъде, се оказа случайност, която работи в наша полза; Трето, GetProcessTimes показа, че по време на паралелно Open времето на процесора съответстваше на астрономическото време: осем номинални нишки консумираха процесорно време колкото за около 1,3 нишки; Работните нишки не бяха активни, а бяха в режим на изчакване в пътя на конкуренция на мениджъра на паметта, блокирани вместо заети

Практическият урок се отнася и извън електронните таблици; Ако дадено натоварване в Delphi заделя интензивно памет, увеличаването на броя на нишките не носи нищо, докато процентът на заделяне не спадне, и лесно може да влоши нещата; Преди тази корекция казвахме на потребителите, настройващи ParallelParseThreads, че при файлове с ограничение от заделяне на памет повече нишки не носят почти нищо

Откъде идват 20 заделяния от хийпа на клетка?

Брояч, инсталиран с SetMemoryManager, отговори точно на този въпрос: около 20 Delphi-MM заделяния на клетка, като 2,87 милиона от тях бяха с размер 32 байта или по-малко; Виновникът изобщо не бяха обектите на клетките; TXMLScaner.GetTokenValue създаваше нов AnsiString при всяко извикване, а той се извиква около 15–20 пъти на клетка: по веднъж за имена на елементи, имена на атрибути, стойности на атрибути и текстово съдържание; На всичкото отгоре пътят UTF8ToWideString на RTL генерираше временно UnicodeString междинно състояние за всяко преобразуване; Обектите на клетките съставляваха само 160 хиляди заделяния, около 8% от общия брой, което веднага провали първоначалния ни план: възнамерявахме да изградим пул от клетъчни обекти, но числата показаха, че това никога няма да се изплати

var
  OldMM, NewMM: TMemoryManagerEx;
  AllocCount, TinyCount: Int64;

function CountingGetMem(Size: NativeInt): Pointer;
begin
  AtomicIncrement(AllocCount);
  if Size <= 32 then
    AtomicIncrement(TinyCount);   // натоварването от малки обекти, което ни интересува
  Result := OldMM.GetMem(Size);
end;

// инсталира се преди Open, възстановява се след това
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);

Този десетминутен диагностичен метод си струва да се взаимства за всяко разследване на производителността в Delphi; Броенето на заделянията по размер не струва почти нищо и ви показва откъде действително идва натискът върху мениджъра на паметта, което в нашия случай бяха два навика на ниво RTL вътре в XML скенера, а не в самия обектен модел; Профайлърите продължаваха да сочат към целия парсер; броячът посочи два конкретни реда

Корекцията: интерниране на токени и UTF-8 декодер без междинни състояния

Две целеви промени в XML четеца премахнаха повече от половината заделяния на клетка, без да засягат структурата на парсера; Първата е интерниране на имена на елементи; XML на работния лист повтаря безкрайно малък набор от думи: row, c, v, r, t, s и няколко имена на атрибути; InternTokenName поддържа 64-позиционен кеш с вече виждани имена и сравнява буфера на скенера с кеширан запис чрез TokenEqualsAnsi — директно байтово сравнение, което не заделя памет; При съвпадение връща кеширания AnsiString и тук изборът на тип е важен: AnsiString е с броене на препратките (reference counted), така че връщането на кеширан екземпляр струва едно увеличение на брояча и нула натоварване на хийпа; WideString няма броене на препратките и всяко присвояване преминава през SysAllocString, така че интернирането на WideString не би спестило нищо; Интернирането си струва да се прави само за типа низ с броене на препратките

function TXMLScaner.InternTokenName: AnsiString;
var
  Slot: Integer;
begin
  Slot := TokenHash mod 64;
  if TokenEqualsAnsi(FInternNames[Slot]) then
    Result := FInternNames[Slot]    // само refcount++, без заделяне
  else
  begin
    Result := GetTokenValue;        // материализира се веднъж, след което се кешира
    FInternNames[Slot] := Result;
  end;
end;

Втората промяна засяга текста в клетките; Старият път изграждаше AnsiString токен, предаваше го на UTF8ToWideString, който изграждаше междинен UnicodeString, който накрая се преобразуваше в WideString, съхраняван в клетката: две Delphi-MM заделяния на текстов токен преди истинското; Новият път XmlUtf8ToWide(TokenPtr, TokenLen) е двупроходен чист Pascal UTF-8 декодер, който чете директно от буфера за сканиране: първият проход измерва дължината на UTF-16, вторият декодира в WideString, заделен веднъж; Нетна цена за текстов токен: едно COM заделяне, нула Delphi-MM заделяния; Една семантична бележка: при повредени UTF-8 последователности новият декодер пропуска байтовете, вместо да поставя заместващи символи, както прави RTL, което засяга само начина, по който се държат повредените файлове; при валиден вход резултатът е идентичен; XML символите (entities) никога не достигат до декодера, тъй като скенерът вече ги е разрешил в UTF-8 в буфера за токени

Какво донесе това и къде паралелното анализиране все още няма да помогне

Двете корекции намалиха заделянията на клетка от около 20 на 9,1 и паралелните числа се придвижиха нагоре; При същия бенчмарк с 8 листа и 5000 реда, осемнишковото подобрение отиде от 14% на 47,4%, което е 1,90 пъти ускорение спрямо последователното; Случаят с 2 нишки се превърна от 26% по-бавен в 23,6% по-бърз, а измерената натовареност на процесора се увеличи от 1,0 на 2,2; Последователният път също стана с около 3% по-бърз, тъй като по-малкото заделяния помагат и на единична нишка; Останалите около 9 заделяния на клетка се дължат на обектите на клетките и разширяването на контейнерите; Преценихме, че възвръщаемостта намалява, и спряхме дотук, като броячът на MM е готов за нови тестове при необходимост

Границите си струва да бъдат заявени също толкова ясно; HotXLS паралелизира на ниво работен лист, така че работна книга, състояща се от един огромен лист, се анализира в една нишка, независимо от ParallelParseThreads; за такъв случай поточният директен четец е по-добрият инструмент, тъй като избягва пълното материализиране на работната книга; Файлове, чието време отива във Фаза C (диаграми, графики, коментари), виждат по-малко полза, тъй като тази фаза остава последователна; Малките файлове не си струва да се нишкат, поради което диспечерът работи последователно при минимален брой задачи; Таванът на мениджъра на паметта също не е изчезнал, а само е отдалечен: при 9,1 заделяния на клетка глобалното заключване все още натоварва нишките, поради което осем нишки дават 1,90 пъти, а не 4 пъти ускорение; За по-широкия инструментариум за намаляване на времето за зареждане и запазване вижте нашето ръководство за производителност при големи работни книги в Delphi

Паралелното анализиране на XLSX, свойствата ParallelParse и ParallelParseThreads, както и икономичният откъм заделяния XML четец, описани тук, се доставят като стандартни части от HotXLS Delphi Excel Component, който чете и пише XLS, XLSX и ODS собственоръчно от Delphi и C++Builder, без използване на Excel автоматизация