epoll·sendfile·False Sharing·TCP BBR: 병목이 다른 성능 최적화

반응형

epoll, sendfile, false sharing 완화, TCP BBR은 모두 고성능 서버 글에서 자주 만난다. 그렇다고 하나의 “빠르게 만드는 기술 묶음”은 아니다. 각각 다른 층의 비용을 줄인다.

기술 주로 다루는 병목 먼저 볼 지표
epoll 많은 file descriptor의 readiness 대기 active connection, ready event 수, event-loop 지연
sendfile·copy avoidance file data를 socket으로 옮기는 CPU·memory copy CPU, throughput, syscall, TLS 경로
false sharing 완화 여러 core 사이 cache-line coherence traffic perf c2c, cache miss, lock·field 접근 패턴
TCP BBR sender의 congestion control RTT, retransmission, delivery rate, queueing delay

한 서버에 네 기술을 모두 적용한다고 네 배 빨라지지 않는다. 병목이 어느 층에 있는지 확인한 뒤 하나씩 검증해야 한다.

epoll은 무엇을 줄이는가

epoll은 많은 file descriptor 가운데 지금 I/O가 가능한 대상을 알려 주는 Linux API다. kernel 내부의 epoll instance에는 관심 대상을 담는 interest list와 준비된 대상을 담는 ready list가 있다.

epoll_ctl() ─> interest list
I/O activity ─> ready list
epoll_wait() ─> ready events 반환

이 구조 덕분에 매번 모든 descriptor를 선형으로 훑는 방식보다 큰 connection set에 잘 확장된다. 그러나 “connection 수와 무관하게 모든 작업이 항상 O(1)”이라고 말하면 과장이다.

  • epoll_wait() 비용은 반환하는 ready event 수의 영향을 받는다.
  • 등록·수정·삭제에는 epoll_ctl()과 kernel bookkeeping 비용이 있다.
  • connection churn, event buffer, application handler 시간이 남는다.
  • 한 descriptor를 오래 처리하면 다른 descriptor가 굶을 수 있다.

따라서 watched descriptor 수와 ready descriptor 수를 나눠 봐야 한다.

Level Triggered와 Edge Triggered

기본인 Level Triggered, LT는 읽을 data가 남아 있는 동안 다시 ready로 알려 준다. Edge Triggered, ET는 상태 변화의 edge를 알려 주므로 event 수를 줄일 수 있지만 처리 규칙이 더 엄격하다.

ET의 기본 원칙은 두 가지다.

  1. descriptor를 nonblocking으로 둔다.
  2. read()write()EAGAIN을 반환할 때까지 처리한다.
event received
  ├─ read succeeds: bounded work 뒤 계속 읽기
  ├─ EAGAIN: 현재 input을 비웠으므로 event loop로 복귀
  ├─ 0: peer가 connection 종료
  └─ other error: errno에 맞춰 정리

ET에서 일부만 읽고 다시 epoll_wait()로 들어가면 이미 buffer에 남은 data를 놓친 채 기다릴 수 있다. 반대로 한 connection을 끝없이 drain하면 event loop가 불공정해질 수 있다. 실제 구현에는 per-event 처리량 제한이나 application-level ready queue가 필요할 수 있다.

connection 수가 적거나 코드 단순성이 더 중요하면 LT가 충분히 좋은 선택이다. ET는 기본 정답이 아니라 측정으로 필요성이 확인된 선택이다.

sendfile은 무엇을 없애는가

일반적인 file 전송은 application이 read()로 data를 user buffer에 받은 뒤 write()로 socket에 보낸다.

file ─> kernel page cache ─> user buffer ─> socket path

sendfile()은 input file descriptor에서 output descriptor로 data를 kernel 안에서 전달해 user-space buffer 왕복을 피할 수 있다. 그래서 CPU 사용과 memory bandwidth, mode transition 수를 줄일 가능성이 있다.

다만 “zero-copy이므로 복사가 정확히 0회”라고 단정하면 안 된다.

  • filesystem과 kernel version에 따라 경로가 다르다.
  • NIC의 scatter-gather, checksum, DMA 같은 offload 지원이 영향을 준다.
  • user-space TLS가 payload를 암호화해야 하면 direct path를 그대로 쓰기 어렵다.
  • kernel TLS나 다른 offload를 쓰더라도 환경별 검증이 필요하다.
  • 성공한 sendfile()도 요청한 byte보다 적게 전송할 수 있다.

그리고 user mode와 kernel mode를 오간 일을 모두 “context switch”라고 부르지 않는 편이 정확하다. system call로 privilege mode가 바뀌는 것과 scheduler가 실행 중인 thread를 교체하는 context switch는 다른 사건이다.

MSG_ZEROCOPY도 공짜가 아니다

MSG_ZEROCOPY는 일반 socket send path에서 copy avoidance를 요청할 수 있게 한다. 하지만 이는 보장이 아니라 hint이며, application이 completion notification을 socket error queue에서 회수해야 한다.

page pinning, accounting, completion 처리 비용이 생기므로 작은 write에서는 오히려 불리할 수 있다. Linux kernel 문서도 구현상 대략 10KB보다 큰 write에서 주로 효과가 나타난다고 설명하지만, 이 숫자를 모든 hardware의 threshold로 고정하면 안 된다.

또한 completion은 “network 전송 완료”가 아니라 application이 buffer를 다시 수정해도 되는 시점을 알리는 의미다. 기존 send call에 flag 하나를 붙이는 수준으로 끝나는 API가 아니다.

False Sharing은 field가 아니라 cache line의 문제다

서로 다른 thread가 서로 다른 변수를 수정해도 두 변수가 같은 cache line에 있으면 coherence traffic이 늘 수 있다. 이것이 false sharing이다.

same cache line
┌────────────────────────────────────────────┐
│ counter_A (CPU 0 writes) │ counter_B (CPU 1 writes) │
└────────────────────────────────────────────┘

cache line 크기는 platform에 따라 다르며 흔히 64 byte인 시스템이 많다는 정도로 이해해야 한다. 무조건 padding을 추가하는 것도 답은 아니다. object 크기가 커져 cache locality가 나빠지고 memory 사용량이 늘 수 있다.

Linux에서는 workload를 재현하면서 다음처럼 cache-to-cache traffic의 후보를 살펴볼 수 있다.

perf c2c record -- ./server-under-test
perf c2c report

이 명령은 성능 측정을 수행하므로 production에서 무심코 실행하기보다 재현 환경과 overhead를 먼저 확인해야 한다. 결과에서 특정 cache line과 write contention이 확인됐을 때 field 분리, per-CPU data, sharding, padding 같은 대안을 비교한다.

BBR과 CUBIC은 승패가 정해진 두 알고리즘이 아니다

CUBIC은 Linux에서 널리 쓰이는 loss-based congestion control이다. BBR은 관측한 bottleneck bandwidth와 round-trip propagation time을 바탕으로 pacing rate와 in-flight data를 조절한다.

BBR이 bufferbloat가 큰 경로에서 throughput과 latency를 개선할 수 있지만, 모든 network에서 CUBIC보다 낫다고 보장할 수는 없다.

  • 다른 congestion-control flow와의 fairness
  • policer와 shallow buffer
  • Wi-Fi·mobile처럼 변동이 큰 link
  • kernel에 포함된 BBR version
  • qdisc, pacing과 NIC offload
  • short flow와 long flow 비율

이 조건들이 결과를 바꾼다. 먼저 현재 host가 어떤 알고리즘을 지원하고 사용하는지 읽어 본다.

sysctl net.ipv4.tcp_available_congestion_control
sysctl net.ipv4.tcp_congestion_control
sysctl net.core.default_qdisc
ss -tin

이 명령들은 상태 확인용이다. host-wide congestion control이나 qdisc를 바꾸는 일은 모든 TCP connection에 영향을 줄 수 있으므로, traffic canary와 rollback 조건 없이 즉시 변경하지 않는다.

네 기술을 같은 실험에 넣지 않는 방법

성능 실험은 한 번에 한 가설만 검증하는 편이 해석하기 쉽다.

1. workload를 고정한다

  • connection 수와 동시 ready 비율
  • request·response 크기
  • static file인지 dynamic response인지
  • TLS termination 위치
  • client RTT와 loss 조건

2. 병목 지표를 먼저 고른다

  • event loop: queueing delay, handler time, ready events/batch
  • copy path: CPU cycle, memory bandwidth, throughput
  • cache: cache-to-cache transfer, LLC miss, core별 utilization
  • network: RTT, retransmission, delivery rate, congestion window

3. 한 가지씩 바꾼다

select에서 epoll로, read/write에서 sendfile로, data layout을 분리하는 식으로 실험한다. 동시에 BBR까지 바꾸면 개선 원인을 분리하기 어렵다.

4. tail latency와 자원 비용을 함께 본다

평균 throughput만 좋아져도 p99 latency, memory 사용량, CPU per request와 fairness가 나빠질 수 있다. 효과가 workload 밖에서도 유지되는지 확인해야 한다.

정리

  • epoll은 readiness 대기 구조를 바꾸지만 application 처리 비용까지 상수로 만들지 않는다.
  • sendfile은 user-space 왕복 copy를 줄이지만 실제 copy 수는 전체 data path에 따라 달라진다.
  • false sharing은 perf c2c 같은 증거를 찾은 뒤 data layout을 바꾼다.
  • BBR은 bandwidth와 RTT model을 활용하지만 network마다 canary 검증이 필요하다.

시스템 성능의 기본 관점O(1) 연산도 느려지는 메모리 이유를 함께 보면 algorithmic complexity와 hardware cost를 분리하기 쉽다. TCP 혼잡 제어 정리는 CUBIC·BBR의 배경으로, Linux 네트워크 성능 진단은 실제 지표를 좁히는 흐름으로 이어진다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글