Internals Zero Copy Payloads - NormB/sipnab GitHub Wiki
Problem
Every captured frame paid two heap copies on the hot path:
parse_packet()copied the transport payload out ofPacket.dataintoParsedPacket.payload(udp.payload().to_vec()).parse_sip()copied the payload again intoSipMessage.raw(data.to_vec()).
At capture rates this is thousands of allocations/second. They exist only to move ownership.
Why not lifetimes
The obvious ParsedPacket<'a> borrowing from Packet.data does not fit
the architecture.
Packets cross a crossbeam channel from the capture thread to the processing thread.ParsedPackets outlive theirPacketinside the reassembler.SipMessages outlive everything inside the dialog store.- Borrowed payloads would force the backing buffer's lifetime
onto every downstream structure.
- Also,
SipMessage.rawborrowing from a sibling field is a self-referential struct.
- Also,
Design: refcounted slices (bytes::Bytes)
-
Packet.data: Bytes— oneVec -> Bytesconversion at capture time (zero-copy take-over of the allocation). -
ParsedPacket.payload: Bytes—data.slice(range): refcount bump + offset, no copy.- Reassembled datagrams genuinely build new buffers.
- They become
Bytes::from(vec)— still no extra copy.
-
SipMessage.raw: Bytes— shares the same backing buffer (payload.clone()is a refcount bump). -
Bytesderefs to[u8].- So consumers that read
&pp.payloadcompile unchanged.
- So consumers that read
-
Only construction sites changed.
-
Buffers free when the last clone drops.
- A stored
SipMessagekeeps its backing frame alive. - That is the same memory the old design held as an owned copy.
- A stored
Measured (criterion, dev host)
This is a same-binary A/B. It isolates the changed operation on a 160-byte payload:
payload_slice_zero_copy(Bytes::slice): 15.6 nspayload_copy_to_vec(heap copy): 15.1 ns
Honest conclusion:
- At typical SIP/RTP packet sizes the heap copy was already as cheap as the refcounted slice.
- The analysis claim of a 20-30% hot-path win did not hold.
- The change costs nothing measurable on the single-threaded hot path.
packet_decap/eth_ipv4_udp_160bis ~127 ns total either way.- That is within environment noise on a loaded host.
What the design actually buys:
- Large payloads stop costing linear copies.
- A TCP-reassembled 64 KB SIP message or max-size HEP packet is a ~1-2 microsecond copy.
- The slice stays ~15 ns.
- No per-packet allocate/free pair crosses the capture -> processing
thread boundary.
- Cross-thread free is the allocator's worst case.
- It is invisible to a single-threaded benchmark.
- It enables
SipMessage.raw/.bodyto share the packet buffer.- Done:
parse_sip_bytes. - It removes the second copy of every SIP message.
- It makes
SipMessage::clone(dialog-store insertion) copy-free.
- Done:
The same lesson, one stage earlier: the offline reader
crate::capture::mapped applies this to the frame itself.
The result is worth recording, because the obvious version of it is a regression.
- The offline
--coresreader is one serial thread.- It does read, copy and host-pair-peek for every packet while N workers wait.
- Reading through libpcap costs it a
readinto libpcap's buffer and a copy out of it.- This is because
next_packetinvalidates what it returned on the next call.
- This is because
- Mapping the file removes the first of those.
Measured on the 535k corpus (median-of-7, idle host, same binary either way via
SIPNAB_NO_MMAP):
| cores | mapped | libpcap | |
|---|---|---|---|
| 1 | 1.59M | 1.60M | — |
| 2 | 2.19M | 2.19M | — |
| 4 | 3.27M | 2.38M | +37% |
| 8 | 3.30M | 2.18M | +51% |
Read the one-core row as a control.
- At the time of that measurement,
--cores 1and a run with no--coresused the single-threaded reader incrate::capture::filethrough libpcap.- So that row is libpcap against itself.
- Its spread is the harness noise floor.
- The gain arrives at four cores.
- That is the point where the serial reader is what the workers wait on.
Since 2026-10-11 the single-threaded reader maps too.
It uses the same crate::capture::frames::Frames the --cores reader uses.
- Corpus: a 535k-packet corpus of 5,000 calls from
bench/carrier.py(median-of-5, two sessions, idle host). - A run with no
--coreswent from 0.95M to 1.16M pkts/s. - On one core the gain is not the
readthe mapping saves.- The gain is what libpcap's path cost per packet:
- glibc's
freadlock (9% of cycles inperf) - a fresh allocation per frame
- glibc's
- The shared 64 KiB blocks replace those.
- The gain is what libpcap's path cost per packet:
What failed, and why it looked right.
-
The natural design hands each frame out as a refcounted slice of the mapping.
- It copies nothing at all.
- It measures 1.88M against libpcap's 2.33M at four cores.
-
One mapping is one atomic refcount.
- So 535k frames cloned by the reader and dropped by the workers push ~1M atomic updates through a single cache line.
- That is free on one core, 21% on four.
-
An ablation that removed only the copy predicted +10.5%. It was wrong.
- It aliased one small buffer that stayed in cache.
- It measured no memory traffic, not no copy.
-
Two further guesses also failed.
MAP_POPULATEplusMADV_SEQUENTIALchanged nothing.- So page faults were not the cost either.
What made the mapping pay was keeping the block copy. It spreads refcounting across one counter per ~270 frames.
- So the conclusion of this document holds at every stage measured so far: at these packet sizes the copy is not the cost. The cross-thread refcount and allocator traffic is.
- A change that removes a copy and adds sharing is not obviously a win.
- It needs measuring at more than one core count.
- The whole effect is invisible at one.
The mapping must do two things that libpcap did for free. Comparing against it over the real corpus, not against fixtures, found both.
- Read snapped captures.
orig_len > snaplenis the definition of snapping, not corruption.- So the strict record parser rules out too much.
- Report a truncated file.
- Stopping quietly at the cut turns "this capture is incomplete" into "read in full".