AF_XDPの動作モード間の処理の違い
この記事は 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
というサンプルプログラムには以下のような初期化コードがあります。
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モードで実行します。
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_sendmsg
はxsk_generic_xmit
関数を呼び出します。
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は通りません。
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を実行します。
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
が呼ばれます。
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
)。
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
が呼ばれます。
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_xmit
はxsk_wakeup
を呼び、xsk_wakeup
はデバイスドライバのndo_xsk_wakeup
を呼ぶだけです。
ixgbeの場合、ndo_xsk_wakeup
は単に強制的に割り込みハンドラを起こすだけです。ixgbe_irq_rearm_queues
がデバイスの特定のレジスタに書き込みを行なっています。
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]。
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
が呼ばれます。
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
で実行されます。
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を実行します。
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リングを初期化するときに設定されています。
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ディスクリプタに設定していることがわかります。
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を使ったり改造したりする人の助けになれば幸いです。
Discussion