Artículo técnico

Análisis paralelo de XLSX en Delphi: Cuello de botella del administrador de memoria

HotXLS, la biblioteca nativa de Excel para Delphi y C++Builder, analiza hojas de trabajo XLSX en múltiples subprocesos a través de una carga de tres fases: el XML de la hoja se descomprime de manera secuencial, se analiza en paralelo y las partes pequeñas se leen secuencialmente después. El primer lanzamiento de esta característica obtuvo solo entre un 12 y un 25% de mejora, porque el bloqueo predeterminado del administrador de memoria de Delphi forzó la ejecución secuencial de los subprocesos de trabajo. Reducir las asignaciones de montón de aproximadamente 20 a 9.1 por celda elevó la aceleración paralela a ×1.90 en ocho subprocesos. Este artículo recorre las mediciones, los caminos incorrectos y las dos soluciones que realmente funcionaron

¿Cómo analiza HotXLS hojas de trabajo XLSX en paralelo?

HotXLS divide Open en tres fases, y solo la del medio se ejecuta en subprocesos de trabajo. La razón es el contenedor ZIP: un archivo ZIP es un flujo de entrada compartido con una única máquina de estados de descompresión (inflate), y esa máquina de estados no puede ser leída por dos subprocesos a la vez. Envolverlo en un bloqueo no tendría sentido, porque la descompresión es inherentemente secuencial por entrada, por lo que un bloqueo solo reproduciría la ejecución secuencial con una sobrecarga adicional. Por lo tanto, la Fase A descomprime el XML de cada hoja de trabajo en su propio TMemoryStream mientras aún se ejecuta en un solo subproceso; en nuestro archivo de prueba de rendimiento, esto tomó aproximadamente 4 ms para ocho hojas, por lo que no está cerca de ser el cuello de botella. La Fase B ejecuta ParseWorksheetXml para cada hoja en un pool de subprocesos de trabajo, que es donde se encuentra casi todo el tiempo de carga. La Fase C regresa al ZIP de forma secuencial para las partes pequeñas: comentarios, dibujos, gráficos y tablas

El pool de subprocesos de trabajo en sí es deliberadamente simple. Los subprocesos extraen índices de trabajo de un contador compartido con InterlockedIncrement, de modo que las hojas de tamaño desigual se equilibran de forma natural sin ningún programador. El número de subprocesos es min(sheet count, CPU cores), la primera excepción del subproceso de trabajo se captura con AcquireExceptionObject y se vuelve a lanzar en el subproceso principal después de la sincronización (join), y el despachador se degrada a un bucle secuencial simple cuando hay cero o un trabajos. Dos propiedades en TXLSXWorkbook controlan la característica: ParallelParse activa el pool y ParallelParseThreads limita el recuento de subprocesos, donde 0 significa automático. Los libros de trabajo con múltiples hojas son los que más se benefician, incluidos los que se producen al duplicar una hoja de trabajo de plantilla decenas de veces

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 qué añadir subprocesos ralentiza el análisis de XLSX en Delphi?

Porque el administrador de memoria predeterminado de Delphi protege su montón (heap) con un bloqueo global, y el análisis de hojas de trabajo es denso en asignaciones: celdas, Variants y WideStrings por millones. Cada subproceso de trabajo que toca el montón hace cola en ese bloqueo, por lo que los subprocesos que parecen independientes en el código fuente se ejecutan casi uno a la vez en la práctica. Nuestra primera prueba de rendimiento hizo esto dolorosamente concreto. En un libro de trabajo de 8 hojas con 5,000 filas por 4 columnas por hoja, medido en un i5-11600K (6 núcleos, 12 subprocesos) bajo Win64, el método Open en paralelo mejoró solo entre un 12 y un 25% frente a una estimación del plan de al menos el 40%. Un barrido del recuento de subprocesos en 2, 3, 4, 6 y 8 subprocesos produjo una curva plana, y en ejecuciones instrumentadas posteriores, la configuración de 2 subprocesos fue en realidad un 26% más lenta que la secuencial, la firma clásica de dos subprocesos alternando en un bloqueo con alta contención

Tres mediciones confirmaron el diagnóstico, y cada una anuló la intuición anterior. Primero, un archivo diminuto (8 hojas de 1 fila) se abrió en 1.2 ms, lo que demuestra que el análisis es esencialmente el 100% de Open y no había ningún costo fijo oculto al que culpar. Segundo, una microprueba de rendimiento de rotación pura de asignaciones mostró que el administrador de memoria de Delphi escalaba hacia atrás: el mismo volumen total de 2 millones de asignaciones de objetos y AnsiString se ejecutó un 60% más lento en 8 subprocesos que en uno solo, mientras que la misma rotación contra el montón de WideString, que es el asignador COM BSTR en lugar del administrador de memoria de Delphi, escaló a ×3.7. Que HotXLS utilice WideString en todas partes resultó ser un accidente de la historia que funcionó a nuestro favor. Tercero, GetProcessTimes mostró que durante un Open en paralelo, el tiempo de CPU equivalía aproximadamente al tiempo real: ocho subprocesos nominales consumían aproximadamente el valor de 1.3 subprocesos de tiempo de CPU. Los subprocesos de trabajo no estaban girando en vacío; estaban inactivos en la ruta de contención del administrador de memoria, bloqueados en lugar de ocupados

La lección práctica se generaliza más allá de las hojas de cálculo. Si una carga de trabajo de Delphi realiza asignaciones intensivas, aumentar el número de subprocesos no sirve de nada hasta que disminuya la tasa de asignación, y fácilmente puede empeorar las cosas. Antes de esta solución, les decíamos a los usuarios que ajustaban ParallelParseThreads la verdad honesta: en archivos limitados por la asignación de memoria, más subprocesos no aportaban casi nada

¿De dónde provienen las 20 asignaciones de montón por celda?

Un contenedor de conteo instalado con SetMemoryManager respondió a esa pregunta con precisión: aproximadamente 20 asignaciones del administrador de memoria de Delphi por celda, con 2.87 million de ellas de 32 bytes o menos. El culpable no eran en absoluto los objetos de celda. TXMLScaner.GetTokenValue materializaba un AnsiString nuevo en cada llamada, y se le llama aproximadamente entre 15 y 20 veces por celda: una vez para los nombres de elementos, otra para los nombres de atributos, otra para los valores de atributos y otra para el contenido del texto. Además, la ruta UTF8ToWideString de la biblioteca en tiempo de ejecución (RTL) fabricaba un intermediario temporal UnicodeString para cada conversión. Los objetos de celda representaban solo 160 mil asignaciones, aproximadamente el 8% del total, lo que descartó nuestro plan original de inmediato: habíamos tenido la intención de construir un pool de objetos de celda, y las cifras decían que nunca se pagaría por sí mismo

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 la pena adoptar ese diagnóstico de diez minutos para cualquier investigación de rendimiento en Delphi. Contar las asignaciones por tamaño de bloque no cuesta casi nada de construir y le indica dónde se origina realmente la presión del administrador de memoria, que en nuestro caso se trataba de dos hábitos a nivel de RTL dentro del escáner XML en lugar de algo en el modelo de objetos. Los analizadores de rendimiento (profilers) seguían apuntando al analizador como un todo; el contenedor de conteo apuntó a dos líneas específicas

La solución: internación de tokens y un decodificador UTF-8 sin intermediarios

Dos cambios específicos en el lector XML eliminaron más de la mitad de las asignaciones por celda sin alterar la estructura del analizador. El primero es la internación de nombres de elementos. El XML de la hoja de trabajo repite un vocabulario diminuto sin cesar: row, c, v, r, t, s y un puñado de nombres de atributos. InternTokenName mantiene una caché de 64 ranuras de nombres vistos anteriormente y compara el búfer de construcción del escáner con una entrada almacenada en caché con TokenEqualsAnsi, una comparación directa de bytes que no realiza asignaciones. En caso de acierto, devuelve el AnsiString almacenado en caché, y aquí la elección del tipo importa: AnsiString tiene conteo de referencias, por lo que devolver una instancia almacenada en caché cuesta un incremento del recuento de referencias y cero tráfico en el montón. WideString no tiene recuento de referencias, y cada asignación pasa por SysAllocString, por lo que la internación de WideStrings no ahorraría nada. La internación solo vale la pena en el tipo de cadena con conteo de referencias

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 solución: internación de tokens y un decodificador UTF-8 sin intermediarios

El segundo cambio ataca el texto de la celda. La ruta antigua construía un token AnsiString, lo entregaba a UTF8ToWideString, el cual construía un intermediario UnicodeString, que finalmente se convertía al WideString que almacena la celda: dos asignaciones del administrador de memoria de Delphi por token de texto antes de la real. El reemplazo, XmlUtf8ToWide(TokenPtr, TokenLen), is un decodificador UTF-8 en Pascal puro de dos pasadas que lee directamente desde el búfer de escaneo: la primera pasada mide la longitud UTF-16 y la segunda decodifica en un WideString asignado una sola vez. Costo neto por token de texto: una asignación COM, cero asignaciones del administrador de memoria de Delphi. Una nota semántica para los precavidos: en secuencias UTF-8 con formato incorrecto, el nuevo decodificador deja pasar los bytes directamente en lugar de sustituirlos por caracteres de reemplazo como hace la RTL, lo que solo afecta a cómo se degradan los archivos corruptos; en entradas válidas la salida es idéntica a nivel de bytes. Las entidades de caracteres XML nunca llegan al decodificador, porque el escáner ya las ha resuelto en UTF-8 en el búfer del token

¿Qué aportó esta solución y dónde seguirá sin ayudar el análisis en paralelo?

Las dos soluciones redujeron las asignaciones por celda de aproximadamente 20 a 9.1, y los números paralelos se movieron de la manera que la teoría indicaba que debían hacerlo. En la misma prueba de rendimiento de 8 hojas y 5,000 filas y en la misma máquina 6C12T, la mejora de 8 subprocesos pasó del 14% al 47.4%, una aceleración de ×1.90 sobre la ejecución secuencial. El caso de 2 subprocesos pasó de ser un 26% más lento a ser un 23.6% más rápido, y la utilización medida de la CPU aumentó de ×1.0 a ×2.2. La ruta secuencial se volvió aproximadamente un 3% más rápida como beneficio adicional, ya que tener menos asignaciones también ayuda a un solo subproceso. Las ~9 asignaciones restantes por celda corresponden aproximadamente a la mitad de objetos de celda y la otra mitad a un crecimiento amortizado del contenedor; las medimos, juzgamos que los rendimientos eran decrecientes y nos detuvimos, con el contenedor de conteo del administrador de memoria listo para volver a realizar un muestreo por punto de llamada si una carga de trabajo futura justifica otra ronda

Vale la pena exponer los límites con la misma claridad que las mejoras. HotXLS paraleliza con la granularidad de la hoja de trabajo, por lo que un libro de trabajo que es una sola hoja gigante se analiza en un único subproceso sin importar lo que indique ParallelParseThreads; para ese caso, el lector directo de flujos (streaming direct reader) es la mejor herramienta, ya que evita materializar el libro de trabajo por completo. Los archivos cuyo tiempo se destina a las partes de la Fase C, dibujos, gráficos y comentarios, ven menos beneficios porque esa fase sigue siendo secuencial por diseño. No vale la pena aplicar subprocesos a los archivos pequeños en absoluto, razón por la cual el despachador se ejecuta silenciosamente de manera secuencial para recuentos de trabajo triviales. Y el límite del administrador de memoria no ha desaparecido, solo ha disminuido: con 9.1 asignaciones por celda, el bloqueo global todavía impone un cargo a los subprocesos de trabajo, razón por la cual os ocho subprocesos producen ×1.90 en lugar de ×4. Para el conjunto de herramientas más amplio de reducción de tiempos de carga y guardado, incluidos estilos, pools y devoluciones de llamadas de fila masivas, consulte nuestra guía sobre el rendimiento con libros de trabajo grandes en Delphi

El análisis paralelo de XLSX, las propiedades ParallelParse y ParallelParseThreads, y el lector XML de bajas asignaciones descrito aquí se distribuyen como partes estándar del componente HotXLS Delphi Excel Component, que lee y escribe XLS, XLSX y ODS de forma nativa desde Delphi y C++Builder sin requerir la automatización de Excel