Reducción del consumo de memoria en librsvg, parte 1: nodos de texto

Translations: en - Tags: gnome, librsvg, performance, rust

Hasta ahora, el consumo de memoria de librsvg no ha sido un problema para GNOME, pues ahí se usa casi sólo para renderear iconitos. Pero para SVGs con miles de elementos, podría comportarse mucho mejor.

El consumo de memoria en el DOM

Librsvg y los navegadores de web atacan más o menos el mismo problema: tienen que construir un árbol DOM en memoria con los elementos del SVG, y mantener un montón de información para cada nodo del árbol. Por ejemplo, cada elemento de un SVG puede tener un atributo id, o class; cada elemento tiene una matriz de transformación; etc.

Además de los metadatos de los nodos del árbol (punteros a los nodos hermanos y padre), cada nodo tiene lo siguiente:

/// Contenido de un nodo del árbol
pub struct NodeData {
    node_type: NodeType,
    element_name: QualName,
    id: Option<String>,    // atributo "id" del elemento XML
    class: Option<String>, // atributo "class" del elemento XML
    specified_values: SpecifiedValues,
    important_styles: HashSet<QualName>,
    result: NodeResult,
    transform: Transform,
    values: ComputedValues,
    cond: bool,
    style_attr: String,

    node_impl: Box<dyn NodeTrait>, // estructura concreta para los tipos de nodos
}

En una compu de 64 bits, ese struct NodeData ocupa 1808 bytes. Los campos más grandes son los de tipo SpecifiedValues (824 bytes) y ComputedValues (704 bytes).

Librsvg representa todos los nodos del árbol con ese struct. Por ejemplo, veamos un SVG como éste:

<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
  <rect x="10" y="20"/>
  <path d="..."/>
  <text x="10" y="20">Hola</text>
  <!-- etc -->
</svg>

Ahí hay 4 elementos. Sin embargo, también hay nodos del árbol para los nodos de texto del XML, es decir, el espacio en blanco entre las etiquetas y el "Hola" adentro del elemento <text>.

El contenido de cada uno de esos nodos de texto es pequeño (un caracter de nueva línea y un par de espacios), pero cada nodo sigue ocupando por lo menos 1808 bytes del struct NodeData, además del tamaño de la cadena de texto.

Vamos a refactorizar esto para hacer más fácil quitar el espacio sobrante.

Primer paso: separar los nodos de texto de los nodos para elementos

En las entrañas de librsvg, los nodos de texto del XML se representan con un struct NodeChars, que es una cadena de texto con un par de cosillas más. Todos los structs que se usan para implementar tipos concretos de nodos en el árbol deben implementar el trait NodeTrait, y NodeChars no es la excepción:

pub struct NodeChars {
   // una cadena con el contenido del nodo de texto
}

impl NodeTrait for NodeChars {
   // una impl más bien vacía porque el texto no hace nada
}

Aunque no se ve en la definición de NodeData en la sección anterior, un nodo de texto pondría el NodeChars en el campo NodeData.node_impl. El NodeChars quedaría en un bloque de memoria aparte, en el heap, gracias al Box — puede hacer eso porque como NodeChars implementa el trait NodeTrait, entonces puede ir dentro del campo node_impl: Box<dyn NodeTrait>.

Lo que hice primero fue convertir el struct NodeData en un enum con dos variantes, y mover todos sus campos anteriores a un struct Element nuevo:

// Éste es nuevo
pub enum NodeData {
    Element(Element),
    Text(NodeChars),
}

// Éste es el struct viejo, pero con un nombre nuevo
pub enum Element {
    node_type: NodeType,
    element_name: QualName,
    id: Option<String>,
    class: Option<String>,
    specified_values: SpecifiedValues,
    important_styles: HashSet<QualName>,
    result: NodeResult,
    transform: Transform,
    values: ComputedValues,
    cond: bool,
    style_attr: String,
    node_impl: Box<dyn NodeTrait>,
}

El tamaño de un enum en Rust es el máximo de los tamaños de sus variantes, más un entero extra para el discriminante del enum (puedes pensar en un struct en C con un int para el discriminante, y una unión de variantes).

El código requirió varios cambios para separar el NodeData de esta manera; me sirvió ponerle funciones de ayuda para accesar las variantes Element o Text sin que el código que usa el enum supiera de su representación interna. Éste es el tipo de refactorización en el que cambias una declaración, y te puedes ayudar de los errores del compilador para cambiar cada uno de los casos necesarios en el resto del código.

Segundo paso: mover la variante Element a un bloque de memoria separado

Ahora, convertimos el NodeData en esto:

pub enum NodeData {
    Element(Box<Element>), // Esto va dentro de un Box
    Text(NodeChars),
}

Así, la variante Element acaba siendo sólo del tamaño de un puntero (i.e. un puntero al Box que vive en el heap), y la variante Text se mantiene del tamaño de un NodeChars igual que antes.

Esto significa que los nodos para un Element son igual de grandes en la memoria que como eran antes, más un puntero adicional, más un bloque extra en el heap.

¡Sin embargo, los nodos Text son mucho más pequeños!

  • Antes: sizeof::<NodeData>() = 1808
  • Después: sizeof::<NodeData>() = 72

Al hacer la variante Element mucho más pequeña (con el tamaño de un Box, que es sólo un puntero), no excede de tamaño a la variante Text.

Esto quiere decir que para el archivo SVG, todo el espacio en blanco entre las etiquetas de XML ahora ocupa mucho menos memoria.

Mediciones de un archivo patológico

El bug 42 se refiere a un archivo SVG que sólo tiene un elemento use repetido muchísimas veces, uno por línea:

<svg xmlns="http://www.w3.org/2000/svg">
  <defs>
    <symbol id="glyph0-0">
      <!-- algunos elementos aquí -->
    </symbol>
  </defs>

  <use xlink:href="#glyph0-0" x="1" y="10"/>
  <use xlink:href="#glyph0-0" x="1" y="10"/>
  <use xlink:href="#glyph0-0" x="1" y="10"/>
  <!-- unas 196,000 líneas de lo mismo -->
</svg>

Entonces tenemos unos 196,000 elementos. De acuerdo a la herramienta Massif de Valgrind, esto hace que rsvg-convert pida 800,501,568 bytes de memoria en la versión vieja, a comparación de 463,412,720 bytes en la versión nueva, o 60% del espacio original.

Qué sigue por hacer

Los nodos de texto de un SVG promedio son muy repetitivos. Por ejemplo, en el archivo patológico de la sección anterior, casi todo el espacio en blanco es idéntico: entre cada elemento hay un caracter de nueva línea y dos espacios. En vez de tener miles de bloquecitos de memoria con cadenas de texto idénticas, podría haber un conjunto de cadenas compartidas. Los archivos SVG con indentación "de deveras" podrían beneficiarse de compartir los nodos de indentación similar.

Los motores de web de deveras son muy cuidadosos y comparten las estructuras de estilos entre varios elementos del SVG o del HTML. Busca "style struct sharing" en "Inside a super fast CSS engine: Quantum CSS". Esto implica una buena cantidad de trabajo para librsvg, pero podemos llegar a ello poco a poco.

Referencias