技術文章

最佳化 GB 級 PDF 處理的 IO 效能

PDF 剖析器第一次有用的讀取卻在檔案的錯誤一端。格式把 startxref 指標放在最後幾個位元組,因此處理 1.8 GB 封存檔時,先跳到尾端、讀取一 KB,再跳往交互參照表所指向的文件目錄位置。之後剖析會在整個位元組範圍隨機漫遊。緩衝 IO 擅長的檔案指標後方循序預讀,完全不是 PDF 的工作負載

本文初版聲稱記憶體對應檔案可解決 TMemoryStream 在 2 GB 輸入上的 32 位元記憶體不足,這是錯誤說法;錯誤之處正指向真正解法:滑動式對應視窗。以下說明存取模式、可編譯的視窗式對應器所修正的 32 位元觀念,以及 1.8 GB、300,000 物件測試檔案的系統呼叫運算

為何 PDF 配置會擊敗緩衝讀取

三項結構事實決定 IO 模式。第一,導覽依偏移量進行:交互參照表把物件編號對應至絕對位元組位置,位置不必排序。經多年增量更新後,物件 4102 可在 1.6 GB 偏移處,4103 卻在 30 KB。TFileStream 迴圈使每次擷取都成為 SeekRead 的兩次核心轉換;因下一次擷取可能相隔數百 MB,緩衝區毫無助益

第二,物件串流(ISO 32000-1 §7.5.7)會把數十或數百個小字典打包進一個 deflate 容器。擷取一個 300 位元組頁面字典,可能要讀取並解壓縮 100 KB 叢集。反面是同時寫入的物件往往同時讀取,因此與叢集同尺寸的緩衝區可免費服務接下來十餘次擷取,這是格式中最可利用的規律

第三是線性化。線性化檔案會把首頁與提示表前置,讓取用端從頭讀到尾。GB 級封存檔幾乎從不線性化;使檔案變大的增量更新與合併同時也會破壞線性化。請以惡劣情況規劃:長距跳躍、沒有排序、由尾端進入

PDF:GB 級 PDF 處理的逐跳存取模式:從檔尾的 startxref 進入,再走訪散落的交叉參照位移
PDF 導覽從檔尾進場,再跳到交叉參照表指向的任何地方,循序預讀就此失效

修正後的 32 位元觀念

32 位元 Windows 程序擁有 2 GB 使用者位址空間,而位元組計數為零的 MapViewOfFile 會要求一段與檔案等大的連續保留空間。2 GB 輸入無法成功:扣除 EXE、分散 DLL 與執行緒堆疊後,典型 32 位元 Delphi 程序最大的連續可用區塊約為 700 MB 至 1.4 GB。呼叫以 ERROR_NOT_ENOUGH_MEMORY 失敗,與 TMemoryStream.LoadFromFile 相同,只是從已提交 RAM 轉成位址空間保留。完整檔案對應不是 32 位元解法

解法是分開看待對應所做的兩件事。CreateFileMapping 建立區段物件,不占位址空間;只有 MapViewOfFile 會花費位址空間,且它可指定 64 位元起始偏移與檢視長度。區段只建一次,對正在剖析的區域對應 64 至 256 MB,滑動前解除對應;成本是一個視窗而非整個檔案。檢視偏移量須為 SYSTEM_INFO.dwAllocationGranularity 的倍數,實務上為 64 KB,所以偏移 1,000,000 的要求會向下取整至 983,040,並把呼叫端指標向前調整差額

Delphi 的滑動視窗對應器

以下類別封裝此完整規範:一個區段物件、一個即時檢視、粒度重新對齊,以及以擴大單一檢視處理跨越視窗邊界的讀取,而非拼接兩個檢視

uses
  Winapi.Windows, System.SysUtils;

type
  TWindowedFileMapper = class
  private
    FFile: THandle;
    FMapping: THandle;
    FFileSize: Int64;
    FGranularity: DWORD;      // SYSTEM_INFO.dwAllocationGranularity
    FWindowSize: NativeUInt;  // 預設檢視大小
    FViewBase: PByte;         // 目前檢視的基底(已對齊)
    FViewOffset: Int64;       // 檔案偏移 FViewBase 對應到
    FViewSize: NativeUInt;    // 目前檢視中已對映的位元組
    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;
  // 區段物件不保留任何位址空間,不論檔案大小
  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)]);

  // 快速路徑:要求的範圍已位於使用中的檢視內
  if (FViewBase <> nil) and (Offset >= FViewOffset) and
     (Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
    Exit(FViewBase + NativeInt(Offset - FViewOffset));

  Unmap;  // 滑動:絕不會同時持有兩個檢視

  // 檢視必須從配置粒度邊界開始
  AlignedOffset := Offset - (Offset mod FGranularity);
  Delta := NativeUInt(Offset - AlignedOffset);

  MapSize := FWindowSize;
  if MapSize < Size + Delta then   // 要求跨越視窗結尾:
    MapSize := Size + Delta;       // 擴大這個檢視以涵蓋它
  if AlignedOffset + Int64(MapSize) > FFileSize then
    MapSize := NativeUInt(FFileSize - AlignedOffset);  // 在 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 MB 時完整掃過 1.8 GB 檔案需要 29 個檢視;256 MB 時只要 8 個,但在碎片化 32 位元空間較難配置;低於約 16 MB 的重跳躍檔案會頻繁重新對應。64 至 256 MB 間的對應流量只是統計雜訊

計算系統呼叫

測試檔案為 1.8 GB、300,000 個間接物件,每個平均約 600 位元組承載資料。逐物件剖析器以 SetFilePointerEx 加 4 KB ReadFile 擷取各物件,共 600,000 次核心轉換。現代 x64 硬體的快取讀取系統呼叫往返約 1.5 μs,所以在剖析任何位元組前,純核心額外負擔便是 600,000 × 1.5 μs ≈ 0.9 秒。冷快取時每次跳躍都是裝置操作:NVMe 4 KB 隨機讀取約 20 μs,300,000 次約需 6 秒;SATA 級儲存則是數分鐘

讀取還搬移錯誤資料:300,000 × 4 KB 會推送 1.2 GB 穿過使用者緩衝區,實際只交付約 180 MB 承載資料,放大六倍,且每個位元組都從核心複製至使用者空間

第一個可靠改善是使用符合物件串流叢集的預讀緩衝:每個叢集讀取一次 256 KB,而非每個物件一次,可將轉換次數降低一至兩個數量級。它也適合不便對應的情況,通常是網路共用

視窗式對應器更進一步。完整掃描僅有 29 次 MapViewOfFile 與 29 次 UnmapViewOfFile,即 58 次明確轉換,相較 600,000 次。實際由 xref 驅動的剖析不是乾淨掃描,但快速路徑吸收即時視窗內所有擷取;測試封存檔的中繼資料索引只需數百次重新對應。對應不會消除核心工作,而是把明確系統呼叫轉成記憶體管理員以多頁叢集解決的分頁錯誤,直接來自檔案快取且無使用者空間複製,未碰觸區域也不耗成本。端到端索引由逐物件讀取的冷快取 23 秒、暖快取 7.1 秒,降至對應器的 6.5 秒與 1.9 秒;剩下的是 zlib 解壓縮而非 IO

PDF:碎裂的 32 位元位址空間拒絕整檔 MapViewOfFile,而 CreateFileMapping 區段加滑動 64 MB 映射視窗可以成功
一個 section 物件加一個活性檢視,就讓 1.8 GB 的 PDF 在 32 位元 Delphi 行程的 2 GB 位址空間裡讀得起來

FILE_FLAG_NO_BUFFERING 的適用位置

FILE_FLAG_NO_BUFFERING 會繞過系統快取,代價是偏移量、長度與緩衝區位址都須對齊磁區。它適合單次循序工作,避免把不會再讀的位元組塞滿快取,例如重寫整個封存檔的批次重新序列化,或已完成輸出的線性化流程。使用 4 至 8 MB 對齊緩衝,可接近裝置循序頻寬而不污染快取

它完全不適合剖析。未緩衝控制代碼的隨機 xref 跳躍會把每次 300 位元組字典擷取變成完整實體讀取,快取無法吸收第二次存取;PDF 剖析又會因不同頁面解析為相同物件串流而持續重訪區域。循序重寫用未緩衝 IO,隨機剖析用對應或快取 IO;旗標以控制代碼為單位,因此同一管線可同時持有兩者

64 位元、工作集與寫入端

64 位元建置時位址空間異議消失:把檔案大小傳作視窗即可得到單一完整對應。但長期服務有其限制:唯讀檔案支援頁面不計入 commit,因此 commit 計數安靜,卻會讓每個碰觸頁面進入工作集;剖析大部分 1.8 GB 後,工作集也隨之長大並排擠其他內容。有限視窗能設定上限,因此即使位址空間充足,滑動模式仍是正確預設

寫入端最便宜的 IO 是從未發出的 IO。PDF 的增量更新機制(ISO 32000-1 §7.5.6)會在原始位元組後附加已變更物件與新的交互參照區段,原始內容永不移動。對 1.8 GB 封存檔蓋上一頁章,只附加數十 KB;完整重寫要搬移全部 1.8 GB,相差五個數量級,且附加是尾端純循序輸出

losLab 函式庫的角色

兩個 losLab PDF 函式庫都將此規範作為 API 介面。HotPDF Direct File API 透過檔案控制代碼讀取頁數與結構而不建立物件樹,以檔案層級複製與解密,並透過 BeginIncrementalUpdate 寫入差異,將上述僅附加策略封裝起來。PDF Library for Delphi 的 Direct Access 層採取相同路線:串流讀取器會原地走訪交互參照表、延後擷取物件、在檔案間擷取頁面範圍,並將編輯保存為增量修訂。自行編寫剖析器可直接採用對應器類別;文件管線則可交由函式庫正確維持視窗

備註:針對 GB 級文件的最佳化 IO 處理已直接內建於 Delphi 與 C++Builder 的 HotPDF Delphi VCL Component

PDF:直條圖:索引含 300000 個物件的 1.8 GB PDF 時,逐物件 ReadFile 系統呼叫 600000 次,對上叢集預讀與 58 次視窗映射器切換
逐物件讀取在測試檔案上燒掉 600,000 次系統呼叫,開窗映射器把整趟掃描壓到 58 次切換