บทความเทคนิค

ค้นหาตัวกินหน่วยความจำ PDF ด้วยกราฟการอ้างอิงของ HotPDF

HotPDF เปิดให้ตรวจสอบโปรไฟล์หน่วยความจำของ PDF ที่โหลดไว้ในรูปแบบกราฟที่คิวรีได้: BuildLoadedObjectDependencyGraph คืนค่าหนึ่งโหนดต่อหนึ่งอ็อบเจกต์ทางอ้อม พร้อมขนาดตื้นโดยประมาณ ขนาดที่ถือครองแบบ dominator และแฟล็กว่าอ็อบเจกต์นั้นยังเข้าถึงได้จาก Catalog ของเอกสารหรือไม่ สิ่งนี้เปลี่ยนคำพูดที่ว่า “ไฟล์นี้ใช้ 800 MB” ให้กลายเป็น “อ็อบเจกต์ 4173 ซึ่งเป็น image XObject ถือครองหน่วยความจำเฉพาะตัวถึง 612 MB” ซึ่งเป็นข้อเท็จจริงที่คุณนำไปแก้ไขได้จริง

ความแตกต่างระหว่างสองประโยคนี้คือประเด็นทั้งหมด ขนาดตื้นบอกว่าอ็อบเจกต์หนึ่งใหญ่แค่ไหน ส่วนขนาดที่ถือครองบอกว่าหน่วยความจำจะถูกปล่อยคืนจริงเท่าไรถ้าอ็อบเจกต์นั้นหายไป ซึ่งเป็นตัวเลขที่ตัดสินว่าการแก้ไขนั้นได้ผลหรือไม่

เหตุใดปริมาณหน่วยความจำรวมจึงไม่ใช่ข้อเท็จจริงที่นำไปแก้ปัญหาได้

เพราะใน PDF แทบไม่มีอะไรเลยที่เป็นของสิ่งใดสิ่งหนึ่งเพียงสิ่งเดียว ฟอนต์ CID ที่ฝังไว้ตัวเดียวถูกอ้างอิงจากดิกชันนารีทรัพยากรของทุกหน้าที่ใช้มัน สตรีมโปรไฟล์ ICC หนึ่งชุดรองรับพื้นที่สีที่คอนเทนต์สตรีมสิบชุดใช้ร่วมกัน Form XObject ที่ใช้เป็นตราประทับปรากฏอยู่ในทั้ง 400 หน้า ถ้าคุณบวกขนาดอ็อบเจกต์ต่อหน้าแบบไม่คิดอะไร คุณจะนับฟอนต์นั้นซ้ำ 400 ครั้งและสรุปว่าทุกหน้ามีขนาดมหาศาล แต่ถ้าคุณหารด้วย 400 คุณจะสรุปว่าไม่มีอะไรมีต้นทุนสูงและหน่วยความจำมาจากที่อื่น

การวิเคราะห์แบบ dominator แก้ความกำกวมนี้ด้วยวิธีเดียวที่ยังใช้ได้จริงเมื่อเจอเอกสารจริง อ็อบเจกต์ X จะถูกระบุว่าเป็นของอ็อบเจกต์ที่ใกล้ที่สุดที่ถือครองมันแต่เพียงผู้เดียว หมายความว่าทุกเส้นทางการอ้างอิงจาก Catalog ไปยัง X ต้องผ่าน dominator นั้น ฟอนต์ที่ทุกหน้าใช้ร่วมกันจะไม่ถูกระบุว่าเป็นของหน้าใดหน้าหนึ่ง แต่จะถูกระบุว่าเป็นของโหนดที่ใกล้ที่สุดที่ทุกเส้นทางเหล่านั้นผ่าน ซึ่งมักจะเป็น Catalog เอง ส่วนฟอนต์ที่ใช้เฉพาะในหน้าเดียวจะถูกระบุว่าเป็นของหน้านั้น ผลลัพธ์คือ EstimatedRetainedBytes รวมค่าได้ถูกต้องแทนที่จะนับซ้ำ และอ็อบเจกต์ที่อยู่บนสุดของรายการก็คืออ็อบเจกต์ที่การลบออกจะปลดปล่อยหน่วยความจำได้จริง

กราฟนี้บรรจุอะไรไว้จริง ๆ

THPDFObjectDependencyNode แต่ละตัวบรรจุตัวระบุอ็อบเจกต์เป็น ObjectNumber และ GenerationNumber พร้อม ObjectType, LifecycleState, EstimatedShallowBytes, EstimatedRetainedBytes, IncomingReferenceCount, OutgoingReferenceCount ตัวระบุ dominator ที่ใกล้ที่สุด และ ReachableFromCatalog ส่วน THPDFObjectDependencyEdge แต่ละตัวบันทึกต้นทาง ปลายทาง Path ของดิกชันนารีที่พบการอ้างอิงนั้น และว่ามัน Resolved หรือไม่

ฟิลด์ Path คือสิ่งที่คนมักใช้ประโยชน์น้อยไป มันคือความต่างระหว่างการรู้แค่ว่าอ็อบเจกต์ 91 ชี้ไปที่อ็อบเจกต์ 4173 กับการรู้ว่ามันชี้ผ่าน /Resources/XObject/Im3 ซึ่งบอกคุณได้ทันทีว่าคุณกำลังดูเนื้อหาหน้า สตรีมรูปลักษณ์ของคำอธิบายประกอบ หรือกลุ่มเนื้อหาที่เป็นตัวเลือกซึ่งไม่มีใครเคยเรนเดอร์เลย

var
  Pdf: THotPDF;
  Nodes: THPDFObjectDependencyNodeArray;
  Edges: THPDFObjectDependencyEdgeArray;
  Info: THPDFObjectDependencyGraphInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('report-800mb.pdf') <> 1 then Exit;

    if not Pdf.BuildLoadedObjectDependencyGraph(Nodes, Edges, Info) then Exit;

    if Info.LimitExceeded then
      Log('Graph truncated: raise MaxObjects / MaxEdges');

    Log(Format('%d objects, %d edges, %d reachable, catalog retains %d bytes',
      [Info.ObjectCount, Info.EdgeCount, Info.ReachableObjectCount,
       Info.CatalogRetainedBytes]));

    SortByRetainedDescending(Nodes);
    for I := 0 to Min(9, High(Nodes)) do
      Log(Format('%d %d obj: shallow %d, retained %d, dominator %d',
        [Nodes[I].ObjectNumber, Nodes[I].GenerationNumber,
         Nodes[I].EstimatedShallowBytes, Nodes[I].EstimatedRetainedBytes,
         Nodes[I].ImmediateDominatorObjectNumber]));
  finally
    Pdf.Free;
  end;
end;

ขอบเขตทั้งสองเป็นพารามิเตอร์ที่ระบุชัดเจน มีค่าเริ่มต้นที่ 250,000 อ็อบเจกต์และ 2,000,000 เอดจ์ เมื่อเอกสารเกินค่าใดค่าหนึ่ง LimitExceeded จะถูกตั้งไว้ และกราฟที่คืนค่ามาจะเป็นส่วนต้นที่ถูกตัดทอน ไม่ใช่การโกหก คือผลลัพธ์บางส่วนพร้อมแฟล็กกำกับ ไม่ใช่ผลรวมที่ผิดอย่างเงียบ ๆ เพิ่มขีดจำกัดอย่างตั้งใจสำหรับการรันเชิงนิติวิทยาศาสตร์ และควรจำไว้ว่าการวิเคราะห์นี้เดินผ่านกราฟอ็อบเจกต์ทั้งหมด จึงควรอยู่ในเส้นทางการวินิจฉัย ไม่ใช่ในลูปเรนเดอร์ของคุณ

อ็อบเจกต์ที่เข้าถึงไม่ได้บอกอะไรคุณบ้าง

อ็อบเจกต์ที่ ReachableFromCatalog เป็น False คือหน่วยความจำที่เอกสารแบกไว้แต่โปรแกรมดูจะไม่มีวันแสดงผล ในทางปฏิบัติมันมาจากสามแหล่ง: การอัปเดตแบบเพิ่มขึ้นที่แทนที่อ็อบเจกต์รุ่นก่อนหน้าแล้วทิ้งของเดิมไว้ ผู้สร้างไฟล์ที่เขียนอ็อบเจกต์ไว้แล้วเชื่อมโยงไม่สำเร็จ หรือไฟล์ที่เสียหายซึ่งตารางอ้างอิงไขว้ถูกสร้างใหม่และหยิบคำนิยามที่ไม่มีอะไรอ้างอิงถึงเข้ามาด้วย

กรณีแรกเป็นเรื่องปกติที่คาดไว้แล้ว และนั่นคือสิ่งที่ การอัปเดตแบบเพิ่มขึ้นและ object stream ถูกออกแบบมาให้ทำ กรณีที่สองและสามควรค่าแก่การตรวจสอบ เมื่อไบต์ที่ถือครองส่วนใหญ่อยู่ในโหนดที่เข้าถึงไม่ได้ คุณก็พบเหตุผลที่เป็นรูปธรรมสำหรับการเขียนไฟล์ใหม่แทนการต่อท้ายไฟล์เดิม และคุณมีจำนวนไบต์ไว้ยืนยันเวลาประมวลผลที่เพิ่มขึ้นให้กับใครก็ตามที่ถาม

ตัวนับตัวจัดสรรหน่วยความจำสองตัวที่ควรอ่านก่อน

ก่อนสรุปว่าเอกสารมีขนาดใหญ่โดยธรรมชาติ ให้ตรวจสอบก่อนว่าตัวแยกวิเคราะห์เองเป็นต้นทุนหรือไม่ HotPDF เปิดตัวนับสองตัวที่บรรยายว่าการโหลดทำงานอย่างไร แทนที่จะบรรยายว่าเอกสารมีอะไรอยู่

GetLastParserArenaStatistics รายงาน block arena ที่ถือครองไว้ ซึ่งใช้สำหรับโทเคนและบัฟเฟอร์ของตัวแยกวิเคราะห์ที่มีอายุสั้น: RetainedBytes, PeakUsedBytes, AllocationCount, ReusedAllocationCount, TokenCount และ TemporaryObjectElisionCount ฟิลด์สุดท้ายนับคีย์ดิกชันนารีที่ถูกแยกวิเคราะห์เข้า arena โดยตรงแทนที่จะผ่านอ็อบเจกต์ชื่อชั่วคราว ซึ่งเป็นการจัดสรรหน่วยความจำที่เคยครองสัดส่วนหลักในการแยกวิเคราะห์ไฟล์ที่มีดิกชันนารีหนาแน่น

GetDocumentStringInternStatistics รายงาน intern pool ระดับเอกสารที่ตัดข้อมูลซ้ำของชื่อ PDF ตัวดำเนินการคอนเทนต์สตรีม และสตริงคงที่ที่ยาวไม่เกิน 64 ไบต์ ที่ซ้ำกัน มันให้ค่า RequestCount, HitCount, MissCount, BypassCount, RetainedBytes และ ReusedBytes พร้อมเพดานที่บังคับใช้อยู่ พูลนี้ถูกจำกัดไว้โดยตั้งใจที่ 65,536 รายการและ 4 MiB เพื่อไม่ให้เอกสารที่มุ่งร้ายซึ่งสร้างชื่อไม่ซ้ำนับล้านชื่อ เปลี่ยนการปรับหน่วยความจำให้เหมาะสมกลายเป็นตัวขยายหน่วยความจำ เมื่อถึงเพดานแล้ว สตริงถัดไปจะข้ามพูลนี้ไป และ BypassCount จะเพิ่มขึ้น

var
  Arena: THPDFParserArenaStatistics;
  Intern: THPDFDocumentStringInternStatistics;
begin
  if Pdf.GetLastParserArenaStatistics(Arena) then
    Log(Format('arena: peak %d, reused %d of %d allocations, %d tokens',
      [Arena.PeakUsedBytes, Arena.ReusedAllocationCount,
       Arena.AllocationCount, Arena.TokenCount]));

  if Pdf.GetDocumentStringInternStatistics(Intern) then
    Log(Format('intern: %d/%d hits, %d bypassed, %d bytes reused',
      [Intern.HitCount, Intern.RequestCount, Intern.BypassCount,
       Intern.ReusedBytes]));
end;

ค่า ReusedBytes ที่สูงพร้อมกับ BypassCount ที่ต่ำ หมายความว่าเอกสารมีคำศัพท์ซ้ำแบบที่ PDF จริงส่วนใหญ่มี และพูลนี้กำลังทำงานคุ้มค่า ส่วน BypassCount ที่เข้าใกล้ RequestCount หมายถึงมีอะไรผิดปกติ ไม่ว่าจะเป็นเอกสารที่ใหญ่มากจริง ๆ หรือเอกสารที่จงใจสร้างชื่อไม่ซ้ำขึ้นมา ซึ่งเป็นสัญญาณอ่อน ๆ ที่ควรบันทึกไว้ในเส้นทางรับไฟล์ที่ไม่น่าเชื่อถือ

ลำดับการคัดกรองที่ใช้ได้ผล

เริ่มจาก CatalogRetainedBytes ในข้อมูลกราฟ ถ้าตัวเลขนั้นใกล้เคียงกับการเติบโตของโพรเซสคุณ หน่วยความจำอยู่ในเอกสารนั้นเอง และกราฟจะบอกคุณว่าอยู่ตรงไหน ถ้าตัวเลขนั้นต่ำกว่ามาก หน่วยความจำอยู่ในแคชของคุณเอง ในบิตแมปที่เรนเดอร์แล้ว หรือในตัวแยกวิเคราะห์ ซึ่งตัวนับ arena จะบอกได้ว่าเป็นอันไหน

จากนั้นให้ดูสิบโหนดบนสุดตาม EstimatedRetainedBytes แล้วดู ObjectType ของมัน ถ้า Image XObject อยู่บนสุด แปลว่าไฟล์นี้เน้นภาพสแกนหนัก และการลดความละเอียดคือทางแก้ ถ้า font descriptor อยู่บนสุด แปลว่ามีการฝังฟอนต์เต็มชุดในจุดที่ควรใช้แค่ subset ถ้าคอนเทนต์สตรีมอยู่บนสุด มักหมายถึงกราฟิกเวกเตอร์ที่สร้างขึ้น มักเป็นแผนที่หรือไฟล์ส่งออกจาก CAD หลังจากนั้นเท่านั้นจึงค่อยคุ้มค่าที่จะดูอ็อบเจกต์ที่เข้าถึงไม่ได้และพฤติกรรมของตัวจัดสรร ถ้าทำตามลำดับนี้ คุณมักจะพบคำตอบในสองขั้นตอนแรก และสำหรับเอกสารขนาดใหญ่มาก แนวทางแบบสตรีมที่อธิบายไว้ใน เวิร์กโฟลว์ Direct File API มักเป็นทางแก้เชิงโครงสร้างมากกว่าการปรับแต่งทีละอ็อบเจกต์

การวินิจฉัยทั้งหมดนี้เป็นเพียงการเรียกฟังก์ชัน Pascal ธรรมดาที่คืนค่าเป็นเรคคอร์ดธรรมดา จึงเสียบเข้ากับเส้นทางล็อกหรือ telemetry ที่มีอยู่แล้วได้ทันที HotPDF เป็นคอมโพเนนต์ VCL แบบเนทีฟสำหรับ Delphi และ C++Builder พร้อมซอร์สโค้ดฉบับเต็ม เอกสารอ้างอิง API และรุ่นทดลองใช้งานอยู่ที่ หน้าคอมโพเนนต์ PDF สำหรับ Delphi ของ HotPDF