GSO/GRO와 네트워크 오프로드
Linux 커널 네트워크 오프로드 메커니즘: 체크섬 오프로드 플래그, GSO(Generic Segmentation Offload)/TSO/UFO, GRO(Generic Receive Offload) 병합 알고리즘, VXLAN/GRE 터널 GSO, HW-GRO, 성능 튜닝 종합 가이드. 커널 내부 데이터 경로, 핵심 자료구조/API, 운영 환경 튜닝 포인트와 장애 디버깅 절차까지 실무 관점으로 다룹니다.
핵심 요약
- 체크섬 오프로드 — CHECKSUM_NONE/UNNECESSARY/PARTIAL/COMPLETE 플래그로 NIC에 체크섬 계산을 위임.
- GSO — 커널이 큰 세그먼트를 유지하다 NIC 직전에 분할. TSO 지원 NIC면 NIC가 분할.
- GRO — napi_gro_receive()에서 연속 패킷을 병합해 프로토콜 스택 호출 횟수 감소.
- 터널 처리 — VXLAN/GRE 등 터널에서 내부 헤더까지 고려한 GSO 분할 필요.
- HW-GRO — NIC가 하드웨어에서 GRO 수행, 드라이버가 gro_list로 전달.
단계별 이해
- 체크섬 오프로드 이해
sk_buff의 ip_summed 필드와 4가지 체크섬 플래그 의미를 먼저 파악합니다. - GSO 전송 흐름
dev_queue_xmit() → validate_xmit_skb() → gso_segment() 경로를 추적합니다. - GRO 수신 흐름
napi_gro_receive() → napi_skb_finish() → skb merge 경로를 확인합니다. - 성능 측정
ethtool -k로 NIC 오프로드 기능 확인, ethtool -K로 켜고 끄며 효과 측정합니다.
체크섬 오프로드 (Checksum Offload)
ip_summed 필드는 sk_buff의 체크섬 처리 상태를 나타냅니다. NIC 하드웨어 체크섬 오프로드를 제어하는 핵심 필드입니다:
| 모드 | 수신 (RX) | 전송 (TX) |
|---|---|---|
CHECKSUM_NONE |
HW 체크섬 미지원. 소프트웨어가 검증해야 함 | 소프트웨어가 체크섬을 이미 계산 완료 |
CHECKSUM_UNNECESSARY |
HW가 체크섬 검증 완료, 유효함 | 체크섬 불필요 (loopback 등) |
CHECKSUM_COMPLETE |
HW가 전체 패킷 체크섬을 skb->csum에 제공 |
사용하지 않음 |
CHECKSUM_PARTIAL |
사용하지 않음 | HW에게 체크섬 계산 위임. csum_start/csum_offset 설정 필요 |
/* 수신 경로: 드라이버에서 체크섬 상태 설정 */
static void my_rx_handler(struct sk_buff *skb, bool hw_csum_ok)
{
if (hw_csum_ok) {
/* NIC가 체크섬 검증 완료 → 소프트웨어 재검증 생략 */
skb->ip_summed = CHECKSUM_UNNECESSARY;
} else {
/* HW 미지원 → 프로토콜 스택이 직접 검증 */
skb->ip_summed = CHECKSUM_NONE;
}
}
/* 전송 경로: 소프트웨어 체크섬 fallback */
if (skb->ip_summed == CHECKSUM_PARTIAL) {
/* NIC가 NETIF_F_HW_CSUM을 지원하지 않으면 */
if (skb_checksum_help(skb)) /* SW로 체크섬 계산 */
goto drop;
}
/* 체크섬 관련 유틸리티 */
skb_checksum(skb, offset, len, 0); /* skb 데이터의 체크섬 계산 */
csum_tcpudp_magic(saddr, daddr, len, proto, csum); /* pseudo-header 포함 */
skb_postpull_rcsum(skb, hdr, hdr_len); /* pull 후 csum 보정 */
GSO/GRO와 sk_buff
GSO (Generic Segmentation Offload)와 GRO (Generic Receive Offload)는 대량 데이터 전송/수신 시 성능을 극대화하는 핵심 메커니즘입니다. 기본 원리는 단순합니다: 네트워크 스택을 통과하는 패킷 수를 줄여 per-packet 오버헤드(헤더 파싱, 룩업, lock 경합, cache miss)를 최소화합니다.
핵심 개념: GSO는 전송(TX) 방향에서 대형 skb를 마지막 순간에 분할하고, GRO는 수신(RX) 방향에서 작은 패킷들을 하나의 대형 skb로 병합합니다. 둘 다 네트워크 스택 통과를 한 번으로 줄여 성능을 극대화합니다. MTU=1500 기준 64KB 데이터 처리 시, ~43개 패킷을 개별 처리하는 대신 1개의 대형 skb로 스택을 한 번만 통과합니다.
napi_gro_receive() 호출 경로, GRO flush 타이머, busy polling과의 연동 등 NAPI 관점의 GRO 통합 아키텍처는 NAPI 심화 — GRO 통합 문서를 참고하세요.GSO (전송 오프로드) 심화
GSO(Generic Segmentation Offload)는 TSO(TCP Segmentation Offload)의 소프트웨어 일반화입니다. 핵심 아이디어: 가능한 한 늦게(late) 세그먼트를 분할하여 네트워크 스택의 중간 계층(Netfilter, TC, qdisc)에서 처리하는 패킷 수를 최소화합니다.
TSO에서 GSO로의 발전
- === 일반 전송 (세그먼트 오프로드 없음) ===
- tcp_sendmsg()가 MSS 단위로 skb를 생성:
- write(fd, buf, 64000)
- → 44개 skb 생성 (각 1460 바이트)
- → 44번 TCP 헤더 생성 + 체크섬 계산
- → 44번 IP 계층 통과 + Netfilter/TC 처리
- → 44번 qdisc enqueue/dequeue
- → 44번 NIC DMA → 매우 높은 CPU 부하
- === TSO (하드웨어 오프로드) ===
- 커널이 최대 64KB의 대형 skb를 생성하여 NIC에 직접 전달:
- → 1개 skb (64KB) → NIC 하드웨어가 MSS 단위 분할
- → NIC가 각 세그먼트에 TCP/IP 헤더 복사 + 시퀀스 번호 증가 + 체크섬 계산
- → 장점: CPU 부하 최소, 10Gbps+ 환경에서 매우 중요
- → 단점: NIC가 TSO를 지원해야 함, 터널/암호화 등에선 미지원 가능
- 확인: ethtool -k eth0 | grep tcp-segmentation
- === GSO (소프트웨어 일반화, 커널 2.6.18+) ===
- TSO와 동일한 "대형 skb" 전략을 소프트웨어로 구현:
- → 커널이 대형 skb를 유지하며 네트워크 스택을 통과
- → validate_xmit_skb()에서 NIC feature 확인:
- NIC가 TSO 지원 → 그대로 NIC에 전달 (HW offload)
- NIC가 TSO 미지원 → skb_gso_segment()로 소프트웨어 분할
- → 중간 계층(Netfilter, TC, qdisc)은 1개 skb만 처리 → 효율 극대화
- GSO의 핵심 이점:
- TSO 미지원 NIC에서도 성능 향상 (SW 분할이라도 늦은 분할이 유리)
- 터널, 가상화 등 복잡한 경로에서도 동작
- UDP, SCTP 등 TCP 외 프로토콜도 지원
GSO 타입 전체 목록
skb_shared_info→gso_type 필드에 설정되는 비트마스크입니다. 여러 타입이 OR로 조합될 수 있습니다:
| GSO 타입 플래그 | 값 | 대상 프로토콜 | 설명 |
|---|---|---|---|
SKB_GSO_TCPV4 | 1 << 0 | IPv4 TCP | 가장 일반적인 TSO/GSO. MSS 단위로 TCP 세그먼트 분할 |
SKB_GSO_TCPV6 | 1 << 5 | IPv6 TCP | IPv6 환경의 TSO/GSO |
SKB_GSO_UDP | 1 << 1 | UDP (IP frag) | IP 단편화(fragmentation) 기반 UDP GSO. UFO(UDP Fragmentation Offload) |
SKB_GSO_UDP_L4 | 1 << 11 | UDP (L4 분할) | UDP 세그먼트 단위 분할 (커널 4.18+). QUIC, WireGuard 등에 사용 |
SKB_GSO_DODGY | 1 << 2 | 모두 | 신뢰할 수 없는 GSO skb (VM에서 전달 등). 재검증 필요 |
SKB_GSO_TCP_ECN | 1 << 3 | TCP + ECN | ECN(Explicit Congestion Notification) 플래그 있는 TCP GSO |
SKB_GSO_TCP_FIXEDID | 1 << 9 | TCP | 모든 세그먼트가 동일 IP ID 사용 (드문 경우) |
SKB_GSO_GRE | 1 << 6 | GRE 터널 | GRE 캡슐화 안의 내부 패킷 GSO |
SKB_GSO_GRE_CSUM | 1 << 7 | GRE + 체크섬 | GRE 체크섬이 활성화된 터널 GSO |
SKB_GSO_UDP_TUNNEL | 1 << 8 | VXLAN/Geneve | UDP 기반 터널 내부 패킷 GSO |
SKB_GSO_UDP_TUNNEL_CSUM | 1 << 10 | VXLAN + csum | 외부 UDP 체크섬이 활성화된 터널 GSO |
SKB_GSO_PARTIAL | 1 << 13 | 터널/복합 | 부분 GSO — 외부 헤더만 NIC가 처리, 내부는 소프트웨어 분할 |
SKB_GSO_TUNNEL_REMCSUM | 1 << 12 | 터널 | 터널 원격 체크섬 오프로드 |
SKB_GSO_SCTP | 1 << 14 | SCTP | SCTP 청크 단위 GSO |
SKB_GSO_ESP | 1 << 15 | IPsec ESP | ESP(Encapsulating Security Payload) GSO |
SKB_GSO_FRAGLIST | 1 << 17 | UDP/IP | frag_list 기반 GSO (GRO에서 병합된 skb 재전송 시) |
GSO 관련 skb_shared_info 필드
/* include/linux/skbuff.h — skb_shared_info의 GSO 관련 필드 */
struct skb_shared_info {
...
unsigned short gso_size; /* 분할 단위 크기 (MSS 또는 세그먼트 크기)
* TCP: MSS (예: 1460)
* UDP GSO: 각 데이터그램 크기 (예: 1472)
* 0이면 GSO 미사용 */
unsigned short gso_segs; /* 예상 분할 세그먼트 수 (힌트 값)
* DIV_ROUND_UP(skb->len - hdr_len, gso_size)
* NIC의 BQL(Byte Queue Limit) 계산에 활용 */
unsigned int gso_type; /* SKB_GSO_* 비트마스크 (위 표 참조)
* 여러 플래그 OR 조합 가능
* 예: SKB_GSO_TCPV4 | SKB_GSO_TCP_ECN */
...
};
/* GSO skb인지 확인하는 헬퍼 함수들 */
static inline bool skb_is_gso(const struct sk_buff *skb)
{
return skb_shinfo(skb)->gso_size; /* gso_size != 0이면 GSO skb */
}
static inline bool skb_is_gso_v6(const struct sk_buff *skb)
{
return skb_shinfo(skb)->gso_type & SKB_GSO_TCPV6;
}
/* GSO 세그먼트의 실제 와이어(wire) 크기 계산 */
static inline unsigned int skb_gso_network_seglen(const struct sk_buff *skb)
{
unsigned int hdr_len = skb_transport_header(skb) - skb_network_header(skb);
return hdr_len + skb_shinfo(skb)->gso_size;
}
GSO 전송 경로 상세
TCP 전송을 예로 들어 GSO skb가 생성되고 분할되는 전체 경로를 추적합니다:
/* 1단계: tcp_sendmsg() — 사용자 데이터를 skb에 적재 */
/*
* tcp_sendmsg_locked()에서 sk_stream_alloc_skb()로 skb 할당
* → tcp_send_mss()로 현재 MSS 결정 (경로 MTU - 헤더)
* → size_goal: GSO 활성 시 MSS * max_segs (최대 64KB)
* → 하나의 skb에 size_goal만큼 데이터를 적재
*/
int mss_now = tcp_send_mss(sk, &size_goal, flags);
/* size_goal 예시:
* MSS=1460, sk->sk_gso_max_segs=44 → size_goal = 1460 * 44 = 64240
* → 하나의 skb에 최대 64KB 데이터 적재
*/
/* 2단계: tcp_write_xmit() — skb에 TCP 헤더 부착 및 GSO 설정 */
/*
* tcp_init_tso_segs()에서 GSO 관련 필드 설정:
*/
static void tcp_set_skb_tso_segs(struct sk_buff *skb, unsigned int mss_now)
{
struct skb_shared_info *shinfo = skb_shinfo(skb);
if (skb->len <= mss_now) {
/* MSS 이하 → 분할 불필요, GSO 미사용 */
shinfo->gso_segs = 1;
shinfo->gso_size = 0;
shinfo->gso_type = 0;
} else {
/* MSS 초과 → GSO skb로 설정 */
shinfo->gso_segs = DIV_ROUND_UP(skb->len, mss_now);
shinfo->gso_size = mss_now;
shinfo->gso_type = sk->sk_gso_type; /* SKB_GSO_TCPV4 등 */
}
}
/* 3단계: ip_queue_xmit() → __dev_queue_xmit() → qdisc */
/*
* IP 계층에서 라우팅 조회, TTL 설정 등을 한 번만 수행
* qdisc에서도 1개의 대형 skb만 큐잉
* → 여기까지 N개 패킷이 아닌 1개의 대형 skb로 처리
*/
/* 4단계: validate_xmit_skb() — GSO 분할 결정의 핵심 */
static struct sk_buff *validate_xmit_skb(struct sk_buff *skb,
struct net_device *dev, bool *again)
{
netdev_features_t features;
/* NIC가 지원하는 feature 확인 */
features = netif_skb_features(skb);
if (skb_is_gso(skb)) {
/* GSO skb이고 NIC가 해당 오프로드를 지원하면 → HW 처리 (분할 안 함) */
/* NIC가 미지원이면 → __skb_gso_segment()로 소프트웨어 분할 */
struct sk_buff *segs = __skb_gso_segment(skb, features, true);
if (IS_ERR_OR_NULL(segs)) {
if (!segs)
return skb; /* NIC가 HW 처리 가능 → 원본 skb 그대로 */
kfree_skb(skb);
return NULL;
}
/* SW 분할 완료: segs는 분할된 skb 리스트 */
consume_skb(skb);
return segs;
}
...
}
skb_gso_segment() 내부 동작
/* net/core/skbuff.c — 소프트웨어 GSO 분할의 핵심 함수 */
struct sk_buff *skb_gso_segment(struct sk_buff *skb, netdev_features_t features)
{
/* 프로토콜별 GSO 콜백 호출 */
/* TCP: tcp4_gso_segment() 또는 tcp6_gso_segment()
* UDP: udp4_ufo_fragment() 또는 __udp_gso_segment()
* 터널: skb_udp_tunnel_segment() 등 */
return skb_mac_gso_segment(skb, features);
}
/* net/ipv4/tcp_offload.c — TCP GSO 분할 */
struct sk_buff *tcp4_gso_segment(struct sk_buff *skb,
netdev_features_t features)
{
/* tcp_gso_segment()가 실제 분할 수행:
*
* 1. 원본 skb의 데이터를 gso_size(MSS) 단위로 분할
* 2. 각 세그먼트에 새 TCP 헤더 복사:
* - seq 번호 증가 (prev_seq += gso_size)
* - PSH 플래그는 마지막 세그먼트에만 설정
* - FIN 플래그는 마지막 세그먼트에만 설정
* - CWR 플래그는 첫 세그먼트에만 설정
* 3. 각 세그먼트의 IP 헤더 업데이트:
* - total_length 재계산
* - IP ID 증가 (SKB_GSO_TCP_FIXEDID 아닌 경우)
* 4. 체크섬 재계산 (CHECKSUM_PARTIAL이면 pseudo-header만)
* 5. 분할된 skb들을 linked list로 반환 (skb->next)
*/
if (!pskb_may_pull(skb, sizeof(struct tcphdr)))
return ERR_PTR(-EINVAL);
return tcp_gso_segment(skb, features);
}
/* 분할 결과 사용 예시 */
struct sk_buff *segs = skb_gso_segment(skb, features);
struct sk_buff *seg, *tmp;
skb_list_walk_safe(segs, seg, tmp) {
skb_mark_not_on_list(seg);
/* 각 세그먼트는 독립적인 skb:
* - 자체 TCP/IP 헤더 보유
* - 정확한 시퀀스 번호
* - 올바른 체크섬
* - skb->len == gso_size + hdr_len (마지막은 작을 수 있음)
*/
dev_queue_xmit(seg);
}
GSO_PARTIAL — 터널 환경의 부분 오프로드
SKB_GSO_PARTIAL은 NIC가 외부(outer) 헤더는 처리할 수 있지만 내부(inner) 패킷의 GSO는 지원하지 않는 경우를 위한 메커니즘입니다:
/* VXLAN 터널에서의 GSO_PARTIAL 동작:
*
* [Outer Eth][Outer IP][Outer UDP][VXLAN Hdr][Inner Eth][Inner IP][Inner TCP][Payload]
*
* NIC가 NETIF_F_GSO_UDP_TUNNEL은 지원하지만
* NETIF_F_GSO_UDP_TUNNEL_CSUM은 미지원하는 경우:
*
* 1. 커널이 내부(inner) TCP 세그먼트를 소프트웨어로 분할
* 2. 각 분할된 세그먼트에 외부(outer) 헤더를 붙임
* 3. 외부 헤더의 처리(체크섬 등)는 NIC에 위임
*
* 관련 NIC feature 확인:
*/
/* # ethtool -k eth0 | grep tunnel
* tx-udp_tnl-segmentation: on (외부 UDP 터널 GSO)
* tx-udp_tnl-csum-segmentation: off (외부 csum 미지원 → PARTIAL 필요)
*/
/* net_device features 비트 매핑 */
NETIF_F_TSO /* TCP Segmentation Offload (IPv4) */
NETIF_F_TSO6 /* TCP Segmentation Offload (IPv6) */
NETIF_F_GSO_GRE /* GRE 터널 내부 GSO */
NETIF_F_GSO_GRE_CSUM /* GRE 체크섬 + 내부 GSO */
NETIF_F_GSO_UDP_TUNNEL /* UDP 터널 (VXLAN/Geneve) 내부 GSO */
NETIF_F_GSO_UDP_TUNNEL_CSUM /* UDP 터널 + 외부 UDP 체크섬 GSO */
NETIF_F_GSO_PARTIAL /* 부분 GSO: 외부는 HW, 내부는 SW */
NETIF_F_GSO_UDP_L4 /* UDP L4 세그먼트 오프로드 (4.18+) */
NETIF_F_GSO_ESP /* IPsec ESP GSO */
NETIF_F_GSO_SCTP /* SCTP GSO */
GSO 최대 크기 제어
/* net_device의 GSO 크기 제한 */
struct net_device {
unsigned int gso_max_size; /* GSO skb 최대 크기 (기본 65536)
* ip link set dev eth0 gso_max_size 32768
* 으로 조절 가능 */
u16 gso_max_segs; /* 최대 세그먼트 수 (기본 65535)
* NIC 하드웨어 제한에 따라 설정 */
unsigned int gso_ipv4_max_size; /* IPv4 전용 GSO 최대 크기 (6.3+) */
};
/* 소켓 레벨에서도 GSO 크기 제어 가능 */
struct sock {
int sk_gso_max_size; /* 소켓별 GSO 최대 크기 */
u16 sk_gso_max_segs; /* 소켓별 최대 세그먼트 수 */
int sk_gso_type; /* 지원하는 GSO 타입 비트마스크 */
};
/* BIG TCP (커널 6.3+): IPv6에서 64KB 넘는 GSO 허용 */
/* ip link set dev eth0 gso_max_size 185000
* → IPv6 jumbogram 활용, 단일 skb에 ~185KB 적재 가능
* → GRO에서도 gro_max_size로 대응
*
* 주의: IPv4는 IP 헤더의 total_length가 16비트이므로 64KB가 한계
* IPv6 jumbogram 확장 헤더로 이 제한을 우회
*/
GRO (수신 오프로드) 심화
GRO(Generic Receive Offload)는 수신된 작은 패킷들을 하나의 큰 skb로 합치는 메커니즘입니다. LRO(Large Receive Offload)의 소프트웨어 일반화로, LRO와 달리 원본 헤더 정보를 보존하여 라우팅/포워딩 환경에서도 안전하게 동작합니다.
LRO vs GRO 비교
| 특성 | LRO (Large Receive Offload) | GRO (Generic Receive Offload) |
|---|---|---|
| 구현 위치 | NIC 드라이버 또는 하드웨어 | 커널 네트워크 스택 (NAPI 레벨) |
| 헤더 보존 | 병합 시 원본 헤더 정보 손실 가능 | 원본 헤더 정보 완전 보존 |
| 라우팅/포워딩 | 패킷 재분할 시 헤더 복구 불가 → 부적합 | 안전하게 재분할 가능 → 적합 |
| 프로토콜 지원 | TCP만 | TCP, UDP, GRE, VXLAN 등 확장 가능 |
| 병합 기준 | 느슨함 (IP/TCP 4-tuple만) | 엄격함 (헤더 완전 일치 검증) |
| 현재 상태 | deprecated (대부분 GRO로 대체) | 표준 수신 오프로드 |
GRO 수신 파이프라인
/* === GRO 수신 경로 전체 흐름 ===
*
* NIC IRQ
* └→ napi_schedule()
* └→ NAPI poll 함수 (드라이버)
* └→ napi_gro_receive(napi, skb) ← 진입점
* └→ dev_gro_receive(napi, skb)
* ├→ gro_list에서 동일 flow 검색
* └→ 프로토콜별 GRO 콜백 체인:
* └→ inet_gro_receive() (L3: IP)
* └→ tcp4_gro_receive() (L4: TCP)
* └→ GRO 결과 반환
* ├→ GRO_MERGED: 기존 skb에 병합 완료
* ├→ GRO_MERGED_FREE: 병합 + 현재 skb 해제
* ├→ GRO_HELD: gro_list에 보관 (다음 패킷 대기)
* ├→ GRO_NORMAL: 병합 불가 → 일반 경로
* └→ GRO_CONSUMED: 콜백이 직접 처리 완료
*/
/* net/core/gro.c — GRO 핵심 진입점 */
gro_result_t napi_gro_receive(struct napi_struct *napi,
struct sk_buff *skb)
{
/* skb 전처리: VLAN 태그 처리, 해시 설정 등 */
skb_gro_reset_offset(skb);
return napi_skb_finish(napi, skb, dev_gro_receive(napi, skb));
}
static enum gro_result dev_gro_receive(struct napi_struct *napi,
struct sk_buff *skb)
{
struct list_head *gro_head = &napi->gro_hash[bucket].list;
int count = napi->gro_hash[bucket].count;
/* gro_list에서 병합 가능한 기존 skb 검색 */
list_for_each_entry(pp, gro_head, list) {
/* same_flow: 동일 flow인지 빠른 비교 (rxhash 기반) */
NAPI_GRO_CB(pp)->same_flow = 1;
}
/* 프로토콜별 GRO 콜백 호출 (inet_gro_receive 등) */
pp = call_gro_receive(ptype->callbacks.gro_receive, gro_head, skb);
if (pp == skb) return GRO_NORMAL; /* 병합 불가 */
if (pp) return GRO_MERGED; /* 병합 성공 → flush 대상 */
if (NAPI_GRO_CB(skb)->flush)
return GRO_NORMAL; /* 병합 불가 플래그 */
/* 새 flow → gro_list에 추가 (다음 패킷 대기) */
if (count < MAX_GRO_SKBS) /* MAX_GRO_SKBS = 8 */
return GRO_HELD;
else
return GRO_NORMAL; /* 버킷 가득 참 → 일반 경로 */
}
GRO 병합 기준
패킷이 기존 flow에 병합되려면 다음 조건을 모두 만족해야 합니다:
/* === TCP GRO 병합 조건 (tcp4_gro_receive) ===
*
* 1. L2 레벨: 동일 수신 해시 (rxhash) — 빠른 사전 필터링
*
* 2. L3 레벨 (inet_gro_receive):
* - 동일 IP 프로토콜 (IPPROTO_TCP)
* - 동일 src/dst IP 주소
* - 동일 TOS (Type of Service)
* - 동일 TTL (합치면 안 되는 경우 방지)
* - IP 옵션 없음 (옵션이 있으면 병합 거부)
* - IP ID가 순차적으로 증가 (또는 DF 비트 설정 시 무관)
*
* 3. L4 레벨 (tcp_gro_receive):
* - 동일 src/dst 포트
* - TCP 시퀀스 번호가 연속 (이전 패킷의 끝 + 1)
* - TCP 윈도우 크기가 동일
* - ACK 플래그만 설정 (SYN, FIN, RST, URG, ECE, CWR → 병합 거부)
* - TCP 타임스탬프 옵션이 있으면 값이 동일하거나 증가
* - 이전에 병합된 패킷과 옵션 길이가 동일
*
* 4. 크기 제한:
* - 병합된 skb 크기가 gro_max_size를 초과하지 않아야 함
* - 기본 gro_max_size = 65536 (BIG TCP 시 더 큼)
*/
/* net/ipv4/tcp_offload.c — TCP GRO 병합 핵심 검증 */
struct sk_buff *tcp_gro_receive(struct list_head *head,
struct sk_buff *skb)
{
struct tcphdr *th = tcp_hdr(skb);
list_for_each_entry(p, head, list) {
if (!NAPI_GRO_CB(p)->same_flow)
continue;
th2 = tcp_hdr(p);
/* 포트 비교 */
if (*((u32 *)&th->source) ^ *((u32 *)&th2->source)) {
NAPI_GRO_CB(p)->same_flow = 0;
continue;
}
/* 시퀀스 번호 연속 확인 */
if (ntohl(th2->seq) + skb_gro_len(p) != ntohl(th->seq)) {
NAPI_GRO_CB(p)->flush = 1; /* 비연속 → flush */
continue;
}
/* ACK 외 플래그 확인: SYN/FIN/RST → flush */
if (th->fin || th->syn || th->rst || th->urg)
flush = 1;
/* 윈도우 크기 일치 확인 */
if (th->window ^ th2->window)
flush = 1;
/* 타임스탬프 옵션 검증 */
if (pcount > 1 || tcp_flag_word(th2) & TCP_FLAG_CWR)
flush = 1;
/* 병합 수행: skb 데이터를 기존 p에 append */
if (!flush)
skb_gro_receive(p, skb); /* → 아래 설명 */
}
...
}
GRO 데이터 병합 방식
GRO는 두 가지 방식으로 수신 데이터를 병합합니다:
| 병합 방식 | 조건 | 데이터 구조 | 장단점 |
|---|---|---|---|
| frag 기반 | skb가 선형(linear) 데이터일 때 | skb_shared_info→frags[] 배열에 페이지 추가 |
메모리 효율적, scatter-gather DMA에 적합. MAX_SKB_FRAGS(17) 제한 |
| frag_list 기반 | frag 공간 부족 또는 skb 자체가 이미 비선형일 때 | skb_shared_info→frag_list에 skb 체인 연결 |
구조 단순, 제한 없음. 하지만 GSO 재분할 시 오버헤드 |
/* net/core/gro.c — skb_gro_receive() 핵심 로직 */
int skb_gro_receive(struct sk_buff *p, struct sk_buff *skb)
{
unsigned int new_len = p->len + skb->len;
/* gro_max_size 초과 검사 */
if (new_len > gro_max_size(p))
return -E2BIG;
if (skb_headlen(skb) <= offset) {
/* === frag 기반 병합 ===
* 새 skb의 페이지를 기존 skb의 frags[]에 추가 */
struct skb_shared_info *pinfo = skb_shinfo(p);
if (pinfo->nr_frags + skb_shinfo(skb)->nr_frags > MAX_SKB_FRAGS)
goto merge_frag_list;
/* frags[] 배열에 새 페이지들을 복사 */
for (i = 0; i < skb_shinfo(skb)->nr_frags; i++) {
pinfo->frags[pinfo->nr_frags++] = skb_shinfo(skb)->frags[i];
}
} else {
merge_frag_list:
/* === frag_list 기반 병합 ===
* 새 skb를 기존 skb의 frag_list 체인에 연결 */
if (!skb_shinfo(p)->frag_list)
skb_shinfo(p)->frag_list = skb;
else
NAPI_GRO_CB(p)->last->next = skb;
NAPI_GRO_CB(p)->last = skb;
}
/* 병합된 skb의 총 길이 업데이트 */
p->len += skb->len;
p->data_len += skb->len;
p->truesize += skb->truesize;
NAPI_GRO_CB(p)->count++; /* 병합된 패킷 수 */
return 0;
}
GRO Flush 메커니즘
gro_list에 보관 중인 병합된 skb는 다음 조건에서 상위 스택으로 전달(flush)됩니다:
/* GRO flush 발생 조건과 동작 */
/* 1. napi_complete_done() 호출 시 — 가장 일반적
* NAPI poll에서 budget 미만 처리 → 인터럽트 재활성화 전 flush */
bool napi_complete_done(struct napi_struct *napi, int work_done)
{
/* gro_list의 모든 보류 skb를 flush */
gro_normal_list(napi); /* → netif_receive_skb_list_internal() */
...
}
/* 2. MAX_GRO_SKBS(8) 초과 시 — 버킷당 보관 제한
* 동일 해시 버킷에 8개 이상 skb가 쌓이면 가장 오래된 것 flush */
/* 3. 비연속 패킷 수신 시 (flush 플래그)
* 시퀀스 번호 불연속, 다른 플래그 등 → 현재 보관 중인 skb flush */
/* 4. gro_flush_timeout 타이머 만료 (busy polling 관련) */
/* sysctl: net.core.gro_flush_timeout
* 기본값: 0 (즉시 flush, 타이머 미사용)
* 설정 시: napi_complete에서 바로 flush하지 않고 타이머 대기
* → 더 많은 패킷을 병합할 기회를 제공하지만 지연 증가
*
* 관련 sysctl:
* net.core.napi_defer_hard_irqs = N
* → N번의 빈 poll 후에야 IRQ 재활성화
* → gro_flush_timeout과 함께 사용하면 GRO 효율 극대화
*/
/* 5. 명시적 flush 호출 */
napi_gro_flush(napi, false); /* 모든 보류 skb를 즉시 flush */
/* flush된 skb는 netif_receive_skb_list_internal()로 전달되어
* 일반 수신 경로(IP → TCP → 소켓)를 탐니다.
* 이때 skb->len은 원래 여러 패킷의 합이므로
* TCP 수신 측에서 효율적으로 처리됩니다. */
GRO API 변형
/* 드라이버에서 사용하는 GRO 진입점들 */
/* 1. napi_gro_receive() — 가장 일반적
* 완전한 skb를 GRO 처리 */
gro_result_t napi_gro_receive(struct napi_struct *napi,
struct sk_buff *skb);
/* 2. napi_gro_frags() — 헤더/데이터 분리 수신 시
* NIC가 헤더를 선형 영역에, 페이로드를 페이지에 배치한 경우
* napi->skb에 미리 설정된 skb 사용 */
gro_result_t napi_gro_frags(struct napi_struct *napi);
/*
* 사용 패턴 (고성능 드라이버):
* napi->skb = netdev_alloc_skb(...);
* skb_put(napi->skb, hdr_len); // 헤더를 선형 영역에
* skb_fill_page_desc(napi->skb, ...); // 페이로드를 frag으로
* napi_gro_frags(napi);
* // napi->skb는 내부에서 소비/해제됨
*/
/* 3. napi_gro_complete() — 내부 함수
* GRO 완료 시 호출, 프로토콜별 gro_complete 콜백 실행 후 상위 전달 */
/* GRO 결과 타입 */
enum gro_result {
GRO_MERGED, /* 기존 skb에 병합 성공 — flush 대상으로 표시 */
GRO_MERGED_FREE, /* 병합 성공 + 현재 skb 해제 가능 */
GRO_HELD, /* gro_list에 보관 (새 flow, 다음 패킷 대기) */
GRO_NORMAL, /* 병합 불가 — 일반 수신 경로로 */
GRO_CONSUMED, /* 콜백이 직접 처리 완료 */
};
하드웨어 GRO (HW-GRO)
커널 5.19+에서 도입된 HW-GRO는 NIC 하드웨어가 GRO를 수행하되, LRO와 달리 헤더 정보를 보존합니다:
/* HW-GRO vs SW-GRO vs LRO 비교
*
* LRO (deprecated):
* - NIC가 패킷 병합, 헤더 정보 손실
* - 포워딩 시 문제 → ip_summed 등 불일치
* - ethtool -K eth0 lro on/off
*
* SW-GRO (기본):
* - 커널 NAPI 레벨에서 병합
* - 헤더 완전 보존, 모든 프로토콜 지원
* - CPU 오버헤드 있음
* - ethtool -K eth0 gro on/off
*
* HW-GRO (5.19+):
* - NIC 하드웨어가 병합하되 개별 헤더를 보존
* - NIC가 "header split" 또는 RSC(Receive Side Coalescing) 활용
* - CPU 오버헤드 최소화 + 헤더 보존의 장점
* - ethtool -K eth0 rx-gro-hw on/off
*
* 확인:
* # ethtool -k eth0 | grep gro
* generic-receive-offload: on
* rx-gro-hw: on [requested on]
*/
/* NIC feature 플래그 */
NETIF_F_GRO /* 소프트웨어 GRO 지원 (기본 on) */
NETIF_F_GRO_HW /* 하드웨어 GRO 지원 (5.19+) */
/* 드라이버에서 HW-GRO 결과를 커널에 전달하는 방법 */
/* NIC가 병합한 패킷을 수신하면, 드라이버는:
* 1. skb->len에 병합된 총 크기 설정
* 2. skb_shinfo(skb)->gso_size에 원래 MSS 설정
* 3. skb_shinfo(skb)->gso_type에 SKB_GSO_TCPV4 등 설정
* 4. napi_gro_receive()가 아닌 일반 경로로 전달 가능
* (이미 병합됨, 커널 GRO 불필요)
*/
프로토콜별 GRO 콜백
/* GRO 콜백 등록 구조 */
struct net_offload {
struct offload_callbacks callbacks;
};
struct offload_callbacks {
struct sk_buff *(*gro_receive)(struct list_head *head,
struct sk_buff *skb);
int (*gro_complete)(struct sk_buff *skb, int nhoff);
};
/* 프로토콜별 GRO 콜백 체인 (L2 → L3 → L4):
*
* Ethernet:
* → eth_gro_receive()
* → inet_gro_receive() (IPv4) 또는 ipv6_gro_receive()
* → tcp4_gro_receive() 또는 udp4_gro_receive()
*
* VXLAN 터널:
* → eth_gro_receive()
* → inet_gro_receive()
* → udp4_gro_receive()
* → vxlan_gro_receive() ← 터널 디캡슐화
* → eth_gro_receive() ← 내부 패킷 재귀 처리
* → inet_gro_receive()
* → tcp4_gro_receive()
*/
/* UDP GRO 콜백 (4.18+ UDP GSO와 짝) */
static struct sk_buff *udp4_gro_receive(struct list_head *head,
struct sk_buff *skb)
{
/* UDP는 TCP와 달리 시퀀스 번호가 없으므로
* 병합 기준이 다름:
* - 동일 src/dst IP + port
* - 동일 데이터그램 크기 (마지막 제외)
* - 소켓이 UDP_GRO 옵션을 설정했어야 함
*/
/* 터널 프로토콜 콜백이 등록되어 있으면 터널 GRO */
struct udp_sock *up = udp_sk(sk);
if (up->encap_type && up->gro_receive)
return call_gro_receive(up->gro_receive, head, skb);
/* 일반 UDP GRO */
return udp_gro_receive(head, skb);
}
드라이버에서의 GRO 사용 패턴
/* 전형적인 NAPI poll 함수에서의 GRO 사용 */
static int my_napi_poll(struct napi_struct *napi, int budget)
{
struct my_adapter *adapter = container_of(napi, struct my_adapter, napi);
int work_done = 0;
while (work_done < budget) {
struct my_rx_desc *desc = my_get_rx_desc(adapter);
struct sk_buff *skb;
if (!desc)
break;
skb = my_build_skb(adapter, desc);
if (!skb) {
adapter->stats.alloc_fail++;
break;
}
/* 필수 skb 메타데이터 설정 — GRO 정확도에 영향 */
skb->protocol = eth_type_trans(skb, adapter->netdev);
/* 체크섬 오프로드 상태: GRO 효율에 직접 영향
* CHECKSUM_UNNECESSARY → GRO가 체크섬 재검증 생략 → 빠른 병합 */
if (desc->rx_status & RX_CSUM_OK)
skb->ip_summed = CHECKSUM_UNNECESSARY;
/* RX 해시: GRO가 동일 flow를 빠르게 찾는 데 사용 */
skb_set_hash(skb, desc->rss_hash, PKT_HASH_TYPE_L4);
/* VLAN 태그 (있으면) */
if (desc->rx_status & RX_VLAN_STRIPPED)
__vlan_hwaccel_put_tag(skb, htons(ETH_P_8021Q), desc->vlan_tci);
/* GRO 진입 — 여기서 패킷 병합 시도 */
napi_gro_receive(napi, skb);
work_done++;
}
if (work_done < budget) {
/* 모든 수신 패킷 처리 완료 → IRQ 재활성화 */
if (napi_complete_done(napi, work_done))
my_enable_irq(adapter);
/* napi_complete_done() 내부에서 gro_list flush 수행 */
}
return work_done;
}
GSO ↔ GRO 상호작용
포워딩 환경에서 GRO로 병합된 skb가 다시 GSO로 분할되는 경우가 빈번합니다. 이 "GRO → forward → GSO" 파이프라인이 네트워크 장비(라우터, 브리지, 로드밸런서)의 성능을 결정합니다:
/* 포워딩 시 GRO → GSO 동작 */
/*
* 1. 수신 NIC에서 GRO가 43개 패킷을 1개 대형 skb로 병합
* 2. ip_forward()에서 라우팅 결정 (1회)
* 3. Netfilter (conntrack, NAT 등) 통과 (1회)
* 4. 송신 NIC로 전달:
* - 송신 NIC가 TSO 지원 → 대형 skb 그대로 전달
* - 송신 NIC가 TSO 미지원 → GSO가 소프트웨어 분할
*
* 포워딩 효율:
* GRO/GSO OFF: 43 × {routing + conntrack + NAT + filter} = 43 × CPU cycles
* GRO/GSO ON: 1 × {routing + conntrack + NAT + filter} = 1 × CPU cycles
* → 약 43배 효율 향상 (64KB / 1500 MTU 기준)
*/
/* GRO로 병합된 skb가 다시 분할될 때의 GSO 타입 */
/* GRO 병합 시 gso_size와 gso_type이 자동 설정되므로
* 포워딩 경로의 GSO가 원본 패킷과 동일하게 분할 가능 */
if (NAPI_GRO_CB(p)->count > 1) {
skb_shinfo(p)->gso_size = skb_gro_len(skb); /* 원본 세그먼트 크기 */
skb_shinfo(p)->gso_type |= SKB_GSO_TCPV4;
}
성능 튜닝과 모니터링
ethtool 오프로드 제어
/* GSO/GRO 관련 ethtool 명령어 */
/* 현재 오프로드 상태 확인 */
# ethtool -k eth0 | grep -E 'offload|gso|gro|tso'
tcp-segmentation-offload: on # TSO (HW)
generic-segmentation-offload: on # GSO (SW fallback)
generic-receive-offload: on # GRO (SW)
rx-gro-hw: on [requested on] # HW-GRO (5.19+)
udp-segmentation-offload: off # USO (4.18+)
tx-udp_tnl-segmentation: on # 터널 TSO
tx-udp_tnl-csum-segmentation: on # 터널 TSO + csum
large-receive-offload: off [requested off] # LRO (deprecated)
/* 개별 오프로드 제어 */
# ethtool -K eth0 gso on|off # GSO 전환
# ethtool -K eth0 gro on|off # GRO 전환
# ethtool -K eth0 tso on|off # TSO 전환
# ethtool -K eth0 rx-gro-hw on|off # HW-GRO 전환
# ethtool -K eth0 lro on|off # LRO 전환 (비권장)
/* GSO/GRO 최대 크기 확인 및 설정 */
# ip -d link show eth0 | grep gso
# gso_max_size 65536 gso_max_segs 65535
# gro_max_size 65536
#
# BIG TCP 활성화 (IPv6, 6.3+):
# ip link set dev eth0 gso_max_size 185000
# ip link set dev eth0 gro_max_size 185000
GRO 관련 sysctl 튜닝
/* GRO 성능에 영향을 주는 sysctl 파라미터 */
/* 1. gro_flush_timeout — GRO 패킷 보관 타임아웃 (나노초)
* 기본값: 0 (napi_complete 시 즉시 flush)
* 설정 시: 타이머 만료까지 더 많은 패킷 병합 시도
* → 처리량 증가, 하지만 지연 시간도 증가 */
# sysctl -w net.core.gro_flush_timeout=20000 # 20μs
/* 2. napi_defer_hard_irqs — 빈 poll 허용 횟수
* 기본값: 0 (즉시 IRQ 재활성화)
* 설정 시: N번 빈 poll 후에야 IRQ 재활성화
* → gro_flush_timeout과 함께 사용하면 busy-poll 모드 */
# sysctl -w net.core.napi_defer_hard_irqs=2 # 2번 빈 poll 허용
/* 3. 권장 조합 (10Gbps+ 고처리량 환경):
* gro_flush_timeout=20000 + napi_defer_hard_irqs=2
* → IRQ 없이 busy-poll로 패킷 수신 → GRO 병합 극대화
* → 단, CPU 사용률 약간 증가 (IRQ coalescence와 트레이드오프)
*
* 4. 지연 민감 환경 (금융, 게임):
* gro_flush_timeout=0 + napi_defer_hard_irqs=0
* → 최소 지연, 하지만 GRO 병합 기회 감소
* → 극단적 경우 GRO 자체를 비활성화 고려 */
/* 5. busy_poll / busy_read — 소켓 레벨 busy polling
* → epoll/poll에서 커널이 먼저 NAPI poll 시도 (IRQ 대기 없이)
* → GRO와 결합하면 ultra-low latency + 높은 처리량 */
# sysctl -w net.core.busy_poll=50 # 50μs busy poll
# sysctl -w net.core.busy_read=50 # 50μs busy read
모니터링과 통계
/* GSO/GRO 동작 상태 확인 */
/* 1. NIC 통계 — GRO 병합 횟수 */
# ethtool -S eth0 | grep -i gro
# rx_gro_packets: 1234567 # GRO로 병합된 패킷 수
# rx_gro_bytes: 98765432000 # GRO로 병합된 바이트 수
# rx_gro_dropped: 0 # GRO 중 드롭
/* 2. /proc/net/dev — 일반 인터페이스 통계 */
# cat /proc/net/dev
# → GRO 활성 시 RX packets가 줄고 bytes는 동일
# → 패킷당 평균 크기가 크면 GRO가 잘 동작하는 것
/* 3. nstat — 프로토콜별 통계 */
# nstat -az | grep -i gro
# TcpExtTCPAutoCorking 123456 # TCP autocorking (GSO 관련)
/* 4. perf로 GSO/GRO 함수 프로파일링 */
# perf top -g -e cycles -- -K
# → skb_gso_segment, tcp_gso_segment, dev_gro_receive 등의 CPU 비중 확인
# → GRO/GSO 오버헤드가 높으면 HW 오프로드 확인
/* 5. ftrace로 GSO 분할 추적 */
# echo 1 > /sys/kernel/debug/tracing/events/net/net_dev_xmit/enable
# cat /sys/kernel/debug/tracing/trace_pipe
# → skb->len 변화로 GSO 분할 여부 확인
/* 6. GRO 효율 지표 계산 */
/* GRO ratio = (NIC rx_packets) / (netif_receive_skb 호출 수)
* → 비율이 높을수록 GRO가 효과적으로 동작
* → TCP 워크로드에서 일반적으로 40~60:1 (64KB / 1500 MTU)
*/
GSO/GRO 주의사항
- 패킷 캡처(tcpdump) — GRO가 병합한 대형 패킷이 캡처되어 MTU 초과로 보일 수 있음. 디버깅 시
ethtool -K eth0 gro off로 비활성화하거나tcpdump가 자동 처리 - Netfilter conntrack — GRO skb가 conntrack에서 분할 없이 하나로 추적되므로, 패킷 카운터가 실제보다 적게 표시됨
- TC(Traffic Control) — GSO skb는 분할 전이므로 큐 깊이가 실제보다 적게 보임.
tc -s qdisc출력 해석 시 주의 - MTU 변경 — GSO/GRO 활성 상태에서 MTU 변경 시 기존 skb의 gso_size와 불일치 가능. 트래픽 중단 후 변경 권장
- IPsec — ESP 암호화 후 GSO 분할 필요.
NETIF_F_GSO_ESP미지원 NIC에서 성능 저하. xfrm offload 확인 필요 - Bridge/OVS — 포워딩 경로에서 GRO로 병합된 skb가 재분할 없이 브리지되면, 수신 측에서 예상치 못한 대형 프레임을 받을 수 있음
- 성능 문제 발생 시
ethtool -K eth0 gro off; ethtool -K eth0 tso off로 오프로드를 순차 비활성화하여 원인 분리 ss -ti로 TCP 소켓별 MSS, cwnd를 확인하여 GSO 세그먼트 크기 추정ip -s link show eth0에서 TX/RX 패킷 수 대비 바이트 수 비율로 GSO/GRO 효과 확인- 가상화 환경에서는 virtio-net의
VIRTIO_NET_F_MRG_RXBUF플래그가 GRO에 직접 영향
관련 문서
GSO/GRO와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.