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 迴圈使每次擷取都成為 Seek 加 Read 的兩次核心轉換;因下一次擷取可能相隔數百 MB,緩衝區毫無助益
第二,物件串流(ISO 32000-1 §7.5.7)會把數十或數百個小字典打包進一個 deflate 容器。擷取一個 300 位元組頁面字典,可能要讀取並解壓縮 100 KB 叢集。反面是同時寫入的物件往往同時讀取,因此與叢集同尺寸的緩衝區可免費服務接下來十餘次擷取,這是格式中最可利用的規律
第三是線性化。線性化檔案會把首頁與提示表前置,讓取用端從頭讀到尾。GB 級封存檔幾乎從不線性化;使檔案變大的增量更新與合併同時也會破壞線性化。請以惡劣情況規劃:長距跳躍、沒有排序、由尾端進入
修正後的 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
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