Artykuł techniczny

Uczciwe benchmarki load/save PDF w Delphi: bramki szumu

Uczciwy benchmark load/save dla PDF Library for Delphi mierzy LoadFromFile i SaveToFile przez QueryPerformanceCounter, zachowuje surowe tyknięcia i częstotliwość licznika, puszcza baseline i kandydata w naprzeminych parach A/B, B/A, A/B, odmawia startu, dopóki obciążenie CPU siedzi powyżej 25%, odrzuca każdy wynik, którego rozrzut zakres-do-mediany przekracza 15%, i wyrzuca każdy pomiar, którego zapisany PDF nie przejdzie walidacji strukturalnej, renderującej albo semantycznej. Ta lista brzmi jak biurokracja, dopóki pierwszy raz nie wyparuje obietnica „20% szybciej” przy powtórce. Poniżej jest to, jak dedykowana sonda korpusowa i jej runner porównawczy do tego doszły, łącznie z przebiegiem, w którym maszyna była po prostu zbyt zajęta, by cokolwiek zmierzyć, a harness trafnie to powiedział

Dlaczego benchmark PDF w Delphi raportuje zero sekund?

Benchmark wczytywania PDF raportuje zero sekund, gdy jego zegar tyka grubiej niż operacja, którą mierzy, a GetTickCount64 to dokładnie taki zegar: zwraca milisekundy, ale na Windows posuwa się dopiero, gdy odpali przerwanie timera systemowego, zwykle co 15,6 ms. Port FPC dema benchmarkowania wielkich plików w PDF Library for Delphi go używał, bo TStopwatch nie jest dostępny w tym toolchainie, i zapisywał czas do trzech miejsc po przecinku. Wczytanie małego rysunku CAD albo krótkiego dokumentu z tagami kończy się z zapasem wewnątrz jednego kroku timera, więc demo czasem drukowało 0.000 dla wczytania, które ewidentnie zrobiło robotę

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

// wewnątrz pętli operacji
Lib:= TPDFlib.Create;
try
  Lib.OnProgress:= Reporter.Progress;
  Started:= GetTickCount64;
  LoadCode:= Lib.LoadFromFile(InputFile, Password);
  ...

Zero jest gorsze niż niedokładna liczba, bo każdy pomiar, który na nim zbudujesz, dzieli przez nie. Runner porównania parami traktuje każdą gałąź z zerowym minimum jako nierozstrzygniętą z uzasadnieniem „Zero duration prevents a meaningful ratio” — to poprawna odmowa, ale oznacza też, że pomiary dema zostawiały lukę pomiarową dokładnie tam, gdzie żyją krótkie pliki. To samo demo instaluje też callback OnProgress, więc jego czasy obejmują narzut callbacka, którego czysty pomiar load/save nie powinien nieść, a zarchiwizowane liczby z dema nie są wymienne z niczym zmierzonym później

Pomiar LoadFromFile i SaveToFile przez QueryPerformanceCounter

Dedykowana sonda konsolowa, Tests/CorpusLoadSave.dpr, mierzy z QueryPerformanceCounter dwie operacje na plik wejściowy: LoadFromFile plus odczyt PageCount oraz LoadFromFile plus PageCount plus SaveToFile. Każda operacja dostaje świeżą instancję TPDFlib i żaden callback postępu, a konstruktor i destruktor instancji siedzą poza mierzonym regionem, podobnie jak zapis CSV i cała walidacja wyjścia. Licznik jest odczytywany tuż przed wczytaniem i tuż po ostatnim wywołaniu biblioteki, a LastErrorCode pobierany jest dopiero po drugim odczycie

Mierzony region sondy korpusowej PDFlibPas: QueryPerformanceCounter odczytywany tuż przed LoadFromFile i ponownie po ostatnim wywołaniu biblioteki, z PageCount i SaveToFile w środku, podczas gdy konfiguracja instancji, zapis CSV, walidacja wyjścia i pobranie kodu błędu pozostają poza mierzonym regionem
Surowe tyknięcia i częstotliwość licznika są zapisywane obok wyliczonych sekund, więc rysunek CAD wczytywany w 8 888 tyknięciach przy dziesięciu milionach tyknięć na sekundę jest zachowywany jako realne dane, zamiast zostać zaokrąglony do zera
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');

Sonda zapisuje surową liczbę tyknięć i częstotliwość licznika obok wyliczonych sekund, formatowanych z dziewięcioma miejscami po przecinku i stałym separatorem dziesiętnym ., więc każdy może przeliczyć iloraz z CSV, zamiast mu wierzyć. Na budowie FPC Win64 próbka CAD wczytywała się w 8 888 tyknięciach przy 10 000 000 tyknięć na sekundę, zapisanych jako 0.000888800 sekundy — obserwacja, którą stary timer zaokrągliłby do zera. Sonda celowo nie ucina krótkich wartości, nie podstawia minimalnego czasu ani nie odejmuje szacowanego narzutu timera, i przy nieudanym wywołaniu biblioteki nadal zapisuje oba wiersze z niezerowym kodem wyjścia. Dziewięć cyfr to jednak nie dokładność: większa zapisana precyzja nie mówi nic o powtarzalności, a zaszumione albo zerowe obserwacje i tak trzeba odrzucać dalej

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));

Co czyni porównanie czasów load/save PDF wiarygodnym?

Porównanie czasów między dwiema budowami PDF Library for Delphi jest wiarygodne tylko wtedy, gdy kolejność startu, warunki startu i rozrzut są kontrolowane i rejestrowane, więc runner porównania planuje co najmniej trzy pary w kolejności A/B, B/A, A/B. Zawsze pierwsze puszczenie baseline po cichu podsuwa kandydatowi cieplejszy cache plików i inny stan termiczny; naprzemienna kolejność rozlewa ten bias na obie gałęzie, zamiast dopisywać go jednej. Przed każdą gałęzią runner liczy hash SHA-256 kompletnego pliku wejściowego, co zarazem weryfikuje, że nic się nie zmieniło, i wczytuje z wyprzedzeniem te same bajty dla każdej gałęzi, a po każdym przebiegu liczy hashe ponownie dla obu plików wykonywalnych i narzędzi walidacji, żeby przebudowany binarnik nie przemknął w środku serii

Następnie runner próbkuje obciążenie CPU całej maszyny raz na sekundę i startuje gałąź dopiero, gdy próbka spadnie do 25% lub niżej, czekając najwyżej 30 sekund, zanim zapisze próbę jako odrzuconą. Ta bramka kontroluje warunek startu i nic ponadto: nie izoluje maszyny w trakcie przebiegu, a stan zasilania, throttling termiczny, praca w tle i cache systemu operacyjnego nadal mogą ruszać liczby. Dlatego drugi filtr jest statystyczny w najprostszym sensie. Dla każdej operacji runner liczy zakres przez medianę dla gałęzi baseline, gałęzi kandydata i rozkładu parami branych ilorazów kandydat/baseline, a gdy którakolwiek z tych trzech wartości przekroczy 0.15, wynik dostaje etykietę noisy zamiast trafić do raportu jako finding

Bramki porównania parami w PDFlibPas: trzy pary biegną w kolejności A/B, B/A, A/B z hashowaniem wejścia SHA-256 przed każdą gałęzią, bramka startowa czeka na CPU na poziomie 25 procent lub niżej, a rozrzuty zakres-do-mediany ponad 0.15 na LoadFromFile albo na gałęzi load-plus-SaveToFile etykietują przebieg jako noisy
Naprzemienna kolejność startu rozlewa bias cache i termiki na obie gałęzie, a kontrola same-binary pokazuje, co taki układ może udowodnić: ilorazy bliskie 1.0 dowodzą powtarzalności, nigdy przyspieszenia

Dlaczego kontrola same-binary dowodzi powtarzalności, a nie szybkości?

Kontrola same-binary puszcza identyczne pliki wykonywalne jako baseline i kandydata, więc iloraz bliski 1.0 może jedynie udowodnić, że układ pomiarowy powtarza sam siebie; nigdy nie pokaże, że implementacja zrobiła się szybsza. Pierwsza rygorystyczna kontrola 2026-09-21 użyła sondy FPC Win64 o wysokiej rozdzielczości na przyjętym tagged guide o 70 stronach, a wszystkie sześć startów zostało odrzuconych, bo próbki CPU wahały się od 26.5% do 93.8%. Raport zawierał same porażki i żadnych agregatów, co jest dokładnie takim wynikiem, jakiego chcesz, gdy maszyna jest zajęta. Ponowna próba tego samego dnia z bajt w bajt identycznymi wejściami, tym samym plikiem wykonywalnym sondy i bez zmian progów przyjęła wszystkie sześć startów w ciągu 3 sekund; każdy rozrzut zakres-do-mediany wylądował między 0.019 a 0.054, a mediany ilorazów wyniosły 1.0084 dla LoadFromFile i 0.9872 dla LoadFromFile + SaveToFile

Ta para liczb ustanawia kwalifikowane okno obserwacji i nic ponadto. Gdy dwa pliki wykonywalne się różnią, stabilny przebieg dostaje etykietę porównania opisowego, z jawną adnotacją, że ilorazy są obserwacjami, a nie istotnością statystyczną czy obietnicą przyspieszenia. Dyscyplina jest najważniejsza, gdy walidujesz celowane optymalizacje, jak te opisane w artykule o profilowaniu PDF Library for Delphi i zastępowaniu gorących ścieżek indeksami hash: profiler mówi ci, gdzie ucieka czas, ale dopiero kontrolowany przebieg parami na prawdziwych dokumentach mówi, czy zmiana przeżyła kontakt z całym pipeline'em. Jeszcze jedna granica warta powiedzenia wprost — normal-save obejmuje wczytywanie, a szczytowy working set rejestrowany przez runnera dotyczy całego procesu, więc nic z tego nie jest pamięcią, którą można przypisać samemu zapisowi

Trzy bramki wyjścia i macierz czterech kompilatorów

Żaden pomiar PDF Library for Delphi się nie liczy, dopóki wyprodukowany przez niego plik nie przejdzie trzech niezależnych bramek, bo zapis, który szybko tworzy zepsuty PDF, nie jest szybszym zapisem. Benchmark najpierw sprawdza, że obie operacje zwróciły 1 i zgłosiły przyjętą liczbę stron, a potem waliduje jedyny zapisany PDF w tej kolejności:

Trzy bramki wyjścia w PDFlibPas: obie operacje muszą zwrócić 1 z przyjętym PageCount, niezależny checker musi przyjąć zapisany plik bez ostrzeżeń, każda strona musi się zrenderować do zbioru SHA-256 obrazów per strona zgodnego ze źródłem, a semantyka niewizualna musi się zgadzać na optional content i strukturach pomiarowych
Zapis, który szybko tworzy zepsuty PDF, nie jest szybszym zapisem, więc pomiar liczy się dopiero wtedy, gdy struktura, rendering i semantyka niewizualna zgodnie potwierdzą, że wyjście to nadal ten sam dokument
  • Struktura: niezależny checker PDF musi przyjąć zapisany plik bez błędów i ostrzeżeń
  • Rendering: każda strona jest renderowana w stanie domyślnym, a zbiór SHA-256 obrazów per strona musi dokładnie zgadzać się z referencyjnym renderingiem przyjętego źródła
  • Semantyka niewizualna: osobne porównanie semantyczne ze źródłem obejmuje wybrane właściwości, których piksele nie pokażą, w tym struktury optional-content i pomiarowe w ramach ich udokumentowanego zakresu

Z tymi bramkami na miejscu pełna lokalna macierz korpusu puściła sondę na FPC Win32, FPC Win64, Delphi Win32 i Delphi Win64 przez 12 przyjętych PDF-ów o 1 612 stronach źródłowych, dając 48 par próbka/cel i 6 448 zwalidowanych stron wyjściowych bez żadnych wybranych różnic semantycznych. Wszystkie 96 pomiarów operacji zachowało dodatnie surowe wartości licznika spójne z raportowanymi sekundami, a tych wartości celowo nie agreguje się do tabeli szybkości międzykompilatorowej, bo macierz jest dowodem funkcjonalnym, a nie kontrolowanym porównaniem. Ścieżka load/save nie rości też sobie prawa do dekodowania każdego osadzonego obrazu, walidacji podpisów, wykonywania XFA ani certyfikacji PDF/UA; jeśli musisz ocenić przepustowość renderingu, a nie koszt load/save, lepszym punktem wyjścia są ograniczenia współbieżności z artykułu o równoległym renderowaniu stron i bezpieczeństwie wątkowym w PDF Library for Delphi

Praktyczny wniosek jest krótki: trzymaj surowe liczniki, naprzemiennie ustalaj kolejność, bramkuj start, odrzucaj zaszumione rozrzuty i nigdy nie mierz wyjścia, którego nie zwalidowałeś. Te reguły pozwalają PDF Library for Delphi mówić „brak mierzalnej zmiany” z tą samą pewnością, co „szybciej”, a to samo źródło sondy kompiluje się bez zmian na Delphi i FPC dla Win32 i Win64. Bibliotekę, jej API load/save i obsługiwane kompilatory możesz przejrzeć na stronie produktu PDF Library for Delphi