Code Delphi Win64 có thể thất bại ở chỗ cùng một mã nguồn chạy êm ru trên Win32, và HotPDF Delphi PDF component dính đúng năm ca như vậy trong một lượt gia cố gần đây: Power(10, N) bấm vào overload Single, một vòng while đọc phải TList.Count cũ kỹ, một biên High(Int64) làm tròn lên 2^63, văn bản float 15 chữ số trên FPC, và các assert kiểm thử ngừng biên dịch
Chẳng cái nào trong số đó lộ ra nếu bạn chỉ build và test Win32, và đó chính là cách chúng lọt vào. Các trường hợp dưới đây đến từ các importer SVG và XPS của HotPDF, page renderer và trình đọc JSON job của nó, và các kết quả số được trích dẫn đã được tái hiện bằng các chương trình dò nhỏ build cho Win32 và Win64. Nếu bạn đang chuyển một codebase Delphi sang 64-bit, mỗi ca xứng đáng một lượt grep
Vì sao Power(10, 100) chỉ tràn số trên Win64?
Trên Win64, System.Math.Power(10, N) với các tham số nguyên phân giải về overload Single, nên kết quả được tính và trả về ở độ chính xác single, và bất cứ thứ gì trên cỡ 3.4E38 đều tràn số. Trên Win32, cùng lời gọi ấy bấm vào overload Extended và chạy trên x87 FPU với độ chính xác 80-bit, nên Power(10, 100) đơn giản là 1E100
System.Math khai báo Power cho Extended, Double và Single, cộng thêm một họ IntPower tương ứng mà Power gọi khi số mũ là một số nguyên vẹn. Trên Win64, Extended chỉ còn là một alias của Double (SizeOf(Extended) = 8), và với hai tham số nguyên, compiler chọn bản Single. Cái lộ hàng là độ chính xác chứ không chỉ tràn số: trên Win64, Power(10, 20) trả về 1.0000000200408773E20, chính xác là Single(1E20). Một kết quả Double sẽ in ra 1E20. Chúng tôi thấy cùng kiểu bấm đó với mọi compiler Win64 chúng tôi thử, từ Delphi 10.3 cho tới compiler version 37.0
Chuyện gì xảy ra tiếp theo phụ thuộc vào mask exception dấu phẩy động. Delphi 12 trở lên mặc định mask mọi exception dấu phẩy động, nên tràn số diễn ra âm thầm: Power(10, 100) trả về +Inf và Power(10, -100) trả về 0. Delphi 11 trở xuống để exOverflow không mask, và cùng lời gọi ấy raise EOverflow. Các ứng dụng tự đặt mask, và các DLL được nạp vào những host như vậy, nhận lấy hành vi mà host đã chọn — vì thế một thư viện không thể giả định trước kết quả nào
uses
System.SysUtils, System.Math;
procedure ShowPowerOverload;
var
N: Integer;
OldMask: TArithmeticExceptionMask;
begin
N := 20;
// Win32 in ra 1E20; Win64 in ra 1.0000000200408773E20 (overload Single)
Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));
// Tái hiện điều Delphi 11, hay một host với cài đặt FP nghiêm ngặt, làm
OldMask := GetExceptionMask;
SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
try
N := 100;
Writeln(Power(10, N)); // Win64: EOverflow; Win32: 1E100
finally
SetExceptionMask(OldMask);
end;
end;
Bỏ mask exOverflow và exInvalidOp trong suốt một phép thử là cách rẻ nhất để thấy điều mà một compiler cũ hay một host nghiêm ngặt thấy. Trên một compiler hiện đại với cài đặt mặc định, bug không làm sập, nó sinh ra vô cực và số 0, và những thứ ấy khó soi hơn nhiều trong log kiểm thử. Hãy khôi phục mask cũ trong finally: mask là trạng thái theo từng thread, và phần còn lại của lượt test kế thừa đúng thứ bạn bỏ lại
Overload ấy chạm tới phần import SVG và XPS của HotPDF ra sao
Các trình đọc path SVG và XPS của HotPDF dùng chung một bộ quét số, và bộ quét ấy scale phần định trị bằng Power(10, Exponent) ngay khi đã đọc xong một số mũ. Bất kỳ SVG nào đưa cho THotPDF.ImportSVGFormXObject (điểm vào đứng sau import SVG vào PDF dưới dạng form XObject tái sử dụng), và bất kỳ hình học path nào được xử lý trong chuyển đổi XPS và OpenXPS sang PDF, đều có thể nạp một tọa độ kiểu 1e100 hay 5e99 vào lời gọi đó
v2.770.91 đã chặn số mũ ở 100 và từ chối các giá trị vượt 1E300, nhìn qua tưởng là đủ: 1E100 còn xa lắc lư với giới hạn Double cỡ 1.8E308. Trên Win64 nó vẫn tràn, vì phép tính chưa bao giờ diễn ra trong Double. Kể từ v2.770.155, bộ quét tự dựng lũy thừa của mười, và các số như 1e-100, hay một định trị dài với số mũ âm lớn, được đọc đúng giá trị thật thay vì sụp về 0
Một lũy thừa của mười an toàn cho số mũ có biên
Khi số mũ bị chặn trong khoảng, lũy thừa của mười an toàn nhất là cái bạn tự dựng bằng phép nhân Double. Một vòng lặp tối đa 100 phép nhân chẳng tốn gì so với việc quét văn bản xung quanh, nó không bao giờ sinh ra một giá trị trung gian lớn hơn tỉ lệ cuối cùng, và nó chạy như nhau trên Win32, Win64 và 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;
// Từ chối kết quả rời khỏi dải 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; // không bao giờ vượt 1E100
if Exponent >= 0 then
Scaled := Value * Scale
else
Scaled := Value / Scale; // chia: 1E-100 không có Double chính xác
Result := True;
end;
Ba chi tiết gánh phần việc chính. Phép kiểm khoảng dùng hai phép so sánh thay vì Abs(Exponent) <= 100, vì Abs(Low(Integer)) vẫn âm và sẽ lướt qua suôn sẻ. Số mũ âm chia cho tỉ lệ thay vì nhân với một 1E-100 dựng sẵn, thứ không có Double chính xác và sẽ thêm một bước làm tròn nữa. Và phép kiểm trước Log10 từ chối các kết quả ngoài dải Double trước khi phép nhân kịp tràn số
Hãy rõ ràng về điều vòng lặp này từ bỏ. Các lũy thừa của mười tới 1E22 là chính xác trong Double; vượt qua đó, mỗi phép nhân đều làm tròn, và sau 100 lần như vậy tỉ lệ nằm cách đúng vài đơn vị ở chữ số cuối so với 1E100 được làm tròn chuẩn. Với tọa độ vẽ thì điều đó vô hình. Với một phép chuyển text-sang-double đa dụng mà phải tái tạo từng giá trị đúng từng bit, thì chưa đủ, và bạn cần một thuật toán chuyển đổi làm tròn chuẩn thay thế
Khi dcc64 đọc phải một TList.Count cũ kỹ trong vòng while
Chúng tôi thấy compiler Win64 (dcc64, compiler version 37.0) sinh code cho một vòng while List.Count > Start do xóa từ cuối danh sách mà so sánh với một temporary trên stack thay vì đọc lại Count. Bản viết lại đã sửa nó là một vòng for ... downto, thứ mà biên của nó được đánh giá đúng một lần theo định nghĩa
Vòng lặp ấy vào ở v2.769.3, bản dạy code transparency-group của renderer giữ các soft mask được tạo trong một group sống qua một lần render hai lượt rồi giải phóng về sau. Phần dọn dẹp nằm trong một khối finally sau một vòng for một hay hai lượt, bên trong vòng lặp theo từng tile. Rút gọn về hình dạng, trước và sau trông như thế này:
// Hình dạng chúng tôi thấy dcc64 (compiler version 37.0) biên dịch sai
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;
// Thay thế: biên chỉ được đánh giá một lần, không temporary nào để cũ kỹ
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
Idx: NativeInt; // TList.Count là NativeInt kể từ Delphi 12
begin
for Idx := Masks.Count - 1 downto Start do
begin
TObject(Masks[Idx]).Free;
Masks.Delete(Idx);
end;
end;
Trong code Win64 được sinh ra, Count trong điều kiện vòng lặp và Count đọc bên trong thân dùng chung một slot stack. Điều kiện so sánh với slot đó ngay khi vào, trước khi bất cứ thứ gì ghi nó, và chẳng gì làm mới nó sau Delete. Khi một group chưa tạo soft mask nào của riêng nó, thân vòng lặp vẫn chạy và hỏi một danh sách rỗng cho phần tử -1, nên trên các bản build 64-bit, mọi trang chứa một transparency group như vậy thất bại với EListError. Code Win32 cho cùng nguồn thì đúng, và v2.770.1 thay vòng lặp ấy
Chúng tôi chưa thu gọn được nó thành một bản tái hiện tối thiểu, và một vòng độc lập nhỏ như DropMasksWhile rất có thể biên dịch đúng; phần try/finally xung quanh và các vòng lặp lồng nhau dường như có vai trò. Hãy coi nó là một hiện tượng sinh code mà chúng tôi thấy trên một phiên bản compiler, chứ không phải một defect đã biết của mọi compiler Win64. Bài học thực dụng rẻ hơn gốc rễ: một vòng lặp mà điều kiện đọc lại số phần tử của một tập hợp trong khi thân làm tập hợp đó thu lại đáng được viết lại thành for ... downto biên cố định, và các thay đổi renderer cần một lượt test Win64 trọn vẹn, chứ không chỉ Win32
Định vị một crash mà chỉ bản build Win64 tối ưu hóa mới lộ
Thất bại chỉ tái hiện được trên bản build Win64 đã tối ưu hóa, nên vị trí đến từ các công cụ ngoài IDE. Một chương trình dò nhỏ đăng ký một vectored exception handler bằng AddVectoredExceptionHandler, chụp stack ngay exception đầu tiên bằng RtlCaptureStackBackTrace, và dịch các địa chỉ trả về thành tên hàm bằng tệp map chi tiết mà linker viết với -GD. Disassemble hàm ấy lúc đó cho thấy phép so sánh đọc một slot stack, [rbp+0x298], thứ chỉ từng được ghi bên trong thân vòng lặp. Đó là mức bằng chứng bạn muốn có trước khi đổ lỗi cho compiler, và nó tốn ít thời gian hơn là bước từng bước qua một bản release
Vì sao High(Int64) không phải một biên trên an toàn cho Double?
Một Double không biểu diễn được High(Int64): chuyển 9223372036854775807 sang Double làm tròn lên đúng 2^63, hơn số Int64 lớn nhất một đơn vị. Trên Win64, phép chuyển đó xảy ra ngay bên trong phép so sánh, nên D <= High(Int64) là True với D = 2^63, và Round hay Trunc theo sau thì tràn số
Win32 che giấu chuyện này vì đúng lý do nó đã che giấu vấn đề Power. Phép so sánh chạy ở độ chính xác Extended 80-bit với định trị 64-bit, nơi High(Int64) là chính xác và 2^63 được so đúng là lớn hơn. Win64 không có kiểu rộng hơn để nương nhờ. Phép chuyển ngoài dải cũng chẳng đẹp đẽ gì: trong các test Win64 của chúng tôi, Round(2^63) trả về Low(Int64) — một lật dấu âm thầm — bất kể exInvalidOp có được mask hay không. Win32 trả về cùng giá trị khi mask và raise EInvalidOp khi không mask
| Biểu thức | Win32 | Win64 |
|---|---|---|
Power(10, N), N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100), exception được mask (mặc định Delphi 12+) | 1E100 | +Inf |
Power(10, 100), exOverflow không mask | 1E100 | EOverflow |
D <= High(Int64), D = 2^63 | False | True |
Round(2^63), exInvalidOp không mask | EInvalidOp | Low(Int64) |
HotPDF gặp chuyện này trong trình đọc JSON đứng sau các giá trị job tài liệu. JSON không đặt giới hạn dải cho số, và serializer cũ biến mọi giá trị có Frac(Value) = 0 thành một số nguyên bằng Round, nên một 1e19 hoàn toàn hợp pháp trở thành một số nguyên sai hay một exception, tùy mask. Kể từ v2.770.169, một số nguyên vẹn chỉ được viết thành số nguyên khi nó vừa Int64, mọi thứ khác giữ nguyên văn bản dấu phẩy động, và các getter số nguyên trả về mặc định của caller với các giá trị ngoài dải thay vì một giá trị bị wrap
const
TwoPow63 = 9223372036854775808.0; // 2^63, chính xác trong Double lẫn 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
// Caller từ chối NaN và vô cực trước: JSON không có cách viết cho chúng
if TryDoubleToInt64(Value, R) then
Result := IntToStr(R)
else
begin
{$IFDEF FPC}
Str(Value:24, Result); // FPC Win64 ffGeneral dừng ở 15 chữ số
Result := Trim(Result);
{$ELSE}
Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
end;
end;
Biên trên là literal 9223372036854775808.0 với một dấu < nghiêm ngặt. Hằng số đó là 2^63, chính xác trong cả Double lẫn Extended, nên phép so sánh mang cùng ý nghĩa trên mọi nền tảng. Biên dưới có thể dùng >= vì -2^63 chính xác là Low(Int64). Việc thử IsNan và IsInfinite trước, với đánh giá ngắn mạch, giữ NaN và vô cực tránh xa Frac cùng các phép so sánh — những thứ có thể raise EInvalidOp khi host không mask nó
Chuyển float-thành-văn bản thực sự cho bạn bao nhiêu chữ số trên Win64?
Ít hơn những gì bạn đòi, trên hai trong ba compiler. FloatToStrF(Value, ffGeneral, 17, 0) của Free Pascal 3.3.1 trên Win64 dừng ở 15 chữ số có nghĩa, nên 1/3 quay về 0.333333333333333 và hai giá trị Double khác nhau có thể serialize thành văn bản y hệt nhau. Str(Value:24, Text) rồi Trim cho ra 17 chữ số có nghĩa theo ký hiệu khoa học, 3.3333333333333331E-001 cho cùng giá trị đó, và luôn viết dấu chấm làm dấu phân cách thập phân bất kể locale. Nếu HotPDF trên FPC là một phần trong ma trận build của bạn, các ghi chú hỗ trợ HotPDF Free Pascal và Lazarus Win64 bàn phần còn lại của những khác biệt nền tảng
Delphi nhận lời yêu cầu 17 chữ số, nhưng hai đích Delphi vẫn bất đồng về đầu ra: FloatToStrF(0.1, ffGeneral, 17, 0) cho 0.10000000000000001 trên Win32 và 0.1 trên Win64. RTL Win64 còn có thể đưa vào một lỗi làm tròn chữ số cuối cả khi định dạng lẫn khi phân tích, nên càng nhiều chữ số càng thu hẹp khoảng cách nhưng không bảo đảm mọi pattern bit Double sống sót qua một vòng văn bản đi-và-về. Tài liệu của HotPDF không hứa hẹn điều đó, và của bạn cũng không nên, trừ khi bạn tự cung cấp một bộ formatter và parser làm tròn chuẩn. Hãy truyền TFormatSettings.Invariant, hay tự thay dấu phân cách trên các phiên bản Delphi cũ, để một locale tiếng Đức hay tiếng Pháp không ghi dấu phẩy vào JSON
Vì sao Assert.AreEqual ngừng biên dịch trên Win64?
Assert.AreEqual(3, Length(Arr)) trên một mảng động biên dịch được cho Win32 và thất bại cho Win64 với E2532, "Couldn't infer generic type argument from different argument types", vì Length của một mảng động trả về NativeInt trên Win64. Với một literal Integer ở bên này và một NativeInt 64-bit ở bên kia, generic Assert.AreEqual<T> của DUnitX không thể chốt một T duy nhất, và bản build dừng lại
TList.Count kích hoạt cùng lỗi ấy kể từ Delphi 12, nơi property này trở thành NativeInt; Delphi 11 vẫn khai báo nó là Integer. Length của một string trả về Integer trên cả hai nền tảng và không bị ảnh hưởng, đó là lý do lỗi xuất hiện ở một số unit test và không ở các unit khác. Hãy viết tham số kiểu tường minh, Assert.AreEqual<NativeInt>(3, Length(Arr)), và biên dịch project test bằng dcc64 trước khi commit. Một bộ test mà chỉ build cho Win32 mãi mãi sẽ không tự báo rằng bản build Win64 của nó đã hỏng cho tới khi ai đó thử
Danh mục port Win64 cho code số học Delphi
- Tìm các lời gọi
Power(vàIntPower(với tham số nguyên; hãy truyền các giá trị kiểuDoublehay tự dựng các lũy thừa của mười có biên - Chạy các test số học ít nhất một lần với
exOverflowvàexInvalidOpđược bỏ quaSetExceptionMask, trên cả Win32 lẫn Win64 - Viết biên trên
Int64thành< 9223372036854775808.0, đừng bao giờ<= High(Int64), và từ chối NaN cùng vô cực trước mọi phép so sánh - Đừng chuyển một số đã parse sang
Int64chỉ vìFracbằng 0; số JSON có thể lớn hơn nhiều - Viết lại các vòng
whileđọc lạiCounttrong khi xóa phần tử thành các vòngfor ... downtobiên cố định - Trên FPC Win64, hãy dùng
Str(Value:24, Text)khi bạn cần hơn 15 chữ số có nghĩa - Dùng
Assert.AreEqual<NativeInt>cho các assertLengthvàCount, và biên dịch test bằng dcc64 trước khi commit - Sau bất kỳ thay đổi nào lên parser hay renderer, hãy chạy trọn bộ hồi quy trên cả Win32 và Win64, chứ đừng chỉ một trong hai
Các bản sửa phía thư viện mô tả ở đây đều có mặt trong HotPDF kể từ v2.770.169, nên import SVG, chuyển đổi XPS, render transparency và xử lý JSON job giờ hành xử như nhau trên Win64 như trên Win32. Nếu bạn sinh hay xử lý tệp PDF từ Delphi hay C++Builder cho cả hai nền tảng, trang HotPDF Delphi PDF component có các bản tải về cùng danh sách tính năng đầy đủ