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 of Packet.data into ParsedPacket.payload (udp.payload().to_vec()).
  • parse_sip() copied the payload again into SipMessage.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 their Packet inside the reassembler.
  • SipMessages outlive everything inside the dialog store.
  • Borrowed payloads would force the backing buffer's lifetime onto every downstream structure.
    • Also, SipMessage.raw borrowing from a sibling field is a self-referential struct.

Design: refcounted slices (bytes::Bytes)

  • Packet.data: Bytes — one Vec -> Bytes conversion 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).

  • Bytes derefs to [u8].

    • So consumers that read &pp.payload compile unchanged.
  • Only construction sites changed.

  • Buffers free when the last clone drops.

    • A stored SipMessage keeps its backing frame alive.
    • That is the same memory the old design held as an owned copy.

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 ns
  • payload_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_160b is ~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/.body to 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.

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 --cores reader 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 read into libpcap's buffer and a copy out of it.
    • This is because next_packet invalidates what it returned on the next call.
  • 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 1 and a run with no --cores used the single-threaded reader in crate::capture::file through 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 --cores went from 0.95M to 1.16M pkts/s.
  • On one core the gain is not the read the mapping saves.
    • The gain is what libpcap's path cost per packet:
      • glibc's fread lock (9% of cycles in perf)
      • a fresh allocation per frame
    • The shared 64 KiB blocks replace those.

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_POPULATE plus MADV_SEQUENTIAL changed 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 > snaplen is 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".