Техническа статия

Win64-специфични бъгове в Delphi, открити в HotPDF

Win64 Delphi код може да провали там, където същият източник върви чисто на Win32, а HotPDF Delphi PDF компонентът удари пет такива случая по време на скорошен проход за закаляване: Power(10, N), свързващ се към Single претоварването, цикъл while, четещ остарял TList.Count, граница High(Int64), закръгляваща нагоре до 2^63, float текст с 15 цифри на FPC и assert-и в тестове, спиращи компилацията

Нито едно не се показва, ако билдвате и тествате само Win32, което е точно как са се промъкнали. Случаите по-долу идват от SVG и XPS импортерите на HotPDF, неговия рендерер на страници и неговия JSON четец на задания, а цитираните числови резултати бяха възпроизведени с малки пробни програми, билднати за 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 на 10 на 20-та връща 1.0000000200408773E20 вместо 1E20, а Power на 10 на 100-та дава плюс безкрайност, когато изключенията са маскирани, или EOverflow, когато не са
загубата на точност е разкривателят: ако степен на десетката се върне с Single шум, грешното претоварване спечели — постройте мащаба сами
uses
  System.SysUtils, System.Math;

procedure ShowPowerOverload;
var
  N: Integer;
  OldMask: TArithmeticExceptionMask;
begin
  N := 20;
  // Win32 отпечатва 1E20; Win64 отпечатва 1.0000000200408773E20 (Single overload)
  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 XObjects), и всяка геометрия на пътища, обработвана по време на конверсията на 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, който научи кода на transparency-group в рендерера да държи живи създадените вътре в група 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-битови билдове всяка страница, съдържаща такава transparency група, проваляше с EListError. Win32 кодът за същия източник беше верен, а v2.770.1 замени цикъла

Капан в codegen на Win64 в HotPDF при почистването на рендерера: цикъл while, пречитащ TList.Count, споделяше един стеков слот между условието и тялото, dcc64 никога не го обнови след Delete, празни transparency групи освободиха елемент -1 и вдигнаха EListError, а поправката е цикъл for downto, чиито граници се оценяват веднъж
практическият урок струва по-малко от коренната причина: цикли downto с фиксирани граници не могат да остареят, а работата по рендерера не е свършена, докато dcc64 не е пуснал сюитата

Не го сведохме до минимална репродукция, а малък самостоятелен цикъл като DropMasksWhile може много добре да се компилира коректно; околният try/finally и вгнездените цикли изглежда имат значение. Третирайте го като code генерация, наблюдавана на една версия на компилатора, не като известен дефект на всеки Win64 компилатор. Практическият урок е по-евтин от коренната причина: цикъл, чието условие пре-чете броя на колекция, докато тялото я свива, си заслужава пренаписването като for ... downto с фиксирани граници, а смени в рендерера се нуждаят от пълен Win64 тестов пуск, не само Win32

Намиране на срив, който показва само оптимизиран Win64 билд

Провалът се възпроизведе само в оптимизирания Win64 билд, така че местоположението дойде от инструменти извън IDE-то. Малка пробна програма регистрира vectored exception handler с 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
една конверсия е целият бъг: границата закръглява върху самата стойност, която изключвате, затова пишете тавана като literal със строго по-малко
ExpressionWin32Win64
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 цяло число се записва като integer само когато се събира в Int64, всичко останало пази своя плаващ текст, а целочислените getters връщат дефолта на извикващия за стойности извън диапазона вместо обвита стойност

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;

Горната граница е literal-ът 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. Win64 RTL-ят може също да въведе грешка в закръглянето на последната цифра както при форматиране, така и при парсване, така че повече цифри стесняват пролуката, без да гарантират, че всеки битов образец 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 literal от едната страна и 64-битов NativeInt от другата, generic-ът Assert.AreEqual<T> на DUnitX не може да се спре на един T, а билдът спира

TList.Count задейства същата грешка от Delphi 12, където свойството стана NativeInt; Delphi 11 все още го обявява като Integer. Length на string връща Integer и на двете платформи и не е засегната, което обяснява защо грешката се появява в някои тестови units и не в други. Пишете типовия аргумент изрично, Assert.AreEqual<NativeInt>(3, Length(Arr)), и компилирайте тестовия проект с dcc64, преди да комитнете. Сюита, която се билдва само за Win32, няма да ви каже, че Win64 билдът ѝ е счупен, докато някой друг не го опита

Контролен списък за пренос на Win64 на Delphi числов код

  • Търсете разговори Power( и IntPower( с целочислени аргументи; подавайте стойности, типизирани като Double, или стройте сами ограничени степени на десетките
  • Пускайте числови тестове поне веднъж с exOverflow и exInvalidOp махнати чрез SetExceptionMask, и на 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 конверсията, рендирането на transparency и обработката на JSON задания вече се държат еднакво на Win64 като на Win32. Ако генерирате или обработвате PDF файлове от Delphi или C++Builder за двете платформи, страницата на HotPDF Delphi PDF компонент има изтеглянията и пълния списък с функции