Műszaki cikk

Win64-es Delphi hibák a HotPDF megerősítése közben

A Win64-es Delphi kód ott bukhat el, ahol ugyanaz a forrás Win32-en tisztán fut, és a HotPDF Delphi PDF component öt ilyen esetbe botlott egy friss megerősítő menetben: a Power(10, N) Single overloadjába kötés, egy while ciklus, ami elavult TList.Count-ot olvas, egy High(Int64) határ, ami 2^63-ra felfelé kerekít, 15 számjegyű float szöveg FPC-n és teszt assertek, amik leállítják a kompilálást

Egyik sem mutatkozik meg, ha csak Win32-t buildelsz és tesztelsz, és pontosan így csúsztak be. Az alábbi esetek a HotPDF SVG és XPS importereiből, az oldalrendereréből és a JSON job olvasójából jönnek, és az idézett numerikus eredmények kis, Win32-re és Win64-re épített próbaprogramokkal lettek reprodukálva. Ha épp egy Delphi kódbázist viszel 64 bitesre, mindegyik megér egy grep-et

Miért csak Win64-en csordul túl a Power(10, 100)?

Win64-en a System.Math.Power(10, N) egész argumentumokkal a Single overloadhoz kötődik, így az eredmény single pontossággal számolódik és adódik vissza, és minden, nagyjából 3.4E38 fölötti túlcsordul. Win32-n ugyanez a hívás az Extended overloadhoz kötődik, és az x87 FPU-n fut 80 bites pontossággal, így a Power(10, 100) egyszerűen 1E100

A System.Math a Power-t Extended-re, Double-ra és Single-re deklarálja, plusz egy illeszkedő IntPower családot, amit a Power hív, ha a kitevő egész szám. Win64-en az Extended csak a Double aliasa (SizeOf(Extended) = 8), és két egész argumentumra a kompilátor a Single verziót választja. Az árulkodó jel a pontosság, nem csak a túlcsordulás: Win64-en a Power(10, 20) 1.0000000200408773E20-t ad vissza, ami pontosan Single(1E20). Egy Double eredmény 1E20-ként íródna. Ugyanezt a kötést láttuk minden Win64 kompilátorral, amit kipróbáltunk, Delphi 10.3-tól a 37.0-s kompilátorverzióig

Aztán az történik, amit a lebegőpontos kivételmaszk enged. A Delphi 12 és újabb alapból minden lebegőpontos kivételt maszkol, így a túlcsordulás csendes: a Power(10, 100) +Inf-et ad, a Power(10, -100) 0-t. A Delphi 11 és korábbi maszkolatlanul hagyja az exOverflow-ot, és ugyanez a hívás EOverflow-t dob. Azok az alkalmazások, amik maguk állítják a maszkot, és az ilyen hostokba töltött DLL-ek, azt a viselkedést kapják, amit a host választott, ezért egy library egyik kimenetre sem számíthat

HotPDF Win64-es numerikus buktató, ahol a System.Math Power egész argumentumokkal a Single overloadhoz kötődik, így a 10 a 20.-on 1.0000000200408773E20-t ad az 1E20 helyett, a 10 a 100.-on pedig plusz végtelent, ha a kivételek maszkolva vannak, vagy EOverflow-t, ha nincsenek
a pontosságvesztés az árulkodó jel: ha egy tíz hatványa Single zajjal jön vissza, a rossz overload nyert — építsd magad a skálát
uses
  System.SysUtils, System.Math;

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

  // Reprodukálja, amit a Delphi 11 vagy egy szigorú FP beállítású host csinál
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

Az exOverflow és exInvalidOp maszkolásának feloldása egy teszt idejére a legolcsóbb módja annak, hogy lásd, mit lát egy öregebb kompilátor vagy egy szigorú host. Modern kompilátornál alapbeállításokkal a hiba nem crashel, végteleneket és nullákat gyárt, és azokat sokkal nehezebb észrevenni egy tesztnaplóban. Állítsd vissza az előző maszkot finally-ben: a maszk threadenkénti állapot, és a tesztfutás többi része örökli, amit magad után hagysz

Hogyan került az overload a HotPDF SVG és XPS importjába

A HotPDF SVG és XPS útolvasói egyetlen számszkenert osztanak meg, és az a szkener Power(10, Exponent)-tal skálázta a mantisszát, amint kiolvasott egy kitevőt. Bármely SVG, ami a THotPDF.ImportSVGFormXObject-ba megy (a SVG PDF-be importálása újrahasznosítható form XObjectként mögötti belépőpont), és bármely útgeometria, ami a XPS és OpenXPS PDF-be konvertálása közben kezelődik, tehát olyan koordinátát is beleenethet ebbe a hívásba, mint az 1e100 vagy az 5e99

A v2.770.91 már 100-ra tette fel a kitevőt, és elutasította azokat az értékeket, amik átmennének az 1E300-on, ami elégnek nézett ki: az 1E100 messze van a Double nagyjából 1.8E308-as limitjétől. Win64-en mégis túlcsordult, mert a számolás egyáltalán nem Double-ben történt. v2.770.155 óta a szkener maga építi a tíz hatványát, és az olyan számok, mint az 1e-100, vagy egy hosszú mantissza nagy negatív kitevővel, a valódi értékükön olvasódnak, 0-ba zsugorodás helyett

Biztonságos tíz hatványa korlátolt kitevőkre

Ha a kitevő korlátos, a legbiztonságosabb tíz hatványa az, amit magad építesz Double szorzásokkal. Legfeljebb 100 szorzás ciklusa semmit sem kerül a körülötte lévő szöveg szkenneléséhez képest, sosem produkál a végső skálánál nagyobb köztes értéket, és Win32-n, Win64-en és Free Pascalban azonosan viselkedik

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;
  // Elutasítja azokat az eredményeket, amik kiszélesednének a Double tartományból
  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;       // sosem haladja meg az 1E100-at
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // osztás: az 1E-100-nak nincs egzakt Double reprezentációja
  Result := True;
end;

Három részlet hordozza a súlyt. A tartományteszt két összehasonlítást használ, nem Abs(Exponent) <= 100-at, mert az Abs(Low(Integer)) továbbra is negatív, és simán átsiklana. A negatív kitevők osztanak a skálával, ahelyett hogy egy előre kiszámolt 1E-100-mal szoroznának, aminek nincs egzakt Double reprezentációja, és még egy kerekítési lépést adna. És a Log10 előteszt még a szorzás előtt elutasítja a Double tartományán kívüli eredményeket, mielőtt a túlcsordulásnak módja lenne

Légy tisztában azzal, mit ad fel a ciklus. A tíz hatványai 1E22-ig egzaktok Double-ben; azon túl minden szorzás kerekít, és 100 után a skála néhány egységre az utolsó helyen ül a helyesen kerekített 1E100-tól. Rajzolási koordinátákhoz ez láthatatlan. Egy általános célú szöveg-double konverzióhoz, aminek minden értéket bájtra bájtra vissza kell adnia, nem elég jó, és helyesen kerekítő konverziós algoritmus kell helyette

Amikor a dcc64 elavult TList.Count-ot olvas egy while-ban

Láttuk, hogy a Win64 kompilátor (dcc64, kompilátorverzió 37.0) kódot generált egy while List.Count > Start do ciklusra, ami a lista végéről törölt, és stack-temporálissal hasonlított a Count újraolvasása helyett. A megoldás egy for ... downto ciklus volt, aminek a határai definíció szerint pontosan egyszer értékelődnek ki

A ciklus a v2.769.3-ban érkezett, ami megtanította a renderer transzparencia-csoport kódjának, hogy a csoporton belül készített soft maszkokat életben tartsa egy kétmenetes renderen át, és azután szabadítsa fel őket. A takarítás egy finally blokkban ült egy egy- vagy kétmenetes for ciklus után, a csempénkénti cikluson belül. Alakjára zsugorítva az előtte-utána így néz ki:

// Az a forma, amit dcc64 (kompilátor verzió 37.0) félrefordítva láttunk
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;

// Csere: a határok egyszer értékelődnek ki, nincs elavuló temporális
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // a TList.Count Delphi 12 óta NativeInt
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

A generált Win64 kódban a ciklusfeltételben lévő Count és a testben olvasott Count egyetlen stack helyet osztott meg. A feltétel belépéskor ahhoz a helyhez hasonlított, mielőtt bármi írt volna bele, és a Delete után semmi nem frissítette. Amikor egy csoport nem készített saját soft maszkot, a test így is lefutott, és egy üres listától kérte a -1. elemet, így 64 bites buildekben minden olyan oldal, ami ilyen transzparencia-csoportot tartalmazott, EListError-ral bukott el. Ugyanennek a forrásnak a Win32 kódja helyes volt, és a v2.770.1 kicserélte a ciklust

HotPDF Win64-es kódgenerálási buktató a renderer takarításában: egy TList.Count-ot újraolvasó while ciklus egyetlen stack helyet osztott meg a feltétel és a test között, a dcc64 a Delete után soha nem frissítette, az üres transzparencia-csoportok a -1. elemet szabadították fel és EListError-t dobtek, a megoldás pedig egy for downto ciklus, aminek a határai egyszer értékelődnek ki
a gyakorlati tanulság olcsóbb, mint a gyökérok: rögzített határú downto ciklusok nem avulhatnak el, és a renderer munkája addig nincs kész, amíg dcc64 nem futtatta a suite-ot

Nem zsugorítottuk minimális reprodukcióra, és egy kis önálló ciklus, mint a DropMasksWhile, jól lehet helyesen fordul; a környező try/finally és a beágyazott ciklusok tűnnek számítódnak. Kezeld úgy, mint egy kompilátorverzión megfigyelt kódgenerálást, nem mint minden Win64 kompilátor ismert hibáját. A gyakorlati tanulság olcsóbb, mint a gyökérok: olyan ciklust, aminek a feltétele újraolvassa egy kollekció countját, miközben a test zsugorítja azt a kollekciót, érdemes rögzített határú for ... downto-ra átírni, és a renderer változtatások teljes Win64 tesztfutást igényelnek, nem csak Win32-t

Egy crash megtalálása, amit csak az optimalizált Win64 build mutat

A hiba csak az optimalizált Win64 buildben reprodukálódott, így a hely az IDE-n kívüli eszközökből jött. Egy kis próbaprogram vektorált kivételkezelőt regisztrált AddVectoredExceptionHandler-ral, az első kivételnél RtlCaptureStackBackTrace-szel elkapta a stacket, és a visszatérési címeket függvénynevekké fordította a részletes map fájl segítségével, amit a linker -GD-vel ír. Annak a függvénynek a szétszerelése megmutatta, hogy az összehasonlítás egy stack helyet olvas, [rbp+0x298], amit csak a ciklus testén belül írtak soha. Ez az a bizonyítékszint, amit akarsz, mielőtt a kompilátort vádolod, és kevesebb időbe telt, mint egy release builden végiglépkedni

Miért nem biztonságos felső határ a Double-nak a High(Int64)?

Egy Double nem tudja reprezentálni a High(Int64)-et: a 9223372036854775807 Double-ra váltása pontosan 2^63-ra kerekít fel, egyvel a legnagyobb Int64 fölött. Win64-en az a konverzió magában az összehasonlításban történik, így a D <= High(Int64) True a D = 2^63-ra, és az azt követő Round vagy Trunc túlcsordul

A Win32 ugyanazért rejti ezt, amiért a Power problémát rejti. Az összehasonlítás 80 bites Extended pontossággal fut 64 bites mantisszával, ahol a High(Int64) egzakt, és a 2^63 helyesen nagyobbnak hasonlít. A Win64-nek nincs szélesebb típusa, amire visszaeshet. A tartományon kívüli konverzió sem szép: Win64 tesztjeinkben a Round(2^63) Low(Int64)-ot adott vissza, csendes előjelcsere, függetlenül attól, hogy az exInvalidOp maszkolva volt-e. A Win32 maszkolva ugyanazt az értéket adja, maszkolatlanul EInvalidOp-ot dob

HotPDF Int64 határ buktató: egy Double nem tudja reprezentálni a High(Int64)-et, így egy Win64 összehasonlítás a limitet 2^63-ra kerekíti fel, a 2^63-mal egyenlő D átment a teszten és a Round csendben Low(Int64)-et ad, míg a Win32 80 bites Extendedben hasonlít, ahol a határ egzakt, és ugyanez az összehasonlítás False
egy konverzió az egész hiba: a határ pont arra az értékre kerekít, amit kizársz, ezért írd a plafont literálként szigorú kisebbseggel
KifejezésWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), kivételek maszkolva (Delphi 12+ alapérték)1E100+Inf
Power(10, 100), exOverflow maszkolatlan1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp maszkolatlanEInvalidOpLow(Int64)

A HotPDF a dokumentum job értékek mögötti JSON olvasójában találkozott ezzel. A JSON nem tesz tartománykorlátot a számokra, és a régi szerializátor minden Frac(Value) = 0 értéket Round-dal egésszé alakított, így egy teljesen legális 1e19 vagy rossz egésszé vált, vagy kivétellé, a maszk szerint. v2.770.169 óta egy egész szám csak akkor íródik egészként, ha belefér az Int64-be, minden más megtartja a lebegőpontos szövegét, és az egész getterek a hívó alapértékét adják vissza tartományon kívüli értékekre, becsomagolt helyett

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, egzakt Double-ben és Extendedben

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
  // A hívók előbb utasítják el a NaN-t és a végteleneket: a JSONnek nincs írásmódja rájuk
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // az FPC Win64 ffGeneral 15 számjegynél megáll
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

A felső határ a 9223372036854775808.0 literál szigorú <-tal. Az a konstans 2^63, egzakt Double-ben és Extended-ben egyaránt, így az összehasonlítás minden platformon ugyanazt jelenti. Az alsó határ használhat >=-t, mert a -2^63 pontosan Low(Int64). Az IsNan és IsInfinite először való tesztelése, rövidzáras kiértékeléssel, távol tartja a NaN-t és a végteleneket a Frac-tól és az összehasonlításoktól, amik EInvalidOp-ot dobhatnak, ha a host maszkolatlanul hagyta

Mennyi számjegyet ad valójában a float-szöveg konverzió Win64-en?

Kevesebbet, mint amennyit kérsz, három kompilátorból kettőn. A Free Pascal 3.3.1 FloatToStrF(Value, ffGeneral, 17, 0) hívása Win64-en 15 jelentős számjegynél megáll, így az 1/3 0.333333333333333-ként jön vissza, és két különböző Double érték azonos szöveggé sorosítóghat. A Str(Value:24, Text) utáni Trim 17 jelentős számjegyet ad tudományos jelölésben, ugyanarra az értékre 3.3333333333333331E-001-et, és mindig pontot ír tizedeselválasztóként, lokáltól függetlenül. Ha a FPC-s HotPDF része a build mátrixodnak, a HotPDF Free Pascal és Lazarus Win64 támogatási jegyzetei lefedik a platformkülönbségek többi részét

A Delphi elfogadja a 17 számjegyű kérést, de a két Delphi cél a kimeneten mégis eltér: a FloatToStrF(0.1, ffGeneral, 17, 0) Win32-en 0.10000000000000001-t, Win64-en 0.1-et ad. A Win64 RTL az utolsó számjegy kerekítési hibáját is becsúsztathatja formázáskor és parzoláskor egyaránt, így több számjegy szűkíti a rést anélkül, hogy garantálná, minden Double bátminta túléli a szöveges oda-vissza utat. A HotPDF dokumentációja nem ígér ilyet, és a tiéd sem ígérjen, hacsak nem szállítasz saját, helyesen kerekítő formázót és parzolót. Add át a TFormatSettings.Invariant-ot, vagy cseréld meg az elválasztót magad öregebb Delphi verziókon, hogy egy német vagy francia lokál ne írjon vesszőt a JSON-be

Miért áll le a kompilálás az Assert.AreEqual-től Win64-en?

A Assert.AreEqual(3, Length(Arr)) dinamikus tömbön Win32-re fordul, Win64-en E2532-vel elbukik, „Nem sikerült a generikus típusargumentumot következtetni különböző argumentumtípusokból", mert egy dinamikus tömb Length-e Win64-en NativeInt. Egy oldalon Integer literállal, a másikon 64 bites NativeInt-tel a DUnitX generikus Assert.AreEqual<T>-ja nem tud egyetlen T-n megállapodni, és a build megáll

A TList.Count ugyanezt a hibát váltja ki Delphi 12 óta, amikor a property NativeInt lett; a Delphi 11 még Integer-ként deklarálja. Egy string Length-e mindkét platformon Integer-t ad, és nem érintett, ezért a hiba néhány teszt unitban felbukkan, másokban nem. Írd ki expliciten a típusargumentumot, Assert.AreEqual<NativeInt>(3, Length(Arr)), és dcc64-gyel fordítsd a teszt projektet, mielőtt commitolsz. Egy suite, ami csak Win32-re buildel soha, nem árulja el, hogy a Win64 buildje törött, míg meg nem próbálja valaki más

Win64 portolási ellenőrzőlista Delphi numerikus kódhoz

  • Keress Power( és IntPower( hívásokat egész argumentumokkal; adj át Double típusú értékeket, vagy építsd magad a korlátolt tíz hatványait
  • Futtasd a numerikus teszteket legalább egyszer exOverflow és exInvalidOp eltávolításával SetExceptionMask-on át, Win32-n és Win64-en egyaránt
  • Írd az Int64 felső határt < 9223372036854775808.0-ként, soha nem <= High(Int64)-ként, és utasítsd el a NaN-t meg a végteleneket bármely összehasonlítás előtt
  • Ne váld át egy parzolt számot Int64-be csak azért, mert a Frac 0; a JSON számok jóval nagyobbak lehetnek
  • Írd át azokat a while ciklusokat, amik elemek törlése közben újraolvasják a Count-ot, rögzített határú for ... downto ciklusokra
  • FPC Win64-en használd a Str(Value:24, Text)-et, ha 15-nél több jelentős számjegyre van szükséged
  • Használj Assert.AreEqual<NativeInt>-ot Length és Count assertekhez, és dcc64-gyel fordítsd a teszteket, mielőtt commitolsz
  • Bármely parser- vagy renderer-változtatás után futtasd a teljes regressziós suite-ot Win32-n és Win64-en, nem csak az egyiken

A library oldali javítások, amiket itt leírtunk, mind a HotPDF-ben vannak v2.770.169 óta, így az SVG import, az XPS konverzió, a transzparencia renderelés és a JSON job kezelés mostantól Win64-en ugyanúgy viselkedik, mint Win32-n. Ha Delphiből vagy C++Builderből generálsz vagy dolgozol fel PDF fájlokat mindkét platformra, a HotPDF Delphi PDF component oldalon vannak a letöltések meg a teljes funkciólista