멀티큐 NIC의 최신 기능을 활용해 UDP 애플리케이션의 지연 시간을 줄이는 방법을 실험과 함께 살펴봅니다. SO_BUSY_POLL, 사용자 공간 폴링, CPU 핀ning, RSS 인디렉션 테이블, 플로우 스티어링, XPS/XFS, RFS/ARFS, 인터럽트 코얼레싱 조정, 하드웨어 타임스탬프, OpenOnload까지 다룹니다.
좋은 아침입니다!
최근 블로그 글에서는 단순한 UDP 애플리케이션을 튜닝해 처리량(throughput)을 극대화하는 방법을 설명했습니다. 이번에는 우리의 UDP 애플리케이션을 지연 시간(latency)에 맞춰 최적화해 보겠습니다. 지연 시간과 싸우는 것은 멀티큐(multiqueue) NIC의 현대적 기능을 논의하기에 아주 좋은 구실입니다. 여기서 다루는 기법 중 일부는 scaling.txt 커널 문서에도 언급되어 있습니다.
CC BY-SA 2.0 image by Xiaojun Deng
우리의 실험은 다음과 같이 구성합니다.
두 대의 물리 Linux 호스트가 있습니다: ‘client’와 ‘server’. 둘은 단순한 UDP 에코(echo) 프로토콜로 통신합니다.
클라이언트는 작은 UDP 프레임(페이로드 32바이트)을 보내고 응답을 기다리며 왕복 시간(RTT)을 측정합니다. 서버는 패킷을 수신하자마자 즉시 다시 에코합니다.
두 호스트 모두 2GHz Xeon CPU를 사용하며, 6코어 2소켓에 하이퍼스레딩(HT)이 활성화되어 있습니다. 즉, 호스트당 24개의 CPU(논리 CPU)가 있습니다.
클라이언트는 Solarflare 10Gb NIC, 서버는 Intel 82599 10Gb NIC를 사용합니다. 두 카드 모두 광(fiber)으로 10Gb 스위치에 연결되어 있습니다.
왕복 시간을 측정할 것입니다. 수치가 꽤 작아서 평균을 내면 지터(jitter)가 크게 나타납니다. 대신, 1초 동안 여러 번 실행한 결과 중 안정적인 값(최저 RTT)을 사용하는 편이 더 의미가 있습니다.
늘 그렇듯, 사용한 코드는 GitHub에 있습니다: udpclient.c, udpserver.c.
먼저 IP 주소를 명시적으로 할당합니다:
bashclient$ ip addr add 192.168.254.1/24 dev eth2 server$ ip addr add 192.168.254.30/24 dev eth3
iptables와 conntrack이 우리의 트래픽을 방해하지 않도록 합니다:
bashclient$ iptables -I INPUT 1 --src 192.168.254.0/24 -j ACCEPT client$ iptables -t raw -I PREROUTING 1 --src 192.168.254.0/24 -j NOTRACK server$ iptables -I INPUT 1 --src 192.168.254.0/24 -j ACCEPT server$ iptables -t raw -I PREROUTING 1 --src 192.168.254.0/24 -j NOTRACK
마지막으로, 멀티큐 네트워크 카드의 인터럽트가 CPU들 사이에 고르게 분배되도록 합니다. irqbalance 서비스를 중지하고, 인터럽트를 수동으로 할당합니다. 단순화를 위해 RX 큐 #0을 CPU #0에, RX 큐 #1을 CPU #1에… 이런 식으로 핀(pin)합니다.
bashclient$ (let CPU=0; cd /sys/class/net/eth2/device/msi_irqs/; for IRQ in *; do echo $CPU > /proc/irq/$IRQ/smp_affinity_list let CPU+=1 done) server$ (let CPU=0; cd /sys/class/net/eth3/device/msi_irqs/; for IRQ in *; do echo $CPU > /proc/irq/$IRQ/smp_affinity_list let CPU+=1 done)
이 스크립트는 각 RX 큐가 발생시키는 인터럽트를 선택된 CPU에 할당합니다. 또한 일부 네트워크 카드는 기본적으로 Ethernet flow control이 활성화되어 있습니다. 우리 실험에서는 패킷을 아주 많이 밀어 넣지 않으니 큰 차이가 없을 수 있습니다. 하지만 어쨌든 일반 사용자가 flow control을 원하는 경우는 드뭅니다. 부하가 높을 때 예측 불가능한 지연 시간 스파이크를 유발할 수 있기 때문입니다.
bashclient$ sudo ethtool -A eth2 autoneg off rx off tx off server$ sudo ethtool -A eth3 autoneg off rx off tx off
클라이언트 코드 개요는 다음과 같습니다. 특별할 건 없고, 패킷을 보내고 응답을 받을 때까지의 시간을 잽니다.
pythonfd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) fd.bind(("0.0.0.0", 65400)) # 비결정성을 줄이기 위해 소스 포트 고정 fd.connect(("192.168.254.30", 4321)) while True: t1 = time.time() fd.sendmsg("\x00" * 32) fd.readmsg() t2 = time.time() print "rtt=%.3fus" % ((t2-t1) * 1000000)
서버도 마찬가지로 단순합니다. 패킷을 기다렸다가 소스로 그대로 돌려보냅니다.
pythonfd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) fd.bind(("0.0.0.0", 4321)) while True: data, client_addr = fd.recvmsg() fd.sendmsg(data, client_addr)
실행해 봅시다:
bashserver$ ./udpserver client$ ./udpclient 192.168.254.30:4321 [*] Sending to 192.168.254.30:4321, src_port=65500 pps= 16815 avg= 57.224us dev= 24.970us min=45.594us pps= 15910 avg= 60.170us dev= 28.810us min=45.679us pps= 15463 avg= 61.892us dev= 33.332us min=44.881us
순진한 실행에서는 초당 약 15k 왕복을 수행할 수 있고, 평균 RTT는 60마이크로초(us), 최소는 44us입니다. 표준편차가 꽤 무섭고 지터가 크다는 뜻입니다.
Linux 3.11에서는 SO_BUSY_POLL 소켓 옵션이 추가되었습니다. 아이디어는 커널이 지정된 시간 동안 들어오는 패킷을 폴링하도록 요청하는 것입니다. 물론 머신의 CPU 사용량은 늘어나지만 지연 시간은 줄어듭니다. 패킷 수신 시 발생하는 큰 컨텍스트 스위치를 피함으로써 이득이 생깁니다. 우리 실험에서는 SO_BUSY_POLL을 활성화하면 최소 지연 시간이 7us 줄어듭니다.
bashserver$ sudo ./udpserver --busy-poll=50 client$ sudo ./udpclient 192.168.254.30:4321 --busy-poll=50 pps= 19440 avg= 49.886us dev= 16.405us min=36.588us pps= 19316 avg= 50.224us dev= 15.764us min=37.283us pps= 19024 avg= 50.960us dev= 18.570us min=37.116us
이 수치에 크게 감탄하진 않지만, SO_BUSY_POLL에는 유효한 사용 사례가 있을 수 있습니다. 제 이해로는 다른 접근과 달리 인터럽트 코얼레싱(rx-usecs)과도 잘 동작합니다.
SO_BUSY_POLL로 커널에서 폴링하는 대신, 애플리케이션에서 직접 폴링할 수 있습니다. recvmsg에서 블록(block)하지 않도록 하고, 바쁜 루프(busy loop)에서 논블로킹 변형을 돌립니다. 서버의 의사 코드는 다음과 같습니다.
pythonwhile True: while True: data, client_addr = fd.recvmsg(MSG_DONTWAIT) if data: break fd.sendmsg(data, client_addr)
이 방법은 놀랄 만큼 효과적입니다.
bashserver$ ./udpserver --polling client$ ./udpclient 192.168.254.30:4321 --polling pps= 25812 avg= 37.426us dev= 11.865us min=31.837us pps= 23877 avg= 40.399us dev= 14.665us min=31.832us pps= 24746 avg= 39.086us dev= 14.041us min=32.540us
최소 시간이 4us 더 내려갔을 뿐만 아니라 평균과 편차도 더 건강해 보입니다.
지금까지는 Linux 스케줄러가 바쁜 폴링 애플리케이션에 CPU를 할당하도록 두었습니다. 지터의 일부는 프로세스가 여기저기 옮겨 다닌 데서 왔습니다. 특정 코어에 핀해 봅시다.
bashserver$ taskset -c 3 ./udpserver --polling client$ taskset -c 3 ./udpclient 192.168.254.30:4321 --polling pps= 26824 avg= 35.879us dev= 11.450us min=30.060us pps= 26424 avg= 36.464us dev= 12.463us min=30.090us pps= 26604 avg= 36.149us dev= 11.321us min=30.421us
추가로 1us를 더 깎았습니다. 하지만 “나쁜” CPU에서 애플리케이션을 실행하면 오히려 수치가 나빠질 수 있습니다. 이유를 이해하려면 패킷이 RX 큐들 사이에서 어떻게 디스패치(dispatch)되는지 다시 봐야 합니다.
이전 글에서 NIC가 해시를 이용해 부하를 여러 RX 큐로 분산한다고 언급했습니다. 이 기법을 RSS(Receive Side Scaling)라고 합니다. softnet.sh 스크립트로 /proc/net/softnet_stat을 관찰하면 이를 확인할 수 있습니다.

이는 타당합니다. udpclient에서 단 하나의 플로우(연결)만 보내므로 모든 패킷은 같은 RX 큐에 들어갑니다. 이 경우 RX 큐 #1이며, CPU #1에 바인딩되어 있습니다. 실제로 클라이언트를 그 CPU에서 실행하면 지연 시간이 약 2us 증가합니다.
bashclient$ taskset -c 1 ./udpclient 192.168.254.30:4321 --polling pps= 25517 avg= 37.615us dev= 12.551us min=31.709us pps= 25425 avg= 37.787us dev= 12.090us min=32.119us pps= 25279 avg= 38.041us dev= 12.565us min=32.235us
도착 인터럽트가 발생하는 코어와 동일한 코어에 프로세스를 두면 지연 시간이 약간 나빠지는 것으로 드러났습니다. 그런데 프로세스는 자신이 “문제의 CPU”에서 실행 중인지 어떻게 알 수 있을까요?
한 가지 방법은 애플리케이션이 커널에 질의하는 것입니다. 커널 3.19에는 SO_INCOMING_CPU 소켓 옵션이 도입되었습니다. 이를 통해 프로세스는 패킷이 원래 어느 CPU로 전달되었는지 알아낼 수 있습니다.
대안으로는, 패킷이 우리가 원하는 CPU들로만 디스패치되도록 보장하는 것입니다. ethtool로 NIC 설정을 조정하면 가능합니다.
RSS는 RX 큐들로 부하를 분산시키려는 목적입니다. 우리의 경우 UDP 플로우에서 RX 큐는 아래 식으로 선택됩니다.
RX_queue = INDIR[hash(src_ip, dst_ip) % 128]
앞서 논의했듯 Solarflare NIC에서 UDP 플로우의 해시 함수는 설정할 수 없습니다. 다행히 INDIR 테이블은 설정할 수 있습니다!
그런데 INDIR는 무엇일까요? 해시의 최하위 비트(least significant bits)를 RX 큐 번호에 매핑하는 인디렉션 테이블입니다. 인디렉션 테이블을 보려면 ethtool -x를 실행합니다.

이는 예를 들어 해시가 72인 패킷은 RX 큐 #6으로, 해시 126은 RX 큐 #5로 간다는 뜻입니다.
이 테이블은 설정 가능합니다. 예를 들어 모든 트래픽이 CPU #0-#5(우리 설정의 첫 번째 NUMA 노드)로만 가게 하려면 다음을 실행합니다.
bashclient$ sudo ethtool -X eth2 weight 1 1 1 1 1 1 0 0 0 0 0 server$ sudo ethtool -X eth3 weight 1 1 1 1 1 1 0 0 0 0 0
조정된 인디렉션 테이블:

이렇게 설정해도 지연 시간 수치는 크게 바뀌지 않지만, 적어도 패킷이 항상 첫 번째 NUMA 노드의 CPU에만 들어오도록 보장할 수 있습니다. NUMA 지역성(locality)이 없으면 보통 약 2us 비용이 듭니다.
참고로 Intel 82599는 UDP 플로우의 RSS 해시 함수를 조정할 수 있지만, 드라이버가 인디렉션 테이블 조작을 지원하지 않습니다. 동작시키기 위해 저는 이 패치를 사용했습니다.
패킷이 특정 CPU로 들어오도록 보장하는 또 다른 방법은 플로우 스티어링 규칙입니다. 플로우 스티어링 규칙은 인디렉션 테이블 위에 예외를 지정합니다. 즉, 특정 플로우를 특정 RX 큐로 보내도록 NIC에 지시합니다. 예를 들어 우리 경우 서버와 클라이언트에서 플로우를 모두 RX 큐 #1에 핀할 수 있습니다.
bashclient$ sudo ethtool -N eth2 flow-type udp4 dst-ip 192.168.254.1 dst-port 65500 action 1 Added rule with ID 12401 server$ sudo ethtool -N eth3 flow-type udp4 dst-port 4321 action 1 Added rule with ID 2045
플로우 스티어링은 더 “마법 같은” 용도로도 쓸 수 있습니다. 예를 들어 action -1을 지정해 특정 트래픽을 NIC에서 바로 드롭(drop)하도록 할 수 있습니다. 이는 DDoS 패킷 플러드 중에 매우 유용하며, 라우터 방화벽에서 드롭하는 것보다 실현 가능한 대안이 되기도 합니다.
일부 관리자는 SSH나 BGP가 높은 서버 부하 중에도 계속 동작하도록 하기 위해 플로우 스티어링을 사용합니다. 인디렉션 테이블을 조작해 운영 트래픽을 예컨대 RX 큐 #0/CPU #0에서 다른 곳으로 옮긴 뒤, 플로우 스티어링 규칙으로 목적지 포트 22나 179로 가는 패킷은 항상 CPU #0(RX 큐 #0)으로만 들어오게 합니다. 그러면 네트워크 부하가 아무리 커도 SSH와 BGP는 전용 CPU와 RX 큐를 타므로 계속 동작합니다.
실험으로 돌아와 /proc/interrupts를 봅시다.

RX 큐 #1에서 수신된 패킷에 해당하는 27k 인터럽트가 보입니다. 그런데 RX 큐 #7에도 27k 인터럽트가 있습니다. RSS/인디렉션 테이블 설정으로 RX 큐를 비활성화했는데 말이죠.
알고 보니 이 인터럽트는 패킷 송신(transmit) 때문에 발생합니다. 이상하게 들릴 수 있지만, Linux는 패킷을 “올바른” TX 큐로 보내야 하는지 알아낼 방법이 없습니다. 패킷 재정렬(reordering)을 피하고 기본값으로 안전하게 동작하기 위해, 송신은 또 다른 플로우 해시에 기반해 분산됩니다. 이 때문에 송신이 애플리케이션이 실행 중인 CPU와 다른 CPU에서 일어나는 것이 거의 보장되어 지연 시간이 늘어납니다.
송신이 로컬 TX 큐에서 이뤄지도록 하기 위해 Linux에는 XFS라는 메커니즘이 있습니다. 이를 설정하려면 각 TX 큐마다 CPU 마스크를 지정해야 합니다. 24개 CPU에 TX 큐는 11개뿐이라 조금 더 복잡합니다.
bashXPS=("0 12" "1 13" "2 14" "3 15" "4 16" "5 17" "6 18" "7 19" "8 20" "9 21" "10 22 11 23"); (let TX=0; for CPUS in "$XPS[@]"; do let mask=0 for CPU in $CPUS; do let mask=$((mask | 1 << $CPU)); done printf %X $mask > /sys/class/net/eth2/queues/tx-$TX/xps_cpus let TX+=1 done)
XPS를 활성화하고 CPU affinity를 조심스럽게 관리(프로세스가 RX 인터럽트와 다른 코어에 있도록 유지)하면 28us까지 짜낼 수 있었습니다.
bashpps= 27613 avg= 34.724us dev= 12.430us min=28.536us pps= 27845 avg= 34.435us dev= 12.050us min=28.379us pps= 27046 avg= 35.365us dev= 12.577us min=29.234us
RSS는 여러 CPU에 부하를 분산시키는 문제는 해결하지만, 지역성(locality) 문제는 해결하지 못합니다. 사실 NIC는 관련 애플리케이션이 어느 코어에서 대기하는지 알 방법이 없습니다. 여기서 RFS가 등장합니다.
RFS는 커널 기술로, 모든 진행 중인 플로우에 대해 (flow_hash, CPU) 매핑을 유지합니다. 패킷이 수신되면 커널은 이 맵을 사용해 패킷을 어느 CPU로 보낼지 빠르게 결정합니다. 물론 애플리케이션이 다른 CPU로 재스케줄링되면 RFS는 빗나갈 수 있습니다. RFS를 사용한다면 애플리케이션을 특정 코어에 핀하는 것을 고려하세요.
클라이언트에서 RFS를 활성화하려면 다음을 실행합니다.
bashclient$ echo 32768 > /proc/sys/net/core/rps_sock_flow_entries client$ for RX in `seq 0 10`; do echo 2048 > /sys/class/net/eth2/queues/rx-$RX/rps_flow_cnt done
디버깅하려면 softnet.sh 스크립트의 6번째 컬럼을 봅니다.

정확한 이유는 완전히 이해하지 못했지만, 소프트웨어 RFS를 켜면 지연 시간이 약 2us 늘어납니다. 반면 RFS는 처리량을 늘리는 데 효과적이라고 알려져 있습니다.
RFS의 소프트웨어 구현은 완벽하지 않습니다. 커널이 여전히 CPU 간 패킷 전달을 해야 하기 때문입니다. 다행히 하드웨어 지원으로 개선할 수 있는데, 이를 Accelerated RFS(ARFS)라고 합니다. ARFS를 지원하는 네트워크 카드 드라이버에서는 커널이 (flow_hash, CPU) 매핑을 NIC와 공유해, NIC가 하드웨어에서 올바르게 패킷을 스티어링하게 합니다.
Solarflare NIC에서는 RFS를 활성화하면 ARFS가 자동으로 활성화됩니다. 이는 /proc/interrupts에서 확인할 수 있습니다.

여기서는 클라이언트 프로세스를 CPU #2에 핀했습니다. 보시다시피 수신과 송신이 같은 CPU에 나타납니다. 프로세스가 다른 CPU로 재스케줄링되면 인터럽트가 “따라” 움직입니다.
RFS는 TCP든 UDP든 “연결된(connected)” 소켓이면 동작합니다. 우리 경우 udpclient는 connect()를 사용하므로 동작하지만, udpserver는 bind() 소켓에서 직접 메시지를 보내기 때문에 동작하지 않습니다.
Intel 드라이버는 ARFS를 지원하지 않지만, 이 기술을 “Flow Director”라는 이름으로 브랜드화한 자체 기능이 있습니다. 기본적으로 활성화되어 있지만 문서가 오해를 부르고, ntuple 라우팅이 활성화되어 있으면 동작하지 않습니다. 동작시키려면 다음을 실행합니다.
$ sudo ethtool -K eth3 ntuple off
효과를 디버깅하려면 ethtool 통계에서 fdir_miss와 fdir_match를 확인합니다.
bash$ sudo ethtool -S eth3|grep fdir fdir_match: 7169438 fdir_miss: 128191066
추가로, Flow Director는 NIC 드라이버에 의해 구현되며 커널과 통합되어 있지 않습니다. 애플리케이션이 실제로 어느 코어에 있는지 알 방법이 없습니다. 대신 가끔 송신 패킷을 보고 추측합니다. 실제로는 20번째(ATR 설정) 패킷마다 혹은 SYN 플래그가 설정된 모든 패킷을 검사합니다. 또한 패킷 재정렬을 유발할 수 있고 UDP는 지원하지 않습니다.
마지막으로, 지연 시간과의 싸움에서 최후 수단은 일련의 저수준 네트워크 카드 설정을 조정하는 것입니다. 인터럽트 코얼레싱은 네트워크 카드가 발생시키는 인터럽트 수를 줄이기 위해 흔히 사용되지만, 그 대가로 시스템에 지연을 추가합니다. 인터럽트 코얼레싱을 끄려면:
bashclient$ sudo ethtool -C eth2 rx-usecs 0 server$ sudo ethtool -C eth3 rx-usecs 0
이로써 지연 시간이 추가로 몇 마이크로초 줄어듭니다.
bashpps= 31096 avg= 30.703us dev= 8.635us min=25.681us pps= 30809 avg= 30.991us dev= 9.065us min=25.833us pps= 30179 avg= 31.659us dev= 9.978us min=25.735us
그 이상의 튜닝은 더 복잡해집니다. 사람들은 GRO나 LRO 같은 고급 기능을 끄라고 권하지만, 우리의 UDP 애플리케이션에서는 큰 차이가 없다고 생각합니다. 이를 끄면 TCP 스트림의 지연 시간은 개선될 수 있지만 처리량은 악화될 것입니다.
더 극단적인 옵션으로는 BIOS에서 프로세서의 C-sleep 상태를 비활성화하는 방법이 있습니다.
Linux에서는 SO_TIMESTAMPNS 소켓 옵션으로 패킷 하드웨어 타임스탬프를 가져올 수 있습니다. 이를 벽시계(wall clock)와 비교하면 커널 네트워크 스택이 추가하는 지연을 측정할 수 있습니다.
bashclient$ taskset -c 1 ./udpclient 192.168.254.30:4321 --polling --timestamp pps=27564 avg=34.722us dev=14.836us min=26.828us packet=4.796us/1.622 pps=29385 avg=32.504us dev=10.670us min=26.897us packet=4.274us/1.415 pps=28679 avg=33.282us dev=12.249us min=26.106us packet=4.491us/1.440
커널이 바쁜 폴링 애플리케이션으로 패킷을 전달하는 데 4.3~5us가 걸립니다.
Solarflare 네트워크 카드가 있으니, 커널 네트워크 스택을 아예 건너뛰는 커널 바이패스 기술인 OpenOnload를 사용할 수 있습니다.
bashclient$ onload ./udpclient 192.168.254.30:4321 --polling --timestamp pps=48881 avg=19.187us dev=1.401us min=17.660us packet=0.470us/0.457 pps=49733 avg=18.804us dev=1.306us min=17.702us packet=0.487us/0.390 pps=49735 avg=18.788us dev=1.220us min=17.654us packet=0.491us/0.466
지연 시간 감소도 감소지만, 편차를 보세요. OpenOnload는 패킷 전달에 걸리는 시간을 10배 줄입니다. 4.3us에서 0.47us로 내려갑니다.
이 글에서는 10Gb 이더넷으로 연결된 두 Linux 호스트 사이에서 기대할 수 있는 네트워크 지연 시간을 논의했습니다. 큰 튜닝 없이도 RTT 40us 정도는 가능하며, 바쁜 폴링이나 CPU affinity 같은 소프트웨어 튜닝을 하면 30us까지 줄일 수 있습니다. 추가로 5us를 더 줄이는 것은 더 어렵지만, 몇 가지 ethtool 토글로 가능하긴 합니다.
일반적으로 커널이 유휴(idling) 애플리케이션에 패킷을 전달하는 데 약 8us가 걸리고, 바쁜 폴링 프로세스에는 약 4us가 걸립니다. 우리 구성에서는 스위치를 통해 두 네트워크 카드 사이로 패킷이 물리적으로 전달되는 데 약 4us가 걸렸습니다.
rx-usecs를 낮추거나 LRO를 비활성화하는 등의 저지연 설정은 처리량을 떨어뜨리고 인터럽트 수를 늘릴 수 있습니다. 즉, 저지연을 위해 시스템을 튜닝하면 서비스 거부(DoS) 문제에 더 취약해질 수 있습니다.
TCP를 특별히 다루진 않았지만, 데이터 지역성을 높이는 ARFS와 XFS를 다뤘습니다. 이를 활성화하면 CPU 간 잡음(cross-CPU chatter)이 줄고 TCP 처리량이 늘어난다고 합니다. 실무에서 TCP는 지연 시간보다 처리량이 더 중요할 때가 많습니다.
일반적인 설정으로는 CPU마다 TX 큐를 하나씩 두고, XFS와 RFS/ARFS를 활성화하는 것을 권합니다. 수신 패킷 디스패치를 효과적으로 하려면 RSS 인디렉션 테이블에서 CPU #0과 모든 가짜 HT 코어를 건너뛰도록 설정하는 것을 권합니다. RFS의 지역성을 높이려면 애플리케이션을 전용 CPU에 핀하는 것을 권합니다.
이런 종류의 저수준 고성능 패킷 다루기에 관심이 있나요? CloudFlare는 채용 중 입니다(런던, 샌프란시스코, 싱가포르).