Технічна стаття

Баги Delphi лише на Win64, знайдені при загартуванні HotPDF

Код 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, завантажені в такі хости, отримують ту поведінку, яку обрав хост, — саме тому бібліотека не може припускати жоден із двох наслідків

Числова пастка Win64 у HotPDF, де System.Math Power з цілочисельними аргументами біндиться до перевантаження Single, тож Power десяти в 20-му повертає 1.0000000200408773E20 замість 1E20, а Power десяти в 100-му дає плюс нескінченність, коли винятки замасковані, чи EOverflow, коли ні
втрата точності — це видача: якщо ступінь десятки повертається з Single-шумом, перемогло неправильне перевантаження — будуйте масштаб самі
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 замінив цикл

Пастка кодогенерації Win64 у чистці рендерера HotPDF: цикл while з перечитуванням TList.Count ділив один стековий слот між умовою і тілом, dcc64 ніколи не освіжаючи його після Delete, порожні групи прозорості звільняли елемент -1 і піднімали EListError, а виправлення — цикл for downto, чиї межі обчислюються один раз
практичний урок коштує дешевше за корінну причину: downto-цикли з фіксованими межами не можуть застаріти, а робота над рендерером не завершена, доки dcc64 не прогнав сюїту

Ми не зводили це до мінімальної репродукції, і маленький окремий цикл на кшталт 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 без неї

Пастка межі Int64 у HotPDF: Double не може репрезентувати High(Int64), тож Win64-порівняння конвертує межу вгору до 2^63, D, рівне 2^63, проходить перевірку, і Round мовчки повертає Low(Int64), тоді як Win32 порівнює в 80-бітовому Extended, де межа точна і те саме порівняння — False
одна конверсія — увесь баг: межа округлюється на те саме значення, яке ви виключаєте, тож пишіть стелю літералом зі строгим less-than
ВиразWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), винятки замасковані (усталено Delphi 12+)1E100+Inf
Power(10, 100), exOverflow без маски1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp без маскиEInvalidOpLow(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 є завантаження та повний список можливостей