Artículo técnico

Análisis paralelo de XLSX en Delphi: El 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 hilos a través de una carga de tres fases: el XML de la hoja se descomprime en serie, se analiza en paralelo y las partes pequeñas se leen en serie después. La primera versión de esa característica obtuvo solo un 12-25%, porque el bloqueo del administrador de memoria predeterminado de Delphi serializó los hilos de trabajo. Reducir las asignaciones de pila de aproximadamente 20 a 9.1 por celda elevó la aceleración paralela a 1.90 veces en ese benchmark con ocho hilos. Este artículo recorre las mediciones, los giros 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 hilos de trabajo. La razón es el contenedor zip: un archivo zip es un flujo de entrada compartido con una máquina de estado de inflado (inflate), y esa máquina de estado no puede ser leída por dos hilos a la vez. Envolverla en un bloqueo no tendría sentido, porque el inflado es intrínsecamente serial por entrada, por lo que un bloqueo simplemente reproduciría la ejecución en serie con una sobrecarga adicional. Por lo tanto, la Fase A descomprime el XML de cada hoja de trabajo en su propio TMemoryStream mientras sigue teniendo un solo hilo; en nuestro archivo de prueba, esto tomó aproximadamente 4 ms para ocho partes de la hoja, por lo que no está cerca del cuello de botella. La Fase B ejecuta ParseWorksheetXml para cada hoja en un grupo de trabajo, que es donde reside casi todo el tiempo de carga. La Fase C regresa al zip en serie para las partes pequeñas: comentarios, dibujos, gráficos y tablas

El grupo de trabajo en sí es deliberadamente simple. Los trabajadores extraen los í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 planificador. El recuento de hilos es min(sheet count, CPU cores), la primera excepción del trabajador se captura con AcquireExceptionObject y se vuelve a generar en el hilo principal después de la unión (join), y el despachador se degrada a un bucle serial simple cuando hay cero o un trabajos. Dos propiedades en TXLSXWorkbook controlan la función: ParallelParse activa el grupo y ParallelParseThreads limita el recuento de hilos, donde 0 significa automático. Los libros de trabajo con múltiples hojas son el formato que se beneficia, incluido el tipo que produce 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;

¿Paralelizar hilos en Delphi ralentiza el análisis de XLSX?

Porque el administrador de memoria predeterminado de Delphi protege su pila con un bloqueo global, y el análisis de la hoja de trabajo requiere muchas asignaciones: millones de celdas, Variants y WideStrings. Cada trabajador que toca la pila se pone en cola en ese bloqueo, por lo que los hilos que parecen independientes en el código fuente se ejecutan casi uno a la vez en la práctica. Nuestra primera prueba de rendimiento lo hizo 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 hilos) bajo Win64, el Open paralelo mejoró solo en un 12-25% frente a una estimación del plan de al menos el 40%. Un barrido del recuento de hilos en 2, 3, 4, 6 y 8 hilos produjo una curva plana, y en ejecuciones instrumentadas posteriores, la configuración de 2 hilos fue en realidad un 26% más lenta que la serie, la firma clásica de dos hilos que se pasan un bloqueo disputado

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 coste 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 a la inversa: el mismo volumen total de 2 millones de asignaciones de objetos y AnsiString se ejecutó un 60% más lento en 8 hilos que en uno, mientras que la misma rotación en la pila de WideString (que es el asignador COM BSTR en lugar del MM de Delphi) escaló a 3.7 veces. El hecho de que HotXLS use WideString en todas partes resultó ser un accidente de la historia que funcionó a nuestro favor. Tercero, GetProcessTimes mostró que durante un Open paralelo, el tiempo de CPU equivalía aproximadamente al tiempo transcurrido (wall time): ocho hilos nominales consumían alrededor de 1.3 hilos de CPU. Los trabajadores no estaban girando; estaban inactivos en la ruta de disputa 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 asigna intensamente, aumentar el recuento de hilos no sirve de nada hasta que la tasa de asignación disminuya, y puede empeorar las cosas fácilmente. Antes de esta solución, les decíamos a los usuarios que ajustaban ParallelParseThreads la pura verdad: en archivos limitados por la asignación, más hilos no aportaban casi nada

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

Un contenedor de conteo instalado con SetMemoryManager respondió a esa pregunta con precisión: alrededor de 20 asignaciones de Delphi-MM por celda, con 2.87 million de ellas de 32 bytes o menos. El culpable no eran los objetos de celda en absoluto. TXMLScaner.GetTokenValue materializaba una AnsiString nueva en cada llamada, y se le llama aproximadamente entre 15 y 20 veces por celda: una vez para los nombres de elementos, nombres de atributos, valores de atributos y contenido de texto. Además, la ruta UTF8ToWideString de RTL fabricaba un UnicodeString temporal intermedio para cada conversión. Los objetos de celda representaron solo 160 mil asignaciones, alrededor del 8% del total, lo que anuló nuestro plan original en el acto: teníamos la intención de construir un grupo de objetos de celda y los números decían que nunca se amortizaría solo

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

La solución: internamiento de tokens y un decodificador UTF-8 con cero intermediarios

Dos cambios específicos en el lector XML eliminaron más de la mitad de las asignaciones por celda sin tocar la estructura del analizador. El primero es el internamiento de nombres de elementos. El XML de la hoja de trabajo repite un vocabulario diminuto sin fin: 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 constructor del escáner con una entrada almacenada en caché mediante TokenEqualsAnsi, una comparación directa de bytes que no asigna nada. En caso de acierto, devuelve la AnsiString almacenada en caché y aquí la elección del tipo importa: AnsiString está sujeta a conteo de referencias (refcount), por lo que devolver una instancia almacenada en caché cuesta un incremento de refcount y cero tráfico de pila. WideString no tiene conteo de referencias y cada asignación pasa por SysAllocString, por lo que internar WideStrings no ahorraría nada. Solo vale la pena internar 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;

El segundo cambio ataca al texto de la celda. La ruta antigua construía un token AnsiString, se lo entregaba a UTF8ToWideString, que construía un UnicodeString intermedio, que finalmente se convertía a la WideString que almacena la celda: dos asignaciones de Delphi-MM por token de texto antes de la real. El reemplazo, XmlUtf8ToWide(TokenPtr, TokenLen), es un decodificador UTF-8 en Pascal puro de dos pasadas que lee directamente desde el búfer de escaneo: la pasada uno mide la longitud de UTF-16, la pasada dos decodifica en una WideString asignada una sola vez. Coste neto por token de texto: una asignación COM, cero asignaciones de Delphi-MM. Una nota semántica para los precavidos: en secuencias UTF-8 mal formadas, el nuevo decodificador pasa los bytes 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 de tokens

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

Las dos soluciones redujeron las asignaciones por celda de aproximadamente 20 a 9.1, y los números paralelos se movieron como decía la teoría. En la misma prueba de rendimiento de 8 hojas y 5,000 filas y en la misma máquina 6C12T, la mejora de 8 hilos pasó del 14% al 47.4%, una aceleración de 1.90 veces con respecto a la serie. El caso de 2 hilos pasó de ser un 26% más lento a un 23.6% más rápido, y la utilización de CPU medida aumentó de 1.0 a 2.2 veces. La ruta en serie se volvió aproximadamente un 3% más rápida como bonificación, ya que tener menos asignaciones también ayuda a un solo hilo. Las aproximadamente 9 asignaciones restantes por celda son más o menos mitad objetos de celda y mitad crecimiento amortizado del contenedor; las medimos, juzgamos que los rendimientos eran decrecientes y nos detuvimos, con el contenedor MM listo para volver a muestrear por sitio de llamada si una carga de trabajo futura justifica otra ronda

Vale la pena exponer los límites con tanta claridad como las victorias. HotXLS paraleliza a nivel de granularidad de hoja de trabajo, por lo que un libro de trabajo que es una única hoja gigante se analiza en un solo hilo, sin importar lo que diga ParallelParseThreads; para ese formato, el lector directo de transmisión 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, comentarios) experimentan menos beneficios porque esa fase permanece serializada por diseño. Los archivos pequeños no merecen en absoluto el uso de hilos, por lo que el despachador se ejecuta silenciosamente en serie para recuentos de trabajo triviales. Y el límite del administrador de memoria no ha desaparecido, solo se ha alejado: con 9.1 asignaciones por celda, el bloqueo global sigue afectando a los trabajadores, por lo que ocho hilos producen una aceleración de 1.90 veces en lugar de 4. Para el conjunto de herramientas más amplio para reducir los tiempos de carga y guardado, incluidos estilos, grupos y devoluciones de llamadas de fila masivas, consulte nuestra guía sobre el rendimiento de 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 HotXLS Delphi Excel Component, que lee y escribe XLS, XLSX y ODS de forma nativa desde Delphi y C++Builder sin que intervenga la automatización de Excel