Skip to content
LinkState
Go back

veth is not free just because it is virtual

Introduction to veth Performance

A virtual Ethernet (veth) pair acts like a software pipe: transmitting on one end delivers the packet to the receive queue of its peer. Each end is a regular struct net_device with its own queues, NAPI structures, and GRO cells. The veth driver implements the standard net_device ops but does not offload work to hardware; all processing happens in the kernel’s software datapath.

Common Misconceptions


Understanding veth Overhead

SKB Allocation and Deallocation

When an application sends data through a socket bound to a veth interface, the kernel creates an skb via alloc_skb() (or __alloc_skb() for DMA‑aware allocations). The allocation draws from the skbuff_head_cache slab, backed by the general page allocator.

Allocation steps

  1. Allocate a struct sk_buff (~256 B on x86_64) from the slab.
  2. Allocate a data buffer of size len + sizeof(struct skb_shared_info) (via kmalloc or page allocation if > PAGE_SIZE).
  3. Initialize skb->head, skb->data, skb->tail, skb->end, and set the reference count to 1.
  4. Set skb->dev to the sending net_device (the veth end).

In the veth transmit path (veth_xmit):

skb = skb_clone(skb, GFP_ATOMIC);
if (!skb)
    return NETDEV_TX_BUSY;
skb_orphan(skb);
skb->dev = veth->peer;
netif_rx(skb);
return NETDEV_TX_OK;

Each outbound packet therefore incurs one skb_clone (allocates a new skb sharing the data buffer) and an skb_orphan (makes the clone the sole owner). The original skb is freed when the caller’s reference count drops, so each packet results in two allocations and two frees (one pair per veth end).

Deallocation
After the peer’s receive path processes the cloned skb, it calls consume_skb() or kfree_skb():

Queueing and Buffering

veth does not implement its own queueing discipline; it relies on the peer’s qdisc for transmission scheduling. By default each veth end inherits the root qdisc of its network namespace (usually noop or pfifo_fast if a default qdisc is set). The txqueuelen controls the size of the netdev’s transmission queue (dev->tx_queue_len). When the queue fills, netif_tx_stop_queue() is called and subsequent ndo_start_xmit returns NETDEV_TX_BUSY.

Key points:

Buffering strategies


GRO Boundaries and Their Impact

What are GRO Boundaries

Generic Receive Offload (GRO) merges multiple incoming skbs into a larger skb to reduce per‑packet processing overhead. A GRO boundary occurs when the GRO engine cannot merge an incoming skb with the current aggregation because of incompatibility (different flow hash, VLAN tag, TCP flags, checksum offload status, or exceeding gro_max_size). When a boundary is hit, the engine flushes the aggregated skb up the stack and starts a new aggregation.

In veth, each end maintains its own struct gro_cells (one per NAPI instance). The GRO decision is made after the veth receive path (veth_gro_receive) but before the skb is delivered to higher layers (IP, TCP, etc.).

How GRO Boundaries Affect Performance

When a boundary is crossed:

  1. The current aggregated skb is passed to netif_receive_skb() (or netif_rx() if GRO is disabled), incurring header adjustments, checksum verification, and protocol processing.
  2. A new aggregation starts with the incoming skb.
  3. The flush incurs the full cost of processing a regular packet (IP/TCP/UDP header parsing, socket lookup, etc.) for each skb in the flushed aggregate.

If traffic patterns cause frequent boundaries (mixed TCP/UDP, VLAN tag changes, asymmetric checksum offload), the GRO benefit diminishes and CPU cost approaches that of processing each packet individually.

GRO Boundary Optimization


Per‑Packet CPU Work and Its Effects

Receive Path CPU Work

When a cloned skb arrives at the peer’s veth receive path:

  1. netif_rx() (or netif_receive_skb() if NAPI is active) places the skb onto the per‑CPU backlog.
  2. NAPI poll (napi_poll) invokes veth_gro_receive():
    • Computes a flow hash (skb_get_hash()).
    • Checks GRO compatibility (same hash, same IP proto, same TCP flags, etc.).
    • If compatible, calls gro_receive() to merge the skb into the current aggregation.
    • If incompatible, triggers a GRO boundary flush as described above.

Each step involves reference‑count manipulation, hash calculations, and conditional branching, all of which consume CPU cycles. The cumulative cost per packet—especially when GRO boundaries are frequent—sets the practical ceiling for veth‑based throughput, far above the negligible cost implied by the “zero‑cost plumbing” myth.


Share this post on:

Previous Post
Parser accepted it but runtime behavior still broke
Next Post
Intended Attach Graph vs the Programs Actually Live