Reducing memory consumption in librsvg, part 1: text nodes

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

Librsvg's memory consumption has not been a problem so far for GNOME's use cases, which is basically rendering icons. But for SVG files with thousands of elements, it could do a lot better.

Memory consumption in the DOM

Librsvg shares some common problems with web browsers: it must construct a DOM tree in memory with SVG elements, and keep a bunch of information for each of the tree's nodes. For example, each SVG element may have an id attribute, or a class; each one has a transformation matrix; etc.

Apart from the tree node metadata (pointers to sibling and parent nodes), each node has this:

/// Contents of a tree node
pub struct NodeData {
    node_type: NodeType,
    element_name: QualName,
    id: Option<String>,    // id attribute from XML element
    class: Option<String>, // class attribute from XML element
    specified_values: SpecifiedValues,
    important_styles: HashSet<QualName>,
    result: NodeResult,
    transform: Transform,
    values: ComputedValues,
    cond: bool,
    style_attr: String,

    node_impl: Box<dyn NodeTrait>, // concrete struct for node types
}

On a 64-bit box, that NodeData struct is 1808 bytes. And the biggest fields are the SpecifiedValues (824 bytes) and ComputedValues (704 bytes).

Librsvg represents all tree nodes with that struct. Consider an SVG like this:

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

There are 4 elements in that file. However, there are also tree nodes for the XML text nodes, that is, the whitespace between tags and the "Hello" inside the <text> element.

The contents of each of those text nodes is tiny (a newline and maybe a couple of spaces), but each node still takes up at least 1808 bytes from the NodeData struct, plus the size of the text string.

Let's refactor this to make it easier to remove that overhead.

First step: separate text nodes from element nodes

Internally, librsvg represents XML text nodes with a NodeChars struct which is basically a string with some extra stuff. All the concrete structs for tree node types must implement a trait called NodeTrait, and NodeChars is no exception:

pub struct NodeChars {
   // a string with the text node's contents
}

impl NodeTrait for NodeChars {
   // a mostly empty impl with methods that do nothing
}

You don't see it in the definition of NodeData in the previous section, but for a text node, the NodeData.node_impl field would point to a heap-allocated NodeChars (it can do that, since NodeChars implements NodeTrait, so it can go into node_impl: Box<dyn NodeTrait>).

First, I turned the NodeData struct into an enum with two variants, and moved all of its previous fields to an Element struct:

// This one is new
pub enum NodeData {
    Element(Element),
    Text(NodeChars),
}

// This is the old struct with a different name
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>,
}

The size of a Rust enum is the maximum of the sizes of its variants, plus a little extra for the discriminant (you can think of a C struct with an int for the discriminant, and a union of variants).

The code needed a few changes to split NodeData in this way, by adding accessor functions to each of the Element or Text cases conveniently. This is one of those refactors where you can just change the declaration, and walk down the compiler's errors to make each case use the accesors instead of whatever was done before.

Second step: move the Element variant to a separate allocation

Now, we turn NodeData into this:

pub enum NodeData {
    Element(Box<Element>), // This goes inside a Box
    Text(NodeChars),
}

That way, the Element variant is the size of a pointer (i.e. a pointer to the heap-allocated Box), and the Text variant is as big as NodeChars as usual.

This means that Element nodes are just as big as before, plus an extra pointer, plus an extra heap allocation.

However, the Text nodes get a lot smaller!

  • Before: sizeof::<NodeData>() = 1808
  • After: sizeof::<NodeData>() = 72

By making the Element variant a lot smaller (the size of a Box, which is just a pointer), it has no extra overhead on the Text variant.

This means that in the SVG file, all the whitespace between XML elements now takes a lot less memory.

Some numbers from a pathological file

Issue 42 is about an SVG file that is just a <use> element repeated many times, once per line:

<svg xmlns="http://www.w3.org/2000/svg">
  <defs>
    <symbol id="glyph0-0">
      <!-- a few elements here -->
    </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"/>
  <!-- about 196,000 similar lines -->
</svg>

So we have around 196,000 elements. According to Valgrind's Massif tool, this makes rsvg-convert allocate 800,501,568 bytes in the old version, versus 463,412,720 bytes in the new version, or about 60% of the space.

Next steps

There is a lot of repetition in the text nodes of a typical SVG file. For example, in that pathological file above, most of the whitespace is identical: between each element there is a newline and two spaces. Instead of having thousands of little allocations, all with the same string, there could be a pool of shared strings. Files with "real" indentation could get benefits from sharing the whitespace-only text nodes.

Real browser engines are very careful to share the style structs across elements if possible. Look for "style struct sharing" in "Inside a super fast CSS engine: Quantum CSS". This is going to take some good work in librsvg, but we can get there gradually.

References