اولین خوانش واقعاً مفید در یک PDF در بدترین جای فایل قرار دارد. قالب، اشارهگر startxref را در بایتهای پایانی میگذارد، بنابراین پردازش یک آرشیو 1.8 گیگابایتی با یک seek به انتهای فایل، یک خوانش یک کیلوبایتی و سپس یک پرش به جایی شروع میشود که جدول ارجاع متقاطع میگوید کاتالوگ سند آنجاست. از آن نقطه به بعد، پارس کردن به یک راهپیمایی تصادفی در سراسر بازه بایتها تبدیل میشود. هر چیزی که IO بافرشده در آن خوب است، یعنی read-ahead ترتیبی پشت اشارهگر فایل، برای باری طراحی شده است که PDF اصولاً ندارد
نسخه نخست این مقاله ادعا میکرد فایل memory-mapped میتواند خطای کمبود حافظه سیودوبیتی را که TMemoryStream روی ورودی 2 گیگابایتی میبیند حل کند. این ادعا نادرست بود و دقیقاً همین نقطه خطا به راهحل واقعی اشاره میکند: پنجره نگاشت لغزان. در ادامه الگوی دسترسی، روایت اصلاحشده برای سیودوبیتی با یک windowed mapper قابل کامپایل، و حسابوکتاب syscallها روی یک فایل آزمایشی 1.8 گیگابایتی با 300000 شیء را میبینید
چرا چیدمان PDF خوانش بافرشده را شکست میدهد
سه واقعیت ساختاری الگوی IO را شکل میدهند. اول اینکه ناوبری مبتنی بر offset است: جدول ارجاع متقاطع هر شماره شیء را به یک موقعیت مطلق بایت نگاشت میکند و هیچ چیزی این موقعیتها را مجبور به مرتب بودن نمیکند. بعد از سالها بهروزرسانی افزایشی، شیء 4102 میتواند در offset برابر با 1.6 گیگابایت باشد، در حالی که شیء 4103 در 30 کیلوبایت نشسته است. یک حلقه TFileStream هر واکشی را به یک Seek بهعلاوه یک Read تبدیل میکند، یعنی دو انتقال هسته، با بافری که هیچ کمکی نمیکند چون واکشی بعدی صدها مگابایت دورتر است
دوم، object streamها طبق ISO 32000-1 §7.5.7 دهها یا صدها دیکشنری کوچک را در یک container فشردهشده جمع میکنند. واکشی یک page dictionary سیصدبایتی میتواند به معنی خواندن و inflate کردن یک خوشه 100 کیلوبایتی باشد. روی دیگر ماجرا این است که اشیائی که با هم نوشته شدهاند معمولاً با هم هم خوانده میشوند، بنابراین بافری که به اندازه خوشه باشد، دوازده واکشی بعدی را تقریباً رایگان پوشش میدهد و این بهرهبردنیترین نظم موجود در این قالب است
سوم، linearization است. یک فایل linearized صفحه اول و جدول hint را جلو میآورد تا مصرفکننده بتواند فایل را از ابتدا به انتها بخواند. آرشیوهای گیگابایتی تقریباً هیچوقت linearized نیستند، چون همان بهروزرسانیهای افزایشی و mergeهایی که فایل را بزرگ کردهاند، linearization را هم از بین بردهاند. برای حالت بدبینانه برنامهریزی کنید: پرشهای بلند، بدون ترتیب، و ورود از انتهای فایل
روایت سیودوبیتی، با اصلاح لازم
یک پردازه Windows سیودوبیتی 2 گیگابایت فضای آدرس کاربر دارد و MapViewOfFile با تعداد بایت صفر درخواست یک رزرو پیوسته به اندازه کل فایل را میدهد. برای ورودی 2 گیگابایتی این رزرو نمیتواند موفق شود: بعد از EXE، DLLهای پراکنده و stackهای thread، بزرگترین بلوک آزاد پیوسته در یک پردازه معمولی Delphi سیودوبیتی جایی بین 700 مگابایت تا 1.4 گیگابایت قرار میگیرد. فراخوانی با ERROR_NOT_ENOUGH_MEMORY شکست میخورد؛ همان دیواری که TMemoryStream.LoadFromFile به آن میخورد، فقط این بار از RAM متعهدشده به رزرو فضای آدرس منتقل شده است. نگاشت کل فایل در سیودوبیتی هیچ درمانی نیست و فقط همان شکست را پشت نامهای API خوشآهنگتر پنهان میکند
راهحل این است که دو کاری را که نگاشت انجام میدهد از هم جدا کنیم. CreateFileMapping شیء section را میسازد و فارغ از اندازه فایل هیچ هزینهای در فضای آدرس ندارد. فقط MapViewOfFile فضای آدرس خرج میکند و هیچ چیزی آن را مجبور نمیکند کل section را نگاشت کند: این تابع یک offset آغاز 64 بیتی و یک طول view میگیرد. section را یکبار بسازید، یک view بین 64 تا 256 مگابایت روی ناحیه در حال پارس نگاشت کنید و پیش از لغزاندن آن را unmap کنید: هزینه فضای آدرس یک پنجره است، نه یک فایل کامل. یک محدودیت هم وجود دارد: offsetهای view باید مضربی از SYSTEM_INFO.dwAllocationGranularity باشند که در عمل 64 کیلوبایت است، بنابراین درخواست برای offset برابر با 1000000 به 983040 گرد میشود و اشارهگر فراخواننده به اندازه اختلاف به جلو تنظیم میشود
یک mapper پنجره لغزان در Delphi
کلاس زیر تمام این انضباط را بستهبندی میکند: یک section object، یک view زنده، همترازی دوباره با granularity، و خوانشهایی که از مرز پنجره عبور میکنند با بزرگ کردن همان view واحد مدیریت میشوند، نه با وصله کردن دو view جداگانه
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;
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 وقتی بازه درخواستی از قبل داخل view زنده قرار دارد، بدون هیچ انتقالی به هسته فقط یک اشارهگر برمیگرداند. به لطف خوشهبندی object streamها، همین حالت مسیر غالب است و بخش اصلی صرفهجویی از همینجا میآید. همچنین اگر درخواست از انتهای پنجره پیشفرض عبور کند، بهجای وصله کردن دو view، فقط MapSize برای همان view بزرگ میشود و در نتیجه ReadBytes یک خط ساده میماند و فراخواننده هم وارد حلقههای partial-read نمیشود
اندازه پنجره یک پیچ تنظیم forgiving است: با 64 مگابایت، یک sweep کامل روی فایل 1.8 گیگابایتی به 29 view نیاز دارد؛ با 256 مگابایت این عدد به 8 میرسد، اما هر رزرو در فضای آدرس تکهتکهشده سیودوبیتی سختتر جا میگیرد؛ و زیر حدود 16 مگابایت، فایلهای پرجهش آنقدر remap میشوند که اثرش محسوس میشود. در هرجای بازه 64 تا 256 مگابایت، ترافیک نگاشت عملاً نویز آماری است
شمارش syscallها
حالا سراغ حسابوکتاب برویم. فایل آزمایشی: 1.8 گیگابایت، 300000 شیء غیرمستقیم با میانگین حدود 600 بایت payload. یک parser شیء-به-شیء هر مورد را با یک SetFilePointerEx و یک ReadFile چهارهزاربایتی واکشی میکند: 600000 انتقال به هسته. یک syscall خوانش با کش روی سختافزار x64 امروزی تقریباً 1.5 میکروثانیه رفتوبرگشت دارد، پس 600000 × 1.5 μs حدود 0.9 ثانیه سربار خالص هسته است، آن هم پیش از پارس شدن حتی یک بایت و در بهترین حالت cache-warm. در حالت cold، هر پرش یک عملیات روی دستگاه است: با latency مؤثر حدود 20 میکروثانیه برای خوانش تصادفی 4 کیلوبایتی NVMe، همین 300000 پرش حدود 6 ثانیه زمان دستگاه میبلعد و روی ذخیرهسازهای کلاس SATA به دقیقهها میرسد
خود خوانشها هم داده اشتباه را جابهجا میکنند: 300000 × 4 کیلوبایت برابر با 1.2 گیگابایت داده را از بافرهای کاربر عبور میدهد تا فقط حدود 180 مگابایت payload تحویل دهد، یعنی شش برابر amplification و کپی شدن تکتک بایتها از هسته به کاربر
یک read-ahead buffer که به اندازه خوشههای object stream تنظیم شده باشد، اولین بهبود صادقانه است: یک خوانش 256 کیلوبایتی برای هر خوشه بهجای یک خوانش برای هر شیء، تعداد انتقالها را یک تا دو مرتبه بزرگی کاهش میدهد. این راهحل همچنین در جاهایی که نگاشت سخت است، معمولاً روی اشتراکهای شبکه، ابزار درستتری است
mapper پنجره لغزان یک قدم جلوتر میرود. یک sweep کامل فقط 29 فراخوانی MapViewOfFile و 29 فراخوانی UnmapViewOfFile دارد، یعنی 58 انتقال صریح در برابر 600000 انتقال. یک parse واقعی مبتنی بر xref البته sweep تمیز نیست، اما مسیر سریع هر واکشی داخل پنجره زنده را جذب میکند. در یک گذر نمایهسازی metadata روی همین آرشیو آزمایشی، تعداد remapها به چندصد مورد رسید. نگاشت کار هسته را حذف نمیکند؛ syscallهای صریح را به page faultهایی تبدیل میکند که memory manager آنها را در خوشههای چندصفحهای و مستقیماً از file cache، بدون کپی در فضای کاربر، حل میکند و نواحیای هم که هرگز لمس نمیشوند هیچ هزینهای ندارند. نتیجه سرتاسری این بود که گذر نمایهسازی از 23 ثانیه cold و 7.1 ثانیه warm با خوانشهای شیء-به-شیء، به 6.5 ثانیه cold و 1.9 ثانیه warm با mapper رسید و آنچه باقی ماند zlib inflate بود، نه IO
جایگاه FILE_FLAG_NO_BUFFERING
FILE_FLAG_NO_BUFFERING با کنار زدن system cache در ازای قواعد سختگیرانه همترازی کار میکند: offsetها، طولها و آدرس بافرها همگی باید sector-aligned باشند. این پرچم در کارهای ترتیبی تکگذر که در غیر این صورت cache را با بایتهایی که دوباره خوانده نمیشوند پر میکنند ارزشمند است، مثل re-serialization دستهای که کل آرشیو را بازنویسی میکند یا یک گذر linearization روی خروجی نهایی. با بافرهای همتراز 4 تا 8 مگابایتی میتواند بدون آلوده کردن کش به پهنای باند ترتیبی دستگاه نزدیک شود
اما برای parsing دقیقاً انتخاب اشتباهی است. پرشهای تصادفی xref روی یک handle بدون بافر، هر واکشی دیکشنری سیصدبایتی را به یک خوانش کامل فیزیکی تبدیل میکند، بدون کشی که بازدید دوم را جذب کند، در حالی که parsing فایل PDF مدام به همان نواحی برمیگردد چون صفحات مختلف به همان object streamها resolve میشوند. IO بدون بافر برای بازنویسی ترتیبی، IO نگاشتی یا cache شده برای parse تصادفی؛ چون این پرچم per-handle است، یک pipeline میتواند هر دو را روی یک فایل نگه دارد
شصتوچهاربیتی، working set و سمت نوشتن
در build شصتوچهاربیتی ایراد فضای آدرس از بین میرود: اگر اندازه فایل را به عنوان اندازه پنجره بدهید، کلاس بالا عملاً به یک نگاشت کامل تبدیل میشود. اما در سرویسهای بلندمدت یک نکته باقی میماند: صفحههای فقطخواندنیِ پشتفایل commit مصرف نمیکنند، پس شمارندههای commit آرام میمانند، ولی هر صفحهای که لمس شود وارد working set میشود. اگر بیشترِ فایل 1.8 گیگابایتی را parse کنید، working set هم تا همان حدود رشد میکند و بقیه چیزها را بیرون میراند. پنجرههای محدود روی این رشد سقف میگذارند، بنابراین الگوی لغزان حتی جایی که فضای آدرس آزاد است هم پیشفرض درست باقی میماند
در سمت نوشتن، ارزانترین IO همان IOای است که اصلاً صادر نمیشود. سازوکار incremental update در PDF طبق ISO 32000-1 §7.5.6 اشیای تغییرکرده و یک بخش ارجاع متقاطع جدید را بعد از بایتهای اصلی append میکند و بایتهای قبلی هرگز جابهجا نمیشوند. اگر فقط یک صفحه را روی آرشیو 1.8 گیگابایتی stamp کنید، چند ده کیلوبایت append میشود؛ در حالی که بازنویسی کامل کل 1.8 گیگابایت را جابهجا میکند. این دو پنج مرتبه بزرگی فاصله دارند و append هم یک خروجی کاملاً ترتیبی در انتهای فایل است
جایگاه کتابخانههای losLab
هر دو کتابخانه PDF شرکت losLab این انضباط را در سطح API ارائه میکنند. HotPDF Direct File API بدون ساختن درخت شیء، تعداد صفحات و ساختار را از طریق file handle میخواند، کپی و decrypt را در سطح فایل انجام میدهد و deltaها را از طریق BeginIncrementalUpdate مینویسد؛ یعنی همان راهبرد append-only که بالاتر توضیح داده شد اما بهصورت بستهبندیشده. PDFlibPas هم با لایه Direct Access همین مسیر را میرود: یک reader جریانی که جدول ارجاع متقاطع را درجا پیمایش میکند، اشیاء را lazy واکشی میکند، بازههای صفحه را از فایل به فایل استخراج میکند و ویرایشها را بهصورت بازبینیهای افزایشی پایدار میسازد. اگر parser خودتان را مینویسید، کلاس mapper را بردارید و استفاده کنید؛ اگر هم یک pipeline سند اجرا میکنید، بگذارید کتابخانه این پنجره را صادق نگه دارد
نکته: رسیدگی به IO بهینه برای اسناد در مقیاس گیگابایت بهصورت مستقیم در HotPDF VCL Component برای Delphi و C++Builder تعبیه شده است