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

Оптимизация ввода-вывода для гигабайтных PDF в Delphi

Первое полезное чтение парсера 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 КБ. Оборотная сторона: объекты, записанные вместе, обычно и читаются вместе, поэтому буфер размером с кластер бесплатно обслуживает следующую дюжину выборок — самая пригодная к эксплуатации закономерность в формате

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

PDF: прыжковый шаблон доступа при обработке гигабайтных PDF, вход через startxref в хвосте файла и последующий обход разбросанных смещений перекрёстных ссылок
Навигация по PDF входит с хвоста, а затем прыгает туда, куда указывает таблица перекрёстных ссылок, что сводит на нет последовательное упреждающее чтение

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

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

PDF: фрагментированное 32-битное адресное пространство отвергает полнофайловый MapViewOfFile, тогда как секция CreateFileMapping и скользящее окно отображения на 64 МБ проходят успешно
Один объект секции плюс одно живое представление сохраняют PDF на 1,8 ГБ читаемым внутри 2 ГБ адресного пространства 32-битного процесса 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(
      'Map request at %d for %d bytes is outside the file',
      [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 МБ — восемь, но каждую резервацию труднее разместить во фрагментированном 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, а не ввод-вывод

PDF: столбчатая диаграмма 600000 пообъектных системных вызовов ReadFile против кластерного упреждающего чтения и 58 переходов оконного отображателя при индексации PDF на 1,8 ГБ с 300000 объектов
Пообъектные чтения сжигают 600 000 системных вызовов на тестовом архиве, тогда как оконный отображатель сводит полный обход к 58 переходам

Где уместен 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

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

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