PERFORMANCEENGINEERING ESSAY · 7 MIN READ

Zero-copy is a lifetime decision

Notebook dates are an editorial chronology, separate from publication dates.

A copy spends bandwidth to buy independent ownership. Removing it is useful only if the resulting lifetime and retention costs are acceptable.

A copy is a change in responsibility

Suppose an illustrative parser extracts a 40-byte identifier from a 4-megabyte input buffer. Returning a borrowed slice avoids copying those 40 bytes. It also makes the identifier depend on the lifetime of the original buffer. That may be ideal while the caller immediately validates and uses the identifier. It may be disastrous if the identifier is stored for hours and therefore prevents the much larger allocation from being released or reused. The operation count improved while the ownership story became more expensive.

I find the phrase zero-copy incomplete unless it names the boundary. No copy during parsing does not mean no copy during network reception, decompression, serialization or transmission. Nor does moving an owning handle necessarily copy its payload. A useful performance argument identifies which bytes would otherwise move, how often they move and what independence the copy provides. Without that account, zero-copy can become a local score that rewards passing retention costs to another part of the program where they are harder to measure.

Borrow while the owner is naturally nearby

Rust's slice types express a view into existing storage rather than a separately owned collection. That makes a synchronous parse-and-use pipeline a natural place to borrow. The illustrative function below returns the bytes before a delimiter without allocating an output buffer. Its return lifetime is tied to the input, so the caller cannot safely retain that view after the owner disappears. This is a useful constraint: it makes a hidden dependency visible at the function boundary instead of requiring every reviewer to reconstruct it from convention.

The example deliberately works with bytes rather than assuming arbitrary input is valid UTF-8. Turning a byte range into text requires checking the relevant encoding boundary, and indexing a string at an arbitrary byte offset can fail. Zero-copy does not remove validation. If the input representation is unsuitable for the requested output, a transformation may be necessary, and that transformation may require allocation. I would prefer an honest owned result to an unsafe reinterpretation whose main virtue is satisfying a performance slogan.

Illustrative borrowed parser; returning None distinguishes a missing delimiter from an empty first field. rust
fn first_field(input: &[u8]) -> Option<&[u8]> {
    let end = input.iter().position(|&byte| byte == b',')?;
    Some(&input[..end])
}

fn main() {
    let packet = b"sensor-17,42";
    assert_eq!(first_field(packet), Some(&b"sensor-17"[..]));
}

References: [1] The Rust Programming Language: The Slice Type

Shared ownership can retain surprising amounts

The Bytes type supports views that can share underlying storage; cloning such a handle need not duplicate the payload. This is convenient when a buffer crosses asynchronous boundaries and a borrow would be awkward. The convenience changes the lifetime mechanism, not the underlying retention problem. A tiny view can still keep a large backing allocation alive. Whether that happens depends on the particular storage representation and operations, so the relevant documentation and observed ownership path matter more than assuming all cheap slices release unused capacity.

Here is illustrative arithmetic, not a benchmark: retaining one 40-byte view from each of 10,000 separate 4-megabyte buffers can keep roughly 40 gigabytes of backing payload reachable. Copying the selected fields would retain 400,000 bytes of field data, plus allocation and indexing overhead. The copy might therefore reduce total memory dramatically despite increasing bytes moved during extraction. If all 10,000 views refer to one shared buffer, the accounting is completely different. Count distinct retained owners, not merely the number of views.

References: [2] bytes::Bytes documentation

The best boundary may use both approaches

I would usually parse with borrowed views while traversing one input, then make an explicit ownership decision when data escapes that local operation. Fields used only for validation can remain borrowed. Small fields retained in a long-lived index may be copied. Large bodies forwarded intact may justify shared ownership. This hybrid design is not a compromise that failed to achieve zero-copy everywhere. It places the cost where the lifetime changes, which is often exactly where the application gains something from paying it.

An adaptive policy can make that reasoning concrete. In an illustrative cache, copy a selected field when its retained owner is much larger than the field and the expected retention exceeds the request lifetime. Otherwise keep a shared view. The policy needs guardrails: expected lifetime can be wrong, allocation sizes may be rounded, and many small copies can increase allocator pressure. I would expose retained-owner bytes and copied-field bytes as separate measurements, then decide whether the extra branch and bookkeeping earn their complexity under representative workloads.

Buffer reuse makes completion part of correctness

A pooled buffer cannot be reused while a consumer still expects its contents to remain stable. Borrow checking can express this within a well-scoped Rust ownership graph, but external I/O and foreign interfaces may introduce completion rules that the type system does not automatically encode. An operation that has been submitted is not necessarily finished reading its memory. If cancellation stops waiting without cancelling the underlying operation, returning the buffer to the pool can create a correctness bug rather than merely a bad latency result.

I would make the release event explicit in the abstraction: synchronous return, completion notification, reference count reaching zero, or a documented external ownership transfer. The buffer pool should not guess from the lifetime of an unrelated request object. Error and timeout paths deserve particular attention because they often bypass the normal completion path. A design that avoids one copy but requires every caller to remember a subtle release protocol may be more expensive in engineering effort than an owned message with a straightforward lifetime.

Less copying can mean worse access patterns

An application that preserves many disjoint views may later traverse fragmented storage with poor locality. Copying selected fields into a compact representation can improve the next stage's access pattern, reduce the retained working set and simplify vectorized processing. That does not make copying inherently faster. It means the performance boundary must include the consumer whose representation is being chosen. Measuring only extraction time would systematically favour the design that postpones consolidation, even if the downstream operation pays the larger cost repeatedly.

Consider an illustrative pipeline that parses once and scans the extracted numeric fields a thousand times. Spending one linear copy to build a dense array can be reasonable if it avoids repeated traversal of large records. For a pipeline that parses once and forwards once, the same copy may be pure overhead. Input size, reuse count and working-set shape decide the tradeoff. I would compare total useful work, peak retained memory and tail behaviour under concurrent requests rather than declaring a winner from a microbenchmark of slice creation.

Measure retained ownership as well as throughput

A useful experiment records allocation counts, bytes copied, peak resident or heap memory with clearly stated boundaries, buffer-pool pressure and end-to-end completion time. It also includes long-lived consumers and stalled consumers, because those are the cases that reveal lifetime coupling. An illustrative failure test can hold one tiny result while releasing every other handle, then inspect which backing allocations remain reachable. That test does not require a large production incident to establish whether the ownership graph behaves as the design intends.

The counterargument is that copies can be dominant and removing them can be essential. I agree. For large payloads moving through compatible boundaries, avoiding repeated materialization may be the right optimization. My objection is to treating the absence of copying as a complete design criterion. A copy purchases a new ownership boundary, and sometimes that purchase is cheap. I want each avoided copy to come with a clear answer about who now owns the bytes, when that ownership ends and how much memory waits for the answer.

Failure cases can invert the result as well. A slow consumer that retains shared buffers may starve a pool and delay unrelated requests. Copying a small escape value can break that coupling. I would include that scenario when evaluating tail latency, rather than benchmark only consumers that release every view immediately.

Sources and further reading

  1. The Rust Programming Language: The Slice Type

    Explains borrowed views and their relationship to owned storage. The parser and ownership-boundary examples here are original illustrations.

  2. bytes::Bytes documentation

    Documents shared backing storage and slicing. The retention arithmetic and adaptive-copy policy are my own analysis, not measured bytes-crate performance.

FROM THE NOTEBOOK.

Back to all notes