Article technique

Bugs Delphi propres à Win64 trouvés en durcissant HotPDF

Le code Delphi Win64 peut échouer là où la même source tourne proprement en Win32, et le composant HotPDF Delphi PDF est tombé sur cinq cas de ce genre pendant une passe de durcissement récente : Power(10, N) lié à la surcharge Single, une boucle while lisant un TList.Count périmé, une borne High(Int64) qui arrondit à 2^63, du texte flottant à 15 chiffres sur FPC, et des asserts de test qui refusent de compiler

Rien de tout ça n’apparaît si vous ne construisez et ne testez qu’en Win32, et c’est exactement ainsi qu’ils se sont glissés dedans. Les cas ci-dessous viennent des importeurs SVG et XPS de HotPDF, de son moteur de rendu de pages et de son lecteur de jobs JSON, et les résultats numériques cités ont été reproduits avec de petits programmes sondes construits pour Win32 et Win64. Si vous migrez une base de code Delphi vers le 64 bits, chacun mérite un grep

Pourquoi Power(10, 100) déborde-t-il seulement en Win64 ?

En Win64, System.Math.Power(10, N) avec des arguments entiers se résout vers la surcharge Single, si bien que le résultat est calculé et renvoyé en simple précision et que tout ce qui dépasse environ 3.4E38 déborde. En Win32, le même appel se lie à la surcharge Extended et tourne sur la FPU x87 à 80 bits de précision, donc Power(10, 100) fait simplement 1E100

System.Math déclare Power pour Extended, Double et Single, plus une famille IntPower assortie que Power appelle quand l’exposant est un nombre entier. En Win64, Extended n’est qu’un alias de Double (SizeOf(Extended) = 8), et pour deux arguments entiers le compilateur choisit la version Single. L’indice, c’est la précision, pas seulement le débordement : en Win64, Power(10, 20) renvoie 1.0000000200408773E20, qui est exactement Single(1E20). Un résultat Double s’imprimerait 1E20. Nous avons vu la même liaison avec chaque compilateur Win64 essayé, de Delphi 10.3 à la version de compilateur 37.0

La suite dépend du masque d’exceptions en virgule flottante. Delphi 12 et ultérieures masquent toutes les exceptions flottantes par défaut, si bien que le débordement est silencieux : Power(10, 100) renvoie +Inf et Power(10, -100) renvoie 0. Delphi 11 et antérieures laissent exOverflow non masqué, et le même appel lève EOverflow. Les applications qui règlent le masque elles-mêmes, et les DLL chargées dans de tels hôtes, obtiennent le comportement que l’hôte a choisi, voilà pourquoi une bibliothèque ne peut supposer aucun des deux résultats

Piège numérique Win64 HotPDF où System.Math Power avec arguments entiers se lie à la surcharge Single, si bien que 10 puissance 20 renvoie 1.0000000200408773E20 au lieu de 1E20 et que 10 puissance 100 donne plus l’infini quand les exceptions sont masquées ou EOverflow sinon
la perte de précision est l’indice : si une puissance de dix revient avec du bruit Single accroché, la mauvaise surcharge a gagné — construisez l’échelle vous-même
uses
  System.SysUtils, System.Math;

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

  // Reproduit ce que fait Delphi 11, ou un hôte aux réglages FP stricts
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64 : EOverflow ; Win32 : 1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

Démasquer exOverflow et exInvalidOp pendant la durée d’un test est la façon la moins chère de voir ce que voit un compilateur plus ancien ou un hôte strict. Sur un compilateur moderne aux réglages par défaut, le bug ne plante pas, il produit des infinis et des zéros, et c’est bien plus difficile à repérer dans un journal de test. Restaurez le masque précédent dans un finally : le masque est un état par thread, et le reste de la campagne de tests hérite de ce que vous laissez derrière vous

Comment la surcharge est arrivée dans l’import SVG et XPS de HotPDF

Les lecteurs de chemins SVG et XPS de HotPDF partagent un même scanner de nombres, et ce scanner multipliait la mantisse par Power(10, Exponent) dès qu’il avait lu un exposant. N’importe quel SVG passé à THotPDF.ImportSVGFormXObject (le point d’entrée derrière l’import de SVG dans PDF comme form XObjects réutilisables), et n’importe quelle géométrie de chemin traitée pendant la conversion XPS et OpenXPS vers PDF, pouvait donc alimenter cet appel avec une coordonnée telle que 1e100 ou 5e99

v2.770.91 avait déjà plafonné l’exposant à 100 et rejeté les valeurs qui dépasseraient 1E300, ce qui semblait suffire : 1E100 est loin de la limite Double d’environ 1.8E308. En Win64 ça débordait quand même, parce que le calcul ne se faisait jamais en Double. Depuis v2.770.155, le scanner construit lui-même la puissance de dix, et des nombres tels que 1e-100, ou une longue mantisse avec un grand exposant négatif, se lisent à leur vraie valeur au lieu de s’effondrer à 0

Une puissance de dix sûre pour des exposants bornés

Quand l’exposant est borné, la puissance de dix la plus sûre est celle que vous construisez vous-même par multiplications Double. Une boucle d’au plus 100 multiplications ne coûte rien à côté du scan du texte autour, elle ne produit jamais d’intermédiaire plus grand que l’échelle finale, et elle se comporte à l’identique en Win32, Win64 et 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;
  // Refuse les résultats qui sortiraient de la plage 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;       // ne dépasse jamais 1E100
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // division : 1E-100 n’a pas de Double exact
  Result := True;
end;

Trois détails portent le poids. Le contrôle de plage utilise deux comparaisons plutôt que Abs(Exponent) <= 100, parce que Abs(Low(Integer)) reste négatif et passerait droit au travers. Les exposants négatifs divisent par l’échelle au lieu de multiplier par un 1E-100 précalculé, qui n’a pas de Double exact et ajouterait un arrondi de plus. Et la pré-vérification Log10 refuse les résultats hors de la plage Double avant que la multiplication n’ait une chance de déborder

Soyez clair sur ce que la boucle abandonne. Les puissances de dix jusqu’à 1E22 sont exactes en Double ; au-delà, chaque multiplication arrondit, et après 100 d’entre elles l’échelle siège à quelques ulps du 1E100 correctement arrondi. Pour des coordonnées de dessin, c’est invisible. Pour une conversion texte-vers-double à usage général qui doit reproduire chaque valeur bit pour bit, ce n’est pas suffisant, et il vous faut un algorithme de conversion correctement arrondi à la place

Quand dcc64 lit un TList.Count périmé dans une boucle while

Nous avons observé le compilateur Win64 (dcc64, version de compilateur 37.0) générer du code pour une boucle while List.Count > Start do qui supprimait depuis la fin de la liste et comparait contre un temporaire de pile au lieu de relire Count. La réécriture qui l’a corrigée fut une boucle for ... downto, dont les bornes sont évaluées exactement une fois par définition

La boucle est arrivée en v2.769.3, qui apprenait au code de groupes de transparence du moteur de rendu à garder en vie les masques mous créés à l’intérieur d’un groupe à travers un rendu en deux passes et à les libérer ensuite. Le nettoyage siégeait dans un bloc finally après une boucle for à une ou deux passes, à l’intérieur de la boucle par tuile. Réduits à leur forme, l’avant et l’après ressemblent à ça :

// La forme que nous avons vu mal compiler par dcc64 (version de compilateur 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;

// Remplacement : les bornes sont évaluées une fois, aucun temporaire à périmer
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.Count est NativeInt depuis Delphi 12
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

Dans le code Win64 généré, le Count de la condition de boucle et le Count lu dans le corps partageaient une même case de pile. La condition comparait contre cette case à l’entrée, avant que quoi que ce soit ne l’ait écrite, et rien ne la rafraîchissait après Delete. Quand un groupe n’avait créé aucun masque mou à lui, le corps tournait quand même et demandait l’élément -1 à une liste vide, si bien qu’en builds 64 bits chaque page contenant un tel groupe de transparence échouait avec EListError. Le code Win32 de la même source était correct, et v2.770.1 a remplacé la boucle

Piège codegen Win64 HotPDF dans le nettoyage du moteur de rendu : une boucle while relisant TList.Count partageait une même case de pile entre condition et corps, dcc64 ne la rafraîchissait jamais après Delete, les groupes de transparence vides libéraient l’élément -1 et levaient EListError, et la correction est une boucle for downto dont les bornes sont évaluées une fois
la leçon pratique coûte moins cher que la cause racine : les boucles downto à bornes fixes ne peuvent pas périmer, et le travail du moteur de rendu n’est pas fini tant que dcc64 n’a pas fait tourner la suite

Nous n’avons pas réduit ça à une reproduction minimale, et une petite boucle isolée comme DropMasksWhile compile sans doute correctement ; le try/finally alentour et les boucles imbriquées semblent compter. Traitez ça comme une génération de code observée sur une version de compilateur, pas comme un défaut connu de tout compilateur Win64. La leçon pratique est moins chère que la cause racine : une boucle dont la condition relit le compte d’une collection pendant que le corps rétrécit cette collection mérite d’être réécrite en for ... downto à bornes fixes, et les changements du moteur de rendu exigent une campagne de tests Win64 complète, pas seulement Win32

Localiser un crash que seul un build Win64 optimisé montre

L’échec ne se reproduisait que dans le build Win64 optimisé, donc la localisation est venue d’outils hors IDE. Un petit programme sonde enregistrait un gestionnaire d’exceptions vectorisé avec AddVectoredExceptionHandler, capturait la pile à la première exception avec RtlCaptureStackBackTrace, et traduisait les adresses de retour en noms de fonctions grâce au fichier map détaillé que l’éditeur de liens écrit avec -GD. Le désassemblage de cette fonction a ensuite montré la comparaison lisant une case de pile, [rbp+0x298], jamais écrite qu’à l’intérieur du corps de boucle. C’est le niveau de preuve qu’on veut avant d’accuser un compilateur, et ça a pris moins de temps que de débugger pas à pas un build release

Pourquoi High(Int64) n’est-il pas une borne haute sûre pour un Double ?

Un Double ne peut pas représenter High(Int64) : convertir 9223372036854775807 en Double arrondit à exactement 2^63, un de plus que le plus grand Int64. En Win64, cette conversion se produit à l’intérieur de la comparaison elle-même, si bien que D <= High(Int64) vaut True pour D = 2^63, et le Round ou le Trunc qui suit déborde

Win32 masque ça pour la même raison qu’il masquait le problème de Power. La comparaison tourne en précision Extended 80 bits avec une mantisse de 64 bits, où High(Int64) est exact et où 2^63 compare correctement plus grand. Win64 n’a pas de type plus large vers quoi retomber. La conversion hors plage n’est pas jolie non plus : dans nos tests Win64, Round(2^63) renvoyait Low(Int64), un basculement de signe silencieux, que exInvalidOp soit masqué ou non. Win32 renvoie la même valeur quand c’est masqué et lève EInvalidOp sinon

Piège de borne Int64 HotPDF : un Double ne peut pas représenter High(Int64), si bien qu’une comparaison Win64 convertit la limite jusqu’à 2^63, D égal à 2^63 passe le contrôle et Round renvoie silencieusement Low(Int64), tandis que Win32 compare en Extended 80 bits où la borne est exacte et où la même comparaison vaut False
une conversion, voilà tout le bug : la borne s’arrondit sur la valeur même que vous excluez, donc écrivez le plafond en littéral avec un strictement inférieur
ExpressionWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), exceptions masquées (défaut Delphi 12+)1E100+Inf
Power(10, 100), exOverflow non masqué1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp non masquéEInvalidOpLow(Int64)

HotPDF a rencontré ça dans le lecteur JSON derrière ses valeurs de jobs de documents. JSON n’impose aucune limite de plage aux nombres, et l’ancien sérialiseur transformait toute valeur avec Frac(Value) = 0 en entier par Round, si bien qu’un 1e19 parfaitement légal devenait soit un entier faux soit une exception, selon le masque. Depuis v2.770.169, un nombre entier n’est écrit comme entier que s’il tient dans un Int64, tout le reste garde son texte en virgule flottante, et les getters entiers renvoient le défaut de l’appelant pour les valeurs hors plage au lieu d’une valeur enroulée

const
  TwoPow63 = 9223372036854775808.0;   // 2^63, exact en Double et 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
  // Les appelants rejettent d’abord NaN et infinis : JSON n’a pas d’écriture pour eux
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // ffGeneral FPC Win64 s’arrête à 15 chiffres
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

La borne haute est le littéral 9223372036854775808.0 avec un < strict. Cette constante vaut 2^63, exacte en Double comme en Extended, si bien que la comparaison veut dire la même chose sur chaque plateforme. La borne basse peut se permettre >= parce que -2^63 est exactement Low(Int64). Tester IsNan et IsInfinite d’abord, avec évaluation en court-circuit, tient NaN et les infinis à l’écart de Frac et des comparaisons, qui peuvent lever EInvalidOp quand l’hôte l’a démasquée

Combien de chiffres la conversion flottant-vers-texte donne-t-elle vraiment en Win64 ?

Moins que vous ne le demandez, sur deux compilateurs sur trois. Le FloatToStrF(Value, ffGeneral, 17, 0) de Free Pascal 3.3.1 en Win64 s’arrête à 15 chiffres significatifs, si bien que 1/3 revient en 0.333333333333333 et que deux valeurs Double différentes peuvent se sérialiser en texte identique. Str(Value:24, Text) suivi d’un Trim produit 17 chiffres significatifs en notation scientifique, 3.3333333333333331E-001 pour la même valeur, et écrit toujours un point comme séparateur décimal quelle que soit la locale. Si HotPDF sous FPC fait partie de votre matrice de build, les notes de support HotPDF Free Pascal et Lazarus Win64 couvrent le reste des différences de plateforme

Delphi accepte la demande de 17 chiffres, mais les deux cibles Delphi continuent de diverger en sortie : FloatToStrF(0.1, ffGeneral, 17, 0) donne 0.10000000000000001 en Win32 et 0.1 en Win64. La RTL Win64 peut aussi introduire une erreur d’arrondi du dernier chiffre autant au formatage qu’au parse, si bien que plus de chiffres resserrent l’écart sans garantir que chaque motif de bits Double survive à un aller-retour par le texte. La documentation de HotPDF ne fait aucune telle promesse, et la vôtre ne devrait pas non plus à moins que vous ne livriez votre propre formateur et parseur correctement arrondis. Passez TFormatSettings.Invariant, ou remplacez le séparateur vous-même sur les anciennes versions de Delphi, pour qu’une locale allemande ou française n’écrive pas une virgule dans le JSON

Pourquoi Assert.AreEqual cesse-t-il de compiler en Win64 ?

Assert.AreEqual(3, Length(Arr)) sur un tableau dynamique compile pour Win32 et échoue pour Win64 avec E2532, « Couldn't infer generic type argument from different argument types », parce que le Length d’un tableau dynamique renvoie un NativeInt en Win64. Avec un littéral Integer d’un côté et un NativeInt 64 bits de l’autre, le Assert.AreEqual<T> générique de DUnitX ne peut pas trancher sur un seul T, et le build s’arrête

TList.Count déclenche la même erreur depuis Delphi 12, où la propriété est devenue NativeInt ; Delphi 11 la déclare toujours en Integer. Le Length d’un string renvoie un Integer sur les deux plateformes et n’est pas touché, voilà pourquoi l’erreur apparaît dans certains modules de test et pas d’autres. Écrivez l’argument de type explicitement, Assert.AreEqual<NativeInt>(3, Length(Arr)), et compilez le projet de test avec dcc64 avant de committer. Une suite qui ne construit jamais qu’en Win32 ne vous dira pas que son build Win64 est cassé avant que quelqu’un d’autre ne l’essaye

Checklist de portage Win64 pour du code numérique Delphi

  • Cherchez les appels Power( et IntPower( avec des arguments entiers ; passez des valeurs typées Double ou construisez vous-même les puissances de dix bornées
  • Faites tourner les tests numériques au moins une fois avec exOverflow et exInvalidOp retirés via SetExceptionMask, en Win32 comme en Win64
  • Écrivez la borne haute Int64 en < 9223372036854775808.0, jamais <= High(Int64), et rejetez NaN et les infinis avant toute comparaison
  • Ne convertissez pas un nombre parsé en Int64 seulement parce que Frac vaut 0 ; les nombres JSON peuvent être bien plus grands
  • Réécrivez les boucles while qui relisent Count pendant qu’on supprime des éléments en boucles for ... downto à bornes fixes
  • Sur FPC Win64, utilisez Str(Value:24, Text) quand vous avez besoin de plus de 15 chiffres significatifs
  • Utilisez Assert.AreEqual<NativeInt> pour les asserts sur Length et Count, et compilez les tests avec dcc64 avant de committer
  • Après toute modification d’un parseur ou du moteur de rendu, faites tourner la suite de régression complète en Win32 et Win64, pas juste l’une des deux

Les corrections côté bibliothèque décrites ici sont toutes dans HotPDF depuis v2.770.169, si bien que l’import SVG, la conversion XPS, le rendu de transparence et la gestion des jobs JSON se comportent désormais pareil en Win64 qu’en Win32. Si vous générez ou traitez des fichiers PDF depuis Delphi ou C++Builder pour les deux plateformes, la page du composant HotPDF Delphi PDF contient les téléchargements et la liste complète des fonctionnalités