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

Чесні бенчмарки load/save PDF у Delphi: шумові гейти

Чесний бенчмарк load/save для PDF Library for Delphi хронометрує LoadFromFile і SaveToFile через QueryPerformanceCounter, зберігає сирі тікти та частоту лічильника, ганяє baseline і кандидата в чергованих парах A/B, B/A, A/B, відмовляється стартувати, поки завантаження CPU тримається вище 25%, відкидає будь-який результат, де відношення розкид/медіана перевищує 15%, і викидає кожен замір, чий збережений PDF провалює структурну, рендерингову чи семантичну перевірку. Цей список читається як бюрократія — доти, доки твердження "швидше на 20%" вперше не випарується на повторному прогоні. Далі — як окремий зонд корпусу і його порівняльний runner до цього дійшли, зокрема прогон, коли машина була просто завалена роботою і міряти нічого не могла, і harness правильно так і сказав

Чому бенчмарк PDF у Delphi звітує нуль секунд?

Бенчмарк завантаження PDF звітує нуль секунд, коли його годинник тікає грубіше за вимірювану операцію, а GetTickCount64 — саме такий годинник: він повертає мілісекунди, але на Windows просувається лише тоді, коли спрацьовує переривання системного таймера, зазвичай раз на 15.6 мс. FPC-порт демо-бенчмарку великих файлів у PDF Library for Delphi використовував його, бо TStopwatch у тому тулчейні недоступний, а час записувався з трьома знаками після коми. Завантаження невеликого CAD-кресленика чи короткого tagged-документа вкладається в один крок таймера з великим запасом, тож демо іноді друкувало 0.000 для завантаження, яке явно виконувало реальну роботу

function ElapsedSeconds(StartTick: QWord): Double;
begin
  Result:= (GetTickCount64- StartTick)/ 1000.0;
end;

// усередині циклу операції
Lib:= TPDFlib.Create;
try
  Lib.OnProgress:= Reporter.Progress;
  Started:= GetTickCount64;
  LoadCode:= Lib.LoadFromFile(InputFile, Password);
  ...

Нуль гірший за неточне число, бо кожне порівняння, яке ви на ньому будуєте, ділить на нього. Парний порівняльний runner трактує будь-який arm із нульовим мінімумом як невизначений із причиною "Zero duration prevents a meaningful ratio" — це правильна відмова, але вона ж означає, що демо-заміри лишили діру у вимірюваннях рівно там, де живуть короткі файли. Те саме демо ставить і callback OnProgress, тож його заміри несуть накладні витрати callback'а, яких чистий замір load/save нести не повинен, а архівні демо-цифри не взаємозамінні ні з чим, що мірялося пізніше

Хронометраж LoadFromFile і SaveToFile через QueryPerformanceCounter

Виділений консольний зонд Tests/CorpusLoadSave.dpr міряє на кожному вхідному файлі дві операції через QueryPerformanceCounter: LoadFromFile плюс читання PageCount і LoadFromFile плюс PageCount плюс SaveToFile. Кожна операція отримує свіжий інстанс TPDFlib без progress-callback, а конструктор і деструктор інстанса сидять поза вимірюваною зоною, як і запис CSV та вся валідація виходу. Лічильник читається безпосередньо перед завантаженням і одразу після останнього виклику бібліотеки, а LastErrorCode забирається лише після другого зчитування

Вимірювана зона corpus-зонда PDFlibPas: QueryPerformanceCounter читається безпосередньо перед LoadFromFile і знову після останнього виклику бібліотеки, з PageCount і SaveToFile всередині, тоді як підготовка інстанса, запис CSV, валідація виходу і зчитування коду помилки лишаються поза вимірюваною зоною
Сирі тікти та частота лічильника записуються поруч із похідними секундами, тож CAD-кресленик, що завантажується за 8 888 тіктів при десяти мільйонах тіктів на секунду, зберігається як справжні дані, а не округлюється до нуля
Lib:= TPDFlib.Create;
try
  if not QueryPerformanceCounter(Started) then
    raise Exception.Create('Performance counter unavailable');
  Code:= Lib.LoadFromFile(WideString(SourceFile), '');
  if Code= 1 then
  begin
    Pages:= Lib.PageCount;
    if Save then Code:= Lib.SaveToFile(WideString(SavedFile));
  end;
  if not QueryPerformanceCounter(Finished) then
    raise Exception.Create('Performance counter unavailable');
  ErrorCode:= Lib.LastErrorCode;
finally
  Lib.Free;
end;
Ticks:= Finished- Started;
if Ticks< 0 then
  raise Exception.Create('Performance counter moved backwards');

Зонд пише сирі тікти та частоту лічильника поруч із похідними секундами, відформатованими з дев'ятьма знаками після коми та фіксованим роздільником ., тож будь-хто може перерахувати частку з CSV, а не вірити йому на слово. На збірці FPC Win64 CAD-зразок завантажувався за 8 888 тіктів при 10 000 000 тіктів на секунду, записано як 0.000888800 секунди — спостереження, яке старий таймер округлив би до нуля. Зонд свідомо не підрізає короткі значення, не підставляє мінімальну тривалість і не віднімає оцінку накладних витрат таймера, а коли виклик бібліотеки падає, він усе одно пише обидва рядки з ненульовим кодом виходу. Дев'ять цифр — це не точність, втім: більша записана точність нічого не каже про повторюваність, а шумні чи нульові спостереження все одно доводиться відкидати далі по конвеєру

if not QueryPerformanceFrequency(Frequency) or (Frequency<= 0) then
  raise Exception.Create('Performance counter frequency unavailable');
NumberFormat:= TFormatSettings.Create;
NumberFormat.DecimalSeparator:= '.';
...
Rows.Add(CSV(ExtractFileName(SourceFile))+ ','+ CSV(Operation)+ ','+
  FormatFloat('0.000000000', Ticks/ Frequency, NumberFormat)+ ','+
  IntToStr(Code)+ ','+ IntToStr(ErrorCode)+ ','+ IntToStr(Pages)+ ','+
  IntToStr(Ticks)+ ','+ IntToStr(Frequency));

Коли порівнянню замірів load/save PDF можна вірити?

Порівняння замірів між двома збірками PDF Library for Delphi заслуговує на довіру, лише коли порядок старту, стартові умови та розкид контролюються й записуються, тож порівняльний runner планує щонайменше три пари в порядку A/B, B/A, A/B. Стартувати baseline завжди першим — значить тихо подарувати кандидатові тепліший файловий кеш та інший тепловий стан; чергування порядку розмазує це зміщення по обох arms, а не зараховує його одному. Перед кожним arm runner хешує повний вхідний файл через SHA-256, що і підтверджує, що нічого не змінилося, і попередньо читає ті самі байти для обох arm, а після кожного прогону він знову хешує обидва виконувані файли та інструменти валідації, щоб перезібраний бінарник не прослизнув у середину серії

Далі runner раз на секунду знімає загальносистемну утилізацію CPU і стартує arm лише коли черговий зразок падає до 25% і нижче, чекаючи щонайбільше 30 секунд, перш ніж записати спробу як відхилену. Цей гейт контролює стартову умову і більше нічого: машина під час прогону не ізолюється, а стан живлення, термічний тротлінг, фонова робота та кешування ОС все ще можуть зсунути цифри. Тож другий фільтр — статистичний у найпростішому сенсі. Для кожної операції runner рахує ділення розкиду на медіану для arm baseline, arm кандидата і розподілу парних відношень кандидат/baseline, і якщо будь-яке з трьох перевищує 0.15, результат позначається як шумний, а не звітується як знахідка

Гейти парного порівняння в PDFlibPas: три пари йдуть у порядку A/B, B/A, A/B із SHA-256-хешуванням входу перед кожним arm, стартовий гейт чекає CPU на рівні 25 відсотків і нижче, а розкид range-to-median понад 0.15 на LoadFromFile або arm load-plus-SaveToFile позначає прогон як шумний
Чергування порядку старту розмазує кешове та термічне зміщення по обох arms, а same-binary контроль показує, що така установка може довести: відношення біля 1.0 встановлюють повторюваність, але ніколи — твердження про прискорення

Чому same-binary контроль доводить повторюваність, а не швидкість?

Same-binary контроль ганяє ідентичні виконувані файли як baseline і як кандидата, тож відношення біля 1.0 може довести лише те, що вимірювальна установка повторює сама себе; воно ніколи не покаже, що реалізація стала швидшою. Перший суворий контроль 2026-09-21 використовував високоточний FPC Win64 зонд проти прийнятого корпусом tagged-посібника на 70 сторінок, і всі шість стартів було відхилено, бо зразки CPU лежали в діапазоні від 26.5% до 93.8%. Звіт містив відмови і жодних агрегатів — це рівно той результат, який вам потрібен, коли машина зайнята. Повтор того ж дня з байт-у-байт ідентичними входами, тим самим виконуваним файлом зонда і незміненими порогами прийняв усі шість стартів за 3 секунди; кожен розкид range-to-median ліг між 0.019 і 0.054, а медіани відношень склали 1.0084 для LoadFromFile і 0.9872 для LoadFromFile + SaveToFile

Ця пара чисел встановлює кваліфіковане вікно спостереження — і не більше. Коли два бінарники різні, стабільний прогон позначається як описове порівняння з явною приміткою, що відношення — це спостереження, а не статистична значущість чи твердження про прискорення. Дисципліна найважливіша, коли ви валідуєте точкові оптимізації на кшталт описаних у профілюванні PDF Library for Delphi та заміні гарячих шляхів хеш-індексами: профайлер каже, куди йде час, але лише контрольований парний прогон на реальних документах каже, чи пережила зміна контакт із цілим конвеєром. Ще одна межа, яку варто промовити вголос: normal-save включає завантаження, а піковий working set, який записує runner, — загальнопроцесний, тож жодна його частина не є пам'яттю, яку можна віднести на рахунок одного лише збереження

Три гейти виходу та матриця на чотири компілятори

Жоден замір PDF Library for Delphi не зараховується, доки файл, який він видав, не пройде три незалежні гейти, бо збереження, що швидко пише зламаний PDF, — не швидше збереження. Бенчмарк спершу перевіряє, що обидві операції повернули 1 і повідомили прийняту кількість сторінок, а потім валідує єдиний збережений PDF у такому порядку:

Три гейти виходу в PDFlibPas: обидві операції мусять повернути 1 з прийнятим PageCount, незалежний checker мусить пропустити збережений файл без попереджень, кожна сторінка мусить відрендеритися в набір per-page SHA-256 зображень, що збігається з джерелом, а невізуальна семантика мусить збігатися на optional content та структурах вимірювань
Збереження, що швидко пише зламаний PDF, — не швидше збереження, тож замір зараховується лише коли структура, рендеринг і невізуальна семантика всі погоджуються, що вивід — все ще той самий документ
  • Структура: незалежний PDF-checker мусить пропустити збережений файл без помилок і попереджень
  • Рендеринг: кожна сторінка рендериться у стані за замовчуванням, а набір per-page SHA-256 зображень мусить точно збігатися з референсним рендером прийнятого джерела
  • Невізуальна семантика: окреме семантичне порівняння з джерелом покриває вибрані властивості, яких пікселі не показують, зокрема структури optional content та вимірювань у межах їх задокументованої сфери

З цими гейтами повна локальна корпусна матриця прогнала зонд на FPC Win32, FPC Win64, Delphi Win32 і Delphi Win64 по 12 прийнятих PDF з 1 612 вихідними сторінками, давши 48 пар sample/target і 6 448 провалідованих вихідних сторінок без жодної вибраної семантичної різниці. Усі 96 замірів операцій зберегли додатні сирі значення лічильника, узгоджені з їхніми звітними секундами, і ці значення свідомо не агрегуються в крос-компіляторну таблицю швидкості, бо матриця — це функціональний доказ, а не контрольоване порівняння. Шлях load/save також не заявляє, що декодує кожне вбудоване зображення, валідує підписи, виконує XFA чи сертифікує PDF/UA; якщо вам треба судити про пропускну здатність рендерингу, а не про ціну load/save, кращий старт — обмеження паралелізму з паралельного рендерингу сторінок і thread safety у PDF Library for Delphi

Практичний виніс короткий: тримайте сирі лічильники, чергуйте порядок, гейтіть старт, відмовляйте шумні розкиди і ніколи не міряйте вивід, який не провалідували. Саме ці правила дозволяють PDF Library for Delphi казати "вимірюваної зміни немає" так само впевнено, як "швидше", а вихідний код зонда компілюється без змін на Delphi і FPC для Win32 і Win64. Бібліотеку, її API load/save і підтримувані компілятори можна оглянути на сторінці продукту PDF Library for Delphi