Pembacaan berguna pertama dari sebuah parser PDF berada di ujung file yang salah. Format tersebut menempatkan penunjuk startxref di bita terakhir, sehingga memproses arsip 1,8 GB dimulai dengan pencarian ke bagian akhir, pembacaan satu kilobita, lalu lompat ke mana pun tabel referensi silang mengatakan katalog dokumen tersebut berada. Dari sana, penguraiannya adalah penelusuran acak di seluruh rentang bita. Segala sesuatu yang IO yang disangga (buffered IO) kuasai — pembacaan berurutan ke depan di belakang penunjuk file — ditujukan pada beban kerja yang tidak dimiliki PDF
Versi pertama artikel ini mengklaim bahwa memori-mapped file menyelesaikan kegagalan kehabisan memori 32-bit yang ditemui TMemoryStream pada input 2 GB. Klaim tersebut salah, dan cara salahnya menunjuk pada perbaikan yang sebenarnya: jendela pemetaan yang bergeser. Berikut adalah pola akses, cerita 32-bit yang diperbaiki dengan mapper berjendela yang dapat dikompilasi, dan aritmatika syscall pada file uji 1,8 GB, 300.000 objek
Mengapa tata letak PDF menggagalkan pembacaan yang disangga (buffered reads)
Tiga fakta struktural membentuk pola IO. Pertama, navigasi digerakkan oleh offset: tabel referensi silang memetakan setiap nomor objek ke posisi bita absolut, dan tidak ada yang mengharuskan posisi tersebut untuk diurutkan. Setelah bertahun-tahun pembaruan bertahap, objek 4102 dapat berada pada offset 1,6 GB sementara objek 4103 berada pada 30 KB. Sebuah loop TFileStream mengubah setiap pengambilan menjadi Seek ditambah Read, dua transisi kernel, dengan buffer yang tidak memberikan kontribusi apa pun karena pengambilan berikutnya berjarak ratusan megabita jauhnya
Kedua, aliran objek (ISO 32000-1 §7.5.7) mengemas lusinan atau ratusan kamus kecil ke dalam satu wadah yang dikempiskan (deflated). Mengambil satu kamus halaman 300 bita dapat berarti membaca dan mengembangkan (inflating) cluster 100 KB. Sisi sebaliknya: objek yang ditulis bersama-sama cenderung dibaca bersama-sama, sehingga buffer yang disesuaikan dengan ukuran cluster melayani lusinan pengambilan berikutnya secara gratis — keteraturan yang paling dapat dieksploitasi dalam format tersebut
Ketiga, linierisasi. File yang dilinierisasi menempatkan halaman pertama dan tabel petunjuk di depan sehingga konsumen dapat membacanya dari depan ke belakang. Arsip gigabita hampir tidak pernah dilinierisasi: linierisasi dihancurkan oleh pembaruan bertahap dan penggabungan yang sama yang membuat file menjadi besar. Rencanakan kasus terburuk: lompatan panjang, tidak ada pengurutan, entri dari belakang terlebih dahulu
Cerita 32-bit, diperbaiki
Proses Windows 32-bit memiliki 2 GB ruang alamat pengguna, dan MapViewOfFile dengan jumlah bita nol meminta satu reservasi berdekatan (contiguous) seukuran file. Untuk input 2 GB reservasi itu tidak dapat berhasil: setelah EXE, DLL yang tersebar, dan tumpukan utas, blok berdekatan bebas terbesar dalam proses Delphi 32-bit tipikal berada di suatu tempat antara 700 MB dan 1,4 GB. Panggilan gagal dengan ERROR_NOT_ENOUGH_MEMORY, dinding yang sama yang dihadapi TMemoryStream.LoadFromFile, hanya dipindahkan dari RAM yang dikomit (committed) ke reservasi ruang alamat. Pemetaan file-penuh bukanlah perbaikan pada 32-bit, hanya kegagalan yang sama di balik nama API yang terdengar lebih baik
Perbaikannya adalah memisahkan dua hal yang dilakukan pemetaan. CreateFileMapping membuat objek bagian dan tidak memakan ruang alamat sama sekali, berapa pun ukuran filenya. Hanya MapViewOfFile yang menghabiskan ruang alamat, dan tidak ada yang memaksanya untuk memetakan seluruh bagian: itu mengambil offset awal 64-bit dan panjang tampilan. Buat bagian sekali, petakan tampilan 64 hingga 256 MB di atas wilayah yang sedang diurai, batalkan pemetaan sebelum bergeser: biaya ruang alamat adalah satu jendela, bukan satu file. Satu batasan: offset tampilan harus kelipatan SYSTEM_INFO.dwAllocationGranularity, 64 KB dalam praktiknya, sehingga permintaan untuk offset 1.000.000 dibulatkan ke bawah menjadi 983.040 dan penunjuk pemanggil disesuaikan ke depan oleh perbedaannya
Mapper jendela bergeser (sliding-window) di Delphi
Kelas di bawah ini membungkus seluruh disiplin: satu objek bagian, satu tampilan langsung, penyesuaian granularitas, dan pembacaan yang melintasi batas jendela ditangani dengan menumbuhkan satu tampilan itu alih-alih menggabungkan dua
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;
Dua detail memikul beban tersebut. Jalur cepat di bagian atas Map mengembalikan penunjuk tanpa transisi kernel ketika rentang yang diminta sudah berada di dalam tampilan langsung; berkat pengelompokan aliran-objek ini adalah kasus umum dan dari sanalah penghematan berasal. Dan permintaan yang melintasi akhir jendela default menumbuhkan MapSize untuk satu tampilan tersebut daripada menggabungkan dua, yang membuat ReadBytes tetap satu baris dan pemanggil bebas dari loop pembacaan-sebagian
Ukuran jendela adalah kenop yang memaafkan: pada 64 MB sapuan penuh file 1,8 GB adalah 29 tampilan, pada 256 MB itu adalah 8 tetapi setiap reservasi lebih sulit ditempatkan di ruang 32-bit yang terfragmentasi, dan di bawah sekitar 16 MB file yang banyak-lompatan memetakan ulang cukup sering untuk diperhatikan. Di mana saja dalam kisaran 64 hingga 256 MB, lalu lintas peta adalah derau statistik
Menghitung syscall
Sekarang aritmatika. File uji: 1,8 GB, 300.000 objek tidak langsung rata-rata sekitar 600 bita muatan (payload). Parser per-objek mengambil masing-masing dengan SetFilePointerEx ditambah 4 KB ReadFile: 600.000 transisi kernel. Syscall baca yang dicache bolak-balik dalam kira-kira 1,5 μs pada perangkat keras x64 saat ini, jadi itu adalah 600.000 × 1,5 μs ≈ 0,9 detik dari murni overhead kernel sebelum mengurai satu bita pun — kasus terbaik cache hangat. Dingin, setiap lompatan adalah operasi perangkat: pada latensi efektif ~20 μs dari pembacaan acak NVMe 4 KB, 300.000 darinya berharga sekitar 6 detik waktu perangkat; pada penyimpanan kelas SATA, bermenit-menit
Pembacaan juga memindahkan data yang salah: 300.000 × 4 KB mendorong 1,2 GB melalui penyangga pengguna untuk mengirimkan kira-kira 180 MB muatan — amplifikasi enam kali lipat, setiap bita disalin dari kernel ke pengguna
Buffer baca-ke-depan (read-ahead) yang disesuaikan dengan cluster aliran-objek adalah peningkatan jujur pertama: satu baca 256 KB per cluster alih-alih satu per objek mengurangi jumlah transisi satu hingga dua kali lipat. Itu juga alat yang tepat di mana pemetaan canggung, biasanya file yang dibagikan dalam jaringan (network shares)
Mapper berjendela melangkah lebih jauh. Sapuan penuh adalah 29 panggilan MapViewOfFile dan 29 UnmapViewOfFile, 58 transisi eksplisit berbanding 600.000. Penguraian yang digerakkan oleh xref nyata bukanlah sapuan bersih, tetapi jalur cepat menyerap setiap pengambilan di dalam jendela langsung; lintasan pengindeksan metadata pada arsip uji menetap pada beberapa ratus peta ulang. Pemetaan tidak menghapus pekerjaan kernel: ia mengubah syscall eksplisit menjadi kesalahan halaman yang diselesaikan oleh manajer memori dalam cluster multi-halaman, langsung dari cache file tanpa salinan ruang pengguna, dan wilayah yang tidak pernah disentuh tidak memakan biaya apa pun. Dari ujung ke ujung, lintasan pengindeksan berubah dari 23 d dingin dan 7,1 d hangat dengan pembacaan per-objek menjadi 6,5 d dingin dan 1,9 d hangat dengan mapper; apa yang tersisa adalah zlib inflate, bukan IO
Di mana FILE_FLAG_NO_BUFFERING cocok
FILE_FLAG_NO_BUFFERING melewati cache sistem dengan imbalan aturan penyelarasan yang keras: offset, panjang, dan alamat buffer semua diselaraskan dengan sektor (sector-aligned). Itu menghasilkan simpanannya pada pekerjaan berurutan lintasan tunggal yang jika tidak akan membanjiri cache dengan bita yang tidak seorang pun baca dua kali — re-serialisasi batch yang menulis ulang seluruh arsip, atau lintasan linierisasi pada output yang sudah selesai. Dengan penyangga selaras 4 hingga 8 MB itu mendekati bandwidth sekuensial perangkat tanpa mencemari cache
Ini benar-benar salah untuk mengurai. Lompatan xref acak melalui pegangan yang tidak disangga mengubah setiap pengambilan kamus 300 bita menjadi baca fisik penuh tanpa cache untuk menyerap kunjungan kedua — dan parser PDF mengunjungi kembali wilayah tersebut secara konstan, karena halaman yang berbeda diselesaikan ke aliran objek yang sama. IO tak-disangga (Unbuffered IO) untuk penulisan ulang berurutan, IO yang dipetakan atau di-cache untuk penguraian acak; flag ini adalah per-pegangan, jadi satu pipa dapat menampung keduanya pada file yang sama
64-bit, set kerja, dan sisi penulisan
Pada build 64-bit masalah ruang alamat menghilang: teruskan ukuran file sebagai jendela dan kelas di atas merosot menjadi pemetaan penuh tunggal. Tangkapannya pada layanan yang berjalan lama: halaman baca-saja yang didukung file tidak membebankan komit (commit), sehingga penghitung komit tetap tenang, tetapi setiap halaman yang disentuh bergabung dengan set kerja (working set); mengurai sebagian besar 1,8 GB dan set kerja tumbuh menyesuaikan, mengeluarkan yang lainnya. Jendela terbatas menempatkan langit-langit pada hal itu, sehingga pola pergeseran tetap menjadi default yang tepat bahkan di mana ruang alamat gratis
Di sisi penulisan, IO termurah adalah IO yang tidak pernah diterbitkan. Mekanisme pembaruan bertahap PDF (ISO 32000-1 §7.5.6) menambahkan objek yang berubah dan bagian referensi silang baru setelah bita aslinya, yang tidak pernah bergerak. Menstempel satu halaman ke arsip 1,8 GB menambahkan puluhan kilobita; penulisan ulang penuh memindahkan semua 1,8 GB, lima kali lipat jaraknya, dan tambahannya adalah murni output sekuensial di bagian akhir (tail)
Di mana pustaka losLab cocok
Kedua pustaka losLab PDF mengirimkan disiplin ini sebagai antarmuka API. HotPDF Direct File API membaca jumlah halaman dan struktur melalui file handle tanpa membangun pohon objek, menyalin dan mendekripsi di tingkat file, dan menulis delta melalui BeginIncrementalUpdate — strategi hanya-tambah di atas, dikemas. PDFlibPas mengambil rute yang sama dengan lapisan Akses Langsung-nya: pembaca aliran yang menelusuri tabel referensi silang di tempat, mengambil objek secara malas, mengekstrak rentang halaman file demi file, dan menyimpan hasil suntingan sebagai revisi bertahap. Jika Anda menulis parser Anda sendiri, kelas mapper menjadi milik Anda untuk diambil; jika Anda menjalankan pipa dokumen, biarkan pustaka menjaga jendelanya tetap jujur
Catatan: Penanganan IO yang dioptimalkan untuk dokumen skala gigabita dibangun langsung ke dalam Komponen HotPDF VCL untuk Delphi dan C++Builder