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

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