Articol tehnic

Bug-uri Delphi exclusiv Win64, găsite la întărirea HotPDF

Codul Delphi Win64 poate eșua acolo unde aceeași sursă rulează curat pe Win32, iar componenta HotPDF Delphi PDF a lovit cinci asemenea cazuri în timpul unei treceri recente de întărire: Power(10, N) legat la overload-ul Single, o buclă while care citește un TList.Count învechit, o margine High(Int64) care rotunjește în sus la 2^63, text float de 15 cifre pe FPC și asserții de test care încetează să se compileze

Niciunul nu apare dacă doar construiți și testați Win32, exact felul în care au scăpat înăuntru. Cazurile de mai jos vin din importatoarele SVG și XPS ale HotPDF, din renderer-ul lui de pagini și din cititorul lui de joburi JSON, iar rezultatele numerice citate au fost reproduse cu mici programe sondă construite pentru Win32 și Win64. Dacă mutați o bază de cod Delphi pe 64 de biți, fiecare merită un grep

De ce dă Power(10, 100) overflow doar pe Win64?

Pe Win64, System.Math.Power(10, N) cu argumente întregi se rezolvă la overload-ul Single, deci rezultatul e calculat și întors în precizie single și orice peste aproximativ 3.4E38 dă overflow. Pe Win32 același apel se leagă la overload-ul Extended și rulează pe FPU x87 cu precizie pe 80 de biți, deci Power(10, 100) e pur și simplu 1E100

System.Math declară Power pentru Extended, Double și Single, plus o familie IntPower potrivită pe care Power o apelează când exponentul e număr întreg. Pe Win64, Extended e doar un alias pentru Double (SizeOf(Extended) = 8), iar pentru două argumente întregi compilatorul alege versiunea Single. Trădarea e precizia, nu doar overflow-ul: pe Win64, Power(10, 20) întoarce 1.0000000200408773E20, care e exact Single(1E20). Un rezultat Double s-ar tipări ca 1E20. Am văzut aceeași legare cu fiecare compilator Win64 pe care l-am încercat, de la Delphi 10.3 până la versiunea de compilator 37.0

Ce se întâmplă apoi depinde de masca de excepții în virgulă mobilă. Delphi 12 și mai nou mască toate excepțiile FP implicit, deci overflow-ul e tăcut: Power(10, 100) întoarce +Inf, iar Power(10, -100) întoarce 0. Delphi 11 și mai vechi lasă exOverflow nemascat, iar același apel ridică EOverflow. Aplicațiile care setează masca singure, iar DLL-urile încărcate în asemenea gazde, primesc comportamentul pe care l-a ales gazda, motiv pentru care o bibliotecă nu poate presupune niciunul dintre rezultate

Capcana numerică Win64 din HotPDF, unde System.Math Power cu argumente întregi se leagă la overload-ul Single, astfel încât Power din 10 la puterea 20 întoarce 1.0000000200408773E20 în loc de 1E20, iar Power din 10 la puterea 100 dă plus infinit când excepțiile sunt mascate sau EOverflow când nu sunt
pierderea de precizie e semnul: dacă o putere a lui 10 revine cu zgomot de Single atașat, overload-ul greșit a câștigat — construiți scara singuri
uses
  System.SysUtils, System.Math;

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

  // Reproduce ce face Delphi 11, sau un host cu setări FP stricte
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

Demascarea lui exOverflow și exInvalidOp pe durata unui test e calea cea mai ieftină de a vedea ce vede un compilator mai vechi sau o gazdă strictă. Pe un compilator modern cu setările implicite bug-ul nu se blochează, ci produce infinități și zerouri, iar acelea sunt mult mai greu de observat într-un log de test. Restaurați masca anterioară în finally: masca e stare per thread, iar restul rulării de test moștenește ce lăsați în urmă

Cum a ajuns overload-ul în importul SVG și XPS din HotPDF

Cititoarele de căi SVG și XPS din HotPDF partajează un singur scanner de numere, iar scannerul acela scala mantisa cu Power(10, Exponent) odată ce citise un exponent. Orice SVG pasat către THotPDF.ImportSVGFormXObject (punctul de intrare din spatele importului SVG în PDF ca form XObject-uri refolosibile) și orice geometrie de cale tratată în timpul conversiei XPS și OpenXPS în PDF puteau deci hrăni apelului acela o coordonată precum 1e100 sau 5e99

v2.770.91 plafonase deja exponentul la 100 și respinsese valorile care ar trece de 1E300, ceea ce părea de ajuns: 1E100 e oriunde altundeva decât lângă limita Double de aproximativ 1.8E308. Pe Win64 dădea totuși overflow, pentru că calculul nu se întâmpla deloc în Double. Din v2.770.155 scanner-ul construiește puterea lui 10 singur, iar numere precum 1e-100, sau o mantisă lungă cu exponent mare negativ, se citesc la valoarea lor reală în loc să se colapseze în 0

O putere sigură a lui 10 pentru exponenți mărginiți

Când exponentul e mărginit, cea mai sigură putere a lui 10 e una pe care o construiți singuri cu înmulțiri Double. O buclă de cel mult 100 de înmulțiri nu costă nimic pe lângă scanarea textului din jur, nu produce niciodată un intermediar mai mare decât scara finală și se poartă identic pe Win32, Win64 și 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;
  // Respinge rezultatele care ar părăsi intervalul 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;       // nu depășește niciodată 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // împarte: 1E-100 n-are Double exact
  Result := True;
end;

Trei detalii cară greutatea. Verificarea de interval folosește două comparații în loc de Abs(Exponent) <= 100, pentru că Abs(Low(Integer)) e încă negativ și ar trece direct prin ea. Exponenții negativi împart la scară în loc să înmulțească cu un 1E-100 precalculat, care n-are un Double exact și ar adăuga încă un pas de rotunjire. Iar pre-verificarea cu Log10 refuză rezultatele din afara intervalului Double înainte ca înmulțirea să aibă șansa de a da overflow

Fii clar la ce renunță bucla. Puterile lui 10 până la 1E22 sunt exacte în Double; dincolo de asta fiecare înmulțire rotunjește, iar după 100 dintre ele scara stă la câteva unități din ultima poziție față de 1E100 corect rotunjit. Pentru coordonate de desenare asta e invizibil. Pentru o conversie generală de la text la double care trebuie să reproducă fiecare valoare bit cu bit, nu e de ajuns, și aveți nevoie de un algoritm de conversie corect rotunjit în schimb

Când dcc64 citește un TList.Count învechit într-o buclă while

Am observat compilatorul Win64 (dcc64, versiune de compilator 37.0) genera cod pentru o buclă while List.Count > Start do care ștergea de la capătul listei și compara contra unui temporar pe stack în loc să recitească Count. Rescrierea care l-a reparat a fost o buclă for ... downto, ale cărei margini sunt evaluate prin definiție exact o dată

Bucla a ajuns în v2.769.3, care a învățat codul de transparency-group al renderer-ului să țină în viață soft mask-urile create în interiorul unui grup peste o randare în două treceri și să le elibereze după. Curățarea stătea într-un bloc finally după o buclă for de una sau două treceri, în interiorul buclei per-tile. Redusă la forma ei, înainte și după arată așa:

// Forma pe care am văzut-o compilată greșit de dcc64 (versiune compilator 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;

// Înlocuire: marginile sunt evaluate o dată, niciun temporar care să îmbătrânească
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.Count e NativeInt din Delphi 12
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

În codul Win64 generat, Count-ul din condiția buclei și Count-ul citit în interiorul corpului partajau un singur slot de stack. Condiția compara contra slotului aceluia la intrare, înainte ca ceva să-l fi scris, și nimic nu-l reîmprospăta după Delete. Când un grup nu crease niciun soft mask propriu, corpul rula oricum și cerea unei liste goale elementul -1, deci în build-urile pe 64 de biți fiecare pagină care conținea un asemenea transparency group eșua cu EListError. Codul Win32 pentru aceeași sursă era corect, iar v2.770.1 a înlocuit bucla

Capcana de codegen Win64 în curățarea renderer-ului din HotPDF: o buclă while care recitea TList.Count partaja un singur slot de stack între condiție și corp, dcc64 nu-l reîmprospăta niciodată după Delete, grupurile de transparență goale eliberau elementul -1 și ridicau EListError, iar repararea e o buclă for downto ale cărei margini sunt evaluate o dată
lecția practică costă mai puțin decât cauza rădăcină: buclele downto cu margini fixe nu pot îmbătrâni, iar munca la renderer nu e gata până când dcc64 nu rulează suita

Nu am redus asta la o reproducere minimală, iar o buclă mică de sine stătătoare precum DropMasksWhile s-ar putea foarte bine să se compileze corect; try/finally-ul din jur și buclele imbricate par să conteze. Tratați-o ca generare de cod observată pe o singură versiune de compilator, nu ca pe un defect cunoscut al oricărui compilator Win64. Lecția practică e mai ieftină decât cauza rădăcină: o buclă a cărei condiție recitește numărul unei colecții în timp ce corpul o micșorează merită rescrisă ca for ... downto cu margini fixe, iar schimbările la renderer au nevoie de o rulare completă de teste Win64, nu doar Win32

Localizarea unei blocări pe care o arată doar un build Win64 optimizat

Eșecul se reproducea doar în build-ul Win64 optimizat, deci locația a venit din unelte din afara IDE. Un mic program sondă a înregistrat un handler de excepții vectored cu AddVectoredExceptionHandler, a capturat stack-ul la prima excepție cu RtlCaptureStackBackTrace și a tradus adresele de retur în nume de funcții folosind fișierul de mapă detaliat pe care linker-ul îl scrie cu -GD. Dezasamblarea funcției aceleia a arătat apoi comparația citind un slot de stack, [rbp+0x298], scris doar în interiorul corpului buclei. Asta e nivelul de evidență pe care îl vreți înainte să dați vina pe un compilator, și a luat mai puțin timp decât pas cu pas printr-un build de release

De ce High(Int64) nu e o margine superioară sigură pentru un Double?

Un Double nu poate reprezenta High(Int64): convertirea lui 9223372036854775807 în Double rotunjește în sus la exact 2^63, unul peste cel mai mare Int64. Pe Win64 conversia aceea se întâmplă în interiorul comparației în sine, deci D <= High(Int64) e True pentru D = 2^63, iar Round-ul sau Trunc-ul care urmează dă overflow

Win32 ascunde asta din același motiv pentru care a ascuns problema Power. Comparația rulează în precizie Extended pe 80 de biți, cu mantisă pe 64 de biți, unde High(Int64) e exact, iar 2^63 compară corect ca mai mare. Win64 nu are un tip mai lat la care să cadă înapoi. Conversia din afara intervalului nu e nici ea drăguță: în testele noastre Win64 Round(2^63) întorcea Low(Int64), o inversare de semn tăcută, indiferent dacă exInvalidOp era mascat sau nu. Win32 întoarce aceeași valoare când e mascat și ridică EInvalidOp când e nemascat

Capcana mărginii Int64 din HotPDF: un Double nu poate reprezenta High(Int64), deci o comparație Win64 convertește limita în sus la 2^63, D egal cu 2^63 trece verificarea, iar Round întoarce în tăcere Low(Int64), în timp ce Win32 compară în Extended pe 80 de biți unde marginea e exactă și aceeași comparație e False
o singură conversie e tot bug-ul: marginea rotunjește exact pe valoarea pe care o excluzi, deci scrie plafonul ca literal cu mai mic strict
ExpresieWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), excepții mascate (implicit Delphi 12+)1E100+Inf
Power(10, 100), exOverflow nemascat1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp nemascatEInvalidOpLow(Int64)

HotPDF a întâlnit asta în cititorul JSON din spatele valorilor de job de document ale lui. JSON nu pune nicio limită de interval pe numere, iar vechiul serializer transforma orice valoare cu Frac(Value) = 0 într-un întreg cu Round, deci un 1e19 perfect legal devenea ori un întreg greșit, ori o excepție, în funcție de mască. Din v2.770.169 un număr întreg e scris ca întreg doar când încap în Int64, tot restul își păstrează textul în virgulă mobilă, iar getter-ele de întreg întorc implicitul apelantului pentru valorile din afara intervalului, în locul unuia cu wrap

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, exact în Double și 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
  // Apelanții resping întâi NaN și infinitățile: JSON nu are ortografie pentru ele
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral se oprește la 15 cifre
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

Marginea superioară e literalul 9223372036854775808.0 cu un < strict. Constanta aceea e 2^63, exactă și în Double, și în Extended, deci comparația înseamnă același lucru pe orice platformă. Marginea inferioară poate folosi >= pentru că -2^63 e exact Low(Int64). Testarea lui IsNan și IsInfinite mai întâi, cu evaluare short-circuit, ține NaN-ul și infinitățile departe de Frac și de comparații, care pot ridica EInvalidOp când gazda l-a demascat

Câte cifre îți dă de fapt float-spre-text pe Win64?

Mai puține decât ceri, la două compilatoare din trei. FloatToStrF(Value, ffGeneral, 17, 0) din Free Pascal 3.3.1 pe Win64 se oprește la 15 cifre semnificative, deci 1/3 revine ca 0.333333333333333 și două valori Double diferite se pot serializa în text identic. Str(Value:24, Text) urmat de Trim produce 17 cifre semnificative în notație științifică, 3.3333333333333331E-001 pentru aceeași valoare, și scrie mereu un punct ca separator zecimal, indiferent de locale. Dacă HotPDF pe FPC face parte din matricea de build, notele de suport HotPDF Free Pascal și Lazarus Win64 acoperă restul diferențelor de platformă

Delphi acceptă cererea de 17 cifre, dar cele două ținte Delphi încă dezacordă la output: FloatToStrF(0.1, ffGeneral, 17, 0) dă 0.10000000000000001 pe Win32 și 0.1 pe Win64. RTL-ul Win64 poate introduce și el o eroare de rotunjire a ultimei cifre atât la formatare, cât și la parsare, deci mai multe cifre strâng decalajul fără să garanteze că fiecare tipar de biți Double supraviețuiește unui dus-întors prin text. Documentația HotPDF nu face o asemenea promisiune, iar a dvs. nu ar trebui să o facă, decât dacă livrați un formatter și un parser corect rotunjite ale dvs. Pasați TFormatSettings.Invariant sau înlocuiți separatorul singuri pe versiunile Delphi mai vechi, astfel încât un locale german sau francez să nu scrie o virgulă în JSON

De ce Assert.AreEqual încetează să se compileze pe Win64?

Assert.AreEqual(3, Length(Arr)) pe un tablou dinamic compilează pentru Win32 și eșuează pentru Win64 cu E2532, „Couldn't infer generic type argument from different argument types”, pentru că Length al unui tablou dinamic întoarce NativeInt pe Win64. Cu un literal Integer de o parte și un NativeInt pe 64 de biți de cealaltă, Assert.AreEqual<T>-ul generic din DUnitX nu se poate hotărî pe un singur T, iar build-ul se oprește

TList.Count declanșează aceeași eroare din Delphi 12, unde proprietatea a devenit NativeInt; Delphi 11 o declară încă ca Integer. Length al unui string întoarce Integer pe ambele platforme și nu e afectat, motiv pentru care eroarea apare în unele unități de test și nu în altele. Scrieți argumentul de tip explicit, Assert.AreEqual<NativeInt>(3, Length(Arr)), și compilați proiectul de teste cu dcc64 înainte de comitere. O suită care construiește doar pentru Win32 nu vă va spune că build-ul ei Win64 e stricat până când nu încearcă altcineva

Lista de verificare de portare Win64 pentru codul numeric Delphi

  • Căutați apeluri Power( și IntPower( cu argumente întregi; pasați valori de tip Double sau construiți singuri puteri mărginite ale lui 10
  • Rulați testele numerice cel puțin o dată cu exOverflow și exInvalidOp eliminate prin SetExceptionMask, atât pe Win32, cât și pe Win64
  • Scrieți marginea superioară Int64 ca < 9223372036854775808.0, niciodată <= High(Int64) și respingeți NaN și infinitățile înaintea oricărei comparații
  • Nu convertiți un număr parsat în Int64 doar pentru că Frac e 0; numerele JSON pot fi mult mai mari
  • Rescrieți buclele while care recitesc Count în timp ce șterg elemente ca bucle for ... downto cu margini fixe
  • Pe FPC Win64, folosiți Str(Value:24, Text) când aveți nevoie de mai mult de 15 cifre semnificative
  • Folosiți Assert.AreEqual<NativeInt> pentru asserții pe Length și Count și compilați testele cu dcc64 înainte de comitere
  • După orice schimbare la un parser sau la un renderer, rulați suita completă de regresie pe Win32 și Win64, nu doar pe una dintre ele

Reparările din partea bibliotecii descrise aici sunt toate în HotPDF din v2.770.169, deci importul SVG, conversia XPS, randarea transparenței și tratarea joburilor JSON se poartă acum la fel pe Win64 ca pe Win32. Dacă generați sau procesați fișiere PDF din Delphi sau C++Builder pentru ambele platforme, pagina componentei HotPDF Delphi PDF are descărcările și lista completă de funcții