Código Delphi Win64 pode falhar onde a mesma fonte roda limpa no Win32, e o componente HotPDF PDF para Delphi bateu em cinco casos assim durante uma passada recente de hardening: Power(10, N) bindando para o overload de Single, um loop while lendo um TList.Count obsoleto, um bound de High(Int64) que arredonda para cima até 2^63, texto de float de 15 dígitos no FPC, e asserts de teste que param de compilar
Nenhum deles aparece se você só constrói e testa Win32, que é exatamente como escorregaram para dentro. Os casos abaixo vêm dos importers de SVG e XPS do HotPDF, do page renderer dele e do leitor de jobs JSON dele, e os resultados numéricos citados foram reproduzidos com pequenos programas de sonda construídos para Win32 e Win64. Se você está movendo uma base de código Delphi para 64 bits, cada um deles vale um grep
Por que Power(10, 100) estoura só no Win64?
No Win64, System.Math.Power(10, N) com argumentos inteiros resolve para o overload de Single, então o resultado é computado e retornado em single precision e qualquer coisa acima de cerca de 3.4E38 estoura. No Win32 a mesma chamada binda para o overload de Extended e roda na FPU x87 com precisão de 80 bits, então Power(10, 100) é simplesmente 1E100
O System.Math declara Power para Extended, Double e Single, mais uma família IntPower correspondente que o Power chama quando o expoente é um número inteiro. No Win64, Extended é só um alias de Double (SizeOf(Extended) = 8), e para dois argumentos inteiros o compilador escolhe a versão Single. A entrega é a precisão, não só o overflow: no Win64, Power(10, 20) retorna 1.0000000200408773E20, que é exatamente Single(1E20). Um resultado Double imprimiria como 1E20. Vimos o mesmo binding com todo compilador Win64 que testamos, do Delphi 10.3 até a compiler version 37.0
O que acontece depois depende da floating-point exception mask. Delphi 12 e posteriores mascaram todas as exceções de ponto flutuante por default, então o overflow é silencioso: Power(10, 100) retorna +Inf e Power(10, -100) retorna 0. Delphi 11 e anteriores deixam o exOverflow sem máscara, e a mesma chamada levanta EOverflow. Aplicações que setam a máscara elas mesmas, e DLLs carregadas em tais hosts, recebem o comportamento que o host escolheu, e é por isso que uma biblioteca não pode assumir nem um nem outro resultado
uses
System.SysUtils, System.Math;
procedure ShowPowerOverload;
var
N: Integer;
OldMask: TArithmeticExceptionMask;
begin
N := 20;
// Win32 imprime 1E20; Win64 imprime 1.0000000200408773E20 (overload Single)
Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));
// Reproduza o que o Delphi 11, ou um host com settings de FP estritas, faz
OldMask := GetExceptionMask;
SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
try
N := 100;
Writeln(Power(10, N)); // Win64: EOverflow; Win32: 1E100
finally
SetExceptionMask(OldMask);
end;
end;
Tirar a máscara do exOverflow e do exInvalidOp pela duração de um teste é o jeito mais barato de ver o que um compilador mais velho ou um host estrito vê. Num compilador moderno com settings default o bug não trava, ele produz infinitos e zeros, e esses são muito mais difíceis de avistar num log de teste. Restaure a máscara anterior no finally: a máscara é estado por thread, e o resto da rodada de testes herda o que quer que você deixe para trás
Como o overload chegou ao import de SVG e XPS do HotPDF
Os leitores de path de SVG e XPS do HotPDF compartilham um scanner de números, e esse scanner escalava a mantissa com Power(10, Exponent) assim que lia um expoente. Qualquer SVG passado ao THotPDF.ImportSVGFormXObject (o entry point por trás de importar SVG para PDF como form XObjects reutilizáveis), e qualquer geometria de path manejada durante a conversão de XPS e OpenXPS para PDF, podia portanto alimentar essa chamada com uma coordenada como 1e100 ou 5e99
A v2.770.91 já limitava o expoente a 100 e recusava valores que passariam de 1E300, o que parecia suficiente: 1E100 está longe do limite de Double de cerca de 1.8E308. No Win64 ainda assim estourava, porque a computação nunca acontecia em Double. Desde a v2.770.155 o scanner constrói a potência de dez ele mesmo, e números como 1e-100, ou uma mantissa longa com expoente negativo grande, leem como o valor real deles em vez de colapsar para 0
Uma potência de dez segura para expoentes limitados
Quando o expoente é limitado, a potência de dez mais segura é uma que você constrói você mesmo com multiplicação de Double. Um loop de no máximo 100 multiplicações não custa nada perto de escanear o texto ao redor, nunca produz um intermediário maior que a escala final, e se comporta identicamente no 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;
// Recuse resultados que sairiam da faixa de 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; // nunca excede 1E100
if Exponent >= 0 then
Scaled := Value * Scale
else
Scaled := Value / Scale; // divida: 1E-100 não tem Double exato
Result := True;
end;
Três detalhes carregam o peso. A checagem de faixa usa duas comparações em vez de Abs(Exponent) <= 100, porque Abs(Low(Integer)) continua negativo e passaria direto. Expoentes negativos dividem pela escala em vez de multiplicar por um 1E-100 pré-computado, que não tem Double exato e acrescentaria mais um passo de arredondamento. E o pre-check de Log10 recusa resultados fora da faixa de Double antes de a multiplicação ter chance de estourar
Seja claro quanto ao que o loop abre mão. Potências de dez até 1E22 são exatas em Double; passado isso toda multiplicação arredonda, e depois de 100 delas a escala fica a poucos units in the last place do 1E100 corretamente arredondado. Para coordenadas de desenho isso é invisível. Para uma conversão texto-para-double de propósito geral que precise reproduzir todo valor bit a bit, não é bom o bastante, e você precisa de um algoritmo de conversão corretamente arredondado em vez disso
Quando o dcc64 lê um TList.Count obsoleto num loop while
Observamos o compilador Win64 (dcc64, compiler version 37.0) gerar código para um loop while List.Count > Start do que deletava do fim da lista e comparava contra um temporário de stack em vez de reler Count. A reescrita que o consertou foi um loop for ... downto, cujos bounds são avaliados exatamente uma vez por definição
O loop chegou na v2.769.3, que ensinou o código de transparency-group do renderer a manter soft masks criados dentro de um grupo vivos através de um render de duas passadas e a liberá-los depois. A limpeza ficava num bloco finally depois de um loop for de uma ou duas passadas, dentro do loop por tile. Reduzido à forma dele, o antes e o depois ficam assim:
// A forma que vimos ser mal compilada pelo dcc64 (compiler version 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;
// Substituição: os bounds são avaliados uma vez, nenhum temporário para ficar obsoleto
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
Idx: NativeInt; // TList.Count é NativeInt desde o Delphi 12
begin
for Idx := Masks.Count - 1 downto Start do
begin
TObject(Masks[Idx]).Free;
Masks.Delete(Idx);
end;
end;
No código Win64 gerado, o Count na condição do loop e o Count lido dentro do corpo compartilhavam um slot de stack. A condição comparava contra aquele slot na entrada, antes de qualquer coisa tê-lo escrito, e nada o refrescava depois do Delete. Quando um grupo não tinha criado soft masks próprios, o corpo rodava mesmo assim e pedia o item -1 a uma lista vazia, então em builds de 64 bits toda página contendo tal transparency group falhava com EListError. O código Win32 para a mesma fonte estava correto, e a v2.770.1 substituiu o loop
Não reduzimos isso a uma reprodução mínima, e um loop standalone pequeno como o DropMasksWhile pode muito bem compilar corretamente; o try/finally em volta e os loops aninhados parecem importar. Trate como geração de código que observamos numa versão de compilador, não como um defeito conhecido de todo compilador Win64. A lição prática é mais barata que a causa raiz: um loop cuja condição relê a contagem de uma coleção enquanto o corpo encolhe essa coleção vale ser reescrito como um for ... downto de bounds fixos, e mudanças de renderer precisam de uma rodada completa de testes Win64, não só Win32
Localizando um crash que só um build Win64 otimizado mostra
A falha só reproduzia no build Win64 otimizado, então a localização veio de tools fora da IDE. Um pequeno programa de sonda registrava um vectored exception handler com AddVectoredExceptionHandler, capturava a stack na primeira exceção com RtlCaptureStackBackTrace, e traduzia os return addresses em nomes de função usando o map file detalhado que o linker escreve com -GD. Desassembling essa função então mostrou a comparação lendo um slot de stack, [rbp+0x298], que só era escrito dentro do corpo do loop. Esse é o nível de evidência que você quer antes de culpar um compilador, e levou menos tempo do que fazer step through num release build
Por que High(Int64) não é um upper bound seguro para um Double?
Um Double não pode representar High(Int64): converter 9223372036854775807 para Double arredonda para cima até exatamente 2^63, um além do maior Int64. No Win64 essa conversão acontece dentro da própria comparação, então D <= High(Int64) é True para D = 2^63, e o Round ou Trunc que vem depois estoura
O Win32 esconde isso pela mesma razão pela qual escondia o problema do Power. A comparação roda em precisão Extended de 80 bits com mantissa de 64 bits, onde High(Int64) é exato e 2^63 corretamente compara maior. O Win64 não tem tipo mais largo para se agarrar. A conversão fora de faixa também não é bonita: nos nossos testes Win64 Round(2^63) retornava Low(Int64), uma virada de sinal silenciosa, com exInvalidOp mascarado ou não. O Win32 retorna o mesmo valor quando mascarado e levanta EInvalidOp quando sem máscara
| Expressão | Win32 | Win64 |
|---|---|---|
Power(10, N), N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100), exceções mascaradas (default do Delphi 12+) | 1E100 | +Inf |
Power(10, 100), exOverflow sem máscara | 1E100 | EOverflow |
D <= High(Int64), D = 2^63 | False | True |
Round(2^63), exInvalidOp sem máscara | EInvalidOp | Low(Int64) |
O HotPDF encontrou isso no leitor de JSON por trás dos valores de job de documento dele. O JSON não impõe limite de faixa em números, e o serializer antigo virava qualquer valor com Frac(Value) = 0 num inteiro com Round, então um perfeitamente legal 1e19 virava ou um inteiro errado ou uma exceção, dependendo da máscara. Desde a v2.770.169 um número inteiro é gravado como inteiro só quando cabe num Int64, todo o resto mantém o texto de ponto flutuante dele, e os getters de inteiro retornam o default do caller para valores fora de faixa em vez de um com wrap
const
TwoPow63 = 9223372036854775808.0; // 2^63, exato em 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
// Callers rejeitam NaN e infinitos primeiro: JSON não tem grafia para eles
if TryDoubleToInt64(Value, R) then
Result := IntToStr(R)
else
begin
{$IFDEF FPC}
Str(Value:24, Result); // FPC Win64 ffGeneral para em 15 dígitos
Result := Trim(Result);
{$ELSE}
Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
end;
end;
O bound superior é o literal 9223372036854775808.0 com um < estrito. Aquela constante é 2^63, exata tanto em Double quanto em Extended, então a comparação significa a mesma coisa em toda plataforma. O bound inferior pode usar >= porque -2^63 é exatamente Low(Int64). Testar IsNan e IsInfinite primeiro, com avaliação short-circuit, mantém NaN e infinitos longe do Frac e das comparações, que podem levantar EInvalidOp quando o host o deixou sem máscara
Quantos dígitos a conversão float-para-texto realmente te dá no Win64?
Menos do que você pede, em dois compiladores de cada três. O FloatToStrF(Value, ffGeneral, 17, 0) do Free Pascal 3.3.1 no Win64 para em 15 dígitos significativos, então 1/3 volta como 0.333333333333333 e dois valores Double diferentes podem serializar para texto idêntico. Str(Value:24, Text) seguido de Trim produz 17 dígitos significativos em notação científica, 3.3333333333333331E-001 para o mesmo valor, e sempre escreve um ponto como separador decimal independentemente do locale. Se o HotPDF no FPC faz parte da sua build matrix, as notas de suporte Win64 do HotPDF para Free Pascal e Lazarus cobrem o resto das diferenças de plataforma
O Delphi aceita o request de 17 dígitos, mas os dois alvos Delphi ainda discordam no output: FloatToStrF(0.1, ffGeneral, 17, 0) dá 0.10000000000000001 no Win32 e 0.1 no Win64. A RTL Win64 também pode introduzir um erro de arredondamento no último dígito tanto ao formatar quanto ao parsear, então mais dígitos estreitam o vão sem garantir que todo bit pattern de Double sobreviva a um round trip por texto. A documentação do HotPDF não promete nada disso, e a sua também não deveria a menos que você embarque um formatter e um parser corretamente arredondados seus. Passe TFormatSettings.Invariant, ou troque o separador você mesmo em versões mais velhas do Delphi, para que um locale alemão ou francês não escreva uma vírgula no JSON
Por que Assert.AreEqual para de compilar no Win64?
Assert.AreEqual(3, Length(Arr)) num dynamic array compila para Win32 e falha para Win64 com E2532, "Couldn't infer generic type argument from different argument types", porque o Length de um dynamic array retorna NativeInt no Win64. Com um literal Integer de um lado e um NativeInt de 64 bits do outro, o Assert.AreEqual<T> genérico do DUnitX não consegue se decidir por um único T, e o build para
TList.Count dispara o mesmo erro desde o Delphi 12, em que a propriedade virou NativeInt; o Delphi 11 ainda a declara como Integer. O Length de uma string retorna Integer nas duas plataformas e não é afetado, e é por isso que o erro aparece em algumas units de teste e não em outras. Escreva o argumento de tipo explicitamente, Assert.AreEqual<NativeInt>(3, Length(Arr)), e compile o projeto de teste com dcc64 antes de commitar. Uma suíte que só constrói para Win32 não vai te contar que o build Win64 dela está quebrado até outra pessoa tentar
Checklist de port para Win64 de código numérico Delphi
- Busque chamadas de
Power(eIntPower(com argumentos inteiros; passe valores tipados comoDoubleou construa potências de dez limitadas você mesmo - Rode testes numéricos ao menos uma vez com
exOverfloweexInvalidOpremovidos através doSetExceptionMask, no Win32 e no Win64 - Escreva o bound superior de
Int64como< 9223372036854775808.0, nunca<= High(Int64), e rejeite NaN e infinitos antes de qualquer comparação - Não converta um número parseado para
Int64só porque oFracé 0; números de JSON podem ser bem maiores - Reescreva loops
whileque relêemCountenquanto deletam itens como loopsfor ... downtode bounds fixos - No FPC Win64, use
Str(Value:24, Text)quando precisar de mais que 15 dígitos significativos - Use
Assert.AreEqual<NativeInt>para asserts deLengtheCount, e compile os testes com dcc64 antes de commitar - Depois de qualquer mudança num parser ou renderer, rode a suíte de regressão completa no Win32 e no Win64, não só num deles
As correções do lado da biblioteca descritas aqui estão todas no HotPDF desde a v2.770.169, então import de SVG, conversão de XPS, renderização de transparência e manejo de jobs JSON agora se comportam igual no Win64 e no Win32. Se você gera ou processa arquivos PDF do Delphi ou C++Builder para ambas as plataformas, a página do componente HotPDF PDF para Delphi tem os downloads e a lista completa de features