مقال تقني

تحسين أداء الإدخال والإخراج لمعالجة ملفات PDF بحجم جيجابايت

أول قراءة مفيدة لمحلل PDF تكون في الطرف الخاطئ من الملف. يضع التنسيق مؤشر startxref في البايتات الأخيرة، لذا تبدأ معالجة أرشيف بحجم 1.8 جيجابايت بالبحث إلى النهاية، وقراءة كيلوبايت واحد، ثم القفز إلى حيث يقول جدول المراجع المتقاطعة إن فهرس المستند موجود. من هناك يكون التحليل عبارة عن سير عشوائي عبر نطاق البايتات بأكمله. كل ما يجيده الإدخال والإخراج المؤقت — القراءة التسلسلية المسبقة خلف مؤشر الملف — موجه نحو عبء عمل لا تمتلكه ملفات PDF

ادعى الإصدار الأول من هذا المقال أن الملف المعين في الذاكرة (memory-mapped file) يحل فشل نفاد الذاكرة في بيئة 32 بت الذي يواجهه TMemoryStream عند إدخال 2 جيجابايت. هذا الادعاء خاطئ، والطريقة التي يخطئ بها تشير إلى الإصلاح الحقيقي: نافذة تعيين منزلقة. ما يلي هو نمط الوصول، والقصة المصححة لبيئة 32 بت مع أداة تعيين ذات نوافذ قابلة للترجمة، وحساب استدعاءات النظام على ملف اختبار بحجم 1.8 جيجابايت يحتوي على 300,000 كائن

لماذا يهزم تخطيط PDF القراءات المؤقتة

ثلاث حقائق هيكلية تشكل نمط الإدخال والإخراج. أولاً، يعتمد التنقل على الإزاحة: يعين جدول المراجع المتقاطعة كل رقم كائن إلى موضع بايت مطلق، ولا يوجد ما يتطلب ترتيب هذه المواضع. بعد سنوات من التحديثات المتزايدة، يمكن أن يقع الكائن 4102 عند إزاحة 1.6 جيجابايت بينما يقع الكائن 4103 عند 30 كيلوبايت. تحول حلقة TFileStream كل عملية جلب إلى Seek بالإضافة إلى Read، وهما انتقالان في النواة (kernel transitions)، مع مخزن مؤقت لا يساهم بشيء لأن عملية الجلب التالية تبعد مئات الميجابايت

ثانياً، تقوم تدفقات الكائنات (ISO 32000-1 §7.5.7) بتعبئة العشرات أو المئات من القواميس الصغيرة في حاوية واحدة مفرغة. جلب قاموس صفحة بحجم 300 بايت قد يعني قراءة وتوسيع كتلة بحجم 100 كيلوبايت. الجانب الآخر: الكائنات التي تُكتب معًا تميل إلى أن تُقرأ معًا، لذا فإن المخزن المؤقت بحجم الكتلة يخدم عمليات الجلب الاثنتي عشرة التالية مجانًا — وهو الانتظام الأكثر قابلية للاستغلال في التنسيق

ثالثاً، الخطية (Linearization). يضع الملف الخطي الصفحة الأولى وجدول التلميحات في المقدمة حتى يتمكن المستهلكون من قراءته من البداية إلى النهاية. نادراً ما تكون الأرشيفات بحجم جيجابايت خطية: يتم تدمير الخطية بنفس التحديثات المتزايدة وعمليات الدمج التي جعلت الملف كبيراً. خطط للحالة الأسوأ: قفزات طويلة، لا يوجد ترتيب، دخول من النهاية أولاً

قصة 32 بت، مصححة

تحتوي عملية Windows ذات 32 بت على 2 جيجابايت من مساحة عنوان المستخدم، وتطلب MapViewOfFile بعدد بايتات صفر حجزاً متجاوزاً بحجم الملف. بالنسبة لإدخال بحجم 2 جيجابايت، لا يمكن أن ينجح هذا الحجز: بعد الـ EXE وملفات DLL المبعثرة ومكدسات سلاسل العمليات، تقع أكبر كتلة متجاورة مجانية في عملية دلفي نموذجية 32 بت في مكان ما بين 700 ميجابايت و 1.4 جيجابايت. يفشل الاستدعاء مع ERROR_NOT_ENOUGH_MEMORY، وهو نفس الجدار الذي يصطدم به TMemoryStream.LoadFromFile، مجرد انتقاله من ذاكرة الوصول العشوائي الملتزم بها إلى حجز مساحة العنوان. إن تعيين الملف بالكامل لا يمثل إصلاحاً في بيئة 32 بت، بل هو نفس الفشل وراء أسماء واجهة برمجة تطبيقات تبدو أفضل

الإصلاح هو فصل الأمرين اللذين يقوم بهما التعيين. يقوم CreateFileMapping بإنشاء كائن القسم ولا يكلف مساحة عنوان على الإطلاق، أياً كان حجم الملف. فقط MapViewOfFile هو الذي يستهلك مساحة العنوان، ولا يوجد ما يجبره على تعيين القسم بالكامل: فهو يأخذ إزاحة بدء 64 بت وطول عرض. أنشئ القسم مرة واحدة، وعيّن عرضاً من 64 إلى 256 ميجابايت فوق المنطقة التي يتم تحليلها، وقم بإلغاء التعيين قبل الانزلاق: تكلفة مساحة العنوان هي نافذة واحدة، وليس ملفاً واحداً. قيد واحد: يجب أن تكون إزاحات العرض مضاعفات لـ SYSTEM_INFO.dwAllocationGranularity، أي 64 كيلوبايت عملياً، لذا فإن طلب الإزاحة 1,000,000 يتم تقريبه للأسفل إلى 983,040 ويتم ضبط مؤشر المتصل للأمام بمقدار الفارق

أداة تعيين ذات نافذة منزلقة في دلفي

تغلف الفئة أدناه الانضباط بأكمله: كائن قسم واحد، عرض حي واحد، إعادة محاذاة الدقة، ويتم التعامل مع القراءات التي تعبر حدود النافذة عن طريق تكبير هذا العرض الواحد بدلاً من خياطة عرضين

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 مؤشراً بدون انتقال النواة عندما يقع النطاق المطلوب بالفعل داخل العرض الحي؛ بفضل تجميع تدفق الكائنات، هذه هي الحالة الشائعة ومن هنا تأتي التوفيرات. كما أن الطلب الذي يمتد عبر نهاية النافذة الافتراضية يزيد من MapSize لهذا العرض الواحد بدلاً من خياطة عرضين، مما يحافظ على ReadBytes كسطر واحد ويحرر المتصلين من حلقات القراءة الجزئية

حجم النافذة هو مقبض متسامح: عند 64 ميجابايت، يكون المسح الكامل لملف بحجم 1.8 جيجابايت هو 29 عرضاً، وعند 256 ميجابايت يكون 8 لكن من الصعب وضع كل حجز في مساحة 32 بت مجزأة، وأقل من 16 ميجابايت تعيد الملفات الكثيفة القفزات التعيين بشكل متكرر بما يكفي لملاحظته. في أي مكان ضمن نطاق 64 إلى 256 ميجابايت، تكون حركة مرور التعيين بمثابة ضوضاء إحصائية

حساب استدعاءات النظام

الآن الحساب. ملف الاختبار: 1.8 جيجابايت، 300,000 كائن غير مباشر بمتوسط حوالي 600 بايت من الحمولة. يجلب محلل لكل كائن كل واحد منها باستخدام SetFilePointerEx بالإضافة إلى 4 كيلوبايت ReadFile: أي 600,000 انتقال نواة. يستغرق استدعاء نظام القراءة المخزن مؤقتاً رحلة ذهاب وإياب في حوالي 1.5 ميكروثانية على أجهزة x64 الحالية، وهذا يعني 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 inflate، وليس الإدخال والإخراج

أين يناسب FILE_FLAG_NO_BUFFERING

يتجاوز FILE_FLAG_NO_BUFFERING ذاكرة التخزين المؤقت للنظام مقابل قواعد محاذاة صارمة: الإزاحات، الأطوال، وعناوين المخزن المؤقت جميعها محاذية للقطاع (sector-aligned). إنه يثبت جدارته في المهام المتسلسلة ذات التمريرة الواحدة التي لولا ذلك لأغرقت ذاكرة التخزين المؤقت ببايتات لا يقرأها أحد مرتين — إعادة تسلسل الدفعات التي تعيد كتابة الأرشيف بأكمله، أو تمريرة خطية فوق المخرجات النهائية. مع مخازن مؤقتة محاذية بحجم 4 إلى 8 ميجابايت، فإنه يقترب من النطاق الترددي المتسلسل للجهاز دون تلويث ذاكرة التخزين المؤقت

إنه خاطئ تماماً بالنسبة للتحليل. القفزات العشوائية لـ xref عبر مقبض غير مؤقت تحول كل جلب لقاموس بحجم 300 بايت إلى قراءة مادية كاملة بدون ذاكرة تخزين مؤقت لامتصاص الزيارة الثانية — وتحليل PDF يعيد زيارة المناطق باستمرار، لأن الصفحات المختلفة تتحلل إلى نفس تدفقات الكائنات. إدخال وإخراج غير مؤقت لإعادة الكتابة المتسلسلة، إدخال وإخراج مؤقت أو معين للتحليل العشوائي؛ العلامة تكون لكل مقبض، لذا يمكن لخط أنابيب واحد أن يحتفظ بكليهما على نفس الملف

64 بت، مجموعات العمل، وجانب الكتابة

في بناء 64 بت، يختفي اعتراض مساحة العنوان: قم بتمرير حجم الملف كالنافذة وتتراجع الفئة أعلاه إلى تعيين كامل واحد. المشكلة في الخدمات طويلة التشغيل: الصفحات المدعومة بالملفات للقراءة فقط لا تفرض أي رسوم التزام، لذا تظل عدادات الالتزام هادئة، لكن كل صفحة يتم لمسها تنضم إلى مجموعة العمل (working set)؛ قم بتحليل معظم الـ 1.8 جيجابايت وتنمو مجموعة العمل لتطابق ذلك، مستبعدة كل شيء آخر. تضع النوافذ المحدودة سقفاً لذلك، لذا يبقى النمط المنزلق هو الافتراضي الصحيح حتى عندما تكون مساحة العنوان مجانية

على جانب الكتابة، أرخص إدخال وإخراج هو الإدخال والإخراج الذي لم يُصدر أبداً. آلية التحديث المتزايد في PDF (ISO 32000-1 §7.5.6) تُلحق الكائنات المتغيرة وقسم مرجع متقاطع جديد بعد البايتات الأصلية، والتي لا تتحرك أبداً. إضافة صفحة واحدة إلى أرشيف 1.8 جيجابايت يُلحق عشرات الكيلوبايتات؛ بينما إعادة الكتابة الكاملة تحرك كل الـ 1.8 جيجابايت، ويفصل بينهما خمس مراتب من الحجم، والإلحاق هو إخراج متسلسل نقي في النهاية

أين تتناسب مكتبات losLab

تُشحن كلتا مكتبتي losLab PDF هذا الانضباط كواجهة برمجة تطبيقات. تقوم واجهة برمجة تطبيقات الملفات المباشرة لـ HotPDF بقراءة عدد الصفحات وبنيتها من خلال مقبض ملف دون بناء شجرة الكائنات، وتقوم بالنسخ وفك التشفير على مستوى الملف، وتكتب التغييرات من خلال BeginIncrementalUpdate — الاستراتيجية المعتمدة على الإلحاق فقط المذكورة أعلاه، معبأة. تتخذ PDFlibPas نفس المسار مع طبقة الوصول المباشر الخاصة بها: قارئ متدفق يمشي على جدول المراجع المتقاطعة في مكانه، يجلب الكائنات بشكل كسول، يستخرج نطاقات الصفحات من ملف إلى ملف، ويحفظ التعديلات كمراجعات متزايدة. إذا كنت تكتب محللك الخاص، ففئة التعيين ملكك لتأخذها؛ وإذا كنت تقوم بتشغيل خط أنابيب مستندات، فدع المكتبة تحافظ على النافذة نزيهة

ملاحظة: تم دمج المعالجة المحسنة للإدخال والإخراج للمستندات بحجم جيجابايت مباشرة في مكون HotPDF VCL لـ Delphi و C++Builder