Teknisk artikel

Win64-only Delphi-bugs fundet under hærdning af HotPDF

Win64 Delphi-kode kan fejle, dér hvor samme kilde kører rent på Win32, og HotPDF Delphi PDF-komponenten ramte fem sådanne tilfælde under en nylig hærdningsrunde: Power(10, N) bundet til Single-overloaden, en while-løkke, der læste en forældet TList.Count, en High(Int64)-grænse, der runder op til 2^63, 15-cifret float-tekst på FPC og test-asserts, der holder op med at kompilere

Ingen af dem viser sig, hvis du kun bygger og tester Win32, hvilket præcis er sådan, de snek sig ind. Tilfældene nedenfor kommer fra HotPDF's SVG- og XPS-importører, dens side-renderer og dens JSON job-læser, og de citerede numeriske resultater blev reproduceret med små probe-programmer bygget til Win32 og Win64. Er du ved at flytte en Delphi-kodebase til 64-bit, er hver enkelt værd en grep

Hvorfor overløber Power(10, 100) kun på Win64?

På Win64 opløses System.Math.Power(10, N) med integer-argumenter til Single-overloaden, så resultatet beregnes og returneres i single precision, og alt over cirka 3.4E38 overløber. På Win32 binder samme kald til Extended-overloaden og kører på x87 FPU'en med 80-bit præcision, så Power(10, 100) bare er 1E100

System.Math deklarerer Power for Extended, Double og Single plus en matchende IntPower-familie, som Power kalder, når eksponenten er et heltal. På Win64 er Extended kun et alias for Double (SizeOf(Extended) = 8), og for to integer-argumenter vælger kompileren Single-versionen. Afsløringen er præcisionen, ikke kun overflowet: på Win64 returnerer Power(10, 20) 1.0000000200408773E20, hvilket er præcis Single(1E20). Et Double-resultat ville printe som 1E20. Vi så samme binding hos hver Win64-kompiler, vi prøvede, fra Delphi 10.3 til compiler version 37.0

Hvad der sker bagefter afhænger af floating-point exception-masken. Delphi 12 og senere maskerer alle floating-point exceptions som default, så overflowet er stille: Power(10, 100) returnerer +Inf, og Power(10, -100) returnerer 0. Delphi 11 og tidligere lader exOverflow stå umaskeret, og samme kald rejser EOverflow. Applikationer, der selv sætter masken, og DLL'er loadet ind i sådanne værter, får den adfærd, værten valgte, hvilket er hvorfor et bibliotek ikke kan regne med nogen af udfaldene

HotPDF Win64 numerisk fælde, hvor System.Math Power med integer-argumenter binder til Single-overloaden, så 10 i 20. potens returnerer 1.0000000200408773E20 i stedet for 1E20, og 10 i 100. potens giver plus uendelig, når exceptions er maskeret, eller EOverflow, når de ikke er
præcisionstabet er afsløringen: kommer en titens potens tilbage med Single-støj på, vandt den forkerte overload — byg skalaen selv
uses
  System.SysUtils, System.Math;

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

  // Reproducér hvad Delphi 11, eller en host med strict FP-indstillinger, gør
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

At unmaskere exOverflow og exInvalidOp i Varigheden af en test er den billigste måde at se, hvad en ældre kompiler eller en strict host ser. På en moderne kompiler med default-indstillinger crasher buggen ikke, den producerer infinities og nuller, og dem er det meget sværere at få øje på i en testlog. Gendan den forrige maske i finally: masken er pr. tråd-tilstand, og resten af testkørslen arver, hvad end du efterlader

Sådan nåede overloaden frem til HotPDF's SVG- og XPS-import

HotPDF's SVG- og XPS-stilæsere deler én talscanner, og den scanner skalerede mantissen med Power(10, Exponent), så snart den havde læst en eksponent. Enhver SVG givet til THotPDF.ImportSVGFormXObject (indgangspunktet bag import af SVG til PDF som genbrugelige form XObjects), og enhver sti-geometri behandlet under XPS- og OpenXPS-til-PDF-konvertering, kunne derfor føde en koordinat som 1e100 eller 5e99 ind i det kald

v2.770.91 havde allerede kappet eksponenten ved 100 og afvist værdier, der ville passere 1E300, hvilket så ud som nok: 1E100 er ingen steder nær Double-grænsen på omkring 1.8E308. På Win64 overløb det alligevel, for beregningen skete slet ikke i Double. Siden v2.770.155 bygger scanneren titens potens selv, og tal som 1e-100, eller en lang mantisse med en stor negativ eksponent, læses som deres rigtige værdi i stedet for at kollapse til 0

En sikker titens potens for afgrænsede eksponenter

Når eksponenten er afgrænset, er den sikreste titens potens én, du bygger selv med Double-multiplikation. En løkke på højst 100 multiplikationer koster intet i forhold til at scanne teksten omkring den, den producerer aldrig en mellemværdi større end den endelige skala, og den opfører sig identisk på Win32, Win64 og 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;
  // Afvis resultater, der ville forlade Double-intervallet
  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;       // overstiger aldrig 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // divider: 1E-100 har ingen eksakt Double
  Result := True;
end;

Tre detaljer bærer vægten. Interval-tjekket bruger to sammenligninger frem for Abs(Exponent) <= 100, for Abs(Low(Integer)) stadig er negativ og ville sejle lige igennem. Negative eksponenter dividerer med skalaen i stedet for at multiplicere med en forudberegnet 1E-100, som ikke har nogen eksakt Double og ville tilføje endnu et afrundingstrin. Og Log10-fortjekket afviser resultater uden for Double-intervallet, før multiplikationen får en chance for at løbe over

Vær klar over, hvad løkken giver afkald på. Titens potenser op til 1E22 er eksakte i Double; derefter runder hver multiplikation, og efter 100 af dem sidder skalaen et par units in the last place fra den korrekt rundede 1E100. Til tegnekoordinater er det usynligt. Til en generisk tekst-til-double-konvertering, der skal reproducere hver værdi bit for bit, er det ikke godt nok, og så behøver du en korrekt rundet konverteringsalgoritme i stedet

Når dcc64 læser en forældet TList.Count i en while-løkke

Vi observerede Win64-kompileren (dcc64, compiler version 37.0) generere kode for en while List.Count > Start do-løkke, der slettede fra listens ende og sammenlignede mod en stak-midlertidig i stedet for at genlæse Count. Omskrivningen, der fik det væk, var en for ... downto-løkke, hvis grænser efter definition evalueres præcis én gang

Løkken kom i v2.769.3, som lærte rendererens transparency group-kode at holde soft masks oprettet inde i en group i live på tværs af en to-trins-render og frigive dem bagefter. Oprydningen sad i en finally-blok efter en én- eller to-trins for-løkke, inde i pr. tile-løkken. Reduceret til sin form ser før og efter sådan ud:

// Den form, vi så fejlkompileret af dcc64 (compiler version 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;

// Erstatning: grænserne evalueres én gang, ingen midlertidig at blive forældet
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.Count er NativeInt siden Delphi 12
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

I den genererede Win64-kode delte Count i løkkebetingelsen og Count-læsningen inde i kroppen én stak-slot. Betingelsen sammenlignede mod den slot ved indgang, før noget havde skrevet den, og intet genopfriskede den efter Delete. Havde en group oprettet ingen soft masks af egen, kørte kroppen alligevel og bad en tom liste om item -1, så i 64-bit builds fejlede hver side med en sådan transparency group med EListError. Win32-koden for samme kilde var korrekt, og v2.770.1 erstattede løkken

HotPDF Win64 codegen-fælde i renderer-oprydningen: en while-løkke, der genlæste TList.Count, delte én stak-slot mellem betingelsen og kroppen, dcc64 genopfriskede den aldrig efter Delete, tomme transparency groups frigjorde item -1 og rejste EListError, og fixet er en for downto-løkke, hvis grænser evalueres én gang
den praktiske lektie koster mindre end rodårsagen: fixed-bound downto-løkker kan ikke blive forældede, og renderer-arbejde er ikke færdigt, før dcc64 har kørt suitten

Vi har ikke reduceret dette til en minimal reproduction, og en lille selvstændig løkke som DropMasksWhile kompilerer sikkert fint; de omgivende try/finally og indlejrede løkker ser ud til at betyde noget. Behandl det som kodegenerering, vi observerede på én compiler-version, ikke som en kendt defekt hos hver Win64-kompiler. Den praktiske lektie er billigere end rodårsagen: en løkke, hvis betingelse genlæser en collections count, mens kroppen skrumpler den collection, er værd at omskrive til en fixed-bound for ... downto, og renderer-ændringer behøver en fuld Win64-testkørsel, ikke bare Win32

At lokalisere et crash, som kun en optimeret Win64-build viser

Fejlen reproducerede kun i den optimerede Win64-build, så lokaliseringen kom fra værktøjer uden for IDE'en. Et lille probe-program registrerede en vectored exception handler med AddVectoredExceptionHandler, fangede stakken ved den første exception med RtlCaptureStackBackTrace og oversatte returadresserne til funktionsnavne ved hjælp af den detaljerede map-fil, linkeren skriver med -GD. At disassemblere den funktion viste så sammenligningen læse en stak-slot, [rbp+0x298], der kun nogensinde blev skrevet inde i løkkekroppen. Det er det niveau af beviser, du vil have, inden du bebrejder en kompiler, og det tog mindre tid end at steppe gennem en release build

Hvorfor er High(Int64) ikke en sikker øvre grænse for en Double?

En Double kan ikke repræsentere High(Int64): at konvertere 9223372036854775807 til Double runder op til præcis 2^63, én forbi den største Int64. På Win64 sker konverteringen inde i selve sammenligningen, så D <= High(Int64) er True for D = 2^63, og den Round eller Trunc, der følger, overløber

Win32 skjuler det af samme grund, som den skjulte Power-problemet. Sammenligningen kører i 80-bit Extended-præcision med en 64-bit mantisse, hvor High(Int64) er eksakt, og 2^63 korrekt sammenlignes større. Win64 har ingen bredere type at falde tilbage på. Konverteringen uden for interval er heller ikke køn: i vores Win64-tests returnerede Round(2^63) Low(Int64), et stiltiende fortegnsskift, uanset om exInvalidOp var maskeret eller ej. Win32 returnerer samme værdi, når maskeret, og rejser EInvalidOp, når umaskeret

HotPDF Int64-grænse-fælde: en Double kan ikke repræsentere High(Int64), så en Win64-sammenligning konverterer grænsen op til 2^63, D lig 2^63 består tjekket, og Round returnerer stiltiende Low(Int64), mens Win32 sammenligner i 80-bit Extended, hvor grænsen er eksakt, og samme sammenligning er False
én konvertering er hele buggen: grænsen runder op på præcis den værdi, du udelukker, så skriv loftet som en literal med et strict less-than
UdtrykWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), exceptions maskeret (Delphi 12+ default)1E100+Inf
Power(10, 100), exOverflow umaskeret1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp umaskeretEInvalidOpLow(Int64)

HotPDF mødte det i JSON-læseren bag dens dokument job-værdier. JSON sætter ingen intervalgrænse på tal, og den gamle serializer omdannede enhver værdi med Frac(Value) = 0 til en integer med Round, så en fuldt lovlig 1e19 blev enten et forkert integer eller en exception, afhængigt af masken. Siden v2.770.169 skrives et heltal kun som integer, når det er plads i Int64, alt andet beholder sin floating-point-tekst, og integer-getterne returnerer kalderens default for værdier uden for interval i stedet for en wrapped en

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, eksakt i Double og 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
  // Kaldere afviser NaN og infinities først: JSON har ingen stavemåde for dem
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral stopper ved 15 cifre
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

Den øvre grænse er literalen 9223372036854775808.0 med en strict <. Den konstant er 2^63, eksakt i både Double og Extended, så sammenligningen betyder det samme på hver platform. Den nedre grænse kan bruge >=, for -2^63 er præcis Low(Int64). At teste IsNan og IsInfinite først, med short-circuit-evaluering, holder NaN og infinities væk fra Frac og sammenligningerne, som kan rejse EInvalidOp, når værten har unmaskeret den

Hvor mange cifre giver float-til-tekst egentlig på Win64?

Færre, end du beder om, på to kompilere ud af tre. Free Pascal 3.3.1's FloatToStrF(Value, ffGeneral, 17, 0) på Win64 stopper ved 15 signifikante cifre, så 1/3 kommer tilbage som 0.333333333333333, og to forskellige Double-værdier kan serialisere til identisk tekst. Str(Value:24, Text) efterfulgt af Trim producerer 17 signifikante cifre i videnskabelig notation, 3.3333333333333331E-001 for samme værdi, og skriver altid et punktum som decimalseparator uanset locale. Er HotPDF på FPC en del af din build-matrix, dækker HotPDF Free Pascal- og Lazarus Win64-supportnoterne resten af platformsforskellene

Delphi accepterer 17-cifre-anmodningen, men de to Delphi-targets er stadig uenige om output: FloatToStrF(0.1, ffGeneral, 17, 0) giver 0.10000000000000001 på Win32 og 0.1 på Win64. Win64-RTL'en kan også introducere en sidste-ciffer-afrundingsfejl både ved formatering og parsing, så flere cifre indsnævrer kløften uden at garantere, at hvert Double-bitmønster overlever en tekst-roundtrip. HotPDF's dokumentation lover ingenting sådant, og din burde det heller ikke, medmindre du skiber din egen korrekt rundede formatter og parser. Giv TFormatSettings.Invariant med, eller erstat separatoren selv på ældre Delphi-versioner, så et tysk eller fransk locale ikke skriver et komma ind i JSON

Hvorfor holder Assert.AreEqual op med at kompilere på Win64?

Assert.AreEqual(3, Length(Arr)) på et dynamisk array kompilerer til Win32 og fejler til Win64 med E2532, "Couldn't infer generic type argument from different argument types", for Length af et dynamisk array returnerer NativeInt på Win64. Med en Integer-literal på den ene side og en 64-bit NativeInt på den anden kan DUnitX' generiske Assert.AreEqual<T> ikke lande på ét enkelt T, og builden stopper

TList.Count udløser samme fejl siden Delphi 12, hvor propertyen blev NativeInt; Delphi 11 deklarerer den stadig som Integer. Length af en string returnerer Integer på begge platforme og er upåvirket, hvilket er hvorfor fejlen optræder i nogle test-units og ikke andre. Skriv type-argumentet eksplicit, Assert.AreEqual<NativeInt>(3, Length(Arr)), og kompilér testprojektet med dcc64, inden du committer. En suite, der kun nogensinde bygger til Win32, fortæller dig ikke, at dens Win64-build er i stykker, før nogen andre prøver den

Win64-porteringstjekliste til Delphi numerisk kode

  • Søg efter Power(- og IntPower(-kald med integer-argumenter; giv Double-typede værdier med, eller byg afgrænsede titens potenser selv
  • Kør numeriske tests mindst én gang med exOverflow og exInvalidOp fjernet gennem SetExceptionMask, på både Win32 og Win64
  • Skriv Int64-øvre grænse som < 9223372036854775808.0, aldrig <= High(Int64), og afvis NaN og infinities før nogen sammenligning
  • Konvertér ikke et parseret tal til Int64 bare fordi Frac er 0; JSON-tal kan være langt større
  • Omskriv while-løkker, der genlæser Count, mens der slettes items, til fixed-bound for ... downto-løkker
  • På FPC Win64, brug Str(Value:24, Text), når du behøver mere end 15 signifikante cifre
  • Brug Assert.AreEqual<NativeInt> til Length- og Count-asserts, og kompilér tests med dcc64, inden du committer
  • Efter enhver ændring af en parser eller renderer, kør den fulde regressionssuite på Win32 og Win64, ikke bare én af dem

Biblioteksside-fixene, der er beskrevet her, er alle i HotPDF siden v2.770.169, så SVG-import, XPS-konvertering, transparency-rendering og JSON job-håndtering opfører sig nu ens på Win64 som på Win32. Genererer eller behandler du PDF-filer fra Delphi eller C++Builder til begge platforme, har HotPDF Delphi PDF-komponenten-siden downloads og den fulde funktionsliste