Hanzo 베타에 참여하고 유료 플랜을 50% 할인받으세요

GB10에서 초당 50만 요청: 병목은 상자가 아니라 네트워크였다

NVIDIA GB10 위에서 클라우드 데이터 플레인이 얼마나 빨리 달릴 수 있는지 보려 했다. 답은 우리 코드보다 NIC 수신 큐에 대해 더 많이 알려 주었고, 같은 선에서 처리량을 두 배 넘게 올렸다.

이 페이지에서

우리 클라우드는 Go 바이너리 하나 위에 있다. AI 게이트웨이, 상거래, IAM, KMS를 올리고 api.hanzo.ai를 내는 산출물 하나다. HTTP 표면은 zip, ZAP 네이티브 웹 프레임워크이고 fasthttp 위에 감싸여 있다. 그래서 단순한 질문을 던졌다. 이 물건은 실제로 얼마나 빨리 갈 수 있는가, 그리고 무엇이 멈추게 하는가.

정직한 답은 우리를 세 번 놀라게 했다. 숫자까지 포함한 수사 기록 전체다. 모두 hanzoai/benchmarks에 공개되어 있다.

첫째: 프레임워크가 세금인가

아니다. zip이 감싼 날것의 fiber/fasthttp 엔진 위에 싣는 것은 일정한 약 24나노초와 48바이트 할당 하나뿐이다. 실제 핸들러 어디에서도 1% 미만이다. 끝에서 끝까지, 루프백에서 zip은 날것의 fasthttp의 **0.95×**를 낸다. 프레임워크는 잡음이다. 좋다. 다음으로 가자.

프레임워크요청/초
날것의 fasthttp365–400k
zip (HTTP)338–348k

둘째: 분산한 결과는 더 느려졌다

여기서부터 흥미로워졌다. 직결 2.5 GbE 유선 링크에 두 대가 있다. 서버는 spark(NVIDIA GB10, 20코어), 부하 생성은 evo(32코어 x86)다. 부하를 상자 밖으로 빼면 spark의 코어가 서빙에 풀려야 했고, 숫자는 오를 것이라 예상했다.

내려갔다. 194k 요청/초. 더 세게 밀려고 연결을 더하자 더 나빠졌다. 4,000 연결에서 167k, 8,000에서 154k, p99 지연은 5 ms에서 52 ms로 올랐다. 교과서에 나오는 혼잡 붕괴다.

그런데 링크는 ~70 MB/s, 2.5 GbE의 22%에 불과했다. 대역폭이 아니다. spark의 20코어도 대부분 놀고 있었다. CPU도 아니다. 그러면 무엇인가.

범인: 수신 큐 하나

ethtool -l이 들통 냈다. NIC는 하드웨어 RX 큐 하나로 돌고 있었다. 들어오는 패킷은 모두 하나의 코어 softirq가 처리했고, 그 코어가 천장이었다. 더 나쁘게도 드라이버는 큐 추가를 단호히 거부했다(ethtool -L → "Operation not supported").

우리가 가진 가장 빠른 개발 상자가 패킷을 퍼 나르는 커널 스레드 하나에 눌려 있었고, 열아홉 코어는 지켜보고 있었다.

고침: 소프트웨어에서 패킷을 돌린다

하드웨어가 다중 큐를 거부하면 커널이 한다. 수신 패킷 스티어링은 흐름을 소프트웨어에서 코어들로 해시한다. 20코어 전부에 RPS(그리고 RFS/XPS)를 켜고, 수신 백로그를 1,000에서 300,000으로 올리고, 소켓 버퍼를 256 MiB로 두고, 성능 거버너를 고정했다. 멱등한 tune.sh 하나를 두 상자에서 돌린다.

같은 선. 같은 핸들러. 초당 518,000 요청. 2.67× 점프, 정점은 696k.

동시성조정 전조정 후
1,000194k518k
4,000154k474k (정점 696k)

핵심은 이것이다. 조정된 네트워크 숫자(518k)가 이제 루프백(365–400k)을 이긴다. 선 위에서는 spark의 코어가 부하 생성기와 나누지 않고 내내 서빙하기 때문이다. 518k에서 상자가 쉬는 시간은 약 ~12%뿐이다. 로더 하나에서 나오는 실제 천장에 가깝다.

셋째: ZAP는 더 빨라야 했다 — 그래서 빠르게 만들었다

zip은 ZAP 네이티브다. 맨 수신 주소는 우리 바이너리 전송을 말하고, http://는 HTTP를 말한다. 바이너리 전송이 이길 것이라 보고 ZAP 부하 클라이언트를 만들었다. HTTP 도구는 그것을 말하지 못한다. 그리고 재었다.

ZAP는 91k였다. HTTP는 481k였다. 네이티브 전송은 졌다. 5×나.

쫓아 보니 선이 아니었다. 코덱이었다. 지연의 꼬리가 이야기를 했다. p50은 건강한 303 µs, 그러나 p99는 13 ms, 가비지 컬렉션의 서명이다. GOGC를 100에서 800으로 늦추자 ZAP는 두 배를 넘어 203k가 되었다. 6초 실행 동안 ZAP 서버는 가비지 컬렉션을 2,203번 일으켰고, HTTP 서버는 57번이었다. 할당 지점 셋이 그것을 짊어졌다. 마셜러가 호출마다 새 프레임 버퍼를 찍는다. 헤더 디코드가 모든 문자열을 맵으로 복사한다. 그리고 큰 것, zaphttp의 서버는 요청 컨텍스트를 요청마다 새로 할당하는데, fasthttp 자신의 서버는 그것을 연결마다 풀링한다.

그래서 고쳤다. 변경(zap-proto/http, 브랜치 perf/zero-alloc-codec)은 기계적이고 구조적이지 않다. 요청 컨텍스트를 연결당 한 번 풀링하고, 풀링된 버퍼에 이어 붙이는 프레임 인코더를 손으로 쓰고, 4바이트 길이 접두를 bufio.Peek/Discard로 빼서 헤더 배열이 힙으로 도망치지 않게 한다. 서빙 루프는 그 재사용 버퍼로 AppendResponse를 써서 직렬화하고 헤더를 그 자리에서 디코드한다. 따뜻해진 연결 위에서는 요청마다 아무것도 할당하지 않는다. 골든 테스트는 전후 선이 바이트 단위로 동일함을 확인한다. 순수 코덱 재작성이지 프로토콜 변경이 아니다.

결과: ZAP는 91k → 570k 요청/초로 간다. 6.3× 상승이 HTTP의 481k를 넘긴다. 루프백에서 ZAP가 이제 1.18×로 이긴다. p99는 13 ms에서 1.19 ms로 무너지고 GC는 2,203에서 62로 떨어진다.

스스로 고쳐야 했던 것이 둘 있다. 둘 다 수정 뒤에도 남으므로 분명하게 적는다. 첫째, 승리는 CPU이지 선이 아니다. ZAP 프레임은 사실 HTTP보다 크다(/health 요청에서 124 대 75바이트). ZAP가 이기는 것은 바이트가 적어서가 아니라 바이트당 사이클을 덜 쓰기 때문이다. 둘째, zaphttp는 JSON을 건너뛰지 않는다. 두 전송 모두에서 HTTP 본문을 불투명 바이트로 나르므로, 핸들러는 어느 쪽이든 같은 마셜 비용을 낸다. 대화형 JSON 페이로드에서 ZAP는 사실 HTTP의 0.95×이고, 본문을 프레임 꼬리로 복사하는 약간의 손실이다. JSON 생략은 네이티브 ZAP 형식 RPC의 성질이고, zaphttp가 밟지 않는 다른 축이다. 여기서의 루프백 승리는 정직하고, 순전히 CPU와 할당의 승리다. 실제 2.5 GbE 선에서는? 두 전송이 ~530k에서 비긴다. 링크는 포화했고 코덱은 더 이상 병목이 아니다.

넷째: 그러면 ZAP는 정말로 JSON을 건너뛰는가

그 마지막 문장, «JSON 생략은 네이티브 ZAP의 성질이며 zaphttp는 그것을 행사하지 않는다»는 약속어음이다. 독자는 내게 그것을 현금화시켜야 한다. zaphttp는 본문을 불투명하게 나르므로, 바이너리 프로토콜이 이겨야 마땅한 것, 곧 텍스트를 파싱하지 않고 구조화 데이터를 읽는 일을 한 번도 시험하지 않는다. 그래서 그 일을 하는 시험을 만들었다.

공유 레코드 하나. eth_getBalance 형태의 호출이고, 각 방향으로 네 필드다(account, block, id → balance, nonce, blockHash). 왕복은 네 가지다. 표준 라이브러리 encoding/json, goccy/go-json과 sonic(Go에서 가장 빠른 JSON 라이브러리 둘), 그리고 zap-proto/go의 형식 있는 바이너리. 들어가는 필드는 같고, 계산은 같고, 나오는 필드는 같다. 왕복 동등 시험이 바이트 정확한 재인코딩을 강제하므로 어느 쪽도 다른 쪽이 하는 일을 건너뛸 수 없다. JSON은 표준 라이브러리가 아니라 가장 빠른 구현에 붙들어 두었다. 아무도 결과를 손사래로 치우지 못하게.

디코드는 학살이다. 네 필드 요청을 읽는다.

코덱디코드할당
JSON 표준 라이브러리2334 ns13
JSON sonic845 ns8
JSON goccy473 ns4
형식 있는 ZAP13 ns0

13나노초, 할당 제로. 가장 빠른 JSON보다 35×, 표준 라이브러리보다 174× 빠르다. 파싱이 없다. 필드는 알려진 오프셋에 있고, 그것을 읽는다. 형식 있는 바이너리 선의 제안 전체가 그것이고, 그것은 버틴다.

다만 ZAP를 치켜세우지 않는 부분도 보고하겠다고 약속했으므로:

  • 인코드는 무승부이고, 할당은 더 많다. ZAP는 629 ns에 할당 12로 직렬화한다. goccy는 426 ns / 4다. 공개된 zap-proto/go v1.3.0 빌더는 가변 길이 꼬리를 복사로 미루고 객체 빌더를 힙으로 탈출시킨다. 그것을 고치는 풀링된 고정 폭 API는 릴리스된 태그에 없다. 그래서 이것이 정직한 숫자다. 상쇄하면 서버 핸들러 전체(디코드 + 계산 + 인코드)는 ZAP를 실제로 선호한다. 274 ns 대 goccy의 431. 디코드의 승리가 인코드의 무승부를 삼키기 때문이다. 그러나 짊어진 것은 디코드다.
  • 끝에서 끝까지, 174×는 ~1.2×로 줄어든다. 실제 /rpc 엔드포인트에서 ZAP는 535k 요청/초, sonic은 483k(1.11×), goccy는 452k(1.18×), 표준 라이브러리는 407k(1.31×)다. 진짜지만 마이크로벤치 간격에는 한참 못 미친다. 초당 50만 요청에서는 소켓과 스케줄러가 그 비용이고, 직렬화는 얇은 조각이다. 더 빨라진 것을 알아차리기 훨씬 전에 코덱은 병목이기를 멈춘다.

ZAP 편으로 베는 놀라움이 하나 있다. 실제 구조화 레코드에서 그 선은 더 크지 않고 더 작다. 162 대 233바이트. JSON이 0x 십육진 문자열, 십진 정수, 따옴표, 필드 이름에 바이트를 쓰기 때문이다. 프레이밍 시험의 «ZAP가 더 크다»는 단서는 사소한 /health 크기 페이로드에만 성립한다. 진짜 형식 필드를 주면 바이너리는 크기에서도 이긴다.

그래서 솔직히 말한다. ZAP는 JSON을 건너뛰는가. 그렇다. 그리고 읽기 경로에서는 차이가 가깝지 않다. 그것을 느끼는가는 직렬화가 병목인가에 달렸다. 작은 레코드의 500k rps에서는 아니다. 두꺼운 중첩 페이로드나 디코드가 무거운 팬아웃으로 바꾸면, 그 174×가 당신이 사는 곳에서 나타나기 시작한다.

우리가 배운 것

  • 경로 전체를 재고, 도구를 의심하라. hey는 우리를 ~100k에서 막았다. 그것은 클라이언트였지 서버가 아니었다. bombardier는 4× 더 많은 것을 드러냈다.
  • 상자가 첫 병목인 경우는 드물다. NIC RX 큐 하나가 애플리케이션 코드 한 줄을 건드리기 전에 2.67×를 가져갔다.
  • 처리량만이 아니라 할당을 재라. ZAP가 «느린» 것은 코덱 문제임이 분명해지기까지 GOGC 플래그 하나였다. 컨텍스트를 풀링하고 할당을 0으로 만들자 네이티브 전송이 HTTP를 앞질렀다.
  • 마이크로벤치와 부하 시험은 다른 질문에 답한다. 형식 있는 디코드는 단독으로 JSON보다 174× 빠르고 끝에서 끝까지는 1.2× 빠르다. 두 숫자 모두 참이며, 하나만 인용하는 것은 생략에 의한 거짓말이다.
  • 공정하고 실물로 보고하라. 같은 핸들러, 따뜻해진 연결, 골라 잡은 GOGC는 없다. JSON은 가장 느린 라이브러리가 아니라 가장 빠른 것에 고정하고, 스크립트를 공개한다. 정직한 숫자가 패배였을 때, 인코드 무승부와 압축된 끝에서 끝까지의 차이도, 패배째로 실었다.

할당이 없는 ZAP 코덱은 끝났고, 이긴다. 남은 정지는 물리적이다. 듀얼 NIC 로더(일곱 자리로 가는 병렬 수신 경로), 더 빠른 상호 연결(10 GbE / RDMA), 큰 페이로드에서 ZAP 프레임 꼬리가 본문 복사 한 번을 건너뛰게 하는 writev 분할, 그리고 Go 1.26이 네이티브로 주는 포스트퀀텀 전송, PQ-TLS 1.3, ZAP-over-PQ-TLS, PQ-QUIC. 거기서는 ML-KEM 핸드셰이크 비용을 정상 상태 처리량에서 갈라낼 것이다.

필요한 만큼 멀리 가져갈 수 있다.

댓글

댓글을 달려면 Hanzo ID로 로그인하세요.

댓글 불러오는 중…

더 읽기