Código Delphi Win64 pode falhar onde a mesma fonte corre limpa em Win32, e o componente PDF Delphi HotPDF acertou em cinco casos destes durante uma passagem recente de hardening: Power(10, N) a ligar-se ao overload Single, um loop while a ler um TList.Count velho, um limite High(Int64) que arredonda para cima até 2^63, texto float de 15 dígitos em FPC, e asserts de testes que deixam de compilar
Nenhum destes aparece se só compilar e testar Win32, que é exatamente como se meteram para dentro. Os casos abaixo vêm dos importadores SVG e XPS do HotPDF, do seu renderizador de páginas e do seu leitor de jobs JSON, e os resultados numéricos citados foram reproduzidos com pequenos programas de sondagem compilados para Win32 e Win64. Se está a mover uma base de código Delphi para 64 bits, cada um deles merece um grep
Porque é que Power(10, 100) transborda só em Win64?
Em Win64, System.Math.Power(10, N) com argumentos inteiros resolve para o overload Single, por isso o resultado é calculado e devolvido em precisão simples e tudo o que passe de cerca de 3.4E38 transborda. Em Win32 a mesma chamada liga-se ao overload Extended e corre na FPU x87 com precisão de 80 bits, por isso 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. Em Win64, Extended é só um alias de Double (SizeOf(Extended) = 8), e para dois argumentos inteiros o compilador escolhe a versão Single. A revelação é a precisão, não só o transbordo: em Win64, Power(10, 20) devolve 1.0000000200408773E20, que é exatamente Single(1E20). Um resultado Double imprimir-se-ia como 1E20. Vimos a mesma binding com todos os compiladores Win64 que experimentámos, de Delphi 10.3 até à versão 37.0 do compilador
O que acontece a seguir depende da máscara de exceções de vírgula flutuante. Delphi 12 e posteriores mascaram todas as exceções de vírgula flutuante por omissão, por isso o transbordo é silencioso: Power(10, 100) devolve +Inf e Power(10, -100) devolve 0. Delphi 11 e anteriores deixam o exOverflow por mascarar, e a mesma chamada levanta EOverflow. Aplicações que ponham a máscara elas próprias, e DLLs carregadas nesses hosts, recebem o comportamento que o host escolheu, razão pela qual uma biblioteca não pode assumir nenhum dos dois resultados
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 Delphi 11, ou um host com definições 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;
Desmascarar o exOverflow e o exInvalidOp durante um teste é a maneira mais barata de ver o que um compilador mais antigo ou um host estrito vê. Num compilador moderno com as predefinições o bug não crasha, produz infinitos e zeros, e esses são muito mais difíceis de apanhar num log de testes. Restaure a máscara anterior num finally: a máscara é estado por thread, e o resto da corrida de testes herda o que você deixar para trás
Como o overload chegou à importação SVG e XPS do HotPDF
Os leitores de paths SVG e XPS do HotPDF partilham um único analisador de números, e esse analisador escalava a mantissa com Power(10, Exponent) assim que tinha lido um expoente. Qualquer SVG passado ao THotPDF.ImportSVGFormXObject (o ponto de entrada por trás de importar SVG para PDF como form XObjects reutilizáveis), e qualquer geometria de path tratada durante a conversão de XPS e OpenXPS para PDF, podia por isso alimentar essa chamada com uma coordenada como 1e100 ou 5e99
A v2.770.91 já limitava o expoente a 100 e rejeitava valores que passassem 1E300, o que parecia chegado: 1E100 está muito longe do limite Double de cerca de 1.8E308. Em Win64 ainda assim transbordava, porque o cálculo nunca acontecia em Double. Desde a v2.770.155 o analisador constrói a potência de dez ele próprio, e números como 1e-100, ou uma mantissa longa com um expoente negativo grande, leem-se pelo seu valor real 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ções Double. Um loop de no máximo 100 multiplicações não custa nada ao lado de analisar o texto em volta, nunca produz um intermédio maior do que a escala final, e comporta-se de forma idêntica em 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 do intervalo 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; // dividir: 1E-100 não tem Double exato
Result := True;
end;
Três detalhes carregam o peso. A verificação de intervalo 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é-calculado, que não tem Double exato e acrescentaria mais um passo de arredondamento. E a pré-verificação com Log10 recusa resultados fora do intervalo Double antes de a multiplicação ter ocasião de transbordar
Seja claro quanto ao que o loop abre mão. Potências de dez até 1E22 são exatas em Double; a partir daí cada multiplicação arredonda, e depois de 100 delas a escala fica a poucas unidades no último lugar da 1E100 corretamente arredondada. Para coordenadas de desenho isso é invisível. Para uma conversão geral de texto para double que tenha de reproduzir cada valor bit a bit, não chega, e precisa de um algoritmo de conversão corretamente arredondado
Quando o dcc64 lê um TList.Count velho num loop while
Observámos o compilador Win64 (dcc64, versão 37.0) gerar código para um loop while List.Count > Start do que apagava do fim da lista e comparava contra um temporário na stack em vez de reler Count. A reescrita que o corrigiu foi um loop for ... downto, cujos limites são por definição avaliados exatamente uma vez
O loop chegou na v2.769.3, que ensinou o código de transparency groups do renderizador a manter soft masks criados dentro de um grupo vivos ao longo de uma renderização de uma ou duas passagens e a libertá-los depois. A limpeza sentava-se num bloco finally depois de um loop for de uma ou duas passagens, dentro do loop por tile. Reduzido à sua forma, o antes e o depois têm este aspecto:
// A forma que vimos mal compilada pelo dcc64 (versão do compilador 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 limites são avaliados uma vez, sem temporário para ficar velho
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
Idx: NativeInt; // TList.Count é NativeInt desde 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 partilhavam um único slot da stack. A condição comparava contra esse slot à entrada, antes de alguém o ter escrito, e nada o refrescava depois do Delete. Quando um grupo não tinha criado soft masks próprios, o corpo corria na mesma e pedia o item -1 a uma lista vazia, por isso em builds de 64 bits cada página contendo tal transparency group falhava com EListError. O código Win32 da mesma fonte estava correto, e a v2.770.1 substituiu o loop
Não reduzimos isto a uma reprodução mínima, e um pequeno loop independente como o DropMasksWhile pode bem compilar corretamente; o try/finally em volta e os loops aninhados parecem importar. Trate-o como geração de código que observámos numa versão de compilador, não como um defeito conhecido de todos os compiladores 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 merece ser reescrito como um for ... downto de limite fixo, e mudanças no renderizador precisam de uma corrida de testes Win64 completa, não só Win32
Localizar um crash que só uma build Win64 otimizada mostra
A falha só se reproduzia na build Win64 otimizada, por isso a localização veio de ferramentas fora da IDE. Um pequeno programa de sondagem registava um manipulador de exceções vetado com AddVectoredExceptionHandler, capturava a stack na primeira exceção com RtlCaptureStackBackTrace, e traduzia os endereços de retorno em nomes de funções usando o map file detalhado que o linker escreve com -GD. Desmontar essa função mostrou então a comparação a ler um slot de stack, [rbp+0x298], que só era escrito dentro do corpo do loop. Esse é o nível de evidência que se quer antes de acusar um compilador, e levou menos tempo do que percorrer uma build release com o debugger
Porque é que High(Int64) não é um limite superior seguro para um Double?
Um Double não consegue representar High(Int64): converter 9223372036854775807 para Double arredonda para cima até exatamente 2^63, um além do maior Int64. Em Win64 essa conversão acontece dentro da própria comparação, por isso D <= High(Int64) é True para D = 2^63, e o Round ou Trunc que vem a seguir transborda
O Win32 esconde isto pela mesma razão que escondia o problema do Power. A comparação corre em precisão Extended de 80 bits com mantissa de 64 bits, onde High(Int64) é exato e 2^63 compara corretamente como maior. O Win64 não tem tipo mais largo para onde recuar. A conversão fora do intervalo também não é bonita: nos nossos testes Win64 Round(2^63) devolvia Low(Int64), uma inversão de sinal silenciosa, estivesse o exInvalidOp mascarado ou não. O Win32 devolve o mesmo valor quando mascarado e levanta EInvalidOp quando não
| Expressão | Win32 | Win64 |
|---|---|---|
Power(10, N), N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100), exceções mascaradas (predefinição Delphi 12+) | 1E100 | +Inf |
Power(10, 100), exOverflow desmascarado | 1E100 | EOverflow |
D <= High(Int64), D = 2^63 | False | True |
Round(2^63), exInvalidOp desmascarado | EInvalidOp | Low(Int64) |
O HotPDF encontrou isto no leitor JSON por trás dos valores de jobs de documentos. O JSON não põe limite de intervalo nos números, e o serializador antigo transformava qualquer valor com Frac(Value) = 0 num inteiro com Round, por isso um perfeitamente legal 1e19 tornava-se ou um inteiro errado ou uma exceção, consoante a máscara. Desde a v2.770.169 um número inteiro só é escrito como inteiro quando cabe num Int64, tudo o resto mantém o seu texto de vírgula flutuante, e os getters de inteiros devolvem a predefinição do chamador para valores fora do intervalo em vez de um valor 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
// Os chamadores 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 pára em 15 dígitos
Result := Trim(Result);
{$ELSE}
Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
end;
end;
O limite superior é o literal 9223372036854775808.0 com um < estrito. Essa constante é 2^63, exata tanto em Double como em Extended, por isso a comparação significa o mesmo em todas as plataformas. O limite inferior pode usar >= porque -2^63 é exatamente Low(Int64). Testar IsNan e IsInfinite primeiro, com avaliação de curto-circuito, mantém NaN e infinitos longe do Frac e das comparações, que podem levantar EInvalidOp quando o host o deixou desmascarado
Quantos dígitos dá afinal a conversão de float para texto em Win64?
Menos do que se pede, em dois compiladores em cada três. O FloatToStrF(Value, ffGeneral, 17, 0) do Free Pascal 3.3.1 em Win64 pára em 15 dígitos significativos, por isso 1/3 volta como 0.333333333333333 e dois valores Double diferentes podem serializar-se para texto idêntico. O 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 escreve sempre um ponto como separador decimal independentemente do locale. Se o HotPDF em FPC faz parte da sua matriz de compilação, as notas de suporte Win64 do HotPDF para Free Pascal e Lazarus cobrem o resto das diferenças de plataforma
O Delphi aceita o pedido de 17 dígitos, mas os dois alvos Delphi ainda discordam no output: FloatToStrF(0.1, ffGeneral, 17, 0) dá 0.10000000000000001 em Win32 e 0.1 em Win64. A RTL Win64 também pode introduzir um erro de arredondamento no último dígito tanto ao formatar como ao analisar, por isso mais dígitos estreitam a falha sem garantir que cada padrão de bits Double sobreviva a um round trip por texto. A documentação do HotPDF não faz tal promessa, e a sua também não devia, a menos que envie um formatador e parser corretamente arredondados seus. Passe TFormatSettings.Invariant, ou substitua você o separador em versões Delphi mais antigas, para um locale alemão ou francês não escrever uma vírgula no JSON
Porque é que Assert.AreEqual deixa de compilar em Win64?
Assert.AreEqual(3, Length(Arr)) sobre um array dinâmico compila para Win32 e falha para Win64 com E2532, "Couldn't infer generic type argument from different argument types", porque o Length de um array dinâmico devolve NativeInt em 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 assentar num único T, e a build pára
O TList.Count despoleta o mesmo erro desde o Delphi 12, em que a propriedade se tornou NativeInt; o Delphi 11 ainda a declara como Integer. O Length de uma string devolve Integer nas duas plataformas e não é afetado, razão pela qual o erro aparece em algumas unidades de teste e noutras não. Escreva o argumento de tipo explicitamente, Assert.AreEqual<NativeInt>(3, Length(Arr)), e compile o projeto de testes com o dcc64 antes de fazer commit. Uma suíte que só nunca compila para Win32 não lhe vai dizer que a build Win64 está partida até alguém a experimentar
Checklist de portagem Win64 para código numérico Delphi
- Procure chamadas a
Power(eIntPower(com argumentos inteiros; passe valores tipados comoDoubleou construa você as potências de dez limitadas - Corra os testes numéricos pelo menos uma vez com
exOverfloweexInvalidOpremovidos através doSetExceptionMask, em Win32 e Win64 - Escreva o limite superior
Int64como< 9223372036854775808.0, nunca<= High(Int64), e rejeite NaN e infinitos antes de qualquer comparação - Não converta um número analisado para
Int64só porque oFracé 0; números JSON podem ser muito maiores - Reescreva loops
whileque releiamCountenquanto apagam itens como loopsfor ... downtode limite fixo - Em FPC Win64, use
Str(Value:24, Text)quando precisar de mais de 15 dígitos significativos - Use
Assert.AreEqual<NativeInt>para asserts deLengtheCount, e compile os testes com o dcc64 antes de fazer commit - Depois de qualquer mudança num parser ou renderizador, corra a suíte de regressão completa em Win32 e Win64, não só numa delas
As correções do lado da biblioteca descritas aqui estão todas no HotPDF desde a v2.770.169, por isso a importação SVG, a conversão XPS, a renderização de transparência e o tratamento de jobs JSON comportam-se agora da mesma maneira em Win64 como em Win32. Se gera ou processa ficheiros PDF de Delphi ou C++Builder para ambas as plataformas, a página do componente PDF Delphi HotPDF tem os downloads e a lista completa de funcionalidades