🚄

AF_XDPの動作モード間の処理の違い

2021/12/20に公開

この記事は Linux Advent Calendar 2021 の20日目の記事として書かれました。

はじめに

AF_XDPは高速なパケット処理を行なうために作られたアドレスファミリー(AF_INETやAF_PACKETの一種)です。AF_XDPのソケットを用いて準備したユーザ・カーネル間で共有するメモリ領域とXDPを活用してデバイスドライバと直接やり取りすることで効率的なパケット処理を実現します。

AF_XDPには、XDP_SKBモードとXDP_DRVモードの2つの動作モードが用意され、XDP_DRVモードにはさらにゼロコピーモードとコピーモードが存在します。この記事ではそれらの動作モードが実際にどのように動作するのかをコードを見ながら解説します。

参照したLinuxのバージョンは5.15です。また具体例として参照するデバイスドライバはixgbeです。

AF_XDP概説

AF_XDPはソケットAPIを用いて各種設定やパケット処理の一部を行ないますが、パケット送受信処理のほとんどはソケットAPIを用いず行ないます。

AF_XDPはUMEMと呼ばれる連続メモリ領域をユーザ・カーネル間で共有します。パケット送受信に使うバッファもこのUMEMを用います(コード内ではpoolと呼ばれているようです)。UMEM上にあるデータであれば、ユーザ・カーネル間でコピーなしでやり取りすることができます。逆にデータがこの領域になければコピーが必要になります。

AF_XDPはUMEM上にFILL, COMPLETION, TX, RXの4つのリングを作り、パケットのデータ及び状態を管理します。個々のリングはパケット処理ではお馴染みのSingle-Producer/Single-Consumer queue (SPSC)になっています(ユーザ・カーネルのどちらかが生産者・消費者になる)。それぞれのリングの役割は以下のようになっています。

  • TX: 送信したいデータのバッファをユーザがカーネルに伝える
  • COMPLETION: 送信完了したデータのバッファをカーネルがユーザに伝える
  • FILL: カーネルが受信に使うバッファをユーザがカーネルに伝える
  • RX: 受信したデータのバッファをカーネルがユーザに伝える

アプリケーションプログラムは、tools/lib/bpf/xsk.hで提供されるAPIを用いてリング上のデータを操作し、パケット転送やパケットジェネレータなどを実現します。

アプリケーションプログラムが受信したいパケットはXDP (eBPF)で選別することができます。AF_XDPでは受信したいパケットはBPFマップ(BPF_MAP_TYPE_XSKMAP)にXDPリダイレクトし、そこでパケットをRXリングへの登録したり必要であればデータコピーを行ないます。

AF_XDPのモード指定

AF_XDPの初期化時にモード指定を行ないます。例えば、samples/bpf/xdpsock_user.cというサンプルプログラムには以下のような初期化コードがあります。

samples/bpf/xdpsock_user.c
	cfg.xdp_flags = opt_xdp_flags;
	cfg.bind_flags = opt_xdp_bind_flags;
// snip
	ret = xsk_socket__create(&xsk->xsk, opt_if, opt_queue, umem->umem,
				 rxr, txr, &cfg);

このopt_xdp_flags, opt_xdp_bind_flagsに指定したフラグで実行モードを切り替えます。

例えば、このサンプルプログラムは-Sオプションを指定するとXDP_FLAGS_SKB_MODEフラグとXDP_COPYフラグを追加し、XDP_SKBモードで実行します。

samples/bpf/xdpsock_user.c
		case 'S':
			opt_xdp_flags |= XDP_FLAGS_SKB_MODE;
			opt_xdp_bind_flags |= XDP_COPY;

XDP_SKBモード

XDP_SKBモードはドライバに修正が不要な代わりに性能が低いモードです。送受信共にsk_buffを生成します。このモードにはゼロコピーモードは存在しません。

送信処理

AF_XDPでは送信処理はsendmsg[1]が起点になります。XDP_SKBモードの場合、AF_XDPソケットのsendmsgハンドラであるxsk_sendmsgxsk_generic_xmit関数を呼び出します。

net/xdp/xsk.c
static int xsk_sendmsg(struct socket *sock, struct msghdr *m, size_t total_len)
{
// snip
	pool = xs->pool;
	if (pool->cached_need_wakeup & XDP_WAKEUP_TX)
		return __xsk_sendmsg(sk);
	return 0;
}

static int __xsk_sendmsg(struct sock *sk)
{
// snip
	return xs->zc ? xsk_zc_xmit(xs) : xsk_generic_xmit(sk);
}

xsk_generic_xmitは、TXリングを参照して送信バッファからsk_buffを生成して(xsk_build_skb)、__dev_direct_xmitを使ってドライバに渡します。__dev_direct_xmitは名前の通りドライバを直接呼び出すのでqdiscは通りません。

net/xdp/xsk.c
static int xsk_generic_xmit(struct sock *sk)
{
// snip
	while (xskq_cons_peek_desc(xs->tx, &desc, xs->pool)) {
// snip
		skb = xsk_build_skb(xs, &desc);
// snip
		err = __dev_direct_xmit(skb, xs->queue_id);
// snip
	}
// snip
}

__dev_direct_xmitは最終的にndo_start_xmit、つまりデバイスドライバのパケット送信関数を呼びます。

デバイスドライバから見ると単にsk_buffを渡されるだけなので、AF_XDPに対する修正は不要というわけです。

受信処理

受信処理はGeneric XDPと同じです。ドライバは通常通りパケット受信処理を行なって、ネットワークスタックへパケットを渡す直前でXDPを実行します。具体的には__netif_receive_skb_coreで呼ぶdo_xdp_genericでXDPを実行します。

net/core/dev.c
static int __netif_receive_skb_core(struct sk_buff **pskb, bool pfmemalloc,
				    struct packet_type **ppt_prev)
{
// snip
	if (static_branch_unlikely(&generic_xdp_needed_key)) {
		int ret2;

		migrate_disable();
		ret2 = do_xdp_generic(rcu_dereference(skb->dev->xdp_prog), skb);
		migrate_enable();

		if (ret2 != XDP_PASS) {
			ret = NET_RX_DROP;
			goto out;
		}
	}
// snip
}

XDPリダイレクトでは、sk_buffのデータをUMEMのメモリ領域へコピーして、そのアドレスをRXリングへ登録します。xdp_do_redirectのターゲットがBPF_MAP_TYPE_XSKMAPだった場合、__xsk_map_redirectが呼ばれます。

net/core/filter.c
int xdp_do_redirect(struct net_device *dev, struct xdp_buff *xdp,
		    struct bpf_prog *xdp_prog)
{
// snip
	switch (map_type) {
// snip
	case BPF_MAP_TYPE_XSKMAP:
		err = __xsk_map_redirect(fwd, xdp);
		break;
// snip
}

__xsk_map_redirectでは、ゼロコピーモードでない場合(=メモリモデルがMEM_TYPE_XSK_BUFF_POOLでない場合(後述))、UMEMからメモリを確保し(xsk_buff_alloc)、sk_buffからそのメモリ領域へデータをコピーします(xsk_copy_xdp)。それ以降はゼロコピーモードと同じように、RXリングにデータを登録します(xskq_prod_reserve_desc)。

net/xdp/xsk.c
int __xsk_map_redirect(struct xdp_sock *xs, struct xdp_buff *xdp)
{
// snip
	err = xsk_rcv(xs, xdp);
// snip
}

static int xsk_rcv(struct xdp_sock *xs, struct xdp_buff *xdp)
{
// snip
	if (xdp->rxq->mem.type == MEM_TYPE_XSK_BUFF_POOL) {
		len = xdp->data_end - xdp->data;
		return __xsk_rcv_zc(xs, xdp, len);
	}

	err = __xsk_rcv(xs, xdp);
// snip
}

static int __xsk_rcv(struct xdp_sock *xs, struct xdp_buff *xdp)
{
// snip
	xsk_xdp = xsk_buff_alloc(xs->pool);
// snip
	xsk_copy_xdp(xsk_xdp, xdp, len);
	err = __xsk_rcv_zc(xs, xsk_xdp, len);
// snip
}

static int __xsk_rcv_zc(struct xdp_sock *xs, struct xdp_buff *xdp, u32 len)
{
// snip
	addr = xp_get_handle(xskb);
	err = xskq_prod_reserve_desc(xs->rx, addr, len);
// snip
	xp_release(xskb);
	return 0;
}

このように、XDP_SKBモードの場合、sk_buffの生成およびデータのUMEM領域へのコピーというオーバヘッドあります。

XDP_DRVモード

ドライバがXDPもしくはAF_XDPのための修正をしている場合に利用できるモードです。もしドライバがAF_XDPのための修正をしている場合、効率の良いゼロコピーモードを使うことができます。そうでなくても、XDPをサポートしていれば、コピーモードを使ってXDP_SKBモードより多少効率的にAF_XDPを利用することができます。

ゼロコピーモード送信処理

送信処理はXDP_SKBモードと同様にsendmsgが起点になます。既出のコードで見た通り、ゼロコピーモードの場合はxsk_zc_xmitが呼ばれます。

net/xdp/xsk.c
static int xsk_zc_xmit(struct xdp_sock *xs)
{
	return xsk_wakeup(xs, XDP_WAKEUP_TX);
}

static int xsk_wakeup(struct xdp_sock *xs, u8 flags)
{
// snip
	err = dev->netdev_ops->ndo_xsk_wakeup(dev, xs->queue_id, flags);
// snip
}

見ての通り、xsk_zc_xmitxsk_wakeupを呼び、xsk_wakeupはデバイスドライバのndo_xsk_wakeupを呼ぶだけです。

ixgbeの場合、ndo_xsk_wakeupは単に強制的に割り込みハンドラを起こすだけです。ixgbe_irq_rearm_queuesがデバイスの特定のレジスタに書き込みを行なっています。

drivers/net/ethernet/intel/ixgbe/ixgbe_xsk.c
int ixgbe_xsk_wakeup(struct net_device *dev, u32 qid, u32 flags)
{
// snip
	if (!napi_if_scheduled_mark_missed(&ring->q_vector->napi)) {
		u64 eics = BIT_ULL(ring->q_vector->v_idx);

		ixgbe_irq_rearm_queues(adapter, eics);
	}
// snip
}

割り込みハンドラが呼ばれると、ixgbeはNAPI pollをスケジュールし、実際の送信処理はNAPI poll (ixgbe_poll)の中で行われます[2]

drivers/net/ethernet/intel/ixgbe/ixgbe_main.c
int ixgbe_poll(struct napi_struct *napi, int budget)
{
// snip
	ixgbe_for_each_ring(ring, q_vector->tx) {
		bool wd = ring->xsk_pool ?
			  ixgbe_clean_xdp_tx_irq(q_vector, ring, budget) :
			  ixgbe_clean_tx_irq(q_vector, ring, budget);
// snip
	}
// snip
}

ゼロコピーモードの場合(ring->xsk_poolが非NULLの場合)、ixgbe_clean_xdp_tx_irqが呼ばれ、その先でixgbe_xmit_zcが呼ばれます。

drivers/net/ethernet/intel/ixgbe/ixgbe_xsk.c
static bool ixgbe_xmit_zc(struct ixgbe_ring *xdp_ring, unsigned int budget)
{
// snip
	while (budget-- > 0) {
// snip
		if (!xsk_tx_peek_desc(pool, &desc))
			break;

		dma = xsk_buff_raw_get_dma(pool, desc.addr);
// snip
		tx_desc = IXGBE_TX_DESC(xdp_ring, xdp_ring->next_to_use);
		tx_desc->read.buffer_addr = cpu_to_le64(dma);
// snip
	}
// snip
}

かなり省略していますが、TXリングのディスクリプタのアドレスをixgbeのディスクリプタのバッファのアドレスに設定しているのがわかると思います。

これによりデータコピーなしで送信処理を実現できていることが確認できました。

ゼロコピーモード受信処理

ixgbeの受信処理はixgbe_pollで実行されます。

drivers/net/ethernet/intel/ixgbe/ixgbe_main.c
int ixgbe_poll(struct napi_struct *napi, int budget)
{
// snip
	ixgbe_for_each_ring(ring, q_vector->rx) {
		int cleaned = ring->xsk_pool ?
			      ixgbe_clean_rx_irq_zc(q_vector, ring,
						    per_ring_budget) :
			      ixgbe_clean_rx_irq(q_vector, ring,
						 per_ring_budget);
// snip
	}
// snip
}

ゼロコピーモードの場合は、ixgbe_clean_rx_irq_zcが呼ばれ、この中でXDPを実行します。

drivers/net/ethernet/intel/ixgbe/ixgbe_xsk.c
int ixgbe_clean_rx_irq_zc(struct ixgbe_q_vector *q_vector,
			  struct ixgbe_ring *rx_ring,
			  const int budget)
{
// snip
	while (likely(total_rx_packets < budget)) {
// snip
		xdp_res = ixgbe_run_xdp_zc(adapter, rx_ring, bi->xdp);
// snip
	}
// snip
}

static int ixgbe_run_xdp_zc(struct ixgbe_adapter *adapter,
			    struct ixgbe_ring *rx_ring,
			    struct xdp_buff *xdp)
{
// snip
	xdp_prog = READ_ONCE(rx_ring->xdp_prog);
	act = bpf_prog_run_xdp(xdp_prog, xdp);

	if (likely(act == XDP_REDIRECT)) {
		err = xdp_do_redirect(rx_ring->netdev, xdp, xdp_prog);
		if (err)
			goto out_failure;
		return IXGBE_XDP_REDIR;
	}
// snip
}

XDPリダイレクトの処理内容は、XDP_SKBモードのセクションで見た通り、RXリングに受信データのアドレスとサイズを設定するだけで、コピーは発生しません。

送信処理と同様に受信処理もゼロコピー通信が実現できていることを確認できました。

ゼロコピーモード時のメモリ割り当て

ちょっと寄り道ですが、MEM_TYPE_XSK_BUFF_POOLがどこから来たのを確認してみます。

XDPリダイレクト時に、受信データをコピーしない条件はメモリモデルがMEM_TYPE_XSK_BUFF_POOLだったときでしたが、これはixgbe_configure_rx_ringで(ixgbeの)RXリングを初期化するときに設定されています。

drivers/net/ethernet/intel/ixgbe/ixgbe_main.c
void ixgbe_configure_rx_ring(struct ixgbe_adapter *adapter,
			     struct ixgbe_ring *ring)
{
// snip
	ring->xsk_pool = ixgbe_xsk_pool(adapter, ring);
	if (ring->xsk_pool) {
		WARN_ON(xdp_rxq_info_reg_mem_model(&ring->xdp_rxq,
						   MEM_TYPE_XSK_BUFF_POOL,
						   NULL));
		xsk_pool_set_rxq_info(ring->xsk_pool, &ring->xdp_rxq);
	} else {
		WARN_ON(xdp_rxq_info_reg_mem_model(&ring->xdp_rxq,
						   MEM_TYPE_PAGE_SHARED, NULL));
	}
// snip
	if (ring->xsk_pool)
		ixgbe_alloc_rx_buffers_zc(ring, ixgbe_desc_unused(ring));
	else
		ixgbe_alloc_rx_buffers(ring, ixgbe_desc_unused(ring));
}

ixgbe_xsk_poolはゼロコピーモードの時のみpoolを返すため、MEM_TYPE_XSK_BUFF_POOLのメモリモデルを使うことになります。

ixgbe_alloc_rx_buffers_zcでは、UMEMのメモリ領域(xsk_pool)から割り当てたバッファをixgbeのRXディスクリプタに設定していることがわかります。

drivers/net/ethernet/intel/ixgbe/ixgbe_xsk.c
bool ixgbe_alloc_rx_buffers_zc(struct ixgbe_ring *rx_ring, u16 count)
{
// snip
	rx_desc = IXGBE_RX_DESC(rx_ring, i);
	bi = &rx_ring->rx_buffer_info[i];
	i -= rx_ring->count;

	do {
		bi->xdp = xsk_buff_alloc(rx_ring->xsk_pool);
		if (!bi->xdp) {
			ok = false;
			break;
		}

		dma = xsk_buff_xdp_get_dma(bi->xdp);

		/* Refresh the desc even if buffer_addrs didn't change
		 * because each write-back erases this info.
		 */
		rx_desc->read.pkt_addr = cpu_to_le64(dma);
// snip
	} while (count);
// snip
}

コピーモード送信処理

XDP_SKBモードと同じです。

コピーモード受信処理

XDP_SKBモードと異なりデバイスドライバ処理中にXDPを実行します。

ixgbe_pollで呼ばれる関数はixgbe_clean_rx_irqで、処理内容はixgbe_clean_rx_irq_zcとほぼ同じです。ですが、前述の通りパケット受信に使われるバッファは通常のルーチン(ixgbe_alloc_rx_buffers)で割り当てるので、XDPリダイレクトでRXリングにパケットを登録するときにXDP_SKBモードと同じようにデータのコピーが必要です。

データのコピーは必要になりますが、sk_buffを生成しない + XDPを実行するまでが早い分、XDP_SKBモードに比べてオーバヘッドが少ないことがわかりました。

おわりに

駆け足ですが、それぞれの動作モードにおけるパケット処理の違いを見ていきました。

XDP_SKBモードはsk_buff生成とデータコピーのオーバヘッドがあるため、パケット処理性能はXDP_DRVモードに比べて低そうなことがわかりました。またXDP_DRVモードであってもコピーモードの場合はXDP_SKBモードとそれほど処理内容が変わらないこともわかりました。AF_XDPを使って高い性能を得るにはゼロコピーモードを使う必要があるようです。

AF_XDPの動作の大きな流れは把握できたと思いますので、今後AF_XDPを使ったり改造したりする人の助けになれば幸いです。

参考文献

脚注
  1. sendmsgシステムコールではなく、struct proto_opssendmsgハンドラのこと。 ↩︎

  2. ixgbeの送信処理が常にNAPI pollで行なわれるというわけではないようです。実際、ndo_start_xmitによる送信処理は地続きで実行されます。 ↩︎

Discussion