מאמר טכני

ניתוח (Parsing) מקבילי של XLSX בדלפי: צוואר הבקבוק של מנהל הזיכרון

HotXLS, ספריית ה-Excel הטבעית עבור דלפי ו-C++Builder, מנתחת גיליונות עבודה של XLSX על גבי מספר תהליכונים (threads) באמצעות טעינה בתלת-שלבית: קובץ ה-XML של הגיליון נפרס סדרתית, מנותח (parsed) במקביל, וחלקים קטנים נקראים סדרתית לאחר מכן. הגרסה הראשונה של תכונה זו הניבה שיפור של 12-25% בלבד, מכיוון שנעילת מנהל הזיכרון כברירת מחדל של דלפי הפכה את תהליכוני העבודה לסדרתיים. הפחתת הקצאות ה-heap מ-20 ל-9.1 לכל תא העלתה את ההאצה המקבילית פי 1.90 בשמונה תהליכונים. מאמר זה מלווה את המדידות, הפניות הלא נכונות ושני התיקונים שאכן עבדו

כיצד HotXLS מנתח גיליונות עבודה של XLSX במקביל?

HotXLS מפצל את ה-Open לשלושה שלבים, ורק האמצעי שבהם רץ על תהליכוני עבודה. הסיבה לכך היא מכולת ה-zip: ארכיון zip הוא זרם קלט משותף אחד עם מכונת מצבים אחת של inflate, ומכונת מצבים זו אינה יכולה להיקרא על ידי שני תהליכונים בו-זמנית. עטיפתה בנעילה תהיה חסרת תועלת, מכיוון ש-inflate הוא סדרתי מטבעו לכל רשומה, כך שנעילה רק תשחזר ריצה סדרתית עם תקורה נוספת. לפיכך, שלב A פורס את ה-XML של כל גיליון עבודה לתוך TMemoryStream משלו בעודו בתהליכון יחיד; בקובץ הבנצ'מרק שלנו זה לקח כ-4 מילי-שניות עבור שמונה חלקי גיליונות, כך שזה רחוק מלהיות צוואר הבקבוק. שלב B מריץ את ParseWorksheetXml עבור כל גיליון על גבי בריכת עובדים (worker pool), וזה המקום שבו נמצא כמעט כל זמן הטעינה. שלב C חוזר ל-zip באופן סדרתי עבור החלקים הקטנים: הערות, שרטוטים, תרשימים וטבלאות

בריכת העובדים עצמה פשוטה במכוון. העובדים מושכים אינדקסים של משימות מתוך מונה משותף בעזרת InterlockedIncrement, כך שגיליונות בגדלים שונים מתאזנים באופן טבעי ללא צורך במתזמן. ספירת התהליכונים היא min(sheet count, CPU cores), החריגה הראשונה של עובד נלכדת בעזרת AcquireExceptionObject ומעולית מחדש בתהליכון הראשי לאחר ההצטרפות, והמנתב יורד ללולאה סדרתית פשוטה כאשר יש אפס או משימה אחת בלבד. שני מאפיינים ב-TXLSXWorkbook שולטים בתכונה זו: ParallelParse המאפשר את הבריכה המקבילית, ו-ParallelParseThreads המגביל את ספירת התהליכונים, כאשר 0 פירושו אוטומטי. חוברות עבודה מרובות גיליונות הן המבנה שמפיק תועלת מכך, כולל הסוג שאתם מייצרים על ידי שכפול גיליון עבודה של תבנית עשרות פעמים

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.ParallelParse := True;      // enable the parallel worker pool
    Book.ParallelParseThreads := 0;  // 0 = auto: min(sheets, CPU cores)
    if Book.Open('quarterly-ledger.xlsx') <= 0 then
      raise Exception.Create('open failed');
    // ... read cells as usual; the workbook is fully materialized ...
  finally
    Book.Free;
  end;
end;

מדוע הוספת תהליכונים הופכת את ניתוח ה-XLSX לאיטי יותר בדלפי?

מכיוון שמנהל הזיכרון של דלפי כברירת מחדל מגן על ה-heap שלו באמצעות נעילה גלובלית, וניתוח גיליון עבודה צפוף בהקצאות: תאים, Variants ו-WideStrings במיליונים. כל תהליכון עבודה שנוגע ב-heap עומד בתור עבור אותה נעילה, כך שתהליכונים שנראים עצמאיים בקוד המקור רצים בפועל כמעט אחד אחד. מדד הביצועים הראשון שלנו הפך זאת למוחשי באופן כואב. בחוברת עבודה בת 8 גיליונות עם 5,000 שורות על 4 עמודות לגיליון, שנמדדה במעבד i5-11600K (בעל 6 ליבות, 12 תהליכונים) תחת Win64, הפונקציה המקבילית Open השתפרה ב-12-25% בלבד מול הערכת תוכנית של לפחות 40%. סריקת ספירת תהליכונים לאורך 2, 3, 4, 6 ו-8 תהליכונים הניבה עקומה שטוחה, ובהרצות מאוחרות יותר תצורה של 2 תהליכונים הייתה למעשה איטית ב-26% מאשר הרצה סדרתית — הסימן הקלאסי של שני תהליכונים המתמסרים בנעילה תחרותית

שלוש מדידות ביססו את האבחנה, וכל אחת מהן הפכה את האינטואיציה הקודמת. ראשית, קובץ זעיר (8 גיליונות של שורה אחת) נפתח ב-1.2 מילי-שנייה, מה שהוכיח שהניתוח מהווה בעצם 100% מזמן ה-Open ולא הייתה עלות קבועה נסתרת להאשים. שנית, מדד ביצועים זעיר של churn הקצאות טהור הראה שמנהל הזיכרון של דלפי מתרחב לאחור (scaling backwards): אותו נפח כולל של 2 מיליון הקצאות אובייקטים ו-AnsiString רץ ב-60% לאט יותר ב-8 תהליכונים מאשר בתהליכון בודד, בעוד שאותו churn מול ה-heap של WideString, שהוא המקצה של COM BSTR ולא ה-MM של דלפי, עלה פי 3.7. העובדה ש-HotXLS משתמש ב-WideString לכל אורך הדרך התבררה כתאונה היסטורית שעבדה לטובתנו. שלישית, GetProcessTimes הראה שבמהלך Open מקבילי, זמן ה-CPU היה שווה בערך לזמן שעון קיר: שמונה תהליכונים נומינליים צרכו כ-1.3 תהליכונים של CPU. עובדי התהליכונים לא ביצעו spin; הם ישנו בנתיב התחרות של מנהל הזיכרון, חסומים ולא עסוקים

הלקח המעשי חל מעבר לגיליונות אלקטרוניים. אם עומס עבודה בדלפי מקצה זיכרון בכבדות, העלאת ספירת התהליכונים אינה מועילה עד שקצב ההקצאות יורד, והיא יכולה בקלות להחמיר את המצב. לפני התיקון הזה, סיפרנו למשתמשים המכוונים את ParallelParseThreads את האמת הכנה: בקבצים המוגבלים בהקצאות, תהליכונים נוספים לא קנו כמעט דבר

מניין מגיעות 20 הקצאות heap לכל תא?

עטיפת ספירה שהותקנה באמצעות SetMemoryManager ענתה על שאלה זו בדיוק: כ-20 הקצאות Delphi-MM לכל תא, כאשר 2.87 מיליון מהן בנפח של 32 בתים או פחות. האשם לא היה באובייקטי התא כלל. TXMLScaner.GetTokenValue יצר AnsiString חדש בכל קריאה, והוא נקרא בערך 15-20 פעמים לכל תא: פעם אחת עבור שמות אלמנטים, שמות מאפיינים, ערכי מאפיינים ותוכן טקסט. בנוסף לכך, נתיב ה-UTF8ToWideString של ה-RTL יצר UnicodeString זמני כשלב ביניים לכל המרה. אובייקטי תאים היו אחראים ל-160 אלף הקצאות בלבד, כ-8% מהסך הכל, מה שחיסל את התוכנית המקורית שלנו בו במקום: התכוונו לבנות בריכת אובייקטי תאים (cell object pool), והמספרים הראו שזה לעולם לא יחזיר את ההשקעה

var
  OldMM, NewMM: TMemoryManagerEx;
  AllocCount, TinyCount: Int64;

function CountingGetMem(Size: NativeInt): Pointer;
begin
  AtomicIncrement(AllocCount);
  if Size <= 32 then
    AtomicIncrement(TinyCount);   // the small-object churn we care about
  Result := OldMM.GetMem(Size);
end;

// install before Open, restore afterwards
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);

אבחון בן עשר דקות זה שווה אימוץ עבור כל חקירת ביצועים בדלפי. ספירת הקצאות לפי גודל אינה עולה כמעט דבר לבנייה ומספרת לכם מכן נובע לחץ מנהל הזיכרון בפועל, שבמקרה שלנו היה שני הרגלים ברמת ה-RTL בתוך סורק ה-XML ולא משהו במודל האובייקטים. פרופיילרים המשיכו להצביע על המנתח (parser) כמכלול; העטיפה הצביעה על שתי שורות ספציפיות

התיקון: אינטרנינג (interning) של אסימונים ומפענח UTF-8 ללא שלבי ביניים

שני שינויים ממוקדים בקורא ה-XML הסירו יותר מחצי מההקצאות לכל תא מבלי לגעת במבנה המנתח. הראשון הוא אינטרנינג (interning) של שמות אלמנטים. קובץ ה-XML של גיליון עבודה חוזר על אוצר מילים קטן ללא הרף: row, c, v, r, t, s, וחופן שמות מאפיינים. הפונקציה InternTokenName מחזיקה מטמון של 64 חריצים של שמות שנראו בעבר ומשווה את באפר הבנייה של הסורק מול ערך השמור במטמון בעזרת TokenEqualsAnsi, השוואת בתים ישירה שאינה מקצה דבר. בהתאמה, היא מחזירה את ה-AnsiString השמור, וכאן בחירת הטיפוס קובעת: AnsiString מנוהל לפי ספירת ייחוסים (reference counted), ולכן החזרת מופע שמור עולה הגדלת ספירת ייחוסים אחת ואפס תעבורת heap. ל-WideString אין ספירת ייחוסים, וכל הקצאה עוברת דרך SysAllocString, כך שאינטרנינג של WideStrings לא היה חוסך דבר. אינטרנינג שווה ביצוע רק על סוג מחרוזת המנוהל לפי ספירת ייחוסים

function TXMLScaner.InternTokenName: AnsiString;
var
  Slot: Integer;
begin
  Slot := TokenHash mod 64;
  if TokenEqualsAnsi(FInternNames[Slot]) then
    Result := FInternNames[Slot]    // refcount++ only, no allocation
  else
  begin
    Result := GetTokenValue;        // materialize once, then cache
    FInternNames[Slot] := Result;
  end;
end;

השינוי השני תוקף את טקסט התא. הנתיב הישן בנה אסימון AnsiString, העביר אותו ל-UTF8ToWideString, אשר בנה UnicodeString כשלב ביניים, ולבסוף זה הומר ל-WideString שהתא מאחסן: שתי הקצאות Delphi-MM לכל אסימון טקסט לפני ההקצאה האמיתית. החלופה, XmlUtf8ToWide(TokenPtr, TokenLen), היא מפענח UTF-8 בפסקל טהור המבוסס על שני מעברים הקורא ישירות מבאפר הסריקה: מעבר אחד מודד את אורך ה-UTF-16, ומעבר שני מפענח לתוך WideString המוקצה פעם אחת. עלות נטו לכל אסימון טקסט: הקצאת COM אחת, אפס הקצאות Delphi-MM. הערה סמנטית אחת לזהירים: ברצפי UTF-8 פגומים המפענח החדש מעביר בתים כמו שהם במקום להחליף בתווי תחליף כפי שעושה ה-RTL, מה שמשפיע רק על האופן שבו קבצים פגומים מדרדרים; בקלט תקין הפלט זהה לחלוטין. ישויות תווים של XML אינן מגיעות למפענח לעולם, מכיוון שהסורק כבר פתר אותן ל-UTF-8 בבאפר האסימונים

מה זה הניב, והיכן ניתוח מקבילי עדיין לא יעזור

שני התיקונים הפחיתו את ההקצאות לכל תא מכ-20 ל-9.1, והמספרים המקביליים זזו כפי שהתיאוריה חזתה. באותו מדד ביצועים של 8 גיליונות ו-5,000 שורות ובאותה מכונת 6C12T, השיפור ב-8 תהליכונים עלה מ-14% ל-47.4%, האצה פי 1.90 לעומת הרצה סדרתית. מקרה 2 התהליכונים נע משלב של 26% איטי יותר ל-23.6% מהיר יותר, וניצול ה-CPU שנמדד עלה מפי 1.0 לפי 2.2. הנתיב הסדרתי הפך למהיר יותר בכ-3% כבונוס, מכיוון שפחות הקצאות מועילות גם לתהליכון בודד. כ-9 ההקצאות הנותרות לכל תא הן בערך חצי אובייקטי תאים וחצי גידול מופחת (amortized) של המכולה; מדדנו אותן, שפטנו את התשואה כפוחתת, ועצרנו, כאשר עטיפת ה-MM מוכנה לדגום מחדש לפי נקודת קריאה אם עומס עבודה עתידי יצדיק סבב נוסף

ראוי לציין את הגבולות בגלוי כמו את ההצלחות. HotXLS מבצע מקביליות ברמת גיליון העבודה, כך שחוברת עבודה המורכבת מגיליון ענק אחד מנותחת בתהליכון בודד ללא קשר למה שמציין המאפיין ParallelParseThreads; עבור מבנה כזה, הקורא הישיר בהזרמה הוא הכלי הטוב יותר, מכיוון שהוא מונע את חומריות חוברת העבודה לחלוטין. קבצים שזמנם מתבזבז על חלקי שלב C, ציורים, תרשימים והערות, רואים פחות תועלת מכיוון ששלב זה נותר סדרתי לפי התכנון. קבצים קטנים אינם שווים תהליכונים כלל, וזו הסיבה שהמנתב מריץ בשקט הרצה סדרתית עבור ספירות משימות זניחות. ותקרת מנהל הזיכרון לא נעלמה, אלא רק נסוגה: ב-9.1 הקצאות לכל תא הנעילה הגלובלית עדיין גובה מחיר מהעובדים, וזו הסיבה שששמונה תהליכונים מניבים פי 1.90 במקום פי 4. עבור ארגז הכלים הרחב יותר לקיצור זמני טעינה ושמירה, כולל סגנונות, בריכות וקריאות callback של שורות בתפזורת, עיינו במדריך שלנו לביצועי חוברות עבודה גדולות בדלפי

ניתוח XLSX מקבילי, המאפיינים ParallelParse ו-ParallelParseThreads, וקורא ה-XML הרזה בהקצאות המתואר כאן מסופקים כחלקים סטנדרטיים של רכיב HotXLS Delphi Excel, הקורא וכותב XLS, XLSX ו-ODS באופן טבעי מדלפי ו-C++Builder ללא התערבות באוטומציה של Excel