การอ่านข้อมูลที่มีประโยชน์ครั้งแรกของโปรแกรมแยกวิเคราะห์ (parser) PDF นั้นอยู่ที่ส่วนปลายที่ผิดด้านของไฟล์ รูปแบบของมันจะวางตัวชี้ (pointer) startxref ไว้ที่ไบต์สุดท้าย ดังนั้นการประมวลผลเอกสารเก็บถาวรขนาด 1.8 GB จึงเริ่มต้นด้วยการค้นหา (seek) ไปยังส่วนท้าย, อ่านข้อมูลขนาดหนึ่งกิโลไบต์, จากนั้นก็กระโดดไปยังที่ใดก็ตามที่ตาราง cross-reference ระบุว่าเป็นที่อยู่ของแค็ตตาล็อกเอกสาร จากจุดนั้น การแยกวิเคราะห์จะเป็นการสุ่มข้ามไปยังส่วนต่างๆ ตลอดทั้งช่วงไบต์ (random walk) ทุกสิ่งที่ buffered IO ถนัด — การอ่านข้อมูลล่วงหน้าตามลำดับ (sequential read-ahead) ต่อจากตำแหน่งของตัวชี้ไฟล์ — ล้วนเป็นรูปแบบการทำงาน (workload) ที่ PDF ไม่มี
บทความเวอร์ชันแรกนี้อ้างว่า memory-mapped file ช่วยแก้ปัญหา out-of-memory บนระบบ 32 บิตที่ TMemoryStream พบเมื่อเจออินพุตขนาด 2 GB ข้ออ้างนั้นผิด และวิธีการที่มันผิดได้ชี้ให้เห็นถึงวิธีแก้ปัญหาที่แท้จริง: คือการใช้ sliding mapping window สิ่งที่ตามมาคือรูปแบบการเข้าถึง (access pattern), เรื่องราวของฝั่ง 32 บิตที่ได้รับการแก้ไขให้ถูกต้องด้วยตัวช่วย mapping แบบหน้าต่าง (windowed mapper) ที่สามารถคอมไพล์ได้, และการคำนวณ syscall บนไฟล์ทดสอบขนาด 1.8 GB ที่มี 300,000 อ็อบเจ็กต์
เหตุใดเค้าโครง PDF จึงทำลายประสิทธิภาพของการอ่านแบบบัฟเฟอร์
ข้อเท็จจริงเชิงโครงสร้างสามประการเป็นตัวกำหนดรูปแบบ IO ประการแรก การนำทางถูกขับเคลื่อนด้วย offset: ตาราง cross-reference จะจับคู่หมายเลขอ็อบเจ็กต์กับตำแหน่ง absolute byte และไม่มีกฎเกณฑ์ใดบังคับว่าตำแหน่งเหล่านั้นต้องเรียงตามลำดับ หลังจากมีการอัปเดตแบบ incremental มานานหลายปี อ็อบเจ็กต์ที่ 4102 อาจไปอยู่ที่ offset 1.6 GB ในขณะที่อ็อบเจ็กต์ 4103 อาจไปอยู่ที่ 30 KB การวนลูปของ TFileStream จะเปลี่ยนทุกการดึงข้อมูลเป็นการใช้ Seek ตามด้วย Read ซึ่งก่อให้เกิดการสลับเปลี่ยนการทำงานในเคอร์เนล (kernel transitions) สองครั้ง พร้อมกับบัฟเฟอร์ที่ไม่ได้ช่วยอะไรเลยเพราะการดึงข้อมูลครั้งต่อไปนั้นอยู่ห่างออกไปหลายร้อยเมกะไบต์
ประการที่สอง object streams (ISO 32000-1 §7.5.7) ทำการบรรจุพจนานุกรมขนาดเล็กหลายสิบหรือหลายร้อยรายการลงในคอนเทนเนอร์ที่ถูกบีบอัด (deflated) เพียงคอนเทนเนอร์เดียว การดึงพจนานุกรมของหน้า (page dictionary) ขนาด 300 ไบต์เพียงอันเดียวอาจหมายถึงการอ่านและการขยายคลัสเตอร์ขนาด 100 KB แต่ในทางกลับกัน: อ็อบเจ็กต์ที่ถูกเขียนไว้ด้วยกันมักจะถูกอ่านพร้อมๆ กัน ดังนั้นบัฟเฟอร์ที่มีขนาดเท่าคลัสเตอร์จะคอยให้บริการการดึงข้อมูลครั้งต่อไปอีกหลายสิบครั้งได้ฟรีๆ — ซึ่งถือเป็นความสม่ำเสมอในรูปแบบไฟล์ที่สามารถนำมาใช้ประโยชน์ได้มากที่สุด
ประการที่สาม คือ Linearization ไฟล์แบบ Linearized จะโหลดหน้าแรกและตารางคำใบ้ (hint table) ไว้ด้านหน้าก่อน เพื่อให้ผู้ใช้งานสามารถอ่านจากหน้าไปหลังได้ ไฟล์เก็บถาวรระดับกิกะไบต์แทบจะไม่มีการทำ Linearization เลย: Linearization มักจะถูกทำลายด้วยการอัปเดตแบบ incremental และการผสานข้อมูลที่เป็นตัวทำให้ไฟล์มีขนาดใหญ่ขึ้น ดังนั้นควรวางแผนสำหรับกรณีที่เลวร้ายที่สุด: มีการกระโดดข้ามข้อมูลไกลๆ, ไม่มีการเรียงลำดับ, และการเข้าถึงจากส่วนท้ายก่อนเสมอ (tail-first entry)
เรื่องราว 32 บิตที่ถูกแก้ไข
โปรเซส Windows แบบ 32 บิตมีพื้นที่ user address ขนาด 2 GB และ MapViewOfFile ที่จำนวนไบต์เป็นศูนย์นั้นเป็นการร้องขอการจองพื้นที่ต่อเนื่องขนาดเท่ากับตัวไฟล์ สำหรับอินพุตขนาด 2 GB การจองพื้นที่นั้นจะไม่สามารถทำได้สำเร็จ: เนื่องจากหลังจากหักพื้นที่สำหรับไฟล์ EXE, ไฟล์ DLL ที่กระจายอยู่, และ thread stacks แล้ว บล็อกที่ว่างเปล่าที่เชื่อมต่อกันที่ใหญ่ที่สุดในโปรเซส Delphi 32 บิตทั่วไป จะมีขนาดอยู่ระหว่าง 700 MB ถึง 1.4 GB การเรียกใช้งานจึงล้มเหลวด้วยข้อผิดพลาด ERROR_NOT_ENOUGH_MEMORY ซึ่งเป็นกำแพงเดียวกันกับที่ TMemoryStream.LoadFromFile เจอ เพียงแต่เปลี่ยนจากการใช้ RAM ที่คอมมิตไว้ มาเป็นการจองพื้นที่ address-space ดังนั้น การจับคู่ไฟล์แบบเต็ม (full-file mapping) จึงไม่ใช่วิธีแก้ปัญหาบนระบบ 32 บิต มันเป็นเพียงความล้มเหลวแบบเดิมที่ซ่อนอยู่หลังชื่อ API ที่ดูดีกว่า
การแก้ไขคือการแยกสองสิ่งที่ mapping ทำออกจากกัน CreateFileMapping จะสร้างอ็อบเจ็กต์ส่วน (section object) และไม่ใช้ address space เลย ไม่ว่าไฟล์จะมีขนาดเท่าใดก็ตาม มีเพียง MapViewOfFile เท่านั้นที่ใช้ address space และไม่มีอะไรบังคับให้มันต้องจับคู่ส่วนนั้นทั้งหมด: มันรับค่า 64-bit starting offset และความยาวของ view (view length) สร้างส่วนนี้ขึ้นมาเพียงครั้งเดียว, ค่อยๆ จับคู่มุมมองขนาด 64 ถึง 256 MB บนพื้นที่ที่กำลังถูกแยกวิเคราะห์ (parsed), ยกเลิกการจับคู่ (unmap) ก่อนที่จะเลื่อน (sliding) ต่อไป: ต้นทุนของ address-space คือหน้าต่างหนึ่งบาน ไม่ใช่หนึ่งไฟล์ ข้อจำกัดหนึ่งคือ: มุมมอง offsets จะต้องเป็นผลคูณของ SYSTEM_INFO.dwAllocationGranularity ซึ่งในทางปฏิบัติคือ 64 KB ดังนั้นคำขอสำหรับ offset 1,000,000 จะถูกปัดเศษลงเหลือ 983,040 และพอยน์เตอร์ของผู้เรียก (caller's pointer) จะถูกปรับเลื่อนไปข้างหน้าตามความแตกต่างนั้น
ตัวสร้างการจับคู่ไฟล์แบบหน้าต่างเลื่อน (sliding-window mapper) ใน Delphi
คลาสด้านล่างนี้ครอบคลุมกระบวนการทั้งหมด: มีหนึ่งอ็อบเจ็กต์ส่วน, มุมมองการใช้งานจริง (live view) หนึ่งมุมมอง, การปรับแต่งความละเอียด (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;
มีสองรายละเอียดที่ต้องให้ความสำคัญ Fast path ที่ส่วนบนสุดของ Map จะคืนค่าพอยน์เตอร์โดยไม่มีการทำ kernel transition หากช่วงที่ถูกขอบังเอิญอยู่ภายใน live view แล้ว; ต้องขอบคุณ object-stream clustering ที่ทำให้กรณีแบบนี้เกิดขึ้นบ่อยและเป็นที่มาของการประหยัดเวลา และคำขอที่คร่อมจุดสิ้นสุดของหน้าต่างเริ่มต้น (default window) จะทำให้ MapSize สำหรับมุมมองนั้นขยายขนาดขึ้นแทนที่จะไปเย็บเชื่อมต่อสองมุมมองเข้าด้วยกัน ซึ่งทำให้ ReadBytes กลายเป็นคำสั่งบรรทัดเดียว และผู้เรียกใช้งานก็ไม่ต้องมานั่งรันลูปสำหรับการอ่านข้อมูลเพียงบางส่วน (partial-read loops)
ขนาดของหน้าต่างนั้นเป็นตัวเลือกที่ยืดหยุ่น: ที่ 64 MB การกวาดข้อมูลไฟล์ 1.8 GB ทั้งหมดจะใช้มุมมองจำนวน 29 ครั้ง, ที่ 256 MB จะใช้ 8 ครั้ง แต่การจองแต่ละครั้งจะหาพื้นที่แทรกในระบบ 32 บิตที่แตกกระจาย (fragmented) ได้ยากกว่า, และถ้าต่ำกว่า 16 MB ไฟล์ที่ต้องมีการกระโดดไปมาบ่อยๆ (hop-heavy) จะต้องทำการ remap บ่อยมากจนสามารถสังเกตเห็นได้ หากตั้งค่าอยู่ในช่วง 64 ถึง 256 MB ปริมาณการใช้งานแมปจะอยู่ในระดับที่แทบไม่มีนัยสำคัญ (statistical noise)
การนับจำนวน syscall
ทีนี้มาคำนวณกัน ไฟล์ทดสอบ: ขนาด 1.8 GB, มี indirect object จำนวน 300,000 รายการ โดยแต่ละอ็อบเจ็กต์มีขนาดเพย์โหลดเฉลี่ยประมาณ 600 ไบต์ โปรแกรมแยกวิเคราะห์ข้อมูลแบบ per-object จะดึงแต่ละอันด้วยคำสั่ง SetFilePointerEx บวกกับการใช้ ReadFile ขนาด 4 KB: ซึ่งเท่ากับการสลับเปลี่ยนเคอร์เนล 600,000 ครั้ง คำสั่ง read syscall แบบไป-กลับที่มีแคช (cached read syscall round-trip) จะใช้เวลาประมาณ 1.5 ไมโครวินาที (μs) บนฮาร์ดแวร์ x64 รุ่นปัจจุบัน ดังนั้นนี่ก็คือ 600,000 × 1.5 μs ≈ 0.9 วินาทีของโอเวอร์เฮด (overhead) บนเคอร์เนลล้วนๆ ก่อนที่จะเริ่มทำการแยกวิเคราะห์แม้แต่ไบต์เดียว — นี่คือกรณีที่ดีที่สุดที่มี warm-cache สำหรับแบบ cold แต่ละการกระโดดข้ามจะเป็นการปฏิบัติการบนอุปกรณ์ (device operation): ด้วยความหน่วงระดับใช้งานจริงที่ประมาณ ~20 μs ของการอ่านข้อมูลแบบสุ่มขนาด 4 KB บน NVMe การเรียกใช้คำสั่งนี้ 300,000 ครั้งจะใช้เวลาอุปกรณ์ประมาณ 6 วินาที; หากเป็นพื้นที่จัดเก็บข้อมูลระดับ SATA จะกินเวลาเป็นนาที
นอกจากนี้ การอ่านยังทำการย้ายข้อมูลที่ผิดด้วย: การดึง 300,000 × 4 KB จะผลักข้อมูล 1.2 GB ผ่าน user buffers เพื่อให้ได้มาซึ่งเพย์โหลดขนาดประมาณ 180 MB — ขยายขนาดเพิ่มขึ้นถึงหกเท่า และทุกไบต์ถูกคัดลอกข้ามจากเคอร์เนลไปยัง user
บัฟเฟอร์การอ่านข้อมูลล่วงหน้า (read-ahead buffer) ที่มีขนาดเหมาะสมกับคลัสเตอร์ของ object-stream คือการปรับปรุงอย่างแท้จริงในขั้นตอนแรก: การอ่านข้อมูลขนาด 256 KB ต่อหนึ่งคลัสเตอร์ แทนการอ่านทีละอ็อบเจ็กต์ สามารถลดจำนวนการเรียกผ่านเคอร์เนล (transition count) ลงได้ 1-2 ระดับ และมันยังเป็นเครื่องมือที่เหมาะสมในกรณีที่การจับคู่ทำได้ลำบาก เช่น การใช้งานผ่านเครือข่ายส่วนกลาง (network shares)
ตัวแปลงแบบหน้าต่าง (windowed mapper) ยังสามารถทำได้ดียิ่งขึ้น การสแกนข้อมูลทั้งหมดใช้การเรียกคำสั่ง MapViewOfFile 29 ครั้ง และ UnmapViewOfFile 29 ครั้ง ซึ่งก่อให้เกิดการถ่ายโอนแบบเจาะจง 58 ครั้งเทียบกับ 600,000 ครั้ง กระบวนการวิเคราะห์โดยใช้ xref-driven อย่างแท้จริงอาจไม่ใช่การสแกนแบบกวาดรวดเดียว แต่วิธี fast path จะคอยดูดซับการดึงข้อมูลที่อยู่ในขอบเขตหน้าต่างจริงทั้งหมด; สำหรับการทำดัชนีเมทาดาต้าของไฟล์ทดสอบจะใช้การทำ remap เพียงแค่ไม่กี่ร้อยครั้ง การทำ Mapping ไม่ได้เป็นการลบขั้นตอนการทำงานของเคอร์เนลออกไป: แต่มันจะทำการแปลง syscalls ไปเป็นหน้าเกิดข้อผิดพลาด (page faults) ที่ระบบจัดการหน่วยความจำต้องไปแก้ไขในระบบคลัสเตอร์หลายหน้า ตรงมาจากส่วนสำรองแคชของไฟล์โดยไม่ต้องมีการคัดลอกส่วนของผู้ใช้งาน (user-space copy) และภูมิภาค (regions) ไหนที่ไม่เคยถูกแตะต้องก็จะไม่มีการใช้ทรัพยากรเลย ตั้งแต่ต้นจนจบ กระบวนการทำดัชนีลดเวลาจากแบบ cold ที่ 23 วินาที และ warm 7.1 วินาที ที่มาพร้อมการอ่านทีละอ็อบเจ็กต์ เหลือแค่ 6.5 วินาทีแบบ cold และ 1.9 วินาทีแบบ warm ด้วย mapper สิ่งที่เหลืออยู่คือการทำงานกับ zlib inflate ไม่ใช่ IO
ส่วนไหนที่ FILE_FLAG_NO_BUFFERING เหมาะสม
FILE_FLAG_NO_BUFFERING จะข้ามระบบแคชเพื่อแลกกับกฎของ alignment ที่เข้มงวด: ค่า offset, ความยาว และตำแหน่งของบัฟเฟอร์ จะต้องจัดเรียงตามเซกเตอร์ให้ตรงกัน (sector-aligned) ทั้งหมด สิ่งนี้จะทำงานได้ดีกับงานลำดับทางเดียว (single-pass sequential) ซึ่งหากทำแบบอื่นก็อาจทำให้ข้อมูลแคชล้นทะลักไปด้วยไบต์ข้อมูลที่ไม่มีใครเคยอ่านซ้ำสอง — เช่น กระบวนการทำ serialization แบบ batch ที่ต้องเขียนชุดเก็บถาวรทั้งชุดใหม่หมด หรือการเรียงข้อมูล (linearization) ใหม่ของไฟล์ที่เสร็จสมบูรณ์แล้ว เมื่อมีบัฟเฟอร์ขนาด 4 ถึง 8 MB ที่ปรับ alignment แล้ว ความสามารถที่ได้จะมีแบนด์วิดท์ใกล้เคียงกับการเชื่อมต่อลำดับอุปกรณ์ (device sequential bandwidth) ได้โดยไม่สร้างปัญหากับแคช
แต่นี่คือวิธีที่ผิดมหันต์หากจะนำมาใช้กับการแยกวิเคราะห์ (parsing) การสุ่มดึงข้อมูลผ่าน xref บนแฮนเดิลที่ไม่ใช้บัฟเฟอร์ จะทำให้ทุกๆ การดึงข้อมูลดิกชันนารีขนาด 300 ไบต์กลายเป็นการอ่านฮาร์ดแวร์จริงแบบเต็มพิกัด โดยไม่มีแคชรองรับสำหรับการเรียกดูครั้งที่สอง — และพาร์เซอร์ของ PDF มักจะทำการเรียกดูตำแหน่งเดิมซ้ำๆ อย่างสม่ำเสมอ เนื่องจากหน้าต่างๆ ถูกเชื่อมโยงเข้าด้วยกันไปยัง object stream ตัวเดียวกัน เราจึงควรใช้ IO ที่ไม่มีบัฟเฟอร์สำหรับการเขียนข้อมูลเรียงลำดับใหม่ แต่ให้ใช้ IO ที่ทำแมปหรือแคชเอาไว้สำหรับการแยกวิเคราะห์แบบสุ่ม; แฟล็กนี้ถูกกำหนดแบบต่อแฮนเดิล (per-handle) ไพป์ไลน์หนึ่งจึงสามารถใช้ทั้งสองวิธีบนไฟล์เดียวกันได้
ระบบ 64 บิต ชุดการทำงาน (working sets) และการทำงานในฝั่งเขียน
ในรุ่นคอมไพล์แบบ 64 บิต ปัญหาเรื่องพื้นที่ Address จะหมดไป: คุณสามารถระบุขนาดไฟล์เป็นค่ากำหนดหน้าต่างได้เลย จากนั้นคลาสด้านบนจะปรับตัวเป็นแบบ mapping เต็มรูปแบบเพียงครั้งเดียว (single full mapping) แต่ปัญหาที่จะเจอในระบบบริการที่ทำงานยาวนาน (long-running services) ก็คือ: หน้าไฟล์ประเภทอ่านอย่างเดียวจะไม่กินค่าคอมมิต (commit) ดังนั้นตัวนับคอมมิต (commit counters) จะไม่แสดงปัญหาแต่อย่างใด แต่ทุกหน้าที่ถูกเรียกอ่านจะเข้าไปรวมอยู่ในกลุ่มของ working set ทันที; หากวิเคราะห์ไฟล์ขนาด 1.8 GB ส่วนใหญ่ของหน้าทำงานก็จะขยายขนาดขึ้นจนถึงระดับที่เท่ากัน ซึ่งไปบีบไล่เอาข้อมูลส่วนอื่นออกทั้งหมด การจำกัดขอบเขตของหน้าต่างจะเป็นการกำหนดเพดานให้กับการทำงานนี้ ดังนั้นการใช้รูปแบบ sliding window จึงยังคงเป็นค่าพื้นฐานที่ถูกต้อง แม้ว่าเราจะอยู่ในระบบที่มี Address Space ว่างเหลือเฝือก็ตาม
ในฝั่งของการเขียน IO ที่มีราคาถูกที่สุดคือ IO ที่ไม่เคยถูกเรียกใช้ กลไกการอัปเดตแบบรับเพิ่มทีละส่วน (incremental update) ของ PDF (ISO 32000-1 §7.5.6) จะช่วยแนบวัตถุที่ถูกแก้ไขและชุดการอ้างอิง cross-reference ใหม่ต่อท้ายข้อมูลไบต์ดั้งเดิม ซึ่งจะไม่มีการเคลื่อนย้ายไปไหน การแทรกรายละเอียดแบบแสตมป์หนึ่งหน้าลงในคลังเก็บถาวรขนาด 1.8 GB จะเพิ่มความจุให้แค่เพียงหลักสิบกิโลไบต์เท่านั้น; ในขณะที่การเขียนข้อมูลใหม่ทั้งหมดจะทำให้ต้องย้ายข้อมูลทั้ง 1.8 GB ซึ่งมีขนาดแตกต่างกันถึงห้าระดับ (five orders of magnitude) และการเขียนเพิ่มที่ส่วนท้ายจะเป็นการทำงานบนลำดับทางเดียว 100%
ตำแหน่งที่ไลบรารี losLab เข้ามาตอบโจทย์
ไลบรารี PDF ของ losLab ทั้งสองระบบมีการนำหลักการนี้มารวมไว้เป็นคุณสมบัติใน API แบบเปิดใช้งาน HotPDF Direct File API สามารถนับหน้าเอกสารและโครงสร้างข้อมูลโดยใช้การส่งถ่ายไฟล์แบบไม่มีการเขียน object tree ทำการคัดลอก (copies) และถอดรหัส (decrypts) ที่ระดับตัวไฟล์ และทำการเขียนความเปลี่ยนแปลงผ่าน BeginIncrementalUpdate — ซึ่งก็คือรูปแบบกลยุทธ์การเขียนต่อเนื่อง (append-only) ที่อธิบายไว้เบื้องต้น ซึ่งได้ถูกรวบรวมมาไว้ให้แล้ว PDFlibPas จะใช้วิธีการทำงานแบบเดียวกันสำหรับส่วนขยายการจัดการไฟล์ Direct Access layer: คือเป็นตัวช่วยอ่านเนื้อหาแบบสตรีมโดยอิงไปตาม cross-reference table เพื่อค้นหาวัตถุในรูปแบบ Lazy สามารถแยกเนื้อหาแบบเป็นหน้าลงสู่ไฟล์ และปรับเก็บเนื้อหาแก้ไขเป็นส่วนอัปเดต ถ้าหากคุณกำลังเขียนตัวพาร์เซอร์ของคุณเอง คลาส mapper ก็เป็นสิ่งที่คุณสามารถนำไปใช้ได้เลย; ส่วนถ้าคุณกำลังใช้งานสายการผลิตเอกสาร (document pipeline) การปล่อยให้ไลบรารีทำหน้าที่ควบคุมการใช้งานแบบระบุหน้าต่างทำงาน (window) ก็จะเป็นตัวเลือกที่ดีที่สุด
หมายเหตุ: การจัดการ IO ที่ผ่านการปรับแต่งสำหรับเอกสารที่มีขนาดระดับกิกะไบต์ได้ถูกใส่เข้าไปแล้วอย่างครบถ้วนใน HotPDF VCL Component ที่ใช้รองรับ Delphi และ C++Builder