Artikel Teknis

Jebakan Codegen Win64: Power, TList.Count, High(Int64)

Code Delphi Win64 bisa gagal di tempat sumber yang sama berjalan mulus di Win32, dan komponen PDF Delphi HotPDF mengalami lima kasus semacam itu selama pass hardening belakangan: Power(10, N) mengikat ke overload Single, loop while yang membaca TList.Count basi, bound High(Int64) yang terbulatkan naik ke 2^63, teks float 15 digit di FPC, dan assert test yang berhenti ter-compile

Tak satu pun muncul kalau Anda hanya build dan test Win32, dan persis begitulah cara mereka menyelinap masuk. Kasus-kasus di bawah datang dari importer SVG dan XPS milik HotPDF, page renderer-nya, dan pembaca job JSON-nya, dan hasil numerik yang dikutip direproduksi dengan program probe kecil yang dibangun untuk Win32 dan Win64. Kalau Anda sedang memindahkan codebase Delphi ke 64-bit, masing-masing pantas satu grep

Kenapa Power(10, 100) overflow hanya di Win64?

Di Win64, System.Math.Power(10, N) dengan argumen integer me-resolve ke overload Single, sehingga hasilnya dihitung dan dikembalikan dalam single precision dan apa pun di atas kira-kira 3,4E38 overflow. Di Win32 panggilan yang sama mengikat ke overload Extended dan jalan di x87 FPU dengan presisi 80-bit, sehingga Power(10, 100) sekadar 1E100

System.Math mendeklarasikan Power untuk Extended, Double, dan Single, plus keluarga IntPower yang cocok yang dipanggil Power ketika eksponennya bilangan bulat. Di Win64, Extended hanyalah alias untuk Double (SizeOf(Extended) = 8), dan untuk dua argumen integer compiler memilih versi Single. Pemberi infonya presisi, bukan cuma overflow-nya: di Win64, Power(10, 20) mengembalikan 1.0000000200408773E20, yang persis Single(1E20). Hasil Double akan tercetak sebagai 1E20. Kami melihat binding yang sama dengan setiap compiler Win64 yang kami coba, dari Delphi 10.3 sampai versi compiler 37.0

Yang terjadi berikutnya bergantung pada floating-point exception mask. Delphi 12 dan lebih baru mem-mask semua floating-point exception secara default, jadi overflow-nya senyap: Power(10, 100) mengembalikan +Inf dan Power(10, -100) mengembalikan 0. Delphi 11 dan lebih lama membiarkan exOverflow tak termask, dan panggilan yang sama me-raise EOverflow. Aplikasi yang menyetel mask-nya sendiri, dan DLL yang dimuat ke host semacam itu, mendapat perilaku apa pun yang dipilih host, itulah kenapa sebuah library tak bisa mengasumsikan salah satu hasil

Jebakan numerik Win64 HotPDF di mana System.Math Power dengan argumen integer mengikat ke overload Single, sehingga Power 10 pangkat 20 mengembalikan 1.0000000200408773E20 alih-alih 1E20 dan Power 10 pangkat 100 menghasilkan plus infinity ketika exception termask atau EOverflow ketika tidak
Kehilangan presisinya adalah pemberi infonya: kalau pangkat sepuluh kembali dengan derau Single menempel, overload yang salah yang menang — bangun skalanya sendiri
uses
  System.SysUtils, System.Math;

procedure ShowPowerOverload;
var
  N: Integer;
  OldMask: TArithmeticExceptionMask;
begin
  N := 20;
  // Win32 mencetak 1E20; Win64 mencetak 1.0000000200408773E20 (overload Single)
  Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));

  // Reproduksi apa yang dikerjakan Delphi 11, atau host dengan setting FP strict
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

Membuka mask exOverflow dan exInvalidOp selama durasi sebuah test adalah cara termurah untuk melihat apa yang dilihat compiler lebih tua atau host yang strict. Di compiler modern dengan setting default, bug ini tak crash, ia menghasilkan infinity dan nol, dan keduanya jauh lebih sulit ditangkap di log test. Kembalikan mask sebelumnya di finally: mask itu state per-thread, dan sisa test run mewarisi apa pun yang Anda tinggalkan

Bagaimana overload itu sampai ke import SVG dan XPS HotPDF

Pembaca path SVG dan XPS milik HotPDF berbagi satu number scanner, dan scanner itu menskalakan mantissa dengan Power(10, Exponent) begitu ia membaca sebuah eksponen. SVG apa pun yang diteruskan ke THotPDF.ImportSVGFormXObject (entry point di balik mengimpor SVG ke PDF sebagai form XObject reusable), dan geometri path apa pun yang ditangani selama konversi XPS dan OpenXPS ke PDF, karenanya bisa menyuntikkan koordinat seperti 1e100 atau 5e99 ke panggilan itu

v2.770.91 sudah membatasi eksponen pada 100 dan menolak nilai yang akan melewati 1E300, yang tampaknya cukup: 1E100 sama sekali tidak dekat dengan limit Double sekitar 1,8E308. Di Win64 ia tetap overflow, karena penghitungannya tak pernah terjadi di Double sama sekali. Sejak v2.770.155 scanner membangun pangkat sepuluhnya sendiri, dan angka seperti 1e-100, atau mantissa panjang dengan eksponen negatif besar, terbaca sebagai nilai aslinya alih-alih menciut ke 0

Pangkat sepuluh yang aman untuk eksponen terbatas

Ketika eksponennya terbatas, pangkat sepuluh yang paling aman adalah yang Anda bangun sendiri dengan perkalian Double. Loop paling banyak 100 perkalian tidak berbiaya dibanding memindai teks di sekitarnya, ia tak pernah menghasilkan intermediate yang lebih besar dari skala finalnya, dan perilakunya identik di Win32, Win64, dan 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;
  // Tolak hasil yang akan keluar dari range 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;       // tak pernah melebihi 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // bagi: 1E-100 tak punya Double eksak
  Result := True;
end;

Tiga detail yang memikul bebannya. Range check memakai dua perbandingan alih-alih Abs(Exponent) <= 100, karena Abs(Low(Integer)) tetap negatif dan akan berlayar lurus lolos. Eksponen negatif membagi dengan skala alih-alih mengalikan dengan 1E-100 yang dihitung sebelumnya, yang tak punya Double eksak dan akan menambah satu langkah pembulatan lagi. Dan pre-check Log10 menolak hasil di luar range Double sebelum perkalian sempat overflow

Jelaslah soal apa yang dikorbankan loop itu. Pangkat sepuluh sampai 1E22 eksak di Double; melewatinya setiap perkalian membulat, dan setelah 100 kali skala itu duduk beberapa unit in the last place dari 1E100 yang correctly rounded. Untuk koordinat menggambar, itu tak terlihat. Untuk konversi teks-ke-double serba guna yang harus mereproduksi setiap nilai bit demi bit, itu tak cukup baik, dan Anda butuh algoritma konversi yang correctly rounded sebagai gantinya

Ketika dcc64 membaca TList.Count basi di loop while

Kami mengamati compiler Win64 (dcc64, versi compiler 37.0) menghasilkan code untuk loop while List.Count > Start do yang menghapus dari ujung list dan membandingkan terhadap temporary di stack alih-alih membaca ulang Count. Rewrite yang memperbaikinya adalah loop for ... downto, yang bounds-nya menurut definisi dievaluasi tepat sekali

Loop itu datang di v2.769.3, yang mengajari code transparency-group milik renderer untuk menjaga soft mask yang diciptakan di dalam sebuah group tetap hidup melintasi render dua-pass dan mem-free-nya setelahnya. Pembersihannya duduk di blok finally setelah loop for satu atau dua pass, di dalam loop per-tile. Direduksi ke bentuknya, sebelum dan sesudahnya tampak seperti ini:

// Bentuk yang kami lihat ter-miscompile oleh dcc64 (versi compiler 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;

// Penggantinya: bounds dievaluasi sekali, tak ada temporary yang basi
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.Count adalah NativeInt sejak Delphi 12
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

Di code Win64 yang dihasilkan, Count di kondisi loop dan Count yang dibaca di dalam badan berbagi satu slot stack. Kondisinya membandingkan terhadap slot itu saat masuk, sebelum apa pun menulisnya, dan tak ada yang me-refresh-nya setelah Delete. Ketika sebuah group tak menciptakan soft mask miliknya sendiri, badan tetap berjalan dan meminta item -1 dari list kosong, sehingga di build 64-bit setiap halaman yang memuat transparency group semacam itu gagal dengan EListError. Code Win32 untuk sumber yang sama benar, dan v2.770.1 mengganti loop itu

Jebakan codegen Win64 HotPDF di pembersih renderer: loop while yang membaca ulang TList.Count berbagi satu slot stack antara kondisi dan badan, dcc64 tak pernah me-refresh-nya setelah Delete, transparency group kosong mem-free item -1 dan me-raise EListError, dan perbaikannya adalah loop for downto yang bounds-nya dievaluasi sekali
Pelajaran praktisnya lebih murah dari akar masalahnya: loop downto ber-bound tetap tak bisa basi, dan kerja renderer belum selesai sampai dcc64 menjalankan suite-nya

Kami belum mereduksinya menjadi reproduksi minimal, dan loop kecil standalone seperti DropMasksWhile bisa saja ter-compile dengan benar; try/finally di sekelilingnya dan loop bersarang tampaknya berpengaruh. Perlakukan ini sebagai code generation yang kami amati pada satu versi compiler, bukan sebagai cacat yang diketahui dari setiap compiler Win64. Pelajaran praktisnya lebih murah dari akar masalahnya: loop yang kondisinya membaca ulang count koleksi sementara badannya mengecilkan koleksi itu pantas ditulis ulang sebagai for ... downto ber-bound tetap, dan perubahan renderer butuh test run Win64 penuh, bukan cuma Win32

Menemukan crash yang hanya ditunjukkan build Win64 teroptimasi

Kegagalannya hanya tereproduksi di build Win64 teroptimasi, jadi lokasinya datang dari tool di luar IDE. Program probe kecil mendaftarkan vectored exception handler dengan AddVectoredExceptionHandler, menangkap stack pada exception pertama dengan RtlCaptureStackBackTrace, dan menerjemahkan return address menjadi nama fungsi memakai map file detail yang ditulis linker dengan -GD. Mendisassembly fungsi itu lalu menunjukkan compare membaca satu slot stack, [rbp+0x298], yang hanya pernah ditulis di dalam badan loop. Itulah level bukti yang Anda mau sebelum menyalahkan sebuah compiler, dan waktunya lebih singkat daripada stepping through build release

Kenapa High(Int64) bukan upper bound yang aman untuk Double?

Sebuah Double tak bisa merepresentasikan High(Int64): mengonversi 9223372036854775807 ke Double membulatkan naik ke persis 2^63, satu di atas Int64 terbesar. Di Win64 konversi itu terjadi di dalam perbandingannya sendiri, sehingga D <= High(Int64) bernilai True untuk D = 2^63, dan Round atau Trunc yang menyusulnya overflow

Win32 menyembunyikan ini karena alasan yang sama seperti menyembunyikan masalah Power. Perbandingannya berjalan dalam presisi Extended 80-bit dengan mantissa 64-bit, tempat High(Int64) eksak dan 2^63 dengan benar terbanding lebih besar. Win64 tak punya tipe lebih lebar untuk berlindung. Konversi out-of-range-nya juga tak elok: di test Win64 kami, Round(2^63) mengembalikan Low(Int64), pembalikan tanda yang senyap, entah exInvalidOp termask atau tidak. Win32 mengembalikan nilai yang sama ketika termask dan me-raise EInvalidOp ketika tak termask

Jebakan bound Int64 HotPDF: sebuah Double tak bisa merepresentasikan High(Int64), sehingga perbandingan Win64 mengonversi limit naik ke 2^63, D sama dengan 2^63 lolos pengecekan dan Round diam-diam mengembalikan Low(Int64), sementara Win32 membandingkan di Extended 80-bit tempat bound itu eksak dan perbandingan yang sama False
Satu konversi adalah seluruh bug-nya: bound itu terbulatkan tepat ke nilai yang sedang Anda eksklusi, jadi tulis ceiling-nya sebagai literal dengan less-than yang ketat
EkspresiWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), exception termask (default Delphi 12+)1E100+Inf
Power(10, 100), exOverflow tak termask1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp tak termaskEInvalidOpLow(Int64)

HotPDF bertemu ini di pembaca JSON di balik nilai job dokumennya. JSON tak menaruh limit range pada angka, dan serializer lama mengubah nilai apa pun dengan Frac(Value) = 0 menjadi integer dengan Round, sehingga 1e19 yang sepenuhnya legal menjadi integer yang salah atau exception, tergantung mask-nya. Sejak v2.770.169 bilangan bulat ditulis sebagai integer hanya ketika muat di Int64, sisanya mempertahankan teks floating-point-nya, dan getter integer mengembalikan default milik caller untuk nilai out-of-range alih-alih yang ter-wrap

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, eksak di Double dan 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 menolak NaN dan infinity lebih dulu: JSON tak punya ejaan untuknya
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral berhenti di 15 digit
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

Bound atasnya adalah literal 9223372036854775808.0 dengan < yang ketat. Konstanta itu 2^63, eksak di Double maupun Extended, jadi perbandingannya bermakna sama di setiap platform. Bound bawahnya boleh memakai >= karena -2^63 persis Low(Int64). Mengujikan IsNan dan IsInfinite lebih dulu, dengan evaluasi short-circuit, menjauhkan NaN dan infinity dari Frac dan perbandingannya, yang bisa me-raise EInvalidOp ketika host membuka mask-nya

Berapa digit yang benar-benar diberikan float-ke-teks di Win64?

Lebih sedikit dari yang Anda minta, pada dua compiler dari tiga. FloatToStrF(Value, ffGeneral, 17, 0) milik Free Pascal 3.3.1 di Win64 berhenti di 15 digit signifikan, sehingga 1/3 kembali sebagai 0.333333333333333 dan dua nilai Double berbeda bisa terserialisasi menjadi teks identik. Str(Value:24, Text) disusul Trim menghasilkan 17 digit signifikan dalam notasi ilmiah, 3.3333333333333331E-001 untuk nilai yang sama, dan selalu menulis titik sebagai pemisah desimal apa pun locale-nya. Kalau HotPDF di FPC bagian dari build matrix Anda, catatan dukungan HotPDF Free Pascal dan Lazarus Win64 membahas sisa perbedaan platformnya

Delphi menerima permintaan 17 digit itu, tapi kedua target Delphi tetap berselisih soal output: FloatToStrF(0.1, ffGeneral, 17, 0) memberi 0.10000000000000001 di Win32 dan 0.1 di Win64. RTL Win64 juga bisa memperkenalkan error pembulatan digit terakhir baik saat memformat maupun saat mem-parse, sehingga makin banyak digit mengecilkan celah tanpa menjamin setiap bit pattern Double selamat dari round trip teks. Dokumentasi HotPDF tak menjanjikan itu, dan milik Anda juga sebaiknya tidak, kecuali Anda mengirim formatter dan parser yang correctly rounded milik sendiri. Teruskan TFormatSettings.Invariant, atau ganti sendiri pemisahnya di versi Delphi lebih tua, agar locale Jerman atau Prancis tak menulis koma ke dalam JSON

Kenapa Assert.AreEqual berhenti ter-compile di Win64?

Assert.AreEqual(3, Length(Arr)) pada dynamic array ter-compile untuk Win32 dan gagal untuk Win64 dengan E2532, "Couldn't infer generic type argument from different argument types", karena Length dari dynamic array mengembalikan NativeInt di Win64. Dengan literal Integer di satu sisi dan NativeInt 64-bit di sisi lain, Assert.AreEqual<T> generik milik DUnitX tak bisa memilih satu T, dan build-nya berhenti

TList.Count memicu error yang sama sejak Delphi 12, tempat property itu menjadi NativeInt; Delphi 11 masih mendeklarasikannya sebagai Integer. Length dari string mengembalikan Integer di kedua platform dan tak terpengaruh, itulah kenapa error itu muncul di beberapa unit test dan tidak di lainnya. Tulis argumen tipenya secara eksplisit, Assert.AreEqual<NativeInt>(3, Length(Arr)), dan compile project test dengan dcc64 sebelum commit. Suite yang hanya pernah dibangun untuk Win32 tak akan memberi tahu bahwa build Win64-nya rusak sampai orang lain mencobanya

Checklist porting Win64 untuk code numerik Delphi

  • Cari panggilan Power( dan IntPower( dengan argumen integer; teruskan nilai bertipe Double atau bangun sendiri pangkat sepuluh yang terbatas
  • Jalankan test numerik setidaknya sekali dengan exOverflow dan exInvalidOp dibuang lewat SetExceptionMask, di Win32 dan Win64 sekaligus
  • Tulis bound atas Int64 sebagai < 9223372036854775808.0, bukan pernah <= High(Int64), dan tolak NaN serta infinity sebelum perbandingan apa pun
  • Jangan konversikan angka hasil parse ke Int64 hanya karena Frac-nya 0; angka JSON bisa jauh lebih besar
  • Tulis ulang loop while yang membaca ulang Count sambil menghapus item menjadi loop for ... downto ber-bound tetap
  • Di FPC Win64, pakai Str(Value:24, Text) ketika Anda butuh lebih dari 15 digit signifikan
  • Pakai Assert.AreEqual<NativeInt> untuk assert Length dan Count, dan compile test dengan dcc64 sebelum commit
  • Setelah perubahan apa pun ke parser atau renderer, jalankan regression suite penuh di Win32 dan Win64, bukan cuma salah satunya

Perbaikan sisi library yang dijelaskan di sini semuanya sudah ada di HotPDF sejak v2.770.169, sehingga import SVG, konversi XPS, rendering transparency, dan penanganan job JSON kini berperilaku sama di Win64 seperti di Win32. Kalau Anda men-generate atau memproses file PDF dari Delphi atau C++Builder untuk kedua platform, halaman komponen PDF Delphi HotPDF menyediakan unduhan dan daftar fitur lengkapnya