HotXLS, pustaka Excel asli untuk Delphi dan C++Builder, mengurai lembar kerja XLSX pada beberapa utas (threads) melalui pemuatan tiga fase: XML lembar dikompresi secara serial, diurai secara paralel, dan bagian-bagian kecil dibaca secara serial setelahnya. Rilis pertama dari fitur tersebut hanya menghasilkan peningkatan 12–25%, karena kunci manajer memori bawaan Delphi menyerialkan utas pekerja. Memotong alokasi heap dari sekitar 20 menjadi 9,1 per sel meningkatkan percepatan paralel hingga ×1,90 pada delapan utas. Artikel ini membahas pengukuran, kesalahan langkah, dan dua perbaikan yang benar-benar berhasil
HotXLS membagi Open menjadi tiga fase, and hanya fase tengah yang berjalan pada utas pekerja. Alasannya adalah wadah zip: arsip zip adalah satu aliran masukan bersama dengan satu mesin status inflate, dan mesin status tersebut tidak dapat dibaca oleh dua utas sekaligus. Membungkusnya dalam kunci akan sia-sia, karena inflate pada dasarnya bersifat serial per entri, sehingga kunci hanya akan mereproduksi eksekusi serial dengan overhead ekstra. Oleh karena itu, Fase A mendekompresi XML setiap lembar kerja ke dalam TMemoryStream miliknya sendiri saat masih berutas tunggal; dalam file tolok ukur (benchmark) kami, ini memakan waktu sekitar 4 ms untuk delapan bagian lembar, sehingga sama sekali tidak mendekati hambatan. Fase B menjalankan ParseWorksheetXml untuk setiap lembar pada kumpulan pekerja (worker pool), yang merupakan tempat hampir semua waktu pemuatan berada. Fase C kembali ke zip secara serial untuk bagian-bagian kecil: komentar, gambar, bagan, dan tabel
Kumpulan pekerja (worker pool) itu sendiri sengaja dibuat sederhana. Pekerja menarik indeks pekerjaan dari penghitung bersama dengan InterlockedIncrement, sehingga lembar dengan ukuran tidak merata seimbang secara alami tanpa penjadwal apa pun. Jumlah utas adalah min(sheet count, CPU cores), pengecualian pekerja pertama ditangkap dengan AcquireExceptionObject dan dimunculkan kembali pada utas utama setelah join, dan dispatcher diturunkan menjadi perulangan serial biasa ketika ada nol atau satu pekerjaan. Dua properti pada TXLSXWorkbook mengontrol fitur tersebut: ParallelParse mengatur gerbang kumpulan pekerja, dan ParallelParseThreads membatasi jumlah utas, dengan 0 berarti otomatis. Buku kerja multi-lembar adalah bentuk yang diuntungkan, termasuk jenis yang Anda buat dengan menduplikasi templat lembar kerja puluhan kali
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.ParallelParse := True; // aktifkan kumpulan pekerja paralel
Book.ParallelParseThreads := 0; // 0 = otomatis: min(lembar, inti CPU)
if Book.Open('quarterly-ledger.xlsx') <= 0 then
raise Exception.Create('open failed');
// ... baca sel seperti biasa; buku kerja terwujud sepenuhnya ...
finally
Book.Free;
end;
end;
Mengapa menambahkan utas membuat penguraian XLSX lebih lambat di Delphi?
Karena manajer memori bawaan Delphi melindungi heap-nya dengan kunci global, dan penguraian lembar kerja padat alokasi: sel, Varian, dan WideString dalam jumlah jutaan. Setiap pekerja yang menyentuh heap mengantre pada kunci tersebut, sehingga utas yang terlihat independen dalam kode sumber dieksekusi hampir satu per satu dalam praktiknya. Tolok ukur pertama kami membuat hal ini menjadi nyata dan menyakitkan. Pada buku kerja 8 lembar dengan 5.000 baris kali 4 kolom per lembar, diukur pada i5-11600K (6 inti, 12 utas) di bawah Win64, paralel Open hanya meningkat 12–25% dibandingkan perkiraan rencana setidaknya 40%. Pemindaian jumlah utas di 2, 3, 4, 6, dan 8 utas menghasilkan kurva datar, and dalam pengujian dengan instrumen berikutnya, konfigurasi 2 utas sebenarnya 26% lebih lambat daripada serial, tanda klasik dari dua utas yang saling berebut kunci yang diperebutkan (contended lock)
Tiga pengukuran memastikan diagnosis tersebut, dan masing-masing membalikkan intuisi sebelumnya. Pertama, file kecil (8 lembar dari 1 baris) dibuka dalam 1,2 ms, membuktikan bahwa penguraian pada dasarnya adalah 100% dari Open dan tidak ada biaya tetap tersembunyi yang patut disalahkan. Kedua, tolok ukur mikro (microbenchmark) dari putaran alokasi murni menunjukkan manajer memori Delphi berskala mundur: total volume yang sama dari 2 juta alokasi objek dan AnsiString berjalan 60% lebih lambat pada 8 utas daripada pada satu utas, sementara putaran yang sama terhadap heap WideString, yang merupakan alokator COM BSTR alih-alih MM Delphi, berskala hingga ×3,7. Fakta bahwa HotXLS menggunakan WideString di seluruh bagian ternyata merupakan kebetulan sejarah yang menguntungkan kami. Ketiga, GetProcessTimes menunjukkan bahwa selama paralel Open, waktu CPU kira-kira sama dengan waktu nyata (wall time): delapan utas nominal mengonsumsi sekitar 1,3 utas CPU. Para pekerja tidak berputar (spinning); mereka tertidur di jalur pertikaian manajer memori, terblokir alih-alih sibuk
Pelajaran praktis ini berlaku umum di luar spreadsheet. Jika beban kerja Delphi mengalokasikan memori secara besar-besaran, meningkatkan jumlah utas tidak akan menghasilkan apa pun sampai tingkat alokasi turun, dan bahkan dapat dengan mudah memperburuk keadaan. Sebelum perbaikan ini, kami memberi tahu pengguna yang menyesuaikan ParallelParseThreads kebenaran yang jujur: pada file yang terikat alokasi, lebih banyak utas hampir tidak menghasilkan apa-apa
Dari mana asal 20 alokasi heap per sel?
var
OldMM, NewMM: TMemoryManagerEx;
AllocCount, TinyCount: Int64;
function CountingGetMem(Size: NativeInt): Pointer;
begin
AtomicIncrement(AllocCount);
if Size <= 32 then
AtomicIncrement(TinyCount); // putaran objek kecil yang kita pedulikan
Result := OldMM.GetMem(Size);
end;
// pasang sebelum Open, pulihkan setelahnya
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);
Diagnosis sepuluh menit itu layak diadopsi untuk penyelidikan kinerja Delphi apa pun. Menghitung alokasi berdasarkan ember ukuran (size bucket) hampir tidak memerlukan biaya pembuatan dan memberi tahu Anda dari mana asal tekanan manajer memori sebenarnya, yang dalam kasus kami adalah dua kebiasaan tingkat RTL di dalam pemindai XML alih-alih apa pun dalam model objek. Profiler terus menunjuk ke pengurai secara keseluruhan; pembungkus menunjuk ke dua baris tertentu
Perbaikan: interning token dan dekoder UTF-8 tanpa perantara
Dua perubahan terarah di pembaca XML menghapus lebih dari setengah alokasi per sel tanpa menyentuh struktur pengurai. Yang pertama adalah interning nama elemen. XML lembar kerja mengulang kosakata kecil tanpa henti: row, c, v, r, t, s, dan beberapa nama atribut. InternTokenName menyimpan cache 64-slot dari nama-nama yang terlihat sebelumnya dan membandingkan penyangga pembangun pemindai terhadap entri yang disimpan dalam cache dengan TokenEqualsAnsi, perbandingan bita langsung yang tidak mengalokasikan apa-apa. Pada hit, ia mengembalikan AnsiString yang disimpan di cache, dan di sini pilihan tipe sangat penting: AnsiString dihitung referensinya, sehingga mengembalikan instans cache membebani satu peningkatan refcount dan nol lalu lintas heap. WideString tidak memiliki penghitung referensi, dan setiap penetapan melalui SysAllocString, sehingga melakukan interning pada WideString tidak akan menghemat apa pun. Interning hanya layak dilakukan pada tipe string yang dihitung referensinya (refcounted)
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; // wujudkan sekali, lalu simpan di cache
FInternNames[Slot] := Result;
end;
end;
Perubahan kedua menyerang teks sel. Jalur lama membangun token AnsiString, menyerahkannya ke UTF8ToWideString, yang membangun UnicodeString perantara, yang akhirnya dikonversi ke WideString yang disimpan sel: dua alokasi Delphi-MM per token teks sebelum alokasi yang sebenarnya. Penggantinya, XmlUtf8ToWide(TokenPtr, TokenLen), adalah dekoder UTF-8 Pascal murni dua lintasan yang membaca langsung dari penyangga pemindaian: lintasan pertama mengukur panjang UTF-16, lintasan kedua mendekode ke dalam WideString yang dialokasikan sekali. Biaya bersih per token teks: satu alokasi COM, nol alokasi Delphi-MM. Satu catatan semantik untuk yang berhati-hati: pada urutan UTF-8 yang cacat, dekoder baru meneruskan bita alih-alih mengganti karakter pengganti seperti yang dilakukan RTL, yang hanya memengaruhi bagaimana file yang rusak mengalami penurunan kualitas; pada masukan yang valid, keluaran identik secara bita. Entitas karakter XML tidak pernah mencapai dekoder, karena pemindai telah menyelesaikannya ke dalam UTF-8 di penyangga token
Apa hasilnya, dan di mana penguraian paralel tetap tidak akan membantu
Kedua perbaikan tersebut memotong alokasi per sel dari sekitar 20 menjadi 9,1, dan angka paralel bergerak sesuai dengan yang dikatakan teori. Pada tolok ukur 8 lembar, 5.000 baris yang sama dan mesin 6C12T yang sama, peningkatan 8 utas naik dari 14% menjadi 47,4%, kecepatan ×1,90 dibandingkan serial. Kasus 2 utas berayun dari 26% lebih lambat menjadi 23,6% lebih cepat, dan pemanfaatan CPU yang diukur naik dari ×1,0 menjadi ×2,2. Jalur serial menjadi sekitar 3% lebih cepat sebagai bonus, karena alokasi yang lebih sedikit membantu utas tunggal juga. Sisa ~9 alokasi per sel kira-kira setengahnya adalah objek sel dan setengahnya adalah pertumbuhan wadah yang diamortisasi; kami mengukurnya, menilai hasilnya menurun, dan berhenti, dengan pembungkus MM yang siap untuk mengambil sampel ulang berdasarkan tempat pemanggilan jika beban kerja di masa mendatang membenarkan putaran lainnya
Batasan-batasan tersebut patut dinyatakan sejelas kemenangannya. HotXLS memaralelkan pada tingkat granulasitas lembar kerja, sehingga buku kerja yang merupakan satu lembar raksasa diurai pada satu utas tidak peduli apa yang dikatakan ParallelParseThreads; untuk bentuk tersebut, pembaca langsung streaming (streaming direct reader) adalah alat yang lebih baik, karena menghindari perwujudan buku kerja sama sekali. File yang waktunya habis untuk bagian Fase C, gambar dan bagan serta komentar, melihat lebih sedikit manfaat karena fase tersebut tetap serial secara desain. File kecil tidak layak dijalankan dengan utas sama sekali, itulah sebabnya dispatcher diam-diam berjalan serial untuk jumlah pekerjaan yang sepele. Dan langit-langit manajer memori belum hilang, hanya menyusut: pada 9,1 alokasi per sel, kunci global tetap membebani pekerja, itulah sebabnya delapan utas menghasilkan ×1,90 alih-alih ×4. Untuk rangkaian alat yang lebih luas dalam memotong waktu pemuatan dan penyimpanan, termasuk gaya (styles), kolam (pools), dan panggilan balik baris massal, lihat panduan kami untuk kinerja buku kerja besar di Delphi
Penguraian XLSX paralel, properti ParallelParse dan ParallelParseThreads, serta pembaca XML yang hemat alokasi yang dijelaskan di sini dikirimkan sebagai bagian standar dari HotXLS Delphi Excel Component, yang membaca dan menulis XLS, XLSX, dan ODS secara native dari Delphi and C++Builder tanpa melibatkan otomatisasi Excel