Kod Delphi na Win64 potrafi zawieść tam, gdzie to samo źródło biegnie czysto na Win32, a komponent HotPDF Delphi PDF trafił na pięć takich przypadków podczas niedawnego przejścia utwardzającego: Power(10, N) wiążące się z overloadem Single, pętla while czytająca zestarzały TList.Count, granica High(Int64) zaokrąglająca się w górę do 2^63, tekst float o 15 cyfrach na FPC i asercje testowe, które blokują kompilację
Żaden z nich nie wychodzi, jeśli budujesz i testujesz tylko Win32 — i dokładnie tak się wsunęły. Poniższe przypadki pochodzą z importerów SVG i XPS w HotPDF, jego renderera stron i czytnika zadań JSON, a przytoczone wyniki liczbowe odtworzono małymi programami probierczymi zbudowanymi dla Win32 i Win64. Jeśli przenosisz codebase Delphi na 64 bity, każdy z nich jest wart grepowania
Dlaczego Power(10, 100) przepełnia się tylko na Win64?
Na Win64 System.Math.Power(10, N) z argumentami całkowitymi rozwiązuje się do overloadu Single, więc wynik jest liczony i zwracany w pojedynczej precyzji i cokolwiek powyżej około 3,4E38 się przepełnia. Na Win32 to samo wywołanie wiąże się z overloadem Extended i biegnie na FPU x87 z precyzją 80-bitową, więc Power(10, 100) to po prostu 1E100
System.Math deklaruje Power dla Extended, Double i Single, plus pasującą rodzinę IntPower, którą Power woła, gdy wykładnik jest liczbą całkowitą. Na Win64 Extended to tylko alias Double (SizeOf(Extended) = 8), a dla dwóch argumentów całkowitych kompilator wybiera wersję Single. Zdradza ją precyzja, nie tylko przepełnienie: na Win64 Power(10, 20) zwraca 1.0000000200408773E20, czyli dokładnie Single(1E20). Wynik Double wydrukowałby się jako 1E20. To samo wiązanie widzieliśmy przy każdym próbowanym kompilatorze Win64, od Delphi 10.3 po kompilator w wersji 37.0
Co dzieje się dalej, zależy od maski wyjątków zmiennoprzecinkowych. Delphi 12 i nowsze maskują domyślnie wszystkie wyjątki zmiennoprzecinkowe, więc przepełnienie jest ciche: Power(10, 100) zwraca +Inf, a Power(10, -100) zwraca 0. Delphi 11 i starsze zostawiają exOverflow niemaskowany i to samo wywołanie podnosi EOverflow. Aplikacje ustawiające maskę same oraz DLL ładowane do takich hostów dostają zachowanie wybrane przez hosta — dlatego biblioteka nie może zakładać żadnego z obu wyników
uses
System.SysUtils, System.Math;
procedure ShowPowerOverload;
var
N: Integer;
OldMask: TArithmeticExceptionMask;
begin
N := 20;
// Win32 drukuje 1E20; Win64 drukuje 1.0000000200408773E20 (overload Single)
Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));
// Odtwórz to, co robi Delphi 11 albo host ze ścisłymi ustawieniami FP
OldMask := GetExceptionMask;
SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
try
N := 100;
Writeln(Power(10, N)); // Win64: EOverflow; Win32: 1E100
finally
SetExceptionMask(OldMask);
end;
end;
Odmaskowanie exOverflow i exInvalidOp na czas testu to najtańszy sposób zobaczenia tego, co widzi starszy kompilator albo ścisły host. Na nowoczesnym kompilatorze z ustawieniami domyślnymi błąd nie wysypuje — produkuje nieskończoności i zera, a tych w logu testu znacznie trudniej upatrzyć. Przywróć poprzednią maskę w finally: maska to stan per wątek i reszta przebiegu testów odziedziczy cokolwiek zostawisz
Jak overload dotarł do importu SVG i XPS w HotPDF
Czytniki ścieżek SVG i XPS w HotPDF dzielą jeden skaner liczb, a ten skaner skalował mantysę przez Power(10, Exponent), gdy tylko przeczytał wykładnik. Każdy SVG podany do THotPDF.ImportSVGFormXObject (punktu wejścia stojącego za importem SVG do PDF jako form XObjectów wielokrotnego użytku) i każda geometria ścieżek obługiwana podczas konwersji XPS i OpenXPS do PDF mogły więc podać do tego wywołania współrzędną taką jak 1e100 albo 5e99
v2.770.91 już wcześniej ograniczało wykładnik do 100 i odrzucało wartości, które przekroczyłyby 1E300, co wyglądało na wystarczające: 1E100 jest daleko od limitu Double około 1,8E308. Na Win64 i tak się przepełniało, bo obliczenie nigdy nie działo się w Double. Od v2.770.155 skaner buduje potęgę dziesiątki sam, a liczby takie jak 1e-100 albo długa mantysa z dużym ujemnym wykładnikiem czytają się jako swoją rzeczywistą wartość, zamiast zapadać się w 0
Bezpieczna potęga dziesiątki dla ograniczonych wykładników
Gdy wykładnik jest ograniczony, najbezpieczniejsza potęga dziesiątki to taka, którą zbudujesz sam przez mnożenie w Double. Pętla najwyżej 100 mnożeń nic nie kosztuje obok skanowania tekstu wokół, nigdy nie produkuje wartości pośredniej większej niż finalna skala i zachowuje się identycznie na Win32, Win64 i 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;
// Odmawiaj wyników, które opuściłyby zakres Double
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; // nigdy nie przekracza 1E100
if Exponent >= 0 then
Scaled := Value * Scale
else
Scaled := Value / Scale; // dziel: 1E-100 nie ma dokładnego Double
Result := True;
end;
Trzy szczegóły niosą ciężar. Test zakresu używa dwóch porównań zamiast Abs(Exponent) <= 100, bo Abs(Low(Integer)) wciąż jest ujemne i przepłynęłoby przez nie prosto. Ujemne wykładniki dzielą przez skalę zamiast mnożyć przez z góry policzone 1E-100, które nie ma dokładnego Double i dodałoby jeszcze jeden krok zaokrąglania. A wstępny test Log10 odmawia wyników poza zakresem Double, zanim mnożenie dostanie szansę się przepełnić
Miej jasność, czego pętla się wyrzeka. Potęgi dziesiątki do 1E22 są dokładne w Double; dalej każde mnożenie zaokrągla, a po stu z nich skala siedzi o kilka jednostek ostatniego miejsca od poprawnie zaokrąglonego 1E100. Dla współrzędnych rysowania jest to niewidoczne. Dla ogólnego przeznaczenia konwersji tekst–double, która musi odtwarzać każdą wartość bit w bit, to za mało i potrzebujesz zamiast tego algorytmu konwersji poprawnie zaokrąglającej
Kiedy dcc64 czyta zestarzały TList.Count w pętli while
Zaobserwowaliśmy, że kompilator Win64 (dcc64, wersja 37.0) wygenerował kod dla pętli while List.Count > Start do, która usuwała z końca listy i porównywała się ze zmienną tymczasową na stosie, zamiast czytać ponownie Count. Przepisanie, które to naprawiło, to pętla for ... downto, której granice są z definicji liczone dokładnie raz
Pętla weszła w v2.769.3, które nauczyło kod grup przezroczystości renderera trzymać miękkie maski stworzone wewnątrz grupy żywymi w poprzek renderu dwuprzebiegowego i zwalniać je potem. Czyszczenie siedziało w bloku finally po pętli for jedno- albo dwuprzebiegowej, wewnątrz pętli po kafelkach. Sprowadzone do kształtu, przed i po wyglądają tak:
// Kształt, który widzieliśmy błędnie skompilowany przez dcc64 (kompilator 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;
// Zamiennik: granice liczone raz, brak zmiennej tymczasowej do zestarzenia
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
Idx: NativeInt; // TList.Count to NativeInt od Delphi 12
begin
for Idx := Masks.Count - 1 downto Start do
begin
TObject(Masks[Idx]).Free;
Masks.Delete(Idx);
end;
end;
W wygenerowanym kodzie Win64 Count w warunku pętli i Count czytany wewnątrz ciała dzieliły jeden slot na stosie. Warunek porównywał się z tym slotem przy wejściu, zanim cokolwiek go zapisało, i nic go nie odświeżało po Delete. Gdy grupa nie stworzyła własnych miękkich masek, ciało i tak się wykonywało i pytało pustą listę o element -1, więc w kompilacjach 64-bitowych każda strona z taką grupą przezroczystości wywalała się z EListError. Kod Win32 dla tego samego źródła był poprawny, a v2.770.1 wymieniło pętlę
Nie zredukowaliśmy tego do minimalnej reprodukcji i mała samodzielna pętla typu DropMasksWhile może się skompilować zupełnie poprawnie; otaczające try/finally i zagnieżdżone pętle zdają się mieć znaczenie. Traktuj to jako generowanie kodu zaobserwowane na jednej wersji kompilatora, nie jako znany defekt każdego kompilatora Win64. Praktyczna lekcja jest tańsza od przyczyny źródłowej: pętla, której warunek czyta ponownie liczbę elementów kolekcji, podczas gdy ciało tę kolekcję kurczy, warta jest przepisania na for ... downto o stałych granicach, a zmiany w rendererze wymagają pełnego przebiegu testów na Win64, nie tylko Win32
Lokalizowanie crasha, który pokazuje tylko zoptymalizowany build Win64
Awaria odtwarzała się tylko w zoptymalizowanym buildzie Win64, więc lokalizacja przyszła z narzędzi spoza IDE. Mały program probierczy zarejestrował wektorowy handler wyjątków przez AddVectoredExceptionHandler, przechwycił stos przy pierwszym wyjątku przez RtlCaptureStackBackTrace i przetłumaczył adresy powrotu na nazwy funkcji korzystając ze szczegółowego pliku mapy, który linker pisze z -GD. Zdeasemblowanie tej funkcji pokazało potem porównanie czytające slot stosu, [rbp+0x298], zapisywany wyłącznie wewnątrz ciała pętli. To jest poziom dowodów, którego chcesz, zanim obwinisz kompilator, i zajął mniej czasu niż krokowanie przez build release
Dlaczego High(Int64) nie jest bezpieczną górną granicą dla Double?
Double nie umie przedstawić High(Int64): konwersja 9223372036854775807 na Double zaokrągla się w górę dokładnie do 2^63, jednego po największym Int64. Na Win64 ta konwersja dzieje się wewnątrz samego porównania, więc D <= High(Int64) jest True dla D = 2^63, a następujące po nim Round albo Trunc się przepełnia
Win32 to ukrywa z tego samego powodu, dla którego ukrywał problem z Power. Porównanie biegnie w 80-bitowej precyzji Extended z 64-bitową mantysą, gdzie High(Int64) jest dokładne, a 2^63 porównuje się poprawnie jako większe. Win64 nie ma szerszego typu, na który można by spaść. Konwersja poza zakresem też nie wygląda ładnie: w naszych testach Win64 Round(2^63) zwracał Low(Int64) — ciche przełączenie znaku, niezależnie od tego, czy exInvalidOp był maskowany. Win32 zwraca tę samą wartość przy masce i podnosi EInvalidOp bez maski
| Wyrażenie | Win32 | Win64 |
|---|---|---|
Power(10, N), N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100), wyjątki maskowane (domyślne Delphi 12+) | 1E100 | +Inf |
Power(10, 100), exOverflow odmaskowany | 1E100 | EOverflow |
D <= High(Int64), D = 2^63 | False | True |
Round(2^63), exInvalidOp odmaskowany | EInvalidOp | Low(Int64) |
HotPDF spotkało to w czytniku JSON stojącym za wartościami zadań dokumentowych. JSON nie nakłada żadnego limitu zakresu na liczby, a stary serializator zamieniał każdą wartość z Frac(Value) = 0 w liczbę całkowitą przez Round, więc całkowicie legalne 1e19 stawało się albo złą liczbą całkowitą, albo wyjątkiem, zależnie od maski. Od v2.770.169 liczba całkowita jest zapisywana jako integer tylko wtedy, gdy mieści się w Int64, wszystko inne zachowuje swój tekst zmiennoprzecinkowy, a gettery całkowitoliczbowe zwracają domyślną wartość wołającego dla wartości poza zakresem zamiast zawiniętej
const
TwoPow63 = 9223372036854775808.0; // 2^63, dokładne w Double i 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
// Wołający odrzucają najpierw NaN i nieskończoności: JSON nie ma zapisu dla nich
if TryDoubleToInt64(Value, R) then
Result := IntToStr(R)
else
begin
{$IFDEF FPC}
Str(Value:24, Result); // FPC Win64 ffGeneral staje przy 15 cyfrach
Result := Trim(Result);
{$ELSE}
Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
end;
end;
Górna granica to literał 9223372036854775808.0 ze ścisłym <. Ta stała to 2^63, dokładna i w Double, i w Extended, więc porównanie znaczy to samo na każdej platformie. Dolna granica może użyć >=, bo -2^63 to dokładnie Low(Int64). Testowanie najpierw IsNan i IsInfinite, z ewaluacją zwarcia, trzyma NaN i nieskończoności z dala od Frac i porównań, które mogą podnieść EInvalidOp, gdy host je odmaskował
Ile cyfr naprawdę daje konwersja float na tekst na Win64?
Mniej, niż prosisz, na dwóch kompilatorach z trzech. FloatToStrF(Value, ffGeneral, 17, 0) w Free Pascal 3.3.1 na Win64 staje przy 15 cyfrach znaczących, więc 1/3 wraca jako 0.333333333333333 i dwie różne wartości Double mogą serializować się do identycznego tekstu. Str(Value:24, Text) z Trim po nim produkuje 17 cyfr znaczących w notacji naukowej — 3.3333333333333331E-001 dla tej samej wartości — i zawsze pisze kropkę jako separator dziesiętny niezależnie od lokalizacji. Jeśli HotPDF na FPC jest częścią twojej macierzy kompilacji, noty wsparcia HotPDF Free Pascal i Lazarus Win64 pokrywają resztę różnic platformowych
Delphi przyjmuje żądanie 17 cyfr, ale oba cele Delphi wciąż różnią się na wyjściu: FloatToStrF(0.1, ffGeneral, 17, 0) daje 0.10000000000000001 na Win32 i 0.1 na Win64. RTL Win64 potrafi też wprowadzić błąd zaokrąglenia ostatniej cyfry i przy formatowaniu, i przy parsowaniu, więc więcej cyfr zwęża lukę bez gwarancji, że każdy wzorzec bitowy Double przeżyje podróż w obie strony przez tekst. Dokumentacja HotPDF nie daje takiej obietnicy i twoja też nie powinna, chyba że wysyłasz własny poprawnie zaokrąglający formater i parser. Przekazuj TFormatSettings.Invariant albo podmieniaj separator sam na starszych wersjach Delphi, żeby niemiecka albo francuska lokalizacja nie wpisała przecinka do JSON-a
Dlaczego Assert.AreEqual przestaje się kompilować na Win64?
Assert.AreEqual(3, Length(Arr)) na tablicy dynamicznej kompiluje się dla Win32 i zawodzi dla Win64 z E2532 — „Couldn't infer generic type argument from different argument types" — bo Length tablicy dynamicznej zwraca NativeInt na Win64. Z literałem Integer po jednej stronie i 64-bitowym NativeInt po drugiej generyczne Assert.AreEqual<T> z DUnitX nie potrafi osiąść na jednym T i build staje
TList.Count odpala ten sam błąd od Delphi 12, gdzie właściwość stała się NativeInt; Delphi 11 wciąż deklaruje ją jako Integer. Length string zwraca Integer na obu platformach i jest nietknięta, dlatego błąd pojawia się w niektórych jednostkach testowych, a w innych nie. Zapisz argument typu jawnie, Assert.AreEqual<NativeInt>(3, Length(Arr)), i kompiluj projekt testowy dcc64 przed commitowaniem. Zestaw testów budowany wyłącznie na Win32 nie powie ci, że jego build Win64 jest popsuty, dopóki ktoś inny nie spróbuje
Lista kontrolna portowania na Win64 dla kodu liczbowego Delphi
- Szukaj wywołań
Power(iIntPower(z argumentami całkowitymi; przekazuj wartości typuDoublealbo buduj ograniczone potęgi dziesiątki sam - Uruchamiaj testy liczbowe przynajmniej raz z
exOverflowiexInvalidOpusuniętymi przezSetExceptionMask, na Win32 i Win64 - Zapisuj górną granicę
Int64jako< 9223372036854775808.0, nigdy<= High(Int64), i odrzucaj NaN oraz nieskończoności przed jakimkolwiek porównaniem - Nie konwertuj sparsowanej liczby do
Int64tylko dlatego, żeFracjest 0; liczby JSON mogą być daleko większe - Przepisuj pętle
while, które czytają ponownieCountprzy usuwaniu elementów, na pętlefor ... downtoo stałych granicach - Na FPC Win64 używaj
Str(Value:24, Text), gdy potrzebujesz więcej niż 15 cyfr znaczących - Używaj
Assert.AreEqual<NativeInt>dla asercjiLengthiCounti kompiluj testy dcc64 przed commitowaniem - Po każdej zmianie parsera albo renderera odpalaj pełny zestaw regresyjny na Win32 i Win64, nie tylko na jednym
Poprawki po stronie biblioteki opisane tutaj są wszystkie w HotPDF od v2.770.169, więc import SVG, konwersja XPS, renderowanie przezroczystości i obsługa zadań JSON zachowują się teraz na Win64 tak samo jak na Win32. Jeśli generujesz albo przetwarzasz pliki PDF z Delphi albo C++Buildera dla obu platform, strona komponentu HotPDF Delphi PDF ma pobrania i pełną listę funkcji