Первое полезное чтение парсера 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