技術記事

HotPDFの堅牢化で見つかったWin64限定のDelphiバグ

Win64のDelphiコードは、同じソースがWin32では何事もなく走るところで失敗し得ます。HotPDF Delphi PDFコンポーネントは最近の堅牢化パスで、その手のケースに5つ出くわしました。整数引数でのPower(10, N)がSingleオーバーロードへバインドする、whileループが古いTList.Countを読む、High(Int64)の上限が2^63へ切り上げられる、FPCでの15桁浮動小数点テキスト、そしてコンパイルを止めるテストアサートです

Win32でだけビルドとテストをしていれば、どれも姿を見せません。まさにそのやり方で入り込んだのです。以下のケースは、HotPDFのSVGとXPSインポーター、ページレンダラー、JSONジョブリーダーから来ており、引用した数値結果は、Win32とWin64向けにビルドした小さなプローブプログラムで再現しました。Delphiのコードベースを64ビットへ移すなら、それぞれgrepする価値があります

Power(10, 100)がWin64だけでオーバーフローする理由

Win64では、整数引数でのSystem.Math.Power(10, N)はSingleオーバーロードへ解決されるので、結果は単精度で計算され返され、およそ3.4E38を超えるものはオーバーフローします。Win32では同じ呼び出しがExtendedオーバーロードへバインドし、80ビット精度のx87 FPUで走るので、Power(10, 100)はただの1E100です

System.MathはExtended、Double、Single向けにPowerを宣言し、加えて、指数が整数のときPowerが呼ぶ一致するIntPowerファミリーを宣言します。Win64ではExtendedはDoubleの別名にすぎず(SizeOf(Extended) = 8)、2つの整数引数に対してコンパイラはSingle版を選びます。決め手は精度であって、オーバーフローだけではありません。Win64ではPower(10, 20)は1.0000000200408773E20を返します。これはちょうどSingle(1E20)です。Doubleの結果なら1E20と印字されるはずのものです。試したすべてのWin64コンパイラ、Delphi 10.3からコンパイラバージョン37.0までで、同じバインディングが見られました

その後どうなるかは、浮動小数点例外マスク次第です。Delphi 12以降はデフォルトですべての浮動小数点例外をマスクするので、オーバーフローは無音です。Power(10, 100)は+Infを返し、Power(10, -100)は0を返します。Delphi 11以前はexOverflowをマスクしないままで、同じ呼び出しはEOverflowを上げます。マスクを自分で設定するアプリケーションと、そういうホストへロードされるDLLは、ホストが選んだ挙動をそのまま受け取ります。だからライブラリは、どちらの結果も仮定できません

整数引数のSystem.Math PowerがSingleオーバーロードへバインドするHotPDF Win64の数値トラップを示す図。10の20乗のPowerは1E20でなく1.0000000200408773E20を返し、10の100乗のPowerは、例外がマスクされていればプラス無限大を、そうでなければEOverflowを出します
精度の劣化が証拠です。10のべきがSingleのノイズ付きで返ってきたら、間違ったオーバーロードの勝ちです — スケールは自分で組み立てましょう
uses
  System.SysUtils, System.Math;

procedure ShowPowerOverload;
var
  N: Integer;
  OldMask: TArithmeticExceptionMask;
begin
  N := 20;
  // Win32は1E20を印字。Win64は1.0000000200408773E20(Singleオーバーロード)
  Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));

  // Delphi 11や、strictなFP設定のホストのやることを再現する
  OldMask := GetExceptionMask;
  SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
  try
    N := 100;
    Writeln(Power(10, N));       // Win64:EOverflow。Win32:1E100
  finally
    SetExceptionMask(OldMask);
  end;
end;

テストの間だけexOverflowとexInvalidOpのマスクを外すのは、古いコンパイラやstrictなホストの見えるものを確認する一番安い方法です。デフォルト設定の現代のコンパイラでは、このバグはクラッシュせず、無限大とゼロを作ります。そしてそれらはテストログの中で、はるかに見つけにくいのです。前のマスクはfinallyで復元してください。マスクはスレッドごとの状態で、テストランの残りはあなたが残したものを引き継ぎます

オーバーロードがHotPDFのSVGとXPSインポートへ届いた経路

HotPDFのSVGとXPSのパスリーダーは1つの数値スキャナーを共有しており、そのスキャナーは指数を読むと、仮数をPower(10, Exponent)でスケールしていました。THotPDF.ImportSVGFormXObject(SVGを再利用可能なフォームXObjectとしてPDFへインポートするの背後にある入口)へ渡される任意のSVGや、XPSとOpenXPSのPDF変換で処理される任意のパスジオメトリは、1e100や5e99のような座標をその呼び出しへ食わせられ得たのです

v2.770.91はすでに指数を100で頭打ちにし、1E300を超える値を拒否していました。十分に見えました。1E100は約1.8E308のDouble上限から遠く離れているからです。ところがWin64ではそれでもオーバーフローしました。計算がそもそもDoubleで起きていなかったからです。v2.770.155以降、スキャナーは10のべきを自分で組み立て、1e-100や、大きな負の指数を持つ長い仮数は、0へ潰れる代わりに実際の値として読まれます

有界な指数のための安全な10のべき

指数が有界なとき、一番安全な10のべきは、Doubleの乗算で自分で組み立てるものです。最大100回の乗算のループは、その周りのテキスト走査に比べればタダ同然で、最終スケールより大きい中間値を決して作らず、Win32、Win64、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;
  // 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;       // 1E100を決して超えない
  if Exponent >= 0 then
    Scaled := Value * Scale
  else
    Scaled := Value / Scale;     // 除算:1E-100に正確なDoubleは存在しない
  Result := True;
end;

重みを担う細部が3つあります。範囲チェックはAbs(Exponent) <= 100でなく2つの比較を使います。Abs(Low(Integer))は依然として負で、そのまますり抜けるからです。負の指数は、事前計算した1E-100を乗算するのでなく、スケールで除算します。1E-100に正確なDoubleは存在せず、丸めステップが1つ増えるからです。そしてLog10の事前検査は、乗算にオーバーフローする機会を与える前に、Double範囲の外の結果を拒否します

ループが何を手放すかは、はっきり言っておきます。1E22までの10のべきはDoubleで正確です。それを過ぎるとすべての乗算が丸め、100回の後、スケールは正しく丸められた1E100から、最終桁の数ユニットぶん離れたところに座っています。描画座標なら見えません。すべての値をビット単位で再現しなければならない汎用のテキストからdoubleへの変換では、足りません。正しく丸められた変換アルゴリズムが代わりに要ります

dcc64がwhileループで古いTList.Countを読むとき

私たちは、Win64コンパイラ(dcc64、コンパイラバージョン37.0)が、リストの末尾から削除するwhile List.Count > Start doループのコードを、Countを再読みするのでなくスタック上の一時変数と比較する形で生成するのを観測しました。これを直した書き換えがfor ... downtoループです。境界は定義上、正確に1度だけ評価されます

このループはv2.769.3に入りました。レンダラーの透過グループコードに、グループ内で作られたソフトマスクを2パス描画をまたいで生かし、後で解放することを教えたものです。クリーンアップは、タイルごとのループの内側、1パスか2パスのforループの後のfinallyブロックに座っていました。形だけに減らすと、beforeとafterはこうなります:

// dcc64(コンパイラバージョン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;

// 置き換え:境界は1度だけ評価され、古くなる一時変数もない
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.CountはDelphi 12以降NativeInt
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

生成されたWin64コードでは、ループ条件のCountと、ボディ内で読まれるCountが、1つのスタックスロットを共有していました。条件は入口で、まだ何も書かれていないそのスロットと比較し、Deleteの後でそれを更新するものはありませんでした。グループが自分のソフトマスクを1つも作っていないとき、ボディはそれでも走り、空のリストにアイテム-1を要求したので、64ビットビルドでは、その種の透過グループを含むすべてのページがEListErrorで失敗しました。同じソースのWin32コードは正しく、v2.770.1がループを置き換えました

レンダラークリーンアップにおけるHotPDF Win64コード生成の罠を示す図。TList.Countを再読みするwhileループは条件とボディの間で1つのスタックスロットを共有し、dcc64はDeleteの後でそれを更新せず、空の透過グループはアイテム-1を解放してEListErrorを上げました。修正は境界が1度だけ評価されるfor downtoループです
実務の教訓は根元の原因より安上がりです。固定境界のdowntoループは古くなれません。そしてdcc64がスイートを回すまで、レンダラーの仕事は終わっていません

これを最小再現へ減らすには至っておらず、DropMasksWhileのような小さなスタンドアローンループは、たぶん正しくコンパイルされるでしょう。周囲のtry/finallyとネストしたループが効いているように見えます。これは、1つのコンパイラバージョンで観測したコード生成として扱い、すべてのWin64コンパイラの既知の欠陥としては扱わないでください。実務の教訓の方が根元の原因より安いのです。条件がコレクションのカウントを再読みし、ボディがそのコレクションを縮めるループは、固定境界のfor ... downtoへ書き直す価値があります。そしてレンダラーの変更には、Win32だけでなく、Win64のフルテストランが要ります

最適化Win64ビルドでしか出ないクラッシュの位置特定

失敗は最適化Win64ビルドでしか再現しなかったので、位置はIDEの外のツールから来ました。小さなプローブプログラムがAddVectoredExceptionHandlerでベクテッド例外ハンドラーを登録し、最初の例外でRtlCaptureStackBackTraceによりスタックを捕捉し、リンカーが-GD付きで書く詳細マップファイルを使って戻りアドレスを関数名へ翻訳しました。その関数を逆アセンブルすると、比較がループボディの内側でしか書かれないスタックスロット[rbp+0x298]を読んでいるのが分かりました。コンパイラを疑う前に欲しいのはこの程度の証拠で、リリースビルドをステップ実行するより時間はかかりませんでした

High(Int64)がDoubleの安全な上限でない理由

DoubleはHigh(Int64)を表現できません。9223372036854775807をDoubleへ変換すると、正確に2^63、最大のInt64の1つ先へ丸められます。Win64ではその変換が比較の内側で起きるので、D <= High(Int64)はD = 2^63でTrueになり、続くRoundやTruncがオーバーフローします

Win32がこれを隠すのは、Powerの問題を隠したのと同じ理由です。比較は64ビット仮数を持つ80ビットExtended精度で走り、そこではHigh(Int64)は正確で、2^63は正しく大きいと比較されます。Win64には頼れるより広い型がありません。範囲外の変換もきれいではありません。私たちのWin64テストではRound(2^63)はLow(Int64)を返しました。静かな符号反転です。exInvalidOpがマスクされていようがいまいが関係ありません。Win32はマスクされたとき同じ値を返し、マスクを外すとEInvalidOpを上げます

HotPDFのInt64境界トラップを示す図。DoubleはHigh(Int64)を表現できないため、Win64の比較は上限を2^63へ切り上げ、2^63に等しいDは検査を通過し、Roundは静かにLow(Int64)を返します。一方Win32は80ビットExtendedで比較し、そこでは境界は正確で、同じ比較はFalseです
1回の変換がバグのすべてです。上限が、まさに排除しようとしている値へ丸められます。だから天井は、厳密なより小なり付きのリテラルとして書きます
式Win32Win64
Power(10, N)、N = 201E201.0000000200408773E20
Power(10, 100)、例外マスクあり(Delphi 12+デフォルト)1E100+Inf
Power(10, 100)、exOverflowマスクなし1E100EOverflow
D <= High(Int64)、D = 2^63FalseTrue
Round(2^63)、exInvalidOpマスクなしEInvalidOpLow(Int64)

HotPDFがこれに出くわしたのは、ドキュメントジョブ値の背後にあるJSONリーダーです。JSONは数値に範囲制限を置かず、旧シリアライザーはFrac(Value) = 0の値をすべてRoundで整数へ変えていたので、まったく合法的な1e19が、マスク次第で、間違った整数か例外になりました。v2.770.169以降、整数として書かれるのはInt64に収まるときだけで、それ以外はすべて浮動小数点テキストを保持し、整数ゲッターは範囲外の値に対して、ラップされた値でなく呼び出し側のデフォルトを返します

const
  TwoPow63 = 9223372036854775808.0;   // 2^63。Doubleと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
  // 呼び出し側が先にNaNと無限大を拒否する。JSONにはその表記がない
  if TryDoubleToInt64(Value, R) then
    Result := IntToStr(R)
  else
  begin
{$IFDEF FPC}
    Str(Value:24, Result);       // FPC Win64のffGeneralは15桁で止まる
    Result := Trim(Result);
{$ELSE}
    Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
  end;
end;

上限は、厳密な<付きのリテラル9223372036854775808.0です。この定数は2^63で、DoubleでもExtendedでも正確なので、比較はすべてのプラットフォームで同じ意味になります。下限は>=を使えます。-2^63は正確にLow(Int64)だからです。IsNanとIsInfiniteを先に、短絡評価でテストするのは、NaNと無限大を、ホストがマスクを外しているときEInvalidOpを上げ得るFracと比較から遠ざけるためです

Win64でfloatからテキストへの変換は実際に何桁くれるのか

3つのコンパイラのうち2つでは、頼んだより少ないです。Free Pascal 3.3.1のWin64でのFloatToStrF(Value, ffGeneral, 17, 0)は15桁で止まるので、1/3は0.333333333333333として返り、2つの異なるDouble値が同じテキストへシリアライズし得ます。Str(Value:24, Text)にTrimが続く形なら、科学表記で17の有効桁を生みます。同じ値で3.3333333333333331E-001です。そしてロケールにかかわらず、小数点記号として常にピリオドを書きます。FPCのHotPDFがビルドマトリクスの一部なら、HotPDFのFree PascalとLazarus Win64サポートノートが、プラットフォーム差の残りを扱います

Delphiは17桁の要求を受け付けますが、2つのDelphiターゲットは出力についてなお食い違います。FloatToStrF(0.1, ffGeneral, 17, 0)はWin32で0.10000000000000001、Win64で0.1になります。Win64 RTLは、フォーマット時にもパース時にも、最終桁の丸め誤差を持ち込み得ます。だから桁を増やしても差は狭まるだけで、すべてのDoubleビットパターンがテキスト往復を生き延びる保証にはなりません。HotPDFのドキュメントはそんな約束をしておらず、あなたのものも、正しく丸められたフォーマッターとパーサーを自分で出荷しないかぎり、すべきではありません。TFormatSettings.Invariantを渡すか、古いDelphiバージョンでは記号を自分で置き換えてください。ドイツ語やフランス語のロケールがJSONへカンマを書き込まないように

Assert.AreEqualがWin64でコンパイルを止める理由

動的配列でのAssert.AreEqual(3, Length(Arr))はWin32向けにはコンパイルでき、Win64向けにはE2532「異なる引数型からジェネリック型引数を推論できませんでした」で失敗します。動的配列のLengthがWin64ではNativeIntを返すからです。片側にIntegerリテラル、もう片側に64ビットのNativeIntがあると、DUnitXのジェネリックなAssert.AreEqual<T>は単一のTに落ち着けず、ビルドが止まります

TList.CountはDelphi 12以降、プロパティがNativeIntになったため、同じエラーを引き起こします。Delphi 11はまだIntegerと宣言しています。stringのLengthは両プラットフォームでIntegerを返し影響を受けないので、このエラーは一部のテストユニットにだけ現れます。型引数を明示的に書き、Assert.AreEqual<NativeInt>(3, Length(Arr))として、コミット前にテストプロジェクトをdcc64でコンパイルしてください。Win32向けにしかビルドしたことのないスイートは、誰か他の人が試すまで、Win64ビルドが壊れていることを教えてくれません

Delphi数値コードのためのWin64移植チェックリスト

  • 整数引数でのPower(とIntPower(の呼び出しを検索する。Double型の値を渡すか、有界な10のべきは自分で組み立てる
  • 数値テストは少なくとも1回、SetExceptionMaskでexOverflowとexInvalidOpを取り除いて、Win32とWin64の両方で走らせる
  • Int64の上限は< 9223372036854775808.0と書き、<= High(Int64)は決して使わない。そしてどんな比較の前にもNaNと無限大を拒否する
  • Fracが0だからというだけで、パースした数をInt64へ変換しない。JSONの数値は遥かに大きくなり得る
  • 削除しながらCountを再読みするwhileループは、固定境界のfor ... downtoループへ書き直す
  • FPC Win64では、15の有効桁を超える必要があるときはStr(Value:24, Text)を使う
  • LengthとCountのアサートにはAssert.AreEqual<NativeInt>を使い、コミット前にテストをdcc64でコンパイルする
  • パーサーやレンダラーへの変更の後は、片方だけでなく、Win32とWin64の両方でフルのリグレッションスイートを走らせる

ここで述べたライブラリ側の修正は、すべてv2.770.169以降HotPDFに入っているので、SVGインポート、XPS変換、透過描画、JSONジョブ処理は、Win64でもWin32と同じように振る舞うようになりました。両プラットフォーム向けにDelphiやC++BuilderからPDFファイルを生成・処理するなら、HotPDF Delphi PDF componentのページにダウンロードと完全な機能リストがあります