Teknisk artikkel

Win64-bare Delphi-buger funnet under herding av HotPDF

Win64 Delphi-kode kan feile der samme kilde kjører rent på Win32, og HotPDF Delphi PDF-komponenten traff fem slike tilfeller under en nylig herdingsrunde: Power(10, N) bundet til Single-overloaden, en while-løkke som leser en foreldet TList.Count, en High(Int64)-grense som runder opp til 2^63, 15-sifret float-tekst på FPC, og test-asserts som slutter å kompilere

Ingen av delene dukker opp hvis du bare bygger og tester Win32, noe som er nøyaktig hvordan de snek seg inn. Tilfellene nedenfor kommer fra HotPDFs SVG- og XPS-importører, dens siderenderer og dens JSON-jobb-leser, og de siterte numeriske resultatene ble reprodusert med små probe-programmer bygget for Win32 og Win64. Flytter du en Delphi-kodebase til 64-bit, er hver enkelt verdt en grep

Hvorfor overflyter Power(10, 100) bare på Win64?

På Win64 løser System.Math.Power(10, N) med heltallsargumenter til Single-overloaden, så resultatet beregnes og returneres i single precision, og alt over omtrent 3.4E38 overflyter. På Win32 binder samme kall til Extended-overloaden og kjører på x87 FPU med 80-bits presisjon, så Power(10, 100) er rett og slett 1E100

System.Math deklarerer Power for Extended, Double og Single, pluss en matchende IntPower-familie som Power kaller når eksponenten er et helt tall. På Win64 er Extended bare et alias for Double (SizeOf(Extended) = 8), og for to heltallsargumenter velger kompilatoren Single-versjonen. Avsløringen er presisjon, ikke bare overflyten: på Win64 returnerer Power(10, 20) 1.0000000200408773E20, som er nøyaktig Single(1E20). Et Double-resultat ville skrevet som 1E20. Vi så samme binding med hver Win64-kompilator vi prøvde, fra Delphi 10.3 gjennom kompilatorversjon 37.0

Hva som skjer videre, avhenger av flyttalls-unntaksmasken. Delphi 12 og senere masker alle flyttallsunntak som standard, så overflyten er stille: Power(10, 100) returnerer +Inf og Power(10, -100) returnerer 0. Delphi 11 og tidligere lar exOverflow være umaskert, og samme kall reiser EOverflow. Applikasjoner som setter masken selv, og DLL-er lastet inn i slike verter, får den atferden verten valgte, noe som er grunnen til at et bibliotek ikke kan anta noe av utfallene

HotPDF Win64 numerisk felle der System.Math Power med heltallsargumenter binder til Single-overloaden, så Power av 10 i 20. potens returnerer 1.0000000200408773E20 i stedet for 1E20, og Power av 10 i 100. potens gir pluss uendelig når unntak er maskert eller EOverflow når de ikke er det
Presisjonstapet er avsløringen: hvis en tierpotens kommer tilbake med Single-støy påhengt, vant feil overload — bygg skalaen selv
uses
  System.SysUtils, System.Math;

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

  // Reproduser det Delphi 11, eller en host med strenge FP-innstillinger, gjør
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

Å unmaskere exOverflow og exInvalidOp i varigheten av en test er den billigste måten å se hva en eldre kompilator eller en streng vert ser. På en moderne kompilator med standardinnstillinger krasjer ikke bugen, den produserer uendeligheter og nuller, og de er mye vanskeligere å få øye på i en testlogg. Gjenopprett forrige maske i finally: masken er per-tråd-tilstand, og resten av testkjøringen arver det du etterlater

Hvordan overloaden nådde HotPDF SVG- og XPS-import

HotPDFs SVG- og XPS-sti-lesere deler én tallskanner, og den skanneren skalerte mantissen med Power(10, Exponent) så snart den hadde lest en eksponent. Enhver SVG sendt til THotPDF.ImportSVGFormXObject (inngangspunktet bak å importere SVG inn i PDF som gjenbrukbare form XObjects), og enhver stigeometri håndtert under XPS- og OpenXPS-til-PDF-konvertering, kunne derfor mate en koordinat som 1e100 eller 5e99 inn i det kallet

v2.770.91 hadde allerede takset eksponenten til 100 og avvist verdier som ville passere 1E300, noe som så tilstrekkelig ut: 1E100 er ingensteds nær Double-grensen på omtrent 1.8E308. På Win64 overflytet det fortsatt, for beregningen skjedde aldri i Double i det hele tatt. Siden v2.770.155 bygger skanneren tierpotensen selv, og tall som 1e-100, eller en lang mantisse med en stor negativ eksponent, leses som sin virkelige verdi i stedet for å kollapse til 0

En trygg tierpotens for begrensede eksponenter

Når eksponenten er begrenset, er den tryggste tierpotensen én du bygger selv med Double-multiplikasjon. En løkke på høyst 100 multiplikasjoner koster ingenting ved siden av å skanne teksten rundt den, den produserer aldri et mellomprodukt større enn den endelige skalaen, og den oppfører seg 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;
  // Avvis resultater som ville forlate Double-området
  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 aldri 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 vekten. Områdekontrollen bruker to sammenligninger i stedet for Abs(Exponent) <= 100, for Abs(Low(Integer)) er fortsatt negativ og ville seilet rett gjennom. Negative eksponenter dividerer med skalaen i stedet for å multiplisere med en forhåndsberegnet 1E-100, som ikke har noen eksakt Double og ville lagt til ett avrundingssteg til. Og Log10-forhåndssjekken avviser resultater utenfor Double-området før multiplikasjonen har en sjanse til å overflyte

Vær klar over hva løkken gir opp. Tierpotenser opp til 1E22 er eksakte i Double; forbi det runder hver multiplikasjon, og etter 100 av dem ligger skalaen noen enheter på sisteplassen unna den korrekt avrundede 1E100. For tegnekoordinater er det usynlig. For en generell tekst-til-double-konvertering som må reprodusere hver verdi bit for bit, er det ikke godt nok, og du trenger en korrekt avrundet konverteringsalgoritme i stedet

Når dcc64 leser en foreldet TList.Count i en while-løkke

Vi observerte at Win64-kompilatoren (dcc64, kompilatorversjon 37.0) genererte kode for en while List.Count > Start do-løkke som slettet fra slutten av listen og sammenlignet mot en stakk-midlertidig i stedet for å lese Count på nytt. Omskrivingen som fikset det, var en for ... downto-løkke, hvis grenser evalueres nøyaktig én gang per definisjon

Løkken ankom i v2.769.3, som lærte rendererens transparency group-kode å holde myke masker opprettet inne i en gruppe i live på tvers av en topass-rendering og frigjøre dem etterpå. Oppryddingen lå i en finally-blokk etter en én- eller topass for-løkke, inne i per-flis-løkken. Redusert til sin form, ser før og etter slik ut:

// Formen vi så feilkompilert av dcc64 (kompilatorversjon 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: grensene evalueres én gang, ingen midlertidig som blir foreldet
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 genererte Win64-koden delte Count i løkkebetingelsen og Count-lesingen inne i kroppen én stakk-plass. Betingelsen sammenlignet mot den plassen ved inngang, før noe hadde skrevet den, og ingenting oppfrisket den etter Delete. Når en gruppe ikke hadde opprettet egne myke masker, kjørte kroppen likevel og ba en tom liste om element -1, så i 64-bits bygg feilet hver side som inneholdt en slik transparency group med EListError. Win32-koden for samme kilde var korrekt, og v2.770.1 erstattet løkken

HotPDF Win64 kodegen-felle i rendererens opprydding: en while-løkke som leste TList.Count på nytt delte én stakk-plass mellom betingelsen og kroppen, dcc64 oppfrisket den aldri etter Delete, tomme transparency groups frigjorde element -1 og reiste EListError, og fiksen er en for downto-løkke hvis grenser evalueres én gang
Den praktiske lærdommen koster mindre enn rotårsaken: fastbegrensede downto-løkker kan ikke bli foreldet, og renderer-arbeid er ikke gjort før dcc64 har kjørt suiten

Vi har ikke redusert dette til en minimal reprodusering, og en liten frittstående løkke som DropMasksWhile kan godt kompilere korrekt; den omgivende try/finally og nestede løkker ser ut til å bety noe. Behandle det som kodegenerering vi observerte på én kompilatorversjon, ikke som en kjent defekt hos hver Win64-kompilator. Den praktiske lærdommen er billigere enn rotårsaken: en løkke hvis betingelse leser en samlings antall på nytt mens kroppen krymper den samlingen, er verdt å omskrive som en fastbegrenset for ... downto, og renderer-endringer trenger en full Win64-testkjøring, ikke bare Win32

Lokalisere et krasj som bare en optimalisert Win64-build viser

Feilen reproduserte bare i det optimaliserte Win64-bygget, så lokasjonen kom fra verktøy utenfor IDE-en. Et lite probe-program registrerte en vectored exception handler med AddVectoredExceptionHandler, fanget stakken ved det første unntaket med RtlCaptureStackBackTrace, og oversatte returadressene til funksjonsnavn ved bruk av den detaljerte map-filen linkeren skriver med -GD. Å dissekere den funksjonen viste så sammenligningen lese en stakk-plass, [rbp+0x298], som bare noensinne ble skrevet inne i løkkelegemet. Det er bevisnivået du vil ha før du klandrer en kompilator, og det tok mindre tid enn å stegge gjennom et release-bygg

Hvorfor er High(Int64) ikke en trygg øvre grense for en Double?

En Double kan ikke representere High(Int64): å konvertere 9223372036854775807 til Double runder opp til nøyaktig 2^63, én forbi den største Int64. På Win64 skjer den konverteringen inne i sammenligningen selv, så D <= High(Int64) er True for D = 2^63, og Round eller Trunc som følger, overflyter

Win32 skjuler dette av samme grunn som det skjulte Power-problemet. Sammenligningen kjører i 80-bits Extended-presisjon med en 64-bits mantisse, der High(Int64) er eksakt og 2^63 korrekt sammenlignes større. Win64 har ingen bredere type å falle tilbake på. Utenfor-området-konverteringen er ikke pen heller: i våre Win64-tester returnerte Round(2^63) Low(Int64), et stille fortegnsbytte, uansett om exInvalidOp var maskert eller ikke. Win32 returnerer samme verdi når maskert og reiser EInvalidOp når umaskert

HotPDF Int64-grensefelle: en Double kan ikke representere High(Int64), så en Win64-sammenligning konverterer grensen opp til 2^63, D lik 2^63 passerer sjekken og Round returnerer i stillhet Low(Int64), mens Win32 sammenligner i 80-bits Extended der grensen er eksakt og samme sammenligning er False
Én konvertering er hele bugen: grensen runder opp på nøyaktig verdien du ekskluderer, så skriv taket som en literal med streng mindre-enn
UttrykkWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), unntak maskert (Delphi 12+-standard)1E100+Inf
Power(10, 100), exOverflow umaskert1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp umaskertEInvalidOpLow(Int64)

HotPDF traff dette i JSON-leseren bak sine dokumentjob-verdier. JSON setter ingen områdegrense på tall, og den gamle serialisatoren gjorde enhver verdi med Frac(Value) = 0 om til et heltall med Round, så et fullt lovlig 1e19 ble enten et feil heltall eller et unntak, avhengig av masken. Siden v2.770.169 skrives et heltall som et heltall bare når det passer i Int64, alt annet beholder sin flyttallstekst, og heltallsgetterne returnerer kallerens standardverdi for utenfor-området-verdier i stedet for en verdi som har rundet over

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
  // Kallere avviser NaN og uendeligheter først: JSON har ingen skrivemåte for dem
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral stopper ved 15 sifre
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

Den øvre grensen er literalen 9223372036854775808.0 med en streng <. Den konstanten er 2^63, eksakt i både Double og Extended, så sammenligningen betyr det samme på enhver plattform. Den nedre grensen kan bruke >= for -2^63 er nøyaktig Low(Int64). Å teste IsNan og IsInfinite først, med kortslutningsevaluering, holder NaN og uendeligheter unna Frac og sammenligningene, som kan reise EInvalidOp når verten har umaskert den

Hvor mange sifre gir float-til-tekst virkelig deg på Win64?

Færre enn du ber om, på to av tre kompilatorer. Free Pascal 3.3.1s FloatToStrF(Value, ffGeneral, 17, 0) på Win64 stopper ved 15 signifikante sifre, så 1/3 kommer tilbake som 0.333333333333333 og to ulike Double-verdier kan serialiseres til identisk tekst. Str(Value:24, Text) etterfulgt av Trim produserer 17 signifikante sifre i vitenskapelig notasjon, 3.3333333333333331E-001 for samme verdi, og skriver alltid et punktum som desimalseparator uansett locale. Er HotPDF på FPC en del av byggmatrisen din, dekker HotPDF Free Pascal- og Lazarus Win64-støttenotatene resten av plattformforskjellene

Delphi godtar den 17-sifrede forespørselen, men de to Delphi-målene er fortsatt uenige om utgangen: FloatToStrF(0.1, ffGeneral, 17, 0) gir 0.10000000000000001 på Win32 og 0.1 på Win64. Win64 RTL kan også innføre en sistesiffer-avrundingsfeil både ved formatering og parsing, så flere sifre snevrer inn gapet uten å garantere at hvert Double-bitmønster overlever en tekst-rundtur. HotPDFs dokumentasjon lover ingenting slikt, og din bør ikke gjøre det heller, med mindre du skiper en korrekt avrundet formatter og parser av egen hånd. Send TFormatSettings.Invariant, eller erstatt separatoren selv på eldre Delphi-versjoner, så en tysk eller fransk locale ikke skriver et komma inn i JSON

Hvorfor slutter Assert.AreEqual å kompilere på Win64?

Assert.AreEqual(3, Length(Arr)) på en dynamisk array kompilerer for Win32 og feiler for Win64 med E2532, «Couldn't infer generic type argument from different argument types», fordi Length av en dynamisk array returnerer NativeInt på Win64. Med en Integer-literal på den ene siden og en 64-bits NativeInt på den andre, kan DUnitXs generiske Assert.AreEqual<T> ikke lande på én enkelt T, og bygget stopper

TList.Count utløser samme feil siden Delphi 12, der egenskapen ble NativeInt; Delphi 11 deklarerer den fortsatt som Integer. Length av en string returnerer Integer på begge plattformer og er upåvirket, noe som er grunnen til at feilen dukker opp i noen test-units og ikke andre. Skriv typeargumentet eksplisitt, Assert.AreEqual<NativeInt>(3, Length(Arr)), og kompiler testprosjektet med dcc64 før du committer. En suite som bare noensinne bygger for Win32, vil ikke fortelle deg at Win64-bygget dens er knust før noen andre prøver det

Win64-porteringssjekkliste for Delphi numerisk kode

  • Søk etter Power(- og IntPower(-kall med heltallsargumenter; send Double-typede verdier eller bygg begrensede tierpotenser selv
  • Kjør numeriske tester minst én gang med exOverflow og exInvalidOp fjernet gjennom SetExceptionMask, på både Win32 og Win64
  • Skriv Int64-øvre grensen som < 9223372036854775808.0, aldri <= High(Int64), og avvis NaN og uendeligheter før noen sammenligning
  • Ikke konverter et parsede tall til Int64 bare fordi Frac er 0; JSON-tall kan være langt større
  • Omskriv while-løkker som leser Count på nytt mens elementer slettes, som fastbegrensede for ... downto-løkker
  • På FPC Win64, bruk Str(Value:24, Text) når du trenger mer enn 15 signifikante sifre
  • Bruk Assert.AreEqual<NativeInt> for Length- og Count-asserts, og kompiler tester med dcc64 før du committer
  • Etter enhver endring av en parser eller renderer, kjør hele regresjonssuiten på Win32 og Win64, ikke bare én av dem

Bibliotekssidens fikser beskrevet her er alle i HotPDF siden v2.770.169, så SVG-import, XPS-konvertering, transparency rendering og JSON-jobbhåndtering oppfører seg nå likt på Win64 som på Win32. Genererer eller prosesserer du PDF-filer fra Delphi eller C++Builder for begge plattformer, har HotPDF Delphi PDF-komponenten-siden nedlastinger og full funksjonsliste