svelte
is a code size profiler.
It analyzes a binary's call graph to answer questions like:
-
Why was this function included in the binary in the first place?
-
What is the retained size of this function? I.e. how much space would be saved if I removed it and all the functions that become dead code after its removal.
Use svelte
to make your binaries slim!
Ensure that you have the Rust toolchain installed, then run:
$ cargo install --git https://github.com/fitzgen/svelte.git
$ svelte --help
Consider the following functions:
pub fn shred() {
gnar_gnar();
bluebird();
}
fn gnar_gnar() {
weather_report();
pow();
}
fn bluebird() {
weather_report();
}
fn weather_report() {
shred();
}
fn pow() {
fluffy();
soft();
}
fn fluffy() {}
fn soft() {}
pub fn baker() {
hood();
}
fn hood() {}
If we treat every function as a vertex in a graph, and if we add an edge from A to B if function A calls function B, then we get the following call graph:
If there is a path where A → B → ... → C through the call graph, then we say
that C is reachable through from A. Dead code is code that is not
reachable in the call graph from any publicly exported functions (for
libraries) or the main
function (for executables).
Imagine that shred
from the last example was our executable's main
function. In this scenario, there is no path through the call graph from shred
to baker
or hood
, so they are dead code. We would expect that the linker
would remove them, and they wouldn't show up in the final binary.
But what if some function that you thought was dead code is appearing inside your binary? Maybe it is deep down in some library you depend on, but inside a submodule of that library that you aren't using, and you wouldn't expect it to be included in the final binary.
In this scenario, svelte
can show you all the paths in the call graph that
lead to the unexpected function. This lets you understand why the unwelcome
function is present, and decide what you can do about it. Maybe if you
refactored your code to avoid calling Y, then there wouldn't be any paths to
the unwelcome function anymore, it would be dead code, and the linker would
remove it.
You can use the svelte paths
subcommand to view the paths to a function in a
given binary's call graph:
$ svelte paths wee_alloc.wasm 'wee_alloc::alloc_first_fit::h9a72de3af77ef93f'
Shallow Bytes │ Shallow % │ Retaining Paths
───────────────┼───────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
225 ┊ 7.99% ┊ wee_alloc::alloc_first_fit::h9a72de3af77ef93f
┊ ┊ ⬑ func[3]
┊ ┊ ⬑ wee_alloc::alloc_with_refill::hb32c1bbce9ebda8e
┊ ┊ ⬑ func[2]
┊ ┊ ⬑ <wee_alloc::size_classes::SizeClassAllocPolicy<'a> as wee_alloc::AllocPolicy>::new_cell_for_free_list::h3987e3054b8224e6
┊ ┊ ⬑ func[5]
┊ ┊ ⬑ elem[0]
┊ ┊ ⬑ hello
┊ ┊ ⬑ func[8]
┊ ┊ ⬑ export "hello"
Imagine the pow
function itself might is not very large. But it calls
functions soft
and fluffy
, both of which are huge. And they are both
only called by pow
, so if pow
were removed, then soft
and fluffy
would
both become dead code and get removed as well. Therefore, pow
's "real" size is
huge, even though it doesn't look like it at a glance. The dominator
relationship gives us a way to reason about the retained size of a function.
In a graph that is rooted at vertex R, vertex A is said to dominate vertex B if every path in the graph from R to B includes A. It follows that if A were removed from the graph, then B would become unreachable.
In our call graphs, the roots are the main
function (for executables) or
publicly exported functions (for libraries).
V is the immediate dominator of a vertex U if V != U, and there does not
exist another distinct vertex W that is dominated by V but also dominates
U. If we take all the vertices from a graph, remove the edges, and then add
edges for each immediate dominator relationship, then we get a tree. Here is the
dominator tree for our call graph from earlier, where shred
is the root:
Using the dominator relationship, we can find the retained size of some function by taking its shallow size and adding the retained sizes of each function that it immediately dominates.
You can use the svelte dominators
subcommand to view the dominator tree for a
given binary's call graph:
$ svelte dominators wee_alloc.wasm
Retained Bytes │ Retained % │ Dominator Tree
────────────────┼────────────┼────────────────────────────────────────────────────────────────────────
774 ┊ 27.48% ┊ "function names" subsection
564 ┊ 20.02% ┊ export "hello"
556 ┊ 19.74% ┊ ⤷ func[8]
551 ┊ 19.56% ┊ ⤷ hello
387 ┊ 13.74% ┊ ⤷ func[2]
378 ┊ 13.42% ┊ ⤷ wee_alloc::alloc_with_refill::hb32c1bbce9ebda8e
226 ┊ 8.02% ┊ ⤷ func[3]
225 ┊ 7.99% ┊ ⤷ wee_alloc::alloc_first_fit::h9a72de3af77ef93f
8 ┊ 0.28% ┊ ⤷ type[4]
4 ┊ 0.14% ┊ ⤷ type[5]
59 ┊ 2.09% ┊ export "goodbye"
49 ┊ 1.74% ┊ ⤷ func[9]
44 ┊ 1.56% ┊ ⤷ goodbye
4 ┊ 0.14% ┊ ⤷ type[3]
11 ┊ 0.39% ┊ export "memory"
2 ┊ 0.07% ┊ ⤷ memory[0]
- WebAssembly's
.wasm
format
Although svelte
doesn't currently support ELF, Mach-O, or PE/COFF, it is
designed with extensibility in mind. The input is translated into a
format-agnostic internal representation (IR), and adding support for new formats
only requires parsing them into this IR. The vast majority of svelte
will not
need modification.
We would love to gain support for new binary formats, and if you're interested
in doing that implementation work, check out
CONTRIBUTING.md
.