8 분 소요


How to receive a million packets per second을 정리한 글입니다.


일상적인 대화에서 우연히 한 동료가 다음과 같이 말하는 것을 들었습니다.
`리눅스 네트워크 스택은 느려요! 코어 당 초당 5 만 개 이상의 패킷을 보낼 수 없어요!”

아마도 코어 당 50kpps 가 실제 애플리케이션의 한계에는 적절하지만,
리눅스 네트워킹 스택의 한계는 어느 정도일까요?


리눅스에서 초당 100만 개의 UDP 패킷을 수신하는 프로그램 을 작성하는 것이 어려운가?


이 질문에 대한 대답이 현대적인 네트워킹 스택 설계에 대한 좋은 교훈이 되길 바랍니다.



먼저, 다음과 같이 가정해보겠습니다:

  • 초당 패킷(pps)을 측정하는 것은 초당 바이트(Bps)를 측정하는 것보다 훨씬 더 의미가 있습니다.
    Bps 는 더 나은 파이프 라이닝과 더 긴 패킷을 전송하여 높일 수 있지만,
    pps 를 개선하는 것은 훨씬 더 어렵습니다.

  • pps에 관심이 있기 때문에, 이 실험은 짧은 UDP 메세지를 사용하겠습니다.
    정확히 말하자면, 32바이트 UDP 페이로드로 이더넷 레이어 74 바이트입니다.

  • 실험에서는 receiversender 2대의 물리 서버를 사용했습니다:

  • 두 서버 모두 6 코어 2GHz Xeon 프로세서입니다.
    하이퍼스레딩(HT)을 활성화하면, 각 서버마다 24개 프로세서를 가지게 됩니다.
    서버에는 Solarflare 의 multi-queue 10G 네트워크 카드가 있고, 11개의
    receive queue가 설정되었습니다. 자세한 건 나중에 더 설명하겠습니다.

  • 테스트 프로그램의 소스 코드는 udpsender, udpreceiver 입니다.



전제 사항

UDP 패킷에 포트 4321을 사용하겠습니다.
시작하기 전에, 다음과 같이 트래픽 영향이 없도록 iptables 설정을 합니다:

receiver$ iptables -I INPUT 1 -p udp --dport 4321 -j ACCEPT
receiver$ iptables -t raw -I PREROUTING 1 -p udp --dport 4321 -j NOTRACK

편의를 위해서 IP 주소를 명시적으로 정의하겠습니다:

receiver$ for i in `seq 1 20`; do \
              ip addr add 192.168.254.$i/24 dev eth2; \
          done
sender$ ip addr add 192.168.254.30/24 dev eth3



1. 순진한 접근 방식

가장 간단한 실험으로 시작하겠습니다.
순진한 송수신으로 얼마나 많은 패킷이 전달될까요?

발신자 의사 코드:

fd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
fd.bind(("0.0.0.0", 65400)) # select source port to reduce nondeterminism
fd.connect(("192.168.254.1", 4321))
while True:
    fd.sendmmsg(["\x00" * 32] * 1024)

일반적인 send syscall 은 효율이 좋지 않습니다.
커널로 컨텍스트 전환하는 비용이 들기 때문에 피하는 것이 좋습니다.
다행히 편리한 syscall sendmmsg가 최근 Linux에 추가되었습니다.
한 번에 1,024 개의 많은 패킷을 보낼 수 있습니다.


수신자 의사 코드:

fd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
fd.bind(("0.0.0.0", 4321))
while True:
    packets = [None] * 1024
    fd.recvmmsg(packets, MSG_WAITFORONE)

마찬가지로 일반적인 recv syscall 보다 recvmmsg가 효율적인 버전입니다.


시도해 보겠습니다.

sender$ ./udpsender 192.168.254.1:4321
receiver$ ./udpreceiver1 0.0.0.0:4321
  0.352M pps  10.730MiB /  90.010Mb
  0.284M pps   8.655MiB /  72.603Mb
  0.262M pps   7.991MiB /  67.033Mb
  0.199M pps   6.081MiB /  51.013Mb
  0.195M pps   5.956MiB /  49.966Mb
  0.199M pps   6.060MiB /  50.836Mb
  0.200M pps   6.097MiB /  51.147Mb
  0.197M pps   6.021MiB /  50.509Mb

순진한 접근 방식으로 197k 에서 350k pps 를 처리할 수 있습니다.
나쁘진 않지만, 불행히도 약간의 가변성이 있습니다.
이는 커널이 코어 사이에서 이 프로그램을 섞어서 실행하며 발생합니다.
프로세스를 CPU에 고정하면 다음과 같은 이점이 있습니다:

sender$ taskset -c 1 ./udpsender 192.168.254.1:4321
receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321
  0.362M pps  11.058MiB /  92.760Mb
  0.374M pps  11.411MiB /  95.723Mb
  0.369M pps  11.252MiB /  94.389Mb
  0.370M pps  11.289MiB /  94.696Mb
  0.365M pps  11.152MiB /  93.552Mb
  0.360M pps  10.971MiB /  92.033Mb

이제 커널 스케줄러는 프로세스를 정의된 CPU에 유지합니다.
이는 프로세서 캐시 위치성을 향상시키고, 우리가 원했던 대로 지표를 더 일관성 있게 만듭니다.



2. 더 많은 패킷 보내기

370k pps 는 순진한 프로그램에는 나쁘지 않지만 여전히 1Mpps 의 목표와는 차이가 큽니다.
더 많이 받기 위해서 먼저 더 많은 패킷을 보내겠습니다.
두 스레드에서 독립적으로 보냈습니다:

sender$ taskset -c 1,2 ./udpsender \
            192.168.254.1:4321 192.168.254.1:4321
receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321
  0.349M pps  10.651MiB /  89.343Mb
  0.354M pps  10.815MiB /  90.724Mb
  0.354M pps  10.806MiB /  90.646Mb
  0.354M pps  10.811MiB /  90.690Mb


수신 측의 지표는 증가하지 않았습니다.
ethtool -S 명령으로 패킷이 실제로 어디로 갔는지 알 수 있습니다.

receiver$ watch 'sudo ethtool -S eth2 |grep rx'
     rx_nodesc_drop_cnt:    451.3k/s
     rx-0.rx_packets:     8.0/s
     rx-1.rx_packets:     0.0/s
     rx-2.rx_packets:     0.0/s
     rx-3.rx_packets:     0.5/s
     rx-4.rx_packets:  355.2k/s
     rx-5.rx_packets:     0.0/s
     rx-6.rx_packets:     0.0/s
     rx-7.rx_packets:     0.5/s
     rx-8.rx_packets:     0.0/s
     rx-9.rx_packets:     0.0/s
     rx-10.rx_packets:    0.0/s

이 통계에서 NIC가 RX queue #4 에 약 350kpps 를 전달한 것을 알 수 있습니다.
rx_nodesc_drop_cnt 는 NIC가 커널에 450kpps 로 전달하는 데 실패했다는 Solarflare 전용 카운터입니다.

패킷이 전달되지 않은 이유가 분명하지 않을 때가 있습니다. 하지만 우리의 경우는 매우 분명합니다.
RX queue #4 는 패킷을 CPU #4 로 전달하고 CPU #4 는 더 이상 작업을 할 수 없습니다 -
350kpps 를 읽는 것만으로도 너무 바쁩니다.

htop 에서 다음과 같이 확인할 수 있습니다:

그림. htop 결과




multi-queue NIC 를 위한 단기 속성 과정

역사적으로 네트워크 카드는 하나의 RX queue 로 하드웨어와 커널 간에 패킷을 전달했습니다.
이 설계에는 명백한 한계가 있었습니다 - 단일 CPU 처리량 이상의 패킷을 전달할 수 없었습니다.

멀티 코어 시스템을 활용하기 위해 NIC는 여러 RX queue 를 지원하기 시작했습니다.
설계는 간단합니다. 각 RX queue 는 별도의 CPU에 고정되어 있어서 NIC가 모든 RX queue
패킷을 전달하여 모든 CPU를 활용하는 것입니다.

그러나 여기서 질문을 있습니다. NIC는 패킷을 넣을 RX queue 을 어떻게 결정합니까?

그림. NIC 와 multi-queue


단일 연결 내에서 패킷 순서를 변경할 수 있으므로 라운드-로빈 밸런싱은 적절하지 않습니다.
대안은 패킷의 해시를 사용하여 RX queue 번호를 결정하는 것입니다.
해시는 일반적으로 튜플(src IP, dst IP, src 포트, dst 포트)에서 계산됩니다.
이렇게 하면 단일 흐름에 대한 패킷이 항상 정확히 동일한 RX queue 에 있게 되고,
단일 흐름 내에서 패킷 순서를 변경할 수 없습니다.

이 예에서 해시는 다음과 같이 사용될 수 있습니다:

RX_queue_number = hash('192.168.254.30', '192.168.254.1', 65400, 4321) % number_of_queues




multi-queue 해싱 알고리즘

해시 알고리즘은 ethtool 에서 설정할 수 있습니다. 우리의 설정은 다음과 같습니다:

receiver$ ethtool -n eth2 rx-flow-hash udp4
UDP over IPV4 flows use these fields for computing Hash flow key:
IP SA
IP DA


이는 IPv4 UDP 패킷의 경우 NIC가 (src IP, dst IP) 주소들을 해시합니다. 즉:

RX_queue_number = hash('192.168.254.30', '192.168.254.1') % number_of_queues


이는 포트 번호가 없어서 제한적입니다.
많은 NIC에서 해시를 커스터마이징할 수 있습니다.
다시 말하지만, ethtool 을 사용하여 튜플 (src IP, dst IP, src 포트, dst 포트)을 해싱을
선택할 수 있습니다:

receiver$ ethtool -N eth2 rx-flow-hash udp4 sdfn
Cannot change RX network flow hashing options: Operation not supported

안타깝게도 NIC가 지원하지 않아서 (src IP, dst IP) 해싱을 사용합니다.




NUMA 성능에 대한 노트

지금까지 모든 패킷은 하나의 RX queue 로만 흐르고 하나의 CPU에만 전달되었습니다.
이것을 다른 CPU의 성능을 벤치마킹하는 기회로 사용하겠습니다.
우리의 설정에서 receiver 호스트에는 두 개의 개별 프로세서 뱅크가 있으며 각각은
다른 NUMA 노드 입니다.

단일 스레드 receiver 를 설정에서 4 개의 CPU 중 하나에 고정할 수 있습니다.
다음 네 가지 옵션이 있습니다:

  1. 다른 CPU에서 receiver 를 실행하되 RX queue 와 동일한 NUMA 노드에서 실행합니다.
    위에서 본 성능은 약 360kpps 입니다.

  2. receiver 가 RX queue 와 정확히 동일한 CPU에 있으면 최대 430kpps 가 나옵니다.
    그러나 가변성이 매우 높습니다. NIC에 패킷이 너무 많으면 성능이 0으로 떨어집니다.

  3. receiver 가 RX queue 을 처리하는 CPU 의 HT 반대편에서 실행될 때
    성능은 일반적인 수치의 절반인 약 200kpps 입니다.

  4. receiver 가 다른 NUMA 노드의 CPU에 있는 RX queue 를 사용하면 ~330k pps 가 나옵니다.
    지표가 너무 일관성이 없습니다.


다른 NUMA 노드에서 실행하면 10%의 성능 저하가 크게 나쁘지 않을 수 있지만,
문제는 규모가 커지면서 성능이 더 나빠집니다.
몇몇 테스트에서 코어당 겨우 250kpps 를 짜낼 수 있었습니다.
모든 cross-NUMA 테스트에서 가변성이 좋지 않았습니다.
NUMA 노드 전체의 성능 저하는 처리량이 높을 수록 더 두드러집니다.
한 테스트에서 나쁜 NUMA 노드에서 receiver는 4배의 성능 저하가 발생했습니다.




3. 여러 수신 IP

NIC의 해싱 알고리즘은 매우 제한적이므로 여러 RX queue 에 패킷을 전달하는 유일한 방법은
많은 IP 주소를 사용하는 것입니다.
다른 대상 IP로 패킷을 보내는 방법은 다음과 같습니다.

sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321


ethtool 로 패킷이 별개의 RX queue 로 이동하는지 확인할 수 있습니다.

receiver$ watch 'sudo ethtool -S eth2 |grep rx'
     rx-0.rx_packets:     8.0/s
     rx-1.rx_packets:     0.0/s
     rx-2.rx_packets:     0.0/s
     rx-3.rx_packets:  355.2k/s
     rx-4.rx_packets:     0.5/s
     rx-5.rx_packets:  297.0k/s
     rx-6.rx_packets:     0.0/s
     rx-7.rx_packets:     0.5/s
     rx-8.rx_packets:     0.0/s
     rx-9.rx_packets:     0.0/s
     rx-10.rx_packets:    0.0/s


수신 부분:

receiver$ taskset -c 1 ./udpreceiver1 0.0.0.0:4321
  0.609M pps  18.599MiB / 156.019Mb
  0.657M pps  20.039MiB / 168.102Mb
  0.649M pps  19.803MiB / 166.120Mb


만세! 두 개의 코어가 RX queue 를 처리하고 실행하면 ~650k pps 를 얻을 수 있습니다!

트래픽을 3~4 개의 RX queue 로 전송하여 이 수치를 더 늘릴 수 있지만,
곧 애플리케이션이 또 다른 제한에 이르게 됩니다.
이번에는 rx_nodesc_drop_cnt 는 증가하지 않지만 netstat 에서 “receiver errors” 를
확인할 수 있습니다.

receiver$ watch 'netstat -s --udp'
Udp:
      437.0k/s packets received
        0.0/s packets to unknown port received.
      386.9k/s packet receive errors
        0.0/s packets sent
    RcvbufErrors:  123.8k/s
    SndbufErrors: 0
    InCsumErrors: 0

이는 NIC가 패킷을 커널에 전달할 수 있지만 커널은 응용프로그램에 전달할 수 없다는
의미입니다.
우리의 경우에는 440kpps 만 전달할 수 있지만 나머지 390kpps + 123kpps
애플리케이션이 빠르게 수신하지 못하기 때문에 드롭됩니다.




4. 많은 스레드에서 수신

receiver 애플리케이션을 확장해야합니다.
순진한 접근 방식은 많은 스레드에서 제대로 수신하지 못합니다:

sender$ taskset -c 1,2 ./udpsender 192.168.254.1:4321 192.168.254.2:4321
receiver$ taskset -c 1,2 ./udpreceiver1 0.0.0.0:4321 2
  0.495M pps  15.108MiB / 126.733Mb
  0.480M pps  14.636MiB / 122.775Mb
  0.461M pps  14.071MiB / 118.038Mb
  0.486M pps  14.820MiB / 124.322Mb

단일 스레드 프로그램에 비해 수신 성능이 저하되는데, 이는 UDP 수신 버퍼의
잠금 경합(Lock Contention)으로 발생합니다.
두 스레드 모두 동일한 소켓 디스크립터를 사용하기 때문에 UDP 수신 버퍼의
잠금을 위해 싸우는 데 불균형한(Disproportionate) 시간을 소비합니다.
Analysis of Linux UDP Sockets Concurrent Performance 에서 이 문제를 자세히 설명합니다.

단일 디스크립터에서 수신하기 위해 많은 스레드를 사용하는 것이 최적은 아닙니다.




5. SO_REUSEPORT

다행히 최근에 Linux에 추가된 해결 방법인 SO_REUSEPORT 플래그가 있습니다.
이 플래그가 소켓 디스크립터에 설정되면 Linux는 많은 프로세스가 동일한 포트에
바인딩되는 것을 허용합니다.
사실, 임의의 수의 프로세스가 바인딩될 수 있으며 로드가 프로세스 전체에 분산됩니다.

SO_REUSEPORT 에서 프로세스들은 각각의 소켓 디스크립터가 있어야 합니다.
따라서 각각 전용 UDP 수신 버퍼를 소유하게 됩니다.
이렇게 하면 이전에 발생한 경합 문제를 방지할 수 있습니다:

receiver$ taskset -c 1,2,3,4 ./udpreceiver1 0.0.0.0:4321 4 1
  1.114M pps  34.007MiB / 285.271Mb
  1.147M pps  34.990MiB / 293.518Mb
  1.126M pps  34.374MiB / 288.354Mb


더 좋아졌습니다! 이제 처리량은 충분합니다.

더 많이 조사하면 개선의 여지가 더 많이 드러날 것입니다.
4 개의 수신 스레드를 시작했지만 부하가 스레드 전체에 균등하게 분산되지 않았습니다:

그림. 여러 수신 스레드


두 개의 스레드는 모든 작업을 수신했고 다른 두 개의 스레드는 패킷을 전혀받지 못했습니다.
이것은 해싱 충돌때문인데, 이번에는 SO_REUSEPORT 레이어에서 발생했습니다.




마무리

몇 가지 추가 테스트를 수행했으며 단일 NUMA 노드에서 완벽하게 정렬된 RX queue
receiver 스레드를 사용하여 1.4Mpps 를 얻을 수 있었습니다.
다른 NUMA 노드에서 receiver 를 실행할 때는 최대 1Mpps 로 감소했습니다.

정리하면, 완벽한 성능을 위해 필요한 것들은:

  • 트래픽이 여러 RX queueSO_REUSEPORT 프로세스에 균등하게 분산되어야 합니다.
    실제로 많은 연결(또는 흐름)이 있으면 일반적으로 부하가 잘 분산됩니다.

  • 커널에서 실제로 패킷을 선택하려면 충분한 여유 CPU 용량이 있어야 합니다.

  • 어렵지만, RX queue 와 receiver 프로세스가 모두 단일 NUMA 노드에 있어야 합니다.


Linux 시스템에서 1Mpps 를 수신하는 것이 기술적으로 가능하다는 것을 보여 주었지만
애플리케이션은 수신된 패킷을 실제로 처리하지 못했습니다 - 트래픽의 내용도 보지 않았습니다.
실제 응용 프로그램에서 더 많은 작업을 처리하지 못하면 이와 같은 성능을 기대할 수 없습니다.

댓글남기기