Technisch artikel

Win64-only Delphi-bugs gevonden bij het verharden van HotPDF

Win64 Delphi-code kan falen waar dezelfde bron netjes draait op Win32, en de HotPDF Delphi PDF-component trof vijf zulke gevallen aan tijdens een recente verhardingsronde: Power(10, N) die aan de Single-overload bindt, een while-lus die een verouderde TList.Count leest, een High(Int64)-grens die ophoogt naar 2^63, 15-cijferige floattekst op FPC, en test-asserts die stoppen met compileren

Geen daarvan verschijnt als u alleen Win32 bouwt en test, en precies zo zijn ze erin geslopen. De gevallen hieronder komen uit de SVG- en XPS-importeurs van HotPDF, zijn paginarender en zijn JSON-joblezer, en de geciteerde numerieke resultaten zijn gereproduceerd met kleine probe-programma's gebouwd voor Win32 en Win64. Verhuist u een Delphi-codebase naar 64-bit, dan is elk geval een grep waard

Waarom loopt Power(10, 100) alleen op Win64 over?

Op Win64 resolvant System.Math.Power(10, N) met integer-argumenten naar de Single-overload, dus het resultaat wordt in single precision berekend en teruggegeven, en alles boven ruwweg 3.4E38 loopt over. Op Win32 bindt dezelfde aanroep aan de Extended-overload en draait op de x87 FPU met 80-bit precisie, dus Power(10, 100) is gewoon 1E100

System.Math declareert Power voor Extended, Double en Single, plus een matchende IntPower-familie die Power aanroept zodra de exponent een geheel getal is. Op Win64 is Extended slechts een alias voor Double (SizeOf(Extended) = 8), en bij twee integer-argumenten kiest de compiler de Single-versie. Het verraad zit in de precisie, niet alleen in de overflow: op Win64 geeft Power(10, 20) 1.0000000200408773E20 terug, wat exact Single(1E20) is. Een Double-resultaat zou als 1E20 printen. We zagen dezelfde binding bij elke Win64-compiler die we probeerden, van Delphi 10.3 tot en met compilerversie 37.0

Wat er daarna gebeurt, hangt af van de floating-point exception mask. Delphi 12 en later maskeren alle floating-point exceptions by default, dus de overflow is stil: Power(10, 100) geeft +Inf terug en Power(10, -100) geeft 0. Delphi 11 en eerder laten exOverflow ongemaskeerd, en dezelfde aanroep geeft een EOverflow. Applicaties die het masker zelf zetten, en DLL's die in zulke hosts worden geladen, krijgen welk gedrag de host ook koos, en daarom kan een library geen van beide uitkomsten aannemen

HotPDF Win64-numerieke valkuil waarbij System.Math Power met integer-argumenten aan de Single-overload bindt, zodat Power van 10 tot de 20ste 1.0000000200408773E20 teruggeeft in plaats van 1E20 en Power van 10 tot de 100ste plus oneindig oplevert zodra exceptions gemaskeerd zijn, of EOverflow als dat niet zo is
het precisieverlies is de verteller: komt een macht van tien terug met Single-ruis eraan, dan heeft de verkeerde overload gewonnen — bouw de schaal zelf
uses
  System.SysUtils, System.Math;

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

  // Reproduceer wat Delphi 11, of een host met strikte FP-instellingen, doet
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

exOverflow en exInvalidOp voor de duur van een test uit het masker halen is de goedkoopste manier om te zien wat een oudere compiler of een strenge host ziet. Op een moderne compiler met defaultinstellingen crasht de bug niet, maar produceert hij oneindigheden en nullen, en die zijn veel moeilijker te spotten in een testlog. Zet het vorige masker in finally terug: het masker is per-thread status, en de rest van de testrun erft wat u achterlaat

Hoe de overload de SVG- en XPS-import van HotPDF bereikte

De SVG- en XPS-padlezers van HotPDF delen één cijferscanner, en die scanner schaalde de mantisse met Power(10, Exponent) zodra hij een exponent had gelezen. Elke SVG die aan THotPDF.ImportSVGFormXObject wordt doorgegeven (de ingang achter SVG importeren in PDF als herbruikbare form XObjects), en elke padgeometrie die wordt verwerkt tijdens XPS- en OpenXPS-naar-PDF-conversie, kon dus een coördinaat zoals 1e100 of 5e99 in die aanroep voeren

v2.770.91 had de exponent al geplafonneerd op 100 en weigerde waarden die voorbij 1E300 zouden gaan, wat als genoeg oogde: 1E100 is mijlenver van de Double-limiet van zo'n 1.8E308. Op Win64 liep het alsnog over, want de berekening gebeurde helemaal niet in Double. Sinds v2.770.155 bouwt de scanner de macht van tien zelf, en getallen zoals 1e-100, of een lange mantisse met een grote negatieve exponent, worden gelezen als hun echte waarde in plaats van in te klappen naar 0

Een veilige macht van tien voor begrensde exponenten

Zodra de exponent begrensd is, is de veiligste macht van tien er een die u zelf bouwt met Double-vermenigvuldiging. Een lus van hooguit 100 vermenigvuldigingen kost niets naast het scannen van de tekst eromheen, produceert nooit een tussentijds getal groter dan de eindeschaal, en gedraagt zich identiek op Win32, Win64 en 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;
  // Weiger resultaten die het Double-bereik zouden verlaten
  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;       // overstijgt nooit 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // delen: 1E-100 heeft geen exacte Double
  Result := True;
end;

Drie details dragen het gewicht. De bereikcontrole gebruikt twee vergelijkingen in plaats van Abs(Exponent) <= 100, want Abs(Low(Integer)) is nog steeds negatief en zou er moeiteloos doorheen glippen. Negatieve exponenten delen door de schaal in plaats van te vermenigvuldigen met een voorberekende 1E-100, die geen exacte Double heeft en nog een extra afrondingsstap zou toevoegen. En de Log10-voorcontrole weigert resultaten buiten het Double-bereik voordat de vermenigvuldiging de kans krijgt te overlopen

Wees helder over wat de lus prijsgeeft. Machten van tien tot 1E22 zijn exact in Double; daarna rondt elke vermenigvuldiging af, en na 100 ervan zit de schaal enkele units van de laatste plaats verwijderd van de correct afgeronde 1E100. Voor tekencoördinaten is dat onzichtbaar. Voor een algemene tekst-naar-double-conversie die elke waarde bit voor bit moet reproduceren, is het niet goed genoeg, en dan heeft u een correct afgerond conversiealgoritme nodig

Als dcc64 een verouderde TList.Count leest in een while-lus

We zagen de Win64-compiler (dcc64, compilerversie 37.0) code genereren voor een while List.Count > Start do-lus die vanaf het einde van de lijst verwijderde en vergeleek tegen een stacktijdelijke in plaats van Count opnieuw te lezen. De herschrijving die het oploste een for ... downto-lus, waarvan de grenzen per definitie exact één keer worden geëvalueerd

De lus kwam binnen in v2.769.3, dat de transparency-group-code van de renderer leerde soft masks die binnen een groep waren aangemaakt in leven te houden over een render in één of twee passen en ze daarna vrij te geven. De opruiming zat in een finally-blok na een for-lus van één of twee passen, binnen de per-tile-lus. Teruggebracht tot zijn vorm zien voor en na er zo uit:

// De vorm die we verkeerd gecompileerd zagen door dcc64 (compilerversie 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;

// Vervanging: de grenzen worden één keer geëvalueerd, geen tijdelijke die kan verouderen
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.Count is NativeInt sinds Delphi 12
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

In de gegenereerde Win64-code deelden de Count in de loopconditie en de Count die binnen de body werd gelezen één stackslot. De conditie vergeleek bij binnenkomst tegen dat slot, voordat er iets in was geschreven, en niets verversde hem na Delete. Had een groep geen eigen soft masks aangemaakt, dan draaide de body toch en vroeg een lege lijst om item -1, dus in 64-bit builds faalde elke pagina met zo'n transparency group met een EListError. De Win32-code voor dezelfde bron was correct, en v2.770.1 verving de lus

HotPDF Win64-codegen-valkuil in de opruiming van de renderer: een while-lus die TList.Count opnieuw leest deelde één stackslot tussen conditie en body, dcc64 verversde hem nooit na Delete, lege transparency groups gaven item -1 vrij en gaven een EListError, en de fix is een for downto-lus waarvan de grenzen één keer worden geëvalueerd
de praktische les kost minder dan de onderliggende oorzaak: downto-lussen met vaste grenzen kunnen niet verouderen, en rendererwerk is niet af totdat dcc64 de suite heeft gedraaid

We hebben dit niet teruggebracht tot een minimale reproductie, en een kleine losse lus zoals DropMasksWhile compileert mogelijk prima; de omliggende try/finally en geneste lussen lijken ertoe te doen. Beschouw het als codegeneratie die we op één compilerversie zagen, niet als een bekend defect van elke Win64-compiler. De praktische les is goedkoper dan de onderliggende oorzaak: een lus waarvan de conditie het aantal van een verzameling opnieuw leest terwijl de body die verzameling krimpt, verdient het om te worden herschreven als een for ... downto met vaste grenzen, en rendererwijzigingen hebben een volledige Win64-testrun nodig, niet alleen Win32

Een crash lokaliseren die alleen een geoptimaliseerde Win64-build toont

De falering reproduceerde alleen in de geoptimaliseerde Win64-build, dus de lokatie kwam van tools buiten de IDE. Een klein probe-programma registreerde een vectored exception handler met AddVectoredExceptionHandler, ving de stack bij de eerste exception met RtlCaptureStackBackTrace, en vertaalde de retouradressen in functienamen met behulp van het gedetailleerde map-bestand dat de linker met -GD wegschrijft. Het disassembleren van die functie toonde toen een vergelijking die een stackslot las, [rbp+0x298], dat alleen ooit binnen de loopbody werd geschreven. Dat is het bewijsniveau dat u wilt voordat u een compiler de schuld geeft, en het kostte minder tijd dan door een release-build stappen

Waarom is High(Int64) geen veilige bovengrens voor een Double?

Een Double kan High(Int64) niet representeren: 9223372036854775807 naar Double converteren rondt ophoogend af op exact 2^63, één voorbij het grootste Int64. Op Win64 gebeurt die conversie binnen de vergelijking zelf, dus D <= High(Int64) is True voor D = 2^63, en de Round of Trunc die volgt loopt over

Win32 verbergt dit om dezelfde reden als waarom het het Power-probleem verborg. De vergelijking draait in 80-bit Extended-precisie met een 64-bit mantisse, waar High(Int64) exact is en 2^63 correct als groter vergelijkt. Win64 heeft geen breder type om op terug te vallen. De buiten-bereik-conversie is ook niet fraai: in onze Win64-tests gaf Round(2^63) Low(Int64) terug, een stille tekenomkering, of exInvalidOp nu gemaskeerd was of niet. Win32 geeft dezelfde waarde terug bij maskering en geeft een EInvalidOp zonder maskering

HotPDF Int64-grensvalkuil: een Double kan High(Int64) niet representeren, dus converteert een Win64-vergelijking de limiet ophoogend naar 2^63, D gelijk aan 2^63 haalt de controle en Round geeft stilletjes Low(Int64) terug, terwijl Win32 in 80-bit Extended vergelijkt waar de grens exact is en dezelfde vergelijking False is
één conversie is de hele bug: de grens rondt af op precies de waarde die u uitsluit, dus schrijf het plafond als literal met een strikte kleiner-dan
ExpressieWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), exceptions gemaskeerd (Delphi 12+ default)1E100+Inf
Power(10, 100), exOverflow ongemaskeerd1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp ongemaskeerdEInvalidOpLow(Int64)

HotPDF trof dit aan in de JSON-lezer achter zijn documentjobwaarden. JSON stelt geen bereiklimiet aan getallen, en de oude serializer maakte van elke waarde met Frac(Value) = 0 een integer met Round, dus een prima legale 1e19 werd ofwel een verkeerde integer ofwel een exception, afhankelijk van het masker. Sinds v2.770.169 wordt een geheel getal alleen als integer weggeschreven zodra hij in Int64 past, houdt al het restant zijn floating-point tekst, en geven de integer-getters de default van de aanroeper terug bij buiten-bereik-waarden in plaats van een omgeslagen waarde

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, exact in Double en 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
  // Aanroepers wijzen NaN en oneindigheden eerst af: JSON heeft er geen spelling voor
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral stopt op 15 cijfers
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

De bovengrens is de literal 9223372036854775808.0 met een strikte <. Die constante is 2^63, exact in zowel Double als Extended, dus de vergelijking betekent op elk platform hetzelfde. De ondergrens kan >= gebruiken omdat -2^63 exact Low(Int64) is. Eerst IsNan en IsInfinite testen, met kortsluitingsevaluatie, houdt NaN en oneindigheden uit de buurt van Frac en de vergelijkingen, die een EInvalidOp kunnen geven zodra de host haar heeft ontmaskerd

Hoeveel cijfers geeft float-naar-tekst werkelijk op Win64?

Minder dan u vraagt, bij twee compilers van de drie. De FloatToStrF(Value, ffGeneral, 17, 0) van Free Pascal 3.3.1 op Win64 stopt op 15 significante cijfers, dus 1/3 komt terug als 0.333333333333333 en twee verschillende Double-waarden kunnen naar identieke tekst serialiseren. Str(Value:24, Text) gevolgd door Trim levert 17 significante cijfers in wetenschappelijke notatie op, 3.3333333333333331E-001 voor dezelfde waarde, en schrijft altijd een punt als decimaalscheiding ongeacht de locale. Maakt HotPDF op FPC deel uit van uw buildmatrix, dan behandelen de HotPDF Free Pascal en Lazarus Win64-ondersteuningsnotities de rest van de platformverschillen

Delphi accepteert het verzoek om 17 cijfers, maar de twee Delphi-doelen zijn het nog steeds oneens over de uitvoer: FloatToStrF(0.1, ffGeneral, 17, 0) geeft 0.10000000000000001 op Win32 en 0.1 op Win64. De Win64-RTL kan bovendien een laatste-cijfer-afrondingsfout introduceren, zowel bij formatteren als bij parsen, dus meer cijfers verkleinen de kloof zonder te garanderen dat elk Double-bitpatroon een tekstroundtrip overleeft. De documentatie van HotPDF doet die belofte niet, en die van u ook niet tenzij u een correct afgeronde formatter en parser van eigen makelij verscheept. Geef TFormatSettings.Invariant door, of vervang het scheidingsteken zelf op oudere Delphi-versies, zodat een Duitse of Franse locale geen komma in JSON schrijft

Waarom stopt Assert.AreEqual met compileren op Win64?

Assert.AreEqual(3, Length(Arr)) op een dynamische array compileert voor Win32 en faalt voor Win64 met E2532, "Couldn't infer generic type argument from different argument types", omdat Length van een dynamische array op Win64 een NativeInt teruggeeft. Met een Integer-literal aan de ene kant en een 64-bit NativeInt aan de andere, kan de generieke Assert.AreEqual<T> van DUnitX niet op één T uitkomen, en de build stopt

TList.Count triggert dezelfde fout sinds Delphi 12, waar de property NativeInt werd; Delphi 11 declareert haar nog als Integer. Length van een string geeft op beide platforms een Integer terug en is onaangetast, en daarom verschijnt de fout in sommige testunits en niet in andere. Schrijf het type-argument expliciet, Assert.AreEqual<NativeInt>(3, Length(Arr)), en compileer het testproject met dcc64 vóór het committen. Een suite die alleen ooit voor Win32 bouwt, vertelt u niet dat haar Win64-build kapot is totdat iemand anders het probeert

Win64-porteringchecklist voor numerieke Delphi-code

  • Zoek naar Power(- en IntPower(-aanroepen met integer-argumenten; geef Double-getypeerde waarden door of bouw begrensde machten van tien zelf
  • Draai numerieke tests ten minste één keer met exOverflow en exInvalidOp weggehaald via SetExceptionMask, op zowel Win32 als Win64
  • Schrijf de Int64-bovengrens als < 9223372036854775808.0, nooit <= High(Int64), en wijs NaN en oneindigheden af vóór elke vergelijking
  • Converteer een geparsed getal niet naar Int64 alleen omdat Frac 0 is; JSON-getallen kunnen veel groter zijn
  • Herschrijf while-lussen die Count opnieuw lezen tijdens het verwijderen van items naar for ... downto-lussen met vaste grenzen
  • Gebruik op FPC Win64 Str(Value:24, Text) zodra u meer dan 15 significante cijfers nodig heeft
  • Gebruik Assert.AreEqual<NativeInt> voor Length- en Count-asserts, en compileer tests met dcc64 vóór het committen
  • Draai na elke wijziging aan een parser of renderer de volledige regressiesuite op Win32 én Win64, niet maar één van de twee

De library-side fixes die hier worden beschreven zitten allemaal sinds v2.770.169 in HotPDF, dus SVG-import, XPS-conversie, transparency-rendering en JSON-jobafhandeling gedragen zich nu op Win64 hetzelfde als op Win32. Genereert of verwerkt u PDF-bestanden vanuit Delphi of C++Builder voor beide platforms, dan staan de downloads en de volledige functielijst op de pagina van de HotPDF Delphi PDF-component