The memory graph climbed past 3.9 GiB while MVM was building a 1 GiB root filesystem. I went looking for a leak and found three perfectly respectable representations of the same data.

My first reaction was that the number had to be misleading. The ext4 builder was pure Rust and deterministic. It did not 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.

The process was still holding almost four copies’ worth of memory at the peak.

The allocator was not doing something mysterious. The code was doing exactly what its ownership model asked it to do, which was more expensive than the API made obvious.

The copies were clean, reasonable, and expensive

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. Because it only had &[Node], it could inspect the file data but could not move those Vec<u8> allocations into the plan. 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:

MpeakMwalk+Mplan+Mimage+MoverheadM_{\text{peak}} \approx M_{\text{walk}} + M_{\text{plan}} + M_{\text{image}} + M_{\text{overhead}}

Once I looked at the lifetime of those three representations rather than the cleanliness of each function, the 3.9 GiB stopped being surprising.

The signature was the real optimization bug

It would have been easy to describe this as “remove a clone().” That was not quite true.

The public API encoded the need for the clone:

pub fn build_image(nodes: &[Node]) -> Result<Vec<u8>, Ext4Error>

That signature promises that the caller retains ownership and that the builder only borrows the source representation. The implementation could not 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 the shape of the pipeline

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 now 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

This was the more important lesson than the missing clone. A later phase should not require the entire rich source object graph merely 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.

One normalization pass 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 was not 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 is usually a sign that the data flow is becoming more honest.

The measurement changed by gigabytes

On the same host and inputs, peak resident memory moved like this:

Source treeBeforeAfter
256 MiB4.12×2.67×
512 MiB4.10×2.64×
1 GiB3.83×2.46×

For the 1 GiB tree:

before: 3926 MiB
after:  2522 MiB

That is a large improvement, but it is not 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 cannot approach the source size alone.

The remaining 2.46× has an explanation. I would rather keep that explanation visible than turn a successful optimization into a claim that the memory problem is finished.

The temptation to keep optimizing

Once a profile improves that much, it is 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 were not 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 is not only about safety

Rust’s ownership model is usually 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 that the source and plan must be able to coexist. The consuming version allows the plan to become the source.

For a large single-use intermediate representation, that difference can be several gigabytes.

The type system did not create the memory problem. It made the cost of our API contract explicit once we looked at the right level.

The refactor was not perfectly 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 is a small behavior change. It is 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 would design differently now

I would 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 is not 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.

For a one-shot build path, consuming APIs are often the more honest APIs. They show where responsibility moves, and they let the implementation reuse the storage that has already been paid for.

The fix was not “be more careful with clones.” It was to stop designing the planner 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.


Next: Nuclear Cleanup Wasn’t Nuclear