Код Delphi на Win64 може падати там, де те саме джерело ганяється чисто на Win32, і компонент HotPDF Delphi PDF натрапив на п'ять таких випадків під час нещодавнього проходу загартування: Power(10, N), що біндиться до перевантаження Single, цикл while, що читає застарілий TList.Count, межа High(Int64), яка округлюється вгору до 2^63, 15-цифровий float-текст на FPC і assert-и в тестах, що перестають компілюватися
Жоден з них не виринає, якщо ви лише збираєте і тестуєте Win32 — саме так вони й прослизнули всередину. Випадки нижче походять із SVG- та XPS-імпортерів HotPDF, його рендерера сторінок і його JSON-читача завдань, а наведені числові результати були відтворені маленькими probe-програмами, зібраними для Win32 та Win64. Якщо ви пересуваєте кодову базу Delphi на 64 біти, кожен вартий grep-а
Чому Power(10, 100) переповнюється лише на Win64?
На Win64 System.Math.Power(10, N) з цілочисельними аргументами розрізнюється до перевантаження Single, тож результат обчислюється і повертається в одинарній точності, і все, що понад приблизно 3.4E38, переповнюється. На Win32 той самий виклик біндиться до перевантаження Extended і ганяється на x87 FPU з 80-бітовою точністю, тож Power(10, 100) — це просто 1E100
System.Math оголошує Power для Extended, Double і Single, плюс відповідну родину IntPower, яку Power викликає, коли показчик — ціле число. На Win64 Extended — це лише аліас для Double (SizeOf(Extended) = 8), і для двох цілочисельних аргументів компілятор обирає версію Single. Видача — у точності, а не лише в переповненні: на Win64 Power(10, 20) повертає 1.0000000200408773E20, що є точно Single(1E20). Результат Double друкувався б як 1E20. Ми бачили те саме біндинг-поведение з кожним Win64-компілятором, який пробували, від Delphi 10.3 до версії компілятора 37.0
Що стається далі — залежить від маски винятків плаваючої крапки. Delphi 12 і новіші маскують усі винятки плаваючої крапки за замовчуванням, тож переповнення мовчазне: Power(10, 100) повертає +Inf, а Power(10, -100) повертає 0. Delphi 11 і старіші лишають exOverflow без маски, і той самий виклик піднімає EOverflow. Програми, що ставлять маску самі, і DLL, завантажені в такі хости, отримують ту поведінку, яку обрав хост, — саме тому бібліотека не може припускати жоден із двох наслідків
uses
System.SysUtils, System.Math;
procedure ShowPowerOverload;
var
N: Integer;
OldMask: TArithmeticExceptionMask;
begin
N := 20;
// Win32 друкує 1E20; Win64 друкує 1.0000000200408773E20 (перевантаження Single)
Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));
// Відтворюємо те, що робить Delphi 11 чи хост із строгими FP-налаштуваннями
OldMask := GetExceptionMask;
SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
try
N := 100;
Writeln(Power(10, N)); // Win64: EOverflow; Win32: 1E100
finally
SetExceptionMask(OldMask);
end;
end;
Зняття маски з exOverflow і exInvalidOp на час тесту — найдешевший спосіб побачити те, що бачать старіший компілятор чи строгий хост. На сучасному компіляторі з усталеними налаштуваннями баг не падає, він породжує нескінченності та нулі, а їх у тестовому журналі помітити значно важче. Повертайте попередню маску в finally: маска — це стан на потік, і решта тестового прогону успадкує все, що ви по собі залишили
Як перевантаження дісталося до SVG- та XPS-імпорту HotPDF
Читачі шляхів SVG і XPS у HotPDF ділять один сканер чисел, і той сканер масштабував мантису через Power(10, Exponent), щойно прочитував показчик. Будь-який SVG, переданий у THotPDF.ImportSVGFormXObject (вхідний пункт за імпортом SVG у PDF як перевикористовуваних form XObject), і будь-яка геометрія шляхів, оброблена під час конверсії XPS та OpenXPS у PDF, міг отже згодувати координату на кшталт 1e100 чи 5e99 у той виклик
v2.770.91 уже обмежував показчик до 100 і відхиляв значення, які пройшли б 1E300, що виглядало достатнім: 1E100 і близько не стоїть до ліміту Double близько 1.8E308. На Win64 воно все одно переповнювалося, бо обчислення взагалі ніколи не відбувалося в Double. З v2.770.155 сканер будує ступінь десятки сам, і числа на кшталт 1e-100 чи довга мантиса з великим від'ємним показчиком читаються як свої справжні значення замість стискання в 0
Безпечний ступінь десятки для обмежених показчиків
Коли показчик обмежений, найбезпечніший ступінь десятки — той, який ви будуєте самі множенням Double. Цикл щонайбільше зі 100 множень нічого не коштує поруч зі скануванням тексту навколо, він ніколи не породжує проміжне значення, більше за фінальний масштаб, і поводиться ідентично на Win32, Win64 та 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;
// Відмовляємо результатам, які покинули б діапазон 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; // ніколи не перевищує 1E100
if Exponent >= 0 then
Scaled := Value * Scale
else
Scaled := Value / Scale; // ділимо: у 1E-100 немає точного Double
Result := True;
end;
Три деталі несуть вагу. Перевірка діапазону користується двома порівняннями замість Abs(Exponent) <= 100, бо Abs(Low(Integer)) досі від'ємне і пройшло б крізь не наскрізь. Від'ємні показчики ділять на масштаб замість множення на наперед обчислений 1E-100, у якого немає точного Double і який додав би ще один крок округлення. А передперевірка Log10 відмовляє результатам поза діапазоном Double до того, як множення встигне переповнитися
Будьте чесні щодо того, чого цикл зрікається. Ступені десятки до 1E22 точні в Double; далі кожне множення округлює, а після 100 з них масштаб сидить за кілька одиниць останнього місця від правильно округленого 1E100. Для координат малювання це невидиме. Для універсальної конверсії текст-у-double, яка мусять відтворювати кожне значення біт за бітом, — недостатньо, і вам потрібен алгоритм конверсії з правильним округленням
Коли dcc64 читає застарілий TList.Count у циклі while
Ми спостерігали, як Win64-компілятор (dcc64, версія компілятора 37.0) генерує код для циклу while List.Count > Start do, який видаляв з кінця списку і порівнював зі стековою тимчасовою замість перечитування Count. Переписування, що його полагодило, — цикл for ... downto, чиї межі за визначенням обчислюються рівно один раз
Цикл прибув у v2.769.3, який навчив код прозорості рендерера тримати soft-маски, створені всередині групи, живими крізь рендер у два проходи і звільняти їх після. Чистка сиділа в блоці finally після однопрохідного чи двопрохідного циклу for, всередині на-плиткового циклу. Зведена до своєї форми, картина до і після виглядає так:
// Форма, яку ми бачили неправильно зкомпільованою dcc64 (версія компілятора 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;
// Заміна: межі обчислюються один раз, немає тимчасової, що могла б застаріти
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
Idx: NativeInt; // TList.Count — NativeInt з Delphi 12
begin
for Idx := Masks.Count - 1 downto Start do
begin
TObject(Masks[Idx]).Free;
Masks.Delete(Idx);
end;
end;
У згенерованому Win64-коді Count в умові циклу та Count, що читався всередині тіла, ділили один стековий слот. Умова порівнювала з тим слотом на вході, до того як будь-що його записало, і ніщо не освіжаючи його після Delete. Коли група не створила власних soft-масок, тіло все одно ганялося і питало порожній список за елементом -1, тож у 64-бітових збірках кожна сторінка з такою групою прозорості падала з EListError. Win32-код для того самого джерела був правильним, і v2.770.1 замінив цикл
Ми не зводили це до мінімальної репродукції, і маленький окремий цикл на кшталт DropMasksWhile цілком може компілюватися правильно; навколишні try/finally та вкладені цикли, здається, мають значення. Трактуйте це як кодогенерацію, яку ми спостерігали на одній версії компілятора, а не як відомий дефект кожного Win64-компілятора. Практичний урок дешевший за корінну причину: цикл, чия умова перечитує кількість колекції, поки тіло її стискує, варто переписати як for ... downto з фіксованими межами, а зміни рендерера потребують повного тестового прогону Win64, а не лише Win32
Локалізація падіння, яке показує лише оптимізована Win64-збірка
Відмова відтворювалася лише в оптимізованій Win64-збірці, тож локалізація прийшла з інструментів поза IDE. Маленька probe-програма зареєструвала векторий обробник винятків через AddVectoredExceptionHandler, зняла стек на першому винятку через RtlCaptureStackBackTrace і переклала адреси повернення в імена функцій, користуючись детальною map-картою, яку лінкер пише з -GD. Дизасемблювання тієї функції тоді показало порівняння, що читає стековий слот [rbp+0x298], який писався лише всередині тіла циклу. Саме той рівень доказів ви хочете, перш ніж звинувачувати компілятор, і зайняло менше часу, ніж крокування крізь release-збірку
Чому High(Int64) — не безпечна верхня межа для Double?
Double не може репрезентувати High(Int64): конверсія 9223372036854775807 у Double округлюється вгору до рівно 2^63 — на один більше за найбільший Int64. На Win64 та конверсія відбувається всередині самого порівняння, тож D <= High(Int64) — це True для D = 2^63, а наступні Round чи Trunc переповнюються
Win32 це ховає з тієї ж причини, з якої ховав проблему Power. Порівняння ганяється в 80-бітовій точності Extended з 64-бітовою мантисою, де High(Int64) точне і 2^63 коректно порівнюється більшим. Win64 не має ширшого типу, на який можна спасти. Конверсія поза діапазоном не гарна також: у наших Win64-тестах Round(2^63) повертав Low(Int64) — мовчазний переворот знаку, замаскований exInvalidOp чи ні. Win32 повертає те саме значення при масці і піднімає EInvalidOp без неї
| Вираз | Win32 | Win64 |
|---|---|---|
Power(10, N), N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100), винятки замасковані (усталено Delphi 12+) | 1E100 | +Inf |
Power(10, 100), exOverflow без маски | 1E100 | EOverflow |
D <= High(Int64), D = 2^63 | False | True |
Round(2^63), exInvalidOp без маски | EInvalidOp | Low(Int64) |
HotPDF зіткнувся з цим у JSON-читачі за своїми значеннями завдань документів. JSON не ставить жодного межового обмеження на числа, а старий серіалізатор перетворював будь-яке значення з Frac(Value) = 0 на ціле через Round, тож цілком легальне 1e19 ставало або неправильним цілим, або винятком — залежно від маски. З v2.770.169 ціле число пишеться як ціле лише коли вміщується в Int64, все інше тримає свій плаваючий текст, а цілочисельні гетери повертають усталення викликача для значень поза діапазоном замість загорнутого
const
TwoPow63 = 9223372036854775808.0; // 2^63, точно в Double і 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
// Викликачі спершу відхиляють NaN і нескінченності: JSON не має для них написання
if TryDoubleToInt64(Value, R) then
Result := IntToStr(R)
else
begin
{$IFDEF FPC}
Str(Value:24, Result); // FPC Win64 ffGeneral зупиняється на 15 цифрах
Result := Trim(Result);
{$ELSE}
Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
end;
end;
Верхня межа — це літерал 9223372036854775808.0 зі строгим <. Та константа — 2^63, точна і в Double, і в Extended, тож порівняння означає те саме на кожній платформі. Нижня межа може користуватися >=, бо -2^63 — це рівно Low(Int64). Тестування IsNan і IsInfinite першими, зі скороченим обчисленням, тримає NaN і нескінченності подалі від Frac і порівнянь, які можуть підняти EInvalidOp, коли хост зняв з них маску
Скільки цифр float-у-текст насправді дає на Win64?
Менше, ніж ви просите, — на двох компіляторах із трьох. FloatToStrF(Value, ffGeneral, 17, 0) у Free Pascal 3.3.1 на Win64 зупиняється на 15 значущих цифрах, тож 1/3 повертається як 0.333333333333333, і два різні значення Double можуть серіалізуватися в ідентичний текст. Str(Value:24, Text) з наступним Trim дає 17 значущих цифр у науковій нотації — 3.3333333333333331E-001 для того самого значення — і завжди пише крапку як десятковий розділювач незалежно від локалі. Якщо HotPDF на FPC — частина вашої збіркової матриці, нотатки підтримки HotPDF Free Pascal і Lazarus Win64 покривають решту платформових відмінностей
Delphi приймає 17-цифрове прохання, але дві Delphi-цілі досі розходяться у виході: FloatToStrF(0.1, ffGeneral, 17, 0) дає 0.10000000000000001 на Win32 і 0.1 на Win64. RTL Win64 може ще й внести помилку округлення останньої цифри як при форматуванні, так і при розборі, тож більше цифр звужує прогалину, не гарантуючи, що кожен бітовий шаблон Double виживає текстовий кругообіг. Документація HotPDF не робить такої обіцянки, і вашій теж не варто, якщо ви не постачаєте власний форматер і парсер з правильним округленням. Передавайте TFormatSettings.Invariant, чи замінюйте розділювач самі на старіших версіях Delphi, щоб німецька чи французька локаль не написала кому в JSON
Чому Assert.AreEqual перестає компілюватися на Win64?
Assert.AreEqual(3, Length(Arr)) на динамічному масиві компілюється для Win32 і падає для Win64 з E2532, «Couldn't infer generic type argument from different argument types», бо Length динамічного масиву повертає NativeInt на Win64. З літералом Integer з одного боку і 64-бітовим NativeInt з іншого генеричний Assert.AreEqual<T> DUnitX не може зупинитися на одному T, і збірка зупиняється
TList.Count тригерить ту саму помилку з Delphi 12, де властивість стала NativeInt; Delphi 11 досі оголошує її як Integer. Length від string повертає Integer на обох платформах і не постраждала, чому помилка з'являється в одних тестових юнітах і не з'являється в інших. Пишіть аргумент типу явно, Assert.AreEqual<NativeInt>(3, Length(Arr)), і компілюйте тестовий проєкт dcc64 перед комітом. Сюїта, що збирається лише для Win32, не скаже вам, що її Win64-збірка зламана, доки хтось інший не спробує
Чекліст портування Win64 для числового коду Delphi
- Шукайте виклики
Power(іIntPower(з цілочисельними аргументами; передавайте значення, типізованіDouble, чи будуйте обмежені ступені десятки самі - Ганяйте числові тести щонайменше раз зі знятою через
SetExceptionMaskмаскоюexOverflowіexInvalidOp, на обох Win32 та Win64 - Пишіть верхню межу
Int64як< 9223372036854775808.0, ніколи не<= High(Int64), і відхиляйте NaN та нескінченності перед будь-яким порівнянням - Не конвертуйте розібране число в
Int64лише тому, щоFrac— 0; JSON-числа можуть бути значно більшими - Переписуйте цикли
while, що перечитуютьCountпід час видалення елементів, у циклиfor ... downtoз фіксованими межами - На FPC Win64 користуйтеся
Str(Value:24, Text), коли потрібно понад 15 значущих цифр - Користуйтеся
Assert.AreEqual<NativeInt>для assert-ів надLengthіCount, і компілюйте тести dcc64 перед комітом - Після будь-якої зміни парсера чи рендерера ганяйте повну регресійну сюїту на Win32 і Win64, а не лише на одній з них
Бібліотечні виправлення, описані тут, усе в HotPDF з v2.770.169, тож SVG-імпорт, конверсія XPS, рендеринг прозорості та обробка JSON-завдань тепер поводяться на Win64 так само, як на Win32. Якщо ви генеруєте чи обробляєте PDF-файли з Delphi чи C++Builder для обох платформ, на сторінці компонента HotPDF Delphi PDF є завантаження та повний список можливостей