HotXLS, library Excel native untuk Delphi dan C++Builder, mem-parsing worksheet XLSX pada beberapa thread lewat pemuatan tiga fase: XML sheet didekompresi secara serial, di-parsing secara paralel, lalu bagian-bagian kecil dibaca secara serial sesudahnya. Rilis pertama fitur itu hanya menghasilkan 12–25%, karena lock pada memory manager bawaan Delphi menserialkan thread pekerjanya. Memangkas alokasi heap dari sekitar 20 menjadi 9.1 per sel menaikkan speedup paralel ke ×1.90 pada delapan thread. Artikel ini menelusuri pengukurannya, belokan-belokan yang keliru, dan dua perbaikan yang benar-benar berhasil
Bagaimana HotXLS mem-parsing worksheet XLSX secara paralel?
HotXLS memecah Open menjadi tiga fase, dan hanya fase tengah yang berjalan di thread pekerja. Alasannya adalah container zip: sebuah arsip zip adalah satu input stream bersama dengan satu state machine inflate, dan state machine itu tidak bisa dibaca dua thread sekaligus. Membungkusnya dengan lock akan sia-sia, karena inflate memang serial secara hakiki untuk setiap entry, sehingga lock hanya akan mereproduksi eksekusi serial dengan tambahan overhead. Karena itu Fase A mendekompresi XML setiap worksheet ke dalam TMemoryStream-nya sendiri selagi masih single-threaded; pada file benchmark kami hal ini memakan sekitar 4 ms untuk delapan bagian sheet, jadi sama sekali jauh dari bottleneck. Fase B menjalankan ParseWorksheetXml untuk setiap sheet di atas worker pool, dan di sinilah hampir seluruh waktu pemuatan berdiam. Fase C kembali ke zip secara serial untuk bagian-bagian kecil: comment, drawing, chart, dan table
Worker pool-nya sendiri sengaja dibuat sederhana. Worker menarik indeks job dari sebuah counter bersama dengan InterlockedIncrement, sehingga sheet berukuran tidak seragam terseimbangkan secara alami tanpa scheduler apa pun. Jumlah thread-nya adalah min(jumlah sheet, core CPU), exception pertama dari worker ditangkap dengan AcquireExceptionObject lalu dilempar ulang di thread utama setelah join, dan dispatcher-nya turun menjadi loop serial biasa ketika job-nya nol atau satu. Dua properti pada TXLSXWorkbook mengendalikan fitur ini: ParallelParse menjadi gerbang pool-nya, dan ParallelParseThreads membatasi jumlah thread, dengan 0 berarti otomatis. Workbook multi-sheet adalah bentuk yang diuntungkan, termasuk jenis yang Anda hasilkan dengan menggandakan worksheet template puluhan kali
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.ParallelParse := True; // aktifkan worker pool paralel
Book.ParallelParseThreads := 0; // 0 = otomatis: min(sheet, core CPU)
if Book.Open('quarterly-ledger.xlsx') <= 0 then
raise Exception.Create('open failed');
// ... baca sel seperti biasa; workbook sudah termaterialisasi penuh ...
finally
Book.Free;
end;
end;
Mengapa menambah thread justru memperlambat parsing XLSX di Delphi?
Karena memory manager bawaan Delphi melindungi heap-nya dengan sebuah lock global, sementara parsing worksheet padat alokasi: sel, Variant, dan WideString sebanyak jutaan. Setiap worker yang menyentuh heap mengantre pada lock itu, sehingga thread yang tampak independen di dalam kode sumber pada praktiknya dieksekusi nyaris satu per satu. Benchmark pertama kami membuat hal ini terasa menyakitkan konkretnya. Pada workbook 8 sheet dengan 5.000 baris kali 4 kolom per sheet, diukur di i5-11600K (6 core, 12 thread) di bawah Win64, Open paralel hanya membaik 12–25% terhadap perkiraan rencana yang setidaknya 40%. Penyapuan jumlah thread di 2, 3, 4, 6, dan 8 thread menghasilkan kurva datar, dan pada uji berinstrumen berikutnya konfigurasi 2 thread justru 26% lebih lambat daripada serial, tanda khas dua thread yang saling mengoper bola pada lock yang diperebutkan
Tiga pengukuran memakukan diagnosisnya, dan masing-masing menjungkirkan intuisi sebelumnya. Pertama, sebuah file mungil (8 sheet berisi 1 baris) terbuka dalam 1.2 ms, membuktikan bahwa parsing pada dasarnya adalah 100% dari Open dan tidak ada biaya tetap tersembunyi yang bisa disalahkan. Kedua, microbenchmark keriuhan alokasi murni menunjukkan memory manager Delphi berskala mundur: volume total yang sama berupa 2 juta alokasi objek dan AnsiString berjalan 60% lebih lambat pada 8 thread dibanding pada satu thread, sementara keriuhan yang sama terhadap heap WideString, yang merupakan alokator BSTR COM alih-alih MM Delphi, berskala hingga ×3.7. Kenyataan bahwa HotXLS memakai WideString di mana-mana ternyata adalah kecelakaan sejarah yang berpihak kepada kami. Ketiga, GetProcessTimes menunjukkan bahwa selama Open paralel, waktu CPU kurang lebih setara dengan waktu dinding: delapan thread nominal hanya menghabiskan sekitar 1.3 thread waktu CPU. Para worker itu tidak sedang berputar-putar; mereka tertidur di jalur perebutan memory manager, terblokir alih-alih sibuk
Pelajaran praktisnya berlaku umum, jauh melampaui spreadsheet. Jika sebuah beban kerja Delphi banyak mengalokasi, menaikkan jumlah thread tidak berbuat apa-apa sampai laju alokasinya turun, dan ia bahkan mudah memperburuk keadaan. Sebelum perbaikan ini, kami menyampaikan kebenaran jujur kepada pengguna yang menyetel ParallelParseThreads: pada file yang terikat alokasi, thread yang lebih banyak nyaris tidak membeli apa-apa
Dari mana datangnya 20 alokasi heap per sel?
Sebuah pembungkus penghitung yang dipasang dengan SetMemoryManager menjawab pertanyaan itu dengan tepat: sekitar 20 alokasi MM Delphi per sel, dengan 2,87 juta di antaranya berukuran 32 byte atau kurang. Biang keroknya sama sekali bukan objek sel. TXMLScaner.GetTokenValue memateralisasi AnsiString baru pada setiap panggilan, dan ia dipanggil kira-kira 15–20 kali per sel: sekali masing-masing untuk nama elemen, nama atribut, nilai atribut, dan konten teks. Di atas itu, rute UTF8ToWideString milik RTL memproduksi perantara UnicodeString sementara untuk setiap konversi. Objek sel hanya menyumbang 160 ribu alokasi, sekitar 8% dari total, dan itu mematikan rencana awal kami seketika: kami semula berniat membangun pool objek sel, dan angkanya mengatakan hal itu tidak akan pernah menutup biayanya sendiri
var
OldMM, NewMM: TMemoryManagerEx;
AllocCount, TinyCount: Int64;
function CountingGetMem(Size: NativeInt): Pointer;
begin
AtomicIncrement(AllocCount);
if Size <= 32 then
AtomicIncrement(TinyCount); // keriuhan objek kecil yang kami pedulikan
Result := OldMM.GetMem(Size);
end;
// pasang sebelum Open, pulihkan sesudahnya
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);
Diagnostik sepuluh menit itu layak dicuri untuk investigasi performa Delphi mana pun. Menghitung alokasi per bucket ukuran hampir tidak berbiaya untuk dibangun dan memberi tahu Anda dari mana tekanan memory manager sebenarnya berasal, yang dalam kasus kami adalah dua kebiasaan setingkat RTL di dalam scanner XML alih-alih apa pun di dalam model objek. Profiler terus-menerus menunjuk parser sebagai satu kesatuan; pembungkus itu menunjuk dua baris yang spesifik
Perbaikannya: interning token dan decoder UTF-8 tanpa perantara
Dua perubahan yang tepat sasaran di dalam pembaca XML menghapus lebih dari separuh alokasi per sel tanpa menyentuh struktur parser-nya. Yang pertama adalah interning nama elemen. XML worksheet mengulang kosakata yang teramat kecil tanpa henti: row, c, v, r, t, s, dan segelintir nama atribut. InternTokenName menyimpan cache 64 slot berisi nama yang pernah dilihat lalu membandingkan buffer builder milik scanner terhadap entry yang di-cache dengan TokenEqualsAnsi, sebuah perbandingan byte langsung yang tidak mengalokasi apa pun. Ketika kena, ia mengembalikan AnsiString yang di-cache, dan di sinilah pilihan tipenya penting: AnsiString dihitung referensinya, sehingga mengembalikan instance yang di-cache berbiaya satu penambahan refcount dan nol lalu lintas heap. WideString tidak punya penghitung referensi, dan setiap penugasan melewati SysAllocString, sehingga meng-intern WideString tidak akan menghemat apa pun. Interning hanya layak dilakukan pada tipe string yang berhitung referensi
function TXMLScaner.InternTokenName: AnsiString;
var
Slot: Integer;
begin
Slot := TokenHash mod 64;
if TokenEqualsAnsi(FInternNames[Slot]) then
Result := FInternNames[Slot] // hanya refcount++, tanpa alokasi
else
begin
Result := GetTokenValue; // materialisasi sekali, lalu simpan
FInternNames[Slot] := Result;
end;
end;
Perubahan kedua menyerang teks sel. Jalur lamanya membangun token AnsiString, menyerahkannya ke UTF8ToWideString, yang membangun perantara UnicodeString, yang akhirnya dikonversi ke WideString yang disimpan sel: dua alokasi MM Delphi per token teks sebelum yang sungguhan. Penggantinya, XmlUtf8ToWide(TokenPtr, TokenLen), adalah decoder UTF-8 dua lintasan murni Pascal yang membaca langsung dari buffer pemindaian: lintasan pertama mengukur panjang UTF-16, lintasan kedua men-decode ke dalam WideString yang dialokasikan sekali saja. Biaya bersih per token teks: satu alokasi COM, nol alokasi MM Delphi. Satu catatan semantik untuk yang berhati-hati: pada urutan UTF-8 yang cacat, decoder baru meneruskan byte-nya apa adanya alih-alih menggantinya dengan karakter pengganti sebagaimana dilakukan RTL, yang hanya memengaruhi cara file rusak merosot; pada input yang valid, keluarannya identik byte demi byte. Entitas karakter XML tidak pernah sampai ke decoder, karena scanner sudah menguraikannya menjadi UTF-8 di dalam buffer token
Apa hasilnya, dan di mana parsing paralel tetap tidak membantu
Kedua perbaikan itu memangkas alokasi per sel dari sekitar 20 menjadi 9.1, dan angka paralelnya bergerak sebagaimana diramalkan teorinya. Pada benchmark 8 sheet dan 5.000 baris yang sama serta mesin 6C12T yang sama, peningkatan 8 thread naik dari 14% menjadi 47.4%, sebuah speedup ×1.90 dibanding serial. Kasus 2 thread berayun dari 26% lebih lambat menjadi 23.6% lebih cepat, dan utilisasi CPU terukur naik dari ×1.0 ke ×2.2. Jalur serialnya ikut sekitar 3% lebih cepat sebagai bonus, karena alokasi yang lebih sedikit juga membantu satu thread. Sisa ~9 alokasi per sel kira-kira separuhnya objek sel dan separuhnya pertumbuhan container yang teramortisasi; kami mengukurnya, menilai imbal hasilnya menyusut, lalu berhenti, dengan pembungkus MM siap dipakai lagi untuk mencuplik per lokasi panggilan jika beban kerja mendatang membenarkan satu putaran lagi
Batasannya layak dinyatakan sejelas keberhasilannya. HotXLS memparalelkan pada granularitas worksheet, sehingga workbook yang berupa satu sheet raksasa akan di-parsing pada satu thread apa pun yang dikatakan ParallelParseThreads; untuk bentuk seperti itu, pembaca langsung streaming adalah alat yang lebih tepat, karena ia sama sekali menghindari materialisasi workbook. File yang waktunya habis di bagian-bagian Fase C, yaitu drawing, chart, dan comment, memperoleh manfaat lebih sedikit karena fase itu memang dirancang tetap serial. File kecil sama sekali tidak layak di-thread, dan itulah sebabnya dispatcher diam-diam berjalan serial untuk jumlah job yang sepele. Dan langit-langit memory manager belum lenyap, hanya mundur: pada 9.1 alokasi per sel, lock global masih memajaki para worker, dan itulah sebabnya delapan thread menghasilkan ×1.90 alih-alih ×4. Untuk perkakas yang lebih luas dalam memangkas waktu muat dan simpan, termasuk style, pool, dan callback baris massal, lihat panduan kami tentang performa workbook besar di Delphi
Parsing XLSX paralel, properti ParallelParse dan ParallelParseThreads, serta pembaca XML hemat alokasi yang diuraikan di sini hadir sebagai bagian standar dari HotXLS Delphi Excel Component, yang membaca dan menulis XLS, XLSX, dan ODS secara native dari Delphi dan C++Builder tanpa melibatkan otomasi Excel