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
- Commits for the whole refactoring
- Turn the NodeData struct into an enum with variants for Element and Text
- Box the Element variant to make Text nodes smaller. The commit message has parts of the massif log with all the interesting numbers.
- Tracker bug for memory consumption