O HotXLS, a biblioteca Excel nativa para Delphi e C++Builder, analisa folhas de trabalho XLSX em múltiplas threads através de um carregamento em três fases: o XML da folha é descomprimido em série, analisado em paralelo e as partes menores são lidas em série posteriormente. O primeiro lançamento desta funcionalidade obteve apenas 12–25% de ganho, devido ao bloqueio padrão do gestor de memória do Delphi que serializava as threads secundárias (worker threads). Reduzir as alocações de heap de aproximadamente 20 para 9.1 por célula aumentou a aceleração paralela para ×1.90 com oito threads. Este artigo aborda as medições, as decisões incorretas e as duas correções que efetivamente funcionaram
Como é que o HotXLS analisa folhas de trabalho XLSX em paralelo?
O HotXLS divide o método Open em três fases, e apenas a intermédia é executada em threads secundárias. O motivo reside no contentor zip: um arquivo zip constitui um fluxo de entrada partilhado com uma máquina de estados inflate única, que não pode ser lida por duas threads em simultâneo. Envolvê-la num bloqueio (lock) seria inútil, pois a descompressão (inflate) é inerentemente em série por entrada, de modo que o bloqueio apenas reproduziria a execução em série com custos adicionais de processamento. A Fase A, portanto, desprime o XML de cada folha de trabalho no seu próprio TMemoryStream ainda em thread única; no nosso ficheiro de teste, este processo demorou cerca de 4 ms para oito componentes de folha, estando longe de constituir o gargalo. A Fase B executa a ParseWorksheetXml para cada folha numa pool de threads secundárias, que é onde se concentra quase todo o tempo de carregamento. A Fase C regressa ao zip em série para os componentes menores: comentários, desenhos, gráficos e tabelas
A pool de threads secundárias é deliberadamente simples. As threads secundárias extraem índices de tarefas a partir de um contador partilhado com InterlockedIncrement, fazendo com que folhas de tamanhos desiguais se equilibrem de forma natural, sem necessidade de um agendador. A contagem de threads corresponde a min(contagem de folhas, núcleos de CPU), a primeira exceção de uma thread secundária é capturada com AcquireExceptionObject e relançada na thread principal após a junção (join), e o despachante reverte para um loop em série simples quando existem zero ou uma tarefas. Duas propriedades em TXLSXWorkbook controlam esta funcionalidade: ParallelParse ativa a pool e ParallelParseThreads limita a contagem de threads, com o 0 a significar modo automático. Os livros de trabalho com múltiplas folhas são a estrutura mais beneficiada, incluindo o tipo que gera ao duplicar uma folha de trabalho modelo dezenas de vezes
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;
Por que razão adicionar threads torna a análise de XLSX mais lenta no Delphi?
Porque o gestor de memória padrão do Delphi protege o seu heap com um bloqueio global, e a análise de folhas de trabalho é densa em alocações: milhões de células, Variants e WideStrings. Cada thread secundária que acede ao heap fica em fila de espera nesse bloqueio, pelo que threads que parecem independentes no código-fonte são executadas quase uma de cada vez na prática. O nosso primeiro teste tornou isto dolorosamente concreto. Num livro de trabalho com 8 folhas e 5.000 linhas por 4 colunas por folha, medido num i5-11600K (6 núcleos, 12 threads) em Win64, o Open paralelo melhorou apenas 12–25% face a uma estimativa planeada de pelo menos 40%. Uma varredura da contagem de threads em 2, 3, 4, 6 e 8 threads produziu uma curva plana, e em execuções instrumentadas posteriores, a configuração de 2 threads foi na realidade 26% mais lenta do que a em série, a assinatura clássica de duas threads a alternarem num bloqueio disputado
Três medições confirmaram o diagnóstico, e cada uma delas contrariou a intuição anterior. Primeiro, um ficheiro minúsculo (8 folhas de 1 linha) abriu em 1.2 ms, provando que a análise representa essencialmente 100% do tempo de Open e que não existia qualquer custo fixo oculto a culpar. Segundo, um teste de microdesempenho da pura rotação de alocações revelou que o gestor de memória do Delphi escalava negativamente: o mesmo volume total de 2 milhões de alocações de objetos e AnsiString correu 60% mais devagar em 8 threads do que numa só, enquanto a mesma rotação no heap do WideString — que constitui o alocador COM BSTR e não o gestor de memória do Delphi — escalou para ×3.7. O facto de o HotXLS utilizar WideString de forma generalizada acabou por ser uma feliz coincidência histórica a nosso favor. Terceiro, o GetProcessTimes mostrou que, durante um Open paralelo, o tempo de CPU equivalia sensivelmente ao tempo de relógio: oito threads nominais consumiam cerca de 1.3 threads de CPU. As threads secundárias não estavam em loop ativo; estavam suspensas no caminho de disputa do gestor de memória, bloqueadas em vez de ocupadas
A lição prática estende-se para lá das folhas de cálculo. Se um processo em Delphi realiza alocações intensivas, aumentar a contagem de threads não traz benefícios até que a taxa de alocações diminua, podendo facilmente piorar a situação. Antes desta correção, transmitíamos aos utilizadores que ajustavam as ParallelParseThreads a pura verdade: em ficheiros limitados por alocações, mais threads não traziam praticamente qualquer vantagem
De onde surgem as 20 alocações de heap por célula?
Um invólucro (wrapper) de contagem instalado com SetMemoryManager respondeu a essa questão com precisão: cerca de 20 alocações do gestor de memória do Delphi por célula, com 2.87 milhões delas em tamanho igual ou inferior a 32 bytes. O culpado não eram, de todo, os objetos de células. O método TXMLScaner.GetTokenValue materializava uma AnsiString nova em cada chamada, que é invocada cerca de 15–20 vezes por célula: uma vez para nomes de elementos, nomes de atributos, valores de atributos e conteúdo de texto. Para além disso, o método UTF8ToWideString da RTL criava um intermediário temporário do tipo UnicodeString para cada conversão. Os objetos de células representavam apenas 160 mil alocações, cerca de 8% do total, o que inviabilizou imediatamente o nosso plano inicial: pretendíamos construir uma pool de objetos de células, mas os números mostraram que isso nunca compensaria o esforço
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);
Esse diagnóstico de dez minutos merece ser replicado em qualquer investigação de desempenho no Delphi. Contar alocações por intervalos de tamanho quase não custa a implementar e revela onde reside realmente a pressão sobre o gestor de memória, que no nosso caso decorria de dois hábitos ao nível da RTL dentro do analisador XML, e não do modelo de objetos. Os profilers continuavam a apontar para o analisador como um todo; o invólucro apontou para duas linhas específicas
A correção: internamento de tokens e um descodificador UTF-8 sem intermediários
Duas alterações focadas no leitor XML removeram mais de metade das alocações por célula sem alterar a estrutura do analisador. A primeira consiste no internamento de nomes de elementos. O XML das folhas de trabalho repete um vocabulário reduzido indefinidamente: row, c, v, r, t, s, e alguns nomes de atributos. O método InternTokenName mantém uma cache de 64 posições para nomes vistos anteriormente e compara o buffer do construtor do analisador com uma entrada em cache usando a TokenEqualsAnsi, uma comparação direta de bytes que não realiza alocações. Havendo correspondência, devolve a AnsiString em cache, e aqui a escolha do tipo é relevante: AnsiString possui contagem de referências, pelo que devolver uma instância em cache custa um incremento na contagem de referências e nenhum tráfego de heap. A WideString não tem contagem de referências, e cada atribuição passa por SysAllocString, de modo que internar WideStrings não traria benefícios. O internamento só compensa no tipo de string com contagem de referências
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;
A segunda alteração foca-se no texto das células. O percurso antigo construía um token AnsiString, passava-o à UTF8ToWideString, que gerava uma UnicodeString intermédia, convertida finalmente na WideString que a célula armazena: duas alocações do gestor de memória do Delphi por token de texto antes da alocação real. O substituto, XmlUtf8ToWide(TokenPtr, TokenLen), consiste num descritor UTF-8 em Pascal puro de duas passagens que lê diretamente do buffer de leitura: a primeira passagem mede o comprimento UTF-16, a segunda descodifica para uma WideString alocada uma única vez. Custo líquido por token de texto: uma alocação COM, zero alocações do gestor de memória do Delphi. Uma nota semântica para os mais cautelosos: em sequências UTF-8 malformadas, o novo descodificador transmite os bytes diretamente em vez de os substituir por carateres de substituição como a RTL faz, o que apenas afeta a degradação de ficheiros corrompidos; em entradas válidas, a saída é idêntica ao nível dos bytes. As entidades de carateres XML nunca chegam ao descodificador, pois o analisador já as converteu em UTF-8 no buffer de tokens
O que se obteve com isto, e onde a análise paralela continuará sem ajudar
As duas correções reduziram as alocações por célula de cerca de 20 para 9.1, e os indicadores paralelos evoluíram como a teoria previa. No mesmo teste de 8 folhas e 5.000 linhas, sob o mesmo processador de 6C12T, a melhoria com 8 threads passou de 14% para 47.4%, representando uma aceleração de ×1.90 face ao processo em série. O cenário com 2 threads passou de 26% mais lento para 23.6% mais rápido, e a utilização de CPU medida aumentou de ×1.0 para ×2.2. O caminho em série tornou-se cerca de 3% mais rápido como bónus, já que menos alocações também beneficiam uma thread única. As restantes ~9 alocações por célula correspondem sensivelmente a metade em objetos de células e metade no crescimento amortizado do contentor; medimo-las, considerámos que os retornos eram decrescentes e parámos, com o invólucro do gestor de memória pronto a reavaliar por ponto de chamada caso um cenário futuro o justifique
Os limites merecem ser expostos de forma tão clara como as vitórias. O HotXLS paraleliza ao nível da folha de trabalho, pelo que um livro de trabalho composto por uma única folha gigante é analisado numa única thread, independentemente do valor de ParallelParseThreads; para esse formato, o leitor direto por fluxo (streaming direct reader) constitui a ferramenta indicada, dado que evita por completo a materialização do livro de trabalho. Ficheiros cujo tempo de processamento é consumido pelos componentes da Fase C — desenhos, gráficos e comentários — obtêm menos benefícios porque essa fase permanece em série por conceção. Ficheiros pequenos não justificam de todo o uso de threads, razão pela qual o despachante corre silenciosamente em série para tarefas com contagens triviais. E o teto do gestor de memória não desapareceu, apenas recuou: com 9.1 alocações por célula, o bloqueio global continua a penalizar as threads secundárias, razão pela qual os oito threads produzem ×1.90 em vez de ×4. Para um conjunto de ferramentas mais abrangente focado em reduzir tempos de carregamento e gravação, incluindo estilos, pools e callbacks de linhas em lote, consulte o nosso guia sobre desempenho de grandes livros de trabalho no Delphi
A análise paralela de XLSX, as propriedades ParallelParse e ParallelParseThreads e o leitor XML com poucas alocações aqui descritos são fornecidos como componentes padrão do HotXLS Delphi Excel Component, que lê e escreve XLS, XLSX e ODS nativamente a partir de Delphi e C++Builder, sem necessidade de automatização do Excel