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-и, заредени в такива хостове, получават каквото поведение хостът е избрал, което е причината библиотека да не може да предполага нито единия изход
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 замени цикъла
Не го сведохме до минимална репродукция, а малък самостоятелен цикъл като 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 при немаскирано
| Expression | 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 цяло число се записва като 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 компонент има изтеглянията и пълния списък с функции