Tehnički članak

Delphi bugovi samo za Win64 pronađeni otvrđivanjem HotPDF-a

Win64 Delphi kod može pasti tamo gdje isti izvor teče čisto na Win32, i HotPDF Delphi PDF komponenta pogodila je pet takvih slučajeva tijekom nedavnog prolaza otvrđivanja: Power(10, N) vezan na Single pretjeranje, while petlja koja čita zastarjeli TList.Count, High(Int64) granica koja se zaokruži na 2^63, 15-znamenkasti float tekst na FPC-u i test aserti koji prestanu kompilirati

Nijedno se ne pokazuje ako samo gradite i testirate Win32, što je točno način na koji su se uvukli. Slučajevi dolje dolaze iz HotPDF SVG i XPS uvoznika, njegovog renderera stranica i njegovog JSON čitača poslova, i navedeni numerički rezultati reproducirani su malim probnim programima građenima za Win32 i Win64. Ako selite Delphi codebase na 64 bita, svaki od njih vrijedi jedan grep

Zašto Power(10, 100) overflowa samo na Win64?

Na Win64 se System.Math.Power(10, N) s cjelobrojnim argumentima razrješuje na Single pretjeranje, pa se rezultat računa i vraća u jednostrukoj preciznosti i sve iznad otprilike 3.4E38 overflowa. Na Win32 se isti poziv veže na Extended pretjeranje i izvodi na x87 FPU-u s 80-bitnom preciznošću, pa je Power(10, 100) jednostavno 1E100

System.Math deklarira Power za Extended, Double i Single, plus odgovarajuću IntPower obitelj koju Power poziva kad je eksponent cijeli broj. Na Win64 Extended je samo alias za Double (SizeOf(Extended) = 8), i za dva cjelobrojna argumenta kompajler bira Single verziju. Otkrivenje je preciznost, ne samo overflow: na Win64 Power(10, 20) vraća 1.0000000200408773E20, što je točno Single(1E20). Double rezultat ispisao bi se kao 1E20. Isti smo binding vidjeli sa svakim Win64 kompajlerom koji smo iskušali, od Delphi 10.3 do kompajler verzije 37.0

Što slijedi ovisi o maski iznimki pokretnog zareza. Delphi 12 i kasniji maskiraju sve iznimke pokretnog zareza po zadanim vrijednostima, pa je overflow tih: Power(10, 100) vraća +Inf, a Power(10, -100) vraća 0. Delphi 11 i raniji ostavljaju exOverflow nemaskiranim, i isti poziv diže EOverflow. Aplikacije koje same postave masku, i DLL-ovi učitani u takve domaćine, dobiju ponašanje koje je domaćin odabrao, pa biblioteka ne može pretpostaviti nijedan ishod

HotPDF Win64 numerička zamka gdje se System.Math Power s cjelobrojnim argumentima veže na Single pretjeranje, pa Power od 10 na 20. vraća 1.0000000200408773E20 umjesto 1E20, a Power od 10 na 100. daje plus beskonačnost kad su iznimke maskirane ili EOverflow kad nisu
gubitak preciznosti je otkrivenje: ako se potencija desetice vrati s Single šumom u doduši, nametnulo se pogrešno pretjeranje — gradite mjerilo sami
uses
  System.SysUtils, System.Math;

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

  // Reproducirajte što radi Delphi 11, ili domaćin sa strogim FP postavkama
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

Demaskiranje exOverflow i exInvalidOp na trajanje testa najjeftiniji je način da vidite što vidi stariji kompajler ili strog domaćin. Na modernom kompajleru sa zadanim postavkama bug se ne ruši, proizvodi beskonačnosti i nule, a one su puno teže uočljive u dnevniku testa. Vratite prethodnu masku u finallyu: maska je stanje po dretvi, i ostatak izvođenja testa nasljeđuje sve što ostavite za sobom

Kako je pretjeranje dospjelo u HotPDF SVG i XPS uvoz

HotPDF SVG i XPS čitači putanja dijele jedan skener brojeva, i taj je skener skalirao mantisu s Power(10, Exponent) jednom kad bi pročitao eksponent. Svaki SVG predan THotPDF.ImportSVGFormXObjectu (ulazna točka iza uvoza SVG-a u PDF kao višekratne form XObjecte), i svaka geometrija putanja obrađena tijekom pretvorbe XPS i OpenXPS u PDF, mogla je stoga utisnuti koordinatu poput 1e100 ili 5e99 u taj poziv

v2.770.91 već je ograničio eksponent na 100 i odbijao vrijednosti koje bi prešle 1E300, što je izgledalo dosta: 1E100 nikud nije blizu Double granice od oko 1.8E308. Na Win64 je i dalje overflowao, jer se račun nikad uopće nije dogodio u Doubleu. Od v2.770.155 skener sam gradi potenciju desetice, pa se brojevi poput 1e-100, ili duga mantisa s velikim negativnim eksponentom, čitaju kao stvarna vrijednost umjesto da se uruše u 0

Sigurna potencija desetice za ograničene eksponente

Kad je eksponent ograničen, najsigurnija potencija desetice jest ona koju sami gradite Double množenjem. Petlja od najviše 100 množenja ne košta ništa naspram skeniranja teksta oko nje, nikad ne proizvodi međuvrijednost veću od konačnog mjerila, i ponaša se identično 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;
  // Odbijte rezultate koji bi napustili Double raspon
  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;       // nikad ne prelazi 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // dijeli: 1E-100 nema točan Double
  Result := True;
end;

Tri detalja nose težinu. Provjera raspona koristi dvije usporedbe umjesto Abs(Exponent) <= 100, jer je Abs(Low(Integer)) i dalje negativno i prošlo bi ravno kroz. Negativni eksponenti dijele mjerilom umjesto da množe unaprijed izračunatom 1E-100, koja nema točan Double i dodala bi još jedan korak zaokruživanja. I Log10 pretprovjera odbija rezultate izvan Double raspona prije nego množenje dobije priliku overflowati

Budite jasni oko toga čega se petlja odriče. Potencije desetice do 1E22 točne su u Doubleu; iza toga svako množenje zaokružuje, i nakon 100 njih mjerilo stoji par jedinica na posljednjem mjestu od točno zaokružene 1E100. Za crtačke koordinate to je nevidljivo. Za općenamjensku pretvorbu teksta u double koja mora reproducirati svaku vrijednost bit po bit nije dovoljno dobro, i trebate algoritam pretvorbe s točnim zaokruživanjem

Kad dcc64 čita zastarjeli TList.Count u while petlji

Primijetili smo da Win64 kompajler (dcc64, kompajler verzija 37.0) generira kod za petlju while List.Count > Start do koja briše s kraja liste i uspoređuje s privremenom vrijednošću na stogu umjesto da ponovno pročita Count. Prepravka koja je to popravila bila je petlja for ... downto, čije se granice po definiciji vrednuju točno jednom

Petlja je stigla u v2.769.3, koji je naučio kod transparency grupe renderera da drži soft maske stvorene unutar grupe živima kroz dvoprolazni render i oslobodi ih poslije. Čišćenje je sjedilo u finally bloku iza jedno- ili dvoprolazne for petlje, unutar petlje po pločici. Svedeno na oblik, prije i poslije izgleda ovako:

// Oblik koji smo vidjeli pogrešno kompiliran od dcc64 (kompajler verzija 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;

// Zamjena: granice se vrednuju jednom, nema privremene vrijednosti koja zastarjeva
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;

U generiranom Win64 kodu Count u uvjetu petlje i Count pročitan unutar tijela dijelili su jedan stog slot. Uvjet se uspoređivao s tim slotom pri ulasku, prije nego je išta u njega zapisalo, i ništa ga nije osvježilo nakon Delete. Kad grupa nije stvorila nijednu vlastitu soft masku, tijelo je ipak izvedeno i zatražilo je od prazne liste stavku -1, pa je u 64-bitnim verzijama svaka stranica koja sadrži takvu transparency grupu padala s EListError. Win32 kod za isti izvor bio je ispravan, i v2.770.1 zamijenio je petlju

HotPDF Win64 codegen zamka u čišćenju renderera: while petlja koja ponovno čita TList.Count dijelila je jedan stog slot između uvjeta i tijela, dcc64 ga nikad nije osvježio nakon Delete, prazne transparency grupe oslobađale su stavku -1 i dižući EListError, a popravak je for downto petlja čije se granice vrednuju jednom
praktična lekcija košta manje od korijena: downto petlje s fiksnim granicama ne mogu zastarjeti, i renderer posao nije gotov dok dcc64 ne izvede paket testova

Nismo ovo sveli na minimalnu reprodukciju, i mala samostalna petlja poput DropMasksWhile sasvim može kompilirati ispravno; okolni try/finally i ugniježđene petlje čine se bitnima. Tretirajte to kao generiranje koda koje smo promatrali na jednoj verziji kompajlera, a ne kao poznati defekt svakog Win64 kompajlera. Praktična lekcija jeftinija je od korijena: petlja čiji uvjet ponovno čita broj zbirke dok tijelo skuplja tu zbirku vrijedi prepraviti u for ... downto s fiksnim granicama, i promjene renderera trebaju potpuno Win64 izvođenje testova, ne samo Win32

Lociranje rušenja koje pokazuje samo optimizirana Win64 verzija

Kvar se reproducirao samo u optimiziranoj Win64 verziji, pa je lokacija došla iz alata izvan IDE-a. Mali probni program registrirao je vectored exception handler s AddVectoredExceptionHandler, zabilježio stog pri prvoj iznimci s RtlCaptureStackBackTrace, i preveo povratne adrese u imena funkcija koristeći detaljnu map datoteku koju linker piše s -GD. Rastavljanje te funkcije pokazalo je zatim usporedbu koja čita stog slot, [rbp+0x298], koji je bio zapisan samo unutar tijela petlje. To je razina dokaza koju želite prije nego optužite kompajler, i trebalo je manje vremena nego korakati kroz release verziju

Zašto High(Int64) nije sigurna gornja granica za Double?

Double ne može predstaviti High(Int64): pretvorba 9223372036854775807 u Double zaokružuje gore točno na 2^63, jedno iza najvećeg Int64a. Na Win64 ta se pretvorba događa unutar same usporedbe, pa je D <= High(Int64) True za D = 2^63, i Round ili Trunc koji slijedi overflowa

Win32 ovo krije iz istog razloga iz kojeg je kriao Power problem. Usporedba izvodi u 80-bitnoj Extended preciznosti s 64-bitnom mantisom, gdje je High(Int64) točan i 2^63 se ispravno uspoređuje većim. Win64 nema širi tip na koji može pasti. Pretvorba izvan raspona također nije lijepa: u našim Win64 testovima Round(2^63) vraćao je Low(Int64), tihu promjenu predznaka, bio exInvalidOp maskiran ili ne. Win32 vraća istu vrijednost kad je maskiran i diže EInvalidOp kad nije

HotPDF Int64 zamka granice: Double ne može predstaviti High(Int64), pa Win64 usporedba pretvara granicu gore na 2^63, D jednak 2^63 prolazi provjeru i Round tiho vraća Low(Int64), dok Win32 uspoređuje u 80-bitnom Extendedu gdje je granica točna i ista usporedba je False
jedna pretvorba cijeli je bug: granica se zaokruži na baš vrijednost koju isključujete, pa pišite strop kao literal sa strogim manje-od
IzrazWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), iznimke maskirane (Delphi 12+ zadano)1E100+Inf
Power(10, 100), exOverflow nemaskiran1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp nemaskiranEInvalidOpLow(Int64)

HotPDF je ovo sreo u JSON čitaču iza vrijednosti poslova dokumenata. JSON ne stavlja granicu raspona na brojeve, i stari je serijalizator svaku vrijednost s Frac(Value) = 0 pretvarao u cijeli broj s Roundom, pa je savršeno legalni 1e19 postao ili pogrešan cijeli broj ili iznimka, ovisno o maski. Od v2.770.169 cijeli se broj zapisuje kao integer samo kad stane u Int64, sve ostalo zadržava svoj tekst pokretnog zareza, i cijelobrojni getteri vraćaju zadanu vrijednost pozivatelja za vrijednosti izvan raspona umjesto omotane

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, točno u 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
  // Pozivatelji prvo odbijaju NaN i beskonačnosti: JSON nema zapisa za njih
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral staje na 15 znamenki
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

Gornja granica literal je 9223372036854775808.0 sa strogim <. Ta je konstanta 2^63, točna i u Doubleu i u Extendedu, pa usporedba znači isto na svakoj platformi. Donja granica može koristiti >= jer je -2^63 točno Low(Int64). Ispitivanje IsNan i IsInfinite prvo, s kratkovalnim vrednovanjem, drži NaN i beskonačnosti dalje od Frac i usporedbi, koje mogu dignuti EInvalidOp kad ga je domaćin demaskirao

Koliko znamenki vam float-u-tekst stvarno daje na Win64?

Manje nego što tražite, na dva kompajlera od tri. Free Pascal 3.3.1 FloatToStrF(Value, ffGeneral, 17, 0) na Win64 staje na 15 značajnih znamenki, pa se 1/3 vraća kao 0.333333333333333 i dvije različite Double vrijednosti mogu se serijalizirati u identičan tekst. Str(Value:24, Text) s Trimom proizvodi 17 značajnih znamenki u znanstvenom zapisu, 3.3333333333333331E-001 za istu vrijednost, i uvijek piše točku kao decimalni separator bez obzira na locale. Ako je HotPDF na FPC-u dio vaše matrice gradnje, HotPDF Free Pascal i Lazarus Win64 napomene o podršci pokrivaju ostatak razlika platformi

Delphi prihvaća zahtjev od 17 znamenki, ali se dva Delphi cilja i dalje ne poklapaju u izlazu: FloatToStrF(0.1, ffGeneral, 17, 0) daje 0.10000000000000001 na Win32 i 0.1 na Win64. Win64 RTL može uvesti i pogrešku zaokruživanja zadnje znamenke i pri formatiranju i pri parsiranju, pa više znamenki sužava jaz bez garancije da će svaki Double bitni obrazac preživjeti putanju teksta tamo i natrag. HotPDF dokumentacija ne daje takvo obećanje, i vaša ne bi smjela osim ako isporučujete vlastiti formater i parser s točnim zaokruživanjem. Prenosite TFormatSettings.Invariant, ili sami zamijenite separator na starijim Delphi verzijama, da njemački ili francuski locale ne zapiše zarez u JSON

Zašto Assert.AreEqual prestaje kompilirati na Win64?

Assert.AreEqual(3, Length(Arr)) na dinamičkom polju kompilira se za Win32 i pada za Win64 s E2532, „Couldn't infer generic type argument from different argument types", jer Length dinamičkog polja vraća NativeInt na Win64. S Integer literalom na jednoj strani i 64-bitnim NativeIntom na drugoj, DUnitX generički Assert.AreEqual<T> ne može se odlučiti za jedan T, i build staje

TList.Count okida istu pogrešku od Delphi 12, gdje je svojstvo postalo NativeInt; Delphi 11 još ga deklarira kao Integer. Length od string vraća Integer na obje platforme i nije zahvaćen, pa se pogreška pojavljuje u nekim testnim jedinicama, a u drugima ne. Zapišite argument tipa eksplicitno, Assert.AreEqual<NativeInt>(3, Length(Arr)), i kompilirajte testni projekt s dcc64 prije predaje. Paket koji se ikad gradi samo za Win32 neće vam reći da mu je Win64 build slomljen dok to netko drugi ne iskuša

Win64 kontrolna lista preseljenja za Delphi numerički kod

  • Tražite pozive Power( i IntPower( s cjelobrojnim argumentima; prenosite Double tipizirane vrijednosti ili sami gradite ograničene potencije desetice
  • Izvedite numeričke testove barem jednom s exOverflow i exInvalidOp uklonjenima preko SetExceptionMask, na Win32 i Win64
  • Pišite Int64 gornju granicu kao < 9223372036854775808.0, nikad <= High(Int64), i odbijajte NaN i beskonačnosti prije bilo koje usporedbe
  • Ne pretvarajte parsirani broj u Int64 samo zato što je Frac 0; JSON brojevi mogu biti daleko veći
  • Prepravite while petlje koje ponovno čitaju Count dok brišu stavke u for ... downto petlje s fiksnim granicama
  • Na FPC Win64 koristite Str(Value:24, Text) kad trebate više od 15 značajnih znamenki
  • Koristite Assert.AreEqual<NativeInt> za Length i Count aserte, i kompilirajte testove s dcc64 prije predaje
  • Nakon svake izmjene parsera ili renderera pokrenite potpuni regresijski paket na Win32 i Win64, ne samo na jednom od njih

Popravci na strani biblioteke opisani ovdje svi su u HotPDF-u od v2.770.169, pa se SVG uvoz, XPS pretvorba, transparency renderiranje i rukovanje JSON poslovima sada ponašaju isto na Win64 kao na Win32. Ako generirate ili obrađujete PDF datoteke iz Delphija ili C++Buildera za obje platforme, stranica HotPDF Delphi PDF komponente ima preuzimanja i potpuni popis značajki