O HotXLS, a biblioteca Excel nativa para Delphi e C++Builder, analisa planilhas XLSX em várias threads por meio de uma carga em três fases: o XML das planilhas é descomprimido em série, analisado em paralelo, e as partes pequenas são lidas em série depois. A primeira versão desse recurso ganhou apenas 12–25%, porque o lock do memory manager padrão do Delphi serializava as threads de trabalho. Cortar as alocações de heap de cerca de 20 para 9,1 por célula elevou o ganho paralelo para ×1,90 em oito threads. Este artigo percorre as medições, os caminhos errados e as duas correções que de fato funcionaram
Como o HotXLS analisa planilhas XLSX em paralelo?
O HotXLS divide o Open em três fases, e só a do meio roda em threads de trabalho. O motivo é o contêiner zip: um arquivo zip é um único fluxo de entrada compartilhado com uma única máquina de estados de inflate, e essa máquina de estados não pode ser lida por duas threads ao mesmo tempo. Envolvê-la em um lock seria inútil, porque o inflate é inerentemente serial por entrada, então um lock só reproduziria a execução serial com custo extra. Por isso a fase A descomprime o XML de cada planilha em um TMemoryStream próprio ainda em thread única; no nosso arquivo de referência isso levou cerca de 4 ms para oito partes de planilha, ou seja, está longe de ser o gargalo. A fase B executa ParseWorksheetXml para cada planilha em um pool de trabalho, e é aí que quase todo o tempo de carga vive. A fase C volta ao zip em série para as partes pequenas: comentários, desenhos, gráficos e tabelas
O próprio pool de trabalho é deliberadamente simples. As threads puxam índices de tarefa de um contador compartilhado com InterlockedIncrement, então planilhas de tamanhos desiguais se equilibram naturalmente sem nenhum escalonador. A contagem de threads é min(sheet count, CPU cores), a primeira exceção de uma thread de trabalho é capturada com AcquireExceptionObject e relançada na thread principal depois do join, e o despachante degrada para um laço serial simples quando há zero ou uma tarefa. Duas propriedades de TXLSXWorkbook controlam o recurso: ParallelParse libera o pool e ParallelParseThreads limita a contagem de threads, com 0 significando automático. As pastas com várias planilhas são o formato que se beneficia, inclusive o tipo que você produz ao duplicar uma planilha de modelo dezenas de vezes
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.ParallelParse := True; // habilita o pool paralelo de trabalho
Book.ParallelParseThreads := 0; // 0 = auto: min(planilhas, núcleos de CPU)
if Book.Open('quarterly-ledger.xlsx') <= 0 then
raise Exception.Create('open failed');
// ... leia as células normalmente; a pasta está totalmente materializada ...
finally
Book.Free;
end;
end;
Por que adicionar threads deixa o parsing de XLSX mais lento no Delphi?
Porque o memory manager padrão do Delphi protege o heap dele com um lock global, e a análise de planilhas é densa em alocações: células, Variants e WideStrings aos milhões. Cada thread de trabalho que toca o heap entra na fila desse lock, então threads que parecem independentes no código-fonte executam quase uma de cada vez na prática. O nosso primeiro teste de referência tornou isso dolorosamente concreto. Em uma pasta de 8 planilhas com 5.000 linhas por 4 colunas em cada uma, medida em um i5-11600K (6 núcleos, 12 threads) sob Win64, o Open paralelo melhorou apenas 12–25% contra uma estimativa de plano de pelo menos 40%. Uma varredura de contagem de threads em 2, 3, 4, 6 e 8 produziu uma curva plana e, em execuções instrumentadas posteriores, a configuração de 2 threads chegou a ser 26% mais lenta que a serial, a assinatura clássica de duas threads jogando pingue-pongue com um lock disputado
Três medições fecharam o diagnóstico, e cada uma derrubou a intuição anterior. Primeiro, um arquivo minúsculo (8 planilhas de 1 linha) abriu em 1,2 ms, provando que a análise é essencialmente 100% do Open e que não havia custo fixo escondido a culpar. Segundo, um microteste de pura rotatividade de alocação mostrou o memory manager do Delphi escalando ao contrário: o mesmo volume total de 2 milhões de alocações de objetos e AnsiStrings rodou 60% mais devagar em 8 threads do que em uma, enquanto a mesma rotatividade contra o heap de WideString, que é o alocador de BSTR do COM e não o MM do Delphi, escalou para ×3,7. Que o HotXLS use WideString em todo lugar acabou sendo um acidente da história a nosso favor. Terceiro, GetProcessTimes mostrou que, durante um Open paralelo, o tempo de CPU praticamente igualava o tempo de relógio: oito threads nominais consumiam cerca de 1,3 thread de CPU. As threads não estavam girando em vazio; estavam dormindo no caminho de disputa do memory manager, bloqueadas e não ocupadas
A lição prática generaliza para além das planilhas. Se uma carga de trabalho em Delphi aloca muito, aumentar a contagem de threads não faz nada até a taxa de alocação cair, e pode facilmente piorar as coisas. Antes dessa correção, dizíamos aos usuários que ajustavam ParallelParseThreads a verdade honesta: em arquivos limitados por alocação, mais threads compravam quase nada
De onde vêm 20 alocações de heap por célula?
Um wrapper de contagem instalado com SetMemoryManager respondeu isso com precisão: cerca de 20 alocações no MM do Delphi por célula, sendo 2,87 milhões delas de 32 bytes ou menos. O culpado não eram os objetos de célula. O TXMLScaner.GetTokenValue materializava uma AnsiString nova a cada chamada, e ele é chamado por volta de 15–20 vezes por célula: uma vez para nomes de elemento, nomes de atributo, valores de atributo e conteúdo de texto. Além disso, o caminho UTF8ToWideString da RTL fabricava uma UnicodeString intermediária temporária para cada conversão. Os objetos de célula respondiam por apenas 160 mil alocações, cerca de 8% do total, o que matou o nosso plano original na hora: pretendíamos construir um pool de objetos de célula, e os números diziam que ele nunca se pagaria
var
OldMM, NewMM: TMemoryManagerEx;
AllocCount, TinyCount: Int64;
function CountingGetMem(Size: NativeInt): Pointer;
begin
AtomicIncrement(AllocCount);
if Size <= 32 then
AtomicIncrement(TinyCount); // a rotatividade de objetos pequenos que nos interessa
Result := OldMM.GetMem(Size);
end;
// instale antes do Open, restaure depois
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);
Vale roubar esse diagnóstico de dez minutos para qualquer investigação de desempenho em Delphi. Contar alocações por faixa de tamanho custa quase nada para montar e diz onde a pressão sobre o memory manager de fato nasce, que no nosso caso eram dois hábitos de nível de RTL dentro do scanner de XML, e não algo no modelo de objetos. Os profilers apontavam para o parser como um todo; o wrapper apontou para duas linhas específicas
A correção: interning de tokens e um decodificador UTF-8 sem intermediários
Duas mudanças pontuais no leitor de XML removeram mais da metade das alocações por célula sem mexer na estrutura do parser. A primeira é o interning de nomes de elemento. O XML de planilha repete um vocabulário minúsculo sem parar: row, c, v, r, t, s e um punhado de nomes de atributo. O InternTokenName mantém um cache de 64 posições com os nomes já vistos e compara o buffer construtor do scanner com uma entrada em cache usando TokenEqualsAnsi, uma comparação direta de bytes que não aloca nada. Em um acerto, ele devolve a AnsiString em cache, e aqui a escolha do tipo importa: AnsiString tem contagem de referências, então devolver uma instância em cache custa um incremento de contador e zero tráfego de heap. WideString não tem contagem de referências, e toda atribuição passa por SysAllocString, então fazer interning de WideStrings não pouparia nada. O interning só vale a pena 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] // só refcount++, sem alocação
else
begin
Result := GetTokenValue; // materializa uma vez e guarda em cache
FInternNames[Slot] := Result;
end;
end;
A segunda mudança ataca o texto das células. O caminho antigo construía um token AnsiString, o entregava a UTF8ToWideString, que construía uma UnicodeString intermediária, que enfim era convertida na WideString que a célula guarda: duas alocações no MM do Delphi por token de texto antes da alocação de verdade. A substituta, XmlUtf8ToWide(TokenPtr, TokenLen), é um decodificador UTF-8 em Pascal puro, de duas passagens, que lê direto do buffer de varredura: a primeira passagem mede o comprimento em UTF-16, a segunda decodifica em uma WideString alocada uma única vez. Custo líquido por token de texto: uma alocação COM, zero alocações no MM do Delphi. Uma nota semântica para os cautelosos: em sequências UTF-8 malformadas o novo decodificador repassa os bytes em vez de substituí-los por caracteres de substituição como a RTL faz, o que só afeta como arquivos corrompidos degradam; em entrada válida a saída é idêntica byte a byte. As entidades de caractere XML nunca chegam ao decodificador, porque o scanner já as resolveu em UTF-8 no buffer de tokens
O que isso rendeu, e onde a análise paralela ainda não vai ajudar
As duas correções cortaram as alocações por célula de cerca de 20 para 9,1, e os números paralelos se moveram como a teoria dizia que deveriam. No mesmo teste de 8 planilhas e 5.000 linhas e na mesma máquina 6C12T, a melhoria com 8 threads passou de 14% para 47,4%, um ganho de ×1,90 sobre a versão serial. O caso de 2 threads virou de 26% mais lento para 23,6% mais rápido, e a utilização de CPU medida subiu de ×1,0 para ×2,2. O caminho serial ficou cerca de 3% mais rápido como bônus, já que menos alocações também ajudam uma única thread. As ~9 alocações restantes por célula são mais ou menos metade objetos de célula e metade crescimento amortizado de contêineres; medimos, julgamos os retornos decrescentes e paramos, com o wrapper do MM pronto para reamostrar por ponto de chamada se uma carga futura justificar outra rodada
Os limites merecem ser enunciados com a mesma clareza dos ganhos. O HotXLS paraleliza na granularidade de planilha, então uma pasta que é uma única planilha gigante é analisada em uma thread, não importa o que ParallelParseThreads diga; para esse formato, o leitor direto de streaming é a ferramenta melhor, já que ele evita materializar a pasta por completo. Arquivos cujo tempo vai para as partes da fase C, desenhos, gráficos e comentários, veem menos benefício porque essa fase continua serial por projeto. Arquivos pequenos não valem thread nenhuma, e é por isso que o despachante roda em série sem alarde para contagens triviais de tarefas. E o teto do memory manager não sumiu, apenas recuou: em 9,1 alocações por célula o lock global ainda taxa as threads, e é por isso que oito threads rendem ×1,90 e não ×4. Para o conjunto mais amplo de técnicas de cortar tempos de carga e gravação, incluindo estilos, pools e callbacks de linhas em lote, veja o nosso guia de desempenho com pastas de trabalho grandes no Delphi
A análise paralela de XLSX, as propriedades ParallelParse e ParallelParseThreads e o leitor de XML enxuto em alocações descrito aqui acompanham como partes padrão o HotXLS Delphi Excel Component, que lê e escreve XLS, XLSX e ODS nativamente a partir do Delphi e do C++Builder, sem nenhuma automação do Excel envolvida