HotXLS, la libreria Excel nativa per Delphi e C++Builder, esegue il parsing dei fogli di lavoro XLSX su più thread attraverso un caricamento a tre fasi: l'XML dei fogli viene decompresso in modo seriale, analizzato in parallelo, e le parti secondarie vengono successivamente lette in modalità seriale. La prima versione di questa funzionalità ha registrato un guadagno prestazionale di solo il 12-25%, a causa del blocco globale del gestore di memoria predefinito di Delphi che serializzava l'esecuzione dei thread di lavoro. Riducendo le allocazioni heap da circa 20 a 9,1 per cella, lo speedup parallelo è salito a 1,90x su otto thread. Questo articolo descrive le misurazioni effettuate, le strade errate intraprese e le due soluzioni che hanno effettivamente risolto il problema
In che modo HotXLS esegue il parsing dei fogli XLSX in parallelo?
HotXLS suddivide la funzione Open in tre fasi, e solo quella centrale viene eseguita sui thread di lavoro. Il motivo risiede nel contenitore zip: un archivio zip rappresenta un singolo flusso di input condiviso con una macchina a stati inflate, e tale macchina non può essere letta da due thread contemporaneamente. Racchiuderla in un blocco (lock) sarebbe inutile, poiché la decompressione è intrinsecamente seriale per ogni elemento, per cui un blocco si limiterebbe a riprodurre un'esecuzione seriale con ulteriore sovraccarico. La Fase A decomprime l'XML di ciascun foglio di lavoro nel proprio TMemoryStream rimanendo a thread singolo; nel nostro file di benchmark, questa operazione ha richiesto circa 4 ms per otto parti di foglio, risultando del tutto ininfluente sul collo di bottiglia. La Fase B esegue ParseWorksheetXml per ciascun foglio su un pool di lavoro, che è dove si concentra quasi tutto il tempo di caricamento. La Fase C torna all'archivio zip in modo seriale per le parti più piccole: commenti, disegni, grafici e tabelle
Il pool di lavoro è volutamente semplice. I thread estraggono gli indici dei job da un contatore condiviso tramite InterlockedIncrement, garantendo che i fogli di dimensioni diverse si bilancino in modo naturale senza scheduler dedicati. Il numero di thread è pari a min(sheet count, CPU cores), la prima eccezione sollevata dai thread viene catturata con AcquireExceptionObject e rilanciata sul thread principale dopo l'unione (join), e il dispatcher torna a un semplice ciclo seriale in presenza di zero o un solo lavoro. Due proprietà di TXLSXWorkbook controllano la funzione: ParallelParse attiva il pool, e ParallelParseThreads limita il numero di thread, con 0 per la gestione automatica. Le cartelle di lavoro multifoglio rappresentano la struttura ideale per trarre vantaggio da questa architettura, inclusi i casi in cui si duplica un foglio di lavoro modello decine di volte
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;
Perché l'aggiunta di thread rallenta il parsing XLSX in Delphi?
A causa del fatto che il gestore di memoria predefinito di Delphi protegge il proprio heap con un blocco globale, e il parsing dei fogli di lavoro richiede un numero elevato di allocazioni: milioni di celle, Variant e WideString. Ogni thread di lavoro che accede all'heap si accoda su quel blocco, per cui thread che nel codice sorgente appaiono indipendenti vengono in realtà eseguiti quasi uno alla volta. Il nostro primo benchmark ha reso questo problema evidente. Su una cartella di lavoro da 8 fogli con 5.000 righe per 4 colonne per foglio, testata su un i5-11600K (6 core, 12 thread) in ambiente Win64, la funzione Open parallela è migliorata solo del 12-25% rispetto a una stima prevista di almeno il 40%. Un test di variazione su 2, 3, 4, 6 e 8 thread ha prodotto una curva piatta, e nelle successive esecuzioni monitorate la configurazione a 2 thread è risultata del 26% più lenta rispetto a quella seriale, segno tipico di due thread che si contendono ripetutamente un blocco
Tre misurazioni hanno confermato la diagnosi, smentendo l'intuizione iniziale. Primo, un file piccolissimo (8 fogli da 1 riga) si è aperto in 1,2 ms, a dimostrazione che il parsing costituisce quasi il 100% di Open e non vi erano costi fissi nascosti. Secondo, un microbenchmark focalizzato sulle allocazioni ha mostrato che il gestore di memoria di Delphi scalava al contrario: lo same volume complessivo di 2 milioni di allocazioni di oggetti e AnsiString è risultato del 60% più lento su 8 thread rispetto a uno singolo, mentre lo stesso carico sull'heap WideString (che utilizza l'allocatore BSTR di COM anziché il MM di Delphi) ha registrato uno scaling di 3,7x. Il fatto che HotXLS utilizzi WideString ovunque si è rivelato un vantaggio fortuito. Terzo, GetProcessTimes ha indicato che durante una chiamata a Open parallela, il tempo CPU equivaleva all'incirca al tempo reale: otto thread teorici consumavano circa 1,3 thread di CPU. I thread di lavoro non stavano eseguendo operazioni inutili, ma erano inattivi nel percorso di contesa del gestore di memoria, bloccati anziché operativi
La lezione pratica si estende oltre i fogli di calcolo. Se un carico di lavoro in Delphi effettua molte allocazioni, aumentare il numero di thread non porta vantaggi finché il tasso di allocazione non diminuisce, e può facilmente peggiorare le prestazioni. Prima di questa correzione, la nostra indicazione per gli utenti che configuravano ParallelParseThreads era chiara: sui file con colli di bottiglia legati all'allocazione, l'aggiunta di thread non offriva benefici
Da dove provengono le 20 allocazioni heap per cella?
Un wrapper di conteggio installato tramite SetMemoryManager ha risposto a questa domanda in modo preciso: circa 20 allocazioni del MM di Delphi per cella, con 2,87 milioni di esse a 32 byte o meno. La causa non risiedeva affatto negli oggetti cella. TXMLScaner.GetTokenValue creava una nuova AnsiString a ogni chiamata, venendo richiamato circa 15-20 volte per cella: una volta ciascuna per i nomi degli elementi, i nomi degli attributi, i valori degli attributi e il contenuto del testo. Oltre a ciò, la funzione RTL UTF8ToWideString generava un'istanza temporanea UnicodeString intermedia per ogni conversione. Gli oggetti cella rappresentavano solo 160.000 allocazioni, circa l'8% del totale, smentendo subito il nostro piano iniziale: volevamo creare un pool di oggetti cella, ma i numeri indicavano che l'investimento non avrebbe portato benefici
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);
Questo metodo diagnostico da dieci minuti è da tenere in considerazione per qualsiasi analisi di prestazioni in Delphi. Configurare un conteggio delle allocazioni per classi di dimensione ha un costo irrisorio e mostra la reale origine della pressione sul gestore di memoria, che nel nostro caso era legata a due abitudini a livello RTL all'interno dello scanner XML, e non a elementi del modello a oggetti. I profiler indicavano il parser nel suo complesso; il wrapper ha evidenziato due righe di codice specifiche
La soluzione: token interning e un decodificatore UTF-8 senza passaggi intermedi
Due modifiche mirate nel lettore XML hanno rimosso più della metà delle allocazioni per cella senza alterare la struttura del parser. La prima riguarda l'interning dei nomi degli elementi. L'XML dei fogli di lavoro ripete all'infinito un vocabolario ristretto: row, c, v, r, t, s, e pochi nomi di attributo. La funzione InternTokenName mantiene una cache a 64 slot dei nomi già riscontrati e confronta il buffer di compilazione dello scanner con una voce in cache tramite TokenEqualsAnsi, confronto di byte diretto che non richiede allocazioni. In caso di corrispondenza, restituisce la AnsiString memorizzata nella cache, e qui la scelta del tipo si rivela fondamentale: AnsiString è reference-counted, quindi restituire un'istanza memorizzata costa solo un incremento del refcount e non genera traffico sull'heap. WideString non ha reference count, e ogni assegnazione richiama SysAllocString, per cui l'interning delle WideString non offrirebbe vantaggi. L'interning è utile solo per i tipi di stringa con gestione del refcount
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;
La seconda modifica interviene sul testo delle celle. Il flusso precedente creava un token AnsiString, lo passava a UTF8ToWideString, che creava un UnicodeString intermedio, poi convertito nella WideString memorizzata nella cella: due allocazioni del MM di Delphi per ciascun token di testo prima di quella finale. La soluzione sostitutiva, XmlUtf8ToWide(TokenPtr, TokenLen), è un decodificatore UTF-8 in puro Pascal a due passaggi che legge direttamente dal buffer di scansione: il primo passaggio rileva la lunghezza UTF-16, il secondo esegue la decodifica in una WideString allocata una sola volta. Costo netto per ciascun token di testo: un'allocazione COM, zero allocazioni nel MM di Delphi. Una nota semantica: in presenza di sequenze UTF-8 non valide, il nuovo decodificatore inoltra direttamente i byte senza inserire caratteri sostitutivi come fa la RTL, il che influisce solo sulle modalità di degradazione dei file corrotti; su input validi, l'output è identico a livello di byte. Le entità carattere XML non raggiungono il decodificatore, in quanto lo scanner le ha già risolte in UTF-8 nel buffer dei token
Quali vantaggi ha portato, e dove il parsing parallelo continua a non essere utile
Le due correzioni hanno ridotto le allocazioni per cella da circa 20 a 9,1, con i valori di esecuzione in parallelo allineati alle previsioni teoriche. Nello stesso benchmark a 8 fogli e 5.000 righe e sulla stessa macchina a 6C12T, il miglioramento su 8 thread è passato dal 14% al 47,4%, equivalente a uno speedup di 1,90x rispetto alla modalità seriale. Il caso a 2 thread è passato dal 26% più lento al 23,6% più veloce, e l'utilizzo misurato della CPU è salito da 1,0x a 2,2x. Anche il percorso seriale ha registrato un incremento prestazionale di circa il 3%, poiché una riduzione delle allocazioni avvantaggia anche il thread singolo. Le restanti ~9 allocazioni per cella sono costituite per metà da oggetti cella e per metà da crescita ammortizzata del contenitore; le abbiamo analizzate e, ritenendo i margini di miglioramento marginali, abbiamo concluso l'intervento, mantenendo il wrapper MM pronto per nuove misurazioni qualora carichi futuri lo richiedano
I limiti meritano di essere esposti con la stessa chiarezza dei successi. HotXLS parallelizza il lavoro a livello di foglio di lavoro, per cui una cartella di lavoro composta da un unico foglio enorme viene analizzata su un singolo thread indipendentemente dal valore di ParallelParseThreads; per tali configurazioni, lo strumento di lettura diretta in streaming è la scelta ideale, poiché evita del tutto la materializzazione della cartella di lavoro. I file in cui il tempo viene assorbito dalle parti della Fase C (disegni, grafici e commenti) registrano benefici inferiori, in quanto tale fase rimane seriale per progettazione. I file di piccole dimensioni non richiedono affatto il threading, motivo per cui il dispatcher esegue silenziosamente il codice in modalità seriale per volumi ridotti. Inoltre, il limite del gestore di memoria non è scomparso ma è arretrato: a 9,1 allocazioni per cella il blocco globale incide ancora sui thread di lavoro, motivo per cui otto thread producono uno speedup di 1,90x invece di 4x. Per un quadro più ampio sulle tecniche di riduzione dei tempi di caricamento e salvataggio, inclusi stili, pool e callback di righe, si rimanda alla nostra guida sulle prestazioni di cartelle di lavoro di grandi dimensioni in Delphi
Il parsing XLSX parallelo, le proprietà ParallelParse e ParallelParseThreads e il lettore XML a basso consumo di allocazioni descritti in questa pagina sono distribuiti come componenti standard di HotXLS Delphi Excel Component, che legge e scrive fogli XLS, XLSX e ODS in modo nativo da Delphi e C++Builder senza l'automazione di Excel