Artigo Técnico

Análise Paralela de XLSX no Delphi: Gargalo do Gerenciador de Memória

O HotXLS, a biblioteca nativa do Excel para Delphi e C++Builder, analisa planilhas XLSX em múltiplas threads por meio de um carregamento em três fases: o XML da planilha é descompactado sequencialmente, analisado em paralelo e as partes menores são lidas sequencialmente depois. O primeiro lançamento desse recurso obteve um ganho de apenas 12% a 25%, porque o bloqueio (lock) padrão do gerenciador de memória do Delphi serializava as threads de trabalho. Reduzir as alocações de heap de cerca de 20 para 9,1 por célula elevou o ganho de velocidade paralelo para 1,90x em oito threads. Este artigo detalha as medições, as decisões incorretas e as duas correções que realmente funcionaram

O HotXLS divide o Open em três fases, e apenas a do meio é executada em threads de trabalho. O motivo é o contêiner ZIP: um arquivo ZIP é um fluxo de entrada compartilhado com uma única máquina de estado de descompactação (inflate), e essa máquina de estado não pode ser lida por duas threads ao mesmo tempo. Envolvê-la em um bloqueio (lock) seria inútil, porque a descompactação é inerentemente sequencial por entrada, de modo que um bloqueio apenas reproduziria a execução sequencial com sobrecarga adicional. A Fase A, portanto, descompacta o XML de cada planilha em seu próprio TMemoryStream enquanto ainda está em thread única; em nosso arquivo de benchmark, isso levou cerca de 4 ms para as oito partes de planilhas, de modo que está longe de ser o gargalo. A Fase B executa o ParseWorksheetXml para cada planilha em um pool de trabalho, onde se concentra quase todo o tempo de carregamento. A Fase C retorna ao ZIP sequencialmente para as partes menores: comentários, desenhos, gráficos e tabelas

O pool de trabalho em si é deliberadamente simples. Os trabalhadores extraem índices de tarefas de um contador compartilhado com InterlockedIncrement, de modo que planilhas de tamanhos diferentes se equilibrem naturalmente sem a necessidade de um agendador. A contagem de threads é min(sheet count, CPU cores), a primeira exceção do pool é capturada com AcquireExceptionObject e gerada novamente na thread principal após a conclusão, e o despachante (dispatcher) rebaixa-se a um loop sequencial simples quando há zero ou uma tarefa. Duas propriedades em TXLSXWorkbook controlam o recurso: ParallelParse ativa o pool e ParallelParseThreads limita a contagem de threads, onde 0 significa automático. Pastas de trabalho com várias planilhas são as mais beneficiadas, incluindo o tipo que você gera ao duplicar uma planilha de modelo dezenas de vezes

Por que adicionar threads torna a análise de XLSX mais lenta no Delphi?

Porque o gerenciador de memória padrão do Delphi protege seu heap com um bloqueio global, e a análise de planilhas é densa em alocações: células, variantes e WideStrings aos milhões. Cada trabalhador que toca no heap entra na fila desse bloqueio, de modo que threads que parecem independentes no código-fonte são executadas quase uma de cada vez na prática. Nosso primeiro benchmark tornou isso dolorosamente concreto. Em uma pasta de trabalho de 8 planilhas com 5.000 linhas por 4 colunas por planilha, medido em um i5-11600K (6 núcleos, 12 threads) sob Win64, o Open paralelo melhorou apenas 12% a 25% contra uma estimativa de plano de pelo menos 40%. Uma varredura de 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 verdade 26% mais lenta do que a sequencial — a assinatura clássica de duas threads disputando um bloqueio concorrido

Três medições confirmaram o diagnóstico, e cada uma delas 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 custos fixos ocultos a culpar. Segundo, um microbenchmark de rotatividade de alocação pura mostrou o gerenciador de memória do Delphi escalando para trás: o mesmo volume total de 2 milhões de alocações de objetos e AnsiString rodou 60% mais lento em 8 threads do que em uma, enquanto a mesma rotatividade contra o heap de WideString, que é o alocador COM BSTR em vez do gerenciador de memória do Delphi, escalou para 3,7x. O fato de o HotXLS usar WideString em toda a sua estrutura acabou se revelando um acidente da história a nosso favor. Terceiro, o GetProcessTimes mostrou que, durante um Open paralelo, o tempo de CPU equivalia aproximadamente ao tempo decorrido (wall time): oito threads nominais estavam consumindo cerca de 1,3 thread de processamento de CPU. Os trabalhadores não estavam em loop ativo; estavam adormecidos no caminho de contenção do gerenciador de memória, bloqueados em vez de ocupados

A lição prática se generaliza além das planilhas. Se uma carga de trabalho do Delphi faz muitas alocações, aumentar a contagem de threads não ajuda em nada até que a taxa de alocação caia, e pode facilmente piorar as coisas. Antes dessa correção, dizíamos aos usuários que ajustavam ParallelParseThreads a pura verdade: em arquivos limitados por alocação, mais threads não traziam quase nada

De onde vêm as 20 alocações de heap por célula?

Um wrapper contador instalado com SetMemoryManager respondeu a essa pergunta com precisão: cerca de 20 alocações do gerenciador de memória do Delphi por célula, com 2,87 milhões delas em 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 cerca de 15 a 20 vezes por célula: uma vez para cada nome de elemento, nome de atributo, valor de atributo e conteúdo de texto. Além disso, a rota UTF8ToWideString da RTL fabricava uma UnicodeString intermediária para cada transformação. Os objetos de célula representavam apenas 160 mil alocações, cerca de 8% do total, o que inviabilizou nosso plano original na hora: pretendíamos construir um pool de objetos de célula, e os números disseram que isso nunca se pagaria

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

Vale a pena adotar esse diagnóstico de dez minutos para qualquer investigação de desempenho no Delphi. Contar alocações por faixas de tamanho não custa quase nada e informa onde a pressão sobre o gerenciador de memória realmente se origina, o que no nosso caso eram dois hábitos em nível de RTL dentro do scanner XML, e não no modelo de objeto. Os analisadores de desempenho (profilers) continuavam apontando para o analisador como um todo; o wrapper apontou para duas linhas específicas

A correção: retenção de tokens (interning) e um decodificador UTF-8 sem intermediários

Duas alterações direcionadas no leitor XML removeram mais da metade das alocações por célula sem tocar na estrutura do analisador. A primeira é a retenção de nomes de elementos (element-name interning). O XML da planilha repete infinitamente um vocabulário minúsculo: row, c, v, r, t, s e alguns nomes de atributos. O InternTokenName mantém um cache de 64 slots de nomes vistos anteriormente e compara o buffer do scanner contra uma entrada em cache com TokenEqualsAnsi, uma comparação direta de bytes que não aloca nada. Em caso de acerto, retorna a AnsiString em cache, e aqui a escolha do tipo importa: AnsiString possui contagem de referências, portanto retornar uma instância em cache custa um incremento de contagem de referências e zero tráfego de heap. WideString não possui contagem de referências, e cada atribuição passa por SysAllocString, de modo que a retenção de WideStrings não economizaria nada. A retenção 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]    // refcount++ only, no allocation
  else
  begin
    Result := GetTokenValue;        // materialize once, then cache
    FInternNames[Slot] := Result;
  end;
end;

A segunda alteração ataca o texto das células. O caminho antigo criava um token AnsiString, passava-o para UTF8ToWideString, que criava uma UnicodeString intermediária, que finalmente era convertida para a WideString que a célula armazena: duas alocações do gerenciador de memória do Delphi por token de texto antes da real. O substituto, XmlUtf8ToWide(TokenPtr, TokenLen), é um decodificador UTF-8 em Pascal puro de duas passagens que lê diretamente do buffer de digitalização: a passagem um mede o comprimento UTF-16, a passagem dois decodifica em uma WideString alocada de uma única vez. Custo líquido por token de texto: uma alocação COM, zero alocação do gerenciador de memória do Delphi. Uma observação semântica para os cautelosos: em sequências UTF-8 malformadas, o novo decodificador passa os bytes diretamente em vez de substituir por caracteres de substituição como a RTL faz, o que apenas afeta a forma como os arquivos corrompidos se degradam; em entradas válidas, a saída é idêntica a nível de bytes. Entidades de caracteres XML nunca chegam ao decodificador, porque o scanner já as resolveu em UTF-8 no buffer de tokens

O que isso trouxe e onde a análise paralela ainda não ajudará?

As duas correções reduziram as alocações por célula de cerca de 20 para 9,1, e os números paralelos moveram-se da maneira que a teoria indicava que deveriam. No mesmo benchmark de 8 planilhas e 5.000 linhas e na mesma máquina de 6C12T, a melhoria de 8 threads passou de 14% para 47,4%, um ganho de velocidade de 1,90x sobre o sequencial. O caso de 2 threads passou de 26% mais lento para 23,6% mais rápido, e a utilização de CPU medida subiu de 1,0x para 2,2x. O caminho sequencial ficou cerca de 3% mais rápido como bônus, já que menos alocações ajudam uma thread única também. As ~9 alocações restantes por célula são aproximadamente metade objetos de célula e metade crescimento amortizado de contêiner; nós as medimos, julgamos que os retornos eram decrescentes e paramos, com o wrapper do gerenciador de memória pronto para testar novamente por local de chamada se uma carga de trabalho futura justificar outra rodada

Os limites valem a pena ser expostos tão claramente quanto os ganhos. O HotXLS paraleliza no nível de granularidade da planilha, de modo que uma pasta de trabalho que consiste em uma única planilha gigante seja analisada em uma única thread, independentemente do que o ParallelParseThreads indique; para esse formato, o leitor direto em fluxo é a melhor ferramenta, pois evita completamente a materialização da pasta de trabalho na memória. Arquivos cujo tempo é consumido pelas partes da Fase C — desenhos, gráficos e comentários — obtêm menos benefícios porque essa fase permanece sequencial por design. Arquivos pequenos não valem a pena usar threads de forma alguma, razão pela qual o despachante executa silenciosamente o modo sequencial para contagens triviais de tarefas. E o teto do gerenciador de memória não desapareceu, apenas recuou: com 9,1 alocações por célula, o bloqueio global ainda taxa os trabalhadores, razão pela qual os trabalhadores entregam 1,90x em vez de 4x. Para o conjunto mais amplo de ferramentas de redução de tempos de carregamento e salvamento, incluindo estilos, pools e callbacks de linha em lote, consulte nosso guia de desempenho para grandes pastas de trabalho no Delphi

A análise paralela de XLSX, as propriedades ParallelParse e ParallelParseThreads e o leitor XML econômico em alocações descrito aqui são fornecidos como partes padrão do HotXLS Delphi Excel Component, que lê e grava XLS, XLSX e ODS nativamente a partir do Delphi e C++Builder, sem automação do Excel envolvida