Tehnični članak

Hrošči Delphi samo na Win64, najdeni pri utrjevanju HotPDF

Koda Delphi Win64 lahko odpove tam, kjer isti vir teče čisto na Win32, komponenta HotPDF Delphi PDF pa je zadnja utrjevalna poteza našla pet takih primerov: Power(10, N), vezan na preobložitev Single, zanka while, ki bere zastarel TList.Count, meja High(Int64), ki se zaokroži navzgor na 2^63, 15-mestno besedilo float na FPC in asercije v testih, ki prenehajo prevajati

Noben se ne pokaže, kadar gradite in testirate samo Win32 — točno tako so ušli pozornosti. Primeri spodaj prihajajo iz uvoznikov SVG in XPS komponente HotPDF, njenega izrisovalnika strani in njenega bralnika poslov JSON, navedeni številčni rezultati pa so bili ponovljeni z majhnimi sondami, zgrajenimi za Win32 in Win64. Če premikate zbirko kode Delphi na 64 bitov, je vsak vredn enega grepa

Zakaj se Power(10, 100) prelije samo na Win64?

Na Win64 se System.Math.Power(10, N) s celoštevilskimi argumenti razreši na preobložitev Single, tako da se rezultat izračuna in vrne v enojni natančnosti in vse nad približno 3.4E38 se prelije. Na Win32 se isti klic veže na preobložitev Extended in teče na FPU x87 s 80-bitno natančnostjo, tako da je Power(10, 100) preprosto 1E100

System.Math deklarira Power za Extended, Double in Single, plus ujemajočo družino IntPower, ki jo Power kliče, kadar je eksponent celo število. Na Win64 je Extended samo vzdevek za Double (SizeOf(Extended) = 8), za dva celoštevilska argumenta pa prevajalnik izbere različico Single. Razkrivalec je natančnost, ne samo prelivanje: na Win64 Power(10, 20) vrne 1.0000000200408773E20, kar je točno Single(1E20). Rezultat Double bi se natisnil kot 1E20. Isto vezavo smo videli s vsakim prevajalnikom Win64, ki smo ga poskusili, od Delphi 10.3 do različice prevajalnika 37.0

Kaj se zgodi naprej, je odvisno od maske izjem plavajoče pike. Delphi 12 in novejši privzeto maskirajo vse izjeme plavajoče pike, tako da je prelivanje tiho: Power(10, 100) vrne +Inf in Power(10, -100) vrne 0. Delphi 11 in starejši pustijo exOverflow nemaskiran in isti klic javi EOverflow. Aplikacije, ki masko nastavijo same, in DLL-ji, naloženi v take gostitelje, dobijo kakršno koli obnašanje, ki ga je izbral gostitelj — zato knjižnica ne more predpostaviti nobenega od obeh izidov

HotPDF številčna past Win64, kjer se System.Math Power s celoštevilskimi argumenti veže na preobložitev Single, tako da Power od 10 na dvajseto vrne 1.0000000200408773E20 namesto 1E20 in Power od 10 na stoto da plus neskončnost, kadar so izjeme maskirane, ali EOverflow, kadar niso
Izguba natančnosti je razkrivalec: če pride potenca deset nazaj s šumom Single, je zmagala napačna preobložitev — zgradite merilo sami
uses
  System.SysUtils, System.Math;

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

  // Ponovite, kaj naredi Delphi 11 ali gostitelj s strogimi FP nastavitvami
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

Odmaskiranje exOverflow in exInvalidOp za trajanje preskusa je najcenejši način, da vidite, kaj vidi starejši prevajalnik ali strog gostitelj. Na modernem prevajalniku s privzetimi nastavitvami hrošč ne sesuje — proizvaja neskončnosti in ničle, ti pa so v dnevniku preskusa mnogo težje opazne. Povrnite prejšnjo masko v finally: maska je stanje na nit in preostanek preskusnega pogona podeduje, kar koli pustite za sabo

Kako je preobložitev dosegla uvoz SVG in XPS v HotPDF

Bralca poti SVG in XPS v HotPDF si delita enega pregledovalnika števil, ta pregledovalnik pa je merilo skaliral z Power(10, Exponent), takoj ko je prebral eksponent. Vsak SVG, podan THotPDF.ImportSVGFormXObject (vstopna točka za uvoz SVG v PDF kot ponovno uporabne forme XObject), in vsaka geometrija poti, obdelana med pretvorbo XPS in OpenXPS v PDF, sta lahko v ta klic nasula koordinato, kot je 1e100 ali 5e99

v2.770.91 je že stisnil eksponent na 100 in zavrl vrednosti, ki bi prešle 1E300, kar je izgledalo dovolj: 1E100 je nikjer blizu meje Double, približno 1.8E308. Na Win64 se je še vedno prelilo, ker se izračun sploh ni zgodil v Double. Od v2.770.155 pregledovalnik sam zgradi potenco deset, številke, kot je 1e-100, ali dolga mantisa z velikim negativnim eksponentom pa se preberejo v svoji pravi vrednosti, namesto da bi se zrušile v 0

Varna potenca deset za omejene eksponente

Kadar je eksponent omejen, je najvarnejša potenca deset tista, ki jo zgradite sami z množenjem Double. Zanka z največ 100 množenji ne stane nič ob pregledu besedila okoli nje, nikoli ne izdela vmesnega rezultata, večjega od končnega merila, in se obnaša identično na Win32, Win64 in Free Pascal

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;
  // Zavrni rezultate, ki bi zapustili obseg 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;       // nikoli ne preseže 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // deljenje: 1E-100 nima točnega Double
  Result := True;
end;

Tri podrobnosti nosijo težo. Preizkus obsega uporablja dve primerjavi namesto Abs(Exponent) <= 100, ker je Abs(Low(Integer)) še vedno negativen in bi zdrsel naravnost skozi. Negativni eksponenti delijo z merilom, namesto da bi množili z vnaprej izračunanim 1E-100, ki nima točnega Double in bi dodal še en korak zaokroževanja. In predpreizkus Log10 zavrne rezultate izven obsega Double, preden ima množenje priložnost preliti

Bodite jasni glede tega, česar se zanka odpove. Potence deset do 1E22 so točne v Double; za tem se zaokroži vsako množenje in po 100 od njih merilo leži za nekaj enot zadnjega mesta proč od pravilno zaokroženega 1E100. Za risalne koordinate je to nevidno. Za splošno pretvorbo besedilo-v-double, ki mora vsako vrednost povrniti bit za bitom, to ni dovolj dobro in potrebujete namesto tega pravilno zaokrožen pretvorbeni algoritem

Kadar dcc64 prebere zastarel TList.Count v zanki while

Opazili smo, da je prevajalnik Win64 (dcc64, različica prevajalnika 37.0) izdelal kodo za zanko while List.Count > Start do, ki je brisala s konca seznama in primerjala s začasno vrednostjo na skladu, namesto da bi ponovno prebrala Count. Prepis, ki jo je popravil, je bila zanka for ... downto, katere meje so po definiciji ocenjene točno enkrat

Zanka je prišla v v2.769.3, ki je naučila kodo skupin prosojnosti izrisovalnika, da obdrži mehke maske, ustvarjene znotraj skupine, žive čez dvoprehodni izris, in jih nato sprosti. Čiščenje je ležalo v bloku finally za eno- ali dvoprehodno zanko for, znotraj zanke na ploščico. Zreducirano na njeno obliko, pred in po izgledata takole:

// Oblika, ki jo smo vidili napačno prevedeno s strani dcc64 (različica prevajalnika 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;

// Zamenjava: meje so ocenjene enkrat, brez začasne vrednosti, ki 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;

V izdelani kodi Win64 si Count v pogoju zanke in Count, prebran znotraj telesa, delila eno mesto na skladu. Pogoj se je primerjal s tem mestom ob vstopu, preden je karkoli zapisal noter, in nič ga ni osvežilo po Delete. Ko skupina ni ustvarila nobene lastne mehke maske, je telo vseeno steklo in vprašalo prazen seznam za predmet -1, tako da je v 64-bitnih izgradnjah vsaka stran s tako skupino prosojnosti odpovedala z EListError. Koda Win32 za isti vir je bila pravilna in v2.770.1 je zanko zamenjal

HotPDF past kodegena Win64 v čiščenju izrisovalnika: zanka while, ki ponovno bere TList.Count, si je delila eno mesto na skladu med pogojem in telesom, dcc64 ga ni osvežil po Delete, prazne skupine prosojnosti so sprostile predmet -1 in javile EListError, popravek pa je zanka for downto, katere meje so ocenjene enkrat
Praktična lekcija stane manj kot korenina: zanke downto s fiksnimi mejami ne morejo zastarati in delo izrisovalnika ni gotovo, dokler dcc64 ne poganja celotne zbirke preskusov

Tega nismo zreducirali na minimalno ponovitev in majhna samostojna zanka, kot je DropMasksWhile, se lahko zelo dobro prevede pravilno; obdajajoči try/finally in gnezdenje zank očitno štejeta. Obravnavajte jo kot generiranje kode, ki smo ga opazili na eni različici prevajalnika, ne kot znano napako vsakega prevajalnika Win64. Praktična lekcija je cenejša od koreninskega vzroka: zanka, katere pogoj ponovno bere števec zbirke, medtem ko telo to zbirko krči, je vredna prepisa v for ... downto s fiksno mejo, spremembe izrisovalnika pa potrebujejo poln preskusni pogon Win64, ne samo Win32

Lociranje sesutja, ki ga pokaže samo optimizirana izgradnja Win64

Odpoved se je ponovila samo v optimizirani izgradnji Win64, tako da je lokacija prišla od orodij izven IDE. Majhna sondu registrira vektorski upravljalnik izjem z AddVectoredExceptionHandler, zajame sklad ob prvi izjemi z RtlCaptureStackBackTrace in pretvori povratne naslove v imena funkcij z uporabo podrobnega zemljevida datoteke, ki ga povezovalnik zapiše z -GD. Razstavljanje te funkcije je nato pokazalo primerjavo, ki bere mesto na skladu, [rbp+0x298], zapisano samo znotraj telesa zanke. To je raven dokazov, ki jih hočete, preden okrivite prevajalnik, in vzela je manj časa kot prestopanje skozi release izgradnjo

Zakaj High(Int64) ni varna zgornja meja za Double?

Double ne more predstaviti High(Int64): pretvorba 9223372036854775807 v Double se zaokroži navzgor točno na 2^63, ena čez največji Int64. Na Win64 se ta pretvorba zgodi znotraj same primerjave, tako da je D <= High(Int64) True za D = 2^63 in Round ali Trunc, ki sledita, se prelijeta

Win32 to skriva iz istega razloga, iz katerega je skril problem Power. Primerjava teče v 80-bitni natančnosti Extended z 64-bitno mantiso, kjer je High(Int64) točen in se 2^63 pravilno primerja kot večji. Win64 nima širšega tipa, na katerega bi padel nazaj. Pretvorba izven obsega ni tudi lepa: v naših preskusih Win64 je Round(2^63) vrnil Low(Int64) — tiho obrat predznaka, ne glede na to, ali je bil exInvalidOp maskiran ali ne. Win32 vrne isto vrednost, kadar je maskiran, in javi EInvalidOp, kadar ni

HotPDF past meje Int64: Double ne more predstaviti High(Int64), tako da pretvorba Win64 v primerjavi dvigne mejo na 2^63, D enak 2^63 gre skozi preizkus in Round tiho vrne Low(Int64), medtem ko Win32 primerja v 80-bitnem Extended, kjer je meja točna in ista primerjava je False
Ena pretvorba je celoten hrošč: meja se zaokroži natančno na vrednost, ki jo izključujete, zato zapišite strop kot literál s strogim manj kot
IzrazWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), izjeme maskirane (privzeto Delphi 12+)1E100+Inf
Power(10, 100), exOverflow nemaskiran1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp nemaskiranEInvalidOpLow(Int64)

HotPDF je to srečal v bralniku JSON za vrednosti dokumentnih poslov. JSON ne postavlja nobene omejitve obsega na števila, stari serializator pa je vsako vrednost z Frac(Value) = 0 spremenil v celo število z Round, tako da je popolnoma legalni 1e19 postal ali napačno celo število ali izjema, odvisno od maske. Od v2.770.169 se celo število zapiše kot celo število samo, kadar gre v Int64, vse drugo obdrži svoje besedilo plavajoče pike, celoštevilčni pridobivalci pa za vrednosti izven obsega vrnejo privzeto vrednost klicalca, namesto prevrnjene

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, točno v Double in 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
  // Klicalci najprej zavrnejo NaN in neskončnosti: JSON nima zapisa zanje
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral se ustavi pri 15 številkah
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

Zgornja meja je literál 9223372036854775808.0 s strogim <. Ta konstanta je 2^63, točna tako v Double kot v Extended, tako da primerjava pomeni isto stvar na vsaki platformi. Spodnja meja lahko uporabi >=, ker je -2^63 točno Low(Int64). Preskušanje IsNan in IsInfinite najprej, z okrajševalnim vrednotenjem, drži NaN in neskončnosti proč od Frac in primerjav, ki lahko javijo EInvalidOp, kadar jih gostitelj ni maskiral

Koliko števk vam pretvorba float-v-besedilo res da na Win64?

Manj, kot prosite, na dveh od treh prevajalnikov. FloatToStrF(Value, ffGeneral, 17, 0) Free Pascal 3.3.1 na Win64 se ustavi pri 15 pomembnih številkah, tako da 1/3 pride nazaj kot 0.333333333333333 in dve različni vrednosti Double se lahko serializirata v identično besedilo. Str(Value:24, Text) sledil z Trim izdela 17 pomembnih števk v znanstvenem zapisu — 3.3333333333333331E-001 za isto vrednost — in vedno zapiše piko kot decimalno ločilo, ne glede na jezikovno okolje. Če je HotPDF na FPC del vaše matrike izgradenj, opombe o podpori HotPDF Free Pascal in Lazarus Win64 pokrivajo preostanek razlik platforme

Delphi sprejme zahtevo po 17 številkah, a se dva cilja Delphi še vedno razhajata glede izhoda: FloatToStrF(0.1, ffGeneral, 17, 0) da 0.10000000000000001 na Win32 in 0.1 na Win64. RTL Win64 lahko vnese tudi napako zaokroževanja zadnje številke tako pri oblikovanju kot pri razčlenjevanju, tako da več števk zoži vrzel, ne da bi jamčilo, da vsak bitni vzorec Double preživi izmeničen prehod skozi besedilo. Dokumentacija HotPDF ne daje take prisege in vaša bi tudi ne smela, razen če pošljete pravilno zaokrožen oblikovalnik in razčlenjevalnik svojega lastnega. Podajte TFormatSettings.Invariant ali zamenjajte ločilo sami na starejših različicah Delphi, da nemško ali francosko jezikovno okolje ne zapiše vejice v JSON

Zakaj Assert.AreEqual na Win64 preneha prevajati?

Assert.AreEqual(3, Length(Arr)) na dinamičnem seznamu se prevede za Win32 in odpove za Win64 z E2532, "Couldn't infer generic type argument from different argument types", ker Length dinamičnega seznama vrača NativeInt na Win64. S literálom Integer na eni strani in 64-bitnim NativeInt na drugi generični Assert.AreEqual<T> DUnitX ne more izbrati enega samega T in izgradnja se ustavi

TList.Count sproži isto napako od Delphi 12, kjer je lastnost postala NativeInt; Delphi 11 jo še vedno deklarira kot Integer. Length niza string vrača Integer na obeh platformah in ni prizadeta — zato se napaka pokaže v nekaterih testnih enotah in ne v drugih. Zapišite tipni argument izrecno, Assert.AreEqual<NativeInt>(3, Length(Arr)), in prevedite testni projekt z dcc64, preden naredite commit. Zbirka preskusov, ki se kadarkoli gradi samo za Win32, vam ne bo povedala, da je njena izgradnja Win64 pokvarjena, dokler tega ne poskusi nekdo drug

Kontrolni seznam prenosa na Win64 za številčno kodo Delphi

  • Poiščite klice Power( in IntPower( s celoštevilskimi argumenti; podajte vrednosti tipizirane Double ali zgradite omejene potence deset sami
  • Poganjajte številčne teste vsaj enkrat z odstranjenima exOverflow in exInvalidOp skozi SetExceptionMask, na Win32 in Win64
  • Zgornjo mejo Int64 zapišite kot < 9223372036854775808.0, nikoli <= High(Int64), in zavrnite NaN ter neskončnosti pred vsako primerjavo
  • Ne pretvarjajte razčlenjenega števila v Int64 samo zato, ker je Frac 0; števila JSON so lahko daleč večja
  • Prepišite zanke while, ki ponovno berejo Count, medtem ko brišejo predmete, v zanke for ... downto s fiksno mejo
  • Na FPC Win64 uporabite Str(Value:24, Text), kadar potrebujete več kot 15 pomembnih števk
  • Uporabite Assert.AreEqual<NativeInt> za asercije Length in Count ter prevedite teste z dcc64, preden naredite commit
  • Po vsaki spremembi razčlenjevalnika ali izrisovalnika pognajte celotno regresijsko zbirko na Win32 in Win64, ne samo na enem od njiju

Popravki na strani knjižnice, opisani tukaj, so vsi v HotPDF od v2.770.169, tako da se uvoz SVG, pretvorba XPS, izrisovanje prosojnosti in ravnanje s posli JSON zdaj obnašajo enako na Win64 kot na Win32. Če izdelujete ali obdelujete datoteke PDF iz Delphija ali C++Builderja za obe platformi, ima stran komponente HotPDF Delphi PDF prenose in celoten seznam zmožnosti