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

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

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

Как HotXLS парсит листы XLSX параллельно?

HotXLS разделяет вызов Open на три фазы, и только вторая выполняется в рабочих потоках. Причина кроется в ZIP-контейнере: архив ZIP представляет собой один общий входной поток с одним автоматом состояний 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;      // включить параллельный пул рабочих потоков
    Book.ParallelParseThreads := 0;  // 0 = авто: минимальное из числа листов и ядер CPU
    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 мс, подтвердив, что парсинг составляет почти 100% времени Open и скрытых постоянных затрат нет. Во-вторых, микробенчмарк чистых операций выделения памяти показал обратное масштабирование менеджера памяти Delphi: тот же объем из 2 млн выделений под объекты и AnsiString выполнялся на 8 потоках на 60% медленнее, чем на одном. При этом аналогичные операции с кучей WideString (которая использует аллокатор COM BSTR, а не менеджер Delphi) масштабировались с ускорением в 3,7 раза. То, что HotXLS использует WideString, оказалось историческим совпадением, сыгравшим нам на руку. В-третьих, замеры через GetProcessTimes показали, что при параллельном Open время CPU практически равнялось астрономическому времени: восемь потоков потребляли ресурс, эквивалентный всего 1,3 потока. Рабочие потоки простаивали в очереди менеджера памяти, будучи заблокированными, а не занятыми

Этот практический урок применим ко многим задачам в Delphi. Если приложение часто выделяет память, увеличение числа потоков бесполезно до снижения частоты аллокаций и может даже ухудшить показатели. До внедрения оптимизации мы открыто говорили пользователям, настраивающим параметр ParallelParseThreads: на файлах с частыми аллокациями добавление потоков не дает практически ничего

Откуда берутся 20 выделений памяти в куче на одну ячейку?

Счетчик, установленный через SetMemoryManager, дал точный ответ: около 20 выделений памяти менеджера Delphi на ячейку, причем 2,87 млн из них составляли блоки размером до 32 байт. Причиной были вовсе не объекты ячеек. Метод TXMLScaner.GetTokenValue создавал новый AnsiString при каждом вызове, который выполняется около 15–20 раз на ячейку: для имен элементов, атрибутов, их значений и текстового контента. Вдобавок системная функция UTF8ToWideString создавала временную промежуточную строку 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. Подсчет выделений памяти по категориям размеров прост в реализации и показывает реальный источник нагрузки на менеджер памяти. В нашем случае проблема крылась в двух системных методах сканера 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++, без выделения памяти
  else
  begin
    Result := GetTokenValue;        // создать один раз, затем кэшировать
    FInternNames[Slot] := Result;
  end;
end;

Второе изменение касается текста ячеек. Раньше создавался токен AnsiString, передавался функции UTF8ToWideString, которая создавала промежуточную строку UnicodeString, затем преобразуемую в WideString для хранения в ячейке. Это приводило к двум промежуточным аллокациям на каждый токен текста. Новая функция XmlUtf8ToWide(TokenPtr, TokenLen) представляет собой двухпроходный UTF-8 декодер на чистом Pascal, читающий данные напрямую из буфера сканирования: на первом проходе измеряется длина UTF-16, на втором — выполняется декодирование в WideString с однократным выделением памяти. Итог: одна аллокация COM, ноль аллокаций менеджера памяти Delphi. Семантическая деталь: при некорректных последовательностях UTF-8 декодер пропускает байты без подстановки символов замены (как это делает RTL), что влияет лишь на поведение при чтении поврежденных файлов. Для корректных данных вывод идентичен. Символьные сущности XML не доходят до декодера, так как сканер уже преобразует их в UTF-8 в буфере токенов

Результаты и сценарии, где параллельный парсинг по-прежнему не поможет

Две эти оптимизации сократили число аллокаций на ячейку с 20 до 9,1, и результаты параллельного выполнения подтвердили теорию. В том же тесте на 8 листах по 5000 строк на процессоре с 6 ядрами (12 потоков) прирост на 8 потоках вырос с 14% до 47,4%, дав 1,90-кратное ускорение по сравнению с последовательным вариантом. Случай с 2 потоками перешел от замедления на 26% к ускорению на 23,6%, а загрузка CPU выросла с 1,0x до 2,2x. В качестве бонуса последовательный путь стал быстрее на 3%, так как сокращение аллокаций полезно и для одного потока. Оставшиеся ~9 аллокаций приходятся на объекты ячеек и рост контейнеров. Мы оценили эти затраты и остановились на достигнутом, оставив счетчик готовым для новых замеров, если потребуются дальнейшие оптимизации

Ограничения важно описать так же четко, как и успехи. HotXLS распределяет задачи на уровне листов, поэтому книга, состоящая из одного огромного листа, будет обрабатываться в один поток, независимо от значения ParallelParseThreads. Для таких структур лучше использовать потоковый прямой ридер, вообще избегающий загрузки книги в память. Файлы, основное время обработки которых уходит на фазу C (рисунки, диаграммы, комментарии), получают меньше выгоды, так как эта фаза выполняется последовательно. Мелкие файлы нет смысла распараллеливать, поэтому при малом числе задач диспетчер автоматически использует последовательный режим. Порог менеджера памяти не исчез совсем, а лишь отодвинулся: при 9,1 аллокациях на ячейку глобальная блокировка всё еще ограничивает потоки, поэтому восемь потоков дают 1,90-кратное ускорение, а не 4-кратное. Подробнее об инструментах сокращения времени загрузки и сохранения файлов (стилях, пулах, пакетных вызовах) читайте в нашем руководстве по производительности больших книг в Delphi

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