기초와 아키텍처
FD.io VPP: 벡터 패킷(Packet) 처리 모델, 그래프 노드 아키텍처, 메모리 관리, 멀티스레딩, clib/vlib/vnet API 계층 등 기초와 아키텍처를 다룹니다.
핵심 요약
- 벡터 패킷 처리 (Vector Packet Processing) — 패킷을 하나씩 처리하지 않고 벡터(배열) 단위로 묶어 동일한 노드를 통과시켜 I-캐시(Cache) 지역성과 분기 예측(Branch Prediction) 효율을 극대화합니다.
- 그래프 노드(Graph Node) 아키텍처 — 패킷 처리 파이프라인(Pipeline)을 방향성 그래프로 구성합니다. 각 노드가 하나의 처리 단계(파싱, 분류, 포워딩 등)를 담당하며, 벡터가 노드 간을 이동합니다.
- vlib 프레임워크 — VPP의 핵심 인프라 라이브러리로, 노드 스케줄링, 메인 루프, 타이머(Timer), 이벤트 로거 등을 제공합니다.
- vlib_buffer_t — 패킷 데이터와 메타데이터(오프셋, 다음 노드 인덱스, 에러 상태)를 묶은 VPP의 핵심 버퍼(Buffer) 구조체로, 커널
sk_buff에 해당하는 역할을 합니다. - 메모리 관리 — mheap 기반 힙(Heap)과 버퍼 풀(Buffer Pool)의 2층 구조, NUMA 인식 할당, 워커별 독립 풀로 할당 경로를 단순화합니다.
- 멀티스레딩 모델 — 메인 스레드(Thread) + N개 워커 스레드, 프레임 핸드오프(Frame Handoff)를 통한 워커 간 패킷 전달, 배리어 동기화(Barrier Sync)로 구성 변경을 안전하게 적용합니다.
- clib 인프라 라이브러리 —
clib_bihash(고성능 해시),clib_pool(인덱스 기반 객체 풀),clib_vec(동적 배열), 원자적(Atomic) 연산, 고해상도 시간 측정 등 저수준 프리미티브를 제공합니다. - API 계층 — clib / vlib / vnet / VCL / TLS 의 역할 분담과 접두사(
clib_*,vlib_*,vnet_*) 명명 규칙으로 계층을 판별할 수 있습니다.
단계별 이해
- 벡터 처리 모델 이해 — 스칼라 처리와 벡터 처리의 차이를 파악합니다.
전통적인 패킷별(scalar) 처리는 노드를 매번 전환하여 I-캐시 미스가 발생합니다. VPP는 동일 노드에서 수백 개 패킷을 연속 처리(벡터)하여 캐시 효율과 쿼드 루프 패턴을 통한 ILP(Instruction-Level Parallelism)를 확보합니다.
- 그래프 노드 구조 학습 — 패킷이 노드 그래프를 따라 이동하는 원리를 이해합니다.
각 노드는
vlib_node_registration_t로 등록되며, 프레임(Frame)을 입력으로 받아 패킷을 처리하고 다음 노드 인덱스를 설정합니다.ip4-input → ip4-lookup → ip4-rewrite → interface-output같은 기본 포워딩 경로와 Feature Arc 삽입 지점을 추적합니다. - vlib_buffer_t 구조 파악 — 패킷 + 메타데이터 구조체를 해부합니다.
버퍼 인덱스만 노드 간에 전달되며, 실제 데이터는
current_data오프셋부터 시작합니다.next_buffer체인으로 다중 세그먼트 패킷을,error·flags필드로 노드 간 상태를 전달합니다. - 메인 루프와 스레드 모델 이해 — 메인 스레드와 워커 스레드의 역할을 구분합니다.
메인 스레드는 제어 평면(API, CLI)을, 워커 스레드는 데이터 평면을 담당합니다. 구성 변경 시
vlib_worker_thread_barrier_sync()로 워커를 일시 정지시켜 일관성을 보장하고, 워커 간 패킷 전달은 프레임 핸드오프 RPC로 수행합니다. - clib 자료구조 숙지 — 실제 코드에서 자주 쓰는 프리미티브를 파악합니다.
clib_bihash는 FIB, ACL, 세션 테이블의 기반이 되며 버킷·스플라이스 기반으로 락(Lock) 경합을 최소화합니다.clib_pool은 인덱스 기반 객체 할당으로 포인터 체이닝 없이 캐시 효율을 높이고,clib_vec은 동적 배열로 가변 길이 데이터를 다룹니다. - API 계층 구분 — 접두사만 보고 어느 계층의 함수인지 판별합니다.
clib_*은 순수 C 인프라(플랫폼 독립),vlib_*은 VPP 런타임(노드·버퍼·스레드),vnet_*은 네트워크 스택(인터페이스·FIB·ACL),vppcom_*/vls_*은 호스트 스택 진입점입니다. 함수 서명만 봐도 책임 경계가 드러나므로 코드 읽기 속도가 크게 빨라집니다. - 다음 단계로 이동 — 기초를 다졌다면 다음 주제로 진행합니다.
실제 패킷이 L2~L4 노드를 어떻게 통과하는지는 데이터 경로, 보안·터널링 기능은 보안과 터널링, 호스트 스택·TLS·QUIC은 호스트 스택 개요, 설치·CLI·플러그인 개발·운영·디버깅(Debugging)은 운영 · 플랫폼 · 확장에서 이어집니다.
drivers/net/tun.c), vhost(drivers/vhost/), AF_PACKET(net/packet/af_packet.c), AF_XDP(net/xdp/xsk.c), UIO/VFIO(drivers/uio/, drivers/vfio/) 서브시스템과 밀접하게 연관됩니다.
VPP 개요
v26.02(2026-02-25 릴리스, 최신 stable)를 기준으로 작성되어 있습니다. 구버전 비교 대상은 v25.02이며, 두 릴리스 사이의 차이(25.06 / 25.10 / 26.02)는 호스트 스택 문서의 "v25.02 → v26.02 주요 변경 요약"에 한 표로 정리되어 있습니다. 각 페이지의 주요 변경 지점에는 인라인 🔄 박스로 "25.02 대비 변경"을 표기합니다.
VPP(Vector Packet Processing)는 Linux Foundation 산하 FD.io(Fast Data Input/Output) 프로젝트의 핵심 구성 요소로, 고성능 유저스페이스 패킷 처리 플랫폼입니다. 원래 Cisco에서 개발한 상용 코드를 2016년에 오픈소스로 공개하였으며, 현재 Linux Foundation이 호스팅하는 커뮤니티 주도 프로젝트입니다. 주요 기능은 메일링 리스트와 공개 리뷰를 거치는 패치(Patch) 시리즈를 통해 계속 확장됩니다.
VPP의 핵심 설계 철학은 벡터 패킷 처리(Vector Packet Processing)입니다. 전통적인 커널 네트워크 스택이 패킷 하나씩 전체 처리 경로를 순회하는 scalar 방식인 반면, VPP는 여러 패킷을 하나의 벡터(배열)로 묶어서 동일한 처리 노드를 일괄 통과시킵니다. 이를 통해 CPU의 명령어 캐시 히트율을 극대화하고, 분기 예측 효율을 높여 범용 x86 CPU에서도 초당 수천만 패킷(수십 Mpps)을 처리할 수 있습니다.
벡터 패킷 처리 모델
Scalar 처리 vs Vector 처리
전통적인 커널 네트워크 스택의 Scalar 처리는 패킷 하나를 수신하면 L2 → L3 → L4 → 소켓(Socket) 전달까지 전체 경로를 완주한 후 다음 패킷을 처리합니다. 각 단계마다 다른 함수 코드가 I-cache에 로드되므로 캐시 미스가 빈번합니다.
VPP의 Vector 처리는 다수의 패킷(벡터)을 동시에 한 노드에서 처리한 후, 다음 노드로 벡터 전체를 넘깁니다. 동일한 코드가 수백 패킷에 반복 적용되므로 I-cache에 상주하며, 분기 예측이 안정되어 파이프라인 버블이 최소화됩니다.
캐시 효율성과 성능
VPP의 성능 우위는 CPU 마이크로아키텍처 수준의 최적화에서 비롯됩니다:
- I-cache 최적화: 하나의 그래프 노드 함수가 256개 패킷에 반복 적용되므로, 명령어가 L1 I-cache에 상주합니다. 커널 스택에서는 패킷마다 수십 개의 함수를 순회하며 I-cache가 지속적으로 교체됩니다.
- D-cache 프리페칭: VPP는 현재 패킷을 처리하는 동안 다음 패킷의 데이터를
CLIB_PREFETCH()매크로(Macro)로 미리 캐시에 로드합니다. - 분기 예측: 동일한 코드 경로가 수백 번 반복 실행되므로 CPU의 Branch Target Buffer(BTB)가 안정화됩니다.
- 듀얼/쿼드 루프: VPP 노드는 패킷을 2개 또는 4개씩 묶어서 처리하는 dual-loop/quad-loop 패턴을 사용해 루프 오버헤드(Overhead)를 줄이고 ILP(Instruction-Level Parallelism)를 극대화합니다.
/* VPP 노드의 전형적인 dual-loop 패턴 (간략화) */
while (n_left_from >= 4) {
/* 다음 2개 패킷 프리페치 */
vlib_buffer_t *p2, *p3;
p2 = vlib_get_buffer(vm, from[2]);
p3 = vlib_get_buffer(vm, from[3]);
CLIB_PREFETCH(p2->data, CLIB_CACHE_LINE_BYTES, LOAD);
CLIB_PREFETCH(p3->data, CLIB_CACHE_LINE_BYTES, LOAD);
/* 현재 2개 패킷 처리 */
vlib_buffer_t *b0, *b1;
b0 = vlib_get_buffer(vm, from[0]);
b1 = vlib_get_buffer(vm, from[1]);
/* 패킷 처리 로직... */
next0 = process_packet(b0);
next1 = process_packet(b1);
vlib_validate_buffer_enqueue_x2(vm, node, next_index,
to_next, n_left_to_next,
bi0, bi1, next0, next1);
from += 2;
n_left_from -= 2;
}
코드 설명
-
3~8행
CLIB_PREFETCH로 다음 2개 패킷 데이터를 L1 캐시에 미리 로드합니다. 현재 패킷 처리 동안 메모리 지연를 숨기는 핵심 최적화입니다. - 11~13행 현재 2개 패킷을 동시에 가져와 처리합니다. CPU의 ILP(Instruction-Level Parallelism)를 활용하여 파이프라인 스톨을 최소화합니다.
-
19~21행
vlib_validate_buffer_enqueue_x2는 2개 패킷의 next 노드가 동일한지 확인하고, 같으면 배치로 한 번에 큐잉합니다. 다르면 개별적으로 프레임을 분리합니다. - 22~23행 포인터를 2씩 전진시키며 남은 패킷 수를 감소합니다. 4개 미만이 남으면 루프를 종료하고 single-loop으로 전환합니다.
쿼드 루프 패턴과 SIMD 최적화
VPP 노드의 가장 최적화된 형태는 quad-loop 패턴입니다. 4개의 패킷을 동시에 처리하여 CPU의 ILP(Instruction-Level Parallelism)를 극대화하고, 프리페치 거리를 확보합니다. 컴파일러가 SIMD 벡터화를 적용할 가능성도 높아집니다.
/* quad-loop 패턴 — 최고 성능 경로 */
while (n_left_from >= 8) {
/* 8개 앞 패킷 프리페치 (4개 처리 동안 캐시 로드) */
CLIB_PREFETCH(vlib_get_buffer(vm, from[4])->data,
CLIB_CACHE_LINE_BYTES, LOAD);
CLIB_PREFETCH(vlib_get_buffer(vm, from[5])->data,
CLIB_CACHE_LINE_BYTES, LOAD);
CLIB_PREFETCH(vlib_get_buffer(vm, from[6])->data,
CLIB_CACHE_LINE_BYTES, LOAD);
CLIB_PREFETCH(vlib_get_buffer(vm, from[7])->data,
CLIB_CACHE_LINE_BYTES, LOAD);
/* 현재 4개 패킷 일괄 처리 */
vlib_buffer_t *b0, *b1, *b2, *b3;
b0 = vlib_get_buffer(vm, from[0]);
b1 = vlib_get_buffer(vm, from[1]);
b2 = vlib_get_buffer(vm, from[2]);
b3 = vlib_get_buffer(vm, from[3]);
next0 = process_packet(b0);
next1 = process_packet(b1);
next2 = process_packet(b2);
next3 = process_packet(b3);
vlib_validate_buffer_enqueue_x4(vm, node, next_index,
to_next, n_left_to_next,
bi0, bi1, bi2, bi3, next0, next1, next2, next3);
from += 4;
n_left_from -= 4;
}
/* single-loop: 나머지 패킷 처리 (1~3개) */
while (n_left_from > 0) {
vlib_buffer_t *b0 = vlib_get_buffer(vm, from[0]);
next0 = process_packet(b0);
vlib_validate_buffer_enqueue_x1(vm, node, next_index,
to_next, n_left_to_next, bi0, next0);
from += 1;
n_left_from -= 1;
}
코드 설명
- 3~11행 8개 앞 패킷을 프리페치하여 4개 처리 동안 캐시 워밍을 완료합니다. 프리페치 거리가 dual-loop(2)보다 2배 길어 메모리 지연 은닉 효과가 극대화됩니다.
- 14~18행 4개 패킷을 동시에 가져와 독립적으로 처리합니다. 컴파일러가 SIMD 벡터화를 적용하기 쉬운 구조이며, 현대 CPU의 슈퍼스칼라 파이프라인을 최대한 활용합니다.
-
20~26행
vlib_validate_buffer_enqueue_x4는 4개 패킷의 next 인덱스를 비교하여 동일 경로인 패킷을 배치 큐잉합니다. 경로가 분산되면 개별 처리로 폴백합니다. - 29~36행 잔여 패킷(1~3개)을 single-loop으로 처리합니다. quad-loop 진입 조건(8개 이상)을 충족하지 못하는 경우의 보완 경로입니다.
| 루프 패턴 | 패킷/반복 | 프리페치 거리 | ILP 활용 | 적합한 상황 |
|---|---|---|---|---|
| Single-loop | 1 | 0~1 | 낮음 | 복잡한 상태 기계, 잔여 패킷 처리 |
| Dual-loop | 2 | 2 | 중간 | 중간 복잡도 노드, 분기 많은 처리 |
| Quad-loop | 4 | 4~8 | 높음 | 단순 처리 (lookup, rewrite, forward) |
ip4-lookup, l2-fwd 같은 핫 패스 노드는 quad-loop을 사용하고, 복잡한 상태 머신이 필요한 tcp-input은 single-loop으로 구현합니다.
벡터 크기 튜닝과 영향
VLIB_FRAME_SIZE는 한 번에 노드에 전달되는 최대 패킷 수를 결정합니다. 기본값 256은 대부분의 상황에서 최적이지만, 특수 워크로드에서는 조정이 필요할 수 있습니다.
| 벡터 크기 | I-cache 효과 | 처리량(Throughput) | 테일 지연 | 메모리 사용 |
|---|---|---|---|---|
| 64 | 보통 | 낮음 | 최소 (좋음) | 낮음 |
| 128 | 좋음 | 중간 | 낮음 | 중간 |
| 256 (기본) | 최적 | 높음 | 중간 | 중간 |
| 512 | 최적 | 최고 | 높음 (나쁨) | 높음 |
운영 환경 벡터 크기 튜닝 실전
기본값 256을 변경하려면 VLIB_FRAME_SIZE 매크로를 재정의하고 VPP를 재빌드해야 합니다(build-data/platforms/의 CMake 옵션). 런타임 변경은 불가능하지만, 입력 노드의 버스트 크기(dpdk-input의 VLIB_FRAME_SIZE 버스트 읽기 개수)와 핸드오프 큐 깊이(frame-queue-nthreads)는 startup.conf에서 조정할 수 있습니다. 세 파라미터를 조합해 실제 워크로드에 맞춰야 합니다.
# startup.conf — 벡터·핸드오프 관련 핵심 파라미터
buffers {
buffers-per-numa 262144 # 워커 수 × VLIB_FRAME_SIZE × 4 이상
default data-size 2048
}
dpdk {
dev 0000:04:00.0 {
num-rx-queues 4
num-rx-desc 2048 # NIC 링 깊이 — 벡터보다 충분히 커야
num-tx-desc 2048
}
}
cpu {
main-core 0
corelist-workers 1,2,3,4
}
# handoff 큐 — 워커 간 재분배 시 벡터 버퍼링
plugins {
plugin dpdk_plugin.so { enable }
}
# DPDK 입력측 burst 크기 변경
vpp# set dpdk interface placement TenGigabitEthernet4/0/0 queue 0 thread 1
| 워크로드 | 권장 벡터 | num-rx-desc | handoff 깊이 | 목표 p99 지연 |
|---|---|---|---|---|
| VoIP / 실시간 게임 | 64 | 512 | 64 | < 80μs |
| 일반 라우터 / NAT | 256 (기본) | 2048 | 256 | < 500μs |
| CDN 엣지 / 대용량 TCP | 256 | 4096 | 512 | < 2ms |
| 배치 암호화 (IPsec bulk) | 512 | 4096 | 512 | 관심 없음 |
튜닝 절차: ① show runtime의 vectors/call이 지속적으로 VLIB_FRAME_SIZE 근처(예: 240~256)이면 포화 상태이므로 num-rx-desc를 늘려 버스트 흡수력을 확보합니다. ② 반대로 평균 벡터가 2~4 수준이면 트래픽이 적은 것이므로 벡터를 늘릴 이유가 없습니다. ③ p99 지연이 목표를 초과하면 벡터를 줄이고 손실 처리량을 워커 추가로 보상합니다. 벡터와 지연의 트레이드오프는 근본적이므로 "양쪽 모두 좋게" 만들 수는 없습니다.
VPP 아키텍처
VPP의 아키텍처는 이 장에서 8개의 핵심 주제로 전개됩니다. 패킷 처리 그래프(Graph Node)로 노드 간 벡터 흐름을 구성하고, vlib_buffer_t로 패킷과 메타데이터를 함께 다룹니다. 메인 루프(Main Loop)가 워커 스레드의 폴링과 노드 디스패치를 담당하며, Feature Arc 메커니즘으로 기존 그래프에 기능을 침습 없이 삽입합니다. 이어서 패킷 1개가 RX 디스크립터에서 TX 큐까지 가는 실제 경로를 따라가고, L2 → L3 → L4 통합 그래프 흐름으로 계층별 상호작용을 확인합니다. 마지막으로 핵심 그래프 노드 내부 구현과 추가 그래프 노드 내부 구현에서 개별 노드 코드를 분석합니다. 먼저 아래 다이어그램으로 전체 그림을 조망한 뒤, 이어지는 각 절에서 주제별 상세를 차례로 펼칩니다.
패킷 처리 그래프 (Graph Node Architecture)
VPP의 핵심은 방향성 비순환 그래프(DAG) 기반 패킷 처리 엔진입니다. 각 처리 단계는 노드(Node)로 구현되며, 노드 간 연결은 arc로 표현됩니다. 패킷(벡터)은 노드에서 노드로 arc를 따라 이동하며, 각 노드는 패킷의 다음 목적지 노드를 결정합니다.
/* 그래프 노드 등록 예제 — src/vnet/ethernet/node.c */
VLIB_REGISTER_NODE (ethernet_input_node) = {
.function = ethernet_input, /* 노드 처리 함수 */
.name = "ethernet-input",
.vector_size = sizeof(u32), /* 벡터 원소 크기 (버퍼 인덱스) */
.n_errors = ETHERNET_N_ERROR,
.error_strings = ethernet_error_strings,
.n_next_nodes = ETHERNET_INPUT_N_NEXT,
.next_nodes = {
[ETHERNET_INPUT_NEXT_IP4_INPUT] = "ip4-input",
[ETHERNET_INPUT_NEXT_IP6_INPUT] = "ip6-input",
[ETHERNET_INPUT_NEXT_L2_INPUT] = "l2-input",
[ETHERNET_INPUT_NEXT_DROP] = "error-drop",
},
};
코드 설명
-
2행
VLIB_REGISTER_NODE매크로는 정적 초기화자로 노드를 그래프에 등록합니다. 컴파일 타임에 노드 메타데이터가 확정되어 런타임 등록 비용이 없습니다. -
3행
.function필드는 노드의 벡터 처리 함수를 지정합니다. 디스패처가 프레임을 전달할 때 이 함수를 호출하여 패킷 배치를 처리합니다. -
5행
vector_size = sizeof(u32)는 벡터 원소가 32비트 버퍼 인덱스임을 선언합니다. 64비트 포인터 대신 인덱스를 사용하여 프레임 크기를 절반으로 줄이고 캐시 효율을 높입니다. -
8~13행
next_nodes배열은 이 노드의 출력 엣지를 정의합니다. 각 엣지에 문자열 이름을 매핑(Mapping)하여, 그래프 초기화 시 이름 기반으로 노드 인덱스가 자동 해석됩니다.
노드 유형 (INTERNAL, INPUT, PROCESS, PRE_INPUT)
| 노드 유형 | 설명 | 실행 방식 | 대표 예 |
|---|---|---|---|
| VLIB_NODE_TYPE_INTERNAL | 그래프 중간 처리 노드 | 이전 노드가 벡터를 전달할 때 실행 | ip4-input, ip4-lookup, l2-fwd |
| VLIB_NODE_TYPE_INPUT | 패킷 입력 노드 | 매 메인 루프마다 폴링(Polling) | dpdk-input, af-packet-input |
| VLIB_NODE_TYPE_PROCESS | 코루틴 기반 프로세스(Process) | 이벤트/타이머(Timer) 기반 실행 | dhcp-client-process, arp-reply |
| VLIB_NODE_TYPE_PRE_INPUT | 입력 전 처리 | INPUT 노드보다 먼저 실행 | epoll-input (이벤트 폴링) |
벡터 크기와 처리 흐름
VPP의 벡터 프레임 크기는 VLIB_FRAME_SIZE로 정의되며, 기본값은 256입니다. 메인 루프(vlib_main_loop)는 다음 순서로 동작합니다:
- PRE_INPUT 노드 실행 (epoll 이벤트 수집 등)
- INPUT 노드 실행 (dpdk-input이 NIC에서 패킷 수집 → 벡터 생성)
- INTERNAL 노드: 보류 중인 프레임이 있는 노드를 순회하며 벡터 처리
- PROCESS 노드: 타이머/이벤트 만료된 코루틴 실행
- 1번으로 돌아감 (busy-loop 또는 sleep)
startup.conf의 poll-sleep-usec 옵션으로 유휴 시 절전 모드(Suspend)를 활성화할 수 있지만, 지연가 증가할 수 있습니다.
메인 루프 내부 동작
VPP의 vlib_main_loop()는 모든 패킷 처리의 진입점(Entry Point)입니다. 단일 스레드 내에서 4단계를 반복 실행하며, 각 단계의 순서가 성능에 직접 영향을 미칩니다.
/* vlib_main_loop 의사코드 (src/vlib/main.c) */
static void
vlib_main_loop (vlib_main_t *vm) {
while (1) {
/* 1단계: PRE_INPUT — epoll, 시그널 체크 */
vlib_node_runtime_update_stats(vm);
dispatch_pre_input_nodes(vm);
/* 2단계: INPUT — NIC 폴링, 벡터 수집 */
dispatch_input_nodes(vm);
/* dpdk-input이 NIC RX 링에서 패킷 배치를 가져와
ethernet-input으로 향하는 프레임을 생성 */
/* 3단계: INTERNAL — 보류 프레임 디스패치 */
for (pending_frame in vm->pending_frames) {
node = pending_frame->node;
frame = pending_frame->frame;
node->function(vm, node, frame);
/* 노드가 새 프레임을 생성하면 pending 추가 */
}
/* 4단계: PROCESS — 코루틴 타이머/이벤트 체크 */
dispatch_process_nodes(vm);
/* 유휴 시 선택적 sleep */
if (no_work && vm->poll_sleep_usec)
usleep(vm->poll_sleep_usec);
}
}
코드 설명
- 5~7행 PRE_INPUT 단계에서 epoll 이벤트와 시그널(Signal)을 체크합니다. CLI 소켓, API 연결, 타이머 휠 등 비패킷 이벤트를 먼저 처리하여 제어 평면 응답성을 보장합니다.
- 9~12행 INPUT 단계에서 모든 입력 노드(dpdk-input 등)가 NIC를 폴링합니다. 수집된 패킷은 프레임으로 묶여 pending_frames에 등록됩니다.
- 14~19행 INTERNAL 단계는 보류 중인 프레임을 순회하며 노드 함수를 호출합니다. 한 노드의 출력이 다른 노드의 입력이 되어, 그래프 전체가 깊이 우선으로 처리됩니다.
-
25~27행
poll_sleep_usec가 설정되면 유휴 상태(Idle State)에서 usleep()으로 CPU를 양보(Yield)합니다. 기본값은 busy-polling이므로 CPU 사용률이 100%이지만, 지연가 가장 낮습니다.
| 루프 단계 | 실행 노드 유형 | 실행 빈도 | 주요 동작 |
|---|---|---|---|
| PRE_INPUT | VLIB_NODE_TYPE_PRE_INPUT | 매 사이클 | epoll 이벤트 수집, 타이머 휠 갱신 |
| INPUT | VLIB_NODE_TYPE_INPUT | 매 사이클 | NIC 폴링, 패킷 벡터 생성 |
| INTERNAL | VLIB_NODE_TYPE_INTERNAL | 프레임 존재 시 | 그래프 노드 순회, 패킷 처리 |
| PROCESS | VLIB_NODE_TYPE_PROCESS | 이벤트/타이머 시 | 코루틴 재개 (ARP, DHCP 등) |
Feature Arc 메커니즘
Feature Arc는 VPP의 핵심 확장 메커니즘으로, 패킷 처리 경로에 기능을 동적으로 삽입하거나 제거할 수 있게 합니다. 커널의 Netfilter 후크(Hook) 체인과 유사한 개념이지만, 컴파일 타임이 아닌 런타임에 노드 체인을 재구성합니다.
/* Feature Arc에 노드 등록 (VNET_FEATURE_INIT 매크로) */
VNET_FEATURE_INIT (my_acl_feature, static) = {
.arc_name = "ip4-unicast", /* 소속 Feature Arc */
.node_name = "my-acl-node", /* 노드 이름 */
.runs_before = VNET_FEATURES("ip4-lookup"), /* 이 노드보다 먼저 실행 */
.runs_after = VNET_FEATURES("ip4-input-no-checksum"),
};
/* 런타임에 인터페이스별 Feature 활성화 */
vpp# set interface feature GigabitEthernet0/8/0 my-acl-node arc ip4-unicast
/* Feature Arc 상태 확인 */
vpp# show features verbose
코드 설명
-
2행
VNET_FEATURE_INIT매크로는 컴파일 타임에 노드를 Feature Arc에 등록합니다.static키워드로 링커(Linker) 섹션에 배치되어 자동 수집됩니다. -
3행
arc_name은 이 feature가 소속될 arc를 지정합니다.ip4-unicastarc는 ip4-input에서 시작하여 ip4-lookup으로 끝나는 체인입니다. -
5~6행
runs_before/runs_after는 위상 정렬(topological sort) 제약을 정의합니다. 런타임에 이 제약을 기반으로 feature 체인의 실행 순서가 자동으로 결정됩니다. - 9행 Feature 활성화는 인터페이스별로 수행됩니다. 활성화하면 해당 인터페이스의 패킷만 이 노드를 거치며, 다른 인터페이스는 영향을 받지 않습니다.
| Feature Arc | 시작 노드 | 기본 종착 노드 | 용도 |
|---|---|---|---|
ip4-unicast | ip4-input | ip4-lookup | IPv4 유니캐스트 인입 처리 |
ip4-output | ip4-rewrite | interface-output | IPv4 송출 처리 |
ip4-multicast | ip4-input | ip4-mfib-forward-lookup | IPv4 멀티캐스트 |
ip6-unicast | ip6-input | ip6-lookup | IPv6 유니캐스트 인입 |
ethernet-output | adj-l2-midchain | interface-output | 이더넷 출력 경로 |
interface-output | interface-output | interface-tx | 최종 인터페이스 출력 |
패킷 1개가 RX 디스크립터에서 TX 큐까지 가는 실제 경로
VPP를 처음 접할 때 가장 헷갈리는 지점은 "패킷이 정확히 어느 시점에 vlib_buffer_t가 되고, 어느 시점에 next 인덱스가 바뀌며, 어느 시점에 실제 NIC 송신 큐에 들어가는가"입니다. 아래 흐름을 기준으로 보면, 추상적인 그래프 모델과 실제 구현 객체가 한 번에 연결됩니다.
- NIC가 RX 디스크립터를 채웁니다. 하드웨어는 DMA로 패킷을 메모리에 써 두고, RX 링의 소유권을 소프트웨어 쪽으로 넘깁니다.
dpdk-input이 burst 단위로 회수합니다. 이 시점에 성능은rx_burst크기, 디스크립터 수, NUMA 일치 여부에 크게 좌우됩니다.- 패킷은
vlib_buffer_t로 표현됩니다. 실제 데이터는 이미 hugepage 메모리에 존재하고, 이후 그래프는 주로 버퍼 인덱스 배열을 이동시킵니다. ethernet-input이 첫 분류를 수행합니다. EtherType과 VLAN을 읽고ip4-input,ip6-input,l2-input중 어느 경로로 보낼지 결정합니다.ip4-input과 Feature Arc가 정책을 적용합니다. ACL, NAT, IPsec, 사용자 플러그인이 이 구간에 삽입되며, 패킷을 드롭하거나 메타데이터를 보강할 수 있습니다.ip4-lookup이 FIB를 조회합니다. 결과는 adjacency 인덱스로 정리되어vnet_buffer(b0)계열 메타데이터에 저장됩니다.ip4-rewrite와interface-output이 송신을 마무리합니다. 최종 L2 헤더를 재작성한 뒤 per-thread TX 프레임에 넣고, 마지막에 NIC TX 링으로 burst 송신합니다.
| 단계 | 핵심 객체 | 주로 바뀌는 값 | 확인 명령 |
|---|---|---|---|
| RX 수집 | RX descriptor, mbuf | burst 길이, queue 인덱스 | show hardware-interfaces |
| 그래프 진입 | vlib_frame_t | n_vectors, cached_next_index | show runtime |
| L2 분류 | ethernet_header_t | EtherType, VLAN 정보 | trace add ethernet-input |
| 정책 적용 | current_config_index | feature 체인 위치, drop 여부 | show features verbose |
| FIB 조회 | mtrie, adjacency | adj_index, fib_index | show ip fib |
| 송신 준비 | rewrite header, TX frame | tx_sw_if_index, rewrite 길이 | show trace |
adj_index, current_config_index, b0->error, tx_sw_if_index를 먼저 확인하는 편이 빠릅니다.
L2 → L3 → L4 통합 그래프 흐름
앞 절까지는 RX 디스크립터부터 TX 링까지의 수평 경로와 개별 노드를 따로 살펴보았습니다. 이 다이어그램은 동일한 경로를 OSI 계층(L1~L7)에 맞춰 세로로 재배치하여, 프레임이 어느 계층에서 어느 VPP 그래프 노드의 책임으로 처리되는지를 한눈에 보여 줍니다. L2 브릿징 경로와 L3 라우팅 경로, 그리고 VPP 호스트 스택(Host Stack)의 L4/세션(Session) 계층까지를 하나의 그림으로 잇습니다.
show features verbose 출력과 실제 패킷 경로를 머릿속에서 빠르게 맞추어 볼 수 있습니다.
메모리 관리(Memory Management) 아키텍처
힙(Heap)/mheap 관리
VPP는 여러 개의 독립적인 메모리 영역을 관리합니다. 모든 영역은 hugepage 위에 할당되며, clib_mem 인프라가 NUMA 인식 할당을 제공합니다.
| 메모리 영역 | 기본 크기 | 용도 | 설정 위치 |
|---|---|---|---|
| Main Heap | 1 GB | FIB, 세션 테이블, 일반 할당 | memory { main-heap-size 2G } |
| Buffer Pools | NUMA별 개별 | 패킷 데이터 (vlib_buffer_t + data) | buffers { buffers-per-numa 32768 } |
| API Segment | 64 MB | Binary API 공유 메모리 | api-segment { global-size 256M } |
| Stats Segment | 32 MB | 통계 카운터 공유 메모리 | statseg { size 64M } |
# VPP 메모리 사용량 확인
vpp# show memory main-heap
Thread 0 vpp_main
base 0x7f8a00000000, size 1g, locked, unmap-on-destroy
page stats: page-size 2M, total 512, mapped 310, not-mapped 202
total: 1073741824, used: 536870912, free: 536870912
vpp# show memory api-segment
Shared API segment:
base 0x7f8900000000, size 64m
# NUMA별 메모리 분포
vpp# show memory numa
show memory verbose 출력 해석
운영 중 메모리 누수나 단편화(Fragmentation)가 의심될 때 가장 먼저 실행하는 명령이 show memory main-heap verbose입니다. 출력이 길고 무엇을 봐야 하는지 헷갈리기 쉬우므로, 아래 표의 네 가지 수치만 집중해서 읽으면 95%의 상황을 판정할 수 있습니다.
vpp# show memory main-heap verbose
Thread 0 vpp_main
base 0x7f8a00000000, size 2g, locked, unmap-on-destroy
page stats: page-size 2M, total 1024, mapped 820, not-mapped 204
total: 2147483648, used: 1288490188, free: 858993460, trimmable: 156823552
free chunks 4821 free fastbin blks 12
max total allocated 1932735283
chunk headers: 4821
largest contiguous free block: 4456448 ← ★ 핵심 ①
fragmentation ratio: 18.3% ← ★ 핵심 ②
max block size allocated: 268435456
small blocks (< 256B) allocated: 892341 ← ★ 핵심 ③
allocation histogram:
[0,64) : 412385
[64,256) : 479956
[256,1024) : 128234
[1024,4K) : 45612
[4K,16K) : 8723
[16K,64K) : 1204
[64K,1M) : 89
[1M+) : 12 ← ★ 핵심 ④
| 지표 | 건강 기준 | 경고 기준 | 해석 |
|---|---|---|---|
| ① largest contiguous free block | > 총 free의 30% | < 1MB | 작으면 큰 할당(FIB 확장, bihash 리사이즈)이 실패할 수 있음 |
| ② fragmentation ratio | < 20% | > 40% | 높으면 reheal 또는 reboot 고려 |
| ③ small blocks allocated | — | 증가 추세 | 시간에 따라 꾸준히 증가 = 메모리 누수 의심 |
| ④ 1M+ 버킷 | 안정 | 급증 | 세션/FIB 폭주의 선행 지표 |
mheap 단편화와 재정리(Reheal) 전략
VPP의 mheap(Martin Heap, clib 인프라)은 dlmalloc 계열이지만 coalescing(인접 free 블록 병합)이 매우 공격적으로 동작합니다. 그럼에도 장기 운영 시 단편화가 누적되는 이유는 bihash 페이지 확장·수축이 대형 연속 블록을 요구하는데, 중간에 작은 장기 객체(예: 영구 FIB 엔트리)가 끼어 있어 병합이 차단되기 때문입니다. 이 현상은 수일~수주 단위로 진행되며, 어느 순간 "show errors에서 bihash add failed가 폭증하고, 메모리는 절반 이상 free인데 할당이 실패"하는 형태로 표면화됩니다.
대응은 세 단계로 나뉩니다:
- 즉시 완화 —
clib_mem_trim()에 해당하는show memory main-heap trim(일부 빌드) 또는memory trace off; memory trace on으로 fastbin을 비우고 상단 블록을 OS에 반환합니다. 단편화는 감소하지 않지만 trimmable 영역이 확보됩니다. - 구조적 해결 —
startup.conf에서main-heap-size를 평소 사용량의 2.5~3배로 설정하고, 대형 bihash는 고정 크기(fixed pre-size)로 미리 할당해 동적 리사이즈를 회피합니다. 예:nat { translation hash buckets 524288 }로 처음부터 큰 크기로 생성. - 주기적 Reheal — 무중단이 필수가 아닌 환경에서는 주 1회 blue-green 재시작(Reboot)으로 mheap을 초기화합니다. 상태(세션·FIB)는 외부 저장소 → 재기동 후 재주입 패턴으로 복원합니다. LCP 구성에서는 리눅스 라우팅 테이블이 진실의 원천이므로 이 방식이 안전합니다.
# 단편화 진단 주간 스크립트
#!/bin/bash
LARGEST=$(vppctl show memory main-heap verbose | \
awk '/largest contiguous/ {print $NF}')
FREE=$(vppctl show memory main-heap | awk '/free:/ {print $6}')
RATIO=$(( LARGEST * 100 / FREE ))
if [ $RATIO -lt 30 ]; then
logger -t vpp-mem "Fragmentation warning: largest/free=$RATIO%"
# 필요 시 알림 시스템(PagerDuty, Slack) 연동
fi
# 메모리 추적(allocation profiling) 활성화 — 누수 추적
vpp# memory trace on main-heap
... 수 분 운영 ...
vpp# show memory main-heap verbose | head -50
vpp# memory trace off main-heap
진단 주의점: memory trace on은 모든 할당에 콜 스택(call stack)을 기록하므로 성능 오버헤드가 20~40% 증가합니다. 운영 환경에서는 짧게(수 분) 활성화한 뒤 반드시 끄고, 가능하면 카나리 인스턴스에서만 수행하세요. 또한 main-heap-size를 늘리면 hugepage 요구량도 같이 증가하므로 hugepage 예약을 먼저 확인해야 합니다.
버퍼 풀 아키텍처
VPP 버퍼 풀은 고성능 패킷 처리의 핵심입니다. NUMA 노드별로 독립된 풀을 유지하여 원격 메모리 접근을 방지하고, 프리 리스트와 재활용(Recycling) 메커니즘으로 할당/해제 오버헤드를 최소화합니다.
/* 버퍼 풀 생성과 생명주기 */
/* 1. 시작 시: hugepage 위에 버퍼 풀 사전 할당 */
/* buffers { buffers-per-numa 32768 } → NUMA별 32768개 버퍼 */
/* 2. INPUT 노드: 버퍼 할당 */
u32 n_alloc = vlib_buffer_alloc(vm, buffer_indices, n_buffers);
/* 스레드 로컬 캐시에서 먼저 할당 → 부족 시 글로벌 free list에서 보충 */
/* 3. 패킷 처리 중: 버퍼 체인 (점보 프레임 등) */
if (b->flags & VLIB_BUFFER_NEXT_PRESENT)
next_b = vlib_get_buffer(vm, b->next_buffer);
/* 4. 처리 완료: 버퍼 해제 (free list 반환) */
vlib_buffer_free(vm, buffer_indices, n_buffers);
/* 로컬 캐시가 가득 차면 글로벌 free list로 반환 */
멀티스레딩 아키텍처
워커 스레드 모델
VPP는 메인 스레드 + N개 워커 스레드 모델을 사용합니다. 메인 스레드는 CLI/API 처리와 제어 평면을 담당하고, 워커 스레드는 데이터 평면(패킷 처리)에 전담합니다. 각 워커는 독립된 메인 루프를 실행합니다.
| 항목 | Main Thread | Worker Thread |
|---|---|---|
| CPU 코어 | main-core (보통 0) | corelist-workers (2,4,6...) |
| 역할 | CLI, API, 제어 평면 | 패킷 처리 (데이터 평면) |
| INPUT 노드 | 실행 가능 (기본 비활성) | dpdk-input 폴링 |
| PROCESS 노드 | ARP, DHCP, BGP 등 | 실행하지 않음 |
| NIC 큐 매핑 | — | 1:1 (큐 N → 워커 N) |
| 공유 데이터 접근 | 배리어 내에서 수정 | 읽기 전용(Read-Only) (RCU 유사) |
/* startup.conf — 스레딩 설정 */
cpu {
main-core 0 /* 메인 스레드: 코어 0 */
corelist-workers 2,4,6,8 /* 워커 4개: 물리 코어만 (HT 제외) */
skip-cores 1 /* 코어 1 건너뛰기 (OS 예약) */
scheduler-policy fifo /* RT 스케줄링 (선택) */
scheduler-priority 50 /* RT 우선순위 */
}
/* 워커 수에 따라 NIC RX 큐를 자동 매핑 */
dpdk {
dev 0000:03:00.0 {
num-rx-queues 4 /* 워커 수와 일치 */
}
}
프레임 핸드오프
NIC의 RSS(Receive Side Scaling)는 5-tuple 해시를 기반으로 수신 큐를 분산합니다. 하지만 NAT44 세션 룩업, IPsec SA 선택, TCP 세그먼트 재조립 등 상태 기반 처리(Stateful Processing)는 동일 플로우의 모든 패킷이 반드시 같은 워커에서 처리되어야 합니다. RSS 해시가 이를 보장하지 못할 때, VPP의 frame handoff 메커니즘이 패킷을 올바른 워커로 재분배합니다.
Handoff 동작 원리
VPP의 handoff는 그래프 노드로 구현되며, 다음과 같은 단계로 동작합니다:
- 플로우 해시 계산:
handoff노드가 패킷의 5-tuple(src IP, dst IP, src port, dst port, protocol)로부터 해시를 계산합니다. - 대상 워커 결정:
hash % n_workers연산으로 패킷이 처리될 워커 인덱스를 결정합니다. - 바이패스 최적화: 패킷이 이미 올바른 워커에 있으면 handoff 없이 다음 노드로 직접 전달합니다. 이 경우 추가 비용이 발생하지 않습니다.
- 크로스 워커 전달: 다른 워커로 이동해야 하면, 해당 워커의 per-worker lockfree ring에 버퍼 인덱스를 enqueue합니다.
- 대상 워커 수신: 대상 워커가 다음 메인 루프(main loop) 반복에서 자신의 ring을 dequeue하여 패킷을 수신하고 그래프 처리를 계속합니다.
- Symmetrical 모드:
symmetrical옵션을 활성화하면, 양방향 플로우(A→B와 B→A)가 동일한 해시를 생성하여 같은 워커에서 처리됩니다. 이를 위해 src/dst를 정렬한 후 해시를 계산합니다.
핵심 구조체: vlib_frame_queue_t
Handoff의 핵심은 per-worker lockfree ring buffer인 vlib_frame_queue_t 구조체입니다. 각 워커마다 하나의 ring이 존재하며, 다른 워커들이 이 ring에 패킷을 enqueue합니다:
| 필드 | 타입 | 설명 |
|---|---|---|
head | volatile u32 | Consumer(수신 워커)가 읽는 위치. 대상 워커만 증가시킵니다. |
tail | volatile u32 | Producer(송신 워커)가 쓰는 위치. Atomic CAS로 증가시킵니다. |
elts[] | vlib_frame_queue_elt_t | Ring의 각 슬롯. 버퍼 인덱스 배열(buffer_index[VLIB_FRAME_SIZE])과 프레임 메타데이터를 포함합니다. |
n_in_use | u32 | 현재 사용 중인 슬롯 수 (오버플로 감지용) |
nelts | u32 | Ring 크기. 기본값은 64이며, frame-queue-nelts 설정으로 조정합니다. |
/* vlib_buffer_enqueue_to_thread() — 핵심 handoff 함수 (의사 코드) */
static_always_inline u32
vlib_buffer_enqueue_to_thread (vlib_main_t *vm,
vlib_node_runtime_t *node,
u32 *buffers, u16 *thread_indices,
u32 n_packets, u32 drop_on_full)
{
vlib_frame_queue_t *fq;
u32 n_left = n_packets;
while (n_left)
{
u16 target_thread = thread_indices[0];
/* 같은 워커이면 bypass — handoff 비용 없음 */
if (target_thread == vm->thread_index)
{
/* 현재 노드의 next로 직접 enqueue */
vlib_buffer_enqueue_to_next (vm, node, buffers, nexts, count);
continue;
}
/* 대상 워커의 frame queue 획득 */
fq = vlib_get_frame_queue (vm, fq_index, target_thread);
/* ring tail을 atomic CAS로 확보 */
u32 tail = __atomic_load_n (&fq->tail, __ATOMIC_ACQUIRE);
u32 new_tail = tail + 1;
/* 오버플로 검사 */
if (PREDICT_FALSE (new_tail - fq->head >= fq->nelts))
{
if (drop_on_full)
vlib_buffer_free (vm, buffers, count);
continue;
}
/* 버퍼 인덱스를 ring 슬롯에 복사 */
vlib_frame_queue_elt_t *elt = &fq->elts[tail & (fq->nelts - 1)];
clib_memcpy_fast (elt->buffer_index, buffers, count * sizeof (u32));
elt->n_vectors = count;
/* tail 전진 (atomic store) — 대상 워커가 볼 수 있게 됨 */
__atomic_store_n (&fq->tail, new_tail, __ATOMIC_RELEASE);
/* 대상 워커의 pending interrupt 신호 */
vlib_node_set_interrupt_pending (
vlib_get_main_by_index (target_thread), node->node_index);
}
return n_packets;
}
코드 설명
- 10~14행 대상 워커가 현재 스레드와 같으면 handoff를 건너뛰고 직접 enqueue합니다. 불필요한 cross-thread 통신을 피하여 자체 워커 패킷의 지연를 제거합니다.
-
20~21행
__atomic_load_n으로 frame queue의 tail을 acquire 시맨틱으로 읽습니다. 잠금(Lock)프리 링 버퍼(Ring Buffer) 구현으로, 명시적 잠금이나 뮤텍스(Mutex) 없이 워커 간 패킷을 전달합니다. -
24~29행
링 오버플로 시
drop_on_full플래그에 따라 패킷을 폐기합니다. 이는 backpressure 메커니즘으로, 대상 워커가 과부하 상태임을 의미합니다. -
32~37행
버퍼 인덱스를 링 슬롯에 복사한 후
__atomic_store_n으로 tail을 release 시맨틱으로 전진시킵니다. 이 시점에서 대상 워커가 새 패킷을 볼 수 있게 됩니다.
Handoff CLI 명령
| 명령 | 설명 | 예시 |
|---|---|---|
set interface handoff | 인터페이스에 handoff 노드를 삽입합니다 | set interface handoff GigabitEthernet0/8/0 workers 0-3 |
symmetrical | 양방향 플로우가 같은 워커에서 처리되도록 합니다 | set interface handoff ... workers 0-3 symmetrical |
show handoff | 워커별 handoff 큐 상태 및 드롭 수를 표시합니다 | show handoff |
frame-queue-nelts | startup.conf에서 ring 크기를 조정합니다 | cpu { frame-queue-nelts 128 } |
show runtime | handoff 노드의 벡터 수/클록 사이클을 확인합니다 | show runtime |
# handoff 노드 삽입 (nat44 이전에 플로우 해시 기반 재분배)
vpp# set interface handoff GigabitEthernet0/8/0 workers 0-3 symmetrical
# handoff 큐 상태 확인
vpp# show handoff
worker 0: 250000 frames, 0 drops
worker 1: 248000 frames, 0 drops
worker 2: 251000 frames, 0 drops
worker 3: 249000 frames, 0 drops
Handoff 성능 영향
| 항목 | Handoff 없음 | Handoff 있음 | 비고 |
|---|---|---|---|
| 패킷당 추가 사이클 | 0 | ~50-80 사이클 | 캐시 미스 포함. bypass 시 ~5 사이클 |
| 메모리 대역폭(Bandwidth) | 기본 | +10-15% | 크로스 워커 버퍼 인덱스 복사 |
| 캐시 효율 | 높음 | L1/L2 미스 증가 | 패킷 메타데이터가 다른 코어 캐시에 존재 |
| 플로우 일관성 | 보장 안 됨 | 보장됨 | 상태 기반 기능 정상 동작 |
| frame queue 메모리 | 0 | 워커당 ~512KB | 64 슬롯 × VLIB_FRAME_SIZE 기준 |
실전 시나리오별 Handoff 설정
| 시나리오 | Handoff 필요 이유 | 권장 설정 |
|---|---|---|
| NAT44 | 세션 테이블이 per-thread이므로 동일 플로우가 다른 워커에서 처리되면 세션 미스(Miss)가 발생합니다 | set interface handoff <if> workers 0-N symmetrical |
| IPsec (터널(Tunnel) 모드) | SA(Security Association) 상태와 시퀀스 번호(Sequence Number)가 per-thread이므로 플로우 분산 시 replay 검사가 실패합니다 | set interface handoff <if> workers 0-N symmetrical |
| TCP 스택 (Host Stack) | TCP 연결 상태(윈도우, ACK 추적)는 단일 워커에서 관리해야 합니다 | set interface handoff <if> workers 0-N symmetrical |
| 순수 L3 포워딩 | FIB 룩업은 stateless이므로 handoff가 불필요하며, 오히려 성능을 저하시킵니다 | Handoff 미설정 (RSS만 사용) |
| ACL 기반 필터링 | ACL 매칭은 stateless이므로 handoff 없이도 정상 동작합니다 | Handoff 미설정 (RSS만 사용) |
show handoff에서 drops가 증가하면, show runtime으로 해당 워커의 병목 노드를 확인하세요. RSS 해시가 불균형한 경우가 흔하며, frame-queue-nelts를 128이나 256으로 늘려 일시적 버스(Bus)트를 흡수할 수 있습니다.
배리어 동기화
VPP의 데이터 평면은 lockfree로 동작하지만, FIB 업데이트나 인터페이스 추가 같은 제어 평면 변경은 모든 워커를 일시 정지시키는 배리어 동기화가 필요합니다.
| 작업 | 배리어 필요 | 이유 |
|---|---|---|
| FIB 엔트리 추가/삭제 | 필요 | mtrie 구조체 수정 |
| 인터페이스 생성/삭제 | 필요 | sw_interface 벡터 재할당 |
| ACL 규칙 변경 | 필요 | ACL 매치 테이블 교체 |
| 통계 카운터 읽기 | 불필요 | per-thread 카운터 (lockfree) |
| 패킷 트레이싱 | 불필요 | per-thread 트레이스 버퍼 |
| show runtime | 불필요 | 읽기 전용 |
show barrier로 배리어 점유 시간을 모니터링하세요. 대량의 FIB 업데이트(BGP 풀 테이블 로딩 등)는 배리어를 장시간 홀드하여 패킷 드롭을 유발할 수 있습니다. FIB 배치 업데이트 API를 사용하면 배리어 점유를 최소화합니다.
큐 어피니티(Queue Affinity)와 플로우 스티키
큐 어피니티(Queue Affinity)는 특정 플로우의 패킷이 항상 동일한 워커 스레드에서 처리되도록 보장하는 메커니즘입니다. NIC의 RSS, 플로우 디렉터(Flow Director), DPDK rte_flow API, VPP의 핸드오프(handoff) 노드 등 여러 계층에서 구현됩니다. 이를 통해 세션 상태의 경합(Contention) 없는 처리와 캐시 지역성(Cache Locality)을 극대화합니다.
RSS(Receive Side Scaling) 상세
RSS는 NIC 하드웨어에서 Toeplitz 해시 함수를 사용하여 5-tuple(소스 IP, 목적지 IP, 소스 포트, 목적지 포트, 프로토콜)에 대한 해시값을 계산하고, 그 결과를 인다이렉션 테이블(Indirection Table)을 통해 RX 큐에 매핑합니다.
/* Toeplitz 해시 계산 과정 (개념적 수도코드) */
static u32 toeplitz_hash(const u8 *key, const u8 *input, int len)
{
u32 hash = 0;
for (int i = 0; i < len * 8; i++) {
if (input[i / 8] & (1 << (7 - (i % 8))))
hash ^= get_key_word(key, i);
}
return hash;
}
/* 해시 → 큐 매핑 */
queue_index = indirection_table[hash & (table_size - 1)];
# VPP startup.conf에서 RSS 설정
dpdk {
dev 0000:03:00.0 {
num-rx-queues 4 # 워커 수에 맞춰 큐 수 설정
rss-fn ipv4-tcp # TCP/IPv4에 대해 RSS 활성화
}
}
Flow Director / rte_flow 기반 정밀 제어
Flow Director는 NIC 하드웨어에서 exact-match 또는 prefix-match 규칙으로 특정 플로우를 지정된 큐로 스티어링(steering)합니다. DPDK의 rte_flow API는 이를 프로그래밍 가능한 방식으로 추상화합니다.
/* rte_flow API로 특정 VIP 트래픽을 큐 2로 스티어링하는 예시 */
struct rte_flow_attr attr = { .ingress = 1 };
struct rte_flow_item pattern[] = {
{ .type = RTE_FLOW_ITEM_TYPE_ETH },
{ .type = RTE_FLOW_ITEM_TYPE_IPV4,
.spec = &(struct rte_flow_item_ipv4){
.hdr.dst_addr = rte_cpu_to_be_32(RTE_IPV4(10,0,0,100))
}},
{ .type = RTE_FLOW_ITEM_TYPE_END },
};
struct rte_flow_action actions[] = {
{ .type = RTE_FLOW_ACTION_TYPE_QUEUE,
.conf = &(struct rte_flow_action_queue){ .index = 2 }},
{ .type = RTE_FLOW_ACTION_TYPE_END },
};
flow = rte_flow_create(port_id, &attr, pattern, actions, &error);
# VPP CLI에서 DPDK 플로우 분류 설정
set dpdk flow-classify interface GigabitEthernet3/0/0 \
ip4-table 0
# 사용 사례: 특정 VIP(10.0.0.100) 트래픽을 전용 워커에 할당
# → NIC 하드웨어가 패킷을 지정 큐로 스티어링
# → 해당 큐에 매핑된 워커가 전담 처리
VPP 세션 워커 어피니티
TCP/TLS 세션은 accept 또는 connect 시점에 현재 워커 스레드에 바인딩됩니다. session_alloc() 함수가 vlib_get_thread_index()로 현재 워커 인덱스를 확인하고, 해당 세션을 그 워커에 고정합니다. 세션의 전체 생명주기(lifetime) 동안 워커 변경은 불가능하며, 이는 FIFO 버퍼를 스레드 간에 공유하지 않기 때문입니다.
/* src/vnet/session/session.c — 세션 할당과 워커 바인딩 */
session_t *
session_alloc (u32 thread_index)
{
session_worker_t *wrk = &session_main.wrk[thread_index];
session_t *s;
/* per-worker 풀에서 세션 할당 — 락 불필요 */
pool_get_aligned(wrk->sessions, s, CLIB_CACHE_LINE_BYTES);
clib_memset(s, 0, sizeof(*s));
s->session_index = s - wrk->sessions;
s->thread_index = thread_index; /* 현재 워커에 고정 */
return s;
}
/* handoff 노드 — RSS 불일치 보정 */
/* 패킷이 잘못된 워커에 도착하면 올바른 워커로 재전달 */
static uword
session_queue_node_fn (vlib_main_t *vm, ...)
{
/* session의 thread_index와 현재 thread_index 비교 */
if (s->thread_index != thread_index)
session_send_rpc_evt_to_thread(s->thread_index, ...);
/* → 올바른 워커로 handoff */
}
대칭 플로우 해시
symmetrical 옵션을 사용하면 소스와 목적지를 교환(src↔dst swap)해도 동일한 해시값이 나오도록 보장합니다. 이 기능은 NAT, IPsec 등에서 인바운드/아웃바운드 경로가 같은 워커에서 처리되도록 하는 데 필수적입니다.
/* src/vnet/ip/ip4_forward.c — 대칭 해시 구현 */
static inline u32
ip4_compute_flow_hash (const ip4_header_t *ip,
flow_hash_config_t cfg)
{
u32 a, b, c;
u32 src = clib_net_to_host_u32(ip->src_address.as_u32);
u32 dst = clib_net_to_host_u32(ip->dst_address.as_u32);
/* 대칭 해시: src/dst를 정렬하여 방향 무관하게 동일 해시 */
if (cfg & IP_FLOW_HASH_SYMMETRIC) {
if (src > dst) {
u32 tmp = src; src = dst; dst = tmp;
}
}
a = src; b = dst; c = ip->protocol;
hash_v3_mix32(&a, &b, &c);
return c;
}
# VPP CLI에서 대칭 해시 활성화
set ip flow-hash table 0 src dst sport dport proto symmetric
# 확인
show ip flow-hash table 0
# 출력: src dst sport dport proto symmetric
실전 설정 가이드
| NIC 속도 | 권장 큐 수 | 권장 워커 수 | handoff 설정 | 비고 |
|---|---|---|---|---|
| 1 GbE | 1–2 | 1–2 | 불필요 | 단일 큐로 충분 |
| 10 GbE | 2–4 | 2–4 | 세션 앱 시 필요 | RSS 기본 분배 |
| 25 GbE | 4–8 | 4–8 | 권장 | Flow Director 병용 권장 |
| 40 GbE | 4–8 | 4–8 | 권장 | NUMA 교차 배치 주의 |
| 100 GbE | 8–16 | 8–16 | 필수 | rte_flow + 대칭 해시 필수 |
문제 진단 CLI 명령
# NIC 큐 ↔ 워커 매핑 확인
vpp# show interface placement
Thread 1 (vpp_wk_0):
GigabitEthernet3/0/0 queue 0
Thread 2 (vpp_wk_1):
GigabitEthernet3/0/0 queue 1
# 핸드오프 통계 — 큐 불일치로 인한 핸드오프 빈도 확인
vpp# show handoff
worker 0: 15234 handoffs
worker 1: 12891 handoffs
# 세션별 워커 확인 — 세션이 어느 워커에 바인딩되었는지 확인
vpp# show session verbose
[0:0] 10.0.0.1:80->10.0.0.2:54321 ESTABLISHED thread 1
[0:1] 10.0.0.1:80->10.0.0.3:54322 ESTABLISHED thread 2
# DPDK 큐 배치 — DPDK 레벨 큐-코어 매핑
vpp# show dpdk interface placement
GigabitEthernet3/0/0
queue 0 -> core 2 (worker 0)
queue 1 -> core 4 (worker 1)
queue 2 -> core 6 (worker 2)
queue 3 -> core 8 (worker 3)
L7 미리보기 — HTTP/2 멀티플렉싱과 세션 워커 분산
지금까지의 절은 모두 L2~L4 그래프 노드와 vppinfra 인프라에 집중했습니다. 그러나 FD.io VPP의 호스트 스택(호스트 스택)이 HTTP/2를 처리할 때는 한 가지 새로운 차원이 추가됩니다. 한 TCP 연결 안의 여러 스트림이 어떻게 워커 스레드에 분산되는가입니다. 이 절은 본 페이지의 멀티스레딩 모델과 호스트 스택을 잇는 짧은 다리 역할이며, 본격적인 세부 사항은 호스트 스택 페이지의 HTTP 프로토콜 절에서 다룹니다.
이 다이어그램의 핵심은 두 가지 원칙이 서로 다른 계층에서 동시에 작동한다는 점입니다.
- 코어 간 분배는 NIC RSS가 5튜플 hash로 결정합니다. 한 TCP 연결은 처음부터 끝까지 같은 워커에 머물러, lock-free 자료구조와 코어별 캐시 친화성을 유지합니다(멀티스레딩 아키텍처 참조).
- 코어 안 다중화(Multiplexing)는 HTTP/2가 책임집니다. 한 워커가 같은 연결의 수십 개 스트림을 라운드로빈/우선순위 순으로 인터리브하며, 큰 응답이 작은 응답을 막는 HOL 블로킹을 완화합니다.
이 분리가 가져오는 결과는 예측 가능한 확장성입니다. 워커 N개를 늘리면 동시 연결 수와 RPS가 거의 선형으로 증가합니다. 다만 한 클라이언트가 단일 TCP 연결 위에서 수천 스트림을 동시에 열면 그 전체 부하가 한 워커에 집중되므로, 헤비 클라이언트는 SETTINGS_MAX_CONCURRENT_STREAMS로 묶어 두는 편이 안전합니다. HPACK 동적 테이블·우선순위 트리·플로 제어 윈도의 구체적인 동작은 호스트 스택 페이지의 HTTP/2 절에서 이어 다룹니다.
커널 네트워크 스택과의 비교
성능 비교
| 시나리오 | 커널 스택 | VPP (DPDK) | VPP (AF_XDP) |
|---|---|---|---|
| L3 포워딩 (64B, 10GbE) | ~1.2 Mpps | ~14.8 Mpps (라인레이트) | ~11 Mpps |
| L2 브릿징 (64B, 10GbE) | ~2 Mpps | ~14.8 Mpps (라인레이트) | ~12 Mpps |
| IPsec (AES-GCM-256, 1500B) | ~2 Gbps | ~20 Gbps (AES-NI) | — |
| NAT44 (64B) | ~800 Kpps | ~10 Mpps | ~8 Mpps |
| TCP 연결 처리 | 우수 (네이티브) | 제한적 (VCL) | 제한적 |
왜 VPP는 빠른가 — 다섯 가지 기제
위 표의 10배 이상 격차는 단일 트릭이 아니라, 서로 다른 계층의 최적화 기제 다섯 가지가 곱해져 만들어집니다. 각 기제는 앞서 다룬 절들에서 따로 설명했지만, 이 절은 "수치적 차이가 어디서 오는가"를 한눈에 정리합니다.
| # | 기제 | 제거하는 비용 | 대략 기여(단일 코어 L3 fwd 기준) |
|---|---|---|---|
| 1 | 커널/유저 경계 제거 (DPDK PMD) | syscall, context switch, skb 할당, softirq, netfilter hook | ~5~10× (1.2 Mpps → ~10 Mpps) |
| 2 | 인터럽트 → 폴링 | per-packet IRQ, top/bottom-half, scheduler 개입 | ~1.5× (지연 분산 제거) |
| 3 | 벡터 처리 (그래프 노드 × 256 packets) | I-cache 미스, 함수 호출 오버헤드, 분기 예측 실패 | ~2× (instruction cache 효과) |
| 4 | quad-loop + prefetch | L1/L2 D-cache 미스 stall (수십~수백 cycle/패킷) | ~1.5× (메모리 latency 은닉) |
| 5 | 2-cacheline 버퍼 레이아웃 + Hugepage | TLB 미스, cacheline 교차, NUMA 원격 접근 | ~1.2~1.5× |
왜 quad-loop가 결정적인가 — IPC와 prefetch 거리
현대 CPU는 한 cycle에 평균 3~4개의 명령어를 retire할 수 있지만, 메모리에서 데이터를 기다리면 IPC(Instructions Per Cycle)가 0.5 이하로 떨어집니다. L1 D-cache 미스는 ~12 cycle, L2 미스는 ~40 cycle, L3 미스(원격 NUMA)는 ~300 cycle을 stall로 유발합니다. 패킷마다 FIB lookup·헤더 재기록을 하면 D-cache 미스가 일상적이고, 이 stall이 처리량을 결정합니다.
quad-loop는 4개 패킷을 함께 다루면서 다음 상황을 만들어냅니다:
- prefetch 거리 확보 — 패킷 N+2/N+3의 헤더와 buffer metadata를 미리 fetch하여 N을 처리하는 동안 캐시 도착을 기다립니다. stall이 다른 패킷의 work로 가려집니다.
- ILP 증가 — 4개 패킷의 독립 연산이 함께 in-flight되어 슈퍼스칼라/OoO 엔진이 비어 있는 실행 슬롯을 채울 수 있습니다.
- 분기 예측 안정화 — 같은 종류의 패킷 4개를 같은 코드 경로로 처리하므로 분기 패턴이 단순해집니다.
이 효과는 노드 코드가 단순할수록 두드러집니다. ip4-lookup, l2-fwd처럼 핫 패스 노드가 quad-loop을 쓰는 이유이며, 복잡한 상태 머신을 가진 tcp-input이 single-loop인 이유이기도 합니다(쿼드 루프 패턴 절 참조).
왜 2-cacheline 버퍼 레이아웃이 중요한가
vlib_buffer_t는 의도적으로 두 개의 64바이트 cacheline에 맞춰 설계되어 있습니다. 첫 번째 cacheline은 핫 필드(current_data, current_length, flags, next_buffer, current_config_index 등)로, 모든 노드가 매 패킷에서 읽고 쓰는 항목입니다. 두 번째 cacheline은 콜드 필드(trace_handle, opaque, opaque2)로, 일부 노드만 접근합니다.
- 핫 필드만 첫 cacheline에 모이므로 일반 포워딩 경로는 패킷당 cacheline 1개만 가져오면 충분합니다 — L1 footprint가 절반으로 줄어듭니다.
- 4 패킷 × 1 cacheline = 256 B → L1 D-cache(32 KB)에 수십 개의 패킷이 동시에 머무를 수 있어 quad-loop의 prefetch가 효과를 봅니다.
- Hugepage(2 MB) 위에 buffer pool을 올리므로 TLB 엔트리 한 개로 수천 개 버퍼를 커버합니다. 4 KB 페이지였다면 패킷 처리 중 dTLB 미스가 빈발했을 것입니다.
이 레이아웃과 quad-loop, prefetch는 따로 떼어 보면 작은 최적화이지만, 결합하면 cycles/packet을 ~150에서 ~30 미만으로 내리는 결정적 차이를 만듭니다. CSIT 보고서의 노드별 cycles/packet 수치를 보면 ip4-lookup이 한 자릿수 cycle인 이유가 여기에 있습니다.
적용 시나리오 선택 기준
| 기준 | 커널 스택 적합 | VPP 적합 |
|---|---|---|
| TCP 애플리케이션 | 웹 서버, DB, 범용 서비스 | L3/L4 포워딩, 터널링 |
| 성능 요구 | < 5 Gbps, 범용 처리 | > 10 Gbps, 라인레이트 포워딩 |
| 기능 복잡도 | 소켓 API, iptables, tc | L2/L3/VPN/NAT 고성능 처리 |
| 운영 편의성 | 표준 Linux 도구 사용 | 별도 CLI/API 학습 필요 |
| NIC 공유 | 여러 앱이 NIC 공유 | NIC 독점(DPDK) 또는 AF_XDP |
| 에코시스템 | 모든 Linux 도구 호환 | linux-cp로 일부 통합 |
참고자료
이 문서는 VPP 25.10 / 26.02 기준으로 작성되어 있으며, 아래 링크는 공식·권위 있는 1차 자료 위주로 정리했습니다. 릴리스마다 API와 노드 이름이 변할 수 있으므로 실제 배포 전에는 반드시 본인이 쓰는 버전의 문서를 교차 확인하시기 바랍니다.
FD.io · VPP 공식 문서
- FD.io 프로젝트 공식 사이트 — VPP/CSIT/HICN 허브
- VPP 문서 루트 — 버전별 최신 문서 인덱스
- VPP 26.02 공식 문서 — 현재 공개된 최상위 트리
- VPP Developer Documentation — VLIB·VNET·Plugin 개발 가이드
- VPP Supported Features — production / experimental 성숙도
- VPP Configuration Reference —
startup.conf전체 옵션 - FD.io Wiki: VPP — 설계 메모, 디자인 토론, 릴리스 정책
VLIB 벡터 처리와 그래프 노드
- VLIB (Vector Processing Library) — 벡터 큐, 프레임, 메인 루프
- VNET (Networking) — L2/L3/L4 노드 구성
- Feature Arcs — 피처 아크 원리와
vnet_feature_enable_disable - Writing a VPP Plugin — 신규 노드·플러그인 단계별 템플릿
- VPP 빌드·실행·기여 가이드 — Gerrit 워크플로
vppinfra (clib) 인프라 라이브러리
- src/vppinfra/ — vec/pool/hash/bihash/mheap 구현체
- vec.h —
vec_*매크로 원형과 헤더 레이아웃 - pool.h — 풀 할당자, free bitmap 구조
- bihash_template.h — 락-프리 bounded-index hash 원형
기초 표준 및 관련 RFC
- RFC 791 — IPv4
- RFC 8200 — IPv6
- RFC 826 — ARP
- RFC 4861 — IPv6 Neighbor Discovery
- RFC 9293 — TCP (통합본)
VPP 소스 트리 탐색
- github.com/FDio/vpp — GitHub 미러(읽기 전용)
- git.fd.io/vpp — 공식 cgit(권위 있는 트리)
- Gerrit: project:vpp — 리뷰 중인 패치
src/vlib/— 벡터 처리 엔진 본체src/vnet/— 네트워킹 노드(L2/L3/L4)src/plugins/— DPDK·ACL·NAT·host stack 등 플러그인src/vpp-api/,src/vlibapi/,src/vlibmemory/— 바이너리 API와 공유 메모리 기반 IPC
git.fd.io/vpp의 cgit은 브랜치별로 코드를 볼 수 있으므로, 글에 등장한 함수(예: vlib_node_runtime_t)를 현재 쓰시는 릴리스 브랜치에서 직접 확인하는 편이 가장 정확합니다. GitHub 미러는 UI가 편하지만 최신 반영이 수 시간 늦을 수 있습니다.
관련 문서
이 주제와 관련된 다른 문서를 더 깊이 이해하고 싶다면 다음을 참고하세요.
오픈소스 코드 인용 고지
| 프로젝트 | 저작권자 | 라이선스 | 공식 저장소 |
|---|---|---|---|
| VPP (Vector Packet Processing) | FD.io contributors | Apache License 2.0 | github.com/FDio/vpp |
| DPDK (Data Plane Development Kit) | DPDK contributors | BSD 3-Clause License | github.com/DPDK/dpdk |
코드 블록 내 주석에 원본 파일 경로가 표기된 부분은 해당 프로젝트 소스 코드에서 발췌·간략화한 것입니다. 전체 소스 코드는 위 공식 저장소에서 확인하시기 바랍니다.