Bài viết kỹ thuật

Các bug Delphi chỉ gặp trên Win64 khi gia cố HotPDF

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

Cái bẫy số học Win64 của HotPDF, nơi System.Math Power với các tham số nguyên bấm vào overload Single, nên Power của 10 mũ 20 trả về 1.0000000200408773E20 thay vì 1E20, còn Power của 10 mũ 100 cho ra vô cực dương khi exception được mask hay EOverflow khi không mask
Mất độ chính xác là dấu hiệu nhận biết: nếu một lũy thừa của mười quay về kèm nhiễu Single, tức overload sai đã thắng — hãy tự dựng tỉ lệ của mình
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

Cái bẫy codegen Win64 trong phần dọn dẹp của renderer HotPDF: một vòng while đọc lại TList.Count dùng chung một slot stack giữa điều kiện và thân, dcc64 chẳng bao giờ làm mới nó sau Delete, các transparency group rỗng giải phóng phần tử -1 và raise EListError, còn bản sửa là một vòng for downto có biên được đánh giá một lần
Bài học thực dụng rẻ hơn gốc rễ: các vòng downto biên cố định không thể trở nên cũ kỹ, và công việc renderer chưa xong cho tới khi dcc64 đã chạy trọn bộ test

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

Cái bẫy biên Int64 của HotPDF: một Double không biểu diễn được High(Int64), nên phép so sánh trên Win64 chuyển giới hạn lên 2^63, D bằng 2^63 qua được phép kiểm và Round lặng lẽ trả về Low(Int64), trong khi Win32 so sánh ở Extended 80-bit nơi biên là chính xác và cùng phép so sánh đó là False
Một phép chuyển đổi là toàn bộ bug: biên làm tròn trúng chính giá trị bạn đang loại trừ, nên hãy viết trần thành một literal với dấu nhỏ hơn nghiêm ngặt
Biểu thứcWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), exception được mask (mặc định Delphi 12+)1E100+Inf
Power(10, 100), exOverflow không mask1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp không maskEInvalidOpLow(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ểu Double hay 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 exOverflow và exInvalidOp được bỏ qua SetExceptionMask, trên cả Win32 lẫn Win64
  • Viết biên trên Int64 thà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 Int64 chỉ vì Frac bằng 0; số JSON có thể lớn hơn nhiều
  • Viết lại các vòng while đọc lại Count trong khi xóa phần tử thành các vòng for ... downto biê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 assert Length và 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 đủ