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

Оптимизация производительности ввода-вывода для обработки PDF-файлов гигабайтного размера

Первое полезное чтение парсера PDF происходит на неправильном конце файла. Формат помещает указатель startxref в последние байты, поэтому обработка архива размером 1,8 ГБ начинается с перехода в конец, чтения одного килобайта, а затем перехода туда, где, согласно таблице перекрестных ссылок, находится каталог документа. Оттуда синтаксический анализ представляет собой случайное блуждание по всему диапазону байтов. Все, в чем хорош буферизованный ввод-вывод — последовательное опережающее чтение за указателем файла — нацелено на рабочую нагрузку, которой у PDF нет

В первой версии этой статьи утверждалось, что файл, отображаемый в память, решает проблему 32-битной нехватки памяти, с которой сталкивается TMemoryStream при вводе 2 ГБ. Это утверждение неверно, и то, как оно неверно, указывает на реальное исправление: скользящее окно отображения. Ниже описан шаблон доступа, исправленная 32-битная история с компилируемым окном отображения и арифметика системных вызовов в тестовом файле размером 1,8 ГБ с 300 000 объектов

Почему макет PDF побеждает буферизованное чтение

Три структурных факта формируют шаблон ввода-вывода. Во-первых, навигация управляется смещением: таблица перекрестных ссылок сопоставляет каждый номер объекта с абсолютной позицией байта, и ничто не требует, чтобы эти позиции были упорядочены. После многих лет инкрементных обновлений объект 4102 может находиться со смещением 1,6 ГБ, а объект 4103 — с 30 КБ. Цикл TFileStream превращает каждую выборку в Seek плюс Read, два перехода ядра с буфером, который ничего не дает, потому что следующая выборка находится в сотнях мегабайт

Во-вторых, потоки объектов (ISO 32000-1 §7.5.7) упаковывают десятки или сотни небольших словарей в один сжатый контейнер. Извлечение одного 300-байтового словаря страницы может означать чтение и распаковку кластера размером 100 КБ. Обратная сторона: объекты, записанные вместе, имеют тенденцию читаться вместе, поэтому буфер размером с кластер обслуживает следующий десяток выборок бесплатно — самая эксплуатируемая регулярность в формате

В-третьих, линеаризация. Линеаризованный файл загружает первую страницу и таблицу подсказок в начале, чтобы потребители могли читать его от начала до конца. Архивы гигабайтного размера почти никогда не линеаризуются: линеаризация разрушается теми же инкрементальными обновлениями и слияниями, которые сделали файл большим. Планируйте враждебный сценарий: долгие прыжки, отсутствие упорядочивания, вход с хвоста

Исправленная 32-битная история

32-битный процесс Windows имеет 2 ГБ пользовательского адресного пространства, а MapViewOfFile с количеством байтов, равным нулю, запрашивает одно непрерывное резервирование размером с файл. Для ввода размером 2 ГБ это резервирование не может быть выполнено: после EXE, разбросанных DLL и стеков потоков самый большой свободный непрерывный блок в типичном 32-битном процессе Delphi находится где-то между 700 МБ и 1,4 ГБ. Вызов завершается ошибкой ERROR_NOT_ENOUGH_MEMORY, той же стеной, в которую ударяется TMemoryStream.LoadFromFile, только что перенесенной из выделенной оперативной памяти в резервирование адресного пространства. Полнофайловое отображение не является решением для 32-битной системы, а лишь той же ошибкой за лучше звучащими именами API

Исправление заключается в разделении двух вещей, которые делает отображение. CreateFileMapping создает объект раздела и вообще не требует адресного пространства, независимо от размера файла. Только MapViewOfFile расходует адресное пространство, и ничто не заставляет его отображать весь раздел: он принимает 64-битное начальное смещение и длину представления. Создайте раздел один раз, отобразите представление размером от 64 до 256 МБ по анализируемой области, отмените отображение перед скольжением дальше: стоимость адресного пространства — одно окно, а не один файл. Одно ограничение: смещения представления должны быть кратны SYSTEM_INFO.dwAllocationGranularity, на практике 64 КБ, поэтому запрос на смещение 1 000 000 округляется в меньшую сторону до 983 040, а указатель вызывающего объекта корректируется вперед на разницу

Инструмент отображения файлов со скользящим окном в Delphi

Приведенный ниже класс оборачивает всю дисциплину: один объект раздела, одно активное представление, перераспределение детализации и чтение, пересекающее границу окна, обрабатываемое путем увеличения этого одного представления вместо сшивания двух

uses
  Winapi.Windows, System.SysUtils;

type
  TWindowedFileMapper = class
  private
    FFile: THandle;
    FMapping: THandle;
    FFileSize: Int64;
    FGranularity: DWORD;      // SYSTEM_INFO.dwAllocationGranularity
    FWindowSize: NativeUInt;  // размер представления по умолчанию
    FViewBase: PByte;         // основа текущего представления (выровнено)
    FViewOffset: Int64;       // файловое смещение, которому соответствует FViewBase
    FViewSize: NativeUInt;    // байты, отображенные в текущем представлении
    procedure Unmap;
  public
    constructor Create(const FileName: string;
      WindowSize: NativeUInt = 64 * 1024 * 1024);
    destructor Destroy; override;
    function Map(Offset: Int64; Size: NativeUInt): PByte;
    procedure ReadBytes(Offset: Int64; var Buffer; Count: NativeUInt);
    property FileSize: Int64 read FFileSize;
  end;

constructor TWindowedFileMapper.Create(const FileName: string;
  WindowSize: NativeUInt);
var
  Info: TSystemInfo;
begin
  inherited Create;
  FFile := CreateFile(PChar(FileName), GENERIC_READ, FILE_SHARE_READ, nil,
    OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
  if FFile = INVALID_HANDLE_VALUE then
    RaiseLastOSError;
  if not GetFileSizeEx(FFile, FFileSize) then
    RaiseLastOSError;
  // Объект раздела не резервирует адресное пространство независимо от размера файла
  FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
  if FMapping = 0 then
    RaiseLastOSError;
  GetSystemInfo(Info);
  FGranularity := Info.dwAllocationGranularity;  // 64 КБ на практике
  FWindowSize := WindowSize;
end;

destructor TWindowedFileMapper.Destroy;
begin
  Unmap;
  if FMapping <> 0 then CloseHandle(FMapping);
  if FFile <> INVALID_HANDLE_VALUE then CloseHandle(FFile);
  inherited;
end;

procedure TWindowedFileMapper.Unmap;
begin
  if FViewBase <> nil then
  begin
    UnmapViewOfFile(FViewBase);
    FViewBase := nil;
    FViewSize := 0;
  end;
end;

function TWindowedFileMapper.Map(Offset: Int64; Size: NativeUInt): PByte;
var
  AlignedOffset: Int64;
  Delta, MapSize: NativeUInt;
begin
  if (Offset < 0) or (Offset + Int64(Size) > FFileSize) then
    raise ERangeError.CreateFmt(
      'Запрос карты на %d для %d байтов находится за пределами файла',
      [Offset, Int64(Size)]);

  // Быстрый путь: запрашиваемый диапазон уже находится внутри активного представления
  if (FViewBase <> nil) and (Offset >= FViewOffset) and
     (Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
    Exit(FViewBase + NativeInt(Offset - FViewOffset));

  Unmap;  // скольжение: никогда не удерживайте два представления одновременно

  // Представления должны начинаться с границы гранулярности распределения
  AlignedOffset := Offset - (Offset mod FGranularity);
  Delta := NativeUInt(Offset - AlignedOffset);

  MapSize := FWindowSize;
  if MapSize < Size + Delta then   // запрос выходит за пределы конца окна:
    MapSize := Size + Delta;       // увеличиваем это одно представление, чтобы покрыть его
  if AlignedOffset + Int64(MapSize) > FFileSize then
    MapSize := NativeUInt(FFileSize - AlignedOffset);  // зажим в конце файла

  FViewBase := MapViewOfFile(FMapping, FILE_MAP_READ,
    DWORD(AlignedOffset shr 32), DWORD(AlignedOffset and $FFFFFFFF),
    MapSize);
  if FViewBase = nil then
    RaiseLastOSError;

  FViewOffset := AlignedOffset;
  FViewSize := MapSize;
  Result := FViewBase + NativeInt(Delta);
end;

procedure TWindowedFileMapper.ReadBytes(Offset: Int64; var Buffer;
  Count: NativeUInt);
begin
  Move(Map(Offset, Count)^, Buffer, Count);
end;

Важны две детали. Быстрый путь в верхней части Map возвращает указатель без перехода ядра, когда запрошенный диапазон уже находится внутри активного представления; благодаря кластеризации объектного потока это обычный случай, откуда и берется экономия. А запрос, выходящий за конец окна по умолчанию, увеличивает MapSize для этого одного представления, а не сшивает два, что сохраняет ReadBytes однострочным, а вызывающие объекты избавляются от циклов частичного чтения

Размер окна — это прощающая настройка: при 64 МБ полная проверка файла размером 1,8 ГБ занимает 29 представлений, при 256 МБ — 8, но каждое резервирование сложнее разместить в фрагментированном 32-битном пространстве, а при значении ниже 16 МБ файлы с частыми переходами перераспределяются достаточно часто, чтобы это было заметно. Где-либо в диапазоне от 64 до 256 МБ трафик карты представляет собой статистический шум

Подсчет системных вызовов

Теперь арифметика. Тестовый файл: 1,8 ГБ, 300 000 косвенных объектов со средним объемом полезной нагрузки около 600 байт. Парсер на каждый объект извлекает каждый из них с помощью SetFilePointerEx плюс ReadFile на 4 КБ: 600 000 переходов ядра. Системный вызов кешированного чтения возвращается примерно за 1,5 мкс на текущем оборудовании x64, поэтому 600 000 × 1,5 мкс ≈ 0,9 секунды чистых накладных расходов ядра перед разбором хотя бы одного байта — лучший случай для горячего кеша. При холодном кеше каждый прыжок — это операция устройства: при эффективной задержке около 20 мкс для случайного чтения NVMe 4 КБ 300 000 из них обходятся примерно в 6 секунд времени устройства; в хранилище класса SATA — в минуты

Чтения также перемещают неправильные данные: 300 000 × 4 КБ проталкивают 1,2 ГБ через буферы пользователя для доставки примерно 180 МБ полезной нагрузки — шестикратное усиление, каждый байт копируется из ядра пользователю

Буфер опережающего чтения размером с кластеры потоков объектов — первое честное улучшение: одно чтение на 256 КБ на кластер вместо одного на объект сокращает количество переходов на один-два порядка. Это также правильный инструмент там, где отображение неудобно, обычно на сетевых ресурсах

Оконный маппер идет дальше. Полная развертка — это 29 вызовов MapViewOfFile и 29 UnmapViewOfFile, 58 явных переходов против 600 000. Реальный анализ, управляемый xref, не является чистым проходом, но быстрый путь поглощает каждую выборку внутри активного окна; проход индексации метаданных по тестовому архиву установился на уровне нескольких сотен перераспределений. Отображение не удаляет работу ядра: оно преобразует явные системные вызовы в ошибки страниц, которые менеджер памяти решает в многостраничных кластерах прямо из файлового кеша без копирования в пространство пользователя, а области, к которым никогда не обращались, ничего не стоят. Из конца в конец процесс индексации сократился с 23 с холодного и 7,1 с теплого с чтением каждого объекта до 6,5 с холодного и 1,9 с теплого с маппером; то, что остается — это раздувание zlib, а не ввод-вывод

Где подходит FILE_FLAG_NO_BUFFERING

FILE_FLAG_NO_BUFFERING обходит системный кеш в обмен на жесткие правила выравнивания: смещения, длины и адреса буферов все выровнены по секторам. Он оправдывает себя на однопроходных последовательных задачах, которые в противном случае затопили бы кеш байтами, которые никто не читает дважды — пакетная повторная сериализация, которая перезаписывает весь архив, или проход линеаризации готового вывода. Благодаря выровненным буферам объемом от 4 до 8 МБ он приближается к последовательной пропускной способности устройства, не загрязняя кеш

Это совершенно неправильно для парсинга. Случайные скачки xref через небуферизованный дескриптор превращают каждую выборку словаря размером 300 байт в полное физическое чтение без кеша для поглощения второго посещения — а синтаксический анализ PDF постоянно возвращается к областям, потому что разные страницы преобразуются в одни и те же объектные потоки. Небуферизованный ввод-вывод для последовательной перезаписи, отображаемый или кешируемый ввод-вывод для случайного парсинга; флаг работает для каждого дескриптора, поэтому в одном конвейере могут храниться оба для одного файла

64-битные рабочие наборы и сторона записи

В 64-битной сборке ограничение на адресное пространство исчезает: передайте размер файла как окно, и приведенный выше класс вырождается в единое полное отображение. Подвох в долго работающих службах: для файловых страниц, доступных только для чтения, фиксация не требуется, поэтому счетчики фиксаций остаются спокойными, но каждая затронутая страница присоединяется к рабочему набору; если вы проанализируете большую часть файла объемом 1,8 ГБ, то рабочий набор вырастет в соответствии с этим, вытеснив все остальное. Ограниченные окна накладывают на это ограничение, поэтому скользящий шаблон остается правильным значением по умолчанию, даже если адресное пространство свободно

На стороне записи самым дешевым вводом-выводом является тот, который никогда не выдавался. Механизм инкрементного обновления PDF (ISO 32000-1 §7.5.6) добавляет измененные объекты и новый раздел перекрестных ссылок после исходных байтов, которые никогда не перемещаются. Печать одной страницы в архиве размером 1,8 ГБ добавляет десятки килобайт; полная перезапись перемещает все 1,8 ГБ на пять порядков, а добавление — это чисто последовательный вывод в конец

Где подходят библиотеки losLab

Обе библиотеки losLab PDF предоставляют эту дисциплину в качестве поверхности API. HotPDF Direct File API считывает количество страниц и структуру через дескриптор файла без построения дерева объектов, копирует и расшифровывает на уровне файла и записывает дельты через BeginIncrementalUpdate — стратегия только для добавления, запакованная. PDFlibPas идет по тому же пути со своим уровнем прямого доступа: потоковый считыватель, который перемещается по таблице перекрестных ссылок на месте, извлекает объекты лениво, извлекает диапазоны страниц файл в файл и сохраняет изменения как инкрементные ревизии. Если вы пишете свой собственный парсер, класс маппера — ваш, если вы запускаете конвейер документов, позвольте библиотеке поддерживать прозрачность окна

Примечание: оптимизированная обработка ввода-вывода для документов гигабайтного размера встроена непосредственно в HotPDF VCL Component для Delphi и C++Builder