HotXLS, thư viện Excel gốc cho Delphi và C++Builder, thực hiện phân tích cú pháp các trang tính XLSX trên nhiều luồng thông qua một quy trình tải ba giai đoạn: tệp XML của trang tính được giải nén tuần tự, phân tích cú pháp song song, và sau đó các phần nhỏ được đọc tuần tự. Phiên bản đầu tiên của tính năng này chỉ cải thiện hiệu năng khoảng 12–25%, vì khóa mặc định của bộ quản lý bộ nhớ Delphi đã vô tình tuần tự hóa các luồng làm việc. Việc cắt giảm phân bổ vùng nhớ heap từ khoảng 20 xuống còn 9.1 lần trên mỗi ô đã giúp tăng hiệu năng chạy song song lên đến 1.90 lần trên tám luồng. Bài viết này trình bày chi tiết các số liệu đo lường, những hướng đi sai lầm, và hai giải pháp khắc phục thực sự mang lại hiệu quả
HotXLS phân tích cú pháp các trang tính XLSX song song như thế nào?
HotXLS chia hàm Open thành ba giai đoạn, và chỉ giai đoạn ở giữa là chạy trên các luồng làm việc (worker threads). Lý do là từ thùng chứa zip: một tài liệu nén zip là một luồng đầu vào dùng chung với một máy trạng thái inflate duy nhất, và máy trạng thái đó không thể được đọc bởi hai luồng cùng một lúc. Việc bọc nó trong một khóa lock là vô nghĩa, vì hoạt động giải nén inflate về bản chất là tuần tự cho mỗi mục, do đó một khóa lock sẽ chỉ lặp lại quá trình thực thi tuần tự với chi phí phát sinh thêm. Do đó, Giai đoạn A thực hiện giải nén XML của từng trang tính vào đối tượng TMemoryStream riêng của nó khi vẫn đang chạy đơn luồng; trong tệp thử nghiệm của chúng tôi, việc này chỉ mất khoảng 4 ms cho tám phần trang tính, nên nó hoàn toàn không phải là nút thắt cổ chai. Giai đoạn B chạy hàm ParseWorksheetXml cho từng trang tính trên một nhóm luồng làm việc (worker pool), đây là nơi chiếm hầu hết thời gian tải tệp. Giai đoạn C quay trở lại tệp zip một cách tuần tự để xử lý các phần nhỏ: chú thích, hình vẽ, biểu đồ và bảng
Bản thân nhóm luồng làm việc được thiết kế đơn giản có chủ ý. Các luồng làm việc lấy chỉ số công việc từ một bộ đếm dùng chung bằng InterlockedIncrement, nhờ đó các trang tính có kích thước không đều sẽ tự động được phân phối cân bằng một cách tự nhiên mà không cần bất kỳ bộ lập lịch nào. Số lượng luồng là min(số trang tính, số nhân CPU), ngoại lệ đầu tiên của luồng làm việc được thu nhận bằng AcquireExceptionObject và được kích hoạt lại trên luồng chính sau khi gộp luồng, và bộ điều phối sẽ tự động chuyển về một vòng lặp tuần tự thông thường khi số lượng công việc chỉ có không hoặc một. Two thuộc tính trên TXLSXWorkbook kiểm soát tính năng này: ParallelParse đóng vai trò cổng kích hoạt nhóm luồng, và ParallelParseThreads giới hạn số lượng luồng, với giá trị 0 nghĩa là tự động. Các sổ làm việc có nhiều trang tính là đối tượng được hưởng lợi lớn nhất, bao gồm cả loại tệp được tạo ra bằng cách nhân bản một trang tính mẫu hàng chục lần
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.ParallelParse := True; // enable the parallel worker pool
Book.ParallelParseThreads := 0; // 0 = auto: min(sheets, CPU cores)
if Book.Open('quarterly-ledger.xlsx') <= 0 then
raise Exception.Create('open failed');
// ... read cells as usual; the workbook is fully materialized ...
finally
Book.Free;
end;
end;
Tại sao thêm luồng lại làm cho việc phân tích cú pháp XLSX chậm đi trong Delphi?
Bởi vì bộ quản lý bộ nhớ mặc định của Delphi bảo vệ vùng nhớ heap của nó bằng một khóa toàn cục (global lock), trong khi việc phân tích cú pháp trang tính lại là một tác vụ phân bổ bộ nhớ cực kỳ đậm đặc: hàng triệu ô dữ liệu, Variant, và WideString. Mọi luồng làm việc khi chạm vào vùng nhớ heap đều phải xếp hàng chờ đợi khóa đó, do đó các luồng trông có vẻ độc lập trong mã nguồn thực tế lại được thực thi gần như tuần tự từng luồng một. Bài thử nghiệm đầu tiên của chúng tôi đã chứng minh điều này một cách rõ ràng. Trên một sổ làm việc có 8 trang tính với 5,000 hàng nhân 4 cột trên mỗi trang tính, đo lường trên bộ vi xử lý i5-11600K (6 nhân, 12 luồng) chạy Win64, lệnh Open song song chỉ cải thiện 12–25% so với mức ước tính ít nhất 40%. Một phép quét số lượng luồng qua các mức 2, 3, 4, 6, và 8 luồng tạo ra một đường cong phẳng lỳ, và trong các lượt chạy đo kiểm sau đó, cấu hình 2 luồng thậm chí còn chậm hơn 26% so với chạy tuần tự, triệu chứng kinh điển của việc hai luồng tranh giành nhau một khóa chung
Ba số liệu đo lường đã chỉ ra chính xác nguyên nhân, và mỗi số liệu đều lật ngược những trực giác trước đó của chúng tôi. Thứ nhất, một tệp tin siêu nhỏ (8 trang tính có 1 hàng) mở trong 1.2 ms, chứng minh rằng việc phân tích cú pháp chiếm gần như 100% thời gian của Open và không có chi phí cố định ẩn nào đáng trách. Thứ hai, một bài kiểm thử nhỏ (microbenchmark) về việc phân bổ bộ nhớ thuần túy cho thấy hiệu năng của bộ quản lý bộ nhớ Delphi bị giảm ngược: cùng một khối lượng phân bổ gồm 2 triệu đối tượng và AnsiString chạy chậm hơn 60% trên 8 luồng so với trên 1 luồng, trong khi phép thử tương tự trên vùng nhớ heap của WideString (vốn sử dụng bộ phân bổ BSTR của COM chứ không phải bộ quản lý bộ nhớ Delphi) lại tăng tốc lên 3.7 lần. Việc HotXLS sử dụng WideString trong toàn bộ hệ thống hóa ra lại là một sự tình cờ của lịch sử mang lại lợi thế cho chúng tôi. Thứ ba, hàm GetProcessTimes cho thấy trong suốt quá trình chạy Open song song, thời gian CPU tiêu thụ xấp xỉ bằng thời gian thực tế trôi qua: tám luồng trên danh nghĩa chỉ tiêu thụ lượng CPU tương đương khoảng 1.3 luồng thực tế. Các luồng làm việc không hề hoạt động tích cực; chúng đang ngủ trong đường dẫn tranh chấp của bộ quản lý bộ nhớ, bị chặn lại thay vì đang xử lý
20 lần phân bổ vùng nhớ heap trên mỗi ô bắt nguồn từ đâu?
Một trình đếm bộ nhớ (counting wrapper) được cài đặt qua hàm SetMemoryManager đã trả lời chính xác câu hỏi đó: có khoảng 20 lần phân bổ bộ nhớ của Delphi trên mỗi ô, với 2.87 triệu lần phân bổ trong số đó có kích thước từ 32 byte trở xuống. Thủ phạm hoàn toàn không phải là các đối tượng ô dữ liệu. Hàm TXMLScaner.GetTokenValue tạo ra một AnsiString mới trong mỗi lần gọi, và nó được gọi khoảng 15–20 lần cho mỗi ô: một lần cho mỗi tên phần tử, tên thuộc tính, giá trị thuộc tính và nội dung văn bản. Hơn thế nữa, đường dẫn hàm UTF8ToWideString của thư viện RTL tạo ra một chuỗi trung gian UnicodeString tạm thời cho mỗi lượt chuyển đổi. Các đối tượng ô chỉ chiếm khoảng 160 nghìn lần phân bổ (khoảng 8% tổng số), điều này đã dập tắt kế hoạch ban đầu của chúng tôi: chúng tôi từng có ý định xây dựng một bể chứa đối tượng ô (cell object pool), nhưng các con số cho thấy chi phí đầu tư sẽ không bao giờ mang lại hiệu quả
var
OldMM, NewMM: TMemoryManagerEx;
AllocCount, TinyCount: Int64;
function CountingGetMem(Size: NativeInt): Pointer;
begin
AtomicIncrement(AllocCount);
if Size <= 32 then
AtomicIncrement(TinyCount); // the small-object churn we care about
Result := OldMM.GetMem(Size);
end;
// install before Open, restore afterwards
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);
Giải pháp khắc phục: lưu trữ chuỗi token dùng chung và bộ giải mã UTF-8 không trung gian
Hai thay đổi mục tiêu trong trình đọc XML đã loại bỏ hơn một nửa số lần phân bổ trên mỗi ô mà không cần chạm vào cấu trúc của trình phân tích cú pháp. Thứ nhất là lưu trữ dùng chung tên phần tử (element-name interning). Tệp XML của trang tính lặp đi lặp lại một lượng từ vựng siêu nhỏ một cách vô tận: row, c, v, r, t, s, và một vài tên thuộc tính. Hàm InternTokenName duy trì một bộ đệm cache gồm 64 ngăn chứa các tên đã gặp trước đó và so sánh bộ đệm xây dựng của trình quét với một mục đã lưu bằng TokenEqualsAnsi, một phép so sánh byte trực tiếp không tốn bộ nhớ phân bổ. Khi khớp, nó trả về đối tượng AnsiString đã đệm, và ở đây việc chọn kiểu dữ liệu rất quan trọng: kiểu AnsiString được đếm tham chiếu (reference counted), do đó việc trả về một phiên bản đệm chỉ tốn một lượt tăng bộ đếm tham chiếu và không phát sinh phân bổ trên heap. Kiểu WideString không có đếm tham chiếu, và mọi phép gán đều đi qua hàm SysAllocString, vì vậy việc lưu trữ dùng chung cho các WideString sẽ không tiết kiệm được gì. Việc lưu dùng chung chỉ có giá trị thực thi trên kiểu chuỗi có đếm tham chiếu
function TXMLScaner.InternTokenName: AnsiString;
var
Slot: Integer;
begin
Slot := TokenHash mod 64;
if TokenEqualsAnsi(FInternNames[Slot]) then
Result := FInternNames[Slot] // refcount++ only, no allocation
else
begin
Result := GetTokenValue; // materialize once, then cache
FInternNames[Slot] := Result;
end;
end;
Thay đổi thứ hai tập trung vào nội dung văn bản của ô. Đường dẫn cũ xây dựng một token AnsiString, chuyển nó sang hàm UTF8ToWideString để tạo ra một chuỗi trung gian UnicodeString, rồi cuối cùng mới chuyển thành WideString mà ô lưu trữ: tốn hai lần phân bổ bộ nhớ của Delphi cho mỗi token văn bản trước khi thực sự lưu trữ nó. Giải pháp thay thế là hàm XmlUtf8ToWide(TokenPtr, TokenLen), một bộ giải mã UTF-8 viết bằng Pascal thuần túy chạy qua hai lượt đọc trực tiếp từ bộ đệm quét: lượt một đo độ dài UTF-16, lượt hai giải mã thẳng thành một WideString được phân bổ một lần duy nhất. Chi phí thực tế cho mỗi token văn bản: một lần phân bổ của COM, không tốn lần phân bổ nào của Delphi. Một lưu ý nhỏ về mặt ngữ nghĩa: đối với các chuỗi UTF-8 bị lỗi định dạng, bộ giải mã mới sẽ chuyển thẳng các byte qua thay vì thay thế bằng các ký tự đại diện như cách thư viện RTL thực hiện, điều này chỉ ảnh hưởng đến cách hiển thị của các tệp tin bị hỏng; còn đối với đầu vào hợp lệ thì kết quả đầu ra giống nhau hoàn toàn từng byte. Các thực thể ký tự XML không bao giờ đi tới bộ giải mã, vì trình quét đã phân giải chúng thành UTF-8 trong bộ đệm token
Cải tiến mang lại kết quả gì, và những trường hợp phân tích cú pháp song song không giúp ích được gì
Hai giải pháp sửa lỗi đã giảm lượng phân bổ trên mỗi ô từ khoảng 20 xuống còn 9.1 lần, và các số liệu đo song song đã cải thiện đúng như những gì lý thuyết dự đoán. Trên cùng bài thử nghiệm 8 trang tính, 5,000 hàng và cùng cấu hình máy tính 6 nhân 12 luồng, mức cải thiện hiệu năng 8 luồng đã tăng từ 14% lên 47.4%, đạt tốc độ nhanh hơn 1.90 lần so với chạy tuần tự. Cấu hình chạy 2 luồng chuyển biến từ chậm hơn 26% thành nhanh hơn 23.6%, và hiệu suất sử dụng CPU đo được tăng từ 1.0 lần lên 2.2 lần. Đường dẫn tuần tự thông thường cũng nhanh hơn khoảng 3%, vì việc giảm phân bổ bộ nhớ cũng giúp ích cho một luồng đơn lẻ. Khoảng 9 lần phân bổ còn lại cho mỗi ô bao gồm một nửa là đối tượng ô và một nửa là sự gia tăng kích thước thùng chứa được khấu hao; chúng tôi đã đo lường chúng, đánh giá hiệu quả thu hồi giảm dần nên đã dừng lại, với trình đếm bộ nhớ luôn sẵn sàng kiểm tra lại theo từng vị trí gọi nếu các khối lượng công việc trong tương lai cần một đợt tối ưu hóa khác
Các giới hạn thực tế cần được nêu rõ ràng tương tự như các cải tiến. HotXLS thực hiện song song hóa ở cấp độ trang tính, vì vậy một sổ làm việc chỉ có một trang tính khổng lồ sẽ vẫn chỉ được phân tích cú pháp trên một luồng duy nhất bất kể bạn thiết lập thuộc tính ParallelParseThreads như thế nào; đối với dạng tệp đó, trình đọc trực tiếp dạng luồng là giải pháp tối ưu hơn, vì nó hoàn toàn không cần khởi tạo sổ làm việc trong bộ nhớ. Các tệp tin dành phần lớn thời gian cho các phần thuộc Giai đoạn C như hình vẽ, biểu đồ và chú thích sẽ nhận được ít lợi ích hơn vì giai đoạn đó được thiết kế chạy tuần tự. Các tệp nhỏ hoàn toàn không đáng để chạy đa luồng, đó là lý do tại sao bộ điều phối sẽ tự động chạy tuần tự khi số lượng công việc quá nhỏ. Và giới hạn trần của bộ quản lý bộ nhớ không hề biến mất mà chỉ lùi lại: ở mức 9.1 lần phân bổ trên mỗi ô, khóa toàn cục vẫn tạo gánh nặng lên các luồng làm việc, đó là lý do tại sao tám luồng chỉ mang lại tốc độ 1.90 lần chứ không phải 4 lần. Để tìm hiểu bộ công cụ rộng hơn nhằm cắt giảm thời gian tải và lưu tệp, bao gồm kiểu dáng, bể chứa đối tượng và các hàm gọi lại dòng hàng loạt, hãy xem tài liệu hướng dẫn của chúng tôi về hiệu năng sổ làm việc lớn trong Delphi
Tính năng phân tích cú pháp XLSX song song, các thuộc tính ParallelParse và ParallelParseThreads, cùng trình đọc XML tiết kiệm bộ nhớ được mô tả ở đây đi kèm như các bộ phận tiêu chuẩn của HotXLS Delphi Excel Component, hỗ trợ đọc và ghi các định dạng XLS, XLSX, và ODS gốc từ Delphi và C++Builder không cần tiến trình tự động hóa Excel nào