Технічна стаття

Паралельний розбір XLSX у Delphi: пляшкове горло менеджера пам'яті

HotXLS, рідна бібліотека Excel для Delphi та C++Builder, розбирає робочі аркуші XLSX у кілька потоків через трифазне завантаження: XML аркуша розпаковується послідовно, розбирається паралельно, а невеликі частини зчитуються послідовно після цього. Перший реліз цієї функції дав приріст лише на 12–25%, оскільки блокування менеджера пам'яті за замовчуванням у Delphi серіалізувало робочі потоки. Скорочення виділень у купі з приблизно 20 до 9,1 на клітинку підняло паралельне прискорення до 1,90-кратного на восьми потоках. Ця стаття описує вимірювання, хибні кроки та два виправлення, які реально спрацювали

Як HotXLS розбирає робочі аркуші XLSX паралельно?

HotXLS розділяє Open на три фази, і лише середня виконується в робочих потоках. Причиною є zip-контейнер: zip-архів — це один спільний вхідний потік з одним автоматом розпакування (inflate), і цей автомат не може зчитуватися двома потоками одночасно. Загортання його в блокування було б безглуздим, оскільки inflate є за своєю суттю послідовним для кожного запису, тому блокування просто відтворило б послідовне виконання з додатковими витратами. Тому фаза А розпаковує XML кожного робочого аркуша у власний TMemoryStream, все ще в одному потоці; у нашому тестовому файлі це зайняло близько 4 мс для восьми частин аркушів, тому це далеко не вузьке місце. Фаза B запускає ParseWorksheetXml для кожного аркуша на пулі робочих потоків, де і відбувається майже весь час завантаження. Фаза C повертається до zip послідовно для невеликих частин: коментарів, малюнків, діаграм та таблиць

Пул робочих потоків сам по себе є навмисно простим. Робочі потоки отримують індекси завдань із спільного лічильника за допомогою InterlockedIncrement, тому аркуші нерівного розміру збалансовуються природним чином без будь-кого планувальника. Кількість потоків дорівнює min(sheet count, CPU cores), перший виняток робочого потоку захоплюється за допомогою AcquireExceptionObject і повторно викликається в головному потоці після завершення роботи, а диспетчер переходить на звичайний послідовний цикл, коли завдань нуль або одне. Дві властивості в TXLSXWorkbook керують цією функцією: ParallelParse активує пул, а ParallelParseThreads обмежує кількість потоків, де 0 означає автоматичний вибір. Багатоаркушеві робочі книги отримують найбільшу вигоду, включаючи випадки, коли ви дублюєте шаблонний аркуш десятки разів

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;

Чому додавання потоків уповільнює розбір XLSX у Delphi?

Оскільки менеджер пам'яті Delphi за замовчуванням захищає свою купу глобальним блокуванням, а розбір аркушів є операцією з високою щільністю виділення пам'яті: мільйони клітинок, Variant та WideString. Кожен робочий потік, що звертається до купи, стає в чергу на цьому блокуванні, тому потоки, які виглядають незалежними в сирному коді, на практиці виконуються майже по одному. Наш перший тест зробив це болісно конкретним. На робочій книзі з 8 аркушів по 5000 рядків на 4 стовпці на аркуш, виміряній на процесорі i5-11600K (6 ядер, 12 потоків) під Win64, паралельне відкриття Open покращилося лише на 12–25% проти планової оцінки щонайменше 40%. Зміна кількості потоків на 2, 3, 4, 6 та 8 дала плоску криву, а в пізніших інструментальних запусках конфігурація з 2 потоками була фактично на 26% повільнішою за послідовну — класична ознака двох потоків, які борються за одне блокування

Три вимірювання підтвердили діагноз, і кожне з них спростувало попередню інтуїцію. По-перше, крихітний файл (8 аркушів по 1 рядку) відкрився за 1,2 мс, довівши, що розбір становить майже 100% часу Open і немає прихованих фіксованих витрат. По-друге, мікротест чистої роботи з купою показав зворотне масштабування менеджера пам'яті Delphi: той самий загальний обсяг у 2 мільйони виділень об'єктів та AnsiString працював на 8 потоках на 60% повільніше, ніж на одному, тоді як робота з купою WideString (яка використовує алокатор COM BSTR, а не MM Delphi) масштабувалася до 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);   // 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);

Цю десятихвилинну діагностику варто взяти на озброєння для будь-якого розслідування продуктивності в Delphi. Підрахунок виділень за розмірами майже нічого не коштує в реалізації і показує, звідки насправді береться тиск на менеджер пам'яті, що в нашому випадку було пов'язано з двома звичками на рівні RTL всередині XML-сканера, а не з чимось у моделі об'єктів. Профайлери продовжували вказувати на парсер в цілому; обгортка вказала на два конкретні рядки

Виправлення: інтернування токенів та UTF-8 декодер без проміжних об'єктів

Дві цільові зміни в XML-зчитувачі прибрали більше половини виділень на клітинку, не торкаючись структури парсера. Перша — інтернування назв елементів. XML аркуша нескінченно повторює крихітний словниковий запас: row, c, v, r, t, s та кілька назв атрибутів. Метод InternTokenName зберігає 64-слотовий кеш раніше бачених назв і порівнює буфер сканера з кешованим записом за допомогою TokenEqualsAnsi — прямого порівняння байтів, яке нічого не виділяє. При збігу він повертає кешований AnsiString, і тут вибір типу є важливим: AnsiString підраховує посилання, тому повернення кешованого екземпляра коштує одного збільшення лічильника посилань і нульового трафіку на купі. WideString не має лічильника посилань, і кожне призначення проходить через SysAllocString, тому інтернування WideString нічого б не заощадило. Інтернування має сенс робити лише для рядків із підрахунком посилань

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;

Друга зміна стосується тексту клітинок. Старий шлях будував токен AnsiString, передавав його в UTF8ToWideString, який будував проміжний UnicodeString, який нарешті конвертувався у WideString, що зберігається в клітинці: два виділення Delphi-MM на текстовий токен перед реальним збереженням. Заміна, XmlUtf8ToWide(TokenPtr, TokenLen), є двопрохідним чистим Pascal UTF-8 декодером, який зчитує дані прямо з буфера сканування: перший прохід вимірює довжину UTF-16, другий — декодує у WideString, виділений один раз. Чиста вартість на текстовий токен: одне виділення COM, нуль виділень Delphi-MM. Один семантичний нюанс для обережності: на некоректних послідовностях UTF-8 новий декодер пропускає байти як є замість підстановки символів заміни, як це робить RTL, що впливає лише на те, як поводяться пошкоджені файли; на коректному виводі результат є байт-ідентичним. Символьні сутності XML ніколи не доходять до декодера, оскільки сканер уже розібрав їх у UTF-8 в буфері токенів

Що це дало, і де паралельний розбір все одно не допоможе

Два виправлення скоротили виділення на клітинку з приблизно 20 до 9,1, і паралельні показники змінилися так, як передбачала теорія. На тому ж бенчмарку з 8 аркушів по 5000 рядків і тій самій машині з 6 ядрами і 12 потоками покращення на 8 потоках зросло з 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