I went looking for a leak in the ext4 builder and found three perfectly respectable representations of the same data.
The memory graph had climbed past 3.9 GiB while MVM was building a 1 GiB root filesystem. My first reaction was that the number had to be misleading.
The builder was pure Rust and deterministic. It didn’t shell out to filesystem tools, mount a loop device, or stage a second directory tree. It walked the source, planned the filesystem, and emitted an image.
And it was still holding almost four copies’ worth of memory at the peak.
The allocator wasn’t doing anything mysterious. The code was doing exactly what its ownership model asked it to do, which was more expensive than the API made obvious.
Three copies, all of them legitimate
The source walk produced a Vec<Node>. Each file node owned its bytes:
enum Node {
Dir { path: String, mode: u32 },
File { path: String, mode: u32, data: Vec<u8> },
Symlink { path: String, target: String },
}
The planner accepted a borrowed slice. With only &[Node] in hand it could look at the file data, but it couldn’t move those Vec<u8> allocations into the plan.
So it cloned them.
Then the emitter allocated the final image.
At the worst point in the pipeline, the process held the source payload, a second payload inside the layout plan, and the output image:
flowchart LR
S[Source walk<br/>file bytes] -->|clone| P[Layout plan<br/>same file bytes]
P --> I[Ext4 image buffer]
S -. still alive .-> I
The rough model was:
Once I looked at the lifetime of those three representations instead of the cleanliness of each function, the 3.9 GiB stopped being surprising.
It was the signature, not the clone
It would have been easy to write this up as “remove a clone().” That wasn’t quite true.
The public API encoded the need for the clone:
pub fn build_image(nodes: &[Node]) -> Result<Vec<u8>, Ext4Error>
That signature promises the caller retains ownership and the builder only borrows the source representation. The implementation couldn’t reuse the caller’s file buffers, because the type system correctly prevented it from taking them.
The node list had one purpose, though: build one image. No caller needed it afterward.
The more honest API was consuming:
pub fn build_image(nodes: Vec<Node>) -> Result<Vec<u8>, Ext4Error>
Once the builder owned the vector, it could move each file’s bytes into the planned inode instead of duplicating them.
That one change forced every caller to answer a useful question: Do you really need this intermediate representation after the build?
In our case, the answer was no.
Moving the bytes exposed a second walk
The original implementation revisited the node list in a later pass to build parent-child directory entries. Consuming the nodes meant that convenient second walk no longer existed.
For a moment the clone looked like the simplest way to preserve the old control flow.
Instead, the consuming pass records only the structural information the later phase needs:
struct ChildEdge {
parent: u32,
name: String,
child: u32,
file_type: u8,
path: String,
}
The large payload moves into the planned inode. The small directory relationship becomes a compact edge record.
flowchart TB
N[Owned Node] -->|move file bytes| P[Planned inode]
N -->|record relationship| E[ChildEdge]
P --> B[Block layout]
E --> D[Directory entries]
B --> O[Ext4 image]
D --> O
A later phase shouldn’t need the whole rich source object graph just because the earlier implementation kept it around. If a pipeline consumes a representation, it should carry forward the minimal facts the next phase actually needs.
Normalizing once was enough
The ownership rewrite exposed another piece of repeated work.
Paths were being normalized during sorting, inode assignment, and parent lookup. Once the builder owned the nodes, it could normalize each path once and keep the result beside the node:
Vec<(String, Node)>
The vector is sorted by the normalized string, and later phases reuse it.
This wasn’t the main memory win, but it made the pipeline easier to understand. The data structure started to match the actual job: one canonical path, one owned node, one consuming traversal.
When a performance change also removes repeated interpretation of the same input, that’s usually a sign the data flow is becoming more honest.
Where the memory went
On the same host and inputs, peak resident memory moved like this:
| Source tree | Before | After |
|---|---|---|
| 256 MiB | 4.12× | 2.67× |
| 512 MiB | 4.10× | 2.64× |
| 1 GiB | 3.83× | 2.46× |
For the 1 GiB tree:
before: 3926 MiB
after: 2522 MiB
That’s a large improvement, but it isn’t a zero-copy pipeline.
The source walk still owns the input bytes before they move. The final ext4 image still has to exist in memory. Metadata, allocator state, and temporary structures add overhead. As long as the builder returns a complete in-memory image, peak usage can’t approach the source size alone.
The remaining 2.46× has an explanation. I’d rather keep that explanation visible than turn a successful optimization into a claim that the memory problem is finished.
Where I stopped optimizing
Once a profile improves that much, it’s hard to stop.
We could pre-size the output buffer more aggressively, pack the planner into tighter structures, stream the source walk, or emit sparse extents directly to a file. Some of those may eventually be worthwhile.
They weren’t automatically the next change.
After the duplicate payload disappeared, the remaining buffer growth was no longer the dominant cost. More elaborate pre-sizing would have added complexity around a much smaller effect. Streaming the image would require a larger API and storage redesign.
The right next optimization should come from another profile, not from the emotional momentum of a good result.
That restraint is part of performance engineering too.
Ownership turned out to be a performance tool
Rust’s ownership model usually gets introduced as the mechanism that prevents use-after-free and data races.
Here it was an architectural performance tool.
These two signatures describe different lifetime graphs:
fn plan(nodes: &[Node]) -> Plan
fn plan(nodes: Vec<Node>) -> Plan
The borrowed version says the source and the plan must be able to coexist. The consuming version lets the plan become the source.
For a large single-use intermediate representation, that difference can be several gigabytes.
The type system didn’t create the memory problem. It made the cost of our API contract explicit once we looked at the right level.
Almost behavior-neutral
The new pipeline normalizes paths while consuming the input list. As a result, an invalid path may now be reported in input order rather than after a later sorted pass.
The same inputs are rejected, but when multiple errors are possible, the first one observed can differ.
That’s a small behavior change. It’s still worth documenting.
Performance refactors often change when work happens, and therefore when failures are discovered. Calling them “purely internal” can hide exactly the details a reviewer needs to reason about.
The builder also had a structural capacity fallback for very large ext4 layouts, but that was never a host-memory limit. A theoretical filesystem-size ceiling says nothing about whether the process can construct the image within its available RAM.
Format capacity and working-set capacity are separate constraints. The profiler was measuring the one that mattered to the user’s machine.
What I’d do differently now
I’d make ownership part of the pipeline design much earlier.
The node list was an intermediate representation with one consumer. Borrowing it looked flexible, but that flexibility required the full source tree to stay alive while another full representation was built beside it.
Try the ownership experiment
When a build path has one consumer, compare these two signatures:
fn build_image(nodes: &[Node]) -> Result<Vec<u8>, Error>
fn build_image(nodes: Vec<Node>) -> Result<Vec<u8>, Error>
Then measure peak memory while building the same image. The second signature isn’t automatically better, but it makes the ownership question impossible to hide. If the caller needs the nodes afterward, keep the borrowing API. If not, stop paying to keep them alive.
The fix wasn’t “be more careful with clones.” The planner had been designed as though the source representation needed to survive it. Once the builder owned the one-shot input, the memory graph looked much more like the work itself.
What I take from this is narrow enough to use on the next pipeline: when an intermediate representation has exactly one consumer, don’t let the type say “shared.” &[Node] and Vec<Node> differ by a single word, and that word was deciding whether two full copies of a tree had to be alive at the same time.