Artigo Técnico

Bugs Delphi só em Win64 encontrados ao endurecer o HotPDF

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

Armadilha numérica Win64 do HotPDF em que o System.Math Power com argumentos inteiros se liga ao overload Single, por isso o Power de 10 elevado a 20 devolve 1.0000000200408773E20 em vez de 1E20 e o Power de 10 elevado a 100 dá mais infinito quando as exceções estão mascaradas ou EOverflow quando não estão
a perda de precisão é a pista: se uma potência de dez voltar com ruído de Single agarrado, o overload errado ganhou — construa a escala você mesmo
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

Armadilha de codegen Win64 no HotPDF na limpeza do renderizador: um loop while a reler TList.Count partilhava um slot de stack entre a condição e o corpo, o dcc64 nunca o refrescava após Delete, transparency groups vazios libertavam o item -1 e levantavam EListError, e a correção é um loop for downto cujos limites são avaliados uma vez
a lição prática custa menos que a causa raiz: loops downto de limite fixo não podem ficar velhos, e trabalho de renderizador não está feito até o dcc64 correr a suíte

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

Armadilha do limite Int64 no HotPDF: um Double não consegue representar High(Int64), por isso uma comparação Win64 converte o limite para cima até 2^63, D igual a 2^63 passa a verificação e Round devolve silenciosamente Low(Int64), enquanto o Win32 compara em Extended de 80 bits onde o limite é exato e a mesma comparação é False
uma conversão é o bug inteiro: o limite arredonda para cima até ao próprio valor que se está a excluir, por isso escreva o teto como um literal com um menor-estrito
ExpressãoWin32Win64
Power(10, N), N = 201E201.0000000200408773E20
Power(10, 100), exceções mascaradas (predefinição Delphi 12+)1E100+Inf
Power(10, 100), exOverflow desmascarado1E100EOverflow
D <= High(Int64), D = 2^63FalseTrue
Round(2^63), exInvalidOp desmascaradoEInvalidOpLow(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( e IntPower( com argumentos inteiros; passe valores tipados como Double ou construa você as potências de dez limitadas
  • Corra os testes numéricos pelo menos uma vez com exOverflow e exInvalidOp removidos através do SetExceptionMask, em Win32 e Win64
  • Escreva o limite superior Int64 como < 9223372036854775808.0, nunca <= High(Int64), e rejeite NaN e infinitos antes de qualquer comparação
  • Não converta um número analisado para Int64 só porque o Frac é 0; números JSON podem ser muito maiores
  • Reescreva loops while que releiam Count enquanto apagam itens como loops for ... downto de 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 de Length e Count, 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