مقاله فنی

بهینه‌سازی ورودی/خروجی برای PDFهای گیگابایتی در دلفی

نخستین خواندن سودمند یک تجزیه‌گر PDF در انتهای نادرست فایل است. این قالب اشاره‌گر startxref را در بایت‌های پایانی می‌گذارد، پس پردازش یک بایگانی 1.8 گیگابایتی با یک جست‌وجو به دُم فایل، یک خواندن یک‌کیلوبایتی و سپس پرشی به هر جا که جدول ارجاع متقاطع می‌گوید کاتالوگ سند آنجاست آغاز می‌شود. از آن پس، تجزیه یک پیمایش تصادفی در سراسر بازهٔ بایت‌هاست. هر چیزی که ورودی/خروجی بافردار در آن خوب است — پیش‌خوانی ترتیبی پشت سر اشاره‌گر فایل — به سمت بار کاری‌ای نشانه رفته که PDF ندارد

نسخهٔ نخست این مقاله ادعا می‌کرد فایل نگاشته‌شده در حافظه شکست کمبود حافظهٔ 32 بیتی را که TMemoryStream روی ورودی دو گیگابایتی می‌خورد حل می‌کند. آن ادعا نادرست است، و شکل نادرستی‌اش به راه‌حل واقعی اشاره می‌کند: یک پنجرهٔ نگاشت لغزان. آنچه در ادامه می‌آید الگوی دسترسی، روایت اصلاح‌شدهٔ 32 بیتی همراه با یک نگاشتگر پنجره‌ای قابل کامپایل، و حساب فراخوانی‌های سیستمی روی یک فایل آزمون 1.8 گیگابایتی با 300,000 شیء است

چرا چیدمان PDF خواندن‌های بافردار را شکست می‌دهد

سه واقعیت ساختاری الگوی ورودی/خروجی را شکل می‌دهند. نخست، ناوبری افست‌محور است: جدول ارجاع متقاطع هر شمارهٔ شیء را به یک موقعیت بایتی مطلق نگاشت می‌کند و هیچ‌چیز آن موقعیت‌ها را به مرتب بودن ملزم نمی‌کند. پس از سال‌ها به‌روزرسانی افزایشی، شیء 4102 می‌تواند در افست 1.6 گیگابایت بنشیند در حالی که شیء 4103 در 30 کیلوبایت است. حلقهٔ TFileStream هر واکشی را به یک Seek به‌علاوهٔ یک Read بدل می‌کند، دو گذار به هسته، با بافری که هیچ نمی‌افزاید چون واکشی بعدی صدها مگابایت آن‌سوتر است

دوم، استریم‌های شیء (ISO 32000-1 §7.5.7) ده‌ها یا صدها دیکشنری کوچک را در یک ظرف فشرده‌شده بسته‌بندی می‌کنند. واکشی یک دیکشنری صفحهٔ 300 بایتی می‌تواند به معنای خواندن و بادکردن یک خوشهٔ 100 کیلوبایتی باشد. روی دیگر سکه: اشیایی که با هم نوشته شده‌اند معمولاً با هم خوانده می‌شوند، پس بافری هم‌اندازهٔ خوشه ده‌ها واکشی بعدی را مجانی سرویس می‌دهد — قابل بهره‌برداری‌ترین قاعده‌مندی در این قالب

سوم، خطی‌سازی. فایل خطی‌شده صفحهٔ نخست و یک جدول راهنما را جلو می‌اندازد تا مصرف‌کننده بتواند آن را از ابتدا به انتها بخواند. بایگانی‌های گیگابایتی تقریباً هرگز خطی‌شده نیستند: خطی‌سازی را همان به‌روزرسانی‌های افزایشی و ادغام‌هایی نابود می‌کنند که فایل را بزرگ کرده‌اند. برای بدترین حالت برنامه بریزید: پرش‌های بلند، بی‌نظمی، ورود از انتها

PDF: الگوی دسترسی پرش‌به‌پرش در پردازش PDFهای گیگابایتی که از startxref در دُم فایل وارد می‌شود و سپس افست‌های پراکندهٔ ارجاع متقاطع را می‌پیماید
ناوبری PDF از دُم فایل وارد می‌شود و سپس به هر جا که جدول ارجاع متقاطع اشاره کند می‌پرد، و همین پیش‌خوانی ترتیبی را شکست می‌دهد

روایت 32 بیتی، اصلاح‌شده

یک فرایند 32 بیتی ویندوز 2 گیگابایت فضای آدرس کاربر دارد، و MapViewOfFile با شمار بایت صفر یک رزرو پیوستهٔ هم‌اندازهٔ فایل می‌خواهد. برای ورودی دو گیگابایتی آن رزرو نمی‌تواند موفق شود: پس از فایل EXE، کتابخانه‌های پویای پراکنده و پشته‌های نخ، بزرگ‌ترین بلوک آزاد پیوسته در یک فرایند معمول دلفی 32 بیتی جایی میان 700 مگابایت و 1.4 گیگابایت می‌نشیند. فراخوانی با ERROR_NOT_ENOUGH_MEMORY شکست می‌خورد، همان دیواری که TMemoryStream.LoadFromFile به آن می‌خورد، فقط از حافظهٔ تعهدشده به رزرو فضای آدرس نقل مکان کرده است. نگاشت کامل فایل روی 32 بیتی راه‌حل نیست، فقط همان شکست است پشت نام‌های API خوش‌آواتر

راه‌حل جدا کردن دو کاری است که نگاشت انجام می‌دهد. تابع CreateFileMapping شیء سکشن را می‌سازد و هیچ فضای آدرسی خرج نمی‌کند، اندازهٔ فایل هرچه باشد. تنها MapViewOfFile فضای آدرس خرج می‌کند، و هیچ‌چیز آن را به نگاشتن کل سکشن وادار نمی‌کند: یک افست آغاز 64 بیتی و یک طول نما می‌گیرد. سکشن را یک بار بسازید، نمایی 64 تا 256 مگابایتی روی ناحیه‌ای که تجزیه می‌شود بنگارید، و پیش از لغزیدن آن را باز کنید: هزینهٔ فضای آدرس یک پنجره است، نه یک فایل. یک قید هست: افست‌های نما باید مضربی از SYSTEM_INFO.dwAllocationGranularity باشند، در عمل 64 کیلوبایت، پس درخواست برای افست 1,000,000 به 983,040 گرد پایین می‌شود و اشاره‌گر فراخواننده به اندازهٔ تفاوت جلو می‌رود

یک نگاشتگر پنجرهٔ لغزان در دلفی

کلاس زیر تمام این انضباط را می‌پیچد: یک شیء سکشن، یک نمای زنده، بازترازسازی دانه‌بندی، و خواندن‌هایی که از مرز پنجره می‌گذرند و با بزرگ کردن همان یک نما به جای دوختن دو نما مدیریت می‌شوند

PDF: فضای آدرس تکه‌تکه‌شدهٔ 32 بیتی که MapViewOfFile سراسر فایل را رد می‌کند در حالی که یک سکشن CreateFileMapping و یک پنجرهٔ نگاشت لغزان 64 مگابایتی موفق می‌شوند
یک شیء سکشن به‌علاوهٔ یک نمای زنده، یک PDF یک‌ونیم‌گیگابایتی را درون فضای آدرس 2 گیگابایتی یک فرایند 32 بیتی دلفی خواندنی نگه می‌دارد
uses
  Winapi.Windows, System.SysUtils;

type
  TWindowedFileMapper = class
  private
    FFile: THandle;
    FMapping: THandle;
    FFileSize: Int64;
    FGranularity: DWORD;      // SYSTEM_INFO.dwAllocationGranularity
    FWindowSize: NativeUInt;  // اندازهٔ پیش‌فرض نما
    FViewBase: PByte;         // پایهٔ نمای جاری (تراز شده)
    FViewOffset: Int64;       // افست فایلی که FViewBase با آن متناظر است
    FViewSize: NativeUInt;    // بایت‌های نگاشته‌شده در نمای جاری
    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;
  // شیء سکشن هیچ فضای آدرسی رزرو نمی‌کند، اندازهٔ فایل هرچه باشد
  FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
  if FMapping = 0 then
    RaiseLastOSError;
  GetSystemInfo(Info);
  FGranularity := Info.dwAllocationGranularity;  // در عمل 64 کیلوبایت
  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)]);

  // مسیر سریع: بازهٔ درخواستی همین حالا درون نمای زنده نشسته است
  if (FViewBase <> nil) and (Offset >= FViewOffset) and
     (Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
    Exit(FViewBase + NativeInt(Offset - FViewOffset));

  Unmap;  // لغزش: هرگز دو نما را همزمان نگه ندارید

  // نماها باید روی مرز دانه‌بندی تخصیص آغاز شوند
  AlignedOffset := Offset - (Offset mod FGranularity);
  Delta := NativeUInt(Offset - AlignedOffset);

  MapSize := FWindowSize;
  if MapSize < Size + Delta then   // درخواست از انتهای پنجره می‌گذرد:
    MapSize := Size + Delta;       // همین یک نما را تا پوشش آن بزرگ کن
  if AlignedOffset + Int64(MapSize) > FFileSize then
    MapSize := NativeUInt(FFileSize - AlignedOffset);  // محدود کردن در 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 وقتی بازهٔ درخواستی از پیش درون نمای زنده نشسته باشد اشاره‌گری بدون هیچ گذار به هسته برمی‌گرداند؛ به لطف خوشه‌بندی استریم‌های شیء این حالت رایج است و صرفه‌جویی از همین‌جا می‌آید. و درخواستی که از انتهای پنجرهٔ پیش‌فرض می‌گذرد MapSize را برای همان یک نما بزرگ می‌کند به جای آنکه دو نما را بدوزد، و همین ReadBytes را یک‌خطی و فراخوانندگان را از حلقه‌های خواندن جزئی آزاد نگه می‌دارد

اندازهٔ پنجره یک پیچ بخشنده است: در 64 مگابایت یک جاروی کامل روی فایل 1.8 گیگابایتی 29 نما می‌شود، در 256 مگابایت 8 نما، اما جا دادن هر رزرو در فضای تکه‌تکه‌شدهٔ 32 بیتی سخت‌تر است، و زیر حدود 16 مگابایت فایل‌های پرپرش آن‌قدر بازنگاشت می‌شوند که به چشم بیاید. هر جایی در بازهٔ 64 تا 256 مگابایت، ترافیک نگاشت نویز آماری است

شمردن فراخوانی‌های سیستمی

حالا نوبت حساب است. فایل آزمون: 1.8 گیگابایت، 300,000 شیء غیرمستقیم که به‌طور میانگین حدود 600 بایت بار دارند. تجزیه‌گری که به‌ازای هر شیء کار می‌کند هر کدام را با یک SetFilePointerEx به‌علاوهٔ یک ReadFile چهارکیلوبایتی می‌گیرد: 600,000 گذار به هسته. یک فراخوانی خواندن از کش روی سخت‌افزار x64 امروزی حدود 1.5 میکروثانیه رفت‌وبرگشت دارد، پس این یعنی 600,000 × 1.5 میکروثانیه ≈ 0.9 ثانیه سربار محض هسته پیش از تجزیهٔ حتی یک بایت — و این بهترین حالت با کش گرم است. با کش سرد، هر پرش یک عملیات دستگاه است: با تأخیر مؤثر حدود 20 میکروثانیه برای خواندن‌های تصادفی 4 کیلوبایتی روی NVMe، 300,000 پرش حدود 6 ثانیه زمان دستگاه خرج می‌کند؛ روی ذخیره‌سازی از ردهٔ SATA، چند دقیقه

این خواندن‌ها دادهٔ نادرست را هم جابه‌جا می‌کنند: 300,000 × 4 کیلوبایت یعنی عبور 1.2 گیگابایت از بافرهای کاربر برای تحویل تقریباً 180 مگابایت بار — تقویتی شش‌برابری، با کپی هر بایت از هسته به کاربر

بافر پیش‌خوانی هم‌اندازهٔ خوشه‌های استریم شیء نخستین بهبود صادقانه است: یک خواندن 256 کیلوبایتی به‌ازای هر خوشه به جای یکی به‌ازای هر شیء، شمار گذارها را یک تا دو مرتبهٔ بزرگی کم می‌کند. همچنین ابزار درست جایی است که نگاشت ناجور است، معمولاً اشتراک‌های شبکه

نگاشتگر پنجره‌ای فراتر می‌رود. یک جاروی کامل 29 فراخوانی MapViewOfFile و 29 فراخوانی UnmapViewOfFile است، 58 گذار صریح در برابر 600,000. تجزیهٔ واقعی که xref هدایتش می‌کند جاروی تمیزی نیست، اما مسیر سریع هر واکشی درون پنجرهٔ زنده را جذب می‌کند؛ یک گذر نمایه‌سازی فراداده روی بایگانی آزمون روی چند صد بازنگاشت آرام گرفت. نگاشت کار هسته را حذف نمی‌کند: فراخوانی‌های سیستمی صریح را به خطاهای صفحه بدل می‌کند که مدیر حافظه در خوشه‌های چندصفحه‌ای، مستقیم از کش فایل و بدون کپی در فضای کاربر حل می‌کند، و نواحی‌ای که هرگز لمس نشوند هیچ خرجی ندارند. در مجموع، گذر نمایه‌سازی از 23 ثانیه با کش سرد و 7.1 ثانیه با کش گرم در حالت خواندن به‌ازای شیء، به 6.5 ثانیهٔ سرد و 1.9 ثانیهٔ گرم با نگاشتگر رسید؛ آنچه باقی می‌ماند بادکردن zlib است، نه ورودی/خروجی

PDF: نمودار ستونی 600000 فراخوانی سیستمی ReadFile به‌ازای هر شیء در برابر پیش‌خوانی خوشه‌ای و 58 گذار نگاشتگر پنجره‌ای هنگام نمایه‌سازی یک PDF یک‌ونیم‌گیگابایتی با 300000 شیء
خواندن به‌ازای هر شیء روی بایگانی آزمون 600,000 فراخوانی سیستمی می‌سوزاند در حالی که نگاشتگر پنجره‌ای جاروی کامل را به 58 گذار می‌رساند

جای FILE_FLAG_NO_BUFFERING کجاست

پرچم FILE_FLAG_NO_BUFFERING کش سیستم را دور می‌زند و در عوض قواعد ترازبندی سخت می‌گذارد: افست‌ها، طول‌ها و آدرس‌های بافر همه باید هم‌تراز سکتور باشند. جایی ارزش خود را ثابت می‌کند که کار تک‌گذر و ترتیبی باشد و در غیر این صورت کش را از بایت‌هایی پر کند که کسی دو بار نمی‌خواندشان — مثلاً یک بازسریال‌سازی دسته‌ای که کل بایگانی را بازمی‌نویسد، یا یک گذر خطی‌سازی روی خروجی نهایی. با بافرهای هم‌تراز 4 تا 8 مگابایتی به پهنای باند ترتیبی دستگاه نزدیک می‌شود بی‌آنکه کش را آلوده کند

برای تجزیه دقیقاً نادرست است. پرش‌های تصادفی xref از راه یک هندل بی‌بافر، هر واکشی دیکشنری 300 بایتی را به یک خواندن فیزیکی کامل بدل می‌کند بی‌آنکه کشی برای جذب بازدید دوم باشد — و تجزیهٔ PDF دائم به نواحی برمی‌گردد، چون صفحه‌های مختلف به همان استریم‌های شیء حل می‌شوند. ورودی/خروجی بی‌بافر برای بازنویسی ترتیبی، ورودی/خروجی نگاشته یا کش‌شده برای تجزیهٔ تصادفی؛ این پرچم به‌ازای هندل است، پس یک خط پردازش می‌تواند هر دو را روی همان فایل نگه دارد

معماری 64 بیتی، مجموعهٔ کاری و سمت نوشتن

در ساخت 64 بیتی ایراد فضای آدرس ناپدید می‌شود: اندازهٔ فایل را به‌عنوان پنجره بدهید و کلاس بالا به یک نگاشت کامل یگانه تحلیل می‌رود. دام کار در سرویس‌های بلندمدت است: صفحه‌های فقط‌خواندنی پشتیبان‌شده با فایل هیچ تعهدی نمی‌گیرند، پس شمارنده‌های تعهد آرام می‌مانند، اما هر صفحه‌ای که لمس شود به مجموعهٔ کاری می‌پیوندد؛ بیشتر آن 1.8 گیگابایت را تجزیه کنید و مجموعهٔ کاری به همان اندازه رشد می‌کند و هر چیز دیگر را بیرون می‌راند. پنجره‌های کراندار سقفی برای آن می‌گذارند، پس الگوی لغزان حتی جایی که فضای آدرس مجانی است پیش‌فرض درستی می‌ماند

در سمت نوشتن، ارزان‌ترین ورودی/خروجی همان است که هرگز صادر نمی‌شود. سازوکار به‌روزرسانی افزایشی PDF (ISO 32000-1 §7.5.6) اشیای تغییریافته و یک بخش ارجاع متقاطع تازه را پس از بایت‌های اصلی الحاق می‌کند و آن بایت‌ها هرگز جابه‌جا نمی‌شوند. مهر زدن یک صفحه روی بایگانی 1.8 گیگابایتی ده‌ها کیلوبایت الحاق می‌کند؛ بازنویسی کامل هر 1.8 گیگابایت را جابه‌جا می‌کند، پنج مرتبهٔ بزرگی فاصله، و الحاق خروجی ترتیبی محض در دُم فایل است

جای کتابخانه‌های losLab کجاست

هر دو کتابخانهٔ PDF شرکت losLab این انضباط را به شکل سطح API عرضه می‌کنند. HotPDF Direct File API شمار صفحه‌ها و ساختار را از راه یک هندل فایل و بدون ساختن درخت اشیا می‌خواند، در سطح فایل کپی و رمزگشایی می‌کند، و دلتاها را با BeginIncrementalUpdate می‌نویسد — همان راهبرد فقط‌الحاقی بالا، بسته‌بندی‌شده. PDF Library for Delphi همان راه را با لایهٔ Direct Access خود می‌رود: خواننده‌ای جریانی که جدول ارجاع متقاطع را در جای خود می‌پیماید، اشیا را تنبل واکشی می‌کند، بازه‌های صفحه را فایل‌به‌فایل استخراج می‌کند و ویرایش‌ها را به شکل بازنگری‌های افزایشی ماندگار می‌سازد. اگر خودتان تجزیه‌گر می‌نویسید، کلاس نگاشتگر مال شماست؛ اگر خط پردازش سند را می‌گردانید، بگذارید کتابخانه پنجره را صادق نگه دارد

یادداشت: مدیریت بهینهٔ ورودی/خروجی برای اسناد گیگابایتی مستقیماً درون HotPDF Delphi VCL Component برای دلفی و C++Builder ساخته شده است