הקריאה השימושית הראשונה של מנתח PDF נמצאת בקצה הלא נכון של הקובץ. הפורמט מציב את מצביע ה-startxref בבתים האחרונים, כך שעיבוד של ארכיון בנפח 1.8 GB מתחיל עם חיפוש (seek) אל הזנב, קריאה של קילובייט אחד, ואז קפיצה לכל מקום בו טבלת ההפניות המקורבות (cross-reference) אומרת שנמצא קטלוג המסמך. משם הניתוח הוא הליכה אקראית על פני כל טווח הבתים. כל מה שקלט/פלט (IO) במאגר (buffered) טוב בו - קריאה רציפה קדימה (read-ahead) מאחורי מצביע הקובץ - מכוון לעומס עבודה שאין ל-PDF
הגרסה הראשונה של מאמר זה טענה שקובץ ממופה בזיכרון (memory-mapped file) פותר את כשל הזיכרון (out-of-memory) של 32 סיביות ש-TMemoryStream נתקל בו בקלט של 2 GB. טענה זו שגויה, והאופן שבו היא שגויה מצביע על התיקון האמיתי: חלון מיפוי מחליק (sliding mapping window). להלן דפוס הגישה, סיפור ה-32 סיביות המתוקן עם מְמַפֶּה (mapper) חלונות הניתן להידור, ואריתמטיקת קריאות המערכת (syscall) על קובץ בדיקה בנפח 1.8 GB ו-300,000 אובייקטים
מדוע פריסת PDF מביסה קריאות במאגר
שלוש עובדות מבניות מעצבות את תבנית ה-IO. ראשית, הניווט מונחה היסט (offset-driven): טבלת ההפניות המקורבות ממפה כל מספר אובייקט למיקום בתים מוחלט, ושום דבר לא דורש שהמיקומים הללו יהיו מסודרים. אחרי שנים של עדכונים מצטברים, אובייקט 4102 יכול לשבת בהיסט של 1.6 GB בעוד אובייקט 4103 יושב ב-30 KB. לולאת TFileStream הופכת כל שליפה (fetch) ל-Seek בתוספת Read, שני מעברי ליבה (kernel transitions), עם מאגר שלא תורם כלום כי השליפה הבאה נמצאת במרחק של מאות מגה-בייטים
שנית, זרמי אובייקטים (ISO 32000-1 §7.5.7) אורזים עשרות או מאות מילונים קטנים לתוך מיכל מכווץ אחד. שליפה של מילון עמודים אחד של 300 בתים יכולה להיות קריאה וניפוח של אשכול בגודל 100 KB. הצד השני: אובייקטים שנכתבים יחד נוטים להיקרא יחד, לכן מאגר המותאם לגודל האשכול משרת את התריסר שליפות הבאות בחינם - הסדירות הניתנת לניצול ביותר בפורמט
שלישית, ליניאריזציה. קובץ שעבר ליניאריזציה טוען מראש את העמוד הראשון וטבלת רמזים כדי שצרכנים יוכלו לקרוא אותו מההתחלה לסוף. ארכיונים של ג'יגה-בייטים כמעט אף פעם לא עוברים ליניאריזציה: הליניאריזציה נהרסת על ידי אותם עדכונים מצטברים ומיזוגים שהפכו את הקובץ לגדול. תכננו למקרה העוין: קפיצות ארוכות, ללא סדר, כניסה מהזנב קודם
סיפור ה-32 סיביות, מתוקן
לתהליך של Windows ב-32 סיביות יש 2 GB של מרחב כתובות למשתמש, ו-MapViewOfFile עם ספירת בתים של אפס מבקש הזמנה רציפה אחת בגודל הקובץ. עבור קלט של 2 GB ההזמנה הזו לא יכולה להצליח: לאחר קובץ ה-EXE, קובצי DLL מפוזרים, ומחסניות הליכי משנה, הגוש הרציף החופשי הגדול ביותר בתהליך Delphi טיפוסי של 32 סיביות יושב איפשהו בין 700 MB ל-1.4 GB. הקריאה נכשלת עם ERROR_NOT_ENOUGH_MEMORY, אותו קיר ש-TMemoryStream.LoadFromFile נתקל בו, רק הועבר מזיכרון RAM שהוקצה להזמנת מרחב כתובות. מיפוי קובץ שלם אינו תיקון ב-32 סיביות, רק אותו כשל מאחורי שמות API שנשמעים טוב יותר
התיקון הוא הפרדה של שני הדברים שמיפוי עושה. CreateFileMapping יוצר את אובייקט המקטע (section object) ולא עולה במרחב כתובות בכלל, לא משנה מה גודל הקובץ. רק MapViewOfFile צורך מרחב כתובות, ושום דבר לא מאלץ אותו למפות את כל המקטע: הוא מקבל היסט התחלתי של 64 סיביות ואורך תצוגה. צרו את המקטע פעם אחת, מפו תצוגה של 64 עד 256 MB מעל האזור המנותח, בטלו את המיפוי (unmap) לפני ההחלקה הלאה: עלות מרחב הכתובות היא חלון אחד, לא קובץ אחד. אילוץ אחד: היסטי תצוגה חייבים להיות כפולות של SYSTEM_INFO.dwAllocationGranularity, 64 KB בפועל, לכן בקשה להיסט 1,000,000 מעוגלת כלפי מטה ל-983,040 והמצביע של המתקשר מותאם קדימה לפי ההפרש
ממפה קבצים חלוני ב-Delphi
הכיתה להלן עוטפת את המשמעת כולה: אובייקט מקטע אחד, תצוגה חיה אחת, התאמה מחדש של הפירוט (granularity realignment), וקריאות שחוצות גבול של חלון המטופלות על ידי הגדלת אותה תצוגה אחת במקום תפירה של שתיים
uses
Winapi.Windows, System.SysUtils;
type
TWindowedFileMapper = class
private
FFile: THandle;
FMapping: THandle;
FFileSize: Int64;
FGranularity: DWORD; // SYSTEM_INFO.dwAllocationGranularity
FWindowSize: NativeUInt; // default view size
FViewBase: PByte; // base of the current view (aligned)
FViewOffset: Int64; // file offset FViewBase corresponds to
FViewSize: NativeUInt; // bytes mapped in the current view
procedure Unmap;
public
constructor Create(const FileName: string;
WindowSize: NativeUInt = 64 * 1024 * 1024);
destructor Destroy; override;
function Map(Offset: Int64; Size: NativeUInt): PByte;
procedure ReadBytes(Offset: Int64; var Buffer; Count: NativeUInt);
property FileSize: Int64 read FFileSize;
end;
constructor TWindowedFileMapper.Create(const FileName: string;
WindowSize: NativeUInt);
var
Info: TSystemInfo;
begin
inherited Create;
FFile := CreateFile(PChar(FileName), GENERIC_READ, FILE_SHARE_READ, nil,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
if FFile = INVALID_HANDLE_VALUE then
RaiseLastOSError;
if not GetFileSizeEx(FFile, FFileSize) then
RaiseLastOSError;
// The section object reserves no address space, whatever the file size
FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
if FMapping = 0 then
RaiseLastOSError;
GetSystemInfo(Info);
FGranularity := Info.dwAllocationGranularity; // 64 KB in practice
FWindowSize := WindowSize;
end;
destructor TWindowedFileMapper.Destroy;
begin
Unmap;
if FMapping <> 0 then CloseHandle(FMapping);
if FFile <> INVALID_HANDLE_VALUE then CloseHandle(FFile);
inherited;
end;
procedure TWindowedFileMapper.Unmap;
begin
if FViewBase <> nil then
begin
UnmapViewOfFile(FViewBase);
FViewBase := nil;
FViewSize := 0;
end;
end;
function TWindowedFileMapper.Map(Offset: Int64; Size: NativeUInt): PByte;
var
AlignedOffset: Int64;
Delta, MapSize: NativeUInt;
begin
if (Offset < 0) or (Offset + Int64(Size) > FFileSize) then
raise ERangeError.CreateFmt(
'Map request at %d for %d bytes is outside the file',
[Offset, Int64(Size)]);
// Fast path: the requested range already sits inside the live view
if (FViewBase <> nil) and (Offset >= FViewOffset) and
(Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
Exit(FViewBase + NativeInt(Offset - FViewOffset));
Unmap; // slide: never hold two views at once
// Views must start on an allocation-granularity boundary
AlignedOffset := Offset - (Offset mod FGranularity);
Delta := NativeUInt(Offset - AlignedOffset);
MapSize := FWindowSize;
if MapSize < Size + Delta then // request straddles the window end:
MapSize := Size + Delta; // grow this one view to cover it
if AlignedOffset + Int64(MapSize) > FFileSize then
MapSize := NativeUInt(FFileSize - AlignedOffset); // clamp at EOF
FViewBase := MapViewOfFile(FMapping, FILE_MAP_READ,
DWORD(AlignedOffset shr 32), DWORD(AlignedOffset and $FFFFFFFF),
MapSize);
if FViewBase = nil then
RaiseLastOSError;
FViewOffset := AlignedOffset;
FViewSize := MapSize;
Result := FViewBase + NativeInt(Delta);
end;
procedure TWindowedFileMapper.ReadBytes(Offset: Int64; var Buffer;
Count: NativeUInt);
begin
Move(Map(Offset, Count)^, Buffer, Count);
end;
שני פרטים נושאים במשקל. הנתיב המהיר בחלק העליון של Map מחזיר מצביע ללא מעבר ליבה כאשר הטווח המבוקש כבר יושב בתוך התצוגה החיה; הודות לקיבוץ (clustering) של זרמי אובייקטים, זהו המקרה הנפוץ ושם נמצא החיסכון. ובקשה שחוצה את קצה חלון ברירת המחדל מגדילה את MapSize עבור התצוגה האחת הזו במקום לתפור שתיים, מה ששומר על ReadBytes כשורת קוד אחת ומשחרר את המתקשרים מלולאות של קריאה חלקית
גודל החלון הוא כפתור סלחני: ב-64 MB, סריקה מלאה של קובץ בנפח 1.8 GB היא 29 תצוגות, ב-256 MB זה 8 אבל כל הזמנה קשה יותר להצבה במרחב 32 סיביות מקוטע, ומתחת לכ-16 MB קבצים כבדי-קפיצות ממופים מחדש בתדירות שמורגשת. בכל מקום בטווח של 64 עד 256 MB, תעבורת המיפוי היא רעש סטטיסטי
ספירת קריאות המערכת (syscalls)
עכשיו האריתמטיקה. קובץ בדיקה: 1.8 GB, 300,000 אובייקטים עקיפים בממוצע של כ-600 בתים של מטען נתונים. מנתח לכל-אובייקט שולף כל אחד עם SetFilePointerEx בתוספת ReadFile של 4 KB: 600,000 מעברי ליבה. קריאת מערכת שנשמרה במטמון עושה סיבוב (round-trip) בכ-1.5 מיקרו-שניות (μs) בחומרת x64 נוכחית, כלומר 600,000 × 1.5 מיקרו-שניות ≈ 0.9 שניות של תקורת ליבה טהורה לפני ניתוח של בית בודד - המקרה הטוב ביותר עם מטמון חם. במצב קר, כל קפיצה היא פעולת התקן: בהשהיה (latency) יעילה של ~20 מיקרו-שניות לקריאות אקראיות של 4 KB ב-NVMe, 300,000 מהן עולות כ-6 שניות של זמן התקן; באחסון ברמת SATA, דקות
הקריאות גם מעבירות את הנתונים הלא נכונים: 300,000 × 4 KB דוחפים 1.2 GB דרך מאגרי המשתמש כדי לספק כ-180 MB של מטען נתונים - הגברה של פי שישה, כל בית מועתק מהליבה למשתמש
מאגר קריאה-קדימה המותאם לאשכולות זרמי האובייקטים הוא השיפור הכן הראשון: קריאה אחת של 256 KB לכל אשכול במקום אחת לכל אובייקט חותכת את ספירת המעברים בסדר גודל אחד עד שניים. זהו גם הכלי הנכון כאשר מיפוי הוא מסורבל, בדרך כלל בתיקיות רשת משותפות
הממפה החלוני מרחיק לכת. סריקה מלאה היא 29 קריאות MapViewOfFile ו-29 קריאות UnmapViewOfFile, 58 מעברים מפורשים לעומת 600,000. ניתוח אמיתי מונחה xref אינו סריקה נקייה, אך הנתיב המהיר סופג כל שליפה בתוך החלון החי; מעבר של אינדוקס מטא-נתונים על פני ארכיון הבדיקה התייצב על כמה מאות מיפויים מחדש. המיפוי אינו מסיר עבודת ליבה: הוא ממיר קריאות מערכת מפורשות לתקלות דף (page faults) שמנהל הזיכרון פותר באשכולות מרובי-דפים, היישר ממטמון הקבצים ללא עותק במרחב המשתמש, ואזורים שלעולם לא נוגעים בהם לא עולים כלום. מקצה לקצה, מעבר האינדוקס ירד מ-23 שניות במצב קר ו-7.1 שניות במצב חם עם קריאות לכל-אובייקט ל-6.5 שניות במצב קר ו-1.9 שניות במצב חם עם הממפה; מה שנותר הוא ניפוח zlib, לא IO
היכן משתלב FILE_FLAG_NO_BUFFERING
FILE_FLAG_NO_BUFFERING עוקף את המטמון של המערכת בתמורה לכללי יישור נוקשים: היסטים, אורכים וכתובות מאגר כולם מיושרים למגזר (sector-aligned). הוא מוכיח את יעילותו בעבודות רציפות במעבר יחיד שהיו מציפות את המטמון בבתים שאף אחד לא קורא פעמיים - סדריות-מחדש (re-serialization) באצווה שכותבת מחדש את כל הארכיון, או מעבר ליניאריזציה על פלט מוגמר. עם מאגרים מיושרים של 4 עד 8 MB הוא מתקרב לרוחב הפס הרציף של ההתקן ללא זיהום המטמון
זה בדיוק שגוי עבור ניתוח. קפיצות xref אקראיות דרך מאחיז (handle) ללא מאגר הופכות כל שליפת מילון של 300 בתים לקריאה פיזית מלאה ללא מטמון שיספוג את הביקור השני - וניתוח PDF מבקר מחדש באזורים ללא הרף, מכיוון שעמודים שונים נפתרים לאותם זרמי אובייקטים. IO ללא מאגר עבור השכתוב הרציף, IO ממופה או במטמון עבור הניתוח האקראי; הדגל הוא פר-מאחיז, כך שצינור עיבוד אחד יכול להחזיק את שניהם על אותו קובץ
64 סיביות, מערכי עבודה (working sets) וצד הכתיבה
בבנייה של 64 סיביות התנגדות מרחב הכתובות נעלמת: העבירו את גודל הקובץ כחלון והכיתה לעיל מתנוונת למיפוי מלא יחיד. הקאטש (catch) בשירותים שרצים זמן רב: דפים בגיבוי-קובץ לקריאה-בלבד אינם מחייבים חיוב commit, כך שמוני ה-commit נשארים רגועים, אך כל דף שנוגעים בו מצטרף למערך העבודה; נתחו את רוב ה-1.8 GB ומערך העבודה יגדל בהתאם, תוך פינוי כל השאר. חלונות תחומים שמים תקרה על כך, ולכן התבנית המחליקה נשארת ברירת המחדל הנכונה גם כאשר מרחב הכתובות פנוי
בצד הכתיבה, ה-IO הזול ביותר הוא זה שמעולם לא הופעל. מנגנון העדכון המצטבר של PDF (ISO 32000-1 §7.5.6) מצרף את האובייקטים שהשתנו וחלק חדש של טבלת הפניות מקורבות אחרי הבתים המקוריים, שלעולם אינם זזים. הטבעת עמוד אחד על ארכיון של 1.8 GB מצרפת עשרות קילובייטים; שכתוב מלא מזיז את כל ה-1.8 GB, חמישה סדרי גודל הפרש, והצירוף הוא פלט רציף טהור בזנב
היכן משתלבות ספריות losLab
שתי ספריות ה-PDF של losLab מספקות משמעת זו כשטח API. ה-HotPDF Direct File API קורא ספירות עמודים ומבנה דרך מאחיז קובץ מבלי לבנות את עץ האובייקטים, מעתיק ומפענח ברמת הקובץ, וכותב דלתאות דרך BeginIncrementalUpdate - אסטרטגיית ה-append-only לעיל, ארוזה. PDFlibPas נוקטת באותו מסלול עם שכבת ה-Direct Access שלה: קורא מוזרם שעובר על טבלת ההפניות המקורבות במקום, שולף אובייקטים בעצלות, מחלץ טווחי עמודים מקובץ לקובץ, ושומר עריכות כתיקונים מצטברים. אם אתם כותבים מנתח משלכם, כיתת הממפה שלכם לקחת; אם אתם מריצים צינור עיבוד מסמכים, תנו לספרייה לשמור על החלון ישר
הערה: טיפול מותאם בקלט/פלט עבור מסמכים בקנה מידה של ג'יגה-בייט מובנה ישירות ב-רכיב VCL של HotPDF עבור Delphi ו-C++Builder