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
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
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
| Expression | Win32 | Win64 |
|---|---|---|
Power(10, N), N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100), exceptions masquées (défaut Delphi 12+) | 1E100 | +Inf |
Power(10, 100), exOverflow non masqué | 1E100 | EOverflow |
D <= High(Int64), D = 2^63 | False | True |
Round(2^63), exInvalidOp non masqué | EInvalidOp | Low(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(etIntPower(avec des arguments entiers ; passez des valeurs typéesDoubleou construisez vous-même les puissances de dix bornées - Faites tourner les tests numériques au moins une fois avec
exOverflowetexInvalidOpretirés viaSetExceptionMask, en Win32 comme en Win64 - Écrivez la borne haute
Int64en< 9223372036854775808.0, jamais<= High(Int64), et rejetez NaN et les infinis avant toute comparaison - Ne convertissez pas un nombre parsé en
Int64seulement parce queFracvaut 0 ; les nombres JSON peuvent être bien plus grands - Réécrivez les boucles
whilequi relisentCountpendant qu’on supprime des éléments en bouclesfor ... 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 surLengthetCount, 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