Technický článek

Win64-only chyby Delphi odhalené při hardeningu HotPDF

Kód Delphi na Win64 může selhat tam, kde tentýž zdroják běží na Win32 čistě, a HotPDF Delphi PDF komponenta při nedávném hardeningu narazila na pět takových případů: Power(10, N) se bindující na overload Single, smyčka while čtoucí zastaralé TList.Count, mez High(Int64), který se zaokrouhlí nahoru na 2^63, float text o 15 číslicích na FPC a testovací asserty, které zastaví kompilaci

Ani jeden se neukáže, pokud stavíte a testujete jen Win32, a právě takhle se dostaly dovnitř. Případy dole pocházejí z SVG a XPS importerů HotPDF, z jeho page rendereru a z jeho JSON job readeru a citované numerické výsledky se reprodukovaly malými probe programy buildnutými pro Win32 i Win64. Pokud přesouváte Delphi codebase na 64 bitů, každý z nich stojí za grep

Proč Power(10, 100) přetéká jen na Win64?

Na Win64 se System.Math.Power(10, N) s celočíselnými argumenty resolvuje na overload Single, takže výsledek se počítá a vrací v single precision a cokoliv nad zhruba 3.4E38 přeteče. Na Win32 se totéž volání binduje na overload Extended a běží na x87 FPU s 80bitovou přesností, takže Power(10, 100) je prostě 1E100

System.Math deklaruje Power pro Extended, Double i Single, plus odpovídající rodinu IntPower, kterou Power volá, když je exponent celé číslo. Na Win64 je Extended jen alias pro Double (SizeOf(Extended) = 8) a pro dva celočíselné argumenty kompilátor vybere verzi Single. Prozradí to přesnost, ne jen přetečení: na Win64 vrátí Power(10, 20) 1.0000000200408773E20, což je přesně Single(1E20). Výsledek Double by se vypsal jako 1E20. Stejný binding jsme viděli u každého Win64 kompilátoru, který jsme zkusili, od Delphi 10.3 po compiler version 37.0

Co se stane dál, závisí na masce výjimek floating-point. Delphi 12 a novější maskují defaultně všechny floating-point výjimky, takže přetečení je tiché: Power(10, 100) vrátí +Inf a Power(10, -100) vrátí 0. Delphi 11 a starší mají exOverflow odmaskovaný a totéž volání vyhodí EOverflow. Aplikace, které si masku nastavují samy, a DLL nahrané do takových hostitelů dostanou chování, které si hostitel zvolil, a proto knihovna nemůže předpokládat ani jeden z obou výstupů

Numerická past HotPDF na Win64, kde System.Math Power s celočíselnými argumenty binduje na overload Single, takže Power 10 na 20. vrací 1.0000000200408773E20 místo 1E20 a Power 10 na 100. dá plus nekonečno, když jsou výjimky maskované, nebo EOverflow, když nejsou
ztrátu přesnosti to prozradí: pokud mocnina deseti dorazí s Single šumem, vyhrál špatný overload — škálu si postavte sami
uses
  System.SysUtils, System.Math;

procedure ShowPowerOverload;
var
  N: Integer;
  OldMask: TArithmeticExceptionMask;
begin
  N := 20;
  // Win32 vypíše 1E20; Win64 vypíše 1.0000000200408773E20 (overload Single)
  Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));

  // Reprodukuje, co dělá Delphi 11 nebo hostitel s přísným FP nastavením
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

Odmaskovat exOverflow a exInvalidOp na dobu testu je nejlevnější způsob, jak vidět, co vidí starší kompilátor nebo přísný hostitel. Na moderním kompilátoru s defaultním nastavením bug nepadá, produkuje nekonečna a nuly a ty se v test logu hledají mnohem hůř. Předchozí masku obnovte v finally: maska je stav per-thread a zbytek test runu zdědí, co tam necháte

Jak se overload dostal do importu SVG a XPS v HotPDF

SVG a XPS path readery HotPDF sdílejí jeden number scanner a tenhle scanner škáloval mantisu přes Power(10, Exponent), jakmile přečetl exponent. Jakýkoli SVG předaný do THotPDF.ImportSVGFormXObject (vstupní bod za importem SVG do PDF jako znovupoužitelných form XObjects) a jakákoli path geometrie zpracovaná při konverzi XPS a OpenXPS do PDF mohly touto cestou strčit do toho volání souřadnici jako 1e100 nebo 5e99

v2.770.91 už stropovala exponent na 100 a odmítala hodnoty, které by překročily 1E300, což vypadalo dost: 1E100 je k Double limitu zhruba 1.8E308 nikde. Na Win64 to stejně přeteklo, protože výpočet se vůbec neodehrál v Double. Od v2.770.155 si scanner mocninu deseti staví sám a čísla jako 1e-100 nebo dlouhá mantisa s velkým negativním exponentem se čtou jako jejich skutečná hodnota místo kolapsu na 0

Bezpečná mocnina deseti pro omezené exponenty

Když je exponent omezený, nejbezpečnější mocnina deseti je ta, kterou si postavíte sami násobením v Double. Smyčka z nejvýše 100 násobení nestojí vedle skenování textu kolem nic, nikdy nevyprodukuje mezivýsledek větší, než je finální škála, a chová se identicky na Win32, Win64 i Free Pascalu

const
  MaxDecimalExponent = 100;

function TryScaleByPowerOf10(const Value: Double; Exponent: Integer;
  out Scaled: Double): Boolean;
var
  Scale: Double;
  I: Integer;
begin
  Scaled := 0;
  Result := False;
  if (Exponent < -MaxDecimalExponent) or (Exponent > MaxDecimalExponent) then
    Exit;
  // Odmítněte výsledky, které by opustily rozsah Double
  if (Exponent > 0) and (Value <> 0) and
     (Log10(Abs(Value)) + Exponent > 300) then
    Exit;
  Scale := 1.0;
  for I := 1 to Abs(Exponent) do
    Scale := Scale * 10.0;       // nikdy nepřekročí 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // dělení: 1E-100 nemá přesné Double
  Result := True;
end;

Tři detaily nesou váhu. Kontrola rozsahu používá dvě porovnání místo Abs(Exponent) <= 100, protože Abs(Low(Integer)) je pořád negativní a proletěla by rovnou. Negativní exponenty dělí škálou místo násobení předpočítaným 1E-100, které nemá přesnou reprezentaci v Double a přidalo by další zaokrouhlovací krok. A předběžná kontrola Log10 odmítne výsledky mimo rozsah Double, dřív než má násobení šanci přetéct

Buďte jasní v tom, čeho smyčka nechává. Mocniny deseti do 1E22 jsou v Double přesné; za tou hranicí zaokrouhluje každé násobení a po stovce z nich sedí škála o pár jednotek posledního místa od správně zaokrouhleného 1E100. Pro kreslicí souřadnice je to neviditelné. Pro všeobecnou konverzi textu na double, která musí reprodukovat každou hodnotu bit za bit, to nestačí a potřebujete algoritmus konverze se správným zaokrouhlením

Když dcc64 čte zastaralé TList.Count ve smyčce while

Pozorovali jsme, jak Win64 kompilátor (dcc64, compiler version 37.0) generuje kód pro smyčku while List.Count > Start do, která mazala od konce seznamu a porovnávala proti stack dočasné proměnné místo znovučtení Count. Přepis, který to spravil, byla smyčka for ... downto, jejíž meze se podle definice vyhodnocují přesně jednou

Smyčka přišla ve v2.769.3, která naučila kód transparency group v rendereru držet soft masky vytvořené uvnitř group naživu přes dvouprůchodový render a uvolnit je potom. Úklid seděl v bloku finally za jedno- nebo dvouprůchodovou smyčkou for, uvnitř smyčky per-tile. Zredukováno na tvar, před a po vypadají takhle:

// Tvar, který jsme viděli špatně zkompilovaný dcc64 (compiler version 37.0)
procedure DropMasksWhile(Masks: TList; Start: Integer);
begin
  while Masks.Count > Start do
  begin
    TObject(Masks[Masks.Count - 1]).Free;
    Masks.Delete(Masks.Count - 1);
  end;
end;

// Náhrada: meze se vyhodnotí jednou, žádná dočasná proměnná k zastarání
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.Count je NativeInt od Delphi 12
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

Ve vygenerovaném Win64 kódu sdílely Count v podmínce smyčky a Count čtený uvnitř těla jeden stack slot. Podmínka se na vstupu porovnávala proti tomu slotu, dřív než cokoliv zapsalo, a nic ho neobčerstvilo po Delete. Když group nevytvořila žádné vlastní soft masky, tělo se stejně spustilo a požádalo prázdný seznam o položku -1, takže v 64bitových buildech selhala každá stránka obsahující takovou transparency group s EListError. Win32 kód pro tentýž zdroják byl správný a v2.770.1 smyčku vyměnila

Codegen past HotPDF na Win64 v úklidu rendereru: smyčka while znovu čtoucí TList.Count sdílela jeden stack slot mezi podmínkou a tělem, dcc64 ho po Delete nikdy neobčerstvil, prázdné transparency groupy uvolnily položku -1 a vyhodily EListError a opravou je smyčka for downto, jejíž meze se vyhodnotí jednou
praktická lekce stojí méně než kořen příčiny: downto smyčky s pevnými mezemi nemůžou zestárnout a práce na rendereru není hotová, dokud dcc64 nepustí sadu testů

Neredukovali jsme to na minimální reprodukci a malá samostatná smyčka jako DropMasksWhile se možná zkompiluje správně; okolní try/finally a vnořené smyčky zjevně hrají roli. Berte to jako code generation pozorovanou na jedné verzi kompilátoru, ne jako známou vadu každého Win64 kompilátoru. Praktická lekce je levnější než kořen příčiny: smyčka, jejíž podmínka znovu čte počet kolekce, zatímco tělo kolekci zmenšuje, stojí za přepis na for ... downto s pevnými mezemi a změny rendereru potřebují plný test run na Win64, ne jen Win32

Lokalizace pádu, který ukazuje jen optimalizovaný Win64 build

Selhání se reprodukovalo jen v optimalizovaném Win64 buildu, takže lokalizace přišla z nástrojů mimo IDE. Malý probe program zaregistroval vectored exception handler přes AddVectoredExceptionHandler, zachytil stack při první výjimce přes RtlCaptureStackBackTrace a přeložil návratové adresy na názvy funkcí podle detailní map file, kterou linker píše s -GD. Rozložení té funkce pak ukázalo, že compare čte stack slot [rbp+0x298], který se zapisoval jen uvnitř těla smyčky. To je ta úroveň důkazů, kterou chcete, než obviníte kompilátor, a trvalo to míň času než procházení release buildu

Proč High(Int64) není bezpečná horní mez pro Double?

Double neumí reprezentovat High(Int64): konverze 9223372036854775807 na Double se zaokrouhlí nahoru přesně na 2^63, jednu za největší Int64. Na Win64 se ta konverze stane uvnitř samotného porovnání, takže D <= High(Int64) je True pro D = 2^63 a následné Round nebo Trunc přeteče

Win32 to skrývá ze stejného důvodu, proč schoval problém Power. Porovnání běží v 80bitové přesnosti Extended s 64bitovou mantisou, kde je High(Int64) přesné a 2^63 se správně porovná jako větší. Win64 nemá širší typ, na který by spadlo. Konverze mimo rozsah vypadá taky nehezkě: v našich Win64 testech vrátila Round(2^63) Low(Int64), tiché překlopení znaménka, ať byla exInvalidOp maskovaná nebo ne. Win32 vrátí tutéž hodnotu, když je maskovaná, a vyhodí EInvalidOp, když není

Past meze Int64 v HotPDF: Double neumí reprezentovat High(Int64), takže porovnání na Win64 převede mez nahoru na 2^63, D rovné 2^63 kontrolou projde a Round potichu vrátí Low(Int64), zatímco Win32 porovnává v 80bitovém Extended, kde je mez přesná a totéž porovnání je False
jedna konverze je celý bug: mez se zaokrouhlí na přesně tu hodnotu, kterou vylučujete, takže strop pište jako literal s přísným menším než
VýrazWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), výjimky pod maskou (default Delphi 12+)1E100+Inf
Power(10, 100), exOverflow bez masky1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp bez maskyEInvalidOpLow(Int64)

HotPDF na to narazil v JSON readeru za hodnotami svých document jobů. JSON neklade na čísla žádné omezení rozsahu a starý serializer měnil každou hodnotu s Frac(Value) = 0 na celé číslo přes Round, takže úplně legální 1e19 se měnilo buď ve špatné celé číslo, nebo ve výjimku, podle masky. Od v2.770.169 se celé číslo zapisuje jako integer, jen když se vejde do Int64, všechno ostatní si nechává svůj floating-point text a integer gettery vracejí default volajícího pro hodnoty mimo rozsah místo přetočené

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, přesné v Double i Extended

function TryDoubleToInt64(const Value: Double; out R: Int64): Boolean;
begin
  R := 0;
  Result := not IsNan(Value) and not IsInfinite(Value) and
    (Frac(Value) = 0) and (Value >= -TwoPow63) and (Value < TwoPow63);
  if Result then
    R := Trunc(Value);
end;

function JsonNumberText(const Value: Double): string;
var
  R: Int64;
begin
  // Volající nejdřív odmítnou NaN a nekonečna: JSON pro ně nemá zápis
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral skončí na 15 číslicích
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

Horní mez je literal 9223372036854775808.0 s přísným <. Ta konstanta je 2^63, přesná v Double i Extended, takže porovnání znamená totéž na každé platformě. Dolní mez může použít >=, protože -2^63 je přesně Low(Int64). Testování IsNan a IsInfinite nejdřív, s vyhodnocováním short-circuit, drží NaN a nekonečna dál od Frac a porovnání, která můžou vyhodit EInvalidOp, když ho hostitel odmaskoval

Kolik číslic vám konverze floatu na text na Win64 reálně dá?

Méně, než si řeknete, u dvou kompilátorů ze tří. FloatToStrF(Value, ffGeneral, 17, 0) ve Free Pascal 3.3.1 na Win64 skončí na 15 významných číslicích, takže 1/3 se vrátí jako 0.333333333333333 a dvě různé hodnoty Double mohou serializovat na identický text. Str(Value:24, Text) následované Trim produkuje 17 významných číslic ve vědecké notaci, 3.3333333333333331E-001 pro tutéž hodnotu, a vždy píše tečku jako desetinný oddělovač bez ohledu na locale. Pokud je HotPDF na FPC součástí vaší build matrix, poznámky o podpoře HotPDF Free Pascal a Lazarus Win64 pokrývají zbytek platformových rozdílů

Delphi přijme žádost o 17 číslic, ale oba Delphi targety se ve výstupu pořád neshodnou: FloatToStrF(0.1, ffGeneral, 17, 0) dává 0.10000000000000001 na Win32 a 0.1 na Win64. Win64 RTL navíc může vnést zaokrouhlovací chybu poslední číslice při formátování i při parsování, takže víc číslic mezeru zúží, ale negarantuje, že každý bit pattern Double přežije cestu textem tam i zpět. Dokumentace HotPDF nic takového neslibuje a vaše by také neměla, pokud nedodáváte vlastní formatter a parser se správným zaokrouhlením. Předávejte TFormatSettings.Invariant, nebo si oddělovač na starších Delphi verzích vyměňte sami, aby německé nebo francouzské locale nezapsalo do JSON čárku

Proč Assert.AreEqual na Win64 přestane kompilovat?

Assert.AreEqual(3, Length(Arr)) na dynamickém poli zkompiluje pro Win32 a pro Win64 selže s E2532, „Couldn't infer generic type argument from different argument types", protože Length dynamického pole vrací na Win64 NativeInt. S literálem Integer na jedné straně a 64bitovým NativeInt na druhé si generický Assert.AreEqual<T> z DUnitX nedokáže vybrat jediné T a build se zastaví

TList.Count spustí tutéž chybu od Delphi 12, kde se property stala NativeInt; Delphi 11 ji stále deklaruje jako Integer. Length string vrací na obou platformách Integer a zůstává nedotčená, proto se chyba objevuje v některých test unitách a v jiných ne. Zapište typový argument explicitně, Assert.AreEqual<NativeInt>(3, Length(Arr)), a test projekt zkompilujte s dcc64, než commitnete. Sada testů, která se staví jen pro Win32, vám neřekne, že její Win64 build je rozbitý, dokud to nezkusí někdo jiný

Checklist portu na Win64 pro numerický kód v Delphi

  • Prohledávejte volání Power( a IntPower( s celočíselnými argumenty; předávejte hodnoty typované na Double nebo si omezené mocniny deseti stavte sami
  • Spusťte numerické testy aspoň jednou s exOverflow a exInvalidOp odstraněnými přes SetExceptionMask, na Win32 i Win64
  • Zapisujte horní mez Int64 jako < 9223372036854775808.0, nikdy <= High(Int64), a odmítejte NaN a nekonečna před jakýmkoli porovnáním
  • Nekonvertujte načtené číslo na Int64 jen proto, že Frac je 0; JSON čísla můžou být daleko větší
  • Přepisujte smyčky while, které znovu čtou Count při mazání položek, na smyčky for ... downto s pevnými mezemi
  • Na FPC Win64 použijte Str(Value:24, Text), když potřebujete víc než 15 významných číslic
  • Používejte Assert.AreEqual<NativeInt> pro asserty Length a Count a kompilujte testy s dcc64, než commitnete
  • Po každé změně parseru nebo rendereru pusťte plnou regresní sadu na Win32 i Win64, ne jen na jedné z nich

Opravy na straně knihovny popsané tady jsou všechny v HotPDF od v2.770.169, takže import SVG, konverze XPS, renderování transparency i obsluha JSON jobů se teď na Win64 chovají stejně jako na Win32. Pokud generujete nebo zpracováváte PDF soubory z Delphi nebo C++Builder pro obě platformy, stránka HotPDF Delphi PDF component má stažení a kompletní seznam funkcí