Teknik Makale

Delphi'de Paralel XLSX Ayrıştırma: Bellek Yöneticisi Darboğazı

Delphi ve C++Builder için yerel Excel kütüphanesi olan HotXLS, XLSX çalışma sayfalarını üç aşamalı bir yükleme ile çoklu iş parçacığı (multi-thread) üzerinde paralel olarak ayrıştırır: sayfa XML'i seri olarak açılır, paralel olarak ayrıştırılır ve ardından küçük parçalar seri olarak okunur. Bu özelliğin ilk sürümü yalnızca %12 ila %25 oranında kazanç sağladı çünkü Delphi'nin varsayılan bellek yöneticisi kilidi çalışan iş parçacıklarını seri hale getiriyordu. Hücre başına yığın tahsislerini yaklaşık 20'den 9,1'e düşürmek, sekiz iş parçacığında paralel hızlanmayı 1,90 kata çıkardı. Bu makale ölçümleri, yanlış yönelimleri ve gerçekte işe yarayan iki düzeltmeyi ele almaktadır

HotXLS XLSX çalışma sayfalarını paralel olarak nasıl ayrıştırır?

HotXLS Open işlemini üç aşamaya ayırır ve yalnızca ortadaki aşama çalışan iş parçacıklarında (worker threads) çalışır. Bunun nedeni zip kapsayıcısıdır: bir zip arşivi tek bir inflate durum makinesine sahip tek bir paylaşılan girdi akışıdır ve bu durum makinesi aynı anda iki iş parçacığı tarafından okunamaz. Onu bir kilitle sarmalamak anlamsız olurdu çünkü inflate işlemi doğası gereği girdi başına seridir, bu nedenle bir kilit yalnızca fazladan ek yük ile seri yürütmeyi yeniden üretirdi. Aşama A bu nedenle hala tek iş parçacıklı iken her çalışma sayfasının XML'ini kendi TMemoryStream nesnesine açar; benchmark dosyamızda bu işlem sekiz sayfa parçası için yaklaşık 4 ms sürdü, bu yüzden darboğaza yakın bile değildir. Aşama B, çalışma grubu (worker pool) üzerindeki her sayfa için ParseWorksheetXml işlevini çalıştırır ki bu aşama yükleme süresinin neredeyse tamamının yaşandığı yerdir. Aşama C, küçük parçalar için seri olarak zip dosyasına geri döner: yorumlar, çizimler, grafikler ve tablolar

Çalışan grubu (worker pool) kendi içinde oldukça basittir. Çalışanlar, InterlockedIncrement ile paylaşılan bir sayaçtan iş indekslerini çeker, böylece eşit olmayan boyuttaki sayfalar herhangi bir zamanlayıcıya ihtiyaç duymadan doğal olarak dengelenir. İş parçacığı sayısı min(sheet count, CPU cores) şeklindedir, çalışanlardan gelen ilk istisna (exception) AcquireExceptionObject ile yakalanır ve birleştirme işleminden sonra ana iş parçacığında yeniden fırlatılır; iş sayısı sıfır veya bir olduğunda ise zamanlayıcı sessizce normal bir seri döngüye düşer. TXLSXWorkbook üzerindeki iki özellik bu özelliği kontrol eder: ParallelParse grubu kapılar ve ParallelParseThreads iş parçacığı sayısını sınırlar; 0 değeri otomatik anlamına gelir. Çok sayfalı çalışma kitapları, bir şablon çalışma sayfasını onlarca kez kopyalayarak ürettikleriniz dahil, bu özellikten faydalanan şekildir

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;

Delphi'de iş parçacığı eklemek XLSX ayrıştırmasını neden yavaşlatır?

Çünkü Delphi'nin varsayılan bellek yöneticisi yığınını (heap) küresel bir kilitle korur ve çalışma sayfası ayrıştırma işlemi bellek tahsisi açısından yoğundur: milyonlarca hücre, Variant ve WideString. Yığına dokunan her çalışan bu kilit üzerinde sıraya girer, bu nedenle kaynak kodda bağımsız görünen iş parçacıkları pratikte neredeyse teker teker yürütülür. İlk karşılaştırmamız (benchmark) bunu acı bir şekilde somutlaştırdı. Win64 altında bir i5-11600K (6 çekirdek, 12 iş parçacığı) üzerinde ölçülen, sayfa başına 5.000 satır ve 4 sütun içeren 8 sayfalık bir çalışma kitabında paralel Open, en az %40'lık bir plan tahminine karşın yalnızca %12–25 oranında iyileşti. 2, 3, 4, 6 ve 8 iş parçacığı üzerindeki bir iş parçacığı sayısı taraması düz bir eğri üretti ve daha sonraki izlenen çalıştırmalarda 2 iş parçacıklı yapılandırma seri çalışmadan %26 daha yavaştı; bu, iki iş parçacığının çekişmeli bir kilidi karşılıklı olarak kilitlediğinin (ping-ponging) klasik işaretidir

Üç ölçüm teşhisi netleştirdi ve her biri bir önceki sezgiyi yıktı. İlk olarak, çok küçük bir dosya (1 satırlık 8 sayfa) 1,2 ms'de açıldı, bu da ayrıştırmanın esasen Open işleminin %100'ü olduğunu ve suçlanacak gizli bir sabit maliyet olmadığını kanıtladı. İkinci olarak, saf bellek tahsisi karmaşasından oluşan bir mikro benchmark, Delphi bellek yöneticisinin geriye doğru ölçeklendiğini gösterdi: aynı toplam hacimdeki 2 milyon nesne ve AnsiString tahsisi 8 iş parçacığında tek bir iş parçacığına göre %60 daha yavaş çalışırken, Delphi MM yerine COM BSTR tahsis edicisi olan WideString yığınına karşı aynı karmaşa 3,7 kata kadar ölçeklendi. HotXLS'in baştan sona WideString kullanması, tarihin bir cilvesi olarak lehimize çalışan bir tesadüf oldu. Üçüncüsü, GetProcessTimes paralel bir Open sırasında işlemci süresinin gerçek süreye kabaca eşit olduğunu gösterdi: sekiz nominal iş parçacığı yaklaşık 1,3 iş parçacığı değerinde işlemci tüketiyordu. Çalışanlar boşta dönmüyordu; bellek yöneticisinin çekişme yolunda uykudaydılar, meşgul olmak yerine bloke olmuşlardı

Pratik ders, elektronik tabloların ötesinde genelleştirilebilir. Bir Delphi iş yükü yoğun bir şekilde bellek tahsis ediyorsa, bellek tahsis oranı düşene kadar iş parçacığı sayısını artırmak hiçbir işe yaramaz ve durumu kolayca daha da kötüleştirebilir. Bu düzeltmeden önce, ParallelParseThreads ayarını yapan kullanıcılara dürüst gerçeği söylüyorduk: bellek sınırındaki dosyalarda daha fazla iş parçacığı neredeyse hiçbir şey kazandırmıyordu

Hücre başına 20 yığın tahsisi nereden geliyor?

SetMemoryManager ile kurulan bir sayıcı sarmalayıcı bu soruya kesin bir cevap verdi: hücre başına yaklaşık 20 Delphi-MM tahsisi, bunların 2,87 milyonu 32 bayt veya altındaydı. Suçlu hücre nesnelerinin kendisi değildi. TXMLScaner.GetTokenValue her çağrıda yeni bir AnsiString üretiyordu ve bu işlev hücre başına yaklaşık 15–20 kez çağrılır: eleman adları, öznitelik adları, öznitelik değerleri ve metin içeriği için birer kez. Bunun da ötesinde, RTL'nin UTF8ToWideString yolu her dönüşüm için geçici bir UnicodeString ara nesnesi üretiyordu. Hücre nesneleri toplamın yaklaşık %8'i olan yalnızca 160 bin tahsise karşılık geliyordu, bu da orijinal planımızı yerle bir etti: bir hücre nesne havuzu oluşturmayı düşünmüştük, ancak rakamlar bunun kendi maliyetini asla karşılamayacağını gösterdi

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);

Bu on dakikalık teşhis, herhangi bir Delphi performans incelemesi için çalmaya değerdir. Bellek tahsislerini boyut kovasına göre saymak neredeyse hiçbir şeye mal olmaz ve bellek yöneticisi basıncının gerçekte nereden kaynaklandığını söyler; bizim durumumuzda bu, nesne modelindeki herhangi bir şey yerine XML tarayıcısının içindeki iki RTL düzeyindeki alışkanlıktı. Profil oluşturucular sürekli olarak bir bütün olarak ayrıştırıcıyı işaret ediyordu; sarmalayıcı ise iki belirli satırı işaret etti

Düzeltme: belirteç stajı (interning) ve sıfır ara nesneli UTF-8 kod çözücü

XML okuyucusundaki iki hedefli değişiklik, ayrıştırıcının yapısına dokunmadan hücre başına düşen tahsislerin yarısından fazlasını ortadan kaldırdı. İlki, eleman adı stajıdır (interning). Çalışma sayfası XML'i küçük bir kelime hazinesini durmaksızın tekrarlar: row, c, v, r, t, s ve bir avuç öznitelik adı. InternTokenName, daha önce görülen adların 64 yuvalı bir önbelleğini tutar ve tarayıcının oluşturucu arabelleğini hiçbir şey tahsis etmeyen doğrudan bir bayt karşılaştırması olan TokenEqualsAnsi ile önbelleğe alınmış bir girdiyle karşılaştırır. Bir isabette önbelleğe alınmış AnsiString nesnesini döndürür ve burada tür seçimi önemlidir: AnsiString referans sayımlıdır (reference counted), bu nedenle önbelleğe alınmış bir örneği döndürmek bir referans sayımı artışına mal olur ve yığın trafiği oluşturmaz. WideString referans sayımına sahip değildir ve her atama SysAllocString üzerinden geçer, bu nedenle WideString'leri staj yapmak hiçbir şey kazandırmaz. Staj yalnızca referans sayımlı dize türü üzerinde yapmaya değerdir

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;

İkinci değişiklik hücre metnine saldırır. Eski yol bir AnsiString belirteci oluşturuyor, bunu UTF8ToWideString işlevine teslim ediyor, o da gerçek olanından önce hücrenin depoladığı WideString'e dönüştürülen geçici bir UnicodeString nesnesi üretiyordu: metin belirteci başına iki Delphi-MM tahsisi. Yeni yazılan alternatif XmlUtf8ToWide(TokenPtr, TokenLen), doğrudan tarama arabelleğinden okuyan iki geçişli saf bir Pascal UTF-8 kod çözücüdür: birinci geçiş UTF-16 uzunluğunu ölçer, ikinci geçiş bir kez tahsis edilen bir WideString'e kodu çözer. Metin belirteci başına net maliyet: bir COM tahsisi, sıfır Delphi-MM tahsisi. Temkinli olanlar için semantik bir not: bozuk UTF-8 dizilerinde yeni kod çözücü, RTL'nin yaptığı gibi yedek karakterler ikame etmek yerine baytları doğrudan iletir, bu yalnızca bozuk dosyaların nasıl bozulduğunu etkiler; geçerli girdilerde çıktı bayt düzeyinde aynıdır. XML karakter varlıkları kod çözücüye asla ulaşmaz çünkü tarayıcı bunları belirteç arabelleğinde UTF-8 olarak çoktan çözmüştür

Neler kazandırdı ve paralel ayrıştırmanın hala yardımcı olmayacağı yerler

İki düzeltme hücre başına düşen tahsisleri yaklaşık 20'den 9,1'e düşürdü ve paralel rakamlar teorinin söylediği şekilde hareket etti. Aynı 8 sayfalık, 5.000 satırlık benchmark ve aynı 6C12T makinesinde, 8 iş parçacıklı iyileşme %14'ten %47,4'e çıktı, bu da seri çalışmaya göre 1,90 kat hızlanma demektir. 2 iş parçacıklı durum %26 daha yavaş olmaktan %23,6 daha hızlı olmaya geçti ve ölçülen işlemci kullanımı 1,0 kattan 2,2 kata yükseldi. Seri yol da bir bonus olarak yaklaşık %3 daha hızlı hale geldi, çünkü daha az tahsis tek bir iş parçacığına juga yardımcı olur. Kalan yaklaşık 9 tahsis, kabaca yarı yarıya hücre nesnelerine ve amorti edilmiş kapsayıcı büyümesine karşılık gelir; bunları ölçtük, getirilerin azaldığına karar verdik ve durduk. MM sarmalayıcımız, gelecekteki bir iş yükü başka bir turu haklı çıkarırsa çağrı alanına göre yeniden örnekleme yapmaya hazırdır

Sınırların da kazanımlar kadar net bir şekilde belirtilmesi gerekir. HotXLS çalışma sayfası düzeyinde paralelleştirme yapar, bu nedenle tek bir devasa sayfadan oluşan bir çalışma kitabı ParallelParseThreads değerinin ne dediğine bakılmaksızın tek bir iş parçacığında ayrıştırılır; bu şekil için, çalışma kitabını belleğe almaktan tamamen kaçınan akışlı doğrudan okuyucu daha iyi bir araçtır. Süresi Aşama C parçalarına (çizimler, grafikler ve yorumlar) giden dosyalar daha az fayda görür çünkü bu aşama tasarım gereği seri kalır. Küçük dosyalar iş parçacığına almaya değmez, bu nedenle zamanlayıcı önemsiz iş sayıları için sessizce seri olarak çalışır. Ve bellek yöneticisi tavanı ortadan kalkmadı, yalnızca geriledi: hücre başına 9,1 tahsiste küresel kilit hala çalışanları vergilendirir, bu nedenle sekiz iş parçacığı 4 kat yerine 1,90 kat kazanç sağlar. Bellek sınırları dahilinde, stil, havuz ve toplu satır geri çağırmaları dahil olmak üzere yükleme ve kaydetme sürelerini kısaltmaya yönelik daha geniş araç seti için Delphi'de büyük çalışma kitabı performansı kılavuzumuza bakın

Paralel XLSX ayrıştırma, ParallelParse ve ParallelParseThreads özellikleri ve burada açıklanan bellek dostu XML okuyucu; Delphi ve C++Builder için Excel otomasyonuna ihtiyaç duymadan yerel olarak XLS, XLSX ve ODS dosyalarını okuyup yazan HotXLS Delphi Excel Bileşeni'nde standart özellikler olarak sunulmaktadır