Artikel Teknis

Mengoptimalkan Kinerja IO untuk Pemrosesan PDF Skala Gigabita

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

PDF: Pola akses hop-by-hop pemrosesan PDF skala-gigabyte, masuk di startxref di ekor berkas lalu menelusuri offset referensi silang yang tersebar
Navigasi PDF masuk dari ekor lalu melompat ke mana pun tabel cross-reference menunjuk, yang mengalahkan read-ahead berurutan

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;  // ukuran view bawaan
    FViewBase: PByte;         // basis view saat ini (aligned)
    FViewOffset: Int64;       // offset file yang sesuai dengan FViewBase
    FViewSize: NativeUInt;    // byte yang dipetakan dalam view saat ini
    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;
  // section object tidak memesan address space, berapa pun ukuran file
  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: range yang diminta sudah berada dalam view aktif
  if (FViewBase <> nil) and (Offset >= FViewOffset) and
     (Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
    Exit(FViewBase + NativeInt(Offset - FViewOffset));

  Unmap;  // geser: jangan pernah menahan dua view sekaligus

  // View harus dimulai pada boundary allocation-granularity
  AlignedOffset := Offset - (Offset mod FGranularity);
  Delta := NativeUInt(Offset - AlignedOffset);

  MapSize := FWindowSize;
  if MapSize < Size + Delta then   // request melintasi ujung window:
    MapSize := Size + Delta;       // perbesar view ini agar mencakupnya
  if AlignedOffset + Int64(MapSize) > FFileSize then
    MapSize := NativeUInt(FFileSize - AlignedOffset);  // batasi pada 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

PDF: Ruang alamat 32-bit terfragmentasi yang menolak MapViewOfFile seluruh-berkas sementara bagian CreateFileMapping dan jendela pemetaan geser 64 MB berhasil
Satu objek section plus satu view hidup menjaga PDF 1.8 GB tetap terbaca di dalam ruang alamat 2 GB milik proses Delphi 32-bit

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. PDF Library for Delphi 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

PDF: Bagan kolom 600000 syscall ReadFile per-objek versus read-ahead klaster dan 58 transisi windowed-mapper saat mengindeks PDF 1.8 GB yang memuat 300000 objek
Pembacaan per objek membakar 600,000 syscall pada arsip uji sementara mapper berjendela memangkas sapuan penuh menjadi 58 transisi