מאמר טכני

באגי Delphi ל-Win64 בלבד שהתגלו בחיזוק HotPDF

קוד Delphi ל-Win64 יכול להיכשל היכן שאותו מקור רץ בצורה נקייה על Win32, ורכיב HotPDF Delphi PDF פגע בחמישה מקרים כאלה במהלך מעבר חיזוק אחרון: Power(10, N) שנקשר אל overload ה-Single, לולאת while שקוראת TList.Count מתיישן, גבול High(Int64) שמעוגל מעלה אל 2^63, טקסט float בן 15 ספרות על FPC, ו-asserts של בדיקות שעוצרים את ההידור

אף אחד מאלה לא מופיע אם בונים ובודקים Win32 בלבד, וכך בדיוק הם דלפו פנימה. המקרים שלהלן מגיעים ממייבאי ה-SVG וה-XPS של HotPDF, מה-renderer לעמודים שלו ומקורא ה-Job של ה-JSON שלו, והתוצאות המספריות המצוטטות שוחזרו עם תוכניות probe קטנות שנבנו עבור Win32 ו-Win64. אם מעבירים codebase של Delphi אל 64 ביט, כל אחד מהם שווה grep

למה Power(10, 100) גולש רק על Win64?

על Win64, System.Math.Power(10, N) עם ארגומנטים שלמים נפתר אל overload ה-Single, ולכן התוצאה מחושבת ומוחזרת בדיוק של single וכל דבר מעל כ-3.4E38 גולש. על Win32 אותה קריאה נקשרת אל overload ה-Extended ורצה על ה-FPU של ה-x87 בדיוק של 80 ביט, ולכן Power(10, 100) הוא פשוט 1E100

System.Math מצהיר על Power עבור Extended, Double ו-Single, בתוספת משפחת IntPower תואמת ש-Power קורא לה כשהמעריך הוא מספר שלם. על Win64, Extended הוא רק כינוי עבור Double (SizeOf(Extended) = 8), ועבור שני ארגומנטים שלמים המהדר בוחר בגרסת ה-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ים שנטענים לתוך מארחים כאלה, מקבלות כל התנהגות שהמארח בחר, ולכן ספרייה לא יכולה להניח אף תוצאה

מלכודת מספרית של Win64 ב-HotPDF שבה System.Math Power עם ארגומנטים שלמים נקשר אל overload ה-Single, כך ש-Power של 10 בחזקת 20 מחזיר 1.0000000200408773E20 במקום 1E20, ו-Power של 10 בחזקת 100 מניב אינסוף חיובי כשהחריגות ממוסכות או EOverflow כשהן אינן
אובדן הדיוק הוא המסגיר: אם חזקה של עשר חוזרת עם רעש Single מחובר, ה-overload הלא נכון ניצח — בונים את הקנה בעצמכם
uses
  System.SysUtils, System.Math;

procedure ShowPowerOverload;
var
  N: Integer;
  OldMask: TArithmeticExceptionMask;
begin
  N := 20;
  // Win32 מדפיס 1E20; Win64 מדפיס 1.0000000200408773E20 (overload של Single)
  Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));

  // משחזר את מה ש-Delphi 11, או מארח עם הגדרות 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 למשך בדיקה היא הדרך הזולה ביותר לראות מה מהדר ישן יותר או מארח מחמיר רואה. על מהדר מודרני עם הגדרות ברירת המחדל הבאג לא קורס, הוא מייצר אינסופים ואפסים, ואלה קשים הרבה יותר לאיתור בלוג בדיקות. משחזרים את המסכה הקודמת ב-finally: המסכה היא state לכל thread, ושאר הרצת הבדיקות יורשת כל מה שמשאירים מאחור

איך ה-overload הגיע אל ייבוא ה-SVG וה-XPS של HotPDF

קוראי הנתיבים של ה-SVG וה-XPS של HotPDF חולקים סורק מספרים אחד, ואותו סורק התאים את ה-mantissa עם Power(10, Exponent) ברגע שקרא מעריך. כל SVG שמועבר אל THotPDF.ImportSVGFormXObject (נקודת הכניסה שמאחורי ייבוא SVG ל-PDF בתור form XObjects לשימוש חוזר), וכל גאומטריית נתיב שמטופלת במהלך המרת XPS ו-OpenXPS ל-PDF, יכלה אפוא להזרים קואורדינטה כמו 1e100 או 5e99 אל אותה קריאה

v2.770.91 כבר חסם את המעריך ב-100 ודחה ערכים שיעברו 1E300, מה שנראה מספיק: 1E100 רחוקה מאוד מגבול ה-Double של כ-1.8E308. על Win64 זה עדיין גלש, כי החישוב מעולם לא קרה בתוך Double כלל. מאז v2.770.155 הסורק בונה את החזקה של עשר בעצמו, ומספרים כמו 1e-100, או mantissa ארוך עם מעריך שלילי גדול, נקראים בערכם האמיתי במקום לקרוס ל-0

חזקה בטוחה של עשר עבור מעריכים חסומים

כשהמעריך חסום, החזקה הבטוחה ביותר של עשר היא כזו שבונים בעצמכם עם כפל של 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;

שלושה פרטים נושאים את המשקל. בדיקת הטווח משתמשת בשתי השוואות במקום Abs(Exponent) <= 100, כי Abs(Low(Integer)) עדיין שלילי והיה חולף היישר. מעריכים שליליים מחלקים בקנה במקום לכפול ב-1E-100 מחושב מראש, שאין לו Double מדויק והיה מוסיף צעד עיגול נוסף. והבדיקה המקדימה של Log10 מסרבת לתוצאות מחוץ לטווח ה-Double לפני שלכפל יש הזדמנות לגלוש

היו ברורים לגבי מה שהלולאה מוותרת עליו. חזקות של עשר עד 1E22 מדויקות בתוך Double; מעבר לזה כל כפל מעגל, ואחרי 100 כפלים הקנה יושב בכמה יחידות בספרה האחרונה מה-1E100 המעוגל נכון. עבור קואורדינטות ציור זה בלתי נראה. עבור המרה כללית מטקסט אל double שחייבת לשחזר כל ערך ביט בביט, זה לא מספיק, וצריך אלגוריתם המרה מעוגל נכון במקום

כשdcc64 קורא TList.Count מתיישן בלולאת while

ראינו את המהדר ל-Win64 (dcc64, גרסת מהדר 37.0) מייצר קוד עבור לולאת while List.Count > Start do שמחקה מסוף הרשימה והשוותה מול זמני מחסנית במקום לקרוא מחדש את Count. הכתיבה מחדש שתיקנה אותה הייתה לולאת for ... downto, שגבולותיה מוערכים בדיוק פעם אחת כבהגדרה

הלולאה הגיעה ב-v2.769.3, שלימד את קוד קבוצת ה-transparency של ה-renderer להשאיר soft masks שנוצרו בתוך קבוצה חיים על פני רינדור דו-שלבי ולשחרר אותם אחר כך. הניקוי ישב בבלוק finally אחרי לולאת for של שלב אחד או שניים, בתוך לולאת ה-per-tile. מצומצם לצורתו, לפני ואחרי נראים כך:

// הצורה שראינו מתקמפלת שגוי על ידי 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;

// החלפה: הגבולות מוערכים פעם אחת, אין זמני שיכול להתיישן
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
  Idx: NativeInt;               // TList.Count הוא NativeInt מאז Delphi 12
begin
  for Idx := Masks.Count - 1 downto Start do
  begin
    TObject(Masks[Idx]).Free;
    Masks.Delete(Idx);
  end;
end;

בקוד ה-Win64 שנוצר, ה-Count בתנאי הלולאה וה-Count שנקרא בתוך הגוף חלקו תא מחסנית אחד. התנאי השווה מול אותו תא בכניסה, לפני שמשהו כתב אותו, ושום דבר לא ריענן אותו אחרי Delete. כשקבוצה לא יצרה soft masks משלה, הגוף רץ בכל זאת וביקש מרשימה ריקה את הפריט -1, ולכן ב-builds של 64 ביט כל עמוד שמכיל קבוצת transparency כזו נכשל עם EListError. הקוד ל-Win32 עבור אותו מקור היה נכון, ו-v2.770.1 החליף את הלולאה

מלכודת codegen של Win64 ב-HotPDF בניקוי ה-renderer: לולאת while שקוראת מחדש TList.Count חלקה תא מחסנית אחד בין התנאי לגוף, dcc64 מעולם לא ריענן אותו אחרי Delete, קבוצות transparency ריקות שחררו את הפריט -1 והעלו EListError, והתיקון הוא לולאת for downto שגבולותיה מוערכים פעם אחת
הלקח המעשי עולה פחות משורש הבעיה: לולאות downto בעלות גבול קבוע לא יכולות להתיישן, ועבודת ה-renderer אינה מוגמרת עד ש-dcc64 הריץ את הסוויטה

לא צמצמנו את זה לשחזור מינימלי, ולולאה קטנה בודדת כמו DropMasksWhile עשויה בהחלט להתקמפל נכון; ה-try/finally המקיף והלולאות המקוננות נראים משפיעים. מתייחסים אל זה בתור יצירת קוד שראינו על גרסת מהדר אחת, ולא בתור פגם מוכר של כל מהדר Win64. הלקח המעשי זול משורש הבעיה: לולאה שהתנאי שלה קורא מחדש את המניין של אוסף בזמן שהגוף מכווץ את אותו אוסף שווה כתיבה מחדש בתור for ... downto בעל גבול קבוע, ושינויי renderer זקוקים להרצת בדיקות Win64 מלאה, לא רק Win32

איתור קריסה שרק build ממוטב של Win64 מציג

הכישלון שוחזר רק ב-build הממוטב של Win64, ולכן המיקום הגיע מכלים מחוץ ל-IDE. תוכנית probe קטנה רשמה vectored exception handler עם AddVectoredExceptionHandler, לכדה את המחסנית בחריגה הראשונה עם RtlCaptureStackBackTrace, ותרגמה את כתובות ההחזרה לשמות פונקציות בעזרת קובץ ה-map המפורט שהלינקר כותב עם -GD. פירוק אותה פונקציה הציג אז את ההשוואה קוראת תא מחסנית, [rbp+0x298], שנכתב רק בתוך גוף הלולאה. זו רמת הראיה שרוצים לפני שמאשימים מהדר, והיא לקחה פחות זמן מלדרוך build של release

למה High(Int64) אינו גבול עליון בטוח עבור Double?

Double לא יכול לייצג את High(Int64): המרה של 9223372036854775807 אל Double מעגלת מעלה אל 2^63 בדיוק, אחד מעבר ל-Int64 הגדול ביותר. על Win64 ההמרה הזאת קורית בתוך ההשוואה עצמה, ולכן D <= High(Int64) הוא True עבור D = 2^63, וה-Round או ה-Trunc שאחריו גולשים

Win32 מסתיר את זה מאותה סיבה שהסתיר את בעיית ה-Power. ההשוואה רצה בדיוק של Extended בן 80 ביט עם mantissa בן 64 ביט, שבו High(Int64) מדויק ו-2^63 מושווה נכון בתור גדול יותר. ל-Win64 אין סוג רחב יותר ליפול אליו. ההמרה מחוץ לטווח אינה יפה גם היא: בבדיקות ה-Win64 שלנו Round(2^63) החזיר Low(Int64), היפוך סימן שקט, בין אם exInvalidOp היה ממוסך ובין לאו. Win32 מחזיר את אותו ערך כשממוסך ומעלה EInvalidOp כשלא ממוסך

מלכודת גבול ה-Int64 של HotPDF: Double לא יכול לייצג את High(Int64), ולכן השוואה של Win64 ממירה את הגבול מעלה אל 2^63, D השווה ל-2^63 עובר את הבדיקה ו-Round מחזיר בשקט Low(Int64), בזמן ש-Win32 משווה ב-Extended בן 80 ביט שבו הגבול מדויק ואותה השוואה היא False
המרה אחת היא כל הבאג: הגבול מעוגל בדיוק אל הערך שמוציאים מן הכלל, ולכן כותבים את התקרה בתור literal עם קטן-ממש
ביטוי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 שמאחורי ערכי ה-Job של המסמך שלו. JSON אינו מטיל מגבלת טווח על מספרים, והסריאליזר הישן הפך כל ערך עם Frac(Value) = 0 למספר שלם עם Round, כך ש-1e19 לגיטימי לחלוטין הפך או למספר שלם שגוי או לחריגה, בהתאם למסכה. מאז v2.770.169 מספר שלם נכתב בתור integer רק כשהוא נכנס לתוך Int64, הכול האחר שומר על טקסט נקודה-הצפה שלו, וה-getters השלמים מחזירים את ברירת המחדל של הקורא עבור ערכים מחוץ לטווח במקום ערך מתגלגל

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;

הגבול העליון הוא ה-literal 9223372036854775808.0 עם < מחמיר. הקבוע הזה הוא 2^63, מדויק גם ב-Double וגם ב-Extended, ולכן ההשוואה אומרת את אותו דבר על כל פלטפורמה. הגבול התחתון יכול להשתמש ב->= כי 2^-63 הוא בדיוק Low(Int64). בדיקת IsNan ו-IsInfinite קודם, עם הערכת short-circuit, מרחיקה NaN ואינסופים מה-Frac ומההשוואות, שיכולות להעלות EInvalidOp כשהמארח הסיר מהן את המסכה

כמה ספרות המרה מ-float לטקסט נותנת באמת על Win64?

פחות משמבקשים, על שני מהדרים מתוך שלושה. FloatToStrF(Value, ffGeneral, 17, 0) של Free Pascal 3.3.1 על Win64 עוצר ב-15 ספרות משמעותיות, כך ש-1/3 חוזר בתור 0.333333333333333 ושני ערכי Double שונים יכולים להמספר לטקסט זהה. Str(Value:24, Text) ואז Trim מניב 17 ספרות משמעותיות בכתיב מדעי, 3.3333333333333331E-001 עבור אותו ערך, ותמיד כותב נקודה בתור מפריד עשרוני ללא קשר ל-locale. אם HotPDF על FPC הוא חלק ממטריצת ה-builds שלכם, הערות התמיכה של HotPDF ל-Free Pascal ו-Lazarus Win64 מכסות את שאר הבדלי הפלטפורמה

Delphi מקבל את הבקשה של 17 הספרות, אבל שני יעדי ה-Delphi עדיין חלוקים על הפלט: FloatToStrF(0.1, ffGeneral, 17, 0) נותן 0.10000000000000001 על Win32 ו-0.1 על Win64. ה-RTL של Win64 יכול גם להכניס שגיאת עיגול בספרה האחרונה גם בעת עיצוב וגם בעת פענוח, ולכן יותר ספרות מצמצמות את הפער בלי לערוב לכך שכל תבנית ביטים של Double שורדת נסיעה-עגולה בטקסט. התיעוד של HotPDF אינו מבטיח דבר כזה, וגם שלכם לא צריך, אלא אם מספקים מעצב ומפענח מעוגלים נכון משלכם. מעבירים TFormatSettings.Invariant, או מחליפים את המפריד בעצמכם על גרסאות Delphi ישנות, כך ש-locale גרמני או צרפתי לא יכתוב פסיק לתוך JSON

למה Assert.AreEqual מפסיק להתקמפל על Win64?

Assert.AreEqual(3, Length(Arr)) על מערך דינמי מתקמפל עבור Win32 ונכשל עבור Win64 עם E2532, "Couldn't infer generic type argument from different argument types", כי Length של מערך דינמי מחזיר NativeInt על Win64. עם literal של Integer בצד אחד ו-NativeInt של 64 ביט בצד השני, ה-Assert.AreEqual<T> הגנרי של DUnitX אינו יכול להתייצב על T יחיד, וה-build נעצר

TList.Count מדליק את אותה שגיאה מאז Delphi 12, שבה ה-property הפך ל-NativeInt; Delphi 11 עדיין מצהיר עליו בתור Integer. Length של string מחזיר Integer על שתי הפלטפורמות ואינו מושפע, ולכן השגיאה מופיעה בחלק מיחידות הבדיקות ולא באחרות. כותבים את ארגומנט הסוג במפורש, Assert.AreEqual<NativeInt>(3, Length(Arr)), ומהדרים את פרויקט הבדיקות עם dcc64 לפני commit. סוויטה שבונה רק עבור Win32 לא תגיד לכם שה-build שלה ל-Win64 שבור עד שמישהו אחר ינסה

צ'קליסט העברה ל-Win64 עבור קוד מספרי ב-Delphi

  • מחפשים קריאות Power( ו-IntPower( עם ארגומנטים שלמים; מעבירים ערכים מסוג Double או בונים חזקות עשר חסומות בעצמכם
  • מריצים בדיקות מספריות לפחות פעם אחת עם exOverflow ו-exInvalidOp מוסרים דרך SetExceptionMask, על Win32 ו-Win64 כאחד
  • כותבים את הגבול העליון של Int64 בתור < 9223372036854775808.0, לעולם לא <= High(Int64), ודוחים NaN ואינסופים לפני כל השוואה
  • לא ממירים מספר שנפרסר אל Int64 רק כי Frac הוא 0; מספרי JSON יכולים להיות גדולים הרבה יותר
  • כותבים מחדש לולאות while שקוראות מחדש Count בזמן מחיקת פריטים בתור לולאות for ... downto בעלות גבול קבוע
  • על FPC Win64, משתמשים ב-Str(Value:24, Text) כשצריך יותר מ-15 ספרות משמעותיות
  • משתמשים ב-Assert.AreEqual<NativeInt> עבור asserts של Length ו-Count, ומהדרים בדיקות עם dcc64 לפני commit
  • אחרי כל שינוי בפרסר או ב-renderer, מריצים את סוויטת ה-regression המלאה על Win32 ו-Win64, לא רק על אחד מהם

תיקוני הספרייה שתוארו כאן כולם בתוך HotPDF מאז v2.770.169, ולכן ייבוא SVG, המרת XPS, רינדור transparency וטיפול ב-Job של JSON מתנהגים עכשיו אותו דבר על Win64 כמו על Win32. אם מייצרים או מעבדים קבצי PDF מ-Delphi או C++Builder עבור שתי הפלטפורמות, בעמוד HotPDF Delphi PDF component יש את ההורדות ואת רשימת היכולות המלאה