RX/TX 심화 & 오프로드 (net_device)
net_device 데이터 경로의 고성능 구현과 오프로드(Offload) 기능을 심층 분석합니다. NAPI 기반 RX/TX 경로, BQL/LLTX 전송 제어, XDP/AF_XDP, 멀티큐·RSS, page_pool, TC 오프로드, busy poll, RT NAPI까지 다룹니다.
핵심 요약
- NAPI — 인터럽트(Interrupt) 폭풍을 막는 폴링(Polling) 기반 RX 처리 모델입니다.
- TX 큐/BQL — 버퍼(Buffer)블로트(Bufferbloat)를 막고 지연(latency)을 낮춥니다.
- XDP/AF_XDP — 드라이버 단에서 고속 패킷(Packet) 처리를 수행합니다.
- page_pool — RX 페이지 재활용으로 캐시(Cache) 효율을 높입니다.
- 오프로드 — checksum/GRO/TC 등을 하드웨어로 위임합니다.
단계별 이해
- RX 경로 이해
IRQ → NAPI poll → skb 전달 흐름을 고정합니다. - TX 경로 이해
전송 요청부터 완료 인터럽트까지 추적합니다. - 큐 제어 적용
BQL/LLTX로 큐 정지·재개 조건을 설계합니다. - 오프로드/고속 경로 확장
XDP, page_pool, TC 오프로드를 순차 적용합니다.
RX 경로: IRQ, NAPI, budget 처리
수신 경로는 인터럽트 기반 진입 후 NAPI poll로 전환하는 모델이 표준입니다. 드라이버는 budget를 존중하며 완료 시 napi_complete_done()를 호출해야 합니다.
/* 개념 예시: IRQ top-half와 NAPI poll 연계 */
static irqreturn_t my_irq_handler(int irq, void *data)
{
struct my_priv *priv = data;
my_mask_rx_irq(priv);
napi_schedule_irqoff(&priv->napi);
return IRQ_HANDLED;
}
static int my_napi_poll(struct napi_struct *napi, int budget)
{
struct my_priv *priv = container_of(napi, struct my_priv, napi);
int work_done = 0;
while (work_done < budget) {
struct sk_buff *skb = my_rx_one_skb(priv);
if (!skb)
break;
skb->protocol = eth_type_trans(skb, priv->ndev);
napi_gro_receive(napi, skb);
work_done++;
}
if (work_done < budget) {
napi_complete_done(napi, work_done);
my_unmask_rx_irq(priv);
}
return work_done;
}
napi_gro_receive() 경로와 page recycling 전략을 함께 설계해야 합니다.
고속 NIC에서는 RX ring refill 정책이 drop/jitter를 크게 좌우합니다.
RX 전체 파이프라인(Pipeline)
아래 다이어그램은 물리 와이어 수신부터 애플리케이션 소켓(Socket) 버퍼 도달까지의 전체 수신 경로를 보여줍니다. 각 단계에서 일어나는 핵심 동작에 주의하세요.
GRO (Generic Receive Offload) 동작 원리
GRO는 네트워크 스택(Network Stack) 진입 전에 동일 flow의 연속 패킷들을 하나의 대형 sk_buff로 집계(merge)하여
프로토콜 스택 처리 횟수를 줄이는 소프트웨어 오프로드 기술입니다. 하드웨어 LRO(Large Receive Offload)와 달리
GRO는 프로토콜 레이어에서 stateless하게 동작하여 포워딩 환경에서도 안전하게 사용할 수 있습니다.
GRO 집계 과정은 다음과 같습니다:
- NAPI poll에서
napi_gro_receive()호출 시, GRO 엔진은napi→gro_hash[]테이블에서 동일 flow를 검색합니다. - 매칭되는 flow가 있으면 현재 패킷의 payload를 기존 skb의
frag_list또는frags[]에 병합합니다. - flow가 완료되거나(PSH, FIN 등) 타이머(Timer)/카운트 제한에 도달하면 집계된 대형 skb를
netif_receive_skb()로 전달합니다. - 매칭되지 않으면 새 flow 엔트리를 생성하거나 즉시 전달합니다.
| 비교 항목 | LRO (Large Receive Offload) | GRO (Generic Receive Offload) |
|---|---|---|
| 구현 위치 | NIC 하드웨어 또는 드라이버 | 커널 네트워크 스택 (dev_gro_receive) |
| 상태 관리 | Stateful — TCP 헤더를 재작성 | Stateless — 원본 헤더 보존 |
| 포워딩 호환 | 불가 — 재작성된 헤더로 인해 checksum/시퀀스 불일치 | 가능 — GSO로 재분할 시 원본 복원 |
| 프로토콜 지원 | TCP만 (일반적) | TCP, UDP, GRE, VXLAN 등 확장 가능 |
| GSO 연계 | 없음 | GRO → GSO 대칭 구조로 설계됨 |
| 커널 권장 | 비권장 (ethtool -K eth0 lro off) | 기본 활성 (ethtool -K eth0 gro on) |
/* GRO 수신 경로 핵심 흐름 (개념 예시) */
gro_result_t napi_gro_receive(struct napi_struct *napi, struct sk_buff *skb)
{
gro_result_t ret;
skb_gro_reset_offset(skb);
ret = dev_gro_receive(napi, skb); /* flow 매칭 + 병합 시도 */
switch (ret) {
case GRO_MERGED:
case GRO_MERGED_FREE:
break; /* 병합 성공, skb 보관 */
case GRO_HELD:
break; /* 새 flow로 보관 */
case GRO_NORMAL:
gro_normal_one(napi, skb, 1); /* GRO 불가, 즉시 전달 */
break;
case GRO_CONSUMED:
break;
}
return ret;
}
/* NAPI poll 종료 시 GRO flush — 보관 중인 flow를 모두 전달 */
void napi_complete_done(struct napi_struct *napi, int work_done)
{
gro_normal_list(napi); /* 버퍼링된 GRO 패킷 일괄 전달 */
/* ... napi state 전환, IRQ 재활성화 ... */
}
ethtool -K eth0 gro off를 고려하세요.
반대로, 벌크 TCP 전송(파일 서버, 스트리밍)에서는 GRO를 반드시 활성화해야 CPU 사용률이 크게 줄어듭니다.
NAPI 상태 머신
NAPI는 명확한 상태 전환 규약을 가진 상태 머신으로 동작합니다. 드라이버가 이 규약을 정확히 따르지 않으면 인터럽트 누수(IRQ 재활성화 누락)나 poll 미진입 같은 심각한 버그가 발생합니다.
| 상태 플래그 | 비트 | 의미 |
|---|---|---|
NAPI_STATE_SCHED | 0 | NAPI가 poll 목록에 스케줄됨. napi_schedule()가 설정, napi_complete_done()이 해제 |
NAPI_STATE_DISABLE | 1 | napi_disable() 진행 중. SCHED 비트 해제를 spin-wait |
NAPI_STATE_NPSVC | 2 | busy polling 서비스 중 표시. poll이 non-softirq 컨텍스트에서 실행됨을 나타냄 |
NAPI_STATE_LISTED | 3 | NAPI가 디바이스의 napi_list에 등록됨 (netif_napi_add()가 설정) |
NAPI_STATE_NO_BUSY_POLL | 4 | busy polling 비활성화 표시 (드라이버가 미지원 선언) |
NAPI_STATE_IN_BUSY_POLL | 5 | 현재 busy poll 실행 중 (re-entrant 방지) |
NAPI_STATE_PREFER_BUSY_POLL | 6 | 소켓이 busy poll 선호 표시 (SO_PREFER_BUSY_POLL) |
NAPI_STATE_THREADED | 7 | threaded NAPI 모드 활성 — 전용 커널 스레드(Kernel Thread)에서 poll 실행 |
NAPI_STATE_SCHED_THREADED | 8 | threaded NAPI에서 스케줄됨 표시 |
/* NAPI enable/disable 올바른 순서 (개념 예시) */
/* === ndo_open: 활성화 순서 === */
static int my_open(struct net_device *ndev)
{
struct my_priv *priv = netdev_priv(ndev);
/* 1. 하드웨어 초기화 (ring 할당, DMA 설정) */
my_hw_init(priv);
/* 2. NAPI 활성화 — 이 시점부터 napi_schedule 가능 */
napi_enable(&priv->napi);
/* 3. IRQ 등록 — NAPI enable 후에 해야 schedule이 동작 */
request_irq(priv->irq, my_irq_handler, 0, "mynic", priv);
/* 4. TX 큐 시작 */
netif_tx_start_all_queues(ndev);
return 0;
}
/* === ndo_stop: 비활성화 순서 === */
static int my_stop(struct net_device *ndev)
{
struct my_priv *priv = netdev_priv(ndev);
/* 1. TX 큐 정지 — 새 xmit 진입 차단 */
netif_tx_disable(ndev);
/* 2. IRQ 비활성화 — 새 napi_schedule 차단 */
disable_irq(priv->irq);
/* 3. napi_disable — 진행 중인 poll 완료를 대기 (barrier 역할) */
napi_disable(&priv->napi);
/* 이 시점 이후 poll 콜백이 절대 실행되지 않음을 보장 */
/* 4. IRQ 해제 */
free_irq(priv->irq, priv);
/* 5. 하드웨어 정리 (ring 해제, DMA 해제) */
my_hw_cleanup(priv);
return 0;
}
napi_disable()은 내부적으로 NAPI_STATE_SCHED 비트를 spin-wait하며, 진행 중인 poll이
napi_complete_done()을 호출해 SCHED를 해제할 때까지 대기합니다. 따라서 napi_disable() 반환 후에는
poll 콜백이 절대 실행되지 않음이 보장되며, 이후 ring 메모리를 안전하게 해제할 수 있습니다.
반드시 IRQ 비활성화 후, ring 해제 전에 호출해야 합니다.
echo 1 > /sys/class/net/eth0/threaded 또는 드라이버에서 dev_set_threaded(ndev, true)를 호출하면
NAPI poll이 softirq 대신 전용 커널 스레드(napi/eth0-N)에서 실행됩니다.
이 모드의 주요 장점:
- RT 커널 호환: softirq는 PREEMPT_RT에서 스레드화되지만 우선순위(Priority) 제어가 어렵습니다. threaded NAPI는
chrt로 직접 스케줄링 정책 설정 가능 - CPU 격리(Isolation):
taskset으로 NAPI 스레드(Thread)를 특정 코어에 바인딩하여 데이터플레인/컨트롤플레인 분리 가능 - cgroup 통합: NAPI 스레드를 cgroup에 배치하여 CPU/메모리 자원 제한 가능
TX 경로: ndo_start_xmit, 큐 정지/재개, BQL
송신 경로의 핵심은 링 용량 관리입니다. TX ring이 포화됐을 때는 NETDEV_TX_BUSY를 남발하지 말고 queue stop/wake 모델을 일관되게 유지해야 합니다.
/* 개념 예시: 멀티큐 TX stop/wake + BQL 경로 */
static netdev_tx_t my_ndo_start_xmit(struct sk_buff *skb, struct net_device *ndev)
{
struct my_priv *priv = netdev_priv(ndev);
struct netdev_queue *txq = netdev_get_tx_queue(ndev, skb_get_queue_mapping(skb));
unsigned long flags;
spin_lock_irqsave(&priv->tx_lock, flags);
if (my_tx_ring_avail(priv) < MAX_SKB_FRAGS + 2) {
netif_tx_stop_queue(txq);
spin_unlock_irqrestore(&priv->tx_lock, flags);
return NETDEV_TX_BUSY;
}
my_map_skb_to_tx_desc(priv, skb);
netdev_tx_sent_queue(txq, skb->len);
my_ring_doorbell(priv);
spin_unlock_irqrestore(&priv->tx_lock, flags);
return NETDEV_TX_OK;
}
static void my_tx_complete(struct my_priv *priv, u16 qid)
{
struct netdev_queue *txq = netdev_get_tx_queue(priv->ndev, qid);
u32 bytes = 0, pkts = 0;
my_reclaim_tx_desc(priv, &bytes, &pkts);
netdev_tx_completed_queue(txq, pkts, bytes);
if (netif_tx_queue_stopped(txq) && my_tx_ring_avail(priv) > 64)
netif_tx_wake_queue(txq);
}
TX 전체 파이프라인
아래 다이어그램은 유저스페이스 send() 호출부터 물리 와이어 송출, TX 완료 인터럽트, 디스크립터 회수까지의 전체 송신 경로를 보여줍니다.
BQL (Byte Queue Limits): 버퍼블로트 방지와 동적 큐 제한
BQL(Byte Queue Limits)은 커널 lib/dynamic_queue_limits.c에 구현된 동적 큐 깊이 제어 알고리즘입니다.
NIC TX 큐에 쌓을 수 있는 바이트 수를 실시간(Real-time)으로 조정하여, 큐가 과도하게 깊어지는 버퍼블로트(bufferbloat)를 방지하면서도
충분한 처리량(Throughput)을 유지합니다.
TX 경로 앞 절에서 netdev_tx_sent_queue/netdev_tx_completed_queue를 호출한 것이 바로 BQL API입니다.
이 절에서는 그 내부 알고리즘, 자료구조, sysfs 튜닝, 그리고 qdisc와의 상호작용을 깊이 있게 살펴봅니다.
버퍼블로트 문제와 BQL의 필요성
NIC의 TX ring이 크거나 드라이버가 큐 깊이를 제한하지 않으면, 상위 계층(qdisc, TCP 혼잡 제어(Congestion Control))이 내린 결정과 무관하게 수백 ms에서 수 초 분량의 패킷이 하드웨어 큐에 쌓일 수 있습니다. 이 "버퍼블로트"는 지연을 극적으로 증가시키면서 처리량은 거의 높이지 않습니다. BQL은 "지금 하드웨어에 내려보낸 바이트"와 "아직 완료되지 않은 바이트"를 추적하여 큐 깊이를 필요 최소한으로 동적 조절합니다.
BQL 동작 원리: 동적 한계 조정 알고리즘
BQL의 핵심은 lib/dynamic_queue_limits.c에 구현된 DQL(Dynamic Queue Limits) 알고리즘입니다.
드라이버가 패킷을 큐에 넣을 때(dql_queued)와 완료될 때(dql_completed)를 추적하여,
현재 inflight(미완료) 바이트가 LIMIT을 초과하면 큐를 멈추고,
완료 시 실제 사용 패턴에 따라 LIMIT을 올리거나 내립니다.
- LIMIT 감소 (오버슈트 교정): 완료 시점에 inflight가 LIMIT보다 큰 적이 없었으면(BELOW), LIMIT = inflight × (LIMIT / (LIMIT − ovlimit + slack)). 즉 과잉 분을 잘라냅니다.
- LIMIT 증가 (여유 확보): 완료 시점에 inflight가 LIMIT 이상이었으면(ABOVE), 다음 주기에서 LIMIT이 부족한 것으로 보고 LIMIT += (completed − LIMIT) / 16 형태로 서서히 올립니다.
- Slack:
slack_hold_time(기본 HZ) 동안 관찰된 최소 여유분(slack)을 반영하여 불필요한 여유를 제거합니다.
/* DQL 알고리즘 의사코드 (lib/dynamic_queue_limits.c 기반) */
/* ① 드라이버가 패킷을 큐에 넣을 때 */
void dql_queued(struct dql *dql, u32 count)
{
dql->last_obj_cnt = count;
dql->num_queued += count;
/* inflight = num_queued - num_completed */
if (inflight >= dql->adj_limit)
netif_tx_stop_queue(); /* 큐 정지 — LIMIT 도달 */
}
/* ② TX 완료 인터럽트에서 */
void dql_completed(struct dql *dql, u32 count)
{
dql->num_completed += count;
ovlimit = dql->num_queued - dql->num_completed - dql->limit;
if (ovlimit <= 0) {
/* BELOW: inflight가 LIMIT 아래 — 과잉 제거 */
dql->slack = min(dql->slack, ovlimit + dql->slack_start);
if (slack_expired)
new_limit = dql->limit - (ovlimit + dql->slack);
} else {
/* ABOVE: inflight가 LIMIT 이상 — LIMIT 확장 */
new_limit = dql->limit + count / 16; /* 완료분의 1/16 증가 */
}
dql->limit = clamp(new_limit, dql->min_limit, dql->max_limit);
if (inflight < dql->adj_limit)
netif_tx_wake_queue(); /* 큐 재개 */
}
핵심 자료구조: struct dql
BQL의 상태는 struct dql(include/linux/dynamic_queue_limits.h)에 저장됩니다.
각 TX 큐(struct netdev_queue)마다 하나의 dql 인스턴스가 내장되어 있습니다.
/* include/linux/dynamic_queue_limits.h */
struct dql {
unsigned int num_queued; /* 큐에 넣은 누적 바이트 (단조 증가) */
unsigned int adj_limit; /* 현재 유효 한계 (limit - num_completed) */
unsigned int last_obj_cnt; /* 마지막 dql_queued 호출의 count */
unsigned int limit ____cacheline_aligned_in_smp;
/* 동적 LIMIT (바이트 단위) */
unsigned int num_completed; /* 완료된 누적 바이트 (단조 증가) */
unsigned int prev_ovlimit; /* 이전 주기의 오버리밋 값 */
unsigned int prev_num_queued; /* 이전 주기의 num_queued */
unsigned int prev_last_obj_cnt;/* 이전 주기의 last_obj_cnt */
unsigned int lowest_slack; /* 관찰된 최소 여유분 */
unsigned long slack_start_time; /* slack 관찰 시작 시각 */
unsigned int max_limit; /* sysfs 설정: LIMIT 상한 (기본 DQL_MAX_LIMIT) */
unsigned int min_limit; /* sysfs 설정: LIMIT 하한 (기본 0) */
unsigned int slack_hold_time; /* slack 관찰 윈도우 (기본 HZ=1초) */
};
limit과 num_completed는 TX 완료 경로(보통 softirq)에서 빈번히 갱신되므로
____cacheline_aligned_in_smp로 분리하여 num_queued/adj_limit(송신 경로)와의 false sharing을 방지합니다.
드라이버 API 통합
드라이버가 BQL을 사용하려면 TX 경로의 세 지점에서 API를 호출합니다. 모든 API는 바이트 단위로 동작하며, 패킷 수가 아닌 누적 바이트를 전달해야 합니다.
/* BQL 드라이버 API 3종 — 호출 시점과 인자 */
/* ① ndo_start_xmit() 내부, skb를 ring에 넣은 직후 */
netdev_tx_sent_queue(txq, skb->len);
/* txq : netdev_get_tx_queue(ndev, queue_index)
* bytes: 전송한 바이트 수 (skb->len)
* 내부: dql_queued(&txq->dql, bytes)
* inflight ≥ limit이면 __netif_tx_stop_queue() 호출 */
/* ② TX 완료 인터럽트/NAPI에서, 디스크립터 회수 후 */
netdev_tx_completed_queue(txq, pkts, bytes);
/* pkts : 완료된 패킷 수 (BQL 자체는 bytes만 사용)
* bytes: 완료된 바이트 수
* 내부: dql_completed(&txq->dql, bytes)
* LIMIT 재조정 + 큐 wake 판단 */
/* ③ ndo_stop() 또는 링크 다운/리셋 시 */
netdev_tx_reset_queue(txq);
/* 모든 BQL 카운터 초기화 (num_queued, num_completed 등)
* 인터페이스 down → up 사이클에서 반드시 호출
* 빠뜨리면 stale 카운터로 큐가 영구 정지될 수 있음 */
ndo_stop()에서 netdev_tx_reset_queue()를 빠뜨리면,
다음 ndo_open() 후 stale 카운터 때문에 BQL이 즉시 큐를 멈추고 트래픽이 흐르지 않습니다.
멀티큐 드라이버는 모든 TX 큐에 대해 개별 reset을 호출해야 합니다.
sysfs 인터페이스와 튜닝
각 TX 큐의 BQL 파라미터는 /sys/class/net/<dev>/queues/tx-<N>/byte_queue_limits/ 경로에 노출됩니다.
운영 환경에서 BQL 동작을 관찰하고 미세 조정할 수 있는 핵심 인터페이스입니다.
# BQL sysfs 파일 확인 (예: eth0의 tx-0 큐)
ls /sys/class/net/eth0/queues/tx-0/byte_queue_limits/
# 출력: hold_time inflight limit limit_max limit_min
# 현재 동적 LIMIT 확인 (알고리즘이 결정한 값)
cat /sys/class/net/eth0/queues/tx-0/byte_queue_limits/limit
# inflight 바이트 확인 (현재 NIC에서 처리 중인 양)
cat /sys/class/net/eth0/queues/tx-0/byte_queue_limits/inflight
# LIMIT 상한 조정 (기본값: DQL_MAX_LIMIT = 매우 큰 값)
# 지연에 민감한 워크로드에서 상한을 낮추면 지연이 더 줄어들 수 있음
echo 30000 > /sys/class/net/eth0/queues/tx-0/byte_queue_limits/limit_max
# LIMIT 하한 조정 (기본값: 0)
# 너무 낮은 LIMIT으로 인한 성능 저하 방지
echo 1500 > /sys/class/net/eth0/queues/tx-0/byte_queue_limits/limit_min
# slack 관찰 윈도우 조정 (기본값: 1000 = HZ, 즉 1초)
cat /sys/class/net/eth0/queues/tx-0/byte_queue_limits/hold_time
# 모든 큐의 BQL limit 한 번에 확인
for q in /sys/class/net/eth0/queues/tx-*/byte_queue_limits/limit; do
echo "$(dirname $(dirname $q)): $(cat $q)"
done
| sysfs 파일 | 읽기/쓰기 | 설명 |
|---|---|---|
limit | R | 현재 동적 LIMIT (바이트). 알고리즘이 자동 조정 |
limit_max | R/W | LIMIT 상한. 낮추면 최대 큐 깊이를 제한 |
limit_min | R/W | LIMIT 하한. 높이면 최소 처리량 보장 |
hold_time | R/W | slack 관찰 윈도우 (ms). 기본 1000 |
inflight | R | 현재 미완료 바이트 (num_queued − num_completed) |
BQL과 qdisc/TC의 상호작용
BQL은 qdisc 아래, NIC ring 위에 위치합니다. 패킷 흐름에서 BQL의 정확한 위치를 이해하면
fq_codel 같은 AQM(Active Queue Management)과의 시너지를 극대화할 수 있습니다.
- fq_codel은 qdisc 레벨에서 소프트웨어 큐의 지연을 제어합니다 (sojourn time 기반 drop/ECN).
- BQL은 드라이버 레벨에서 하드웨어 큐에 과도한 바이트가 쌓이는 것을 방지합니다.
- BQL 없이 fq_codel만 사용하면 NIC ring에 수백 패킷이 쌓여 fq_codel의 AQM 효과가 무력화됩니다.
- BQL이 하드웨어 큐 깊이를 최소화하면, fq_codel이 더 정확한 sojourn time을 측정하여 공정한 스케줄링이 가능합니다.
실전 디버깅(Debugging)과 모니터링
BQL이 올바르게 동작하는지 확인하고, 문제 발생 시 원인을 추적하는 방법입니다.
# ── BQL 상태 종합 확인 ──
# 모든 TX 큐의 limit과 inflight를 한 번에 출력
for q in /sys/class/net/eth0/queues/tx-*/byte_queue_limits; do
echo "=== $(basename $(dirname $q)) ==="
echo " limit: $(cat $q/limit)"
echo " inflight: $(cat $q/inflight)"
echo " max: $(cat $q/limit_max)"
echo " min: $(cat $q/limit_min)"
done
# ── tc 통계와 BQL 연계 확인 ──
# qdisc의 backlog과 BQL의 inflight를 비교하여 병목 위치 판단
tc -s qdisc show dev eth0
# ── bpftrace로 BQL limit 변화 실시간 추적 ──
# dql_completed 호출 시 limit 값 변화를 추적
bpftrace -e 'kprobe:dql_completed {
$dql = (struct dql *)arg0;
printf("cpu=%d limit=%u completed=%u\n",
cpu, $dql->limit, arg1);
}'
# ── perf로 BQL 관련 함수 호출 빈도 확인 ──
perf stat -e 'probe:dql_queued,probe:dql_completed' -a sleep 5
# ── 문제 진단 체크리스트 ──
# 1. limit이 0이면? → netdev_tx_reset_queue() 누락 가능
# 2. inflight가 limit과 같고 큐 정지? → 정상 (완료 대기 중)
# 3. limit이 limit_max에 고정? → 트래픽이 항상 LIMIT 소진 → limit_max 낮출 것
# 4. limit이 매우 작고 throughput 저하? → limit_min을 MTU 이상으로 설정
inflight ≈ limit이 지속되면 BQL이 적극적으로 큐를 제한하고 있는 뜻입니다.
이때 throughput이 충분하면 정상이고, 부족하면 limit_min을 높이거나 NIC의 TX 완료 인터럽트 코얼레싱을 줄여
완료 통지를 빠르게 받아 LIMIT을 더 빨리 해제하세요.
LLTX (NETIF_F_LLTX): lockless TX 계약과 실무 주의점
NETIF_F_LLTX는 TX 잠금을 네트워크 코어가 아닌 드라이버가 직접 책임지는 오래된 모델입니다.
즉, ndo_start_xmit() 동시 호출에 대한 직렬화(Serialization)/경합(Contention) 제어를 드라이버가 스스로 보장해야 하며,
큐 stop/wake, timeout 복구, completion 경로까지 하나의 동시성 계약으로 맞춰야 합니다.
| 항목 | 일반 TX 경로 | LLTX 경로 |
|---|---|---|
| 직렬화 주체 | 코어/큐 잠금 + 드라이버 보조 잠금 | 드라이버가 전적으로 책임 |
| 병목(Bottleneck) 위치 | 잠금 경합(Lock Contention)은 비교적 예측 가능 | 드라이버 구현 품질에 따라 편차 큼 |
| 디버깅 난이도 | 표준 패턴과 도구가 많음 | race 재현/분석 난이도 높음 |
| 권장도 | 신규 구현 권장 | 기존 드라이버 유지보수 목적 외 비권장 |
LLTX를 유지해야 하는 코드베이스라면 아래 4가지를 반드시 고정 규칙으로 문서화해야 합니다.
- xmit 직렬화 규칙
ndo_start_xmit()의 re-entry 허용 범위(전역/큐별)를 명시하고 잠금 순서를 고정 - queue 상태 전이 규칙
netif_tx_stop_queue()/netif_tx_wake_queue()호출 조건을 단일 함수로 중앙화 - completion 메모리 순서
descriptor reclaim 이후 wake 판단 전까지의 barrier 규칙을 아키텍처별로 검증 - timeout 복구 규칙
ndo_tx_timeout()에서 즉시 리셋하지 말고 workqueue로 이관해 중복 reset 방지
/* LLTX 유지보수 시 권장되는 최소 패턴 (개념 예시) */
static netdev_tx_t my_lltx_xmit(struct sk_buff *skb, struct net_device *ndev)
{
struct my_priv *priv = netdev_priv(ndev);
unsigned long flags;
/* LLTX에서는 드라이버가 자체 직렬화를 반드시 보장 */
spin_lock_irqsave(&priv->tx_lock, flags);
if (!my_has_room(priv)) {
my_stop_txq_if_needed(priv);
spin_unlock_irqrestore(&priv->tx_lock, flags);
return NETDEV_TX_BUSY;
}
my_post_desc(priv, skb);
my_kick_doorbell(priv);
spin_unlock_irqrestore(&priv->tx_lock, flags);
return NETDEV_TX_OK;
}
XDP, AF_XDP, 드라이버 오프로드
XDP 지원 드라이버는 RX hot path 초기에 프로그램을 실행해 drop/redirect를 빠르게 처리합니다. ndo_bpf, zero-copy AF_XDP, page_pool의 조합이 고성능 경로의 핵심입니다.
/* 개념 예시: XDP action 분기와 프레임 반환 계약 */
static int my_xdp_run(struct my_priv *priv, struct xdp_buff *xdp)
{
u32 act;
act = bpf_prog_run_xdp(rcu_dereference(priv->xdp_prog), xdp);
switch (act) {
case XDP_PASS:
return XDP_PASS;
case XDP_DROP:
xdp_return_frame_rx_napi(xdp);
return XDP_DROP;
case XDP_TX:
my_xdp_xmit(priv, xdp);
return XDP_TX;
default:
xdp_return_frame_rx_napi(xdp);
return XDP_ABORTED;
}
}
ndo_bpf 콜백 구현 상세
XDP 프로그램을 드라이버에 부착(Attach)하려면 ndo_bpf 콜백을 구현해야 합니다. 커널의 dev_xdp_install() 함수가 이 콜백을 호출하여 XDP 프로그램의 설치/제거를 드라이버에 통지합니다.
ndo_bpf는 XDP_SETUP_PROG 명령을 통해 프로그램을 설치합니다. 드라이버는 이 시점에 RX 링 크기 재설정, 헤더룸(Headroom) 확보 등을 수행해야 합니다.
/* net/core/dev.c - dev_xdp_install() 핵심 분석 */
static int dev_xdp_install(struct net_device *dev,
bpf_op_t bpf_op,
struct netlink_ext_ack *extack,
u32 flags,
struct bpf_prog *prog)
{
struct netdev_bpf xdp;
memset(&xdp, 0, sizeof(xdp));
xdp.command = XDP_SETUP_PROG;
xdp.extack = extack;
xdp.flags = flags;
xdp.prog = prog;
/* 드라이버의 ndo_bpf 콜백 호출 */
return bpf_op(dev, &xdp);
}
/* 드라이버 측 ndo_bpf 구현 예시 */
static int my_ndo_bpf(struct net_device *dev,
struct netdev_bpf *bpf)
{
struct my_priv *priv = netdev_priv(dev);
switch (bpf->command) {
case XDP_SETUP_PROG:
return my_xdp_setup_prog(priv, bpf->prog, bpf->extack);
case XDP_SETUP_XSK_POOL:
return my_xsk_pool_setup(priv, bpf->xsk.pool,
bpf->xsk.queue_id);
default:
return -EINVAL;
}
}
static int my_xdp_setup_prog(struct my_priv *priv,
struct bpf_prog *prog,
struct netlink_ext_ack *extack)
{
struct bpf_prog *old_prog;
bool need_reconfig;
/* MTU 제한 검증: XDP는 단일 페이지에 맞아야 함 */
if (prog && priv->ndev->mtu > MY_XDP_MAX_MTU) {
NL_SET_ERR_MSG_MOD(extack,
"MTU too large for XDP");
return -EINVAL;
}
/* XDP 프로그램 유무에 따라 링 재설정 필요 여부 결정 */
old_prog = rcu_dereference_protected(priv->xdp_prog,
lockdep_is_held(&priv->lock));
need_reconfig = (!!prog != !!old_prog);
if (need_reconfig && netif_running(priv->ndev)) {
/* RX 링 재설정: XDP headroom 확보 */
my_stop_rx_rings(priv);
my_reconfigure_rx_headroom(priv,
prog ? XDP_PACKET_HEADROOM : 0);
my_start_rx_rings(priv);
}
/* RCU로 프로그램 교체 */
rcu_assign_pointer(priv->xdp_prog, prog);
if (old_prog)
bpf_prog_put(old_prog);
return 0;
}
XDP 메타데이터와 힌트(Hints) 시스템
XDP 프로그램은 xdp_buff의 메타데이터(Metadata) 영역을 통해 드라이버로부터 하드웨어 힌트(Hints)를 수신할 수 있습니다. 메타데이터 영역은 data_meta에서 data 사이에 위치하며, 드라이버가 RX 타임스탬프(Timestamp), RSS 해시(Hash) 등의 정보를 기록합니다.
커널 6.3 이후 도입된 kfunc 기반 메타데이터 접근 방식은 XDP 힌트(XDP Hints)라고 불리며, 구조화된 인터페이스를 제공합니다.
/* XDP 메타데이터 영역 구조:
*
* +------------------+------------------+------------------+
* | data_hard_start| data_meta | data |
* +------------------+------------------+------------------+
* |<-- metadata -->|<-- packet ------>|
* | (rx_timestamp) | (Ethernet frame) |
* | (rx_hash) | |
*/
/* 드라이버 RX 경로에서 메타데이터 기록 */
static void my_rx_populate_metadata(struct xdp_buff *xdp,
struct my_rx_desc *desc)
{
struct xdp_hints_common *hints;
/* 메타데이터 영역 확보 */
hints = xdp->data - sizeof(*hints);
if ((void *)hints < xdp->data_hard_start)
return; /* 공간 부족 */
/* RX 타임스탬프 기록 */
if (desc->flags & RX_DESC_TS_VALID) {
hints->rx_timestamp = my_hw_ts_to_ns(desc->timestamp);
hints->flags |= XDP_HINTS_RX_TIMESTAMP;
}
/* RSS 해시 기록 */
if (desc->flags & RX_DESC_HASH_VALID) {
hints->rx_hash = desc->rss_hash;
hints->rx_hash_type = my_hash_type_to_xdp(desc->hash_type);
hints->flags |= XDP_HINTS_RX_HASH;
}
xdp->data_meta = (void *)hints;
}
/* XDP kfunc 기반 힌트 접근 (커널 6.3+) */
/* XDP 프로그램에서 bpf_xdp_metadata_rx_timestamp()으로 접근 가능 */
static int my_xdp_rx_timestamp(const struct xdp_md *ctx,
u64 *timestamp)
{
struct xdp_buff *xdp = (struct xdp_buff *)ctx;
struct my_rx_ring *ring = my_get_rx_ring(xdp);
struct my_rx_desc *desc = my_get_current_desc(ring);
if (!(desc->flags & RX_DESC_TS_VALID))
return -ENODATA;
*timestamp = my_hw_ts_to_ns(desc->timestamp);
return 0;
}
static const struct xdp_metadata_ops my_xdp_metadata_ops = {
.xmo_rx_timestamp = my_xdp_rx_timestamp,
.xmo_rx_hash = my_xdp_rx_hash,
};
AF_XDP Zero-Copy 드라이버 통합
AF_XDP(Address Family XDP)의 제로 카피(Zero-Copy) 모드는 커널 메모리 복사 없이 유저스페이스와 NIC 간 직접 DMA 전송을 수행합니다. 드라이버는 xsk_pool을 등록하고, UMEM(User Memory) 영역의 버퍼를 직접 DMA 맵핑하여 사용합니다.
/* AF_XDP zero-copy를 지원하는 NAPI poll 구현 */
static int my_xsk_napi_poll(struct napi_struct *napi, int budget)
{
struct my_qvec *qv = container_of(napi, struct my_qvec, napi);
struct my_priv *priv = qv->priv;
struct xsk_buff_pool *pool = qv->xsk_pool;
int rx_done = 0, tx_done = 0;
if (pool) {
/* Zero-copy RX 처리 */
rx_done = my_xsk_rx_clean(qv, budget);
/* Zero-copy TX 처리 */
tx_done = my_xsk_tx_clean(qv);
/* XSK wakeup 처리 */
if (xsk_uses_need_wakeup(pool))
xsk_set_tx_need_wakeup(pool);
} else {
/* 일반 경로 */
rx_done = my_rx_clean(qv, budget);
tx_done = my_tx_clean(qv);
}
if (rx_done < budget) {
napi_complete_done(napi, rx_done);
my_enable_irq(qv);
}
return rx_done;
}
/* XSK RX 경로: UMEM 버퍼를 직접 DMA로 수신 */
static int my_xsk_rx_clean(struct my_qvec *qv, int budget)
{
struct xsk_buff_pool *pool = qv->xsk_pool;
int cleaned = 0;
while (cleaned < budget) {
struct xdp_buff *xdp;
struct my_rx_desc *desc = my_get_next_rx_desc(qv);
if (!desc)
break;
/* FILL ring에서 UMEM 프레임 가져오기 */
xdp = xsk_buff_alloc(pool);
if (!xdp)
break;
/* 디스크립터에서 길이 가져와서 xdp_buff에 설정 */
xsk_buff_set_size(xdp, desc->length);
xsk_buff_dma_sync_for_cpu(xdp, pool);
/* XDP 프로그램 실행 */
u32 act = bpf_prog_run_xdp(
rcu_dereference(qv->priv->xdp_prog), xdp);
switch (act) {
case XDP_PASS:
/* SKB로 변환하여 스택으로 전달 */
my_xsk_to_skb(qv, xdp);
break;
case XDP_REDIRECT:
xdp_do_redirect(qv->priv->ndev, xdp,
rcu_dereference(qv->priv->xdp_prog));
break;
default:
xsk_buff_free(xdp);
break;
}
cleaned++;
}
/* FILL ring 리필 */
my_xsk_refill_rx(qv, pool);
return cleaned;
}
XDP 멀티버퍼(Multi-Buffer) 지원
XDP 멀티버퍼(Multi-Buffer)는 커널 6.0에서 도입된 기능으로, 단일 페이지(Page)를 초과하는 프레임(예: 점보 프레임(Jumbo Frame))을 XDP에서 처리할 수 있게 합니다. xdp_buff에 mb(multi-buffer) 플래그가 설정되면, 추가 프래그먼트(Fragment)가 skb_shared_info 구조체(Struct)를 통해 연결됩니다.
| 항목 | 단일 버퍼 XDP | 멀티버퍼 XDP |
|---|---|---|
| 최대 프레임 크기 | PAGE_SIZE - headroom - tailroom (~3500B) | 제한 없음 (드라이버 구현에 따라) |
xdp_buff.flags | 0 | XDP_FLAGS_HAS_FRAGS |
| 프래그먼트 접근 | N/A | skb_shared_info의 frags[] 배열 |
| XDP 프로그램 요구사항 | 기본 | BPF_F_XDP_HAS_FRAGS 플래그로 로드 |
| 성능 영향 | 최소 오버헤드 | 프래그먼트 순회 비용 추가 |
| 드라이버 지원 (주요) | 거의 모든 XDP 드라이버 | mlx5, i40e, ice, veth, virtio_net |
/* 드라이버에서 XDP 멀티버퍼 프레임 구성 */
static void my_rx_build_xdp_mb(struct my_qvec *qv,
struct xdp_buff *xdp,
struct my_rx_desc *first_desc)
{
struct skb_shared_info *sinfo;
struct my_rx_desc *desc = first_desc;
int nr_frags = 0;
/* 첫 번째 디스크립터: 선형 데이터 */
xdp_init_buff(xdp, MY_RX_BUF_SIZE, &qv->xdp_rxq);
xdp_prepare_buff(xdp, page_address(desc->page),
XDP_PACKET_HEADROOM, desc->length, true);
/* multi-buffer 플래그 설정 */
xdp->flags |= XDP_FLAGS_HAS_FRAGS;
/* tailroom에 skb_shared_info 배치 */
sinfo = xdp_get_shared_info_from_buff(xdp);
sinfo->nr_frags = 0;
/* 후속 디스크립터들을 프래그먼트로 추가 */
while (!(desc->flags & RX_DESC_EOP)) {
desc = my_get_next_rx_desc(qv);
if (!desc || nr_frags >= MAX_SKB_FRAGS)
break;
skb_frag_fill_page_desc(&sinfo->frags[nr_frags],
desc->page, 0, desc->length);
sinfo->nr_frags = ++nr_frags;
sinfo->xdp_frags_size += desc->length;
}
}
/* ndo_bpf에서 멀티버퍼 능력 광고 */
static int my_xdp_setup_prog(struct my_priv *priv,
struct bpf_prog *prog,
struct netlink_ext_ack *extack)
{
/* 멀티버퍼 미지원 프로그램이 점보 프레임 환경에서 동작하면 거부 */
if (prog && !prog->aux->xdp_has_frags &&
priv->ndev->mtu > MY_XDP_MAX_MTU) {
NL_SET_ERR_MSG_MOD(extack,
"Non-multi-buffer XDP prog with jumbo MTU");
return -EINVAL;
}
/* ... 이하 프로그램 설치 로직 ... */
return 0;
}
멀티큐, RSS, IRQ affinity 설계
10/25/100GbE 구간에서는 단일 큐 모델이 거의 항상 병목입니다. 드라이버는 RX/TX 큐, MSI-X vector, NAPI 인스턴스를 1:1 또는 N:1로 설계하고, NUMA/CPU 토폴로지(Topology)에 맞춰 IRQ affinity를 배치해야 합니다.
/* 개념 예시: 멀티큐 qvec와 MSI-X 벡터 매핑 */
struct my_qvec {
struct napi_struct napi;
int qid;
int irq;
};
static int my_alloc_qvecs(struct my_priv *priv, int num_q)
{
int i;
for (i = 0; i < num_q; i++) {
struct my_qvec *qv = &priv->qvec[i];
qv->qid = i;
netif_napi_add(priv->ndev, &qv->napi, my_qvec_poll);
my_request_msix_vector(priv, i, &qv->irq, my_msix_irq_handler);
}
return 0;
}
| 설정 항목 | 실무 기준 |
|---|---|
| 큐 개수 | 활성 CPU 수와 동일 또는 NUMA 노드 단위 |
| RSS indirection | 핫플로우가 특정 큐에 치우치지 않게 분산 |
| IRQ affinity | 해당 큐를 소비하는 CPU에 고정 |
| RPS/RFS | HW RSS 부족 시 보조적으로 사용 |
RSS 해시 함수와 Indirection Table 내부
RSS(Receive Side Scaling)는 수신 패킷을 여러 RX 큐에 분산하는 하드웨어 메커니즘입니다. 핵심은 토플리츠 해시 알고리즘으로, 패킷의 소스/목적지 IP와 포트를 입력으로 받아 해시 값을 계산하고, 간접 테이블(Indirection Table)을 통해 최종 큐 번호를 결정합니다.
토플리츠 해시는 비밀 키(Secret Key)와 입력 데이터의 비트별 XOR 연산으로 구성됩니다. 커널은 netdev_rss_key_fill()로 부팅 시 랜덤 키를 생성하여 모든 디바이스에 공유합니다.
/* net/core/ethtool.c - RSS 키와 indirection table 관리 */
/* netdev_rss_key_fill(): 전역 RSS 키를 랜덤 생성 */
void netdev_rss_key_fill(void *buffer, size_t len)
{
static u8 netdev_rss_key[NETDEV_RSS_KEY_LEN] __read_mostly;
static bool rss_key_initialized = false;
if (!rss_key_initialized) {
get_random_bytes(netdev_rss_key, sizeof(netdev_rss_key));
rss_key_initialized = true;
}
memcpy(buffer, netdev_rss_key, len);
}
/* 드라이버에서 RSS indirection table 설정 */
static int my_set_rxfh(struct net_device *dev,
struct ethtool_rxfh_param *rxfh,
struct netlink_ext_ack *extack)
{
struct my_priv *priv = netdev_priv(dev);
int i;
/* 해시 함수 변경 */
if (rxfh->hfunc != ETH_RSS_HASH_NO_CHANGE) {
if (rxfh->hfunc != ETH_RSS_HASH_TOP)
return -EOPNOTSUPP; /* Toeplitz만 지원 */
priv->rss_hfunc = rxfh->hfunc;
}
/* RSS 키 갱신 */
if (rxfh->key)
memcpy(priv->rss_key, rxfh->key, MY_RSS_KEY_SIZE);
/* Indirection table 갱신 */
if (rxfh->indir) {
for (i = 0; i < MY_RSS_INDIR_SIZE; i++) {
if (rxfh->indir[i] >= priv->num_rx_queues)
return -EINVAL;
priv->rss_indir[i] = rxfh->indir[i];
}
}
/* 하드웨어에 새 RSS 설정 적용 */
return my_hw_write_rss_config(priv);
}
/* 대칭 RSS (Symmetric RSS): 양방향 플로우를 같은 큐로 */
/* 대칭 Toeplitz는 src/dst를 XOR하여 순서 무관하게 동일 해시 생성 */
static u32 my_symmetric_toeplitz_hash(const u8 *key,
__be32 saddr, __be32 daddr,
__be16 sport, __be16 dport)
{
/* src XOR dst를 입력으로 사용하면 A->B와 B->A가 동일 해시 */
__be32 addr_xor = saddr ^ daddr;
__be16 port_xor = sport ^ dport;
return toeplitz_hash(key, addr_xor, port_xor);
}
MSI-X 벡터 할당과 IRQ affinity
고성능 NIC 드라이버는 MSI-X(Message Signaled Interrupts - Extended) 인터럽트를 사용하여 각 RX/TX 큐에 독립적인 인터럽트 벡터를 할당합니다. pci_alloc_irq_vectors()가 벡터를 할당하고, irq_set_affinity_hint()가 CPU 친화도(Affinity)를 설정합니다.
/* MSI-X 벡터 할당과 managed affinity 설정 */
static int my_alloc_msix(struct my_priv *priv)
{
struct irq_affinity affd = {
.pre_vectors = 1, /* 관리/이벤트 벡터 1개 예약 */
.post_vectors = 0,
};
int num_vecs, ret;
/* 원하는 큐 수 + 관리 벡터 */
num_vecs = priv->num_queues + affd.pre_vectors;
/* managed affinity로 벡터 할당 — 커널이 NUMA 최적 배치 */
ret = pci_alloc_irq_vectors_affinity(priv->pdev,
affd.pre_vectors + 1, /* 최소: 관리 + 큐 1개 */
num_vecs, /* 최대 */
PCI_IRQ_MSIX | PCI_IRQ_AFFINITY,
&affd);
if (ret < 0)
return ret;
priv->num_msix_vecs = ret;
priv->num_queues = ret - affd.pre_vectors;
/* 각 큐 벡터에 대해 IRQ 핸들러 등록 */
for (int i = 0; i < priv->num_queues; i++) {
int vec = i + affd.pre_vectors;
struct my_qvec *qv = &priv->qvec[i];
qv->irq = pci_irq_vector(priv->pdev, vec);
ret = request_irq(qv->irq, my_msix_handler,
0, qv->name, qv);
if (ret)
goto err_free;
/* managed affinity 사용 시 hint는 불필요
* 비-managed인 경우에만 수동 설정 */
if (!(priv->pdev->msix_enabled))
irq_set_affinity_hint(qv->irq,
get_cpu_mask(cpumask_local_spread(i,
dev_to_node(&priv->pdev->dev))));
}
/* 관리 벡터 등록 (벡터 0) */
priv->mgmt_irq = pci_irq_vector(priv->pdev, 0);
ret = request_irq(priv->mgmt_irq, my_mgmt_handler,
0, "my-mgmt", priv);
return ret;
err_free:
while (--i >= 0)
free_irq(priv->qvec[i].irq, &priv->qvec[i]);
pci_free_irq_vectors(priv->pdev);
return ret;
}
XPS (Transmit Packet Steering) 내부 동작
XPS(Transmit Packet Steering)는 송신 패킷을 특정 TX 큐로 유도하여 캐시 효율을 높이는 메커니즘입니다. CPU 맵(CPU map)과 RX 큐 맵(RX queue map) 두 가지 모드가 있습니다.
| XPS 모드 | 맵 기준 | 설정 경로 | 효과 |
|---|---|---|---|
| CPU map | 송신 CPU → TX 큐 | /sys/class/net/dev/queues/tx-N/xps_cpus | TX 완료 IRQ와 같은 CPU에서 송신하여 캐시 히트 향상 |
| RX queue map | RX 큐 → TX 큐 | /sys/class/net/dev/queues/tx-N/xps_rxqs | 수신-응답 경로가 동일 큐 쌍을 사용하여 지역성(Locality) 향상 |
/* net/core/dev.c - __netif_set_xps_queue() 핵심 분석 */
/* XPS는 dev->xps_maps에 CPU/RX큐 -> TX큐 매핑을 저장 */
/* XPS와 ndo_select_queue의 상호작용 */
static u16 my_select_queue(struct net_device *dev,
struct sk_buff *skb,
struct net_device *sb_dev)
{
/* 커널 기본 경로:
* 1. skb->queue_mapping이 설정되어 있으면 그대로 사용
* 2. XPS 맵에서 현재 CPU에 해당하는 TX 큐 선택
* 3. XPS가 없으면 해시 기반 분산 (skb_tx_hash)
*/
/* 드라이버가 특별한 큐 선택 로직이 필요한 경우에만 구현 */
/* 예: TC(Traffic Class) 기반 큐 선택 */
if (skb->priority >= MY_PRIO_THRESHOLD)
return priv->high_prio_queue;
/* 기본 XPS/해시 선택에 위임 */
return netdev_pick_tx(dev, skb, sb_dev);
}
/* sysfs를 통한 XPS CPU map 설정 예시 */
/*
* # TX 큐 0은 CPU 0,1에서 사용
* echo 3 > /sys/class/net/eth0/queues/tx-0/xps_cpus
*
* # TX 큐 1은 CPU 2,3에서 사용
* echo c > /sys/class/net/eth0/queues/tx-1/xps_cpus
*
* # RX 큐 맵: TX 큐 0은 RX 큐 0의 응답 경로
* echo 1 > /sys/class/net/eth0/queues/tx-0/xps_rxqs
*/
RPS/RFS 소프트웨어 스티어링
RPS(Receive Packet Steering)와 RFS(Receive Flow Steering)는 하드웨어 RSS를 지원하지 않는 디바이스에서 소프트웨어적으로 패킷을 여러 CPU에 분산하는 메커니즘입니다. RPS는 패킷 해시 기반으로, RFS는 애플리케이션의 소켓 위치 기반으로 CPU를 선택합니다.
| 기능 | RPS | RFS |
|---|---|---|
| 분산 기준 | 패킷 해시 (소스/목적지 IP+포트) | 소켓이 마지막으로 처리된 CPU |
| 목적 | softirq 부하 분산(Load Balancing) | 애플리케이션-인터럽트 CPU 일치 |
| 캐시 효과 | 패킷 처리 분산 | 소켓 데이터의 캐시 지역성 극대화 |
| 설정 | /sys/class/net/dev/queues/rx-N/rps_cpus | /proc/sys/net/core/rps_sock_flow_entries |
| 커널 자료구조 | rps_dev_flow_table | rps_sock_flow_table |
| 사용 시점 | HW RSS 미지원 또는 단일 큐 NIC | RPS와 함께 사용, 서버 워크로드 |
/* net/core/dev.c - get_rps_cpu() 핵심 분석 */
static int get_rps_cpu(struct net_device *dev,
struct sk_buff *skb,
struct rps_dev_flow **rflowp)
{
const struct rps_sock_flow_table *sock_flow_table;
struct netdev_rx_queue *rxqueue = dev->_rx;
struct rps_dev_flow_table *flow_table;
struct rps_map *map;
u32 hash, next_cpu, ident;
int cpu = -1;
/* 1단계: 패킷 해시 계산 (또는 HW 해시 재사용) */
hash = skb_get_hash(skb);
if (!hash)
goto done;
/* 2단계: RPS 맵에서 해시 기반 CPU 선택 */
map = rcu_dereference(rxqueue->rps_map);
if (map) {
cpu = map->cpus[hash & (map->len - 1)]; /* reciprocal_scale 대신 간소화 */
}
/* 3단계: RFS 활성화 시 소켓 CPU와 비교 */
sock_flow_table = rcu_dereference(rps_sock_flow_table);
if (sock_flow_table) {
ident = sock_flow_table->ents[hash & sock_flow_table->mask];
next_cpu = ident & rps_cpu_mask;
/* 소켓이 처리 중인 CPU가 RPS 맵에 있으면 그 CPU 선택 */
if (cpu_online(next_cpu))
cpu = next_cpu;
}
/* 4단계: 디바이스 플로우 테이블 갱신 */
flow_table = rcu_dereference(rxqueue->rps_flow_table);
if (flow_table) {
struct rps_dev_flow *rflow =
&flow_table->flows[hash & flow_table->mask];
rflow->cpu = cpu;
*rflowp = rflow;
}
done:
return cpu;
}
/* RPS/RFS 활성화 예시 (sysfs) */
/*
* # 모든 CPU에서 RPS 활성화 (8 CPU 시스템)
* echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus
*
* # RFS 플로우 테이블 크기 설정 (보통 32768)
* echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
*
* # 디바이스별 플로우 테이블 크기
* echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
*/
NUMA 친화적 큐 배치 전략
NUMA(Non-Uniform Memory Access) 시스템에서 네트워크 드라이버의 성능은 큐, 인터럽트, 메모리 할당이 올바른 NUMA 노드에 배치되었는지에 크게 좌우됩니다. 원격 NUMA 노드의 메모리 접근은 로컬 접근 대비 상당히 높은 지연이 발생합니다.
| 리소스 | NUMA 최적화 방법 | 커널 API |
|---|---|---|
| DMA 링 메모리 | NIC가 연결된 NUMA 노드에 할당 | dev_to_node(), dma_alloc_coherent() |
| NAPI 구조체 | 처리 CPU와 같은 노드에 할당 | kzalloc_node() |
| page_pool 페이지 | page_pool_params.nid 설정 | page_pool_create() |
| IRQ affinity | 로컬 NUMA 코어에 고정 | cpumask_local_spread() |
| XPS 맵 | TX 큐를 로컬 CPU에 매핑(Mapping) | netif_set_xps_queue() |
/* NUMA 친화적 큐 초기화 패턴 (ixgbe/mlx5 참고) */
static int my_alloc_queue_resources(struct my_priv *priv, int qid)
{
struct device *dev = &priv->pdev->dev;
int numa_node = dev_to_node(dev);
struct my_ring *rx_ring, *tx_ring;
/* 1. 링 구조체를 NIC의 NUMA 노드에 할당 */
rx_ring = kzalloc_node(sizeof(*rx_ring), GFP_KERNEL, numa_node);
tx_ring = kzalloc_node(sizeof(*tx_ring), GFP_KERNEL, numa_node);
if (!rx_ring || !tx_ring)
return -ENOMEM;
/* 2. DMA 일관성 메모리도 같은 노드에서 할당 */
rx_ring->desc = dma_alloc_coherent(dev,
rx_ring->size * sizeof(struct my_rx_desc),
&rx_ring->dma_addr, GFP_KERNEL);
/* 3. page_pool을 같은 NUMA 노드로 설정 */
struct page_pool_params pp = {
.nid = numa_node,
.dev = dev,
.flags = PP_FLAG_DMA_MAP | PP_FLAG_DMA_SYNC_DEV,
.dma_dir = DMA_FROM_DEVICE,
};
rx_ring->pp = page_pool_create(&pp);
/* 4. IRQ affinity: cpumask_local_spread()로 로컬 CPU 우선 */
int target_cpu = cpumask_local_spread(qid, numa_node);
irq_set_affinity_hint(priv->qvec[qid].irq,
get_cpu_mask(target_cpu));
/* 5. XPS 설정: TX 큐를 로컬 CPU에 매핑 */
netif_set_xps_queue(priv->ndev,
get_cpu_mask(target_cpu), qid);
priv->rx_ring[qid] = rx_ring;
priv->tx_ring[qid] = tx_ring;
return 0;
}
/* cpumask_local_spread() 동작 원리:
* - idx=0이면 numa_node의 첫 번째 온라인 CPU 반환
* - idx가 로컬 CPU 수를 초과하면 원격 NUMA 노드 CPU 반환
* - 이렇게 하면 큐 0~N은 로컬 CPU에 우선 배치되고,
* 남는 큐는 원격 CPU에 할당됩니다
*/
/* 실무: ethtool로 NUMA 배치 확인 */
/*
* # NIC의 NUMA 노드 확인
* cat /sys/class/net/eth0/device/numa_node
*
* # 각 큐의 IRQ affinity 확인
* for irq in $(grep eth0 /proc/interrupts | awk '{print $1}' | tr -d ':'); do
* echo "IRQ $irq: $(cat /proc/irq/$irq/smp_affinity_list)"
* done
*
* # NUMA 노드별 메모리 사용 확인
* numastat -p $(pidof ksoftirqd/0)
*/
dev_to_node()가 NUMA_NO_NODE(-1)을 반환하는 경우가 있습니다(예: 가상 머신, ACPI SRAT 테이블 누락). 이 경우 first_online_node로 폴백(Fallback)해야 합니다. 또한 kzalloc_node()에 잘못된 노드를 전달하면 로컬 노드로 자동 폴백되므로, 할당 자체는 실패하지 않지만 성능이 저하될 수 있습니다.
RX 메모리 경로: page_pool과 DMA recycling
고속 수신 경로에서 alloc_pages()/dma_map를 패킷마다 반복하면 CPU 비용이 폭증합니다. page_pool 기반 재사용은 대부분의 고성능 NIC 드라이버에서 사실상 표준 패턴입니다.
/* 개념 예시: page_pool 기반 RX 메모리 재사용 */
static int my_rx_pool_init(struct my_priv *priv)
{
struct page_pool_params pp = {
.flags = PP_FLAG_DMA_MAP | PP_FLAG_DMA_SYNC_DEV,
.order = 0,
.pool_size = 4096,
.nid = dev_to_node(priv->dev),
.dev = priv->dev,
.dma_dir = DMA_FROM_DEVICE,
};
priv->rx_pp = page_pool_create(&pp);
if (IS_ERR(priv->rx_pp))
return PTR_ERR(priv->rx_pp);
return 0;
}
static void my_rx_recycle_page(struct my_priv *priv, struct page *page)
{
page_pool_recycle_direct(priv->rx_pp, page);
}
XDP_REDIRECT, XDP_TX, XDP_DROP 경로별 반환 API를 혼용하면 double free/메모리 누수가 쉽게 발생합니다.
page_pool 내부 자료구조와 할당 경로
struct page_pool은 크게 세 가지 계층으로 구성됩니다. 가장 빠른 경로인 alloc 캐시는 per-CPU 배열(기본 128개)로, NAPI 폴링 컨텍스트에서 잠금 없이 페이지를 꺼내옵니다. 캐시가 비면 ptr_ring 기반 리사이클 링으로 폴백(fallback)하며, 여기서도 부족하면 슬로 패스(slow path)로 버디 할당자(Buddy Allocator)에서 새 페이지를 할당합니다.
커널 소스(net/core/page_pool.c)에서 page_pool_alloc_pages()의 핵심 흐름을 분석하면 다음과 같습니다.
/* net/core/page_pool.c — 할당 경로 핵심 분석 */
struct page *page_pool_alloc_pages(struct page_pool *pool,
gfp_t gfp)
{
struct page *page;
/* 1단계: alloc 캐시에서 꺼냄 (가장 빠름, 락 없음) */
if (likely(pool->alloc.count)) {
page = pool->alloc.cache[--pool->alloc.count];
return page;
}
/* 2단계: ptr_ring에서 벌크로 캐시 채움 */
page = __page_pool_get_cached(pool);
if (page)
return page;
/* 3단계: 슬로 패스 — 버디 할당자에서 새 페이지 할당 + DMA 매핑 */
page = __page_pool_alloc_pages_slow(pool, gfp);
return page;
}
/* 슬로 패스: DMA 매핑 포함 */
static struct page *__page_pool_alloc_pages_slow(
struct page_pool *pool, gfp_t gfp)
{
struct page *page;
page = alloc_pages_node(pool->p.nid, gfp, pool->p.order);
if (unlikely(!page))
return NULL;
/* PP_FLAG_DMA_MAP이 설정된 경우 자동 DMA 매핑 */
if (pool->p.flags & PP_FLAG_DMA_MAP) {
if (__page_pool_dma_map(pool, page)) {
put_page(page);
return NULL;
}
}
page_pool_set_pp_info(pool, page);
pool->pages_state_hold_cnt++;
return page;
}
핵심 포인트는 alloc 캐시가 비었을 때 ptr_ring에서 최대 PP_ALLOC_CACHE_REFILL(기본 64)개를 한 번에 가져와 캐시를 채우는 벌크 리필(bulk refill) 전략입니다. 이 방식으로 ptr_ring 잠금 경합을 최소화합니다.
PP_FLAG 옵션 상세
page_pool_params에 설정하는 플래그(Flag)는 page_pool의 동작을 근본적으로 변경합니다. 각 플래그의 의미와 성능 영향을 정확히 이해해야 합니다.
| 플래그 | 설명 | 일반적 사용처 | 성능 영향 |
|---|---|---|---|
PP_FLAG_DMA_MAP |
page_pool이 DMA 매핑/언매핑(Unmapping)을 자동 관리. 재활용(Recycling) 시 remap 생략 | 모든 NIC 드라이버 (기본 권장) | 재활용 경로에서 dma_map/unmap 제거 → 10~30% RX 성능 향상 |
PP_FLAG_DMA_SYNC_DEV |
페이지 반환 시 dma_sync_single_range_for_device() 자동 호출 |
non-coherent DMA 아키텍처 (ARM 등) | 캐시 일관성(Cache Coherency) 보장, 약간의 오버헤드 추가 |
PP_FLAG_PAGE_FRAG |
하나의 페이지를 여러 프래그먼트(Fragment)로 분할 할당 | 소형 패킷 위주 트래픽 (DNS, VoIP 등) | 메모리 효율 크게 향상, 단 프래그먼트 추적 오버헤드 |
PP_FLAG_SYSTEM_POOL |
시스템 전역 공유 풀 사용. 여러 netdev가 하나의 pool 공유 | 가상 NIC, 다수 인터페이스 환경 | 메모리 절약, 캐시 히트율 하락 가능 |
PP_FLAG_DMA_MAP | PP_FLAG_DMA_SYNC_DEV 조합이 표준입니다. ARM64 서버에서는 PP_FLAG_DMA_SYNC_DEV가 필수이고, x86에서도 안전을 위해 포함하는 것이 좋습니다.
page_pool 통계와 모니터링
page_pool은 커널 6.2부터 ethtool 통계 인터페이스를 통해 재활용 효율(Recycle Efficiency)을 실시간으로 모니터링할 수 있습니다. 재활용 히트율(Hit Ratio)이 90% 이하로 떨어지면 패킷 처리 성능이 급격히 저하됩니다.
/* ethtool을 통한 page_pool 통계 조회 */
/* $ ethtool -S eth0 | grep page_pool */
/* page_pool_alloc_fast: 1234567 ← alloc 캐시 히트 */
/* page_pool_alloc_slow: 456 ← 버디 할당자 폴백 */
/* page_pool_alloc_refill: 12345 ← ptr_ring 리필 */
/* page_pool_recycle_cached: 1234000 ← 직접 캐시 반환 */
/* page_pool_recycle_ring: 567 ← ptr_ring 반환 */
/* page_pool_recycle_released: 89 ← 풀로 못 돌려 해제 */
/* 드라이버에서 page_pool 통계를 ethtool에 노출하는 방법 */
static void my_get_ethtool_stats(struct net_device *ndev,
struct ethtool_stats *stats,
u64 *data)
{
struct my_priv *priv = netdev_priv(ndev);
struct page_pool_stats pp_stats = {};
int i = 0;
/* per-queue 통계 수집 */
for (int q = 0; q < priv->num_rx_queues; q++) {
if (!priv->rx_ring[q].page_pool)
continue;
page_pool_get_stats(priv->rx_ring[q].page_pool,
&pp_stats);
}
data[i++] = pp_stats.alloc_stats.fast;
data[i++] = pp_stats.alloc_stats.slow;
data[i++] = pp_stats.alloc_stats.slow_high_order;
data[i++] = pp_stats.alloc_stats.empty;
data[i++] = pp_stats.alloc_stats.refill;
data[i++] = pp_stats.alloc_stats.waive;
data[i++] = pp_stats.recycle_stats.cached;
data[i++] = pp_stats.recycle_stats.cache_full;
data[i++] = pp_stats.recycle_stats.ring;
data[i++] = pp_stats.recycle_stats.ring_full;
data[i++] = pp_stats.recycle_stats.released_refcnt;
}
bpftrace를 사용하면 런타임에 page_pool 슬로 패스 진입 빈도를 추적할 수 있습니다.
/* bpftrace: page_pool 슬로 패스 추적 */
/* $ sudo bpftrace -e '
kprobe:__page_pool_alloc_pages_slow {
@slow_alloc[comm] = count();
}
kprobe:page_pool_alloc_pages {
@total_alloc[comm] = count();
}
interval:s:5 {
print(@slow_alloc);
print(@total_alloc);
clear(@slow_alloc);
clear(@total_alloc);
}
' */
hit_ratio = (alloc_fast + alloc_refill) / (alloc_fast + alloc_refill + alloc_slow). 이 비율이 95% 이상이어야 최적 성능입니다. 90% 이하이면 alloc.count 튜닝이나 NAPI budget 조정을 검토해야 합니다.
프래그먼트 모드와 Header/Data Split
PP_FLAG_PAGE_FRAG를 설정하면 page_pool은 하나의 페이지를 여러 개의 작은 프래그먼트(Fragment)로 나누어 할당합니다. 예를 들어 4KB 페이지에서 256바이트짜리 DNS 패킷용 버퍼 15개를 할당할 수 있어 메모리 효율이 크게 향상됩니다.
헤더/데이터 분리(Header/Data Split) 패턴에서는 이더넷(Ethernet) 프레임의 헤더 부분(일반적으로 128~256바이트)만 선형 버퍼에 배치하고, 나머지 페이로드(Payload)는 page_pool 프래그먼트로 구성합니다. 이 방식은 캐시 효율과 제로 카피(Zero-copy) 전달을 동시에 만족시킵니다.
/* 프래그먼트 기반 RX 경로 구현 예시 */
static struct sk_buff *my_rx_build_skb_frag(
struct my_rx_ring *ring,
struct my_rx_desc *desc)
{
struct page *page = ring->rx_buf[desc->idx].page;
unsigned int offset = ring->rx_buf[desc->idx].offset;
unsigned int len = desc->length;
unsigned int hdr_len = min_t(unsigned int, len,
ring->rx_hdr_size);
unsigned int data_len = len - hdr_len;
struct sk_buff *skb;
/* 헤더 부분으로 SKB 생성 */
skb = napi_alloc_skb(&ring->napi, hdr_len);
if (unlikely(!skb))
return NULL;
/* 헤더 복사 (캐시 효율적, 작은 크기) */
memcpy(skb_put(skb, hdr_len),
page_address(page) + offset, hdr_len);
/* 데이터 부분은 프래그먼트로 추가 (제로 카피) */
if (data_len) {
skb_add_rx_frag(skb, 0, page,
offset + hdr_len, data_len,
ring->rx_buf_size);
/* page_pool 재활용을 위해 마킹 */
skb_mark_for_recycle(skb);
} else {
/* 헤더만으로 충분한 소형 패킷: 페이지 즉시 반환 */
page_pool_put_full_page(ring->page_pool,
page, false);
}
return skb;
}
/* page_pool 프래그먼트 모드 초기화 */
static int my_setup_page_pool_frag(struct my_priv *priv)
{
struct page_pool_params pp = {
.order = 0,
.pool_size = priv->rx_ring_size * 2,
.nid = dev_to_node(&priv->pdev->dev),
.dev = &priv->pdev->dev,
.dma_dir = DMA_FROM_DEVICE,
.flags = PP_FLAG_DMA_MAP |
PP_FLAG_DMA_SYNC_DEV |
PP_FLAG_PAGE_FRAG,
.max_len = PAGE_SIZE,
};
priv->rx_pp = page_pool_create(&pp);
return IS_ERR(priv->rx_pp) ?
PTR_ERR(priv->rx_pp) : 0;
}
max_len 파라미터를 실제 최대 패킷 크기에 맞추어야 합니다. 기본값 PAGE_SIZE보다 작게 설정하면 하나의 페이지에서 더 많은 프래그먼트를 할당할 수 있습니다. MTU 1500 환경에서는 max_len = 2048이 적절합니다.
TC/NFT 오프로드와 switchdev 연계
데이터센터 NIC는 tc flower 규칙을 하드웨어 테이블로 오프로드해 CPU 부하를 낮춥니다. 이때 드라이버는 수용 가능한 매치/액션 집합을 명확히 제한하고, 부분 실패 시 fallback 정책을 분명히 해야 합니다.
/* 개념 예시: TC setup type별 오프로드 분기 */
static int my_ndo_setup_tc(struct net_device *ndev, enum tc_setup_type type, void *type_data)
{
switch (type) {
case TC_SETUP_BLOCK:
return my_tc_block_cb_setup(ndev, type_data);
case TC_SETUP_QDISC_MQPRIO:
return my_mqprio_setup(ndev, type_data);
default:
return -EOPNOTSUPP;
}
}
| 오프로드 대상 | 대표 인터페이스 | 주의점 |
|---|---|---|
| 분류/필터 | tc flower + ndo_setup_tc | 규칙 우선순위/충돌 처리 |
| eSwitch | switchdev, representor netdev | VF/representor 일관성 |
| 암호화(Encryption)/터널(Tunnel) | xfrm offload, UDP tunnel offload | fallback 경로와 통계 구분 |
tc flower 오프로드 내부 흐름
tc flower 오프로드는 FLOW_CLS_REPLACE, FLOW_CLS_DESTROY, FLOW_CLS_STATS 세 가지 명령으로 구성됩니다. 사용자가 tc filter add를 실행하면 커널은 fl_hw_replace_filter()를 통해 드라이버의 ndo_setup_tc를 호출하고, 드라이버는 하드웨어 플로 테이블(Flow Table)에 규칙을 삽입합니다.
/* 완전한 flower 오프로드 콜백 구현 */
static int my_flower_replace(struct my_priv *priv,
struct flow_cls_offload *f)
{
struct flow_rule *rule = flow_cls_offload_flow_rule(f);
struct flow_match_eth_addrs match_eth;
struct flow_match_ipv4_addrs match_ip;
struct flow_match_ports match_ports;
struct my_flow_entry *entry;
int err;
/* 매치 키 파싱 */
if (flow_rule_match_key(rule, FLOW_DISSECTOR_KEY_ETH_ADDRS))
flow_rule_match_eth_addrs(rule, &match_eth);
if (flow_rule_match_key(rule, FLOW_DISSECTOR_KEY_IPV4_ADDRS))
flow_rule_match_ipv4_addrs(rule, &match_ip);
if (flow_rule_match_key(rule, FLOW_DISSECTOR_KEY_PORTS))
flow_rule_match_ports(rule, &match_ports);
/* 지원하지 않는 매치 키 검사 */
if (flow_rule_match_key(rule, FLOW_DISSECTOR_KEY_ENC_KEYID)) {
NL_SET_ERR_MSG_MOD(f->common.extack,
"tunnel key match not supported");
return -EOPNOTSUPP;
}
/* 액션 파싱 */
if (!flow_action_has_entries(&rule->action))
return -EINVAL;
entry = kzalloc(sizeof(*entry), GFP_KERNEL);
if (!entry)
return -ENOMEM;
entry->cookie = f->cookie;
/* HW 플로 테이블에 규칙 삽입 */
err = my_hw_add_flow(priv, entry, rule);
if (err) {
kfree(entry);
return err;
}
/* 쿠키 기반 해시 테이블에 저장 (나중에 삭제/통계 조회용) */
hash_add(priv->flow_table, &entry->node,
entry->cookie);
return 0;
}
static int my_flower_destroy(struct my_priv *priv,
struct flow_cls_offload *f)
{
struct my_flow_entry *entry;
entry = my_flow_lookup(priv, f->cookie);
if (!entry)
return -ENOENT;
my_hw_del_flow(priv, entry);
hash_del(&entry->node);
kfree(entry);
return 0;
}
static int my_flower_stats(struct my_priv *priv,
struct flow_cls_offload *f)
{
struct my_flow_entry *entry;
u64 packets, bytes, lastused;
entry = my_flow_lookup(priv, f->cookie);
if (!entry)
return -ENOENT;
my_hw_read_flow_stats(priv, entry,
&packets, &bytes, &lastused);
flow_stats_update(&f->stats, bytes, packets,
0, lastused,
FLOW_ACTION_HW_STATS_DELAYED);
return 0;
}
/* block 콜백 진입점 */
static int my_tc_block_cb(enum tc_setup_type type,
void *type_data, void *cb_priv)
{
struct my_priv *priv = cb_priv;
struct flow_cls_offload *f = type_data;
if (type != TC_SETUP_CLSFLOWER)
return -EOPNOTSUPP;
switch (f->command) {
case FLOW_CLS_REPLACE:
return my_flower_replace(priv, f);
case FLOW_CLS_DESTROY:
return my_flower_destroy(priv, f);
case FLOW_CLS_STATS:
return my_flower_stats(priv, f);
default:
return -EOPNOTSUPP;
}
}
하드웨어 Flow Table 관리
하드웨어 플로 테이블(Hardware Flow Table)은 TCAM(Ternary Content-Addressable Memory) 또는 eSwitch의 플로 테이블로 구현됩니다. 테이블 항목의 생명주기를 올바르게 관리하지 않으면 규칙 누수(Rule Leak)나 스톨(Stall) 오프로드(Stale Offload)가 발생합니다.
각 플로 항목은 tc가 할당한 쿠키(Cookie)로 식별됩니다. 쿠키는 규칙의 전체 생명주기 동안 유일하며, FLOW_CLS_DESTROY 시 드라이버는 이 쿠키로 대응하는 하드웨어 항목을 찾아 삭제합니다.
/* 부분 오프로드 처리 패턴 */
static int my_flower_replace_partial(struct my_priv *priv,
struct flow_cls_offload *f)
{
struct flow_rule *rule = flow_cls_offload_flow_rule(f);
const struct flow_action_entry *act;
int i;
/* 모든 액션 순회하며 HW 지원 여부 확인 */
flow_action_for_each(i, act, &rule->action) {
switch (act->id) {
case FLOW_ACTION_DROP:
case FLOW_ACTION_REDIRECT:
case FLOW_ACTION_MIRRED:
break; /* 지원 */
case FLOW_ACTION_MANGLE:
/* 헤더 수정: L3/L4만 지원, L2 수정은 SW fallback */
if (act->mangle.htype == FLOW_ACT_MANGLE_HDR_TYPE_ETH) {
NL_SET_ERR_MSG_MOD(f->common.extack,
"L2 header modification not supported in HW");
return -EOPNOTSUPP;
}
break;
default:
NL_SET_ERR_MSG_MOD(f->common.extack,
"unsupported action for HW offload");
return -EOPNOTSUPP;
}
}
/* HW 테이블 용량 확인 */
if (atomic_read(&priv->flow_count) >= priv->max_flows) {
NL_SET_ERR_MSG_MOD(f->common.extack,
"HW flow table full");
return -ENOSPC;
}
return my_hw_insert_flow(priv, f);
}
NL_SET_ERR_MSG_MOD()를 사용해 사용자에게 구체적인 실패 이유를 전달해야 합니다. 단순히 -EOPNOTSUPP만 반환하면 운영자가 원인을 파악하기 어렵습니다. tc -s filter show로 오프로드 상태(in_hw / not_in_hw)를 확인할 수 있습니다.
switchdev 통합과 FDB 오프로드
switchdev 모델은 하드웨어 스위치 ASIC를 리눅스 브리지(Linux Bridge)와 통합하는 프레임워크입니다. 드라이버는 SWITCHDEV_OBJ_ID_PORT_FDB 등의 알림(Notification)을 처리하여 FDB(Forwarding Database) 항목을 하드웨어에 오프로드합니다.
| switchdev 오브젝트 | 설명 | 대응 동작 |
|---|---|---|
SWITCHDEV_OBJ_ID_PORT_FDB | MAC 주소 → 포트 매핑 | HW FDB 테이블 업데이트 |
SWITCHDEV_OBJ_ID_PORT_MDB | 멀티캐스트(Multicast) 그룹 → 포트 매핑 | HW MDB 테이블 업데이트 |
SWITCHDEV_OBJ_ID_PORT_VLAN | VLAN ID → 포트 매핑 | HW VLAN 필터 테이블 |
SWITCHDEV_OBJ_ID_HOST_MDB | 호스트 멀티캐스트 그룹 | CPU 포트로 멀티캐스트 전달 |
SWITCHDEV_ATTR_ID_BRIDGE_VLAN_FILTERING | VLAN 필터링 활성화/비활성화 | 브리지 전체 VLAN 모드 설정 |
/* switchdev 알림 등록과 FDB 오프로드 */
static int my_switchdev_event(struct notifier_block *nb,
unsigned long event,
void *ptr)
{
struct net_device *dev = switchdev_notifier_info_to_dev(ptr);
struct switchdev_notifier_fdb_info *fdb_info;
if (!my_is_our_port(dev))
return NOTIFY_DONE;
switch (event) {
case SWITCHDEV_FDB_ADD_TO_DEVICE:
fdb_info = ptr;
/* 비동기 처리를 위해 workqueue에 이관 */
my_schedule_fdb_work(dev, fdb_info, true);
break;
case SWITCHDEV_FDB_DEL_TO_DEVICE:
fdb_info = ptr;
my_schedule_fdb_work(dev, fdb_info, false);
break;
}
return NOTIFY_DONE;
}
static struct notifier_block my_switchdev_nb = {
.notifier_call = my_switchdev_event,
};
/* 모듈 초기화 시 등록 */
static int __init my_sw_init(void)
{
int err;
err = register_switchdev_notifier(&my_switchdev_nb);
if (err)
return err;
err = register_switchdev_blocking_notifier(
&my_switchdev_blocking_nb);
if (err) {
unregister_switchdev_notifier(&my_switchdev_nb);
return err;
}
return 0;
}
register_switchdev_blocking_notifier()는 블로킹 컨텍스트에서 호출되므로 직접 처리가 가능합니다.
UDP 터널 오프로드
VXLAN(Virtual Extensible LAN), Geneve 등의 UDP 터널은 NIC 하드웨어가 외부 UDP 포트를 인식해야 내부 패킷의 RSS, checksum offload 등이 올바르게 동작합니다. udp_tunnel_nic_info 구조체를 통해 드라이버는 지원하는 터널 유형과 최대 포트 수를 선언합니다.
| 터널 유형 | 기본 UDP 포트 | 대표 드라이버 지원 | 비고 |
|---|---|---|---|
| VXLAN | 4789 | mlx5, ice, bnxt, i40e | 가장 널리 사용되는 오버레이(Overlay) |
| Geneve | 6081 | mlx5, ice, bnxt | 유연한 TLV 옵션 지원 |
| VXLAN-GPE | 4790 | mlx5 | 다중 프로토콜 캡슐화(Encapsulation) |
| GTP-U | 2152 | 일부 SmartNIC | 5G/LTE 백홀(Backhaul) |
/* UDP 터널 오프로드 구성 */
static const struct udp_tunnel_nic_info my_tunnel_info = {
.set_port = my_udp_tunnel_set_port,
.unset_port = my_udp_tunnel_unset_port,
.sync_table = my_udp_tunnel_sync,
.flags = UDP_TUNNEL_NIC_INFO_MAY_SLEEP |
UDP_TUNNEL_NIC_INFO_OPEN_ONLY,
.tables = {
{
.n_entries = 2, /* 최대 2개 VXLAN 포트 */
.tunnel_types = UDP_TUNNEL_TYPE_VXLAN,
},
{
.n_entries = 2, /* 최대 2개 Geneve 포트 */
.tunnel_types = UDP_TUNNEL_TYPE_GENEVE,
},
},
};
/* set_port 콜백: HW에 UDP 포트 등록 */
static int my_udp_tunnel_set_port(
struct net_device *ndev,
unsigned int table, unsigned int entry,
struct udp_tunnel_info *ti)
{
struct my_priv *priv = netdev_priv(ndev);
u16 port = ntohs(ti->port);
netdev_info(ndev,
"adding UDP tunnel port %u type %d\n",
port, ti->type);
/* 하드웨어 레지스터에 터널 포트 설정 */
my_hw_write_tunnel_port(priv, table, entry, port,
ti->type);
/* RSS 해시 정책을 터널 내부 헤더 기반으로 변경 */
my_hw_set_inner_rss(priv, true);
return 0;
}
/* ndo_open 시 터널 포트 동기화 요청 */
static int my_ndo_open_with_tunnel(struct net_device *ndev)
{
int err;
err = my_hw_init(ndev);
if (err)
return err;
/* 커널에 등록된 터널 포트를 HW로 푸시 */
udp_tunnel_nic_reset_ntf(ndev);
netif_tx_start_all_queues(ndev);
return 0;
}
UDP_TUNNEL_NIC_INFO_OPEN_ONLY 플래그를 설정하면 인터페이스가 UP 상태일 때만 포트를 동기화합니다. 인터페이스가 DOWN 상태에서 터널이 생성/삭제되면, 다음 ndo_open() 시 udp_tunnel_nic_reset_ntf()를 호출하여 전체 테이블을 재동기화합니다.
오프로드 계약: checksum/GSO/GRO/VLAN
오프로드 기능은 “켜고 끄는 옵션”이 아니라 드라이버와 스택 사이의 계약입니다. advertise한 기능을 데이터 경로에서 일관되게 지키지 않으면 패킷 손실, checksum 오류, MTU 이상 동작이 발생합니다.
/* 개념 예시: feature dependency를 fix_features에서 강제 */
static netdev_features_t my_ndo_fix_features(struct net_device *ndev,
netdev_features_t features)
{
/* HW가 IPv6 TSO를 지원하지 않으면 강제 비활성화 */
if (!(features & NETIF_F_IP_CSUM))
features &= ~NETIF_F_TSO;
if (!my_hw_supports_tso6(ndev))
features &= ~NETIF_F_TSO6;
return features;
}
static netdev_features_t my_ndo_features_check(struct sk_buff *skb,
struct net_device *ndev,
netdev_features_t features)
{
/* 헤더 길이/세그먼트 조건 미충족 시 SW fallback */
if (skb_is_gso(skb) && skb_shinfo(skb)->gso_segs > 512)
features &= ~(NETIF_F_GSO_MASK);
return features;
}
| 기능군 | 관련 플래그/API | 드라이버 확인 포인트 |
|---|---|---|
| Checksum offload | NETIF_F_HW_CSUM, skb->ip_summed | partial checksum descriptor 구성 |
| TSO/GSO | NETIF_F_TSO*, gso_size | 세그먼트 제한, header split 처리 |
| GRO/LRO | napi_gro_receive() | 재조립 후 메타데이터 일관성 |
| VLAN offload | NETIF_F_HW_VLAN_CTAG_TX/RX | tag insert/strip와 통계 동기화 |
SR-IOV, representor, 스위치 모드 전환
클라우드 환경에서는 PF/VF 분리와 representor netdev 운영이 기본입니다. 드라이버는 legacy 모드와 switchdev 모드 전환 시 control-plane 일관성을 보장해야 합니다.
/* 개념 예시: eswitch 모드 전환과 대표자 netdev 동기화 */
static int my_eswitch_mode_set(struct my_priv *priv, u16 mode)
{
if (mode == DEVLINK_ESWITCH_MODE_SWITCHDEV)
return my_enable_representors(priv);
if (mode == DEVLINK_ESWITCH_MODE_LEGACY)
return my_disable_representors(priv);
return -EOPNOTSUPP;
}
TX 큐 선택: ndo_select_queue, XPS, CPU locality
멀티큐 NIC에서 ndo_start_xmit() 성능은 큐 선택 품질에 크게 좌우됩니다. 플로우 해시, CPU affinity, XPS 정책이 맞지 않으면 lock 경합과 cache miss가 급증합니다.
/* 개념 예시: qdisc/XPS 힌트를 반영한 TX queue 선택 */
static u16 my_ndo_select_queue(struct net_device *dev, struct sk_buff *skb,
struct net_device *sb_dev)
{
u32 hash = skb_get_hash(skb);
u16 q = reciprocal_scale(hash, dev->real_num_tx_queues);
/* 로컬 CPU 우선 정책이 있으면 q를 재매핑 */
q = my_xps_remap(dev, q, raw_smp_processor_id());
return q;
}
- XPS:
/sys/class/net/<dev>/queues/tx-*/xps_cpus와 드라이버 큐 매핑 일치 - RFS/RPS와 충돌 회피: RX CPU와 TX completion CPU가 지나치게 분산되지 않도록 설계
- NUMA: queue memory, IRQ, NAPI poll CPU를 같은 노드로 묶어 캐시 효율 확보
Doorbell 메커니즘과 DMA 메모리 배리어(Memory Barrier)
약한 메모리 모델 CPU(ARM64 등)에서 descriptor write와 doorbell MMIO write의 순서가 보장되지 않으면 간헐적 TX hang이 생깁니다. 게시 경로에서 barrier 사용 규칙을 문서화해야 합니다.
Doorbell이란?
Doorbell은 PCIe BAR 공간의 장치 레지스터(Register)에 MMIO write를 수행하여 NIC에 새 작업(디스크립터)이 준비되었음을 알리는 메커니즘입니다. Doorbell이 없으면 NIC이 링 버퍼(Ring Buffer)를 지속적으로 폴링해야 하며, 이는 PCIe 대역폭(Bandwidth) 낭비와 전력 소모 증가를 초래합니다.
Doorbell의 핵심 속성은 다음과 같습니다.
- 단일 원자적(Atomic) MMIO write: 일반적으로 4바이트(32비트) 크기이며, CPU의 단일 스토어 명령으로 실행됩니다.
- PCIe 트랜잭션(Transaction) 비용: doorbell 1회당 수백 나노초(200~500 ns)가 소요되며, PCIe TLP(Transaction Layer Packet) 오버헤드를 포함합니다.
- 메모리 배리어 선행 필수: doorbell 이전에 반드시 적절한 메모리 배리어(
dma_wmb())가 선행되어야 디바이스가 완전한 디스크립터를 읽을 수 있습니다.
서브시스템별 Doorbell 비교
Doorbell 메커니즘은 NIC뿐 아니라 다양한 PCIe 디바이스 서브시스템에서 사용됩니다. 아래 표는 주요 서브시스템별 doorbell 특성을 비교합니다.
| 서브시스템 | Doorbell 대상 | 기록 값 | 최적화 기법 |
|---|---|---|---|
| NIC TX | Tail Pointer 레지스터 | 마지막 디스크립터 인덱스 | 배치 게시 |
| NVMe | SQ Tail Doorbell | 큐 tail 포인터 | Shadow Doorbell Buffer |
| xHCI (USB 3.x) | Doorbell Array | EP 인덱스 | 스트림 기반 배치 |
| NTB (PCI) | Doorbell 비트맵(Bitmap) | 이벤트 비트 | 비트마스크 |
Doorbell 최적화 기법
Doorbell은 PCIe MMIO 트랜잭션이므로 호출 빈도를 줄이는 것이 성능 최적화의 핵심입니다. 주요 기법은 다음과 같습니다.
- 배치 게시(Batch Posting): 여러 디스크립터를 작성한 후 doorbell을 1회만 수행합니다. NVMe의
commit_rqs콜백이 대표적인 패턴으로, 다수의 SQ entry를 기록한 뒤 마지막에 한 번만 tail doorbell을 갱신합니다. NIC 드라이버에서도xmit_more플래그를 확인하여 배치 doorbell을 구현할 수 있습니다. - Shadow Doorbell: MMIO 대신 호스트 메모리 영역에 tail 값을 기록하고, 디바이스가 해당 메모리를 폴링하는 방식입니다. NVMe 1.3+ 스펙의 Shadow Doorbell Buffer가 대표적이며, PCIe MMIO 왕복 비용을 제거하여 수십 퍼센트의 IOPS 향상을 달성할 수 있습니다.
- Write Combining: doorbell 레지스터가 위치한 PCIe BAR 페이지를 WC(Write Combining) 매핑하여, 여러 doorbell write가 단일 PCIe 트랜잭션으로 합쳐지도록 합니다. 이를 통해 PCIe TLP 오버헤드를 줄이고 대역폭 효율을 향상시킵니다.
/* 개념 예시: doorbell 전 메모리 배리어 보장 */
static void my_post_tx_desc(struct my_priv *priv, struct my_desc *d)
{
priv->tx_ring[d->idx] = *d;
/* descriptor 메모리 write 완료 보장 */
dma_wmb();
/* 이후 doorbell write */
writel(d->idx, priv->tx_doorbell);
}
| 상황 | 권장 배리어 | 목적 |
|---|---|---|
| descriptor → MMIO doorbell | dma_wmb() | 디바이스가 완전한 descriptor만 보도록 보장 |
| MMIO status read 후 메모리 참조 | dma_rmb() | 완료 상태와 data buffer ordering 보장 |
| 일반 CPU 공유 데이터 | smp_wmb/rmb | 소프트웨어 스레드 간 ordering 보장 |
Busy Poll/NAPI 조합과 지연 최적화
초저지연 워크로드에서는 interrupt moderation보다 busy-poll이 유리할 수 있습니다. 드라이버는 NAPI 상태 전이를 안정적으로 유지해 busy-poll 사용자와 일반 트래픽이 충돌하지 않게 해야 합니다.
# 실습 예제: busy poll 파라미터 조정 및 즉시 확인
# 소켓 단위 busy poll (마이크로초)
sysctl -w net.core.busy_poll=50
sysctl -w net.core.busy_read=50
# NIC interrupt moderation과 함께 튜닝
ethtool -C eth0 rx-usecs 0
ethtool -C eth0 tx-usecs 0
Busy Poll 내부 동작 원리
Busy poll의 핵심은 napi_busy_loop() 함수입니다. 소켓이 데이터를 기다릴 때 인터럽트 기반 수신 대신 NAPI poll 함수를 직접 호출하여 패킷을 가져옵니다. 이 과정에서 커널은 sk_can_busy_loop()으로 소켓이 busy poll 가능 상태인지 먼저 확인합니다.
/* net/core/dev.c - napi_busy_loop() 핵심 경로 분석 */
void napi_busy_loop(unsigned int napi_id,
bool (*loop_end)(void *, unsigned long),
void *loop_end_arg, bool prefer_busy_poll,
u16 budget)
{
unsigned long start_time = loop_end ? busy_loop_current_time() : 0;
int (*napi_poll)(struct napi_struct *napi, int budget);
struct napi_struct *napi;
restart:
napi_poll = NULL;
rcu_read_lock();
napi = napi_by_id(napi_id);
if (!napi)
goto out;
/* NAPI가 스케줄 가능 상태인지 확인 */
if (!test_bit(NAPI_STATE_SCHED, &napi->state))
goto out;
/* prefer_busy_poll 플래그로 NAPI 독점 모드 요청 */
if (prefer_busy_poll)
set_bit(NAPI_STATE_PREFER_BUSY_POLL, &napi->state);
for (;;) {
/* napi_poll 함수 포인터를 통해 드라이버의 poll 직접 호출 */
work = napi_poll(napi, budget);
if (loop_end && loop_end(loop_end_arg, start_time))
break;
/* 타임아웃 또는 시그널 확인 */
if (need_resched())
break;
cpu_relax(); /* 전력 소모를 약간 줄이는 힌트 */
}
out:
rcu_read_unlock();
}
sk_can_busy_loop()은 소켓의 busy poll 적격 여부를 판단합니다. NAPI ID가 할당되어 있고, 소켓에 SO_BUSY_POLL 옵션이 설정되어 있어야 합니다.
/* include/net/busy_poll.h - busy poll 적격 판단 */
static inline bool sk_can_busy_loop(const struct sock *sk)
{
/* 1) NAPI ID가 유효한지 (드라이버가 할당했는지) */
/* 2) 소켓에 busy_poll 타임아웃이 설정되어 있는지 */
/* 3) 소켓이 커널에 의해 잠기지 않았는지 */
return sk->sk_napi_id &&
READ_ONCE(sk->sk_ll_usec) &&
!skb_queue_empty_lockless(&sk->sk_receive_queue);
}
Poll 예산(Budget)은 busy-poll 모드에서 일반 NAPI poll과 다르게 동작합니다. 기본 NAPI poll의 예산이 64인 반면, busy-poll에서는 소켓별로 설정된 예산을 사용하며, 이는 처리량과 독점 방지 사이의 균형을 조절합니다. NAPI_STATE_SCHED 비트가 설정되어 있어야 busy-poll이 NAPI를 접근할 수 있으며, 일반 인터럽트 기반 NAPI 스케줄링과 상호 배제(Mutual Exclusion)됩니다.
드라이버 Busy Poll 지원 요구사항
현대 커널(v4.11+)에서 busy poll은 더 이상 별도의 ndo_busy_poll 콜백을 필요로 하지 않습니다. 대신 NAPI 기반 busy poll을 사용하며, 드라이버가 NAPI를 올바르게 구현하고 napi_id를 소켓에 연결하면 자동으로 지원됩니다.
드라이버의 busy poll 지원 핵심 요구사항은 다음과 같습니다.
- NAPI ID 할당:
netif_napi_add()호출 시 자동으로 고유napi_id가 할당됩니다. - 소켓-NAPI 연결: RX 경로에서
skb_mark_napi_id()를 호출하여 소켓이 어느 NAPI에서 패킷을 받는지 기록합니다. - prefer_busy_poll 플래그:
NAPI_STATE_PREFER_BUSY_POLL비트가 설정되면 일반 인터럽트 기반 NAPI 스케줄링이 양보(Yield)합니다. - 예산 존중: poll 함수가 전달받은 budget 인자를 정확하게 존중해야 합니다.
/* 드라이버 busy poll 지원 확인 패턴 */
static int my_driver_rx_poll(struct napi_struct *napi, int budget)
{
struct my_rx_ring *ring = container_of(napi, struct my_rx_ring, napi);
int work_done = 0;
while (work_done < budget) {
struct sk_buff *skb = my_fetch_rx_packet(ring);
if (!skb)
break;
/* 핵심: NAPI ID를 skb에 기록 → 소켓과 NAPI 연결 */
skb_mark_napi_id(skb, napi);
napi_gro_receive(napi, skb);
work_done++;
}
if (work_done < budget) {
napi_complete_done(napi, work_done);
/* 인터럽트 재활성화 */
my_enable_rx_irq(ring);
}
return work_done;
}
/* 드라이버 초기화에서 NAPI 등록 */
static void my_driver_init_napi(struct my_priv *priv)
{
/* netif_napi_add()가 napi_id를 자동 할당 */
netif_napi_add(priv->netdev, &priv->rx_ring.napi,
my_driver_rx_poll);
napi_enable(&priv->rx_ring.napi);
}
소켓 레벨 Busy Poll 설정
소켓 레벨에서 busy poll을 활성화하는 방법은 두 가지입니다. 시스템 전역 sysctl 설정과 소켓별 옵션 설정이며, 소켓별 설정이 우선합니다. epoll 기반 이벤트 루프(Event Loop)에서도 busy poll을 활용할 수 있습니다.
/* 애플리케이션 레벨 busy poll 설정 예시 */
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>
int setup_busy_poll_socket(int sockfd)
{
int busy_poll_usec = 50; /* 50μs busy poll 시간 */
int prefer_busy_poll = 1; /* busy poll 우선 모드 */
int busy_budget = 8; /* poll당 최대 처리 패킷 수 */
/* SO_BUSY_POLL: 소켓별 busy poll 타임아웃 (μs) */
setsockopt(sockfd, SOL_SOCKET, SO_BUSY_POLL,
&busy_poll_usec, sizeof(busy_poll_usec));
/* SO_PREFER_BUSY_POLL: 인터럽트 대신 busy poll 우선 */
setsockopt(sockfd, SOL_SOCKET, SO_PREFER_BUSY_POLL,
&prefer_busy_poll, sizeof(prefer_busy_poll));
/* SO_BUSY_POLL_BUDGET: poll 1회당 처리 예산 */
setsockopt(sockfd, SOL_SOCKET, SO_BUSY_POLL_BUDGET,
&busy_budget, sizeof(busy_budget));
return 0;
}
/* epoll 기반 busy poll 활용 */
int busy_poll_event_loop(int epfd, int sockfd)
{
struct epoll_event events[64];
setup_busy_poll_socket(sockfd);
for (;;) {
/* epoll_wait timeout=0 → busy poll이 데이터 폴링 */
int n = epoll_wait(epfd, events, 64, 0);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
/* 데이터 즉시 수신 가능 — 지연 최소화 */
process_packet(events[i].data.fd);
}
}
}
}
| 설정 방법 | 범위 | 파라미터 | 설명 |
|---|---|---|---|
net.core.busy_poll | 시스템 전역 | μs 단위 타임아웃 | poll()/select() 시 기본 busy poll 시간 |
net.core.busy_read | 시스템 전역 | μs 단위 타임아웃 | read()/recv() 시 기본 busy poll 시간 |
SO_BUSY_POLL | 소켓별 | μs 단위 타임아웃 | 소켓별 busy poll 타임아웃 (전역 설정 오버라이드) |
SO_PREFER_BUSY_POLL | 소켓별 | 0 또는 1 | 인터럽트 대신 busy poll 우선 사용 |
SO_BUSY_POLL_BUDGET | 소켓별 | 패킷 수 | busy poll 1회당 최대 처리 패킷 |
Busy Poll 성능 분석과 트레이드오프
Busy poll은 지연 시간을 극적으로 줄이지만 CPU 사용률이라는 대가를 치릅니다. 워크로드 특성에 따라 이 트레이드오프가 유리할 수도, 불리할 수도 있습니다.
| 수신 방식 | 평균 지연 | P99 지연 | CPU 사용률 | 적합 시나리오 |
|---|---|---|---|---|
| 인터럽트 기반 (기본) | 높음 | 높음 | 낮음 (유휴 가능) | 범용 서버, 배치 처리 |
| 인터럽트 + coalescing | 높음 | 매우 높음 | 매우 낮음 | 고대역폭, CPU 절약 |
| Busy Poll (SO_BUSY_POLL) | 낮음 | 낮음 | 높음 (코어 점유) | 초저지연 트레이딩, HFT |
| Busy Poll + prefer | 매우 낮음 | 매우 낮음 | 매우 높음 (100%) | 전용 코어 할당 가능 환경 |
| XDP (커널 바이패스) | 매우 낮음 | 매우 낮음 | 중간 | L2/L3 패킷 처리, 방화벽(Firewall) |
| DPDK (완전 사용자 공간(User Space)) | 매우 낮음 | 매우 낮음 | 매우 높음 | 통신사 패킷 처리 |
Busy poll이 유리한 경우:
- 고빈도 트레이딩(HFT, High-Frequency Trading) 환경에서 마이크로초 단위 지연이 수익에 직결되는 경우
- DPDK 도입이 어려운 환경에서 커널 네트워크 스택을 유지하면서 지연을 최소화해야 하는 경우
- 전용 CPU 코어를 네트워크 처리에 할당할 수 있는 경우 (
isolcpus,cpuset활용) - 패킷 도착률이 높아 인터럽트 오버헤드가 CPU 부하의 주요 원인인 경우
Busy poll이 불리한 경우:
- 멀티 테넌트(Multi-tenant) 환경에서 CPU를 공유해야 하는 경우
- 전력 소모가 제한된 임베디드(Embedded) 또는 모바일 환경
- 패킷 도착률이 낮아 대부분의 시간을 유휴 폴링에 소비하는 경우
- 다수의 소켓에서 동시에 busy poll을 사용하여 CPU 경합이 발생하는 경우
# bpftrace로 busy poll 효과 측정
# napi_busy_loop 진입/종료 시간 측정
bpftrace -e '
kprobe:napi_busy_loop {
@start[tid] = nsecs;
}
kretprobe:napi_busy_loop /@start[tid]/ {
@busy_loop_ns = hist(nsecs - @start[tid]);
delete(@start[tid]);
}
'
# busy poll에서 실제 패킷을 처리한 비율 확인
bpftrace -e '
tracepoint:napi:napi_poll {
@total++;
if (args->work > 0) { @useful++; }
}
interval:s:5 {
printf("유효 poll 비율: %d/%d (%d%%)\n",
@useful, @total,
@total ? @useful * 100 / @total : 0);
clear(@total); clear(@useful);
}
'
# 지연 시간 비교: busy poll ON vs OFF
# 1) busy poll 비활성화 상태 측정
sysctl -w net.core.busy_poll=0
sockperf under-load -i 10.0.0.1 -p 12345 --mps=10000 -t 30
# 2) busy poll 활성화 상태 측정
sysctl -w net.core.busy_poll=50
sockperf under-load -i 10.0.0.1 -p 12345 --mps=10000 -t 30
QoS/DCB: mqprio, ETS, PFC 운영 포인트
데이터센터 환경에서는 대역폭 분배와 무손실 트래픽 제어(Traffic Control)가 중요합니다. 드라이버가 mqprio, DCB, PFC를 부분 지원하는 경우 지원 범위를 명확히 노출해야 운영 오해를 줄일 수 있습니다.
# 실습 예제: mqprio/ethtool로 큐 정책 검증
# mqprio qdisc 예시 (TC별 큐 매핑)
tc qdisc replace dev eth0 root mqprio num_tc 4 \
map 0 1 2 3 3 3 3 3 \
queues 1@0 1@1 2@2 4@4 hw 1
# DCB/PFC 상태 확인 예시 (환경별 도구 상이)
dcbtool gc eth0 dcb
ethtool --show-priv-flags eth0
| 항목 | 드라이버 책임 | 실패 시 증상 |
|---|---|---|
| TC→queue 매핑 | qdisc 설정과 HW scheduler 동기화 | 특정 클래스 starvation |
| PFC | priority별 pause on/off 적용 | drop 급증 또는 head-of-line blocking |
| ETS | bandwidth share를 HW arbitration에 반영 | 대역폭 분배 불일치 |
mqprio hw vs sw 모드
mqprio qdisc는 트래픽 클래스(Traffic Class, TC)별로 TX 큐를 매핑하는 멀티큐 우선순위 스케줄러(Scheduler)입니다. hw 0(소프트웨어 모드)과 hw 1(하드웨어 오프로드 모드)의 차이를 정확히 이해해야 올바른 QoS 정책을 구현할 수 있습니다.
hw 1 모드에서는 TC_MQPRIO_HW_OFFLOAD_TCS 플래그가 드라이버에 전달되며, 드라이버는 하드웨어 스케줄러에 TC-to-queue 매핑을 프로그래밍해야 합니다. 소프트웨어 모드에서는 커널이 큐 선택만 수행하고 실제 우선순위 처리는 하지 않습니다.
| 속성 | hw 0 (소프트웨어) | hw 1 (하드웨어 오프로드) |
|---|---|---|
| 큐 매핑 | 커널이 TC→큐 매핑 수행 | 드라이버가 HW scheduler에 프로그래밍 |
| 우선순위 보장 | 없음 (단순 큐 분리) | 하드웨어 레벨 strict/WRR 지원 |
| 대역폭 제어 | 불가 | ETS/rate limit 가능 |
| 드라이버 콜백 | 불필요 | ndo_setup_tc 필수 |
| 성능 영향 | 최소 | 하드웨어 의존적 |
# mqprio 설정 및 검증 예시
# 1. 소프트웨어 모드: TC별 큐 분리만 수행
tc qdisc replace dev eth0 root mqprio \
num_tc 4 \
map 0 1 2 3 3 3 3 3 0 1 2 3 3 3 3 3 \
queues 2@0 2@2 2@4 2@6 \
hw 0
# 2. 하드웨어 오프로드 모드: NIC scheduler에 TC 매핑
tc qdisc replace dev eth0 root mqprio \
num_tc 4 \
map 0 1 2 3 3 3 3 3 0 1 2 3 3 3 3 3 \
queues 2@0 2@2 2@4 2@6 \
hw 1 \
mode dcb
# 3. 채널(Channel) 모드: TC별 독립 qdisc 연결 가능
tc qdisc replace dev eth0 root mqprio \
num_tc 3 \
map 0 1 2 2 2 2 2 2 \
queues 4@0 4@4 4@8 \
hw 1 \
mode channel \
shaper bw_rlimit \
min_rate 1Gbit 2Gbit 0 \
max_rate 5Gbit 8Gbit 10Gbit
# 검증: TC 매핑 확인
tc qdisc show dev eth0
tc class show dev eth0
# 큐별 패킷 통계로 분배 확인
ethtool -S eth0 | grep -E "tx_queue_[0-9]+_packets"
PFC 데드락 방지와 워치독
우선순위 기반 흐름 제어(Priority Flow Control, PFC)는 IEEE 802.1Qbb 표준으로, 특정 우선순위의 트래픽에 대해서만 pause 프레임(Pause Frame)을 보내 무손실(Lossless) 전송을 보장합니다. 그러나 PFC 스톰(PFC Storm)이 발생하면 해당 우선순위의 트래픽이 완전히 차단되는 데드락(Deadlock) 상태에 빠질 수 있습니다.
PFC pause 프레임의 동작 원리는 다음과 같습니다.
- 수신 측: 수신 버퍼가 임계값에 도달하면 해당 우선순위의 pause 프레임을 송신 측에 전송합니다.
- 송신 측: pause 프레임을 수신하면 해당 우선순위의 전송을 일시 중단합니다. 다른 우선순위의 트래픽은 영향받지 않습니다.
- 타이머: pause 프레임에는 quanta 단위의 타이머가 포함되어 있어, 타이머 만료 후 자동으로 전송이 재개됩니다.
PFC 스톰은 수신 측이 지속적으로 pause 프레임을 보내는 상태로, 네트워크 전체로 전파(Head-of-Line Blocking)될 수 있습니다. 이를 감지하고 자동 복구하는 워치독(Watchdog) 메커니즘이 필요합니다.
# PFC 설정 및 모니터링
# 1. PFC 활성화 (priority 3, 4에 대해)
mlnx_qos -i eth0 --pfc 0,0,0,1,1,0,0,0
# lldptool을 사용한 PFC 설정 (lldpad 사용 시)
lldptool -T -i eth0 -V PFC enabled=0,0,0,1,1,0,0,0
# 2. PFC 카운터 모니터링
ethtool -S eth0 | grep -iE "pfc|pause"
# 주요 카운터:
# rx_pfc_pri_N_pause - 수신한 PFC pause 프레임 수
# tx_pfc_pri_N_pause - 송신한 PFC pause 프레임 수
# rx_pfc_pri_N_duration - pause 지속 시간 (quanta)
# 3. PFC 워치독 상태 확인 (드라이버 지원 시)
devlink health show pci/0000:03:00.0 reporter tx
# 4. PFC 스톰 감지 스크립트
PREV_PAUSE=0
while true; do
CURR_PAUSE=$(ethtool -S eth0 | grep rx_pfc_pri_3_pause | awk '{print $2}')
RATE=$((CURR_PAUSE - PREV_PAUSE))
if [ $RATE -gt 1000 ]; then
echo "[경고] PFC 스톰 의심: priority 3, rate=$RATE/sec"
# 자동 대응: PFC 비활성화 또는 관리자 알림
fi
PREV_PAUSE=$CURR_PAUSE
sleep 1
done
CBS/TAS IEEE 802.1Qav/Qbv 오프로드
시간 민감 네트워킹(Time-Sensitive Networking, TSN)에서는 CBS(Credit Based Shaper, IEEE 802.1Qav)와 TAS(Time-Aware Shaper, IEEE 802.1Qbv)가 핵심 트래픽 제어 메커니즘입니다. 리눅스 커널은 cbs와 taprio qdisc를 통해 이들을 지원하며, 하드웨어 오프로드가 가능한 NIC에서는 정밀한 타이밍 제어가 가능합니다.
- CBS (802.1Qav): 크레딧 기반 쉐이핑으로, 각 트래픽 클래스에 대역폭 한도를 설정합니다. 크레딧이 양수일 때만 전송이 허용되어 대역폭을 공정하게 분배합니다.
- TAS (802.1Qbv): 시간 기반 게이트 제어로, 사전 정의된 스케줄에 따라 특정 시점에 특정 트래픽 클래스만 전송을 허용합니다. 결정론적(Deterministic) 지연을 보장합니다.
- taprio qdisc: TAS를 리눅스에서 구현한 qdisc입니다.
flags 0x2로 하드웨어 오프로드,flags 0x1로 전체 오프로드를 설정합니다.
# TSN 설정 예시
# 1. CBS qdisc 설정 (802.1Qav)
# TC 0에 대해 idleSlope=100Mbit, sendSlope=-900Mbit (1Gbit 링크 기준)
tc qdisc replace dev eth0 parent root handle 100 mqprio \
num_tc 3 map 2 2 1 0 2 2 2 2 \
queues 1@0 1@1 2@2 hw 0
tc qdisc replace dev eth0 parent 100:1 cbs \
idleslope 100000 sendslope -900000 \
hicredit 12 locredit -88 offload 1
# 2. taprio qdisc 설정 (802.1Qbv)
# 1ms 주기: TC0 200us, TC1 300us, TC2 500us
tc qdisc replace dev eth0 parent root taprio \
num_tc 3 \
map 2 2 1 0 2 2 2 2 2 2 2 2 2 2 2 2 \
queues 1@0 1@1 2@2 \
base-time 1000000000 \
sched-entry S 01 200000 \
sched-entry S 02 300000 \
sched-entry S 04 500000 \
flags 0x2 \
clockid CLOCK_TAI
# 검증: taprio 스케줄 확인
tc qdisc show dev eth0 root
# PTP 클럭 동기화 확인 (TSN 필수 조건)
ptp4l -i eth0 -m &
phc2sys -s eth0 -c CLOCK_REALTIME -w -m &
TSN 기능별 드라이버 지원 현황은 다음과 같습니다.
| TSN 기능 | qdisc | igc (Intel i225) | stmmac (Intel EHL) | enetc (NXP) | am65 (TI) |
|---|---|---|---|---|---|
| CBS (802.1Qav) | cbs | HW 오프로드 | HW 오프로드 | HW 오프로드 | HW 오프로드 |
| TAS (802.1Qbv) | taprio | HW 오프로드 | HW 오프로드 | HW 오프로드 | HW 오프로드 |
| Frame Preemption (802.1Qbu) | ethtool | 지원 | 지원 | 미지원 | 미지원 |
| PTP (802.1AS) | ptp4l | HW 타임스탬프 | HW 타임스탬프 | HW 타임스탬프 | HW 타임스탬프 |
| Launch Time (ETF) | etf | 지원 | 지원 | 지원 | 미지원 |
PREEMPT_RT와 NAPI threaded 모드
실시간 커널에서는 IRQ/softirq 모델이 일반 커널과 다르게 동작합니다. 드라이버는 spinlock 길이를 줄이고, napi poll 지연 상한을 보장하도록 설계해야 합니다.
- 긴 IRQ-off 구간 제거: TX reclaim 루프를 짧게 쪼개고 예산 기반 처리 유지
- 잠금 경쟁 최소화: 큐별 잠금 분리, 필요 시 lockless ring 고려
- 우선순위 설계: IRQ thread/NAPI 실행 CPU를 RT 태스크(Task)와 분리
- 지연 측정:
cyclictest와 네트워크 부하를 동시 실행해 tail latency 확인
NAPI threaded 모드 내부 구현
커널 5.12부터 도입된 NAPI 스레드 모드(Threaded Mode)는 softirq 컨텍스트 대신 전용 커널 스레드에서 NAPI poll을 실행합니다. 이를 통해 cgroup 기반 CPU 제어, 우선순위 설정, CPU 친화성(Affinity) 지정이 가능해지며, PREEMPT_RT 환경에서 특히 유용합니다.
dev_set_threaded() API를 호출하면 해당 net_device에 등록된 모든 NAPI 인스턴스에 대해 전용 스레드가 생성됩니다. 내부적으로 NAPI_STATE_THREADED 플래그가 설정되며, 이후 napi_schedule()은 softirq 대신 해당 스레드를 깨웁니다.
/* NAPI threaded 모드 활성화 - 드라이버 코드 예시 */
/* probe 함수에서 threaded 모드 활성화 */
static int my_probe(struct pci_dev *pdev,
const struct pci_device_id *id)
{
struct net_device *ndev;
struct my_priv *priv;
int err;
/* ... 기본 초기화 ... */
/* NAPI 등록 */
for (int i = 0; i < priv->num_queues; i++) {
netif_napi_add(ndev, &priv->queues[i].napi,
my_poll);
}
/* threaded NAPI 활성화
* 커널이 napi-N 형태의 kthread를 자동 생성합니다
* 예: napi/eth0-0, napi/eth0-1, ... */
err = dev_set_threaded(ndev, true);
if (err)
netdev_warn(ndev,
"threaded NAPI 활성화 실패: %d\n", err);
/* ... 나머지 초기화 ... */
return 0;
}
/* napi_threaded_poll() 커널 소스 분석 (net/core/dev.c)
*
* static int napi_threaded_poll(void *data)
* {
* struct napi_struct *napi = data;
*
* while (!kthread_should_stop()) {
* // NAPI_STATE_SCHED 플래그 대기
* set_current_state(TASK_INTERRUPTIBLE);
* while (!test_bit(NAPI_STATE_SCHED, &napi->state))
* schedule();
* set_current_state(TASK_RUNNING);
*
* // poll 실행 (budget = netdev_budget)
* napi_threaded_poll_loop(napi);
* }
* }
*/
# sysfs를 통한 NAPI threaded 모드 제어
# 현재 상태 확인
cat /sys/class/net/eth0/threaded
# 1 = threaded 모드 활성, 0 = softirq 모드
# 활성화
echo 1 > /sys/class/net/eth0/threaded
# NAPI 스레드 확인
ps -eo pid,cls,pri,ni,comm | grep napi
# NAPI 스레드 CPU 친화성 설정
# 예: 큐 0은 CPU 2에, 큐 1은 CPU 3에 고정
for pid in $(pgrep -f "napi/eth0"); do
echo "PID $pid: $(cat /proc/$pid/comm)"
taskset -pc $pid
done
taskset -pc 2 $(pgrep -f "napi/eth0-0")
taskset -pc 3 $(pgrep -f "napi/eth0-1")
# RT 우선순위 설정 (SCHED_FIFO)
chrt -f -p 50 $(pgrep -f "napi/eth0-0")
PREEMPT_RT에서의 네트워크 스택 동작
PREEMPT_RT 패치(Patch)가 적용된 커널에서는 네트워크 스택의 동작이 크게 달라집니다. 모든 인터럽트가 스레드화(Forced Threading)되고, spinlock이 슬리핑 뮤텍스(Mutex)로 변환되며, local_bh_disable()이 뮤텍스 기반 보호로 변경됩니다. 이러한 변화가 드라이버에 미치는 영향을 이해해야 합니다.
| 항목 | 일반 커널 | PREEMPT_RT 커널 | 드라이버 영향 |
|---|---|---|---|
| 하드 IRQ | 인터럽트 컨텍스트에서 실행 | 스레드에서 실행 (강제 스레딩) | IRQ 핸들러에서 preemption 가능 |
| softirq | ksoftirqd 또는 IRQ 반환 시 실행 | 전용 스레드 (rcuc/N 등) | NAPI poll 지연 증가 가능 |
| spinlock | busy-wait, preemption 비활성 | rt_mutex (슬리핑 가능) | 우선순위 역전(Priority Inversion) 해소 |
| local_bh_disable | softirq 비활성화 | per-CPU 뮤텍스 획득 | 경합 시 슬리핑 가능 |
| raw_spinlock | spinlock과 동일 | 진짜 spinlock (busy-wait 유지) | 극히 짧은 임계 구간에만 사용 |
| timer softirq | softirq에서 처리 | 전용 스레드 | 타이머 콜백 지연 가능 |
/* PREEMPT_RT 안전한 드라이버 코드 패턴 */
/* 1. raw_spinlock: 진짜 인터럽트 비활성화가 필요한 최소 구간 */
struct my_priv {
raw_spinlock_t irq_lock; /* HW 레지스터 접근 보호 */
spinlock_t config_lock; /* 설정 변경 보호 (RT에서 mutex) */
};
/* IRQ 핸들러: raw_spinlock 사용 (최소한의 작업만) */
static irqreturn_t my_irq_handler(int irq, void *data)
{
struct my_priv *priv = data;
u32 status;
raw_spin_lock(&priv->irq_lock);
status = my_read_isr(priv);
if (!status) {
raw_spin_unlock(&priv->irq_lock);
return IRQ_NONE;
}
/* 인터럽트 비활성화하고 NAPI 스케줄 */
my_disable_irq(priv);
raw_spin_unlock(&priv->irq_lock);
napi_schedule_irqoff(&priv->napi);
return IRQ_HANDLED;
}
/* 2. 설정 경로: 일반 spinlock (RT에서 mutex로 변환) */
static int my_set_features(struct net_device *ndev,
netdev_features_t features)
{
struct my_priv *priv = netdev_priv(ndev);
/* RT 커널에서 이 lock은 sleeping mutex로 동작
* → 우선순위 상속(Priority Inheritance) 지원 */
spin_lock(&priv->config_lock);
my_apply_features(priv, features);
spin_unlock(&priv->config_lock);
return 0;
}
실시간 네트워크 지연 측정
실시간 네트워크 시스템의 성능은 평균 처리량이 아니라 최악의 경우 지연으로 평가합니다. cyclictest, oslat, hwlatdetect 등의 도구를 네트워크 부하와 함께 실행하여 실제 운영 환경에서의 꼬리 지연을 측정해야 합니다.
- cyclictest: 타이머 기반 지연 측정 도구로, 스레드 깨우기(Wakeup) 지연을 마이크로초 단위로 측정합니다. 네트워크 부하를 동시에 걸어 간섭(Interference) 효과를 측정합니다.
- oslat: Red Hat이 개발한 OS 레벨 지연 측정 도구로, busy-loop 기반으로 OS가 유발하는 지터(Jitter)를 정밀하게 측정합니다.
- hwlatdetect: 하드웨어가 유발하는 지연(SMI, NMI 등)을 감지합니다. 네트워크 장비에서 SMI(System Management Interrupt)가 간헐적 패킷 손실의 원인인 경우가 있습니다.
- 네트워크 RTT:
ping -i 0.001이나 전용 PTP(Precision Time Protocol) 장비로 네트워크 왕복 지연을 측정합니다.
# RT 네트워크 지연 종합 테스트 스크립트
#!/bin/bash
DURATION=300 # 5분 테스트
RT_PRIO=80
ISOL_CPUS="2,3" # 격리된 CPU
NET_IF="eth0"
RESULTS_DIR="/tmp/rt-net-test-$(date +%Y%m%d_%H%M%S)"
mkdir -p $RESULTS_DIR
echo "=== RT 네트워크 지연 테스트 시작 ==="
echo "기간: ${DURATION}초, RT 우선순위: $RT_PRIO, 격리 CPU: $ISOL_CPUS"
# 1. 하드웨어 지연 감지 (SMI 등)
echo "--- hwlatdetect (60초) ---"
hwlatdetect --duration=60 --threshold=10 | tee $RESULTS_DIR/hwlat.log
# 2. 네트워크 부하 생성 (백그라운드)
echo "--- 네트워크 부하 생성 ---"
iperf3 -c 10.0.0.2 -t $DURATION -P 4 --bind-dev $NET_IF &
IPERF_PID=$!
# 3. cyclictest 실행 (격리 CPU에서)
echo "--- cyclictest 실행 ---"
cyclictest \
--mlockall \
--smp \
--priority=$RT_PRIO \
--interval=1000 \
--distance=0 \
--duration=$DURATION \
--affinity=$ISOL_CPUS \
--histofall=1000 \
--histfile=$RESULTS_DIR/cyclictest-hist.txt \
| tee $RESULTS_DIR/cyclictest.log &
CYCLIC_PID=$!
# 4. oslat 실행 (별도 CPU)
echo "--- oslat 실행 ---"
oslat \
--duration $DURATION \
--rtprio $RT_PRIO \
--cpu-list $ISOL_CPUS \
| tee $RESULTS_DIR/oslat.log &
OSLAT_PID=$!
# 5. 네트워크 RTT 측정
echo "--- ping RTT 측정 ---"
ping -i 0.01 -c $((DURATION * 100)) -D 10.0.0.2 \
| tee $RESULTS_DIR/ping.log &
PING_PID=$!
# 대기 및 결과 수집
wait $CYCLIC_PID $OSLAT_PID $PING_PID
kill $IPERF_PID 2>/dev/null
# 6. 결과 요약
echo "\n=== 결과 요약 ==="
echo "--- cyclictest 최대 지연 ---"
grep -E "Max|Avg" $RESULTS_DIR/cyclictest.log
echo "--- ping 통계 ---"
tail -3 $RESULTS_DIR/ping.log
echo "결과 디렉터리: $RESULTS_DIR"
관련 문서
- 네트워크 디바이스 드라이버 (net_device) — 구조·콜백 레퍼런스
- 네트워크 드라이버 구현 가이드 (net_device) — 구현 실습
- NAPI (New API) — 폴링 메커니즘
- BPF/XDP — 드라이버 레벨 고속 패킷 처리
- AF_XDP — 유저스페이스 zero-copy 경로
- 하드웨어 타임스탬핑 — PTP 패킷 타임스탬프