โค้ด Delphi บน Win64 ล้มได้ในที่ที่ซอร์สเดียวกันรันสวยงามบน Win32 และ HotPDF Delphi PDF component เจอเคสแบบนี้ห้าเคสระหว่างรอบทำให้แข็งแรงครั้งล่าสุด: Power(10, N) ที่ bind เข้า overload ฝั่ง Single, loop while ที่อ่าน TList.Count ค้างเก่า, ขอบ High(Int64) ที่ปัดขึ้นไปเป็น 2^63, text ทศนิยม 15 หลักบน FPC และ assert ของ test ที่ compile ไม่ผ่าน
ไม่มีตัวไหนโผล่ถ้าคุณ build กับ test แค่ Win32 ซึ่งเป๊ะกับวิธีที่พวกมันแอบเข้ามา เคสด้านล่างมาจาก importer ของ SVG กับ XPS, page renderer และตัวอ่าน JSON job ของ HotPDF และผลทางตัวเลขที่ยกมาถูกจำลองซ้ำด้วยโปรแกรม probe เล็ก ๆ ที่ build ทั้ง Win32 กับ Win64 ถ้าคุณกำลังย้าย codebase ของ Delphi ไป 64 บิต แต่ละตัวคุ้มค่ากับการ grep หนึ่งรอบ
ทำไม Power(10, 100) ถึงล้นแค่บน Win64
บน Win64 System.Math.Power(10, N) ที่ได้ argument เป็นจำนวนเต็มจะ resolve ไปตกที่ overload ฝั่ง Single ผลจึงถูกคำนวณและคืนมาในความแม่นยำ single และค่าที่สูงเกินราว 3.4E38 ขึ้นไปล้นทันที บน Win32 call เดิม bind เข้า overload ฝั่ง Extended และรันบน x87 FPU ที่แม่น 80 บิต Power(10, 100) จึงเป็น 1E100 ไปเฉย ๆ
System.Math ประกาศ Power สำหรับ Extended, Double กับ Single บวกตระกูล IntPower ที่จับคู่กันซึ่ง Power จะเรียกเมื่อเลขชี้กำลังเป็นจำนวนเต็ม บน Win64 Extended เป็นแค่ alias ของ Double (SizeOf(Extended) = 8) และกับ argument จำนวนเต็มสองตัว compiler เลือกเวอร์ชัน Single ตัวฟ้องคือความแม่นยำ ไม่ใช่แค่การล้น บน Win64 Power(10, 20) คืน 1.0000000200408773E20 ซึ่งเป๊ะกับ Single(1E20) ผลแบบ Double จะพิมพ์ออกมาเป็น 1E20 เราเจอ binding แบบนี้กับ Win64 compiler ทุกตัวที่ลอง ตั้งแต่ Delphi 10.3 ถึง compiler version 37.0
สิ่งที่เกิดต่อจากนั้นขึ้นกับ floating-point exception mask Delphi 12 ขึ้นไป mask floating-point exception ทุกตัวไว้เป็นค่าเริ่มต้น การล้นจึงเงียบ: Power(10, 100) คืน +Inf และ Power(10, -100) คืน 0 Delphi 11 ลงไปปล่อย exOverflow ไม่ mask ไว้ call เดิมจึง raise EOverflow แอปพลิเคชันที่เซ็ต mask เอง และ DLL ที่ถูกโหลดเข้า host แบบนั้น จะได้พฤติกรรมอะไรก็ตามที่ host เลือกไว้ library จึงสมมติผลลัพธ์ฝั่งไหนไม่ได้เลย
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 หรือ host ที่ตั้งค่า FP แบบเข้ม
OldMask := GetExceptionMask;
SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
try
N := 100;
Writeln(Power(10, N)); // Win64: EOverflow; Win32: 1E100
finally
SetExceptionMask(OldMask);
end;
end;
การเอา mask ออกจาก exOverflow กับ exInvalidOp ช่วงสั้น ๆ ของการทดสอบ คือวิธีถูกที่สุดในการเห็นสิ่งที่ compiler รุ่นเก่าหรือ host ที่เข้มงวดเห็น บน compiler สมัยใหม่ที่ค่าเริ่มต้นเป็นอย่างไร บั๊กไม่ crash มันผลิต infinity กับศูนย์ ซึ่งจับยากกว่าใน log ของ test มากนัก อย่าลืมกู้ mask เดิมใน finally: mask เป็นสถานะราย thread และการทดสอบที่เหลือทั้งชุดจะได้รับมรดกสิ่งที่คุณทิ้งไว้
วิธีที่ overload หลุดเข้ามาถึงการนำเข้า SVG กับ XPS ของ HotPDF
ตัวอ่าน path ของ SVG กับ XPS ของ HotPDF ใช้ number scanner ตัวเดียวกัน และ scanner ตัวนั้น scale mantissa ด้วย Power(10, Exponent) เมื่ออ่านเลขชี้กำลังเจอ SVG ใด ๆ ที่ส่งเข้า THotPDF.ImportSVGFormXObject (entry point เบื้องหลังการนำเข้า SVG เป็น form XObject ใช้ซ้ำใน PDF) และ geometry ของ path ใด ๆ ระหว่างการแปลง XPS กับ OpenXPS เป็น PDFจึงสามารถป้อนพิกัดอย่าง 1e100 หรือ 5e99 เข้า call ตัวนี้ได้
v2.770.91 จำกัดเลขชี้กำลังไว้ที่ 100 และปฏิเสธค่าที่จะไปเกิน 1E300 แล้ว ซึ่งดูเหมือนเพียงพอ: 1E100 ห่างเหินจากขีดจำกัดของ Double ที่ราว 1.8E308 ไปมาก บน Win64 มันยังล้น เพราะการคำนวณไม่เคยเกิดใน Double เลยแม้แต่น้อย ตั้งแต่ v2.770.155 scanner สร้างค่ายกกำลังสิบเอง ตัวเลขอย่าง 1e-100 หรือ mantissa ยาว ๆ กับเลขชี้กลังติดลบจัด จึงถูกอ่านได้ค่าจริง แทนที่จะยุบเหลือ 0
ค่ายกกำลังสิบที่ปลอดภัยสำหรับเลขชี้กำลังที่มีขอบ
เมื่อเลขชี้กำลังถูกจำกัด ค่ายกกำลังสิบที่ปลอดภัยที่สุดคือค่าที่คุณสร้างเองด้วยการคูณ Double loop ที่คูณไม่เกิน 100 รอบไม่มีค่าใช้จ่ายเทียบกับการอ่านข้อความรอบตัวมัน ไม่เคยผลิตค่าระหว่างทางใหญ่กว่า scale สุดท้าย และทำงานเหมือนกันเป๊ะทั้ง 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)) ยังติดลบอยู่ดีและจะลอดผ่านไปอย่างสบาย เลขชี้กำลังติดลบหารด้วย scale แทนที่จะคูณด้วย 1E-100 ที่ precompute ไว้ ซึ่งไม่มีค่า Double ที่ตรงเป๊ะและจะเพิ่มขั้นปัดเศษอีกหนึ่งรอบ และเช็คล่วงหน้าด้วย Log10 ปฏิเสธผลที่หลุดช่วงของ Double ก่อนการคูณจะมีโอกาสล้น
จงชัดเจนว่า loop นี้แลกอะไรไป ค่ายกกำลังสิบถึง 1E22 ตรงเป๊ะใน Double ข้ามจุดนั้นการคูณทุกครั้งปัดเศษ และหลังผ่านไป 100 ครั้ง scale จะวางห่างจาก 1E100 ที่ปัดถูกต้องไปสองสามหน่วยในหลักสุดท้าย กับพิกัดวาดรูปสิ่งนี้มองไม่เห็น แต่สำหรับการแปลง text เป็น double แบบทั่วไปที่ต้องคืนทุกค่าไบต์ต่อไบต์ มันไม่พอ คุณต้องใช้ algorithm แปลงที่ปัดถูกต้องแทน
เมื่อ dcc64 อ่าน TList.Count ค้างเก่าใน while loop
เราสังเกตเห็น Win64 compiler (dcc64, compiler version 37.0) สร้างโค้ดให้ loop while List.Count > Start do ที่ลบจากท้ายรายการ โดยเทียบกับ temporary บน stack แทนที่จะอ่าน Count ใหม่ การเขียนใหม่ที่แก้มันคือ loop for ... downto ซึ่งขอบเขตถูกประเมินครั้งเดียวตามนิยาม
loop ตัวนี้มาพร้อม v2.769.3 ซึ่งสอนโค้ดของ transparency group ใน renderer ให้เก็บ soft mask ที่สร้างข้างใน group ไว้ทะลุผ่านการ render สองรอบ แล้วค่อย free ทีหลัง ตัวเก็บกวาดนั่งอยู่ในบล็อก finally หลัง loop for หนึ่งหรือสองรอบ ภายใน loop ต่อ tile พับให้เหลือแต่รูปทรง ก่อนและหลังหน้าตาแบบนี้:
// รูปทรงที่เราเจอ dcc64 (compiler version 37.0) compile ผิด
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;
// ตัวแทน: ขอบเขตถูกประเมินครั้งเดียว ไม่มี temporary ให้ค้างเก่า
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 ในเงื่อนไขของ loop กับ Count ที่ถูกอ่านข้างใน body ใช้ช่อง stack ตัวเดียวร่วมกัน เงื่อนไขเทียบกับช่องนั้นตอนเข้า loop ก่อนอะไรจะเขียนมันลงไป และไม่มีอะไร refresh มันหลัง Delete เมื่อ group ไม่ได้สร้าง soft mask ของตัวเองเลย body ก็ยังรันแล้วไปขอ item -1 จากรายการว่าง build 64 บิตทุกหน้าที่มี transparency group แบบนี้จึงล้มด้วย EListError โค้ด Win32 จากซอร์สเดียวกันถูกต้อง และ v2.770.1 แทนที่ loop ไป
เรายังไม่ได้ย่อมันเหลือ reproduction ของน้อยที่สุด และ loop เดี่ยว ๆ เล็ก ๆ อย่าง DropMasksWhile อาจ compile ได้ถูกต้องก็เป็นได้ try/finally รอบ ๆ กับ loop ที่ซ้อนกันดูจะมีผล จงถือว่ามันคือ code generation ที่เราสังเกตเห็นบน compiler version หนึ่ง ไม่ใช่ข้อบกพร่องที่รู้จักของ Win64 compiler ทุกตัว บทเรียนใช้งานจริงถูกกว่าสาเหตุ: loop ที่เงื่อนไขอ่าน count ของ collection ซ้ำขณะ body หด collection นั้น คุ้มค่าที่จะเขียนใหม่เป็น for ... downto ขอบตายตัว และการเปลี่ยนแปลงฝั่ง renderer ต้องผ่านการ test ชุดเต็มบน Win64 ไม่ใช่แค่ Win32
หา crash ที่โผล่แค่ใน build แบบ optimized ของ Win64
ความพังจำลองซ้ำได้แค่ใน build แบบ optimized ของ Win64 ตำแหน่งจึงมาจากเครื่องมือนอก IDE โปรแกรม probe เล็ก ๆ ลงทะเบียน vectored exception handler ด้วย AddVectoredExceptionHandler จับ stack ตอน exception ตัวแรกด้วย RtlCaptureStackBackTrace แล้วแปลง return address เป็นชื่อฟังก์ชันด้วย map file ฉบับละเอียดที่ linker เขียนด้วย -GD การ disassemble ฟังก์ชันนั้นต่อก็เห็นการเทียบที่อ่านช่อง stack [rbp+0x298] ซึ่งถูกเขียนข้างใน loop body เท่านั้น นั่นคือระดับหลักฐานที่ควรมีก่อนจะโทษ compiler และมันกินเวลาน้อยกว่าการเดิน debug ผ่าน release build ด้วยซ้ำ
ทำไม High(Int64) ถึงไม่ใช่ขอบบนที่ปลอดภัยของ Double
Double เป็นตัวแทน High(Int64) ไม่ได้ การแปลง 9223372036854775807 เป็น Double ปัดขึ้นไปเป๊ะที่ 2^63 ซึ่งล้ำเลย Int64 ที่ใหญ่ที่สุดไปหนึ่งค่า บน Win64 การแปลงนี้เกิดข้างในการเปรียบเทียบเอง D <= High(Int64) จึงเป็น True เมื่อ D = 2^63 และ Round หรือ Trunc ที่ตามมาก็ล้น
Win32 ซ่อนเรื่องนี้ด้วยเหตุผลเดียวกับที่มันซ่อนปัญหา Power การเปรียบเทียบรันในความแม่น Extended 80 บิตที่ mantissa ยาว 64 บิต ซึ่ง High(Int64) ตรงเป๊ะและ 2^63 ถูกเทียบว่าใหญ่กว่าอย่างถูกต้อง Win64 ไม่มีชนิดที่กว้างกว่าไว้ประคอง การแปลงที่หลุดช่วงก็ไม่สวยเหมือนกัน ใน test ฝั่ง Win64 ของเรา Round(2^63) คืน Low(Int64) การพลิกเครื่องหมายแบบเงียบ ๆ ไม่ว่า exInvalidOp จะถูก mask หรือไม่ Win32 คืนค่าเดียวกันเมื่อ mask ไว้ และ raise EInvalidOp เมื่อไม่ mask
| นิพจน์ | Win32 | Win64 |
|---|---|---|
Power(10, N), N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100), exception ถูก mask (ค่าเริ่มต้น Delphi 12 ขึ้นไป) | 1E100 | +Inf |
Power(10, 100), exOverflow ไม่ถูก mask | 1E100 | EOverflow |
D <= High(Int64), D = 2^63 | False | True |
Round(2^63), exInvalidOp ไม่ถูก mask | EInvalidOp | Low(Int64) |
HotPDF เจอเรื่องนี้ในตัวอ่าน JSON ที่อยู่หลังค่า job ของเอกสาร JSON ไม่จำกัดช่วงของตัวเลข และ serializer ตัวเก่าเปลี่ยนค่าใด ๆ ที่ Frac(Value) = 0 ให้เป็นจำนวนเต็มด้วย Round ค่า 1e19 ที่ถูกกฎหมายสมบูรณ์จึงกลายเป็นจำนวนเต็มผิดหรือ exception ไป แล้วแต่ mask ตั้งแต่ v2.770.169 จำนวนเต็มทั้งตัวจะถูกเขียนเป็นจำนวนเต็มเมื่อมันลงตัวใน Int64 เท่านั้น ส่วนอื่น ๆ ทุกตัวคง text ทศนิยมของตัวเอง และ getter ฝั่งจำนวนเต็มคืนค่าเริ่มต้นของ caller กับค่าที่หลุดช่วง แทนค่าที่วนกลับ
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
// ให้ caller ปฏิเสธ NaN กับ infinity ก่อน 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 การเปรียบเทียบจึงมีความหมายเดียวกันทุก platform ขอบล่างใช้ >= ได้ เพราะ -2^63 เป๊ะกับ Low(Int64) การทดสอบ IsNan กับ IsInfinite ก่อนด้วยการประเมินแบบ short-circuit กัน NaN กับ infinity ออกห่างจาก Frac กับการเปรียบเทียบ ซึ่ง raise EInvalidOp ได้เมื่อ host เอา mask ออกไว้
float เปลี่ยนเป็น text ให้กี่หลักกันแน่บน Win64
น้อยกว่าที่ขอ บนสองในสาม compiler FloatToStrF(Value, ffGeneral, 17, 0) ของ Free Pascal 3.3.1 บน Win64 หยุดที่ 15 หลักมีนัยสำคัญ 1/3 จึงกลับมาเป็น 0.333333333333333 และค่า Double ต่างกันสองค่า serialize เป็น text เหมือนกันได้ Str(Value:24, Text) ตามด้วย Trim ผลิต 17 หลักมีนัยสำคัญในรูป scientific notation เป็น 3.3333333333333331E-001 สำหรับค่าเดียวกัน และเขียนจุดทศนิยมเป็นตัวคั่นเสมอไม่ว่า locale จะเป็นอะไร ถ้า HotPDF บน FPC อยู่ใน build matrix ของคุณ บันทึกรองรับ Free Pascal กับ Lazarus Win64 ของ HotPDFเล่าความต่างด้านอื่นของ platform ให้ครบ
Delphi ยอมรับคำขอ 17 หลัก แต่เป้าหมายฝั่ง Delphi ทั้งสองยังเห็นต่างกันใน output: FloatToStrF(0.1, ffGeneral, 17, 0) ให้ 0.10000000000000001 บน Win32 และ 0.1 บน Win64 RTL ฝั่ง Win64 ยังแทรกความผิดพลาดของการปัดหลักสุดท้ายได้ทั้งตอน format และตอน parse จำนวนหลักที่มากขึ้นจึงเพียงเค้นช่องว่างให้แคบลง โดยไม่การันตีว่า bit pattern ของ Double ทุกแบบจะรอดการเดินทางผ่าน text ได้ เอกสารของ HotPDF ไม่เคยให้สัญญาแบบนี้ และของคุณก็ไม่ควรให้เช่นกัน เว้นแต่คุณจะส่ง formatter กับ parser ที่ปัดถูกต้องของตัวเองไปด้วย ให้ส่ง TFormatSettings.Invariant หรือแทนที่ตัวคั่นเองบน Delphi รุ่นเก่า locale เยอรมันหรือฝรั่งเศสจะได้ไม่เขียน comma ลงใน JSON
ทำไม Assert.AreEqual ถึง compile ไม่ผ่านบน Win64
Assert.AreEqual(3, Length(Arr)) บน dynamic array compile ผ่านบน Win32 และ fail บน Win64 ด้วย E2532 ว่า "Couldn't infer generic type argument from different argument types" เพราะ Length ของ dynamic array คืน NativeInt บน Win64 มี literal Integer ข้างหนึ่งกับ NativeInt 64 บิตอีกข้าง DUnitX ผ่าน generic Assert.AreEqual<T> จึงตัดสินใจไม่ได้ว่า T คืออะไร และ build ก็หยุด
TList.Count กระตุ้น error เดียวกันตั้งแต่ Delphi 12 ซึ่ง property เปลี่ยนเป็น NativeInt Delphi 11 ยังประกาศมันเป็น Integer อยู่ Length ของ string คืน Integer ทั้งสอง platform และไม่ถูกกระทบ นั่นเหตุผลที่ error โผล่ใน test unit บางไฟล์แต่ไม่โผล่ไฟล์อื่น เขียน type argument แบบแจ้งชัดเป็น Assert.AreEqual<NativeInt>(3, Length(Arr)) แล้ว compile โปรเจกต์ test ด้วย dcc64 ก่อน commit test suite ที่ build แต่ Win32 จะไม่เคยบอกคุณว่า build ฝั่ง Win64 ของมันพัง จนกว่าใครคนอื่นจะมาลองให้ดู
เช็คลิสต์ port ไป Win64 ของโค้ดตัวเลขใน Delphi
- ค้นหา call
Power(กับIntPower(ที่ได้ argument เป็นจำนวนเต็ม ส่งค่าที่พิมพ์เป็นDoubleหรือสร้างค่ายกกำลังสิบที่จำกัดขอบเอง - รัน test ฝั่งตัวเลขอย่างน้อยหนึ่งรอบโดยถอด
exOverflowกับexInvalidOpออกผ่านSetExceptionMaskบนทั้ง Win32 กับ Win64 - เขียนขอบบนของ
Int64เป็น< 9223372036854775808.0ห้ามใช้<= High(Int64)และปฏิเสธ NaN กับ infinity ก่อนการเปรียบเทียบใด ๆ - อย่าแปลงตัวเลขที่ parse มาเป็น
Int64เฉพาะเพราะFracเป็นศูนย์ ตัวเลขใน JSON ใหญ่กว่านั้นได้ไกล - เขียนใหม่ loop
whileที่อ่านCountซ้ำขณะลบ item เป็น loopfor ... downtoขอบตายตัว - บน FPC Win64 ใช้
Str(Value:24, Text)เมื่อต้องการมากกว่า 15 หลักมีนัยสำคัญ - ใช้
Assert.AreEqual<NativeInt>กับ assert ของLengthกับCountแล้ว compile test ด้วย dcc64 ก่อน commit - หลังแก้ parser หรือ renderer อะไรก็ตาม ให้รัน regression suite เต็มทั้ง Win32 กับ Win64 ไม่ใช่วิ่งแค่ฝั่งใดฝั่งหนึ่ง
การแก้ฝั่ง library ที่เล่าไว้ในนี้ทั้งหมดอยู่ใน HotPDF ตั้งแต่ v2.770.169 การนำเข้า SVG, การแปลง XPS, การ render ความโปร่งใสและการจัดการ JSON job จึงทำงานเหมือนกันบน Win64 เท่ากับบน Win32 แล้ว ถ้าคุณสร้างหรือประมวลผลไฟล์ PDF จาก Delphi หรือ C++Builder สำหรับทั้งสอง platform หน้าHotPDF Delphi PDF componentมีดาวน์โหลดและรายการฟีเจอร์เต็ม