Tehnički članak

Win64-only Delphi bugovi pronađeni dok se očvršćavao HotPDF

Win64 Delphi kod može pasti gde isti izvor radi čisto na Win32, i HotPDF Delphi PDF komponenta nanela je pet takvih slučajeva tokom skorašnjeg prolaza očvršćavanja: Power(10, N) vezan na Single overload, while petlja koja čita zastareli TList.Count, High(Int64) granica koja se zaokruži na 2^63, 15-cifreni float tekst na FPC-u i test asserti koji prekidaju kompajliranje

Ništa od ovoga ne pokazuje se ako samo gradite i testirate Win32, što je upravo kako su se ubacili. Slučajevi ispod dolaze iz HotPDF SVG i XPS uvoznika, njegovog renderer-a stranica i njegovog čitača JSON poslova, i citirani numerički rezultati reprodukovani su malim probnim programima građenim za Win32 i Win64. Ako premeštate Delphi bazu koda na 64 bita, svaki od njih vredi grep

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

Na Win64, System.Math.Power(10, N) sa celobrojnim argumentima razrešava se na Single overload, pa se rezultat računa i vraća u jednostrukoj preciznosti i sve iznad otprilike 3.4E38 se prelije. Na Win32 isti poziv vezuje se na Extended overload i radi na x87 FPU sa 80-bitnom preciznošću, pa je Power(10, 100) jednostavno 1E100

System.Math deklariše Power za Extended, Double i Single, plus odgovarajuću IntPower porodicu koju Power zove kad je eksponent ceo broj. Na Win64, Extended je samo alias za Double (SizeOf(Extended) = 8), i za dva celobrojna argumenta kompajler bira Single verziju. Otkrivalac je preciznost, ne samo prelivanje: na Win64, Power(10, 20) vraća 1.0000000200408773E20, što je tačno Single(1E20). Double rezultat ispisao bi se kao 1E20. Videli smo isto vezivanje sa svakim Win64 kompajlerom koji smo probali, od Delphi 10.3 do kompajlerske verzije 37.0

Šta se dalje dešava zavisi od maske izuzetaka pokretnog zareza. Delphi 12 i noviji maskiraju sve izuzetke pokretnog zareza podrazumevano, pa je prelivanje tiho: Power(10, 100) vraća +Inf a Power(10, -100) vraća 0. Delphi 11 i stariji ostavljaju exOverflow nemaskiran, i isti poziv podiže EOverflow. Aplikacije koje same postave masku, i DLL-ovi učitani u takve domaćine, dobijaju ono ponašanje koje je domaćin izabrao, pa biblioteka ne može pretpostaviti nijedan ishod

HotPDF Win64 numerička zamka gde se System.Math Power sa celobrojnim argumentima vezuje na Single overload, pa Power od 10 na 20. vraća 1.0000000200408773E20 umesto 1E20 a Power od 10 na 100. daje plus beskonačnost kad su izuzeci maskirani ili EOverflow kad nisu
gubitak preciznosti je otkrivalac: ako se stepen desetice vrati sa Single šumom, pogrešni overload je pobedio — gradite razmeru sami
uses
  System.SysUtils, System.Math;

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

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

Skidanje maske sa exOverflow i exInvalidOp za vreme testa najjeftiniji je način da vidite šta vide stariji kompajler ili strog domaćin. Na modernom kompajleru sa podrazumevanim podešavanjima bug ne pada, proizvodi beskonačnosti i nule, a one se mnogo teže primete u logu testa. Vratite prethodnu masku u finally: maska je stanje po niti, i ostatak test izvođenja nasleđuje ono što ostavite za sobom

Kako je overload stigao do HotPDF SVG i XPS uvoza

HotPDF SVG i XPS čitači putanja dele jedan skener brojeva, i taj skener skalirao je mantisu sa Power(10, Exponent) jednom kad pročita eksponent. Svaki SVG predat THotPDF.ImportSVGFormXObject (ulazna tačka iza uvoza SVG u PDF kao višekratno upotrebljive form XObject-e), i svaka geometrija putanja obrađena tokom konverzije XPS i OpenXPS u PDF, mogla je zato da dohrani koordinatu poput 1e100 ili 5e99 u taj poziv

v2.770.91 već je ograničavala eksponent na 100 i odbijala vrednosti koje bi prešle 1E300, što je izgledalo dovoljno: 1E100 nimalo nije blizu Double granice od oko 1.8E308. Na Win64 i dalje se prelivalo, jer se računanje nikada dešavalo u Double. Od v2.770.155 skener sam gradi stepen desetice, i brojevi poput 1e-100 ili duga mantisa sa velikim negativnim eksponentom čitaju se kao svoja prava vrednost umesto da se sruše na 0

Bezbedan stepen desetice za ograničene eksponente

Kad je eksponent ograničen, najbezbedniji stepen desetice je onaj koji sami sagradite Double množenjem. Petlja od najviše 100 množenja ne košta ništa uz skeniranje teksta oko nje, nikada ne proizvodi međuvrednost veću od konačne razmere, 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 opseg
  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;       // nikada ne premašuje 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // delite: 1E-100 nema tačan Double
  Result := True;
end;

Tri detalja nose težinu. Provera opsega koristi dva poređenja umesto Abs(Exponent) <= 100, jer je Abs(Low(Integer)) i dalje negativno i prošlo bi pravo kroz. Negativni eksponenti dele razmerom umesto množenja prethodno izračunatim 1E-100, koji nema tačan Double i dodao bi još jedan korak zaokruživanja. I Log10 provera unapred odbija rezultate van Double opsega pre nego što množenje dobije šansu da se prelije

Budite jasni oko onoga što petlja ustupa. Stepeni desetice do 1E22 tačni su u Double-u; iza toga svako množenje zaokružuje, i posle 100 njih razmera sedi nekoliko jedinica na poslednjem mestu od ispravno zaokruženog 1E100. Za koordinate crtanja to je nevidljivo. Za opštu konverziju tekst-u-double koja mora reprodukovati svaku vrednost bit po bit, nije dovoljno dobro, i treba vam ispravno zaokružen algoritam konverzije umesto

Kad dcc64 čita zastareli TList.Count u while petlji

Uočili smo da Win64 kompajler (dcc64, kompajlerska verzija 37.0) generiše kod za while List.Count > Start do petlju koja briše sa kraja liste i poredi protiv stekne privremene umesto da ponovo čita Count. Prepravka koja ga je popravila bila je for ... downto petlja, čije se granice po definiciji izračunavaju tačno jednom

Petlja je stigla u v2.769.3, koja je naučila kod prozirnih grupa renderer-a da drži meke maske stvorene unutar grupe živim kroz dvoprolazni render i oslobodi ih posle. Čišćenje je sedelo u finally bloku posle jedno- ili dvoprolazne for petlje, unutar petlje po pločici. Svedeno na svoj oblik, pre i posle izgleda ovako:

// Oblik koji smo videli pogrešno kompajliran od dcc64 (kompajlerska 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;

// Zamena: granice se izračunavaju jednom, nema privremene koja zastari
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 generisanom Win64 kodu, Count u uslovu petlje i Count pročitan unutar tela delili su jedan stekni slot. Uslov je poredio protiv tog slota na ulazu, pre nego što je bilo šta upisano, i ništa ga nije osvežilo posle Delete. Kad grupa nije stvorila sopstvene meke maske, telo je ipak radilo i tražilo je od prazne liste stavku -1, pa je u 64-bitnim verzijama svaka stranica koja sadrži takvu prozirnu grupu padala sa EListError. Win32 kod za isti izvor bio je ispravan, i v2.770.1 zamenila je petlju

HotPDF Win64 codegen zamka u čišćenju renderer-a: while petlja koja ponovo čita TList.Count delila je jedan stekni slot između uslova i tela, dcc64 ga nikada nije osvežio posle Delete, prazne prozirne grupe oslobađale su stavku -1 i podizale EListError, a popravka je for downto petlja čije se granice izračunavaju jednom
praktična lekcija košta manje od korena: downto petlje fiksnih granica ne mogu zastareti, i rad na renderer-u nije gotov dok dcc64 ne izvrši paket testova

Nismo ovo sveli na minimalnu reprodukciju, i mala samostalna petlja poput DropMasksWhile može se sasvim dobro kompajlirati ispravno; okolni try/finally i ugnježdene petlje izgleda su bitne. Tretirajte to kao generisanje koda koje smo uočili na jednoj kompajlerskoj verziji, ne kao poznat defekt svakog Win64 kompajlera. Praktična lekcija je jeftinija od korena: petlja čiji uslov ponovo čita broj zbirke dok telo skuplja tu zbirku vredi prepraviti u for ... downto fiksnih granica, i izmene renderer-a traže potpuno Win64 test izvođenje, ne samo Win32

Nalaženje pada koji pokazuje samo optimizovana Win64 verzija

Kvar se reprodukovao samo u optimizovanoj Win64 verziji, pa je lokacija došla iz alata van IDE-a. Mali probni program registrovao je vectored exception handler sa AddVectoredExceptionHandler, uhvatio stek pri prvom izuzetku sa RtlCaptureStackBackTrace, i preveo povratne adrese u imena funkcija koristeći detaljni map fajl koji linker piše sa -GD. Rastavljanje te funkcije zatim pokazalo je poređenje koje čita stekni slot, [rbp+0x298], koji je pisano samo unutar tela petlje. To je nivo dokaza koji hoćete pre nego što okrivite kompajler, i trajalo je kraće od prelaska kroz release verziju

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

Double ne može predstaviti High(Int64): pretvaranje 9223372036854775807 u Double zaokružuje se tačno na 2^63, jedan iza najvećeg Int64. Na Win64 to pretvaranje dešava se unutar samog poređenja, pa je D <= High(Int64) True za D = 2^63, i Round ili Trunc koji prati se prelije

Win32 ovo krije iz istog razloga iz kojeg krije Power problem. Poređenje radi u 80-bitnoj Extended preciznosti sa 64-bitnom mantisom, gde je High(Int64) tačan i 2^63 se ispravno poredi kao veće. Win64 nema širi tip na koji može pasti. Van-opseg pretvaranje nije ni lepo: u našim Win64 testovima Round(2^63) vratio je Low(Int64), tihi preokret znaka, bilo da je exInvalidOp bio maskiran ili ne. Win32 vraća istu vrednost kad je maskirano i podiže EInvalidOp kad nije

HotPDF Int64 zamka granice: Double ne može predstaviti High(Int64), pa Win64 poređenje pretvara granicu gore na 2^63, D jednak 2^63 prolazi proveru i Round tiho vraća Low(Int64), dok Win32 poredi u 80-bitnom Extended gde je granica tačna i isto poređenje je False
jedno pretvaranje je ceo bug: granica se zaokruži na baš vrednost koju isključujete, pa pišite plafon kao literal sa strogim manje-od
IzrazWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), izuzeci maskirani (Delphi 12+ podrazumevano)1E100+Inf
Power(10, 100), exOverflow nemaskiran1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp nemaskiranEInvalidOpLow(Int64)

HotPDF je ovo sreo u čitaču JSON-a iza vrednosti poslova njegovih dokumenata. JSON ne stavlja granicu opsega na brojeve, i stari serijalizator pretvarao je svaku vrednost sa Frac(Value) = 0 u ceo broj sa Round, pa je sasvim legalni 1e19 postajao ili pogrešan celobrojni ili izuzetak, u zavisnosti od maske. Od v2.770.169 ceo broj piše se kao celobrojni samo kad staje u Int64, sve ostalo čuva svoj tekst pokretnog zareza, i celobrojni getteri vraćaju podrazumevanu vrednost pozivaoca za van-opseg vrednosti umesto omotane

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

Gornja granica je literal 9223372036854775808.0 sa strogim <. Ta konstanta je 2^63, tačna i u Double i u Extended, pa poređenje znači isto na svakoj platformi. Donja granica može koristiti >= jer je -2^63 tačno Low(Int64). Testiranje IsNan i IsInfinite prvo, sa short-circuit vrednovanjem, drži NaN i beskonačnosti van Frac i poređenja, koja mogu podići EInvalidOp kad ih domaćin skine sa maske

Koliko cifara float-u-tekst zaista 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 cifara, pa se 1/3 vraća kao 0.333333333333333 i dve različite Double vrednosti mogu se serijalizovati u identičan tekst. Str(Value:24, Text) praćen sa Trim proizvodi 17 značajnih cifara u naučnoj notaciji, 3.3333333333333331E-001 za istu vrednost, i uvek piše tačku kao decimalni separator bez obzira na lokal. Ako je HotPDF na FPC-u deo vaše matrice gradnje, HotPDF Free Pascal i Lazarus Win64 napomene podrške pokrivaju ostatak razlika platforme

Delphi prima zahtev od 17 cifara, ali se dva Delphi cilja i dalje ne slažu o izlazu: FloatToStrF(0.1, ffGeneral, 17, 0) daje 0.10000000000000001 na Win32 i 0.1 na Win64. Win64 RTL takođe može uvesti grešku zaokruživanja poslednje cifre i pri formatiranju i pri parsiranju, pa više cifara sužava procep bez garancije da svaki Double bit obrazac preživi tekstualni put tamo-nazad. HotPDF dokumentacija ne daje takvo obećanje, i vaša ne bi smela dok ne isporučite sopstveni ispravno zaokružen formater i parser. Prosledite TFormatSettings.Invariant, ili zamenite separator sami na starijim Delphi verzijama, da nemački ili francuski lokal ne upiše zarez u JSON

Zašto Assert.AreEqual prestaje da se kompajlira na Win64?

Assert.AreEqual(3, Length(Arr)) na dinamičkom nizu kompajlira se za Win32 i pada za Win64 sa E2532, „Couldn't infer generic type argument from different argument types“, jer Length dinamičkog niza vraća NativeInt na Win64. Sa Integer literalom sa jedne strane i 64-bitnim NativeInt sa druge, DUnitX generički Assert.AreEqual<T> ne može se odlučiti za jedno T, i gradnja staje

TList.Count okida istu grešku od Delphi 12, gde je svojstvo postalo NativeInt; Delphi 11 ga i dalje deklariše kao Integer. Length od string vraća Integer na obe platforme i nije pogođen, pa se greška pojavljuje u nekim test jedinicama a u drugima ne. Napišite argument tipa eksplicitno, Assert.AreEqual<NativeInt>(3, Length(Arr)), i kompajlirajte test projekat sa dcc64 pre commit-a. Paket koji se samo gradi za Win32 neće vam reći da mu je Win64 gradnja polomljena dok neko drugi ne proba

Win64 proverna lista za prenos Delphi numeričkog koda

  • Tražite Power( i IntPower( pozive sa celobrojnim argumentima; predajte Double-tipovane vrednosti ili sami gradite ograničene stepene desetice
  • Pokrenite numeričke testove bar jednom sa exOverflow i exInvalidOp uklonjenim kroz SetExceptionMask, i na Win32 i na Win64
  • Pišite Int64 gornju granicu kao < 9223372036854775808.0, nikada <= High(Int64), i odbijajte NaN i beskonačnosti pre bilo kog poređenja
  • Ne pretvarajte parsiran broj u Int64 samo zato što je Frac 0; JSON brojevi mogu biti daleko veći
  • Prepravite while petlje koje ponovo čitaju Count dok brišu stavke u for ... downto petlje fiksnih granica
  • Na FPC Win64, koristite Str(Value:24, Text) kad treba više od 15 značajnih cifara
  • Koristite Assert.AreEqual<NativeInt> za Length i Count aserte, i kompajlirajte testove sa dcc64 pre commit-a
  • Posle svake izmene parsera ili renderer-a, pokrenite kompletan regresioni paket na Win32 i Win64, ne samo na jednom od njih

Popravke na strani biblioteke opisane ovde sve su u HotPDF-u od v2.770.169, pa se SVG uvoz, XPS konverzija, renderovanje prozirnosti i rukovanje JSON poslovima sada ponašaju isto na Win64 kao na Win32. Ako generišete ili obrađujete PDF fajlove iz Delphi-ja ili C++Builder-a za obe platforme, HotPDF Delphi PDF komponenta stranica ima preuzimanja i kompletnu listu funkcija