Techninis straipsnis

Tik Win64 Delphi klaidos, rastos kietinant HotPDF

Win64 Delphi kodas gali žlugti ten, kur tas pats šaltinis Win32 sukasi švariai, ir HotPDF Delphi PDF komponentas per neseniai vykusį kietinimo etapą užkliuvo penkiais tokiais atvejais: Power(10, N) prisirišimu prie Single perkrovos, while ciklu, skaitančiu pasenusį TList.Count, High(Int64) riba, apvalinama iki 2^63, 15 skaitmenų float tekstu FPC viduje ir testų assertais, kurie sustabdo kompiliavimą

Joks jų nepasirodys, jei statote ir testuojate tik Win32 – būtent taip jie ir įslyso. Žemiau esantys atvejai ateina iš HotPDF SVG ir XPS importuotojų, jo puslapių atvaizduoklio ir JSON užduočių skaitytuvo, o cituoti skaitiniai rezultatai atkurti mažais zondo procesais, sustatytais Win32 ir Win64. Jei keliate Delphi kodų bazę į 64 bitus, kiekvienas vertas grep'o

Kodėl Power(10, 100) perpildo tik Win64?

Win64 System.Math.Power(10, N) su sveikaisiais argumentais išspręndžia į Single perkrovą, tad rezultatas skaičiuojamas ir grąžinamas viengubu tikslumu, o viskas, kas aukščiau maždaug 3.4E38, perpildo. Win32 tas pats kvietimas prisiriša prie Extended perkrovos ir sukasi ant x87 FPU su 80 bitų tikslumu, tad Power(10, 100) tiesiog yra 1E100

System.Math deklaruoja Power Extended, Double ir Single, plius atitinkančią IntPower šeimą, kurią Power kviečia, kai laipsnio rodiklis sveikasis. Win64 Extended yra tik Double aliasas (SizeOf(Extended) = 8), o dviem sveikiesiems argumentams kompiliatorius renkasi Single versiją. Išdavikas – tikslumas, o ne vien perpildymas: Win64 Power(10, 20) grąžina 1.0000000200408773E20, kas yra lygiai Single(1E20). Double rezultatas atspausdintų 1E20. Tą patį prisirišimą matėme su kiekvienu išbandytu Win64 kompiliatoriumi – nuo Delphi 10.3 iki kompiliatoriaus versijos 37.0

Kas vyksta toliau, priklauso nuo slankiojo kablelio išimčių kaukės. Delphi 12 ir vėlesnės pagal nutylėjimą uždengia visas slankiojo kablelio išimtis, tad perpildymas tylus: Power(10, 100) grąžina +Inf, o Power(10, -100) – 0. Delphi 11 ir ankstesnės exOverflow palieka atidengtą, ir tas pats kvietimas kelia EOverflow. Programos, pačios nustatančios kaukę, ir į tokius šeimininkus įkeliamos DLL gauna tai, ką pasirinko šeimininkas – todėl biblioteka negali manyti nė vienos baigties

HotPDF Win64 skaitiniai spąstai, kai System.Math Power su sveikaisiais argumentais prisiriša prie Single perkrovos, tad dešimt dvidešimtuoju laipsniu grąžina 1.0000000200408773E20 vietoj 1E20, o dešimt šimtuoju – teigiamą begalybę, kai išimtys uždengtos, arba EOverflow, kai atidengtos
tikslumo praradimas – išdavikas: jei dešimties laipsnis grįžta su Single triukšmu prie kailio, laimėjo netinkamoji perkrova — mastelį pasistatykite patys
uses
  System.SysUtils, System.Math;

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

  // Atkurkite tai, ką daro Delphi 11 arba griežtų FP nustatymų šeimininkas
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

exOverflow ir exInvalidOp atdengimas testo metu – pigiausias būdas pamatyti tai, ką mato senesnis kompiliatorius arba griežtas šeimininkas. Ant modernaus kompiliatoriaus su numatytosiomis nustatymais klaida nežlugsta – ji pagimdo begalybes ir nulius, o tuos testų žurnale pastebėti gerokai sunkiau. Ankstesnę kaukę atstatykite finally bloke: kaukė – gijos būsena, ir likusi testo bėgimo dalis paveldi tai, ką paliksite

Kaip perkrova pasiekė HotPDF SVG ir XPS importą

HotPDF SVG ir XPS kelių skaitytuvai dalijasi vienu skaičių skeneriu, o tas skeneris mantisę masteluoja su Power(10, Exponent), vos perskaitęs laipsnio rodiklį. Bet koks SVG, perduotas THotPDF.ImportSVGFormXObject (įėjimo taškui už SVG importavimo į PDF kaip pakartotinai naudojamus formos XObject), ir bet kokia kelio geometrija, apdorojama XPS ir OpenXPS konversijos į PDF metu, galėjo į tą kvietimą įleisti koordinatę, tokią kaip 1e100 arba 5e99

v2.770.91 laipsnio rodiklį jau ribojo iki 100 ir atmesdavo reikšmes, kurios praeitų 1E300, ir atrodė, kad to užtenka: 1E100 niekur šalia Double ribos, apie 1.8E308. Win64 vis tiek perpildydavo, nes skaičiavimas iš viso niekada nevykdavo Double viduje. Nuo v2.770.155 skeneris dešimties laipsnį stato pats, tad skaičiai, tokie kaip 1e-100, ar ilga mantisė su didele neigiama laipsnio rodiklio reikšme skaitomi kaip tikroji jų vertė, vietoj to, kad susigriautų į 0

Saugi dešimties laipsnis apribotiems laipsnio rodikliams

Kai laipsnio rodiklis apribotas, saugiausia dešimties laipsnis – ta, kurią pasistatote patys Double daugyba. Ne daugiau kaip 100 daugybų ciklas nieko nekainuoja greta aplinkinio teksto skenavimo, jis niekada nepagamina tarpinio, didesnio už galutinį mastelį, ir elgiasi identiškai Win32, Win64 ir 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;
  // Atmeskite rezultatus, išeinančius už Double intervalo
  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;       // niekada neviršija 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // dalinkite: 1E-100 neturi tikraus Double
  Result := True;
end;

Trys detalės neša sunkį. Intervalo patikra naudoja du lyginimus vietoj Abs(Exponent) <= 100, nes Abs(Low(Integer)) tebėra neigiamas ir praskrietų pro šalį. Neigiami laipsnio rodikliai dalija iš mastelio vietoj daugybos iš anksto apskaičiuotu 1E-100, kuris neturi tikraus Double ir pridėtų dar vieną apvalinimo žingsnį. O Log10 išankstinė patikra atmeta rezultatus už Double intervalo, dar prieš daugybai atsirandant galimybei perpilti

Būkite aiškūs dėl to, ko ciklas atsisako. Dešimties laipsniai iki 1E22 Double viduje tikrūs; toliau kiekviena daugyba apvalina, o po 100 jų mastelis sėdi keliais vienetais paskutinėje vietoje nuo teisingai apvalinto 1E100. Piešimo koordinatėms tai nematoma. Bendrosios paskirties teksto-į-double konversijai, kuri privalo atkurti kiekvieną reikšmę bitas po bito, to nepakanka, ir jums reikia teisingai apvalinančio konversijos algoritmo

Kada dcc64 skaito pasenusį TList.Count while cikle

Stebėjome, kaip Win64 kompiliatorius (dcc64, kompiliatoriaus versija 37.0) sugeneravo kodą while List.Count > Start do ciklui, trinančiam nuo sąrašo galo ir lyginančiam su steko laikinuoju vietoj pakartotinio Count skaitymo. Perrašymas, jį ištaisęs, – for ... downto ciklas, kurio ribos pagal apibrėžtį vertinamos lygiai kartą

Ciklas atkeliavo v2.769.3, kuris išmokė atvaizduoklio permatomumo grupės kodą išlaikyti grupės viduje sukurtus švelniąsias kaukes gyvus per dviejų ėjimų atvaizdavimą ir atlaisvinti juos po to. Valymas sėdėjo finally bloke po vieno ar dviejų ėjimų for ciklo, plytelių ciklo viduje. Sutrauktas iki savo išvaizdos, prieš ir po atrodo taip:

// Išvaizda, kurią matėme sukompiliuotą neteisingai dcc64 (kompiliatoriaus versija 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;

// Pakaitalas: ribos įvertinamos kartą, nėra laikinojo, galinčio pasenti
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.Count yra NativeInt nuo Delphi 12
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

Sugeneruotame Win64 kode Count ciklo sąlygoje ir Count, skaitomas kūno viduje, dalijosi vienu steko langeliu. Sąlyga lygino su tuo langeliu įėjime, dar niekam jo nerašius, ir niekas jo neatnaujino po Delete. Kai grupė nebūdavo sukūrusi savų švelniųjų kaukių, kūnas vis tiek sukodavo ir prašydavo tuščio sąrašo duoti elementą -1, tad 64 bitų dariniuose kiekvienas puslapis su tokia permatomumo grupe žlugdavo su EListError. Win32 kodas tam pačiam šaltiniui buvo teisingas, o v2.770.1 ciklą pakeitė

HotPDF Win64 codegen spąstai atvaizduoklio valyme: while ciklas, pakartotinai skaitantis TList.Count, dalijosi vienu steko langeliu tarp sąlygos ir kūno, dcc64 jo neatnaujino po Delete, tuščios permatomumo grupės atlaisvino elementą -1 ir kelė EListError, o taisymas – for downto ciklas, kurio ribos vertinamos kartą
praktinė pamoka kainuoja pigiau už šakninę priežastį: fiksuotų ribų downto ciklai negali pasenti, o atvaizduoklio darbas nebaigtas, kol dcc64 nepraveda rinkinio

Mes to nesutraukėme iki minimalaus atkūrimo, ir mažas atskiras ciklas, toks kaip DropMasksWhile, gana tikra susikompiliuos teisingai; aplinkinis try/finally ir įdėtieji ciklai, atrodo, turi reikšmės. Traktuokite tai kaip vienoje kompiliatoriaus versijoje stebėtą kodo generavimą, ne kaip žinomą kiekvieno Win64 kompiliatoriaus defektą. Praktinė pamoka pigesnė už šakninę priežastį: ciklas, kurio sąlyga pakartotinai skaito kolekcijos skaičių, kol kūnas tą kolekciją mažina, vertas perrašymo į fiksuotų ribų for ... downto, o atvaizduoklio pakeitimams reikia pilno Win64 testo bėgimo, o ne vien Win32

Griūties, kurią rodo tik optimizuotas Win64 darinys, suradimas

Nesėkmė atsikartojo tik optimizuotame Win64 darinyje, tad vieta atkeliavo iš įrankių už IDE ribų. Maža zondo programa užregistravo vektorinį išimčių doroklį su AddVectoredExceptionHandler, užfiksavo steką pirmosios išimties metu su RtlCaptureStackBackTrace ir grąžos adresus išvertė į funkcijų vardus naudodama detalųjį failų žemėlapį, kurį linkeris rašo su -GD. Tos funkcijos išskleidimas tada parodė lyginimą, skaitantį steko langelį [rbp+0x298], rašytą tik ciklo kūno viduje. Tai tas įrodymų lygis, kurio norite prieš kaltindami kompiliatorių, ir užėmė mažiau laiko nei žingsniavimas pro release darinį

Kodėl High(Int64) nėra saugi viršutinė riba Double?

Double negali atvaizduoti High(Int64): 9223372036854775807 konversija į Double apvalina iki tiksliai 2^63 – vienu už didžiausiąjį Int64. Win64 ta konversija vyksta pačiame lyginime, tad D <= High(Int64) yra True esant D = 2^63, o sekantis Round arba Trunc perpildo

Win32 tai slepia dėl tos pačios priežasties, kuria slėpė Power problemą. Lyginimas sukasi 80 bitų Extended tikslumu su 64 bitų mantise, kur High(Int64) tikrus, o 2^63 teisingai lyginasi didesne. Win64 neturi platesnio tipo, į kurį grįžti. Už intervalo einanti konversija irgi graži nebūna: mūsų Win64 testuose Round(2^63) grąžino Low(Int64) – tylią ženklo apvertimą, nesvarbu, ar exInvalidOp uždengtas. Win32 grąžina tą pačią reikšmę uždengtai kaukei ir kelia EInvalidOp atidengtai

HotPDF Int64 ribos spąstai: Double negali atvaizduoti High(Int64), tad Win64 lyginimas ribą apvalina iki 2^63, D, lygus 2^63, praeina patikrą, o Round tylioje grąžina Low(Int64), kol Win32 lygina 80 bitų Extended viduje, kur riba tikra, o tas pats lyginimas yra False
viena konversija – visa klaida: riba apvalina ant pačios reikšmės, kurią atmetate, tad lubas rašykite literalu su griežtu mažiau
IšraiškaWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), exceptions masked (Delphi 12+ default)1E100+Inf
Power(10, 100), exOverflow unmasked1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp unmaskedEInvalidOpLow(Int64)

HotPDF tai sutiko JSON skaitytuve, slypinčiame už jo dokumento užduočių reikšmių. JSON skaičiams jokio intervalo limito nėra, o senasis serializatorius bet kurią reikšmę su Frac(Value) = 0 paverčia sveikuoju su Round, tad visiškai teisėta 1e19 tapdavo arba netinkamu sveikuoju, arba išimtimi – pagal kaukę. Nuo v2.770.169 sveikasis skaičius rašomas kaip sveikasis tik tada, kai telpa į Int64, visa kita išlaiko savą slankiojo kablelio tekstą, o sveikųjų getteriai už intervalo einančioms reikšmėms grąžina kvietėjo numatytąją, o ne apvyniotąją

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, tikras Double ir Extended viduje

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
  // Kvietėjai pirmiausia atmeta NaN ir begalybes: JSON jų neparašo
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral sustoja ties 15 skaitmenų
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

Viršutinė riba – literalas 9223372036854775808.0 su griežtu <. Ta konstanta – 2^63, tikra ir Double, ir Extended viduje, tad lyginimas kiekvienoje platformoje reiškia tą patį. Apatinė riba gali naudoti >=, nes -2^63 yra lygiai Low(Int64). IsNan ir IsInfinite tikrinimas pirmiausia, trumpojo jungimo įvertinimu, laiko NaN ir begalybes atokiau nuo Frac ir lyginimų, kurie gali kelti EInvalidOp, kai šeimininkas juos atidengęs

Kiek skaitmenų float-į-tekstą iš tikrųjų duoda Win64?

Mažiau, nei prašote, dviejuose kompiliatoriuose iš trijų. Free Pascal 3.3.1 FloatToStrF(Value, ffGeneral, 17, 0) Win64 sustoja ties 15 reikšmingųjų skaitmenų, tad 1/3 grįžta kaip 0.333333333333333, o dvi skirtingos Double reikšmės gali užrašytis į identišką tekstą. Str(Value:24, Text), po kurio Trim, duoda 17 reikšmingųjų skaitmenų moksline notacija – 3.3333333333333331E-001 tai pačiai reikšmei – ir visada rašo tašką kaip dešimtainį skirtuką, nesvarbu, kokia lokalė. Jeigu HotPDF ant FPC yra jūsų statymo matricos dalis, HotPDF Free Pascal ir Lazarus Win64 palaikymo pastabos dengia likusius platformų skirtumus

Delphi priima 17 skaitmenų prašymą, bet abu Delphi tikslai vis dar nesutaria dėl išvesties: FloatToStrF(0.1, ffGeneral, 17, 0) duoda 0.10000000000000001 Win32 ir 0.1 Win64. Win64 RTL gali įnešti paskutinio skaitmens apvalinimo klaidą ir formatuodamas, ir skaitęs, tad daugiau skaitmenų siaurina plyšį negarantuodamos, kad kiekvienas Double bitų raštas išgyvens teksto kelionę pirmyn ir atgal. HotPDF dokumentacija tokio pažado neduoda, ir jūsų neturėtų, nebent gabenate savą teisingai apvalinantį formatuotoją ir skaitiklį. Perduokite TFormatSettings.Invariant arba patys pakeiskite skirtuką senesnėse Delphi versijose, kad vokiečių ar prancūzų lokalė neįrašytų kablelio į JSON

Kodėl Assert.AreEqual sustabdo kompiliavimą Win64?

Assert.AreEqual(3, Length(Arr)) ant dinaminio masyvo susikompiliuoja Win32 ir krinta Win64 su E2532 – „Couldn't infer generic type argument from different argument types“ – nes dinaminio masyvo Length Win64 grąžina NativeInt. Su Integer literalu vienoje pusėje ir 64 bitų NativeInt kitoje DUnitX generinis Assert.AreEqual<T> neapsisprendžia dėl vienintelės T, ir statymas sustoja

TList.Count sukelia tą pačią klaidą nuo Delphi 12, kur savybė tapo NativeInt; Delphi 11 vis dar deklaruoja ją kaip Integer. string Length abiejose platformose grąžina Integer ir nepaveikta – todėl klaida matoma kai kuriuose testų unituose, o kituose ne. Rašykite tipo argumentą aiškiai – Assert.AreEqual<NativeInt>(3, Length(Arr)) – ir statykite testų projektą dcc64 prieš commitinant. Rinkinys, statomas tik Win32, apie savo sulaužytą Win64 statymą nepraneš, kol kiti nebandys

Win64 pernešimo atmintinė Delphi skaitiniam kodui

  • Ieškokite Power( ir IntPower( kvietimų su sveikaisiais argumentais; perduokite Double tipo reikšmes arba statykite apribotas dešimties laipsnis patys
  • Paleiskite skaitinius testus bent kartą su exOverflow ir exInvalidOp, nuimtais per SetExceptionMask, abiejose Win32 ir Win64
  • Rašykite Int64 viršutinę ribą kaip < 9223372036854775808.0, niekada <= High(Int64), ir atmeskite NaN bei begalybes prieš bet kokį lyginimą
  • Neverskite išanalizuoto skaičiaus į Int64 vien todėl, kad Frac lygus 0; JSON skaičiai gali būti gerokai didesni
  • Perrašykite while ciklus, pakartotinai skaitančius Count, kol trinami elementai, į fiksuotų ribų for ... downto ciklus
  • FPC Win64 naudokite Str(Value:24, Text), kai reikia daugiau nei 15 reikšmingųjų skaitmenų
  • Naudokite Assert.AreEqual<NativeInt> Length ir Count assertams, ir statykite testus dcc64 prieš commitinant
  • Po bet kokio analizatoriaus ar atvaizduoklio pakeitimo praveskite pilną regresijos rinkinį Win32 ir Win64, o ne vieną iš jų

Bibliotekos pusės taisymai, aprašyti čia, visi HotPDF nuo v2.770.169, tad SVG importas, XPS konversija, permatomumo atvaizdavimas ir JSON užduočių apdorojimas dabar Win64 elgiasi taip pat kaip Win32. Jeigu PDF failus generuojate arba apdorojate iš Delphi arba C++Builder abiems platformoms, HotPDF Delphi PDF komponento puslapyje yra parsisiuntimai ir pilnas funkcijų sąrašas