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

โปรแกรมดู PDFium Delphi: แคชการแสดงผลและกลยุทธ์การซูมที่ราบรื่น

ลองกดปุ่มซูมค้างไว้ในตัวแสดงผล PDF แบบไร้เดียงสาแล้วดูกราฟ CPU การกดปุ่มซูมแบบ auto-repeat เพียงครั้งเดียวจะยิงขั้นตอนการซูมสิบกว่าครั้งต่อวินาที และถ้าแต่ละขั้นตอนสั่งให้เรนเดอร์หน้าที่มองเห็นใหม่แบบคุณภาพเต็มทุกครั้ง งานเรนเดอร์ก็จะกองพะเนินเร็วกว่าที่มันจะทำเสร็จ หน้าเดี่ยว ๆ เรนเดอร์ได้ดีเวลาแยกทำ อาจจะ 180 มิลลิวินาทีสำหรับสแกน A4 หนึ่งหน้า แต่ตอนนี้คุณกำลังรันการเรนเดอร์ 180 มิลลิวินาทีอยู่สิบกว่ารอบ กับงานที่ผู้ใช้เลื่อนผ่านไปแล้ว ตัวแสดงผลจะค้าง คอร์หนึ่งจะพุ่งไปที่ 100% และกว่าหน้าจอจะตามทัน ผู้ใช้ก็หยุดอยู่ที่ระดับซูมซึ่งผ่านการเรนเดอร์ไปแล้วสี่รอบก่อนหน้า ทางแก้ไม่ใช่ตัวเรนเดอร์ที่เร็วขึ้น แต่คือแคชที่คืนหน้าที่เสร็จแล้วได้ทันที และลูปเรนเดอร์ที่ยอมทิ้งงานทันทีที่มันล้าสมัยไปแล้ว

PDFium Component มอบชิ้นส่วนสำหรับทั้งสองเรื่องนี้ให้คุณ แต่ไม่ยุ่งกับนโยบายการตัดสินใจ คุณจะได้ bitmap ที่ผู้เรียกเป็นเจ้าของ ตัวเรนเดอร์แบบ progressive ที่รับ cancellation token ได้ fit mode ที่คำนวณซูมใหม่เมื่อปรับขนาดหน้าต่าง และฟังก์ชันแบ่ง tile สำหรับหน้าที่ใหญ่เกินกว่าจะเรนเดอร์ทั้งหน้าในครั้งเดียว สิ่งที่มันตั้งใจไม่ให้มาคือตัวแคชเอง เพราะนโยบายการเอาออก (eviction) ที่ถูกต้องขึ้นอยู่กับวิวพอร์ตของคุณ เพดานหน่วยความจำของแพลตฟอร์มคุณ และวิธีที่ผู้ใช้ของคุณเลื่อนหน้าจอ การตัดสินใจนั้นเป็นหน้าที่ของคุณที่ต้องทำให้ถูกต้อง และผลที่ตามมาถ้าทำผิดก็คืออาการค้างและการรั่วไหลของหน่วยความจำนั่นเอง

มิลลิวินาทีและเมกะไบต์หายไปไหน

ก่อนจะออกแบบอะไรทั้งนั้น ให้ใส่ตัวเลขลงบนต้นทุนก่อน หน้า A4 ที่ 96 DPI มีขนาดประมาณ 794 x 1123 พิกเซล คิดเป็น bitmap แบบ 32 บิตราว 3.5 MB ถ้าซูมไปที่ 200% ตัวเลขนั้นจะเพิ่มเป็นสี่เท่า ที่ 400% บนจอความละเอียดสูง คุณกำลังจัดสรรและเติมข้อมูลให้ bitmap หน้าเดียวขนาด 50 ถึง 60 MB และตัวแสดงผลแบบเลื่อนต่อเนื่องก็เก็บหลายหน้าไว้พร้อมกันในหน่วยความจำ ต้นทุนการทำ rasterization แปรผันตามจำนวนพิกเซลเอาต์พุต ดังนั้นทุกครั้งที่ซูมเพิ่มเป็นสองเท่า ทั้งเวลาเรนเดอร์และหน่วยความจำก็จะเพิ่มเป็นราวสี่เท่าไปพร้อมกัน

มีผลลัพธ์สองอย่างที่ตามมาโดยตรงจากเลขคณิตนี้ แคชที่คีย์ไม่สนใจระดับซูมนั้นไร้ประโยชน์ เพราะท่าทางที่มันต้องเร่งความเร็วให้พอดี คือการซูม จะสร้าง bitmap ใหม่ขึ้นทุกครั้ง และแคชที่ไม่มีขอบเขตจะทำให้โปรเซสแบบ 32 บิตหมดพื้นที่ address space พอดีกับเอกสารที่คนซูมหนักที่สุด: สแกนโฉนดที่ดินความหนาแน่นสูง แบบวิศวกรรม แผนที่ขนาดใหญ่ แคชต้องมีคีย์ที่ถูกต้องและมีเพดานที่แน่นหนา ทั้งสองอย่างนี้ไม่ใช่ทางเลือก

อะไรบ้างที่ควรอยู่ในคีย์ของแคช

bitmap ที่แคชไว้จะปลอดภัยต่อการนำกลับมาใช้ซ้ำก็ต่อเมื่อทุกอินพุตที่กำหนดรูปพิกเซลของมันยังคงตรงกันอยู่เท่านั้น นั่นหมายถึงหมายเลขหน้า ระดับซูมที่มีผลจริง (หรือเทียบเท่าคือขนาดพิกเซลของเอาต์พุต) การหมุน DPI ของจอมอนิเตอร์ และออปชันเรนเดอร์ที่มีผลอยู่ตอนที่มันถูกสร้างขึ้น หน้าที่เรนเดอร์ด้วย reAnnotations เป็นภาพที่ต่างจากหน้าเดียวกันที่ไม่มีคำอธิบายประกอบ และการผ่าน grayscale ด้วย reGrayscale ก็ต่างออกไปอีก ถ้าตัดข้อไหนออกจากคีย์ บั๊กที่จะเกิดขึ้นก็คาดเดาได้: ภาพซ้อนทับของคำอธิบายประกอบที่ยังค้างอยู่หลังจากผู้ตรวจทานลบความเห็นไปแล้ว หรือหน้าที่กลายเป็นภาพเบลอทันทีที่ผู้ใช้ลากหน้าต่างจากจอโน้ตบุ๊กไปยังจอ 4K ภายนอก แล้ว DPI เปลี่ยนไปข้างใต้ bitmap ที่ล้าสมัยแล้ว

การค้นแคชเรนเดอร์ของ PDFium ใน viewer ของ Delphi ที่คีย์แคชประกอบจากหน้า, ซูม, การหมุน, DPI ของจอ และออปชันเรนเดอร์, hit คืนบิตแมปภายในระดับไมโครวินาที และการขับไล่คืนบิตแมปทุกตัวที่มันทิ้ง
cache key ครอบคลุมอินพุตทุกตัวที่หล่อหลอมพิกเซล และการขับไล่คืนบิตแมปที่มันทิ้ง
function TPageCache.Acquire(Pdf: TPdf; PageNo: Integer; ZoomPct: Single;
  Rotation: TRotation; Opts: TRenderOptions): TBitmap;
var
  Key: string;
begin
  Key := Format('%d|%.0f|%d|%d|%d',
    [PageNo, ZoomPct, Ord(Rotation), Screen.PixelsPerInch, OptionsMask(Opts)]);
  if FBitmaps.TryGetValue(Key, Result) then
    Exit;

  Pdf.PageNumber := PageNo;
  Result := Pdf.RenderPage(0, 0, OutputWidth(PageNo, ZoomPct),
    OutputHeight(PageNo, ZoomPct), Rotation, Opts);
  FBitmaps.Add(Key, Result);   // ตอนนี้แคชเป็นเจ้าของ bitmap นี้แล้ว
end;

เมื่อแคชฮิต (hit) ฟังก์ชันนี้จะคืนค่าในระดับไมโครวินาที ซึ่งเป็นประเด็นทั้งหมดของมัน คำถามที่ยากกว่าคือเกิดอะไรขึ้นกับ bitmap ที่หลุดออกจากแคชไป และนั่นกลายเป็นคำถามเรื่องว่าใครเป็นเจ้าของมันกันแน่

ใครเป็นคนปล่อย (free) bitmap

รูปแบบฟังก์ชันของ RenderPage คืนค่า TBitmap ที่ผู้เรียกเป็นเจ้าของ ในการส่งออกแบบครั้งเดียวจบ ความเป็นเจ้าของนั้นชัดเจนและปฏิบัติตามได้ง่าย แต่ภายในแคชมันกลายเป็นการรั่วไหลที่พบบ่อยที่สุดในตัวแสดงผล PDF ของ Delphi เพราะดิกชันนารีตอนนี้ถือการอ้างอิงเดียวของ bitmap แต่ละตัว และ TDictionary ธรรมดาจะปล่อยคีย์และค่าให้คุณเองก็ต่อเมื่อมันเป็น managed type เท่านั้น ซึ่ง TBitmap ไม่ใช่ ถ้าเอาเอนทรีออกโดยไม่เรียก Free พิกเซลก็ยังคงถูกจัดสรรอยู่โดยไม่มีอะไรชี้ไปหามันอีกแล้ว

เหตุผลที่เรื่องนี้หลุดรอดไปได้คือเรื่องจังหวะเวลา การทดสอบแบบ smoke test สิบนาทีไม่มีวันซูมหน้าที่ต่างกันมากพอที่จะสังเกตเห็น การรั่วไหลจะแสดงตัวก็ต่อเมื่อมีคนเลื่อนและซูมเอกสารยาว ๆ ไปสักสองสามชั่วโมง ถึงตอนนั้นโปรเซสก็ถือ bitmap หน้าที่ไม่มีเจ้าของเป็นร้อย ๆ ตัวอยู่ และเครื่องก็เริ่มทำ paging นี่คือเหตุผลที่การเอาออก (eviction) ต้องอยู่ในแคชตั้งแต่เวอร์ชันแรก ไม่ใช่มาเพิ่มทีหลัง ให้กำหนดเพดานแคชด้วยจำนวนไบต์ที่ประมาณไว้ คำนวณจากความกว้างคูณความสูงคูณสี่ เอาหน้าที่ใช้น้อยที่สุดล่าสุด (least-recently-used) ที่อยู่นอกวิวพอร์ตและหน้าต่าง prefetch ออกไป แล้วปล่อย bitmap ทุกตัวเมื่อเอามันออก สำหรับการวาดที่เป็นแบบชั่วคราวจริง ๆ overload ที่เรนเดอร์ลงใน TBitmap ที่ผู้เรียกจัดเตรียมไว้ให้ หรือเรนเดอร์ตรงลงบน HDC จะช่วยให้คุณข้ามเรื่องความเป็นเจ้าของไปได้เลย ตัวอย่างที่ชัดเจนคือพรีวิวการพิมพ์ เพราะคุณเรนเดอร์แต่ละแผ่นแค่ครั้งเดียว การแคชมันจึงไม่ได้ประโยชน์อะไรเลย

การเรนเดอร์แบบ progressive และการยกเลิกอย่างซื่อตรง

overload ธรรมดาของ RenderPage จะบล็อกจนกว่าหน้าจะเสร็จ ซึ่งเป็นพฤติกรรมที่คุณไม่ต้องการเลยในขณะที่ผู้ใช้ยังคงขยับตัวควบคุมการซูมอยู่ สำหรับกรณีนั้นคุณต้องหันไปใช้ RenderPageProgressive มันรับ IPdfCancellationToken และคืนค่าเป็นหนึ่งใน prsDone, prsCancelled หรือ prsFailed รายละเอียดเชิงพฤติกรรมที่ทำให้คนพลาดคือการยกเลิกไม่ได้เกิดขึ้นทันที token จะถูก poll ที่ขอบเขตของแต่ละ chunk ภายในการเรนเดอร์ ดังนั้น token ที่คุณส่งสัญญาณกลางคันของ chunk จะมีผลก็ต่อเมื่อ chunk นั้นทำเสร็จแล้วเท่านั้น บนหน้าที่ซับซ้อน ความหน่วงระหว่างการขอกับการหยุดจริงอยู่ที่หลักสิบมิลลิวินาที ให้ออกแบบรอบ ๆ ช่องว่างนั้น แทนที่จะหวังให้มันหายไปเฉย ๆ: ยกเลิก token ก่อนหน้าทันทีที่ค่าซูมใหม่มาถึง แต่อย่าสมมติว่าการเรนเดอร์เก่าจะหยุดทันทีที่คุณสั่งให้มันหยุด

ไทม์ไลน์การเรนเดอร์แบบ progressive ของ PDFium ใน Delphi ที่คำขอซูมใหม่แต่ละคำขอยกเลิก token ก่อนหน้า, การยกเลิกตกที่ขอบ chunk, เรนเดอร์ที่ถูกแทนที่คืน prsCancelled และความพยายามสุดท้ายคืน prsDone
คำขอ zoom ใหม่แต่ละครั้งยกเลิก token เรนเดอร์ตัวก่อน และการยกเลิกลงจอดที่ขอบ chunk
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // ทิ้งการเรนเดอร์ก่อนหน้าที่ยังทำงานค้างอยู่
  FTokenSource := TPdfCancellationTokenSource.New;  // ยูนิต FPdfAsync

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // ถูกแทนที่ด้วยคำขอที่ใหม่กว่า: ทิ้งไปอย่างเงียบ ๆ
    prsFailed:    ShowRenderFailure;
  end;
end;

ในระหว่างการโต้ตอบ prsCancelled คือผลลัพธ์ปกติ ไม่ใช่กรณีพิเศษ การเรนเดอร์ส่วนใหญ่ที่ท่าทางการซูมเริ่มไว้จะถูกแทนที่ก่อนที่มันจะเสร็จ ดังนั้นให้ถือว่าการยกเลิกเป็นเรื่องปกติประจำวันแล้วทิ้งผลลัพธ์ไปอย่างเงียบ ๆ คิวเรนเดอร์ที่บันทึกการยกเลิกทุกครั้งเป็น warning จะฝังความล้มเหลวที่สำคัญจริง ๆ หนึ่งครั้งไว้ใต้บรรทัด log นับพันบรรทัดที่เป็นแค่สัญญาณรบกวน เพื่อไม่ให้หน้าจอดูเหมือนค้างในขณะที่การเรนเดอร์จริงกำลังทำงานอยู่ ให้จับคู่เส้นทาง progressive กับตัวแทนราคาถูก: ปรับสเกล bitmap ที่แคชไว้ก่อนหน้าให้เป็นซูมใหม่แล้วแสดงผลทันที มันจะดูเบลอไปสักร้อยสองร้อยมิลลิวินาที แต่ผู้ใช้จะรู้สึกว่ามันเกิดขึ้นทันที และมันจะซื้อเวลาให้การเรนเดอร์คุณภาพเต็มมีเวลาพอที่จะทำเสร็จหรือถูกยกเลิกโดยท่าทางถัดไป

fit mode ที่การซูมปิดไปเงียบ ๆ

พร็อพเพอร์ตี้ FitMode ของตัวแสดงผล เมื่อตั้งเป็น pfmFitPage หรือ pfmFitWidth จะคำนวณซูมใหม่ทุกครั้งที่ปรับขนาดหน้าต่าง เพื่อให้หน้ายังคงพอดีเมื่อหน้าต่างเปลี่ยนขนาด ข้อดักคือการกำหนดค่า Zoom โดยตรงจะรีเซ็ต FitMode กลับไปเป็น pfmNone ในฐานะค่าเริ่มต้นนี่ถูกต้องแล้ว: ผู้ใช้ที่ตั้งใจพิมพ์ 150% ไม่ต้องการให้การปรับขนาดหน้าต่างครั้งถัดไปทิ้งค่านั้นไป แต่มันจะทำให้ใครก็ตามที่ต่อปุ่มซูมเข้าด้วย Zoom := Zoom * 1.25 แล้วหาสาเหตุไม่เจอว่าทำไม fit-to-width ถึงหยุดตอบสนองหลังคลิกครั้งแรกต้องประหลาดใจ ถ้าทูลบาร์ของคุณมีทั้งการซูมแบบระบุค่าตรง ๆ และ fit mode คุณต้องจำค่าที่ผู้ใช้เลือก fit ไว้ล่าสุดเอง แล้วกำหนดกลับเมื่อเขากดปุ่ม fit อีกครั้ง คอมโพเนนต์จะไม่คืนโหมดที่การกำหนดค่าซูมเพิ่งล้างไปให้เอง และมันก็ไม่ควรจะทำแบบนั้นด้วย

งบประมาณหน่วยความจำที่คุณอธิบายได้

งบประมาณที่คุณเขียนลงเป็นตัวเลขได้คืองบประมาณที่คุณต่อสู้เพื่อมันได้ใน code review ดังนั้นให้เริ่มจากสถานการณ์จริงที่จับต้องได้ สมมติว่าการเลื่อนต่อเนื่องเก็บหน้าที่มองเห็นบวกกับหน้า prefetch หนึ่งหน้าด้านบนและด้านล่าง พร้อมกับแถบภาพย่อ ที่ 100% บนจอ 96 DPI bitmap ขนาดเต็มสามตัวนั้นรวมกันประมาณ 3.5 MB ต่อตัว ซึ่งถือว่าไม่มากเลย แต่ที่ 300% บนจอ 4K bitmap สามตัวเดิมกลายเป็นราว 30 MB ต่อตัว และนั่นยังไม่รวมที่แคชเก็บหน้าเก่าไว้แม้แต่หน้าเดียว การเติบโตอยู่ที่ท่าทางการใช้งาน ไม่ใช่ที่ตัวเอกสาร

เลขคณิตหน่วยความจำบิตแมปของ PDFium สำหรับ viewer ของ Delphi ที่การซูมขยายสองเท่าแต่ละครั้งทำให้หน่วยความจำหน้าโตสี่เท่า, การเลื่อนแบบต่อเนื่องคงหน้าที่มีชีวิตสามหน้า, งบ LRU ที่จำกัดเพดานป้องกันแคช และ RenderTile จัดการภาพวาดที่ใหญ่เกิน
การเพิ่ม zoom เป็นสองเท่าทุกครั้งทำให้หน่วยความจำบิตแมปสี่เท่าตัว แคชจึงต้องมีเพดานแบบตายตัวและการแบ่งเป็น tile สำหรับหน้าขนาดเกิน

ค่าเริ่มต้นที่สมเหตุสมผลสำหรับโปรเซส Delphi แบบ 32 บิตคืองบ bitmap 256 MB ภายใต้การเอาออกแบบ LRU บน 64 บิตคุณสามารถปรับตามขนาด RAM จริงได้ แต่ก็ยังต้องมีเพดานตายตัวอยู่ดี เพราะความล้มเหลวที่คุณกำลังป้องกันไม่ใช่โปรเซสของคุณ crash แต่คือทั้งเครื่องกำลังเธรชชิ่ง (thrashing) page file ของมัน ในขณะที่ตัวแสดงผลของคุณในทางเทคนิคยังคงทำงานอยู่ และผู้ใช้ก็สงสัยว่าทำไมทุกอย่างอื่นถึงช้าลง เพดานตายตัวจะล้มเหลวแบบคาดเดาได้ ส่วนแคชที่ไม่มีขอบเขตจะล้มเหลวโดยลากทั้งเดสก์ท็อปลงไปด้วย ภาพย่อสมควรได้รับการดูแลของตัวเองต่างหาก: เรนเดอร์แต่ละภาพเพียงครั้งเดียวที่ขนาดเป้าหมายเล็ก ๆ ของมัน แล้วเก็บไว้ในพูลแยกต่างหากที่ตรรกะ LRU ไม่แตะต้องเลย การสร้างภาพย่อขนาด 120 พิกเซลใหม่ด้วยการลดขนาดจาก bitmap เต็มหน้าขนาด 60 MB คือวิธีที่สิ้นเปลืองที่สุดในการผลิตแสตมป์ดวงเล็ก ๆ ดวงหนึ่ง

บางหน้าเดี่ยว ๆ ก็เอาชนะงบประมาณใด ๆ ได้เลย แบบวิศวกรรมขนาด E หรือแผนที่ขนาดใหญ่ที่เรนเดอร์ทั้งหน้าที่ 400% คือการจัดสรรหน่วยความจำหลายร้อยเมกะไบต์ และไม่มีนโยบายการเอาออกไหนทำให้เรื่องนั้นยอมรับได้ คำตอบตรงนั้นคือหยุดเรนเดอร์ทั้งหน้า RenderTile จะทำ rasterize เฉพาะบริเวณที่ตำแหน่งพิกเซล (Left, Top) ภายในหน้าที่ถูกสเกลตามแนวคิดเป็น PageWidth คูณ PageHeight ดังนั้นคุณจะเรนเดอร์แค่สี่เหลี่ยมที่มองเห็นบวกกับขอบหนึ่ง tile รอบ ๆ มันเพื่อให้การเลื่อนภาพราบรื่น และคุณจะรวม tile offset เข้าไปในคีย์ของแคชควบคู่ไปกับซูม ให้เก็บขนาด tile ให้คงที่ตลอดทั้งเอกสาร tile ที่คงที่หมายความว่าการเปลี่ยน DPI จะทำให้กริดทั้งหมดใช้ไม่ได้อย่างเรียบร้อยพร้อมกันหมด ในขณะที่ tile ที่ขนาดแปรผันจะทำให้คุณต้องไล่ตามรอยต่อที่มองเห็นได้ระหว่างบริเวณที่เรนเดอร์ด้วยสเกลต่างกันเล็กน้อย

มีอีกสองฟีเจอร์ที่อยู่ใกล้เคียงกันซึ่งเพิ่มภาระให้กับเรื่องทั้งหมดนี้อย่างเงียบ ๆ การผ่านฟิลเตอร์สีอย่าง grayscale หรือการกลับสีทำงานหลังจากเรนเดอร์ และสร้าง bitmap ขนาดเต็มตัวที่สองขึ้นมาทุกครั้ง ทำให้ภาระต่อหน้าของมุมมองใดก็ตามที่ใช้มันเพิ่มเป็นสองเท่า ต้นทุนนี้เป็นหัวข้อของบทความการกรองสีสำหรับผู้อ่านสายตาเลือนรางในตัวแสดงผล PDF ของ Delphi และตัวแสดงผลที่ไฮไลต์คำระหว่าง text-to-speech จะทำให้มุมมองที่เรนเดอร์แล้วใช้ไม่ได้ทุกครั้งที่มีการอ่านออกเสียงคำใหม่ ดังนั้นปฏิสัมพันธ์ระหว่างการวาดไฮไลต์ใหม่กับอัตราการอ่านออกเสียงจึงสำคัญกว่าที่เห็นในตอนแรกมาก ตามที่กล่าวไว้ในการไฮไลต์แบบทีละคำสำหรับ TTS

overload ของการเรนเดอร์ รหัสสถานะแบบ progressive และตัวคอมโพเนนต์ตัวแสดงผลเองล้วนมีเอกสารประกอบอยู่บนหน้าผลิตภัณฑ์ของ PDFium Component