การใช้เวลาสองนาทีในการคัดลอกเอกสารสามหน้าออกมาจาก PDF ความยาว 40 หน้านั้นไม่ใช่ปัญหาของการปรับแต่งประสิทธิภาพ (performance tuning) มันคือสัญญาณบอกให้รู้ว่ามีการใช้เส้นทาง API ที่ผิด เมื่อผมเห็นตัวเลขเวลานี้ครั้งแรกในตัวอย่างการคัดลอกหน้ากระดาษของ HotPDF Component สัญชาตญาณแรกของผมคือให้ไปดูโครงสร้างเอกสารก่อนแล้วจึงค่อยดูโค้ดทีหลัง ซึ่งปรากฎว่าลำดับนั้นสำคัญมาก
อะไรที่ทำให้ช้าจริง ๆ
PDF ต้นเรื่องเป็นเอกสารอ้างอิงขนาด 40 หน้าที่มีโครงสร้างของ page tree ไม่ธรรมดาเลย: มีโหนด /Pages ระดับกลางหลายจุดแทนที่จะเป็น array แบน ๆ ตัวเดียว โค้ดตัวอย่างดั้งเดิมเรียกใช้ LoadFromFile จากนั้นสร้างเอกสารใหม่ด้วย BeginDoc นำหมายเลขหน้าที่เลือกเข้าลูปการทำซ้ำ และในแต่ละรอบมันจะโหลดเอกสารต้นฉบับจากดิสก์ซ้ำแล้วซ้ำเล่าเพื่อดึงหน้ามาหนึ่งหน้า นั่นหมายถึงต้นทุนการประมวลผล (parsing) อย่างเต็มสูบคูณด้วยจำนวนหน้าที่คุณต้องการ ไฟล์ขนาด 12 MB จะถูกอ่านจากดิสก์ถึงหกครั้งเพื่อการดึงข้อมูลแค่สามหน้า ทั้งหมดนี้ก็เพราะไม่มีใครสนใจว่าไฟล์จำเป็นต้องเปิดค้างไว้ตลอดทุกรอบลูปหรือไม่
ปัจจัยที่สองถูกซ่อนเอาไว้ในโค้ด: LoadFromFile ของ HotPDF จะทำงานด้วยการอ่านโครงสร้างตารางอ้างอิง (cross-reference table) ทั้งหมดและคลายการบีบอัด object stream ทุกอันตอนที่มันกำลังโหลด ซึ่งพฤติกรรมนี้ถูกต้องแล้วสำหรับเอกสารที่คุณตั้งใจจะปรับเปลี่ยน แต่มันคืองานที่มากเกินไปถ้าคุณต้องการเพียงแค่ดึงตัวเลขนับหน้าและเลือกแค่บางหน้าออกมา สำหรับการเข้าถึงโครงสร้างเพื่อการอ่านอย่างเดียว (read-only) คำสั่ง DAOpenFileReadOnly จะช่วยหลีกเลี่ยงการกระทำการพ่นออบเจกต์ทรี (deserialize object tree) ทั้งหมดแบบเต็มรูปแบบ ซึ่งมีประโยชน์อย่างยิ่งในกรณีของไฟล์ที่ถูกบีบอัดพร้อมกับรูปภาพใหญ่ ๆ
ทั้งคู่ไม่ใช่ปัญหาของไลบรารี (library bug) ทั้งคู่คือปัญหาที่ผู้ใช้เลือก API สำหรับทำงานหนึ่ง ไปใช้กับงานอื่น
การใช้ InsertPagesFromDocument สำหรับการคัดลอกหน้า
เส้นทางที่ถูกต้องสำหรับการคัดลอกช่วงหน้ากระดาษจากเอกสาร HotPDF เล่มหนึ่งไปยังอีกเล่มคือ InsertPagesFromDocument ซึ่งจะถูกเรียกหลังจาก LoadFromFile ลงบนต้นฉบับ คุณโหลดต้นฉบับครั้งเดียว โหลดหรือสร้างปลายทางครั้งเดียว ย้ายหน้ากระดาษ แล้วก็บันทึก ต้นฉบับจะยังคงอยู่ในหน่วยความจำตลอดการแทรกหน้ากระดาษเหล่านั้น:
procedure ExtractPages(const SourceFile, DestFile: string;
const PageRange: string);
var
Source, Dest: THotPDF;
begin
Source := THotPDF.Create(nil);
Dest := THotPDF.Create(nil);
try
// Load source once: full parse happens here and only here
Source.LoadFromFile(SourceFile);
// Build a minimal destination document
Dest.FileName := DestFile;
Dest.BeginDoc;
// Copy the requested range; '1-3' inserts pages 1 through 3
// starting at position 1 in the destination
Dest.InsertPagesFromDocument(Source, PageRange, 1);
Dest.EndDoc;
finally
Source.Free;
Dest.Free;
end;
end;
พารามิเตอร์ PageRange ยอมรับรูปแบบเดียวกับตัวอย่างแบบคอมมานด์-ไลน์: รายการคั่นด้วยเครื่องหมายจุลภาคสำหรับระบุเลขหน้าหรือช่วงหน้า อย่าง '1-3' หรือ '1,5,7-9' หน้ากระดาษเริ่มต้นจากหมายเลข 1 InsertPagesFromDocument จะคัดลอก content streams, พจนานุกรมทรัพยากร, และรูปทรงของหน้ากระดาษ โดยไม่แตะต้อง metadata, ที่คั่นหน้า, หรือไฟล์แนบฝังตัว นอกเสียจากว่าสิ่งเหล่านั้นจะถูกอ้างอิงจากหน้าที่คัดลอก สำหรับการคัดลอกเพียง 3 หน้าออกจากเอกสารหนา 40 หน้า ถือเป็นหน่วยการทำงาน (working set) ขนาดเล็ก
ผลลัพธ์ของเวลาสำหรับการใช้ไฟล์ 12 MB ตัวเดิมที่ตอนแรกทำงานนานถึงสองนาที: เหลือไม่ถึง 1.5 วินาที ด้วยรูปแบบ (pattern) โค้ดนี้ เวลาส่วนใหญ่ที่ใช้ไปก็คือตอนเรียก LoadFromFile นั่นแหละ โครงสร้างเอกสารจะกลายเป็นสิ่งไม่เกี่ยวพันทันทีที่ออบเจกต์ต่าง ๆ ถูกคลี่คลายในครั้งแรก
เมื่อ LoadFromFile เป็นงานที่หนักไป: Direct File API
หากคุณต้องการเพียงแค่นับหน้า ตรวจสอบข้อมูลของเอกสาร หรือคัดลอกไฟล์โดยไม่ต้องแตะเนื้อหาภายใน Direct File API จะหลีกเลี่ยงกระบวนการประมวลผลเต็มรูปแบบไปโดยสิ้นเชิง DAOpenFileReadOnly จะจับคู่ตารางการอ้างอิง (cross-reference table) แบบคร่าว ๆ โดยไม่ทำคลายการบีบอัดจาก object stream เลย ทำให้การนับจำนวนหน้าทำงานในแบบ O(xref size) แทนที่จะเป็น O(file size):
procedure InspectPDF(const FileName: string);
var
Pdf: THotPDF;
Handle, PageCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Handle := Pdf.DAOpenFileReadOnly(FileName, '');
if Handle <= 0 then
Exit;
try
PageCount := Pdf.DAGetPageCount(Handle);
Writeln('Pages: ', PageCount);
// DACopyFile is a byte-preserving copy, no re-serialization
Pdf.DACopyFile(FileName, 'archive-copy.pdf');
finally
Pdf.DACloseFile(Handle);
end;
finally
Pdf.Free;
end;
end;
ข้อควรระวัง: DAOpenFileReadOnly รองรับการใช้พารามิเตอร์รหัสผ่าน แต่มันจะใช้การทำงานแบบตีกลับ (fallback) เป็นการประมวลผล (parse) แบบเต็มสำหรับเอกสารที่ถูกเข้ารหัส เพราะการถอดรหัส (decryption) นั้นต้องการออบเจกต์ทรีสำหรับการถอดรหัสพจนานุกรมการเข้ารหัส (encryption dictionary) หากไฟล์ต้นฉบับมีการเข้ารหัส ให้คุณสั่งถอดรหัสก่อนโดยใช้ DecryptFile สำหรับคัดลอกข้อมูลฉบับถอดรหัสออกมาก่อน แล้วจึงส่งไปเปิดด้วย Direct File API อีกครั้ง การสั่งถอดรหัสที่ระดับไฟล์โดยฟังก์ชัน DecryptFile จะนำไปสู่เส้นทางของการเขียน AES-256 ซ้ำด้วยการเข้ารหัสรูปแบบมาตรฐาน และจะประมวลผลไวกว่าเมื่อเทียบกับการเรียก LoadFromFile แล้วตามด้วย SaveLoadedDocument สำหรับไฟล์ขนาดใหญ่ เพราะแบบหลังสุดนี้ไม่ได้ช่วยเรื่องการทำ in-memory ให้กับ object model เลย
ความจำสำหรับการทำงานชุดใหญ่ (large-batch processing)
การทำงานของแบตช์งานที่สั่งรันไฟล์จำนวนหลายสิบไฟล์ผ่านลูปมีรูปแบบหนึ่งที่ดูเหมือนจะถูกต้องแต่ทำให้สะสมหน่วยความจำ: คือสร้าง THotPDF ไว้ข้างในลูป เรียก LoadFromFile แล้วทำงาน จากนั้นเรียกใช้ Free โครงสร้างมันดูโอเคดีอยู่ แต่ปัญหาจะเกิดเมื่อการทำงานด้านในลูปเริ่มสร้าง scratch object, จับ (catch) ข้อผิดพลาด (exception) เปล่า ๆ แล้วทิ้งออบเจกต์เหล่านั้นไว้ในตอนเกิดข้อผิดพลาด การจัดการหน่วยความจำของ Delphi (Memory manager) ไม่ทำหน้าที่จัดเรียง (compact) ตัวมันเอง ดังนั้นร่องรอยของการรั่วไหล (leak) เป็นร้อยจุดในการรันหนึ่งแบตช์สามารถดันให้หน่วยความจำทะยานสูงขึ้นจนไปทำให้การจัดสรรหน่วยความจำส่วนอื่น ๆ ช้าลง
วิธีแก้ปัญหาไม่ได้แปลกใหม่อะไร ทุก THotPDF และทุก TStream หรือ TBitmap ระดับกลางที่มีส่วนในการทำงานของ PDF ควรกำหนดให้อยู่ภายในบล็อกการทำงาน try/finally โดยมี Free เป็นคำสั่งสุดท้ายของคำสั่ง ให้ตั้ง pointer เฉพาะที่ให้มีค่า nil ก่อนส่วนการ try ทั้งนี้เพื่อให้การทำงานแบบ branch สามารถเรียกใช้คำสั่งตรวจสอบได้อย่าง if Assigned(x) then x.Free เพื่อความปลอดภัยเมื่อการเตรียมระบบมีความขัดข้องกลางคัน ถือเป็นมาตรฐานสำคัญในการดูแลสิทธิ์ของ Delphi และรวมถึงคลาสปัญหาทั้งหมดด้วย
อีกสิ่งหนึ่งที่ควรตรวจสอบในบริบทการทำงานแบบแบตช์: AddImage จะจัดการเรื่องการขึ้นทะเบียนกับรายการที่มีอยู่ (internal list) ซึ่งมีบทบาทสำคัญตลอดช่วงการทำงานของอินสแตนซ์ THotPDF หากคุณใช้อินสแตนซ์เดิมกับเอกสารตัวอื่นโดยการเรียก LoadFromFile ซ้ำหลาย ๆ ที รายชื่อทะเบียนรูปภาพจากเอกสารชิ้นเดิมก็จะคงทิ้งไว้ในลิสต์ ไม่สร้างอินสแตนซ์หน้าใหม่ต่อชิ้นเอกสาร ก็เรียกใช้ล้างค่าระบบเก็บรูปภาพทิ้งก่อนการทำไฟล์ถัดไป
การวัดผลก่อนลงมือเปลี่ยน
ก่อนตัดสินใจเลือกลงมือในสไตล์การเข้าถึงใด ๆ ลองคำนวณการทำงานเสียก่อน เครื่องมืออย่าง TStopwatch ของ Delphi ที่อยู่ใน System.Diagnostics จะห่อตัวทำงานของ QueryPerformanceCounter ที่แม่นยำมากพอต่อความสามารถในการทำงานด้วย wall-clock profiling ระดับไฟล์ I/O ทดลองจับเวลาครอบเฉพาะแค่ส่วนการเรียกใช้ LoadFromFile ตัวเดียวดูว่าใช้เวลาไปเท่าไหร่ หากเวลาที่ใช้คือความต่างประมาณ 90% ของภาพรวม เวลาที่แก้ก็คือนำ Direct File API ไปใช้งานเสีย หรือช่วยบรรเทาภาระการประมวลผลไฟล์เดิมให้ทำงานน้อยลง แต่หากอยู่ในเกณฑ์เพียง 20% ก็เชื่อได้เลยว่าส่วนคอขวดมาจากตรงอื่นแล้วและคุณกำลังตามหาผิดที่
การดึงหน้าสองนาทีที่เริ่มต้นโพสต์นี้ กลายเป็นรูปแบบการโหลดที่ทำซ้ำทั้งหมด โครงสร้างเอกสารไม่มีส่วนเกี่ยวข้องใดๆ: โครงสร้างผังหน้าแบบระนาบเดียวก็มีค่าการรันออกมาเหมือนกัน การเปลี่ยนให้รัน LoadFromFile 1 ตัว ตามหลังต่อด้วย InsertPagesFromDocument ลดระยะเวลาลงมาได้ถึงเกณฑ์ที่ 1.3 วินาทีสำหรับระดับฮาร์ดแวร์ตัวเดียวกัน โดยไม่ต้องเสียเวลาแก้ตัวอื่นอีกเลย
API การปรับแต่งหน้ากระดาษที่แสดงในที่นี้เป็นส่วนหนึ่งของ HotPDF Component สำหรับ Delphi และ C++Builder