GSO/GRO와 네트워크 오프로드
Linux 커널 네트워크 오프로드 메커니즘: 체크섬(Checksum) 오프로드 플래그, GSO(Generic Segmentation Offload)/TSO/UFO, GRO(Generic Receive Offload) 병합 알고리즘, VXLAN/GRE 터널(Tunnel) GSO, HW-GRO, 성능 튜닝 종합 가이드. 커널 내부 데이터 경로, 핵심 자료구조/API, 운영 환경 튜닝 포인트와 장애 디버깅(Debugging) 절차까지 실무 관점으로 다룹니다.
핵심 요약
- 체크섬 오프로드 — CHECKSUM_NONE/UNNECESSARY/PARTIAL/COMPLETE 플래그로 NIC에 체크섬 계산을 위임.
- GSO — 커널이 큰 세그먼트를 유지하다 NIC 직전에 분할. TSO 지원 NIC면 NIC가 분할.
- GRO — napi_gro_receive()에서 연속 패킷(Packet)을 병합해 프로토콜 스택 호출 횟수 감소.
- 터널 처리 — 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 설정 필요 |
CHECKSUM_COMPLETE 상세 동작
CHECKSUM_COMPLETE는 NIC가 L2 페이로드(Payload) 전체의 raw 1's complement 합산값을 skb->csum에 제공하는 모드입니다. 프로토콜 스택은 이 값에 pseudo-header만 추가하면 됩니다:
/* CHECKSUM_COMPLETE 처리 흐름 상세 */
/* 1. NIC 드라이버: raw 체크섬을 skb->csum에 저장 */
static void driver_set_csum_complete(struct sk_buff *skb,
__wsum hw_csum)
{
skb->ip_summed = CHECKSUM_COMPLETE;
skb->csum = hw_csum; /* NIC가 계산한 L2 페이로드 전체 합산값
* = sum(IP header + TCP/UDP header + payload)
* pseudo-header는 포함하지 않음 */
}
/* 2. IP 계층: __skb_checksum_validate_complete()
* NIC의 raw csum에 pseudo-header 체크섬을 추가하여 검증 */
static inline __sum16 __skb_checksum_validate_complete(
struct sk_buff *skb, bool complete, __wsum psum)
{
if (skb->ip_summed == CHECKSUM_COMPLETE) {
/* skb->csum (NIC raw) + psum (pseudo-header) == 0이면 유효 */
if (!csum_fold(csum_add(psum, skb->csum))) {
/* 검증 성공 → UNNECESSARY로 승격 (이후 재검증 생략) */
skb->ip_summed = CHECKSUM_UNNECESSARY;
skb->csum_valid = 1;
return 0;
}
}
/* 실패 → 소프트웨어 전체 재검증 */
return __skb_checksum_complete(skb);
}
/* 3. TCP 수신 경로에서의 사용 예 */
/* tcp_v4_rcv() → tcp_checksum_complete() */
static inline bool tcp_checksum_complete(struct sk_buff *skb)
{
/* CHECKSUM_UNNECESSARY → 즉시 통과 (0 반환)
* CHECKSUM_COMPLETE → pseudo-header 추가 후 검증
* CHECKSUM_NONE → 전체 SW 재계산 */
return !skb_csum_unnecessary(skb) &&
__skb_checksum_complete(skb);
}
/* CHECKSUM_COMPLETE의 장점:
* - NIC가 프로토콜을 이해할 필요 없음 (raw 합산만)
* - L4 프로토콜에 무관하게 동작 (TCP, UDP, SCTP, DCCP 등)
* - CHECKSUM_UNNECESSARY보다 범용적
* - 단점: pseudo-header 추가 연산이 필요 (미미한 오버헤드) */
CHECKSUM_PARTIAL TX 내부 동작
/* CHECKSUM_PARTIAL: 프로토콜 스택이 pseudo-header를 미리 삽입하고
* NIC가 나머지 실제 체크섬을 완성하는 협력 모델 */
/* TCP 전송 시 체크섬 설정 흐름 */
static void tcp_v4_send_check(struct sock *sk, struct sk_buff *skb)
{
struct tcphdr *th = tcp_hdr(skb);
struct inet_sock *inet = inet_sk(sk);
if (skb->ip_summed == CHECKSUM_PARTIAL) {
/* pseudo-header 체크섬만 TCP 체크섬 필드에 삽입
* NIC가 이 위에 실제 데이터 합산값을 더해 최종 체크섬 완성 */
th->check = ~tcp_v4_check(skb_tail_pointer(skb) - (u8 *)th,
inet->inet_saddr, inet->inet_daddr, 0);
/* csum_start/csum_offset 설정 — NIC에게 알려주는 좌표 */
skb->csum_start = skb_transport_header(skb) - skb->head;
skb->csum_offset = offsetof(struct tcphdr, check);
/*
* csum_start: 체크섬 계산 시작점 (TCP 헤더 시작)
* csum_offset: 체크섬을 기록할 위치 (TCP 헤더 내 check 필드)
*
* NIC의 동작:
* 1. skb->data + csum_start 부터 끝까지 1's complement 합산
* 2. 결과를 csum_start + csum_offset 위치에 기록
* 3. 이미 삽입된 pseudo-header csum과 자동 합산됨
*/
}
}
/* NIC feature별 체크섬 오프로드 능력 비교 */
/*
* NETIF_F_IP_CSUM:
* - IPv4 TCP/UDP만 지원
* - NIC가 IPv4 pseudo-header 구조를 알고 있음
* - 구형 NIC에서 주로 사용
*
* NETIF_F_IPV6_CSUM:
* - IPv6 TCP/UDP 지원
* - NETIF_F_IP_CSUM과 보통 함께 지원
*
* NETIF_F_HW_CSUM:
* - 프로토콜 무관 범용 체크섬
* - csum_start/csum_offset으로 임의 위치 체크섬 계산
* - 터널, SCTP, 새로운 프로토콜 등 모두 지원
* - 최신 NIC(Intel E810, Mellanox CX-5+)에서 지원
* - NETIF_F_IP_CSUM/IPV6_CSUM을 완전히 대체
*
* 확인:
* # ethtool -k eth0 | grep csum
* tx-checksum-ipv4: on
* tx-checksum-ipv6: on
* tx-checksum-ip-generic: on [NETIF_F_HW_CSUM]
*/
Scatter-Gather와 체크섬 계산
대부분의 고성능 NIC는 Scatter-Gather DMA를 사용합니다. 비선형(nonlinear) skb의 체크섬 계산은 frags[] 배열을 순회해야 합니다:
/* 비선형 skb의 체크섬 계산 — skb_checksum() 내부 */
__wsum skb_checksum(const struct sk_buff *skb,
int offset, int len, __wsum csum)
{
/* 1단계: 선형(linear) 영역의 체크섬 */
int copy = min(len, skb_headlen(skb) - offset);
if (copy > 0) {
csum = csum_partial(skb->data + offset, copy, csum);
len -= copy;
offset += copy;
}
/* 2단계: frags[] 배열의 각 페이지 체크섬 */
for (i = 0; i < skb_shinfo(skb)->nr_frags; i++) {
struct skb_frag_struct *frag = &skb_shinfo(skb)->frags[i];
int frag_size = skb_frag_size(frag);
if (copy < frag_size) {
u8 *vaddr = kmap_local_page(skb_frag_page(frag));
csum = csum_partial(vaddr + skb_frag_off(frag) + offset,
min(len, frag_size - offset), csum);
kunmap_local(vaddr);
}
}
/* 3단계: frag_list 체인 (있으면) */
skb_walk_frags(skb, frag_iter) {
csum = skb_checksum(frag_iter, offset, len, csum); /* 재귀 */
}
return csum;
}
/* 성능 영향:
* - CHECKSUM_NONE + 비선형 skb = 최악의 조합
* → 모든 frag 페이지를 kmap하면서 체크섬 계산
* → 10Gbps 환경에서 CPU 50%+ 소비 가능
* - CHECKSUM_UNNECESSARY → 이 과정 전체 생략
* - GSO skb의 경우: 체크섬은 분할 시 한꺼번에 계산
* → skb_gso_segment() 내부에서 각 세그먼트별 계산
*/
터널/캡슐화(Encapsulation) 체크섬 처리
VXLAN, GRE 등 터널 환경에서는 외부(outer)와 내부(inner) 두 겹의 체크섬을 처리해야 합니다:
/* 터널 체크섬 이중 처리 구조 */
/*
* [Outer Eth][Outer IP][Outer UDP csum][VXLAN][Inner Eth][Inner IP][Inner TCP csum][Payload]
* ↑ 외부 체크섬 ↑ 내부 체크섬
*
* NETIF_F_HW_CSUM 지원 NIC:
* → 내부 체크섬: CHECKSUM_PARTIAL로 NIC에 위임
* → 외부 체크섬: 비활성화(0) 또는 NIC가 별도 처리
*
* 터널 체크섬 관련 NIC feature:
* NETIF_F_GSO_UDP_TUNNEL_CSUM: 외부 UDP csum + 내부 GSO
* → NIC가 내부 세그먼트 분할 후 각각의 외부 UDP csum도 계산
*/
/* VXLAN TX에서의 체크섬 설정 */
static void vxlan_xmit_skb(struct sk_buff *skb, ...)
{
/* 내부 패킷 체크섬은 이미 CHECKSUM_PARTIAL로 설정됨 */
/* 외부 UDP 체크섬 설정 */
if (!udp_sum) {
/* 외부 UDP csum 비활성화 (0으로 설정) — 성능 최적화
* RFC 7348: VXLAN에서 외부 UDP csum은 선택사항 */
uh->check = 0;
} else {
/* 외부 UDP csum 활성화 — 무결성 강화 */
udp_set_csum(skb->ip_summed != CHECKSUM_PARTIAL,
skb, saddr, daddr, skb->len);
}
/* 터널 encap 후 체크섬 정보 갱신 */
skb_set_inner_protocol(skb, htons(ETH_P_TEB));
skb_set_inner_network_header(skb, inner_nhoff);
skb_set_inner_transport_header(skb, inner_thoff);
/* 이 inner offset 정보가 NIC의 터널 체크섬 오프로드에 사용됨 */
}
/* 수신 측: 터널 디캡슐화 시 체크섬 검증 */
/*
* 1. 외부 UDP csum 검증 (0이면 생략)
* 2. VXLAN 헤더 파싱
* 3. 내부 패킷의 ip_summed 복원:
* - 외부가 CHECKSUM_UNNECESSARY → 내부도 UNNECESSARY 가능
* - skb_checksum_simple_validate()로 내부 csum 검증 최적화
* 4. remcsum 오프로드: VXLAN GPE 등에서 원격 체크섬 보조
* → TUNNEL_REMCSUM 플래그로 NIC가 내부 csum을 계산
*/
/* 수신 경로: 드라이버에서 체크섬 상태 설정 */
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)는 대량 데이터 전송/수신 시 성능을 극대화하는 핵심 메커니즘입니다. 기본 원리는 단순합니다: 네트워크 스택(Network Stack)을 통과하는 패킷 수를 줄여 per-packet 오버헤드(Overhead)(헤더 파싱, 룩업, lock 경합(Contention), cache miss)를 최소화합니다.
핵심 개념: GSO는 전송(TX) 방향에서 대형 skb를 마지막 순간에 분할하고, GRO는 수신(RX) 방향에서 작은 패킷들을 하나의 대형 skb로 병합합니다. 둘 다 네트워크 스택 통과를 한 번으로 줄여 성능을 극대화합니다. MTU=1500 기준 64KB 데이터 처리 시, ~43개 패킷을 개별 처리하는 대신 1개의 대형 skb로 스택을 한 번만 통과합니다.
napi_gro_receive() 호출 경로, GRO flush 타이머(Timer), 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를 지원해야 함, 터널/암호화(Encryption) 등에선 미지원 가능
- 확인: 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 분할이라도 늦은 분할이 유리)
- 터널, 가상화(Virtualization) 등 복잡한 경로에서도 동작
- 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 재전송(Retransmission) 시) |
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;
}
코드 설명
- skb_shared_info
include/linux/skbuff.h에 정의된 구조체(Struct)로, 모든 skb 뒤에 위치하는 메타데이터 영역입니다.gso_size는 분할 단위(TCP의 경우 MSS),gso_segs는 예상 세그먼트 수,gso_type은SKB_GSO_TCPV4등 프로토콜별 비트마스크를 저장합니다. - skb_is_gso()
gso_size가 0이 아니면 GSO skb로 판별합니다. 전송 경로의validate_xmit_skb()에서 이 함수로 분할 여부를 결정합니다. - skb_gso_network_seglen()GSO 분할 후 각 세그먼트의 네트워크 계층 크기를 계산합니다.
ip_finish_output()에서 MTU 초과 여부를 검사할 때 사용하며, 전송 헤더 오프셋(Offset)과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;
}
...
}
코드 설명
- tcp_send_mss()
net/ipv4/tcp_output.c에서 현재 MSS와size_goal을 결정합니다. GSO 활성 시size_goal = MSS × max_segs로 하나의 skb에 최대 64KB 데이터를 적재하여 시스템 콜(System Call) 오버헤드를 줄입니다. - tcp_set_skb_tso_segs()skb 크기가 MSS를 초과하면
gso_size,gso_segs,gso_type을 설정하여 GSO skb로 표시합니다. MSS 이하이면 GSO 필드를 0으로 설정하여 일반 skb로 처리합니다. - validate_xmit_skb()
net/core/dev.c의 핵심 결정 지점입니다.netif_skb_features()로 NIC의 오프로드 능력을 확인하고, NIC가 해당 GSO 타입을 지원하면 대형 skb를 그대로 하드웨어에 전달합니다. 미지원 시__skb_gso_segment()로 소프트웨어 분할을 수행합니다. - HW vs SW 분기
__skb_gso_segment()가NULL을 반환하면 NIC가 하드웨어 처리 가능하다는 의미이며, 유효한 skb 리스트를 반환하면 소프트웨어 분할이 완료된 것입니다. 이 투명한 폴백 구조 덕분에 상위 계층은 NIC 능력을 알 필요가 없습니다.
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);
}
코드 설명
- skb_gso_segment()
net/core/skbuff.c에 정의된 소프트웨어 GSO 분할의 진입점(Entry Point)입니다.skb_mac_gso_segment()를 통해 프로토콜별 GSO 콜백(Callback)을 호출하며, TCP는tcp4_gso_segment(), UDP는__udp_gso_segment()가 실제 분할을 수행합니다. - tcp4_gso_segment()
net/ipv4/tcp_offload.c에서 TCP GSO 분할을 담당합니다. 원본 skb를gso_size(MSS) 단위로 나누고, 각 세그먼트에 올바른 시퀀스 번호, PSH/FIN/CWR 플래그, IP ID를 설정합니다. - skb_list_walk_safe()분할 결과는
skb->next로 연결된 링크드 리스트입니다. 각 세그먼트는 독립적인 TCP/IP 헤더와 올바른 체크섬을 가지므로dev_queue_xmit()로 개별 전송할 수 있습니다.
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 (수신 오프로드(Receive Offload))
GRO(Generic Receive Offload)는 수신된 작은 패킷들을 하나의 큰 skb로 합치는 메커니즘입니다. LRO(Large Receive Offload)의 소프트웨어 일반화로, LRO와 달리 원본 헤더 정보를 보존하여 라우팅(Routing)/포워딩 환경에서도 안전하게 동작합니다.
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 수신 파이프라인(Pipeline)
/* === 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; /* 버킷 가득 참 → 일반 경로 */
}
코드 설명
- napi_gro_receive()
net/core/gro.c에 정의된 GRO의 주요 진입점입니다. NIC 드라이버의 NAPI poll 함수에서 호출하며,skb_gro_reset_offset()로 GRO 오프셋을 초기화한 뒤dev_gro_receive()에 처리를 위임합니다. - dev_gro_receive()GRO 매칭 엔진의 핵심입니다.
gro_hash[]해시 테이블(Hash Table)에서 동일 flow를 검색하고,call_gro_receive()로 프로토콜별 콜백 체인(L2→L3→L4)을 호출하여 병합 가능 여부를 판단합니다. - GRO 결과 분기콜백이 skb 자체를 반환하면
GRO_NORMAL(병합 불가), 다른 skb를 반환하면GRO_MERGED(flush 대상),flush플래그가 설정되면 일반 경로로 전달합니다. 새로운 flow이고 버킷에 여유가 있으면(MAX_GRO_SKBS=8)GRO_HELD로 다음 패킷을 대기합니다.
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); /* → 아래 설명 */
}
...
}
코드 설명
- tcp_gro_receive()
net/ipv4/tcp_offload.c에서 TCP GRO 병합 조건을 검증합니다.same_flow가 1인 기존 skb에 대해 소스/대상 포트 비교(4바이트 XOR 1회), 시퀀스 번호 연속성, TCP 플래그, 윈도우 크기를 순차적으로 확인합니다. - 포트 비교
th->source와th->dest를u32로 캐스팅하여 한 번의 XOR 연산으로 소스·대상 포트를 동시에 비교합니다. 불일치하면same_flow = 0으로 표시하고 다음 후보로 넘어갑니다. - 시퀀스 번호 확인기존 skb의 끝 시퀀스(
ntohl(th2->seq) + skb_gro_len(p))와 새 패킷의 시작 시퀀스가 일치해야 합니다. 불일치하면flush = 1로 설정하여 기존 skb를 상위 스택으로 전달합니다. - 플래그 검증SYN, FIN, RST, URG 플래그가 설정된 패킷은 연결 상태 변경을 의미하므로 병합하지 않습니다. ACK만 있는 일반 데이터 패킷만 병합 대상입니다.
GRO 데이터 병합 방식
GRO는 두 가지 방식으로 수신 데이터를 병합합니다:
| 병합 방식 | 조건 | 데이터 구조 | 장단점 |
|---|---|---|---|
| frag 기반 | skb가 선형(linear) 데이터일 때 | skb_shared_info→frags[] 배열에 페이지(Page) 추가 |
메모리 효율적, 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;
}
코드 설명
- skb_gro_receive()
net/core/gro.c에 정의된 실제 데이터 병합 함수입니다. 기존 skbp에 새 skb의 데이터를 추가하며, 먼저gro_max_size초과 여부를 검사하여 과도한 병합을 방지합니다. - frag 기반 병합새 skb의 헤더 길이가 오프셋 이하이면,
skb_shared_info->frags[]배열에 페이지 참조를 복사합니다.MAX_SKB_FRAGS(17)개까지 가능하며, scatter-gather DMA에 최적화된 방식입니다. - frag_list 기반 병합frag 공간이 부족하거나 skb가 이미 비선형이면,
frag_list체인에 skb 전체를 연결합니다. 구조는 단순하지만 나중에 GSO로 재분할할 때 오버헤드가 발생할 수 있습니다. - 메타데이터 갱신병합 후
p->len,p->data_len,p->truesize를 누적하고NAPI_GRO_CB(p)->count를 증가시킵니다. 이 count 값은 나중에gso_segs로 변환되어 상위 스택에서 실제 패킷 수를 파악하는 데 활용됩니다.
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, /* 콜백이 직접 처리 완료 */
};
코드 설명
- napi_gro_receive()완전한 skb를 GRO 처리하는 가장 일반적인 진입점입니다. 대부분의 NIC 드라이버(e1000e, ixgbe, mlx5 등)가 NAPI poll 루프에서 이 함수를 호출합니다.
- napi_gro_frags()고성능 드라이버가 헤더와 페이로드를 분리 수신할 때 사용합니다.
napi->skb에 미리 설정된 skb를 사용하며, 헤더는 선형 영역에, 페이로드는skb_fill_page_desc()로 frag에 배치합니다. 불필요한 메모리 복사를 피하는 제로카피(zero-copy) 최적화입니다. - gro_result enum
include/linux/netdevice.h에 정의된 GRO 결과 타입입니다.GRO_MERGED_FREE는GRO_MERGED와 달리 현재 skb를 해제해도 되는 의미이며,napi_skb_finish()에서 이 값에 따라 skb 메모리를 관리합니다.
하드웨어 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 콜백(Callback)
/* 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;
}
UDP GSO (USO — UDP Segmentation Offload)
커널 4.18에서 도입된 UDP GSO(흔히 USO라 부름)는 TCP GSO와 동일한 "지연(Latency) 분할" 전략을 UDP에 적용합니다. QUIC, WireGuard, DNS-over-HTTPS, 게임 서버 등 대량 UDP 전송에서 획기적인 성능 향상을 제공합니다.
UDP GSO 사용법 (사용자 공간(User Space) API)
/* 사용자 공간에서 UDP GSO 사용 — sendmsg() + UDP_SEGMENT ancillary data */
#include <netinet/udp.h>
int send_udp_gso(int fd, const void *buf, size_t len, uint16_t gso_size)
{
struct msghdr msg = {};
struct iovec iov = { .iov_base = (void *)buf, .iov_len = len };
char control[CMSG_SPACE(sizeof(uint16_t))];
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof(control);
/* UDP_SEGMENT cmsg로 세그먼트 크기 지정 */
struct cmsghdr *cm = CMSG_FIRSTHDR(&msg);
cm->cmsg_level = SOL_UDP;
cm->cmsg_type = UDP_SEGMENT; /* 커널 4.18+ */
cm->cmsg_len = CMSG_LEN(sizeof(uint16_t));
*((uint16_t *)CMSG_DATA(cm)) = gso_size;
/* 64KB 버퍼를 한 번의 sendmsg()로 전송
* → 커널이 내부적으로 gso_size 단위 분할
* → sendmsg() 시스템콜 1회로 44개 UDP 데이터그램 전송 */
return sendmsg(fd, &msg, 0);
}
/* 사용 예시 */
char buf[65536];
send_udp_gso(sockfd, buf, sizeof(buf), 1472);
/* → 커널이 65536 / 1472 = 44+1개 UDP 데이터그램으로 분할 전송
* 마지막 세그먼트: 65536 - 1472*44 = 768 바이트 (짧을 수 있음)
*/
/* 수신 측: UDP GRO로 병합된 패킷을 효율적으로 수신 */
int val = 1;
setsockopt(sockfd, SOL_UDP, UDP_GRO, &val, sizeof(val));
/* → 커널이 GRO로 병합된 대형 UDP 메시지를 한 번에 전달
* → recvmsg()의 GRO_UDP ancillary data로 원본 세그먼트 크기 확인 가능
*/
UDP GSO 커널 내부
/* net/ipv4/udp_offload.c — __udp_gso_segment() 핵심 로직 */
struct sk_buff *__udp_gso_segment(struct sk_buff *gso_skb,
netdev_features_t features,
bool is_ipv6)
{
unsigned int mss = skb_shinfo(gso_skb)->gso_size;
struct sk_buff *segs, *seg;
struct udphdr *uh;
/* skb_segment()로 mss 단위 분할 수행 */
segs = skb_segment(gso_skb, features & ~NETIF_F_SG, false);
if (IS_ERR(segs))
return segs;
/* 각 세그먼트에 독립 UDP 헤더 설정 */
seg = segs;
do {
uh = udp_hdr(seg);
/* UDP length = 세그먼트 데이터 크기 + UDP 헤더(8) */
uh->len = htons(sizeof(*uh) + seg->len - skb_transport_offset(seg)
- sizeof(*uh));
/* 마지막 세그먼트가 아니면 동일 UDP src/dst 포트 유지
* (TCP와 달리 시퀀스 번호 증가 없음 — 각 세그먼트가 독립 데이터그램) */
/* 체크섬 재계산 */
if (seg->ip_summed == CHECKSUM_PARTIAL)
udp_set_csum(!0, seg, ...);
} while ((seg = seg->next));
return segs;
}
/* UDP GSO vs TCP GSO 핵심 차이 */
/*
* TCP GSO:
* - 시퀀스 번호 증가: seq += gso_size (각 세그먼트)
* - PSH/FIN 마지막에만, CWR 첫 세그먼트에만
* - 수신 측 TCP가 자동 재조립 (시퀀스 기반)
*
* UDP GSO:
* - 시퀀스 번호 없음 → 각 세그먼트가 완전 독립 데이터그램
* - IP ID는 증가 (IPv4)
* - 수신 측 재조립은 애플리케이션 책임
* - UDP_GRO 소켓 옵션으로 GRO 병합 시 원본 크기 보존
*
* UDP GSO가 유용한 워크로드:
* - QUIC (자체 시퀀스/신뢰성)
* - WireGuard (고정 크기 패킷 대량 전송)
* - DNS-over-HTTPS 서버 (다수 응답 일괄 전송)
* - 미디어 스트리밍 (RTP/UDP 대량 전송)
* - 게임 서버 (다수 클라이언트에 상태 업데이트)
*/
UDP GSO 성능 영향
| 시나리오 | sendmsg() 호출 수 | 시스템콜 오버헤드 | 스택 통과 횟수 | 처리량(Throughput) (10Gbps NIC) |
|---|---|---|---|---|
| 개별 UDP 전송 (1472B × 44) | 44회 | 44 × syscall | 44회 | ~3.5 Gbps |
| sendmmsg() 사용 | 1회 (배치) | 1 × syscall | 44회 | ~5.2 Gbps |
| UDP GSO (64KB) | 1회 | 1 × syscall | 1회 | ~9.2 Gbps |
| UDP GSO + HW USO | 1회 | 1 × syscall | 1회 | ~9.8 Gbps (CPU 최소) |
gso_size를 QUIC 패킷 크기(보통 1200~1350B)로 설정하면, 단일 sendmsg()로 수십 개의 QUIC 패킷을 일괄 전송할 수 있습니다.
BIG TCP (대형 세그먼트, 커널 6.3+)
전통적으로 GSO/GRO의 최대 크기는 64KB(IP 헤더의 Total Length 필드가 16비트)로 제한되었습니다. BIG TCP는 IPv6 Jumbogram 확장 헤더를 활용하여 이 제한을 우회, 최대 512KB까지의 super-skb를 허용합니다.
BIG TCP 설정과 구조
/* BIG TCP 활성화 (커널 6.3+, IPv6 전용) */
# GSO/GRO 최대 크기를 64KB 이상으로 확장
# ip link set dev eth0 gso_max_size 185000
# ip link set dev eth0 gro_max_size 185000
#
# 더 큰 값도 가능 (NIC 지원 시):
# ip link set dev eth0 gso_max_size 524280
# ip link set dev eth0 gro_max_size 524280
#
# 확인:
# ip -d link show eth0 | grep -E 'gso_max|gro_max'
# gso_max_size 185000 gso_max_segs 65535
# gro_max_size 185000
/* BIG TCP 조건과 제약사항 */
/*
* 1. IPv6 전용: IPv4는 IP 헤더 total_length가 16비트라 64KB 제한 불변
* → IPv6 Jumbogram 확장 헤더로 32비트 payload length 사용
*
* 2. NIC 지원 필요: TSO/GRO 하드웨어가 64KB 이상을 처리할 수 있어야 함
* → Intel E810, Mellanox CX-5/6/7, Broadcom BCM5750X 등 지원
* → ethtool -k eth0에서 TSO max size 확인
*
* 3. Jumbogram은 로컬 스택에서만 사용:
* → 와이어(wire)에는 여전히 MSS 단위 세그먼트가 전송됨
* → Jumbogram 헤더는 GRO 병합/GSO 분할 내부에서만 존재
* → 네트워크 장비(라우터/스위치)에 영향 없음
*
* 4. 커널 내부 변경점:
* → skb->len이 u32 (4GB까지) → 문제 없음
* → skb_shared_info->gso_size는 u16 (65535) → 문제 없음 (MSS 단위)
* → net_device->gso_max_size가 unsigned int로 확장 (6.3+)
*/
/* 커널 내부: BIG TCP skb 생성 */
static int tcp_sendmsg_locked(struct sock *sk, ...)
{
/* size_goal 계산에서 BIG TCP 반영 */
int mss_now = tcp_send_mss(sk, &size_goal, flags);
/* BIG TCP 활성 시:
* sk->sk_gso_max_size = 185000 (또는 더 큰 값)
* size_goal = min(sk->sk_gso_max_size, 65535 * mss_now / mss_now)
* = 185000 (IPv6)
* → 하나의 skb에 185KB 데이터 적재
* → gso_segs = 185000 / 1460 = 126개
*/
}
/* IPv6 Jumbogram 처리 */
/* tcp_gso_segment()에서 BIG TCP skb 분할 시:
* - payload_length > 65535 감지
* - Hop-by-Hop Jumbo Payload 옵션 헤더 추가
* - 각 세그먼트는 일반 크기 (MSS)이므로 Jumbogram 불필요
* - 수신 GRO 측: 일반 패킷을 병합하면 자동으로 BIG TCP skb 생성
*/
- IPv4 미지원: IPv4는 total_length 16비트 한계로 BIG TCP 불가. IPv6 전용.
- tcpdump: GRO 시 185KB+ 패킷이 캡처될 수 있음. MTU 초과처럼 보이지만 정상.
- iptables/nftables: conntrack이 대형 skb를 처리하므로 패킷 카운터가 크게 다를 수 있음.
- MTU 경로: BIG TCP는 로컬 스택 내부만 영향. 와이어에는 MSS 단위 전송.
GRO 내부 자료구조
GRO의 핵심은 napi_struct 내부의 해시 테이블(Hash Table)과 NAPI_GRO_CB 제어 블록입니다. 이 자료구조가 O(1)에 가까운 flow 매칭을 가능하게 합니다.
GRO 해시 테이블 구조
/* include/linux/netdevice.h — GRO 해시 테이블 */
#define GRO_HASH_BUCKETS 8 /* 해시 버킷 수 */
struct gro_list {
struct list_head list; /* 이 버킷의 skb 리스트 */
int count; /* 리스트의 skb 수 (최대 MAX_GRO_SKBS=8) */
};
struct napi_struct {
...
struct gro_list gro_hash[GRO_HASH_BUCKETS];
unsigned long gro_bitmask; /* 비어있지 않은 버킷의 비트맵
* → 빈 버킷은 건너뛰어 flush 최적화 */
struct list_head rx_list; /* GRO 완료 후 상위 전달 대기 리스트 */
int rx_count; /* rx_list의 skb 수 */
...
};
/* 해시 버킷 선택: skb의 rxhash를 버킷 수로 모듈러 */
static inline unsigned int gro_hash_bucket(struct sk_buff *skb)
{
/* skb->hash: NIC의 RSS(Receive Side Scaling) 또는 SW 해시
* → 동일 flow의 패킷은 동일 해시 → 동일 버킷
* → 해시 품질이 GRO 효율에 직결 */
return skb_get_hash_raw(skb) & (GRO_HASH_BUCKETS - 1);
}
/* NAPI_GRO_CB: skb->cb[] 공간을 GRO 제어 블록으로 활용 */
struct napi_gro_cb {
/* frag0 최적화: 첫 번째 frag의 가상 주소를 캐시
* → 비선형 skb에서 헤더 접근 시 kmap 비용 절감 */
void *frag0;
unsigned int frag0_len;
/* 데이터 오프셋: L4 페이로드 시작 위치
* → 프로토콜 콜백이 이전 계층의 헤더 길이를 전달 */
int data_offset;
/* 병합 제어 플래그 */
u16 flush; /* !0 → 이 skb를 즉시 flush */
u16 flush_id; /* IP ID 불연속 시 flush 카운터 */
u16 count; /* 이 skb에 병합된 패킷 수 */
/* flow 매칭 */
u8 same_flow:1; /* 1: 동일 flow로 판정 (프로토콜 콜백이 설정) */
u8 encap_mark:1; /* 터널 캡슐화 표시 */
u8 csum_valid:1; /* 체크섬 검증 완료 */
u8 csum_cnt; /* 체크섬 불필요 카운트 (터널 중첩) */
u8 is_flist; /* frag_list 기반 GRO */
/* frag_list 병합용 */
struct sk_buff *last; /* frag_list의 마지막 skb */
/* 재귀적 GRO (터널) */
int recursion_counter; /* 터널 중첩 깊이 (최대 3) */
int network_offset; /* 네트워크 헤더 오프셋 */
int inner_network_offset; /* 내부 네트워크 헤더 오프셋 */
};
/* sizeof(napi_gro_cb) ≤ 48 (skb->cb[] = 48 바이트)
* → 별도 메모리 할당 없이 skb 내부 공간 활용
* → 캐시 라인 효율 극대화 */
GRO Flush 상세 경로
/* GRO flush: gro_list의 병합된 skb를 상위 스택으로 전달 */
/* 1. napi_gro_flush() — 전체 버킷 flush */
void napi_gro_flush(struct napi_struct *napi, bool flush_old)
{
unsigned long bitmask = napi->gro_bitmask;
unsigned int i;
/* bitmask로 비어있지 않은 버킷만 순회 — 불필요한 탐색 제거 */
while ((i = __ffs(bitmask)) < GRO_HASH_BUCKETS) {
struct list_head *head = &napi->gro_hash[i].list;
struct sk_buff *skb, *p;
list_for_each_entry_safe(skb, p, head, list) {
if (flush_old && NAPI_GRO_CB(skb)->age == jiffies)
continue; /* 이번 NAPI 사이클에서 추가된 건 유지 */
/* flush: 프로토콜별 gro_complete 콜백 호출 후 상위 전달 */
gro_flush_oldest(napi, head);
}
bitmask &= ~(1UL << i);
}
}
/* 2. gro_normal_list() — rx_list 일괄 전달 */
static void gro_normal_list(struct napi_struct *napi)
{
if (!napi->rx_count)
return;
/* rx_list의 모든 skb를 한꺼번에 상위 스택으로 전달
* → netif_receive_skb_list_internal() — 리스트 기반 최적화
* → 개별 netif_receive_skb() 대비 RCU/lock 오버헤드 감소 */
netif_receive_skb_list_internal(&napi->rx_list);
INIT_LIST_HEAD(&napi->rx_list);
napi->rx_count = 0;
}
/* 3. gro_complete 콜백 체인 */
/*
* flush 시 호출되는 프로토콜별 gro_complete:
*
* tcp4_gro_complete():
* - TCP 헤더의 ACK number 최종 설정
* - TCP 윈도우 업데이트
* - skb_shinfo->gso_type = SKB_GSO_TCPV4 설정
* - skb_shinfo->gso_segs = 병합된 패킷 수
* → GSO로 다시 분할할 때 필요한 정보 보존
*
* inet_gro_complete():
* - IP 헤더의 total_length 업데이트 (병합 후 총 크기)
* - IP ID 범위 기록
*
* 최종적으로 netif_receive_skb()로 전달:
* → ip_rcv() → tcp_v4_rcv() → sk_receive_queue
* → 애플리케이션의 recv() 한 번으로 64KB+ 수신 가능
*/
가상화 환경의 GSO/GRO
가상화, 컨테이너(Container), SDN 환경에서 GSO/GRO는 가상 인터페이스 간 패킷 전달 최적화의 핵심입니다. virtio-net, veth, macvtap, TAP 등 가상 디바이스는 하드웨어 오프로드가 없으므로 소프트웨어 GSO/GRO에 전적으로 의존합니다.
virtio-net GSO/GRO 연동
/* virtio-net의 GSO 오프로드 핵심: vnet_hdr */
/* Guest → Host: GSO skb를 분할하지 않고 그대로 전달 */
struct virtio_net_hdr_v1 {
__u8 flags; /* VIRTIO_NET_HDR_F_NEEDS_CSUM 등 */
__u8 gso_type; /* VIRTIO_NET_HDR_GSO_TCPV4/V6/UDP */
__le16 hdr_len; /* 모든 헤더의 총 길이 */
__le16 gso_size; /* MSS (분할 단위) — skb_shinfo->gso_size에 매핑 */
__le16 csum_start; /* 체크섬 시작 오프셋 */
__le16 csum_offset; /* 체크섬 필드 오프셋 */
__le16 num_buffers; /* MRG_RXBUF: 사용된 버퍼 수 */
};
/* Guest TX: GSO skb → vnet_hdr 변환 */
/*
* 1. Guest의 TCP 스택이 64KB GSO skb 생성
* 2. virtio-net 드라이버가 vnet_hdr에 GSO 정보 기록
* 3. skb 데이터를 분할하지 않고 vring으로 전달
* 4. Host의 vhost-net 또는 QEMU가 수신:
* a. vnet_hdr의 gso_type/gso_size를 읽어
* b. Host skb의 skb_shinfo에 GSO 정보 복원
* c. Host 네트워크 스택은 이 skb를 GSO skb로 인식
* d. Host → 물리 NIC 전달 시: HW TSO 또는 SW GSO로 분할
*
* → Guest-Host 간 패킷이 분할 없이 전달 → 매우 효율적
*/
/* VIRTIO_NET_F_MRG_RXBUF (Mergeable Receive Buffers) */
/*
* Host → Guest 방향에서 GRO 효율을 극대화:
* - 여러 vring 버퍼를 하나의 큰 패킷으로 병합
* - num_buffers 필드로 사용된 버퍼 수 표시
* - Guest가 64KB skb를 받을 때:
* → 여러 4KB vring 버퍼가 하나의 skb로 조립
* → skb_shinfo->frags[]에 각 버퍼 페이지 매핑
* → GRO가 병합한 대형 패킷을 Guest가 효율적으로 수신
*
* MRG_RXBUF 없으면:
* → 각 vring 버퍼가 별도 skb → 64KB 패킷을 위해 44개 skb
* → Guest의 수신 성능 크게 저하
*/
/* VIRTIO_NET_F_GUEST_TSO4/TSO6/UFO */
/*
* Host가 Guest에 GSO skb를 직접 전달할 수 있음:
* - Host의 GRO로 병합된 패킷 → 분할 없이 Guest에 전달
* - Guest의 네트워크 스택이 GSO skb로 인식
* - Guest가 다른 NIC로 포워딩 시: Guest의 GSO가 분할
*
* 이 feature 없으면:
* - Host가 반드시 세그먼트 단위로 분할 후 전달
* - 성능 대폭 저하 (특히 포워딩 시)
*/
veth (Container) GSO/GRO
/* veth: 컨테이너 네트워킹의 핵심 가상 인터페이스 */
/* veth의 GSO 처리가 특별한 이유:
* - veth는 peer 디바이스와 1:1 연결
* - TX 측의 GSO skb를 분할하지 않고 RX peer로 직접 전달
* - peer의 GRO에서 재병합할 필요 없음 (이미 대형 skb)
*
* drivers/net/veth.c */
static netdev_tx_t veth_xmit(struct sk_buff *skb,
struct net_device *dev)
{
struct veth_priv *priv = netdev_priv(dev);
struct net_device *rcv = rcu_dereference(priv->peer);
/* GSO skb를 분할하지 않고 peer로 직접 전달!
* → 64KB skb가 그대로 peer의 수신 경로에 진입
* → peer 측에서 netif_rx() 또는 napi_gro_receive() */
if (likely(veth_forward_skb(rcv, skb, priv, rq, false) == NET_RX_SUCCESS))
return NETDEV_TX_OK;
...
}
/* veth features: 사실상 모든 오프로드를 "지원"
* → 실제 HW가 아니므로 SW GSO fallback이지만,
* peer 전달 시 분할이 발생하지 않으므로 무관 */
/*
* veth가 광고하는 features:
* NETIF_F_SG → Scatter-Gather (항상)
* NETIF_F_HW_CSUM → 체크섬 (소프트웨어로 처리)
* NETIF_F_TSO → TCP GSO
* NETIF_F_TSO6 → TCP GSO IPv6
* NETIF_F_GSO_UDP_L4 → UDP GSO
* NETIF_F_GSO_UDP_TUNNEL → 터널 GSO
* ...
*
* 이 features 덕분에:
* Container → veth TX: GSO skb 그대로 전달 (분할 안 함)
* veth peer RX → Bridge/OVS: 대형 skb로 전달
* Bridge → Physical NIC TX: NIC의 실제 feature에 맞춰 분할
*
* 성능 영향:
* veth + GSO ON: ~40 Gbps (Container 간)
* veth + GSO OFF: ~8 Gbps (44배 더 많은 skb 처리)
*/
XDP와 GSO/GRO 상호작용
XDP는 GRO 이전에 동작합니다. NIC IRQ → NAPI poll 과정에서 XDP 프로그램이 먼저 실행되고, 그 결과에 따라 GRO 경로 진입 여부가 결정됩니다:
- XDP_PASS — GRO로 진입합니다 (
napi_gro_receive). - XDP_DROP — 즉시 드롭되며, GRO에 도달하지 않습니다.
- XDP_TX — 같은 NIC로 반송합니다 (GRO 우회).
- XDP_REDIRECT — 다른 NIC/AF_XDP로 전달합니다 (GRO 우회).
핵심은 XDP_TX/XDP_REDIRECT가 개별 패킷 단위로 동작한다는 점입니다. GRO 병합 이전이므로 각 패킷이 독립적이며, 대량 트래픽에서 per-packet XDP는 CPU 집약적입니다.
XDP에서 GSO skb를 처리할 수 없는 이유
xdp_frame/xdp_buff는 단일 선형 버퍼(Buffer)만 지원합니다:
frags[]미지원 (XDP multi-buffer는 5.18+에서 부분 지원)skb_shared_info의gso_size/gso_type필드 없음- 따라서 GSO super-skb를 XDP로 처리할 수 없습니다
GRO → XDP 경로를 시도하는 경우, GRO가 병합한 대형 skb는 XDP_PASS 후 상위 스택으로 전달됩니다. XDP generic 모드는 skb 기반이므로 GSO skb 처리가 가능하지만, 성능 이점이 사라집니다 (native XDP 대비 10배 이상 느림). XDP에서 GRO와 유사한 기능이 필요하면 AF_XDP + 사용자 공간 병합(DPDK 스타일) 또는 eBPF TC(GRO 이후 동작, GSO skb 처리 가능)를 사용해야 합니다.
eBPF TC classifier와 GSO/GRO
TC eBPF는 GRO 이후, GSO 이전에 동작합니다:
GRO → IP → [TC ingress eBPF] → routing → [TC egress eBPF] → GSO → NIC
따라서 TC eBPF에서는 다음과 같이 동작합니다:
- 수신 — GRO로 병합된 대형 skb를 처리합니다 (효율적).
- 송신 — GSO 분할 전 대형 skb를 처리합니다 (효율적).
skb->len이 64KB 이상일 수 있으므로 주의해야 합니다.bpf_skb_change_tail()등 크기 변경 시 GSO가 무효화(Invalidation)될 수 있습니다.
IPsec ESP GSO
IPsec ESP(Encapsulating Security Payload) 환경에서 GSO는 특별한 도전 과제를 제기합니다. 암호화는 패킷 단위로 수행해야 하지만, GSO는 분할을 최대한 지연시키기 때문입니다.
ESP GSO의 핵심 문제: ESP는 패킷 단위로 암호화/인증을 수행하여 IV, SN, ICV가 패킷별로 고유합니다. 반면 GSO는 분할을 최대한 지연합니다. 대형 skb 상태에서 패킷별 ESP 처리를 어떻게 수행할 것인지가 핵심 과제입니다.
- 해결책 1: SW ESP + GSO 분할 선행 (기존 방식)
- GSO를 먼저 분할하고 44개 패킷에 각각 ESP를 적용합니다. 스택 효율 이점이 상실됩니다.
- 해결책 2: ESP GSO offload (
NETIF_F_GSO_ESP) - 대형 skb에 ESP 메타데이터만 설정하고,
validate_xmit_skb()에서 분할과 ESP를 동시에 처리합니다. 또는 NIC 하드웨어가 암호화+분할을 일괄 처리합니다 (inline crypto).
xfrm offload 설정 확인
# ip xfrm state list
...
offload dev eth0 dir out ← NIC 오프로드 활성
# ethtool -k eth0 | grep esp
esp-hw-offload: on
tx-esp-segmentation: on ← ESP GSO 지원
# 설정:
# ip xfrm state add ... offload dev eth0 dir out
ESP GSO 분할 시 각 세그먼트 처리
esp4_gso_segment() / esp6_gso_segment()는 다음 과정을 수행합니다:
- 원본 대형 skb를 MSS 단위로 분할합니다.
- 각 세그먼트에 다음을 적용합니다:
- 고유 ESP SPI 유지 (동일 SA)
- 고유 Sequence Number 할당 (증가)
- 고유 IV(Initialization Vector) 생성
- ESP 트레일러(Trailer)(Padding + Next Header + ICV) 추가
- AES-GCM/CBC 등으로 암호화
- ICV(Integrity Check Value) 계산
- 각 세그먼트가 독립적인 ESP 패킷으로 완성됩니다.
HW offload 시에는 위 과정을 NIC 하드웨어가 수행하며, CPU는 SA 메타데이터만 DMA 디스크립터에 기록합니다. 100Gbps IPsec 환경에서 필수적입니다.
TCP Autocorking과 GSO
TCP autocorking은 GSO 효율을 극대화하는 보조 메커니즘입니다. 작은 write()가 연속될 때 자동으로 데이터를 모아 하나의 큰 GSO skb를 만듭니다.
/* TCP Autocorking (커널 3.14+, 기본 활성) */
/* 문제 상황:
* 애플리케이션이 작은 write()를 반복:
* write(fd, buf1, 100); // 100B
* write(fd, buf2, 200); // 200B
* write(fd, buf3, 150); // 150B
*
* Autocorking 없으면:
* → 각 write()마다 별도 skb → 3개 작은 패킷 전송
* → GSO 효과 없음, Nagle과 다름 (Nagle은 ACK 대기)
*
* Autocorking 있으면:
* → 이미 전송 큐에 미전송 skb가 있고, 아직 ACK 안 왔으면
* → 새 데이터를 기존 skb에 append
* → 최종적으로 하나의 큰 skb로 GSO 전송
*/
/* net/ipv4/tcp.c — tcp_sendmsg_locked()에서의 autocorking 판단 */
static bool tcp_should_autocork(struct sock *sk,
struct sk_buff *skb,
int size_goal)
{
/* autocorking 조건:
* 1. net.ipv4.tcp_autocorking = 1 (sysctl, 기본 on)
* 2. 전송 큐에 이미 미전송 데이터가 있음 (sk->sk_wmem_queued > 0)
* 3. 마지막 전송 후 아직 ACK를 받지 못함
* 4. 현재 skb에 충분한 공간이 남아있음 (< size_goal)
*/
return sysctl_tcp_autocorking &&
skb != tcp_send_head(sk) &&
refcount_read(&sk->sk_wmem_alloc) > SKB_TRUESIZE(1) &&
tcp_packets_in_flight(tcp_sk(sk)) >= tcp_sk(sk)->snd_cwnd;
}
/* Autocorking ↔ GSO 시너지 */
/*
* Autocorking이 데이터를 모으는 동안:
* → 하나의 skb에 여러 write() 데이터가 축적
* → skb->len이 size_goal(= MSS × max_segs)에 도달하면 전송
* → 결과: 최대 64KB GSO skb로 전송
*
* Autocorking이 없으면:
* → 각 write()가 즉시 tcp_push() → 작은 skb 다수
* → GSO 효과 미미 (skb당 데이터가 적음)
*
* sysctl 제어:
* # sysctl net.ipv4.tcp_autocorking
* net.ipv4.tcp_autocorking = 1 (기본)
*
* TCP_CORK 소켓 옵션과의 차이:
* TCP_CORK: 명시적 — setsockopt()로 수동 제어
* Autocorking: 자동 — 전송 상태를 보고 커널이 결정
* → 대부분의 경우 autocorking으로 충분
* → 정밀 제어가 필요하면 TCP_CORK 또는 TCP_NODELAY
*
* 모니터링:
* # nstat -az | grep AutoCorking
* TcpExtTCPAutoCorking 123456 # autocorking 발동 횟수
*/
SCTP GSO
SCTP(Stream Control Transmission Protocol)는 TCP와 달리 청크(chunk) 기반 프로토콜이므로 GSO 분할 방식이 다릅니다:
/* SCTP GSO (커널 4.14+, SKB_GSO_SCTP) */
/* SCTP vs TCP 분할 차이:
*
* TCP GSO:
* [IP][TCP hdr, seq=1000][Payload 64KB]
* → [IP][TCP, seq=1000][1460B] + [IP][TCP, seq=2460][1460B] + ...
* 시퀀스 번호 기반 분할
*
* SCTP GSO:
* [IP][SCTP Common Hdr][DATA chunk 1][DATA chunk 2]...[DATA chunk N]
* → [IP][SCTP Hdr][DATA chunk 1] + [IP][SCTP Hdr][DATA chunk 2] + ...
* 청크 단위 분할 (각 청크가 자체 TSN 보유)
*/
/* net/sctp/offload.c — SCTP GSO 분할 */
static struct sk_buff *sctp_gso_segment(struct sk_buff *skb,
netdev_features_t features)
{
/* SCTP 분할 특이점:
* 1. 각 DATA 청크가 이미 독립적 (자체 TSN, Stream ID)
* 2. SCTP 공통 헤더의 CRC32c 체크섬을 각 세그먼트에서 재계산
* 3. IP 헤더의 total_length 업데이트
*
* gso_size = 하나의 DATA 청크 크기
* → skb_segment()로 청크 경계에서 분할
*/
struct sk_buff *segs = skb_segment(skb, features, false);
struct sk_buff *seg;
struct sctphdr *sh;
/* 각 세그먼트의 SCTP CRC32c 재계산 */
skb_list_walk_safe(segs, seg, tmp) {
sh = sctp_hdr(seg);
sh->checksum = sctp_compute_cksum(seg, skb_transport_offset(seg));
}
return segs;
}
/* SCTP GSO의 체크섬 특이점:
* - SCTP는 TCP/UDP와 달리 CRC32c 체크섬 사용
* - pseudo-header 없음 (IP 주소 변경에 무관)
* - CHECKSUM_PARTIAL로 NIC에 위임 불가 (대부분 NIC가 CRC32c 미지원)
* - 따라서 항상 소프트웨어에서 CRC32c 계산
* - NETIF_F_SCTP_CRC: CRC32c HW 오프로드 (일부 NIC, Intel X710+)
*
* # ethtool -k eth0 | grep sctp
* tx-sctp-segmentation: on # SCTP GSO
* tx-checksum-sctp: on # SCTP CRC32c HW offload
*/
GSO ↔ GRO 상호작용
포워딩 환경에서 GRO로 병합된 skb가 다시 GSO로 분할되는 경우가 빈번합니다. 이 "GRO → forward → GSO" 파이프라인이 네트워크 장비(라우터, 브리지(Bridge), 로드밸런서)의 성능을 결정합니다:
/* 포워딩 시 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 오프로드 제어
현재 오프로드 상태 확인
# 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_flush_timeout— GRO 패킷 보관 타임아웃(나노초)입니다. 기본값은 0으로,napi_complete시 즉시 flush합니다. 값을 설정하면 타이머 만료까지 더 많은 패킷 병합을 시도하여 처리량이 증가하지만, 지연 시간도 함께 증가합니다.# sysctl -w net.core.gro_flush_timeout=20000 # 20μs -
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 허용 -
권장 조합 (10Gbps+ 고처리량 환경):
gro_flush_timeout=20000+napi_defer_hard_irqs=2조합을 사용하면 IRQ 없이 busy-poll로 패킷을 수신하여 GRO 병합을 극대화합니다. 단, CPU 사용률이 약간 증가합니다 (IRQ coalescence와 트레이드오프). -
지연 민감 환경 (금융, 게임):
gro_flush_timeout=0+napi_defer_hard_irqs=0으로 최소 지연을 달성합니다. 하지만 GRO 병합 기회가 감소하며, 극단적인 경우 GRO 자체를 비활성화하는 것을 고려해야 합니다. -
busy_poll/busy_read— 소켓(Socket) 레벨 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
모니터링과 통계
-
NIC 통계 — GRO 병합 횟수
# ethtool -S eth0 | grep -i gro rx_gro_packets: 1234567 ← GRO로 병합된 패킷 수 rx_gro_bytes: 98765432000 ← GRO로 병합된 바이트 수 rx_gro_dropped: 0 ← GRO 중 드롭
-
/proc/net/dev— 일반 인터페이스 통계# cat /proc/net/devGRO 활성 시 RX packets가 줄고 bytes는 동일합니다. 패킷당 평균 크기가 크면 GRO가 잘 동작하고 있는 것입니다.
-
nstat — 프로토콜별 통계
# nstat -az | grep -i gro TcpExtTCPAutoCorking 123456 ← TCP autocorking (GSO 관련) -
perf로 GSO/GRO 함수 프로파일링(Profiling)
# perf top -g -e cycles -- -Kskb_gso_segment,tcp_gso_segment,dev_gro_receive등의 CPU 비중을 확인합니다. GRO/GSO 오버헤드가 높으면 HW 오프로드를 확인해야 합니다. -
ftrace로 GSO 분할 추적
# echo 1 > /sys/kernel/debug/tracing/events/net/net_dev_xmit/enable # cat /sys/kernel/debug/tracing/trace_pipeskb->len변화로 GSO 분할 여부를 확인할 수 있습니다. -
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의 효과를 정량적으로 확인하는 벤치마크 방법과 결과입니다. 환경에 따라 수치가 달라지므로 자체 측정이 필수입니다.
벤치마크 측정 방법
-
기준점 설정: 오프로드 비활성화
# ethtool -K eth0 gso off gro off tso off # ethtool -K eth0 rx-gro-hw off 2>/dev/null -
TCP 처리량 측정 (iperf3)
# 서버: iperf3 -s # 클라이언트: iperf3 -c SERVER_IP -t 30 -P 1 → Bandwidth: 2.8 Gbps (GSO/GRO OFF) # 오프로드 활성화 후 재측정: # ethtool -K eth0 gso on gro on tso on # iperf3 -c SERVER_IP -t 30 -P 1 → Bandwidth: 9.7 Gbps (GSO/GRO ON) -
CPU 사용률 동시 측정
# mpstat -P ALL 1 30 GSO/GRO OFF: CPU 82% (softirq + ksoftirqd 병목) GSO/GRO ON: CPU 28% (대부분 유휴) -
포워딩 성능 (netperf)
# Host A → Router → Host B # Router에서: # sysctl net.ipv4.ip_forward=1 # netperf -H HOST_B -t TCP_STREAM -l 30 -- -m 65536 GRO/GSO OFF: 4.5 Gbps (conntrack per-packet 병목) GRO/GSO ON: 9.4 Gbps (conntrack 1/44 실행) -
UDP GSO 벤치마크
# 서버: iperf3 -s # 클라이언트: iperf3 -c SERVER_IP -u -b 10G -l 64000 --udp-gso UDP 개별: 6.5 Gbps UDP GSO: 9.5 Gbps -
GRO 효율 확인
# 전송 중 패킷 통계 비교: # watch -n1 'cat /proc/net/dev | grep eth0' GRO OFF: RX packets ~815,000/sec (815K PPS) GRO ON: RX packets ~19,000/sec (19K PPS, 각 ~64KB) → GRO 비율: 815K / 19K ≈ 43:1 (거의 이론치) -
perf로 핫스팟 분석
# perf record -g -a -- sleep 10 # perf report GSO/GRO OFF 핫스팟: 15.2% tcp_v4_rcv 12.8% ip_rcv 8.5% nf_conntrack_in 7.2% __netif_receive_skb_core GSO/GRO ON 핫스팟: 22.1% copy_to_user (데이터 복사가 주 병목) 8.3% tcp_recvmsg 5.1% skb_gso_segment (GSO 분할)
시나리오별 최적 설정
| 환경 | GSO | GRO | TSO | HW-GRO | gro_flush_timeout | napi_defer_hard_irqs | 비고 |
|---|---|---|---|---|---|---|---|
| 웹 서버 (HTTP/HTTPS) | ON | ON | ON | ON | 0 | 0 | 기본 설정으로 충분 |
| 10Gbps+ 고처리량 | ON | ON | ON | ON | 20000 | 2 | GRO 병합 극대화 |
| 100Gbps IPv6 | ON | ON | ON | ON | 20000 | 2 | BIG TCP 185KB+ |
| 라우터/방화벽(Firewall) | ON | ON | ON | ON | 0 | 0 | 포워딩 효율 핵심 |
| 지연 민감 (금융) | ON | ON | ON | OFF | 0 | 0 | busy_poll=50 추가 |
| 극저지연 (HFT) | OFF | OFF | OFF | OFF | 0 | 0 | 커널 우회(DPDK) 권장 |
| 가상화 (KVM) | ON | ON | ON | ON | 0 | 0 | virtio MRG_RXBUF 필수 |
| 컨테이너 (K8s) | ON | ON | ON | ON | 0 | 0 | veth GSO 자동 활성 |
| IPsec VPN | ON | ON | ON | ON | 0 | 0 | xfrm offload + ESP GSO |
| 패킷 캡처/분석 | ON | OFF | ON | OFF | 0 | 0 | GRO OFF로 원본 패킷 확인 |
장애 사례와 디버깅
실전 트러블슈팅 사례
증상: tcpdump -i eth0 -n에서 TCP, length 65160처럼 MTU(1500)보다 훨씬 큰 패킷이 보입니다.
원인: tcpdump는 GRO 이후의 skb를 캡처하므로, GRO가 병합한 대형 skb가 그대로 pcap에 기록됩니다. 이는 정상 동작이며 문제가 아닙니다.
확인 방법:
# ethtool -K eth0 gro off
# tcpdump -i eth0 -n
→ 이제 MSS 단위 패킷만 보임 (1460B 등)
# ethtool -K eth0 gro on ← 측정 후 복원
또는 AF_PACKET 레벨에서 tcpdump --immediate-mode를 사용하면 일부 환경에서 도움이 됩니다.
증상: netstat -s | grep "packets dropped"에서 큰 수의 드롭이 보이며, GRO OFF 시 감소합니다.
원인: GRO가 64KB skb를 생성하여 소켓 수신 버퍼(sk_rcvbuf)를 빠르게 소진합니다. sk_rmem_alloc이 sk_rcvbuf를 초과하면 드롭이 발생합니다.
해결:
# sysctl -w net.core.rmem_max=16777216
# sysctl -w net.core.rmem_default=8388608
또는 애플리케이션에서 setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size))로 설정합니다.
증상: VXLAN 터널 throughput이 물리 NIC의 50% 수준입니다.
원인: NIC가 터널 GSO를 지원하지 않아 모든 패킷이 SW GSO로 분할되고, 외부 UDP 체크섬도 SW로 계산됩니다.
진단:
# ethtool -k eth0 | grep tunnel
tx-udp_tnl-segmentation: off [requested on] ← HW 미지원!
tx-udp_tnl-csum-segmentation: off
해결:
- 터널 GSO 지원 NIC로 교체합니다 (Intel E810, Mellanox CX-5+ 등).
- 외부 UDP csum을 비활성화합니다:
ip link set vxlan0 type vxlan udpcsum off(RFC 7348에서 허용, 약 10~20% 성능 개선) GSO_PARTIAL을 활용합니다 (NIC가 외부 헤더만 처리, 내부는 SW 분할).
증상: KVM Guest에서 iperf3 결과가 2Gbps 미만입니다.
진단:
# (Guest) ethtool -k eth0 generic-segmentation-offload: off ← GSO 비활성! generic-receive-offload: off
원인: virtio-net feature negotiation 실패 또는 QEMU 옵션에서 오프로드가 비활성화되어 있습니다.
해결:
- QEMU 옵션을 확인합니다:
-device virtio-net-pci,host_tso4=on,guest_tso4=on,host_ufo=on,guest_ufo=on,mrg_rxbuf=on - Guest 내에서 활성화합니다:
ethtool -K eth0 gso on gro on tso on - vhost-net 사용 여부를 확인합니다 (QEMU 프로세스(Process) 내 처리보다 빠릅니다).
증상: iptables -m length --length 0:1500 매칭이 동작하지 않습니다 (GSO skb는 64KB이므로 매칭 실패).
원인: GSO skb가 분할 전에 Netfilter를 통과하므로, skb->len이 64KB이고 length 매칭이 MSS 단위가 아닙니다.
해결:
-m connbytes를 사용합니다 (바이트 기반).- NFQUEUE를 사용하여 GSO 분할 후 처리합니다.
- nftables에서는
meta l4proto+ payload 매칭을 권장합니다.
증상: ip link set eth0 mtu 9000 후 기존 TCP 연결이 끊깁니다.
원인: 기존 연결의 MSS가 이전 MTU 기준이며, GSO gso_size가 이전 MSS 기준으로 설정되어 MTU 변경 후 불일치가 발생합니다.
해결:
- MTU 변경 전에 기존 연결을 종료합니다.
TCP_MAXSEG소켓 옵션으로 MSS를 수동 조정합니다.- 점진적 MTU 변경을 서비스 재시작(Reboot) 배포와 함께 수행합니다.
GSO/GRO 디버깅 도구 모음
-
ethtool — 오프로드 상태 및 통계
# ethtool -k eth0 ← 오프로드 플래그 # ethtool -S eth0 | grep -i gro ← GRO 통계 # ethtool -S eth0 | grep -i tso ← TSO 통계 # ethtool --show-features eth0 ← 전체 feature 목록
-
ip 명령 — GSO/GRO 최대 크기
# ip -d link show eth0 | grep -E 'gso|gro' # ip -s link show eth0 ← TX/RX 통계 -
ss — TCP 소켓 상태
# ss -ti state established → mss:1460 cwnd:44 → GSO 가능 세그먼트 수 추정 → delivery_rate 102Mbps → 실제 전송률
-
/proc/net/snmp— 프로토콜 카운터# cat /proc/net/snmp | grep Tcp InSegs: GRO 병합 후 카운트 (적을수록 GRO 효과적) OutSegs: GSO 분할 전 카운트 -
nstat — 세부 통계
# nstat -az | grep -i -E 'gro|cork|seg' TcpExtTCPAutoCorking: autocorking 발동 횟수 TcpExtTCPOrigDataSent: 원본 데이터 세그먼트 수 -
ftrace — GSO/GRO 함수 추적
# echo 'skb_gso_segment' > /sys/kernel/debug/tracing/set_ftrace_filter # echo function > /sys/kernel/debug/tracing/current_tracer # echo 1 > /sys/kernel/debug/tracing/tracing_on # cat /sys/kernel/debug/tracing/trace_pipe → GSO 분할 호출 빈도와 타이밍 확인 -
perf probe — 동적 트레이스포인트
# perf probe --add 'dev_gro_receive skb->len' # perf record -e probe:dev_gro_receive -a -- sleep 5 # perf script → GRO 진입 시 skb 크기 분포 확인 -
bpftrace — eBPF 기반 실시간(Real-time) 분석
# bpftrace -e ' kprobe:napi_gro_receive { @gro_count = count(); @gro_size = hist(((struct sk_buff *)arg1)->len); } interval:s:5 { print(@gro_size); clear(@gro_size); } ' → 5초마다 GRO로 들어오는 skb 크기 히스토그램 -
dropwatch — 패킷 드롭 추적
# dropwatch -l kas → GSO/GRO 관련 드롭 지점 식별 → skb_gso_validate 실패, gro_max_size 초과 등
커널 버전별 GSO/GRO 진화
| 커널 버전 | 기능 | GSO 타입/Feature | 주요 저자 | 핵심 변경 |
|---|---|---|---|---|
| 2.6.18 | GSO 프레임워크 | SKB_GSO_TCPV4 | Herbert Xu | tcp_gso_segment(), validate_xmit_skb() |
| 2.6.18 | UFO | SKB_GSO_UDP | Herbert Xu | UDP Fragmentation Offload (IP 단편화) |
| 2.6.29 | GRO 프레임워크 | NETIF_F_GRO | Herbert Xu | napi_gro_receive(), 프로토콜 콜백 체인 |
| 3.7 | 터널 GSO | SKB_GSO_GRE/UDP_TUNNEL | Pravin Shelar | VXLAN/GRE 내부 패킷 GSO 지원 |
| 3.14 | TCP Autocorking | - | Eric Dumazet | 자동 데이터 취합으로 GSO 효율 향상 |
| 3.18 | GSO_PARTIAL | SKB_GSO_PARTIAL | Alexander Duyck | 터널 부분 오프로드 (외부 HW + 내부 SW) |
| 4.13 | ESP GSO | SKB_GSO_ESP | Steffen Klassert | IPsec xfrm offload, inline crypto |
| 4.14 | SCTP GSO | SKB_GSO_SCTP | Marcelo R. Leitner | SCTP 청크 단위 GSO |
| 4.18 | UDP GSO | SKB_GSO_UDP_L4 | Willem de Bruijn | UDP L4 세그먼트 분할, UDP_SEGMENT cmsg |
| 4.18 | UDP GRO | UDP_GRO sockopt | Eric Dumazet | UDP 수신 병합 (QUIC 지원) |
| 5.1 | GRO 해시 테이블 | gro_hash[8] | Li RongQing | gro_list를 8-bucket 해시(Hash)로 교체 → O(1) 검색 |
| 5.18 | XDP multi-buffer | - | Lorenzo Bianconi | XDP에서 비선형 패킷 부분 지원 |
| 5.19 | HW-GRO | NETIF_F_GRO_HW | Eric Dumazet | NIC 하드웨어 GRO (헤더 보존) |
| 6.3 | BIG TCP | gso/gro_max_size 확장 | Eric Dumazet | IPv6 64KB→512KB, Jumbogram 활용 |
| 6.3 | gso_ipv4_max_size | net_device 필드 | Eric Dumazet | IPv4/IPv6 GSO 크기 독립 제어 |
커널 소스 맵
GSO/GRO 관련 핵심 커널 소스 파일 위치입니다. 커널 소스 탐색 시 참고하세요:
| 소스 파일 | 핵심 함수/구조체(Struct) | 역할 |
|---|---|---|
include/linux/skbuff.h | skb_shared_info, skb_is_gso(), skb_gso_* | GSO/GRO 관련 skb 헤더 정의 |
include/linux/netdevice.h | napi_struct, gro_list, NETIF_F_* | NAPI GRO 구조체, NIC feature 플래그 |
include/net/gro.h | napi_gro_cb, GRO_HASH_BUCKETS | GRO 제어 블록, 해시 상수 |
net/core/gro.c | dev_gro_receive(), napi_gro_receive(), skb_gro_receive() | GRO 핵심 엔진 |
net/core/skbuff.c | skb_segment(), __skb_gso_segment() | GSO 분할 핵심 |
net/core/dev.c | validate_xmit_skb(), netif_skb_features() | GSO 분할 결정 지점 |
net/ipv4/tcp_offload.c | tcp4_gso_segment(), tcp4_gro_receive() | TCP GSO/GRO 콜백 |
net/ipv6/tcpv6_offload.c | tcp6_gso_segment(), tcp6_gro_receive() | IPv6 TCP GSO/GRO |
net/ipv4/udp_offload.c | __udp_gso_segment(), udp4_gro_receive() | UDP GSO/GRO 콜백 |
net/ipv4/af_inet.c | inet_gro_receive(), inet_gro_complete() | IPv4 GRO L3 콜백 |
net/ipv4/ip_output.c | ip_queue_xmit(), ip_finish_output_gso() | IP 출력 경로 GSO 처리 |
net/ipv4/tcp.c | tcp_sendmsg_locked(), tcp_should_autocork() | TCP 전송 + autocorking |
net/ipv4/tcp_output.c | tcp_write_xmit(), tcp_set_skb_tso_segs() | TCP 출력 + GSO 설정 |
net/xfrm/xfrm_device.c | xfrm_dev_state_add() | IPsec xfrm HW offload |
net/ipv4/esp4_offload.c | esp4_gso_segment() | ESP GSO 분할 |
net/sctp/offload.c | sctp_gso_segment() | SCTP GSO 분할 |
drivers/net/veth.c | veth_xmit(), veth_poll() | veth GSO 전달 |
drivers/net/virtio_net.c | virtnet_poll(), free_old_xmit() | virtio-net GSO/GRO |
drivers/net/vxlan/vxlan_core.c | vxlan_xmit_skb(), vxlan_gro_receive() | VXLAN 터널 GSO/GRO |
grep -rn 'gso_segment' net/— 프로토콜별 GSO 분할 함수 찾기grep -rn 'gro_receive' net/— 프로토콜별 GRO 병합 함수 찾기grep -rn 'NETIF_F_GSO' include/linux/netdev_features.h— GSO feature 비트 정의git log --oneline --all -- net/core/gro.c— GRO 변경 이력 추적
GRO Flush 타이밍과 napi_gro_flush 내부
GRO의 성능은 병합 시간(hold time)과 flush 타이밍의 균형에 달려 있습니다. 너무 오래 보관하면 지연이 증가하고, 너무 빨리 flush하면 병합 효율이 떨어집니다. 이 섹션에서는 flush 발생의 모든 경로와 타이밍 제어 메커니즘을 상세히 다룹니다.
/* === napi_gro_flush() 내부 구현 상세 === */
/* net/core/gro.c — napi_gro_flush의 실제 동작 */
void napi_gro_flush(struct napi_struct *napi, bool flush_old)
{
unsigned long bitmask = napi->gro_bitmask;
unsigned int i;
while ((i = __ffs(bitmask)) < GRO_HASH_BUCKETS) {
struct list_head *head = &napi->gro_hash[i].list;
struct sk_buff *skb, *p;
list_for_each_entry_safe(skb, p, head, list) {
if (flush_old && NAPI_GRO_CB(skb)->age == jiffies)
continue;
/* age == jiffies: 이번 jiffy에 추가된 skb
* → flush_old=true일 때 이번 사이클의 fresh skb는 유지
* → 다음 사이클에서 추가 병합 기회 제공 */
/* gro_complete 콜백 체인 호출 */
napi_gro_complete(napi, skb);
/* 내부적으로:
* 1. ptype->callbacks.gro_complete(skb, nhoff) 호출
* → tcp4_gro_complete(): gso_type, gso_segs 설정
* → inet_gro_complete(): IP total_length 업데이트
* 2. skb를 rx_list에 추가 (아직 상위 전달 안 함)
* 3. rx_count++ */
napi->gro_hash[i].count--;
if (!napi->gro_hash[i].count)
__clear_bit(i, &napi->gro_bitmask);
}
bitmask &= ~(1UL << i);
}
}
/* gro_flush_timeout 타이머의 정확한 동작 메커니즘 */
/*
* 1. napi_complete_done()에서 gro_list에 보류 skb가 남아있으면:
* → gro_flush_timeout > 0이면 hrtimer 시작
* → 타이머 만료 시 napi_schedule() 호출
* → 다음 poll에서 napi_gro_flush(napi, true) 실행
*
* 2. napi_defer_hard_irqs와의 조합:
* → napi_defer_hard_irqs=N: N번의 빈 poll을 허용
* → 빈 poll 동안 IRQ를 재활성화하지 않음
* → gro_flush_timeout 내에 새 패킷이 오면 병합 계속
* → 결과: busy-poll 효과 + GRO 병합 극대화
*
* 3. 타이밍 시나리오 (gro_flush_timeout=20000, napi_defer_hard_irqs=2):
* t=0: 패킷 도착, NAPI poll 시작
* t=0~10us: 100개 패킷 수신, GRO 병합 → 3개 super-skb
* t=10us: budget 미소진, napi_complete_done()
* → gro_list에 3개 skb 보류
* → hrtimer 20us 설정
* t=10~15us: 빈 poll 1회 (defer_hard_irqs 카운트)
* t=15~18us: 새 패킷 50개 도착, 기존 skb에 추가 병합
* t=18us: budget 미소진, napi_complete_done()
* → hrtimer 리셋 (20us)
* t=20us: IRQ 재활성화 (defer_hard_irqs 소진)
* t=30us: 새 패킷 없음, 타이머 만료
* → napi_gro_flush(napi, true)
* → 150개 패킷이 3개 super-skb로 전달
* → 스택 처리 3회만 수행 (50:1 비율)
*/
/* per-NAPI vs 전역 gro_flush_timeout (커널 6.6+) */
/*
* 커널 6.6 이전: net.core.gro_flush_timeout (전역 sysctl)
* → 모든 NAPI 인스턴스에 동일 적용
* → 단점: NIC마다 다른 워크로드에 대응 불가
*
* 커널 6.6+: per-NAPI 설정 가능
* → /sys/class/net/eth0/napi/N/gro_flush_timeout
* → 각 RX 큐별 독립 타이밍 제어
* → 실시간 큐와 벌크 큐에 다른 타이밍 적용 가능
*
* 예시:
* # echo 0 > /sys/class/net/eth0/napi/1/gro_flush_timeout # 큐 1: 저지연
* # echo 50000 > /sys/class/net/eth0/napi/2/gro_flush_timeout # 큐 2: 고처리량
*/
GRO_NORMAL vs GRO_MERGED_FREE 경로 상세
GRO 처리 결과는 5가지 gro_result 열거값으로 분기됩니다. 각 결과에 따라 skb의 생명주기와 메모리 관리(Memory Management)가 크게 달라집니다. 특히 GRO_NORMAL과 GRO_MERGED_FREE는 성능 최적화의 핵심 분기점입니다.
/* === GRO 결과별 상세 경로 분석 === */
/* net/core/gro.c — napi_skb_finish()에서 결과 처리 */
static gro_result_t napi_skb_finish(struct napi_struct *napi,
struct sk_buff *skb,
gro_result_t ret)
{
switch (ret) {
case GRO_NORMAL:
/* 병합 불가 — skb를 일반 수신 경로로 즉시 전달
*
* 발생 조건:
* a. 새 flow: gro_list에 매칭되는 기존 skb 없음
* + 버킷이 이미 MAX_GRO_SKBS(8)개 가득 참
* b. flush 플래그: 프로토콜 콜백이 병합 거부
* (SYN/FIN/RST, 비연속 seq, 옵션 불일치 등)
* c. GRO 비활성: ethtool -K eth0 gro off
* d. 프로토콜 미지원: GRO 콜백이 없는 프로토콜
*
* 처리:
* → gro_normal_one(napi, skb)
* → skb를 napi->rx_list에 추가
* → rx_count++
* → rx_count >= READ_ONCE(net_hotdata.gro_normal_batch)
* 이면 gro_normal_list() 호출하여 일괄 전달
*
* 성능 영향:
* → 병합 없이 개별 패킷으로 스택 통과
* → per-packet 오버헤드 전부 부담
* → 이 경로가 주류이면 GRO 효과 없음
*/
gro_normal_one(napi, skb, 1);
break;
case GRO_MERGED_FREE:
/* 병합 성공 + 현재 skb 해제 가능
*
* 가장 효율적인 경로:
* → 현재 skb의 데이터가 기존 skb에 복사/링크됨
* → 현재 skb 자체는 더 이상 필요 없음 → 즉시 해제
* → 기존 skb(head)만 gro_list에 남음
*
* skb 해제 방식:
* → skb가 NAPI alloc인 경우: napi_skb_cache에 반환 (빠름)
* → 일반 alloc인 경우: kfree_skb_partial() (느림)
*
* frag 기반 병합 시 발생:
* → 현재 skb의 페이지가 head skb의 frags[]로 이동
* → 현재 skb 쉘(shell)만 해제
* → page refcount는 유지 (데이터는 head skb가 소유)
*/
if (NAPI_GRO_CB(skb)->free == NAPI_GRO_FREE_STOLEN_HEAD)
napi_skb_free_stolen_head(skb);
else
__kfree_skb_defer(skb);
break;
case GRO_HELD:
/* gro_list에 보관 — 다음 패킷 병합 대기
*
* 발생 조건:
* → 새 flow이고 버킷에 공간 있음 (count < MAX_GRO_SKBS)
* → 또는 기존 flow에 첫 패킷 (아직 병합 대상 없음)
*
* 처리:
* → skb를 해당 버킷의 list 앞에 추가
* → count++
* → bitmask에 해당 버킷 비트 설정
* → age = jiffies 기록 (flush 타이밍 결정에 사용)
*
* 이후:
* → 같은 flow의 다음 패킷이 오면 GRO_MERGED로 병합
* → 타임아웃/napi_complete 시 flush
*/
break;
case GRO_MERGED:
/* 병합 성공 — 현재 skb도 gro_list에 유지
*
* frag_list 기반 병합 시 발생:
* → 현재 skb가 head skb의 frag_list 체인에 연결
* → 현재 skb 자체가 데이터의 일부로 남아야 함
* → 해제하면 안 됨 (데이터 손실)
*
* GRO_MERGED vs GRO_MERGED_FREE 차이:
* MERGED_FREE: 데이터만 복사/이동 → skb 쉘 해제 가능
* MERGED: skb 전체가 체인에 남아야 → 해제 불가
*
* head skb의 상태 업데이트:
* → p->len += skb->len (총 길이)
* → p->data_len += skb->len (비선형 데이터 크기)
* → NAPI_GRO_CB(p)->count++ (병합 패킷 수)
*/
break;
case GRO_CONSUMED:
/* 콜백이 직접 처리 완료 — skb 관리도 콜백 책임
*
* 발생 조건: 드문 경우
* → 특수 프로토콜 콜백이 skb를 직접 소비
* → napi_skb_finish()에서 추가 처리 불필요
*/
break;
}
return ret;
}
/* GRO_NORMAL 경로의 배치 최적화 — gro_normal_batch */
/*
* GRO_NORMAL로 빠진 skb도 개별 전달하지 않고 배치로 모음:
*
* net_hotdata.gro_normal_batch (기본 8):
* → rx_list에 8개 이상 쌓이면 gro_normal_list() 호출
* → netif_receive_skb_list_internal()로 일괄 전달
* → RCU read lock 1회, 프로토콜 핸들러 탐색 1회
* → 개별 netif_receive_skb() 대비 약 15% 성능 향상
*
* sysctl로 조절 가능:
* # sysctl -w net.core.gro_normal_batch=16
* → 더 큰 배치 = 더 높은 처리량, 약간 더 높은 지연
*/
XDP 환경에서의 GRO 비활성화/우회 이슈
XDP(eXpress Data Path)는 네트워크 스택 진입 전에 패킷을 처리하여 극한의 성능을 달성합니다. 그러나 XDP는 GRO보다 먼저 실행되므로, GRO 병합의 이점을 받을 수 없는 구조적 한계가 있습니다. 이 섹션에서는 XDP와 GRO의 상호작용 문제와 해결책을 상세히 다룹니다.
NIC 드라이버 내부의 패킷 처리 순서는 다음과 같습니다:
┌─ NIC IRQ ─────────────────────────────────────────────┐ │ 1. DMA로 패킷 수신 (ring buffer) │ │ 2. XDP 프로그램 실행 ← 이 시점에서는 개별 패킷 │ │ ├─ XDP_DROP: 즉시 드롭, skb 할당 없음 │ │ ├─ XDP_TX: 같은 NIC로 반송 │ │ ├─ XDP_REDIRECT: 다른 NIC/AF_XDP로 전달 │ │ └─ XDP_PASS: GRO 경로로 진입 │ │ 3. skb 할당 (XDP_PASS인 경우) │ │ 4. napi_gro_receive() → GRO 병합 시도 │ └────────────────────────────────────────────────────────┘
핵심 문제: XDP는 skb 할당 전에 xdp_buff/xdp_frame 단위로 동작하므로, GRO 병합된 super-skb를 XDP에서 볼 수 없습니다. XDP_TX/XDP_REDIRECT는 항상 개별 패킷 단위입니다.
XDP generic 모드와 GRO
- XDP generic (SKB 모드) — GRO 이후에 동작
- skb가 이미 할당된 상태에서 XDP가 실행되며, GRO로 병합된 super-skb가 XDP 프로그램에 전달될 수 있습니다. 하지만
xdp_buff는 단일 선형 버퍼를 가정하므로, 비선형 skb(frags[],frag_list)는 linearize가 필요합니다.__skb_linearize()비용(데이터 복사 + 메모리 할당)이 크며, 64KB skb linearize는 심각한 성능 저하를 야기합니다. - XDP native (드라이버 모드) — GRO 이전에 동작
- 개별 패킷 단위로 XDP를 실행하여 빠르게 처리합니다.
XDP_PASS후에만 GRO가 가능하며,XDP_TX/XDP_REDIRECT는 GRO를 우회합니다 (병합 이점 없음).
XDP multi-buffer (커널 5.18+)
XDP_PASS 후 GRO가 병합한 skb를 재분할 없이 활용할 수 있습니다. xdp_buff가 frags를 지원하지만(xdp_buff.mb = 1), GSO super-skb 수준의 대형 패킷은 미지원이며 주로 점보 프레임(9000B MTU)을 위한 것입니다.
# XDP multi-buffer 지원 확인:
# bpftool net show dev eth0
→ xdp: mode driver, id 42, features [mb]
XDP에서 GRO와 유사한 효과를 얻는 방법
- XDP_PASS → GRO → eBPF TC: XDP로 빠른 필터링(DROP) 후 PASS하면 GRO가 병합하고, TC eBPF에서 병합된 super-skb를 처리합니다. 병합 이점과 eBPF 유연성을 모두 확보할 수 있습니다.
- AF_XDP + 사용자 공간 병합:
XDP_REDIRECT→ AF_XDP 소켓으로 전달한 뒤, 사용자 공간에서 flow별 병합을 구현합니다 (DPDK 스타일). 완전한 제어권을 얻지만 구현이 복잡하고 커널 GRO보다 비효율적입니다. - veth + XDP (컨테이너 환경): 물리 NIC에서 XDP로 필터링 후
XDP_REDIRECT→ veth로 전달하고, veth peer에서 GRO를 활성화하여 병합합니다. 물리 NIC XDP 성능과 veth GRO 효율을 조합하는 방식입니다.
XDP redirect와 GSO 비호환성
xdp_frame은 GSO 메타데이터를 포함하지 않습니다 (gso_size, gso_type 필드 없음). 따라서 XDP_REDIRECT로 전달된 패킷은 항상 MSS 이하이며, GSO super-skb를 XDP로 전달하려면 먼저 분할이 필요합니다. dev_map_generic_redirect()에서 skb_gso_segment()로 개별 패킷으로 분할한 뒤 XDP를 실행하지만, 이 경우 성능 이점이 사라집니다.
설계 원칙: XDP는 와이어에 가까운 곳에서 동작하므로 GSO/GRO 같은 스택 최적화와 본질적으로 상충합니다.
컨테이너/veth 환경 GSO/GRO 주의사항
Kubernetes, Docker 등 컨테이너 환경에서 veth 쌍과 virtio-net을 통한 GSO/GRO 처리는 물리 NIC와 다른 특성을 보입니다. 특히 veth segmentation, virtio offload negotiation, 네트워크 네임스페이스(Namespace) 간 전달에서 예상치 못한 성능 문제가 발생할 수 있습니다.
veth의 NAPI 기반 수신 (drivers/net/veth.c)
- 커널 4.19 이전: veth는
netif_rx()로 수신하여 softirq에서 처리했습니다. GRO가 적용되지 않아 성능이 제한되었습니다. - 커널 4.19+: veth에 NAPI poll 함수가 추가되었습니다.
veth_poll()에서napi_gro_receive()를 호출하여 GRO 병합이 가능해졌고, 수신 성능이 대폭 개선되었습니다. - 커널 5.13+: veth XDP 지원이 강화되었습니다. veth peer에서 XDP 프로그램 실행이 가능하지만, GSO skb와 XDP 충돌 문제가 발생합니다.
veth + virtio-net (KVM Guest) 성능 최적화
KVM Guest에서 컨테이너를 운영하는 경우의 이중 가상화 경로입니다:
Guest App → Guest TCP → virtio-net TX → vhost-net → Host veth TX → Host Bridge → Physical NIC TX
GSO skb 전달 경로는 다음과 같습니다:
- Guest TCP가 64KB GSO skb를 생성합니다.
- virtio-net이
vnet_hdr에 GSO 정보를 기록하며, skb를 분할하지 않습니다. - Host vhost-net이
vnet_hdr에서 GSO 정보를 복원하여 Host skb에 설정합니다. - Host skb는 GSO skb로 Bridge/OVS를 통과합니다 (1개 skb).
- 물리 NIC TX에서 HW TSO로 분할하거나 SW GSO를 수행합니다.
전체 경로에서 분할이 한 번도 일어나지 않을 수 있습니다 (최적 경로). 단, Guest에서 TSO가 OFF이면 Guest TCP가 MSS 단위 skb 44개를 생성하여 각각 별도로 vring 전송되므로, 성능이 5~10배 저하됩니다.
Kubernetes CNI 플러그인별 GSO/GRO 영향
| CNI 플러그인 | 경로 | GSO/GRO 특성 |
|---|---|---|
| Flannel (VXLAN 모드) | Pod → veth → bridge → VXLAN encap → eth0 | VXLAN 터널 GSO 지원 필요 (NETIF_F_GSO_UDP_TUNNEL). NIC 미지원 시 SW 터널 GSO로 성능이 저하됩니다. ethtool -k eth0 | grep udp_tnl로 확인합니다. |
| Calico (BGP 모드) | Pod → veth → routing → eth0 | 터널이 없으므로 일반 GSO/GRO가 적용됩니다. 성능이 가장 좋습니다 (터널 오버헤드 없음). |
| Cilium (eBPF 모드) | Pod → veth → tc-bpf redirect → eth0 | eBPF가 skb를 직접 redirect하여 GSO skb를 그대로 전달합니다 (매우 효율적). 단, bpf_skb_change_head() 호출 시 GSO가 무효화될 수 있습니다. |
| OVN/OVS | Pod → veth → OVS → Geneve/VXLAN → eth0 | OVS 내부에서 GSO skb를 그대로 포워딩합니다. 터널 오프로드 NIC 지원이 핵심입니다. |
veth NAPI 모드 확인 및 활성화
# ethtool -k veth0 | grep gro generic-receive-offload: on ← GRO 활성 # cat /sys/class/net/veth0/gro_flush_timeout 0 ← 기본: 즉시 flush # veth peer에서 NAPI 활성 확인: # ls /sys/class/net/veth0/queues/rx-0/ → napi_id 파일이 있으면 NAPI 활성 # 성능 튜닝 (veth 양쪽 모두 설정해야 함): # ethtool -K veth0 gro on tso on gso on sg on # ethtool -K veth0 tx-checksum-ip-generic on # ip link show veth0 → peer 인터페이스 확인 # ethtool -K veth_peer gro on tso on gso on sg on
GSO Partial과 하드웨어 GSO 오프로드 경로
SKB_GSO_PARTIAL은 NIC가 터널의 외부(outer) 헤더만 처리할 수 있고 내부(inner) 패킷의 세그멘테이션은 소프트웨어가 담당해야 하는 경우를 위한 하이브리드 오프로드 메커니즘입니다. 이 경로의 이해는 터널 환경 성능 최적화에 필수적입니다.
/* === GSO_PARTIAL 상세 동작 메커니즘 === */
/* GSO_PARTIAL이 필요한 상황:
*
* [Outer Eth][Outer IP][Outer UDP][VXLAN][Inner Eth][Inner IP][Inner TCP][Payload]
*
* NIC capabilities 조합에 따른 경로:
*
* Case A: NIC가 NETIF_F_GSO_UDP_TUNNEL + NETIF_F_TSO 지원
* → 전체 HW 오프로드: NIC가 내부 TCP 분할 + 외부 헤더 복사
* → 가장 효율적 (CPU 무관)
*
* Case B: NIC가 NETIF_F_GSO_PARTIAL 지원
* → 하이브리드: 커널이 내부 TCP를 MSS 단위로 분할
* → 각 분할 세그먼트에 외부 헤더(VXLAN 등)를 붙임
* → 외부 헤더의 체크섬/길이는 NIC가 처리
* → 중간 수준 효율
*
* Case C: NIC가 터널 오프로드 미지원
* → 전체 SW GSO: 커널이 내부+외부 모두 분할/재계산
* → 가장 느림 (모든 체크섬 SW 계산)
*/
/* net/core/dev.c — GSO_PARTIAL 경로 결정 로직 */
static struct sk_buff *validate_xmit_skb(struct sk_buff *skb,
struct net_device *dev,
bool *again)
{
netdev_features_t features = netif_skb_features(skb);
if (skb_is_gso(skb)) {
/* gso_features_check(): NIC가 이 GSO 타입을 처리할 수 있는지 확인
*
* GSO_PARTIAL 판정 흐름:
* 1. skb의 gso_type 확인 (예: SKB_GSO_UDP_TUNNEL | SKB_GSO_TCPV4)
* 2. NIC features에 대응하는 NETIF_F_GSO_* 확인
* 3. 내부 GSO 타입(TCPv4)은 지원하지만
* 터널 GSO(UDP_TUNNEL)는 미지원인 경우:
* → NETIF_F_GSO_PARTIAL이 있으면 부분 오프로드
* → NETIF_F_GSO_PARTIAL도 없으면 전체 SW 분할
*/
struct sk_buff *segs;
segs = __skb_gso_segment(skb, features, true);
/* __skb_gso_segment() 내부에서:
* - 부분 오프로드 가능 → 내부만 분할, 외부 유지
* - 전체 분할 필요 → 모든 계층 분할
*/
}
...
}
/* GSO_PARTIAL 분할 과정 상세 */
/*
* 원본: [OuterIP][OuterUDP][VXLAN][InnerIP][InnerTCP][64KB payload]
*
* GSO_PARTIAL 분할 결과 (44개 세그먼트):
*
* Seg 1: [OuterIP'][OuterUDP'][VXLAN][InnerIP'][InnerTCP,seq=0][1460B]
* Seg 2: [OuterIP'][OuterUDP'][VXLAN][InnerIP'][InnerTCP,seq=1460][1460B]
* ...
* Seg 44:[OuterIP'][OuterUDP'][VXLAN][InnerIP'][InnerTCP,seq=62780][420B]
*
* 커널이 하는 일:
* → Inner TCP 분할 (seq 번호 증가, 체크섬)
* → Inner IP 헤더 업데이트 (total_length)
* → Outer VXLAN 헤더 복사
*
* NIC가 하는 일 (PARTIAL):
* → Outer UDP length 업데이트
* → Outer IP total_length 업데이트
* → Outer UDP 체크섬 (설정된 경우)
* → Outer IP 체크섬
*
* 결과: 외부 헤더 계산 부담만 NIC에 위임
*/
/* 하드웨어 GSO 오프로드 NIC별 지원 상황 */
/*
* Intel E810 (ice 드라이버):
* NETIF_F_TSO, NETIF_F_TSO6
* NETIF_F_GSO_UDP_TUNNEL, NETIF_F_GSO_UDP_TUNNEL_CSUM
* NETIF_F_GSO_GRE, NETIF_F_GSO_GRE_CSUM
* NETIF_F_GSO_PARTIAL
* NETIF_F_GSO_ESP (inline crypto)
* → 거의 모든 터널 GSO HW 지원
*
* Mellanox ConnectX-5/6/7 (mlx5 드라이버):
* 모든 위 플래그 + NETIF_F_GSO_UDP_L4
* → UDP GSO까지 HW 지원 (가장 광범위)
*
* Broadcom BCM5750X (bnxt 드라이버):
* NETIF_F_TSO, NETIF_F_TSO6
* NETIF_F_GSO_PARTIAL (터널용)
* → 터널은 부분 오프로드
*
* virtio-net:
* 모든 GSO 타입을 "지원" (광고)
* → 실제로는 Host가 SW 처리
* → Host의 물리 NIC HW가 최종 분할
*
* 확인 방법:
* # ethtool -k eth0 | grep -E 'segmentation|partial'
*/
GRO Cells와 지연 GRO (Delayed GRO)
GRO cells는 터널 디캡슐화 경로나 브리지 포워딩 경로에서 NAPI 컨텍스트 밖에서도 GRO를 적용할 수 있게 하는 메커니즘입니다. 또한 지연 GRO(Delayed GRO)는 GRO 처리를 softirq 컨텍스트로 미뤄 더 많은 패킷을 병합할 기회를 제공합니다.
/* === GRO Cells 구현 상세 === */
/* include/net/gro_cells.h */
struct gro_cell {
struct sk_buff_head napi_skbs; /* skb 큐 */
struct napi_struct napi; /* per-CPU 가상 NAPI */
};
struct gro_cells {
struct gro_cell __percpu *cells;
};
/* gro_cells_receive(): 터널/브리지에서 GRO 적용 */
static inline int gro_cells_receive(struct gro_cells *gcells,
struct sk_buff *skb)
{
struct gro_cell *cell;
struct net_device *dev = skb->dev;
/* preempt 비활성화 + 현재 CPU의 gro_cell 접근 */
cell = this_cpu_ptr(gcells->cells);
if (skb_queue_len(&cell->napi_skbs) > READ_ONCE(
dev->gro_max_size)) {
/* 큐 초과 → 드롭 */
atomic_long_inc(&dev->rx_dropped);
kfree_skb(skb);
return NET_RX_DROP;
}
/* skb를 per-CPU 큐에 추가 */
__skb_queue_tail(&cell->napi_skbs, skb);
/* NAPI poll 스케줄링 (아직 스케줄 안 됐으면) */
if (skb_queue_len(&cell->napi_skbs) == 1)
napi_schedule(&cell->napi);
return NET_RX_SUCCESS;
}
/* gro_cell_poll(): 가상 NAPI poll에서 GRO 수행 */
static int gro_cell_poll(struct napi_struct *napi, int budget)
{
struct gro_cell *cell = container_of(napi, struct gro_cell, napi);
struct sk_buff *skb;
int work_done = 0;
while (work_done < budget) {
skb = __skb_dequeue(&cell->napi_skbs);
if (!skb)
break;
/* 여기서 GRO 적용! */
napi_gro_receive(napi, skb);
work_done++;
}
if (work_done < budget)
napi_complete_done(napi, work_done);
return work_done;
}
/* GRO Cells를 사용하는 주요 코드 */
/*
* VXLAN 디캡슐화:
* vxlan_rcv() → gro_cells_receive(&vxlan->gro_cells, skb)
* → 내부 패킷을 GRO Cells로 전달
* → softirq에서 내부 패킷끼리 GRO 병합
*
* GRE 터널:
* gre_rcv() → gro_cells_receive()
*
* IP-in-IP 터널:
* ipip_rcv() → gro_cells_receive()
*
* macvlan:
* macvlan_broadcast() → gro_cells_receive()
*/
/* Delayed GRO (지연 GRO) 개념 */
/*
* 지연 GRO는 GRO 처리를 의도적으로 미뤄 병합 기회를 늘리는 전략:
*
* 1. gro_flush_timeout + napi_defer_hard_irqs 조합:
* → IRQ를 지연시켜 더 많은 패킷이 ring buffer에 쌓이게 함
* → 다음 poll에서 한꺼번에 GRO 병합
* → 결과: 병합 비율 향상 (30:1 → 50:1 등)
*
* 2. GRO Cells의 자연스러운 지연:
* → gro_cells_receive()는 softirq로 미룸
* → 여러 터널에서 디캡슐화된 패킷이 모인 후 일괄 GRO
* → 터널 환경에서 특히 효과적
*
* 3. TCP coalescing과의 시너지:
* → GRO에서 병합 후 TCP가 추가로 coalesce
* → tcp_try_coalesce(): GRO skb를 기존 소켓 버퍼에 병합
* → 최종적으로 recv() 한 번에 수백KB 수신 가능
*/
GSO/GRO와 Netfilter/conntrack 상호작용
GSO/GRO는 Netfilter/conntrack과 복잡한 상호작용을 합니다. 대형 GSO skb가 iptables/nftables 규칙을 통과하는 방식, conntrack 엔트리의 패킷/바이트 카운터 해석, NAT 변환 시 GSO skb 처리 등을 이해해야 올바른 방화벽 규칙을 작성할 수 있습니다.
nf_conntrack_in()에서 GSO skb 처리
conntrack은 GSO skb를 하나의 "패킷"으로 추적합니다:
- 5-tuple 매칭: src/dst IP, src/dst port, proto로 매칭하며, GSO skb도 동일 5-tuple이므로 정상적으로 매칭됩니다.
- 카운터 왜곡: packets 카운터는 1만 증가하지만 실제 와이어에는 44개 패킷이 전송됩니다. bytes 카운터(
skb->len= 64KB)는 정확합니다. - conntrack helper(ALG): FTP, SIP 등 ALG가 payload를 검사할 때 GSO skb의 전체 64KB payload를 검사합니다. 대부분의 경우 문제가 없지만(프로토콜 명령은 첫 부분에 위치), 드물게 64KB 중간에 제어 메시지가 있으면 놓칠 수 있습니다.
- NAT 변환: conntrack NAT은 skb 헤더만 변경하며, GSO skb의 L3/L4 헤더를 변경합니다. 분할 후 각 세그먼트에 동일 NAT이 적용되고,
CHECKSUM_PARTIAL이면 NIC가 체크섬을 재계산합니다. 정상 동작하며 성능 영향도 최소입니다.
Netfilter flowtable과 GSO
nf_flow_offload(커널 4.16+)는 첫 몇 패킷만 conntrack 전체 경로를 통과시키고, 이후에는 flowtable에서 fastpath 포워딩을 수행합니다. GSO skb도 flowtable fastpath를 사용하여 conntrack/NAT 오버헤드를 거의 제거합니다. 100Gbps 이상 포워딩 환경에서 핵심적인 기능입니다.
# flowtable 설정:
nft add flowtable inet filter f { hook ingress priority 0; devices = { eth0, eth1 }; }
nft add rule inet filter forward ct state established flow add @f
flowtable + GRO/GSO 조합에서는 GRO 병합된 64KB skb가 flowtable에서 1회 포워딩되며, conntrack table 탐색 1회 후 flowtable 캐시(Cache)에 히트하여 NAT 변환 + TTL 감소 + 출력 NIC으로 직접 전달됩니다. ip_forward() 전체 경로를 우회합니다.
NFQUEUE와 GSO skb
NFQUEUE를 사용하는 IDS/IPS/DPI 시스템에서 주의해야 합니다:
- 자동 분할: NFQUEUE는 GSO skb를 받으면
nfqueue_enqueue()에서skb_gso_segment()를 호출하여 자동 분할합니다. 사용자 공간에는 MSS 단위 패킷이 전달되며, 사용자 공간 프로그램은 GSO를 인지할 필요가 없습니다. - 성능 영향: NFQUEUE에서의 GSO 분할은 GRO 효과를 상쇄합니다. 64KB가 44개 패킷으로 분할된 후 사용자 공간에 전달되므로, NFQUEUE 대역폭(Bandwidth)이 병목(Bottleneck)이 됩니다 (커널-사용자 공간 전환 44배).
- 해결책:
--queue-bypass로 연결 설정 후 bypass하거나, NFQUEUE + GRO off로 원본 패킷 단위 처리(정확하지만 느림)를 선택하거나, eBPF TC로 대체(GRO 이후 동작, GSO skb 직접 처리)합니다.
커널 소스 구조
GSO/GRO 관련 커널 코드는 net/core/gro.c, net/core/skbuff.c, net/core/dev.c 세 파일에 핵심 로직이 집중되어 있습니다. 이 섹션에서는 각 파일의 역할과 주요 함수 호출 관계를 상세히 분석합니다.
net/core/gro.c — GRO 엔진 핵심 (최근 6.x 계열)
- 1. GRO 유틸리티 함수
-
skb_gro_receive()— skb 데이터 병합 (frag/frag_list)skb_gro_reset_offset()— GRO 오프셋 초기화skb_gro_header_slow()— 비선형 skb 헤더 접근gro_pull_from_frag0()— frag0 최적화 해제
- 2. GRO 핵심 경로
-
dev_gro_receive()— GRO 매칭 + 프로토콜 콜백napi_gro_receive()— 드라이버 진입점 (skb 기반)napi_gro_frags()— 드라이버 진입점 (frag 기반)napi_gro_complete()— 병합 완료 후 상위 전달
- 3. GRO Flush
-
napi_gro_flush()— 전체 flushgro_normal_list()— rx_list 일괄 전달gro_normal_one()— 개별 skb 큐잉
- 4. GRO 결과 처리
-
napi_skb_finish()—gro_result에 따른 분기napi_frags_finish()— frag 기반 결과 처리
net/core/skbuff.c — GSO 분할 엔진
skb_segment()- GSO 분할의 핵심 함수입니다. 대형 skb를
gso_size단위로 분할하며, 선형/비선형 데이터 모두 처리합니다. 각 세그먼트에 헤더를 복사하고, 분할된 skb linked list(skb->next)를 반환합니다. __skb_gso_segment()skb_segment()의 래퍼 함수입니다. 프로토콜별 GSO 콜백을 먼저 호출하고, NIC feature와 GSO 타입을 매칭합니다.skb_gso_validate_network_len()- GSO 세그먼트가 MTU를 초과하지 않는지 검증합니다.
ip_finish_output()에서 호출됩니다.
net/core/dev.c — GSO/GRO 결정 지점
TX 경로 (GSO):
__dev_queue_xmit()
→ validate_xmit_skb() : GSO 분할 여부 결정
→ netif_needs_gso() : NIC가 이 GSO를 처리할 수 있는지
→ __skb_gso_segment() : 불가하면 SW 분할
→ skb_checksum_help() : 체크섬 SW fallback
→ dev_hard_start_xmit() : NIC 드라이버 xmit 호출
RX 경로 (GRO → 상위 전달):
netif_receive_skb_list_internal()
→ __netif_receive_skb_list_core()
→ __netif_receive_skb_core() : 프로토콜 핸들러 디스패치
→ deliver_skb() : ptype_all, ptype_base 전달
→ ip_rcv() : IPv4 입력
프로토콜별 오프로드 콜백 파일
| 소스 파일 | GSO 함수 | GRO 함수 |
|---|---|---|
net/ipv4/tcp_offload.c | tcp4_gso_segment(), tcp_gso_segment() | tcp4_gro_receive(), tcp4_gro_complete(), tcp_gro_receive() |
net/ipv6/tcpv6_offload.c | tcp6_gso_segment() | tcp6_gro_receive() |
net/ipv4/udp_offload.c | __udp_gso_segment() | udp4_gro_receive(), udp_gro_receive() |
net/ipv4/af_inet.c | - | inet_gro_receive(), inet_gro_complete() |
net/ipv6/ip6_offload.c | ipv6_gso_segment() | ipv6_gro_receive() |
오프로드 콜백 등록 메커니즘
net/ipv4/af_inet.c에서 IPv4 오프로드를 등록합니다. inet_init()에서 inet_add_offload(&tcpv4_offload, IPPROTO_TCP)와 inet_add_offload(&udpv4_offload, IPPROTO_UDP)를 호출하면, inet_offloads[IPPROTO_TCP]에 콜백이 등록됩니다. dev_gro_receive()에서 프로토콜 번호로 콜백을 룩업합니다.
커스텀 프로토콜에 GRO를 추가하려면 inet_add_offload(&my_offload, MY_PROTO_NUM)으로 자체 프로토콜에 GRO/GSO 콜백을 등록할 수 있습니다. 이 방식은 터널 프로토콜에서 주로 사용됩니다.
GRO 해시와 Flow 병합 판단 로직
GRO의 성능은 flow 매칭 속도에 직결됩니다. gro_hash[] 해시 테이블의 구조, RSS 해시를 활용한 버킷 선택, 프로토콜별 same_flow 판정 알고리즘을 상세히 분석합니다.
/* === GRO Flow 매칭 알고리즘 전체 분석 === */
/* 1단계: 해시 버킷 선택 (O(1)) */
static inline unsigned int gro_hash_bucket(struct sk_buff *skb)
{
/* skb->hash 원천:
* a. NIC RSS (Receive Side Scaling) 하드웨어 해시
* → Toeplitz 해시 알고리즘
* → 입력: src/dst IP, src/dst port, protocol
* → 출력: 32비트 해시값
* → NIC가 패킷 수신 시 자동 계산
* → 장점: CPU 오버헤드 0, 동일 flow = 동일 해시
*
* b. 소프트웨어 해시 (NIC 미지원 시)
* → __skb_get_hash() / skb_get_hash()
* → Jenkins 해시 또는 jhash2 사용
* → 성능: NIC RSS 대비 약간 느림
*
* c. skb_set_hash()로 드라이버가 직접 설정
*/
return skb_get_hash_raw(skb) & (GRO_HASH_BUCKETS - 1);
/* GRO_HASH_BUCKETS = 8 → 하위 3비트로 버킷 선택
* → 동일 flow는 항상 같은 버킷에 배치
* → 다른 flow와의 충돌: 선형 검색 (최대 8개)
* → 평균적으로 flow 수 / 8 개의 엔트리를 검색 */
}
/* 2단계: 버킷 내 skb 리스트 순회 (O(n), n ≤ 8) */
static enum gro_result dev_gro_receive(struct napi_struct *napi,
struct sk_buff *skb)
{
unsigned int bucket = gro_hash_bucket(skb);
struct list_head *gro_head = &napi->gro_hash[bucket].list;
/* 초기 same_flow 설정: 모든 기존 skb에 대해 1로 시작 */
list_for_each_entry(pp, gro_head, list) {
NAPI_GRO_CB(pp)->same_flow = 1;
/* 프로토콜 콜백이 0으로 변경할 것들을 걸러냄
* → "guilty until proven innocent" 반대:
* "같다고 가정하고, 다르면 배제" 패턴
* → 대부분의 경우 첫 비교에서 탈락 → 빠름 */
}
/* 3단계: L2 프로토콜 콜백 (eth_gro_receive) */
/* → vlan 태그, 프로토콜 타입 비교
* → 다르면 same_flow = 0 */
/* 4단계: L3 프로토콜 콜백 (inet_gro_receive) */
/* → src/dst IP 주소 비교 (4바이트 또는 16바이트 memcmp)
* → TOS, TTL 비교
* → IP 옵션 유무 확인
* → IP ID 연속성 확인 (DF 비트 없을 때)
* → 다르면 same_flow = 0 */
/* 5단계: L4 프로토콜 콜백 (tcp_gro_receive) */
/* → src/dst 포트 비교 (4바이트 비교 1회)
* → TCP 시퀀스 번호 연속성 확인
* → TCP 윈도우 크기 일치 확인
* → TCP 플래그 확인 (SYN/FIN/RST/URG → flush)
* → TCP 옵션 (타임스탬프) 확인
* → 모두 통과하면 same_flow = 1 유지 → 병합 수행 */
pp = call_gro_receive(ptype->callbacks.gro_receive, gro_head, skb);
...
}
/* GRO 해시 효율과 RSS 해시 품질의 관계 */
/*
* RSS 해시 품질이 GRO 효율에 직접 영향:
*
* 좋은 RSS 해시 (4-tuple 기반):
* → 동일 TCP 연결 = 동일 해시 = 동일 GRO 버킷
* → 다른 연결 = 다른 해시 = 다른 버킷 (대부분)
* → 버킷 내 검색 = 1~2개 (빠름)
* → GRO 병합률 높음
*
* 나쁜 RSS 해시 (src IP만 사용 등):
* → 같은 서버의 여러 연결이 같은 버킷에 몰림
* → 버킷 내 검색 = 6~8개 (느림)
* → same_flow 판정에 CPU 시간 낭비
* → GRO 효율 저하
*
* RSS 해시 타입 확인:
* # ethtool -n eth0 rx-flow-hash tcp4
* TCP over IPV4 flows use these fields for computing Hash flow key:
* IP SA, IP DA, L4 bytes 0 & 1, L4 bytes 2 & 3
* → 4-tuple (이상적)
*
* 해시 타입 변경 (2-tuple → 4-tuple):
* # ethtool -N eth0 rx-flow-hash tcp4 sdfn
* → s: src IP, d: dst IP, f: src port, n: dst port
*/
/* GRO 병합 실패 원인 분석 (디버깅) */
/*
* bpftrace로 GRO 병합 실패 원인 추적:
*
* # bpftrace -e '
* kprobe:tcp_gro_receive {
* @total = count();
* }
* kretprobe:tcp_gro_receive / retval == 0 / {
* @merged = count();
* }
* interval:s:5 {
* printf("merge ratio: %d/%d\n", @merged, @total);
* clear(@merged); clear(@total);
* }
* '
*
* 병합률이 낮은 일반적 원인:
* 1. 다수의 짧은 TCP 연결 (HTTP/1.1 keep-alive 없음)
* → 각 연결당 1~2 패킷 → 병합 기회 없음
* 2. 혼합 트래픽 (TCP + UDP + ICMP)
* → UDP/ICMP는 GRO 대상이 아닌 경우 많음
* 3. NIC RSS 해시 부재
* → skb->hash = 0 → 모든 패킷이 버킷 0에 집중
* 4. MTU 불일치
* → 경로상 다른 MTU → 패킷 크기 불균일 → 병합 실패
* 5. TCP 타임스탬프 옵션 불일치
* → 일부 중간 장비가 타임스탬프 수정 → 병합 거부
*/
코드 설명
- gro_hash_bucket()
skb->hash의 하위 3비트로GRO_HASH_BUCKETS(8)개 버킷 중 하나를 O(1)로 선택합니다. NIC RSS 하드웨어 해시(Toeplitz 알고리즘)가 있으면 CPU 오버헤드 없이 동일 flow를 같은 버킷에 배치합니다. - same_flow 패턴버킷 내 모든 기존 skb의
same_flow를 1로 초기화한 뒤, 프로토콜 콜백 체인(L2→L3→L4)이 불일치 항목을 0으로 변경하는 방식입니다. 대부분의 경우 첫 비교에서 빠르게 탈락하여 효율적입니다. - 프로토콜 콜백 체인
eth_gro_receive()→inet_gro_receive()→tcp_gro_receive()순서로 VLAN 태그, src/dst IP, TOS/TTL, 포트, 시퀀스 번호, TCP 플래그를 점진적으로 검증합니다. 각 단계에서 불일치하면same_flow = 0으로 조기 배제됩니다. - RSS 해시 품질4-tuple(src/dst IP + src/dst port) RSS 해시는 동일 TCP 연결을 동일 버킷에 배치하여 검색 범위를 1~2개로 줄입니다. 2-tuple 해시는 여러 연결이 같은 버킷에 집중되어 GRO 효율이 크게 저하되며,
ethtool -N eth0 rx-flow-hash tcp4 sdfn으로 4-tuple로 변경할 수 있습니다.
ethtool -n eth0 rx-flow-hash tcp4로 4-tuple RSS 해시 확인ethtool -k eth0 | grep gro로 GRO/HW-GRO 활성 확인- NIC의
rx_gro_packets와rx_packets비율로 병합 효율 계산 - 지연 허용 환경이면
gro_flush_timeout=20000설정 - 소켓(Socket)에
SO_INCOMING_NAPI_ID설정하여 NAPI-소켓 친화성 활용 bpftrace로 GRO 병합 비율 실시간(Real-time) 모니터링
GSO/GRO 최신 변화 (v6.8~v6.15)
GSO/GRO 인프라는 v6.8 이후 page_pool-클론 skb coalescing, HW-GRO 드라이버 확대, XDP-GRO 통합, tunneling 처리 개선이 이루어졌습니다. 아래는 실무에 영향이 큰 변화입니다.
page_pool clone skb coalescing (v6.8)
v6.8에서 page_pool을 사용하는 드라이버의 RX 경로에서 cloned skb도 GRO 병합 대상이 되도록 확장되었습니다. 기존에는 page_pool backed skb가 clone되면 병합에서 제외되었으나, 이제 동일 page_pool 기반이라면 skb_can_coalesce()가 true를 반환해 RX 처리량이 개선됩니다.
- TCP/UDP-GRO 양쪽 모두 이득
- 대규모 동시 연결 환경에서 CPU 사용률 감소
XDP CPU redirect 이후 GRO 활성 (v6.15)
v6.15에서 XDP cpumap redirect 이후에도 GRO가 정상 동작하도록 수정되었습니다. XDP 재배치(Relocation)로 다른 CPU 큐에 패킷을 분산시키는 워크로드에서, 재배치된 CPU의 NAPI 경로가 GRO 병합을 수행해 TCP 스트림 처리량이 최대 2배까지 향상됩니다.
bpf_redirect_map()으로 cpumap에 넣은 뒤 호스트 GRO를 병합하는 구성이 자연스러워졌습니다.
HW-GRO 드라이버 확대 (v6.10~v6.12)
HW-GRO는 NIC가 수신 단계에서 이미 병합된 슈퍼-프레임을 호스트에 올려 주는 구조입니다. v6.10 이후 다음 드라이버가 HW-GRO를 지원합니다.
- NVIDIA/Mellanox
mlx5HW-GRO (v6.11) - Intel
ice,ixgbe,i40e: ethtool HW timestamping 통계와 연동되어 HW-GRO 카운터 관찰 개선(v6.10) - Broadcom
bnxt: TPA(Transparent Packet Aggregation)가 HW-GRO 경로로 통합
ethtool로 확인:
ethtool -k eth0 | grep -E 'rx-gro-hw|generic-receive-offload'
ethtool -S eth0 | grep -i 'hw_gro\|lro'
UDP GSO/GRO 개선 (v6.11~v6.15)
- v6.11: VirtIO-net AF_XDP RX zero-copy 연동 경로에서 UDP GRO 처리가 드라이버 영역으로 이동
- v6.14: TCP GSO가 NAT 환경에서 IPv6와 함께 사용될 때의 세그멘테이션 문제 수정
- v6.15: UDP flood 시
sk_tsflags무조건 접근 제거로 +10% 처리량
터널링 GSO/GRO 정리 (v6.8~v6.13)
VXLAN/GENEVE/GRE 등 캡슐화 프로토콜의 GSO/GRO 경로가 점진적으로 재정리되었습니다.
- inner-outer 체크섬 계산 중복 제거
- v6.11 OpenVSwitch sample multicasting과 연동된 flowtable XDP offload
- v6.13 TX H/W shaping 설정(generic netlink) — 터널 트래픽의 shaping 제어가 단일 API로 통합
참고 링크
- Segmentation Offloads 공식 문서: kernel.org — segmentation-offloads
- netdev 연간 리뷰 2024: netdev in 2024
- Phoronix Linux 6.15 네트워킹: Linux 6.15 Networking
GSO/GRO 최신 변화 (v6.16~v6.19)
v6.16 이후 GSO/GRO 경로는 UDP 터널(Tunnel) GSO 확장, GRO 엔진 최적화, 락-프리 전송 큐와의 시너지가 주요 흐름입니다.
UDP 터널 GSO 지원 확대 (v6.17)
v6.17에서 tun 드라이버와 virtio-net이 UDP 터널 위 GSO를 지원하게 되었습니다. 이전에는 캡슐화(encapsulation)된 패킷을 전송할 때 커널이 강제로 세그멘테이션을 수행해야 했지만, 이제 SKB_GSO_UDP_TUNNEL 플래그가 올바르게 전파되어 NIC 또는 가상화 레이어에서 분할이 이루어집니다.
- tun(TUN/TAP) 드라이버의
tun_net_xmit()경로에서 UDP 터널 GSO 플래그 보존 - virtio-net의 GSO 협상 시 UDP 터널 기능 플래그 추가
- 가상 머신(VM) 내부에서 VXLAN/GENEVE를 사용하는 컨테이너 워크로드의 오프로드 효율 향상
GRO 엔진 UDP 터널 최적화 (v6.16)
v6.16에서 GRO 엔진의 UDP 터널 처리 경로가 최적화되어 스트림(stream) 관련 테스트에서 약 10%의 처리량 향상이 측정되었습니다. 터널 내부/외부 헤더의 해시(hash) 계산 중복이 제거되고, GRO 엔트리(Entry) 탐색이 단순화되었습니다.
커널 6.16부터: UDP 터널 GRO 최적화로 VXLAN/GENEVE 오버레이(Overlay) 네트워크의 수신 처리량이 약 10% 향상됩니다.
TCP 수신 버퍼 자동 조정 개선 (v6.16)
v6.16에서 TCP 수신 버퍼 자동 조정(auto-tuning) 알고리즘이 개선되어, 200Gbps 링크 단일 플로우 최대 처리량 테스트에서 60% 이상의 향상이 측정되었습니다. GRO로 병합된 슈퍼 세그먼트(super segment)의 크기를 버퍼 조정 로직에 반영하여 불필요한 버퍼 축소를 방지합니다.
락-프리 큐잉과 GSO/GRO 상호작용 (v6.19)
v6.19에서 전송 큐잉 계층이 락-프리 리스트로 전환됨에 따라, GSO 분할 후 다수의 세그먼트를 큐에 넣는 경로에서도 락 경합이 사라졌습니다. GRO로 병합된 대형 패킷이 GSO를 통해 분할되어 드라이버로 전달되는 전체 경로의 지연이 감소합니다.
참고 링크 (v6.16~v6.19)
- Phoronix Linux 6.16 네트워킹: Linux 6.16 Networking
- Phoronix Linux 6.18 네트워킹: Linux 6.18 Networking
- Phoronix Linux 6.19 네트워킹: Linux 6.19 Networking
- Linux 6.17 커널 뉴비스: kernelnewbies.org — Linux_6.17
관련 문서
GSO/GRO와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.