Articolo tecnico

Bug Delphi solo su Win64 trovati irrobustendo HotPDF

Il codice Delphi Win64 può fallire dove la stessa sorgente gira liscia su Win32, e il componente Delphi PDF HotPDF ne ha incontrati cinque durante un recente pass di irrobustimento: Power(10, N) che si lega all'overload Single, un loop while che legge un TList.Count non aggiornato, un limite High(Int64) che si arrotonda a 2^63, testo float a 15 cifre su FPC e assert nei test che smettono di compilare

Nessuno di questi emerge se costruisci e provi solo Win32, ed è esattamente così che sono entrati. I casi qui sotto vengono dagli importer SVG e XPS di HotPDF, dal suo renderer di pagina e dal suo lettore di job JSON, e i risultati numerici citati sono stati riprodotti con piccoli programmi sonda costruiti per Win32 e Win64. Se stai spostando una codebase Delphi a 64 bit, ognuno di questi merita una grep

Perché Power(10, 100) va in overflow solo su Win64?

Su Win64, System.Math.Power(10, N) con argomenti interi si risolve nell'overload Single, quindi il risultato viene calcolato e restituito in singola precisione e qualsiasi cosa sopra circa 3.4E38 va in overflow. Su Win32 la stessa chiamata si lega all'overload Extended e gira sulla FPU x87 con precisione a 80 bit, così Power(10, 100) è semplicemente 1E100

System.Math dichiara Power per Extended, Double e Single, più una famiglia IntPower corrispondente che Power chiama quando l'esponente è un numero intero. Su Win64, Extended è solo un alias di Double (SizeOf(Extended) = 8), e per due argomenti interi il compilatore sceglie la versione Single. A tradire è la precisione, non solo l'overflow: su Win64, Power(10, 20) restituisce 1.0000000200408773E20, che è esattamente Single(1E20). Un risultato Double si stamperebbe come 1E20. Abbiamo visto lo stesso binding con ogni compilatore Win64 che abbiamo provato, da Delphi 10.3 alla versione 37.0 del compilatore

Quello che succede dopo dipende dalla maschera delle eccezioni in virgola mobile. Delphi 12 e successivi mascherano tutte le eccezioni floating-point per default, quindi l'overflow è silenzioso: Power(10, 100) restituisce +Inf e Power(10, -100) restituisce 0. Delphi 11 e precedenti lasciano exOverflow non mascherato, e la stessa chiamata solleva EOverflow. Le applicazioni che impostano la maschera da sole, e le DLL caricate in host del genere, si prendono il comportamento che l'host ha scelto, ed è per questo che una libreria non può dare per scontato nessuno dei due esiti

Trappola numerica Win64 di HotPDF dove System.Math Power con argomenti interi si lega all'overload Single, così Power di 10 alla 20 restituisce 1.0000000200408773E20 invece di 1E20 e Power di 10 alla 100 dà più infinito con le eccezioni mascherate o EOverflow se non lo sono
la perdita di precisione è la traccia: se una potenza di dieci torna con rumore da Single attaccato, ha vinto l'overload sbagliato — costruisciti la scala da te
uses
  System.SysUtils, System.Math;

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

  // Riproduce ciò che fa Delphi 11, o un host con impostazioni FP rigorose
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64: EOverflow; Win32: 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

Togliere la maschera a exOverflow e exInvalidOp per la durata di un test è il modo più economico di vedere ciò che vede un compilatore più vecchio o un host rigoroso. Su un compilatore moderno con impostazioni di default il bug non crasha, produce infiniti e zeri, e quelli sono molto più difficili da individuare in un log di test. Ripristina la maschera precedente in un finally: la maschera è stato per thread, e il resto della sessione di test eredita ciò che lasci dietro

Come l'overload è arrivato nell'import SVG e XPS di HotPDF

I lettori di path SVG e XPS di HotPDF condividono uno scanner di numeri, e quello scanner scalava la mantissa con Power(10, Exponent) una volta letto un esponente. Qualsiasi SVG passato a THotPDF.ImportSVGFormXObject (il punto d'ingresso dietro l'importazione di SVG in PDF come form XObject riutilizzabili), e qualsiasi geometria di path gestita durante la conversione da XPS e OpenXPS a PDF, poteva quindi imboccare a quella chiamata una coordinata come 1e100 o 5e99

La v2.770.91 aveva già limitato l'esponente a 100 e rifiutato i valori che avrebbero superato 1E300, il che sembrava sufficiente: 1E100 non è vicino al limite Double di circa 1.8E308. Su Win64 andava comunque in overflow, perché il calcolo non avveniva mai in Double. Dalla v2.770.155 lo scanner costruisce da sé la potenza di dieci, e numeri come 1e-100, o una mantissa lunga con un esponente negativo grande, vengono letti col loro valore reale invece di collassare a 0

Una potenza di dieci sicura per esponenti limitati

Quando l'esponente è limitato, la potenza di dieci più sicura è una che ti costruisci da te con moltiplicazioni Double. Un loop di al massimo 100 moltiplicazioni non costa nulla accanto alla scansione del testo attorno, non produce mai un intermedio più grande della scala finale, e si comporta identico su Win32, Win64 e 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;
  // Rifiuta i risultati che uscirebbero dall'intervallo 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;       // non supera mai 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // divisione: 1E-100 non ha un Double esatto
  Result := True;
end;

Tre dettagli portano il peso. Il controllo di intervallo usa due confronti invece di Abs(Exponent) <= 100, perché Abs(Low(Integer)) resta negativo e passerebbe liscio. Gli esponenti negativi dividono per la scala invece di moltiplicare per una 1E-100 precalcolata, che non ha un Double esatto e aggiungerebbe un ulteriore passaggio di arrotondamento. E il pre-controllo Log10 rifiuta i risultati fuori dall'intervallo Double prima che la moltiplicazione abbia la possibilità di andare in overflow

Sii chiaro su ciò a cui il loop rinuncia. Le potenze di dieci fino a 1E22 sono esatte in Double; oltre ogni moltiplicazione arrotonda, e dopo 100 di esse la scala resta a qualche unità in ultima posizione dalla 1E100 correttamente arrotondata. Per coordinate di disegno è invisibile. Per una conversione generale da testo a double che deve riprodurre ogni valore bit per bit non basta, e ti serve invece un algoritmo di conversione correttamente arrotondato

Quando dcc64 legge un TList.Count non aggiornato in un loop while

Abbiamo osservato il compilatore Win64 (dcc64, versione 37.0) generare codice per un loop while List.Count > Start do che cancellava dalla fine della lista e confrontava contro un temporaneo sullo stack invece di rileggere Count. La riscrittura che l'ha sistemato è stata un loop for ... downto, i cui limiti vengono valutati esattamente una volta per definizione

Il loop è arrivato nella v2.769.3, che insegnava al codice dei transparency group del renderer a tenere vive le soft mask create dentro un gruppo attraverso un render a due pass e a liberarle dopo. La pulizia stava in un blocco finally dopo un loop for a uno o due pass, dentro il loop per tile. Ridotto alla sua forma, il prima e il dopo sono così:

// La forma che abbiamo visto malcompilata da dcc64 (versione 37.0 del compilatore)
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;

// Sostituzione: i limiti vengono valutati una volta, nessun temporaneo che diventa stantio
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.Count è NativeInt da Delphi 12
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

Nel codice Win64 generato, il Count nella condizione del loop e il Count letto dentro il corpo condividevano uno slot di stack. La condizione confrontava contro quello slot all'ingresso, prima che qualcosa lo avesse scritto, e nulla lo rinfrescava dopo Delete. Quando un gruppo non aveva creato soft mask proprie, il corpo girava comunque e chiedeva a una lista vuota l'elemento -1, così nelle build a 64 bit ogni pagina contenente un transparency group del genere falliva con EListError. Il codice Win32 della stessa sorgente era corretto, e la v2.770.1 ha sostituito il loop

Trappola di codegen Win64 di HotPDF nella pulizia del renderer: un loop while che rilegge TList.Count condivideva uno slot di stack tra condizione e corpo, dcc64 non lo rinfrescava mai dopo Delete, i transparency group vuoti liberavano l'elemento -1 e sollevavano EListError, e la correzione è un loop for downto i cui limiti vengono valutati una volta
la lezione pratica costa meno della causa radice: i loop downto a limiti fissi non possono diventare stanti, e il lavoro sul renderer non è finito finché dcc64 non ha fatto girare la suite

Non l'abbiamo ridotto a una riproduzione minima, e un piccolo loop autonomo come DropMasksWhile potrebbe benissimo compilare correttamente; il try/finally circostante e i loop annidati sembrano contare. Trattatelo come una generazione di codice osservata su una versione del compilatore, non come un difetto noto di ogni compilatore Win64. La lezione pratica costa meno della causa radice: un loop la cui condizione rilegge il conteggio di una collezione mentre il corpo la riduce merita di essere riscritto come un for ... downto a limiti fissi, e le modifiche al renderer richiedono una sessione completa di test Win64, non solo Win32

Localizzare un crash che mostra solo la build Win64 ottimizzata

Il fallimento si riproduceva solo nella build Win64 ottimizzata, quindi la localizzazione è arrivata da strumenti fuori dall'IDE. Un piccolo programma sonda registrava un vectored exception handler con AddVectoredExceptionHandler, catturava lo stack alla prima eccezione con RtlCaptureStackBackTrace, e traduceva gli indirizzi di ritorno in nomi di funzione usando il map file dettagliato che il linker scrive con -GD. Disassemblando quella funzione si vedeva poi il confronto leggere uno slot di stack, [rbp+0x298], che veniva scritto solo dentro il corpo del loop. Questo è il livello di prove che vuoi prima di accusare un compilatore, e ha richiesto meno tempo che fare stepping in una build release

Perché High(Int64) non è un limite superiore sicuro per un Double?

Un Double non può rappresentare High(Int64): convertire 9223372036854775807 in Double arrotonda verso l'alto a esattamente 2^63, uno oltre il più grande Int64. Su Win64 quella conversione avviene dentro il confronto stesso, così D <= High(Int64) è True per D = 2^63, e il Round o il Trunc che segue va in overflow

Win32 nasconde questo per lo stesso motivo per cui nascondeva il problema di Power. Il confronto gira in precisione Extended a 80 bit con mantissa a 64 bit, dove High(Int64) è esatto e 2^63 confronta correttamente come maggiore. Win64 non ha un tipo più largo su cui ricadere. Nemmeno la conversione fuori intervallo è carina: nei nostri test Win64 Round(2^63) restituiva Low(Int64), un ribaltamento di segno silenzioso, che exInvalidOp fosse mascherato o no. Win32 restituisce lo stesso valore quando è mascherato e solleva EInvalidOp quando non lo è

Trappola del limite Int64 di HotPDF: un Double non può rappresentare High(Int64), così un confronto Win64 converte il limite verso l'alto a 2^63, D uguale a 2^63 passa il controllo e Round restituisce in silenzio Low(Int64), mentre Win32 confronta in Extended a 80 bit dove il limite è esatto e lo stesso confronto è False
una conversione è tutto il bug: il limite si arrotonda proprio sul valore che stai escludendo, quindi scrivi il tetto come un literal con un minore stretto
EspressioneWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), eccezioni mascherate (default Delphi 12+)1E100+Inf
Power(10, 100), exOverflow non mascherato1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp non mascheratoEInvalidOpLow(Int64)

HotPDF l'ha incontrato nel lettore JSON dietro i valori dei document job. JSON non mette alcun limite di intervallo sui numeri, e il vecchio serializzatore trasformava qualsiasi valore con Frac(Value) = 0 in un intero con Round, così un 1e19 perfettamente legale diventava o un intero sbagliato o un'eccezione, a seconda della maschera. Dalla v2.770.169 un numero intero viene scritto come intero solo quando sta in un Int64, tutto il resto conserva il proprio testo floating-point, e i getter degli interi restituiscono il default del chiamante per i valori fuori intervallo invece di uno ripiegato

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, esatto in Double e 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
  // I chiamanti rifiutano prima NaN e infiniti: JSON non ha modo di scriverli
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64 ffGeneral si ferma a 15 cifre
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

Il limite superiore è il literal 9223372036854775808.0 con un < stretto. Quella costante è 2^63, esatta sia in Double sia in Extended, quindi il confronto significa la stessa cosa su ogni piattaforma. Il limite inferiore può usare >= perché -2^63 è esattamente Low(Int64). Testare prima IsNan e IsInfinite, con valutazione short-circuit, tiene NaN e infiniti lontani da Frac e dai confronti, che possono sollevare EInvalidOp quando l'host l'ha smascherato

Quante cifre dà davvero la conversione da float a testo su Win64?

Meno di quante ne chiedi, su due compilatori su tre. La FloatToStrF(Value, ffGeneral, 17, 0) di Free Pascal 3.3.1 su Win64 si ferma a 15 cifre significative, così 1/3 torna come 0.333333333333333 e due valori Double diversi possono serializzarsi nello stesso testo. Str(Value:24, Text) seguito da Trim produce 17 cifre significative in notazione scientifica, 3.3333333333333331E-001 per lo stesso valore, e scrive sempre un punto come separatore decimale a prescindere dal locale. Se HotPDF su FPC fa parte della tua build matrix, le note di supporto HotPDF Free Pascal e Lazarus Win64 coprono il resto delle differenze di piattaforma

Delphi accetta la richiesta a 17 cifre, ma i due target Delphi non sono d'accordo sull'output: FloatToStrF(0.1, ffGeneral, 17, 0) dà 0.10000000000000001 su Win32 e 0.1 su Win64. La RTL Win64 può anche introdurre un errore di arrotondamento sull'ultima cifra sia quando formatta sia quando fa il parsing, quindi più cifre restringono il varco senza garantire che ogni pattern di bit di un Double sopravviva a un round trip via testo. La documentazione di HotPDF non fa una promessa del genere, e nemmeno dovrebbe farla la tua finché non spedi un formatter e un parser correttamente arrotondati di tua produzione. Passa TFormatSettings.Invariant, o sostituisci tu il separatore su versioni Delphi più vecchie, così un locale tedesco o francese non scrive una virgola dentro il JSON

Perché Assert.AreEqual smette di compilare su Win64?

Assert.AreEqual(3, Length(Arr)) su un array dinamico compila per Win32 e fallisce per Win64 con E2532, "Couldn't infer generic type argument from different argument types", perché la Length di un array dinamico restituisce NativeInt su Win64. Con un literal Integer da un lato e un NativeInt a 64 bit dall'altro, la Assert.AreEqual<T> generica di DUnitX non riesce a decidere un unico T, e la build si ferma

TList.Count innesca lo stesso errore da Delphi 12, dove la proprietà è diventata NativeInt; Delphi 11 la dichiara ancora come Integer. La Length di una string restituisce Integer su entrambe le piattaforme e non è toccata, ecco perché l'errore appare in alcune unità di test e non in altre. Scrivi l'argomento di tipo esplicitamente, Assert.AreEqual<NativeInt>(3, Length(Arr)), e compila il progetto di test con dcc64 prima di fare commit. Una suite che costruisce solo per Win32 non ti dirà che la sua build Win64 è rotta finché non ci prova qualcun altro

Checklist di porting Win64 per codice numerico Delphi

  • Cerca le chiamate a Power( e IntPower( con argomenti interi; passa valori tipizzati Double o costruisci da te le potenze di dieci limitate
  • Fai girare i test numerici almeno una volta con exOverflow e exInvalidOp rimossi tramite SetExceptionMask, sia su Win32 sia su Win64
  • Scrivi il limite superiore Int64 come < 9223372036854775808.0, mai <= High(Int64), e rifiuta NaN e infiniti prima di qualsiasi confronto
  • Non convertire un numero parseato in Int64 solo perché Frac è 0; i numeri JSON possono essere molto più grandi
  • Riscrivi i loop while che rileggono Count mentre cancellano elementi come loop for ... downto a limiti fissi
  • Su FPC Win64 usa Str(Value:24, Text) quando ti servono più di 15 cifre significative
  • Usa Assert.AreEqual<NativeInt> per gli assert su Length e Count, e compila i test con dcc64 prima di fare commit
  • Dopo qualsiasi modifica a un parser o a un renderer, fai girare la suite completa di regressioni su Win32 e Win64, non solo su una delle due

Le correzioni lato libreria descritte qui sono tutte in HotPDF dalla v2.770.169, così l'import SVG, la conversione XPS, il rendering dei transparency group e la gestione dei job JSON ora si comportano allo stesso modo su Win64 e su Win32. Se generi o elabori file PDF da Delphi o C++Builder per entrambe le piattaforme, la pagina del componente Delphi PDF HotPDF ha i download e la lista completa delle funzionalità