콘텐츠로 이동

Messaging local bench 규격

이 문서는 로컬 개발 머신에서 gRPC, ZLink raw binding, ZLink framework의 상대 비용을 같은 형식으로 비교하기 위한 기준이다. 측정 대상은 server process A가 server process B에 보내는 메시징(server-to-server)이고, 부하는 A가 HTTP trigger를 받은 뒤 자기 안에서 만든다(§10). HTTP 호출은 시작 신호이며 측정 operation이 아니다. 대상 언어는 dotnet, node, java, kotlin, cpp 다섯 개이고, 여기에 C로 작성한 기준 bench를 바닥 값으로 함께 사용한다. 운영 환경의 mesh, TLS, L7 load balancer, multi-node 분배, 네트워크 지연을 대표하지 않는다.

이 bench는 서비스 측면 비교다. 같은 업무를 각 스택으로 구현했을 때 실제로 치르는 비용을 측정한다. 두 스택이 내부에서 같은 메커니즘을 사용하는지는 묻지 않는다. 한쪽 스택에만 있는 기능 때문에 생기는 차이는 감추지 않고 결과에 그대로 남긴다. 이 문서가 답하는 질문은 "같은 업무를 gRPC로 구현하면 이 비용, ZLink로 구현하면 이 비용"이다. "두 스택이 같은 방식으로 전송했을 때 어느 쪽이 빠른가"는 이 bench의 질문이 아니다.

1. 비교 대상

1.1 구현 이름

언어 하나마다 세 구현을 비교한다. <lang>dotnet, node, java, kotlin, cpp 중 하나다. report 표와 RESULT 라인에서도 같은 이름을 사용한다.

구현 이름 의미
grpc-<lang> 해당 언어의 gRPC unary RPC
zlink-<lang> framework를 거치지 않는 raw binding의 ROUTER↔ROUTER TCP 경로
zlink-framework-<lang> framework RouteMesh의 RID 직접 node request/send

기본 실행 순서는 grpc-<lang>, zlink-<lang>, zlink-framework-<lang> 순서다. 같은 payload 크기와 같은 active duration에서 세 구현을 같은 패턴으로 실행하므로, 표에서는 한 패턴 아래에 세 구현이 나란히 출력된다.

1.2 C 기준 bench

framework/bench/grpc/cgrpc-czlink-c를 바닥 기준으로 함께 유지한다. framework 계층은 C에 없으므로 C에는 이 두 구현만 존재한다. C 기준 bench는 §10의 server-driven 모델로 바꾸지 않고 client-driven 그대로 둔다. 기준값으로 쓰는 것은 요청당 비용이지 부하 생성 위치가 아니기 때문이다. 이 차이는 §7.2의 분모를 읽을 때 함께 적는다. zlink-crequest-backpressure 값은 각 언어 raw binding의 위치를 판정하는 기준값이다(§7.2).

ZLink 쪽 두 행은 모두 ROUTER↔ROUTER, 상대 node를 RID로 직접 지정한다. 이 조건은 선택 사항이 아니라 판정식이 성립하기 위한 계약이다. 셀마다 server(B)는 하나다.

소켓 구성
zlink-framework-<lang> RouteMesh의 node 직접 호출 requestToNode / sendToNode로 server node의 RID를 지정한다. RouteMesh는 ROUTER↔ROUTER로 연결한다
zlink-<lang> raw binding의 ROUTER↔ROUTER. client 쪽도 ROUTER를 만들고 상대 ROUTER의 routing id를 지정해 request·send 한다
zlink-c 위 raw binding 행과 같은 ROUTER↔ROUTER 구성
%%{init: {'flowchart': {'nodeSpacing': 32, 'rankSpacing': 40, 'padding': 8, 'wrappingWidth': 180}, 'themeVariables': {'fontSize': '18px'}}}%%
flowchart LR
    FC[framework RouteMesh ToNode client] <--> FS[framework node handler server]
    RC[raw binding ROUTER] <--> RS[raw binding ROUTER]

이 계약이 필요한 이유는 §7.2의 판정식 때문이다. zlink-framework-<lang> / zlink-<lang>는 framework 계층이 추가로 요구하는 비용을 보기 위한 비율이다. 두 행이 같은 소켓 패턴과 같은 대상 지정 방식을 사용할 때에만 이 비율이 framework 계층만 남긴 값이 된다. raw 행이 DEALER→ROUTER 이거나 framework 행이 channel 이름으로 보내는 requestToChannel/sendToChannel(node 선택이 들어간다)이면 나눗셈 결과에 framework 계층 비용과 소켓 패턴·대상 선택 차이가 함께 들어가고, 그 값은 framework 계층 비용이 아니다. gRPC 행은 채널 하나가 server 하나에 1:1로 붙으므로 위 구성이 그것과 같은 모양이다.

ZLink 모델 사이의 비교 — DEALER→ROUTER 대 ROUTER↔ROUTER, ToChannel(node 선택 포함) 대 ToNode, ClientServer 대 RouteMesh — 는 이 bench의 항목이 아니며 별도의 ZLink 모델 비교 bench에서 다룬다. 이 bench는 gRPC와 같은 모양의 1:1 요청 경로만 비교한다.

현재 구현 상태를 함께 적는다. Java와 .NET·C++·Node framework 행은 이 구성으로 전환했다(Issue

13). raw 경로와 C 기준 bench client의 전환은 별도 작업 범위다. 위 표는 측정에 사용할 구성을

규정한 것이며, 전환이 끝나기 전에 얻은 값은 이 규격을 만족하는 값이 아니다.

2. 측정 패턴

처음 범위는 두 payload 크기와 세 패턴만 사용한다. payload 크기는 1024, 4096 bytes를 기본값으로 한다. payload 크기는 protobuf bytes body 또는 raw ZLink message body의 전체 크기다. 앞 29 bytes는 측정 header로 사용하고, 나머지를 business payload 영역으로 채운다. gRPC HTTP/2 frame, protobuf field overhead, ZLink envelope, ZMP header는 이 크기에 포함하지 않는다.

패턴 이름 gRPC ZLink raw binding ZLink framework 해석
request-serial unary Echo RPC raw request 전송과 reply 수신 node request 호출 요청 하나를 보내고 reply 완료 뒤 다음 요청을 보낸다
request-backpressure unary Echo RPC raw request 전송과 reply 수신 node request 호출 미완료 request 수에 상한을 두지 않는다. admission backpressure를 만날 때까지 연속 제출한다
send-saturation unary Command RPC, 응답은 Empty raw 단방향 send 제출 node send 제출 reply payload가 없는 command 경로를 비교한다

세 구현이 사용하는 API 이름은 언어마다 다르지만 계약은 같다. Java에서 framework node request는 requestToNode(mesh, rid, message)이고 node send는 sendToNode(mesh, rid, message)다(§1.3). 언어별 모듈은 §8에 둔다.

request-serial은 한 요청의 왕복 지연이 처리량을 결정한다. 이 값은 "한 번에 하나만 처리하는" 사용 패턴의 비용을 보기 위한 값이다.

request-backpressure는 미완료 request 수에 application 상한을 두지 않는다. client는 reply를 기다리지 않고 admission backpressure를 만날 때까지 request를 연속 제출하고, 재개 신호에서 이어서 제출한다. gRPC 쪽도 같은 모양으로 자기 flow control이 제출을 막을 때까지 제출한다. 어느 쪽에도 application 상한을 두지 않는다.

이 패턴에서 미완료 깊이는 설정값이 아니라 측정값이다. 스택이 도달한 깊이는 그 스택의 설계가 정하는 결과이므로 결과의 일부로 기록한다(§5.2).

이 loop 모양은 새로 만든 것이 아니라 저장소에 이미 있는 구현을 옮긴 것이다. 기준 구현은 bindings/node/perf/multi/perf_multi_socket_reqrep.ts이며, reply를 기다리지 않고 async request를 연속 제출한 뒤 completion pump에 turn을 넘긴다. 여섯 행이 모두 같은 모양을 사용하고, gRPC 쪽도 같게 unary 호출을 하나씩 기다리지 않고 연속 제출한다.

이 저장소의 perf 규약은 이미 inflight 깊이를 인위적으로 고정하지 않고 admission backpressure를 만날 때까지 연속 제출하도록 정하고 있다(doc/perf/PERF_MULTI_TEST_POLICY.md의 request/reply client 항목, doc/perf/PERF_POLICY.md의 app 고정 window 항목). 고정 window 패턴(request-window)은 그 규약과 어긋나므로 이 bench에서 제외했다. 고정 깊이에서의 동작은 ZLink 모델 비교 bench의 항목이다.

send-saturation은 request/reply와 섞어 평균 내지 않는다. 이 패턴은 command 계열의 상대 비용을 따로 보기 위한 항목이다.

2.1 send-saturation의 gRPC 대응물

send-saturation의 gRPC 쪽은 현행 unary Command(BenchPayload) returns (Empty)를 그대로 사용한다. client-streaming RPC를 추가하지 않고, proto에 RPC를 추가하지 않는다. 이 선택이 이 bench에서 옳은 비교인 이유는 아래와 같다.

  • 실제 서비스에서 응답이 필요 없는 호출은 unary 호출과 Empty 응답으로 구현한다. client-streaming은 업로드와 대량 적재를 위한 형태이며, 서비스가 응답 없는 명령 하나를 보내는 방식이 아니다.
  • gRPC에는 단방향 호출 원시 기능이 없다. 그래서 이 업무를 처리할 때 왕복을 한 번 치른다. 서비스 측면 비교에서 그 비용은 결과에 포함되어야 하는 값이다.
  • 비교를 맞추려고 gRPC 쪽에 다른 사용 형태를 끼워 넣으면, 실제 서비스가 쓰지 않는 구현을 측정하게 된다.

이 셀의 서술 규칙을 함께 정한다. 이 셀을 전송 속도 차이로 서술하지 않는다. 올바른 문장 형태는 아래와 같다.

응답이 필요 없는 명령을 처리할 때, gRPC는 unary 왕복을 치러야 하고 ZLink는 단방향 send로
끝난다. 이 조건에서 그 차이는 N배로 관찰됐다.

3. 실행 조건

  • 구현(grpc-<lang>, zlink-<lang>, zlink-framework-<lang>)마다 source process Atarget process B를 각 1개 띄운다. A는 HTTP trigger listener와 stats endpoint를 갖고 B로 향하는 client(gRPC stub, raw ROUTER, framework node client)를 품는다. B는 echo(request) 또는 수신 집계(send)와 stats endpoint를 갖는다. 역할·trigger 계약·셀 순서는 §10이 정한다.
  • 로컬 runner는 셀마다 B → A 순서로 띄우고, A의 trigger endpoint에 HTTP로 phase 시작을 알린다. runner 자체는 부하를 만들지 않는다.
  • loopback 주소(127.0.0.1)만 사용한다. 포트는 §9의 언어별 대역을 사용한다.
  • Release build로 실행한다.
  • warmup 뒤 정해진 시간의 measured active 구간을 실행한다. warmup 길이는 언어마다 다르게 두고 사용한 값을 결과에 기록한다(§8.2).
  • 기본 payload 크기는 1024,4096 bytes다.
  • request-backpressure 패턴에는 미완료 request 상한 설정이 없다. 이 패턴에서 깊이는 설정하는 조건이 아니라 측정해 기록하는 결과다(§5.2). request_window 설정은 이 bench에 없다.
  • 기본 send concurrency는 8이다.
  • gRPC와 ZLink framework는 같은 protobuf DTO를 사용한다. ZLink raw binding은 framework를 거치지 않을 뿐 wire 모양은 같다. envelope 헤더 part 하나와 protobuf로 인코딩한 BenchPayload part 하나, 모두 두 part로 보낸다. 측정 header 29 bytes는 그 protobuf bytes body 안에 들어간다. 이 모양은 선택이 아니라 판정식의 전제다. §7.2 formula 1이 zlink-<lang>zlink-c로 나누므로 두 행의 wire 모양이 다르면 서로 다른 실험을 나눈 값이 된다. 기준 구현은 framework/bench/grpc/cbench_zlink_client.cpp:14-16:130-140이다.
  • ZLink는 location store 없이 manual endpoint 연결을 사용한다.
  • ZLink 쪽 두 행은 §1.3의 ROUTER↔ROUTER 구성을 사용한다.
  • ZLink raw binding의 request echo endpoint와 command 수신 endpoint는 분리한다. command 측정에서 reply 없는 단방향 수신량을 보려면 request echo reply가 같은 socket에 섞이면 안 되기 때문이다.
  • TLS, compression, service mesh, gateway, broker는 사용하지 않는다.
  • 언어를 동시에 측정하지 않는다. 한 번에 한 언어만 측정한다.

ZLink 두 행의 endpoint 개수는 서로 다르다. raw binding 행은 request echo endpoint와 command endpoint를 분리해 두 개를 사용하고, framework 행은 request 처리기와 send 처리기를 함께 두는 RouteMesh 연결 하나를 사용한다. 이 차이는 의도한 구성이며 어떤 셀도 오염하지 않는다. 패턴을 한 번에 하나씩 측정하기 때문이다. send-saturation 셀을 측정하는 동안에는 두 행 모두 send 트래픽만 발생하고, request 계열 셀을 측정하는 동안에는 두 행 모두 request 트래픽만 발생한다.

이 근거는 한 번에 한 패턴만 측정한다는 전제에 의존한다. 두 패턴을 동시에 실행하는 시나리오를 추가하면 이 전제가 성립하지 않으므로, 그때는 이 endpoint 구성을 다시 검토해야 한다.

셀 사이의 settle은 다음 계약을 따른다. 한 셀을 끝낸 뒤에는 고정 시간을 기다리지 않는다. server가 받은 수를 폴링해 그 값이 더는 증가하지 않을 때까지 기다리고, 이 대기에는 상한을 둔다. 상한 안에 값이 멈추지 않으면 그 사실과 관측한 drain 시간을 결과에 기록하고, 같은 server를 쓰는 다음 셀을 오염된 것으로 표시해 표와 판정에서 제외한다. 오염된 셀은 측정해서 싣지 않는다. 관측한 drain 시간은 셀마다 결과에 기록한다. 이 상한은 형식적인 값이 아니라 측정을 좌우하는 값이다. 상한을 작게 잡으면 정상적인 실행에서도 셀을 잃게 되므로, 건강한 실행이 상한에 닿지 않을 만큼 충분히 크게 잡는다. 기준값은 30초다.

4. 출력 형식

report 표는 패턴별로 묶고, 각 행에 구현 이름을 표시한다. 예시는 아래 형식이다.

  > Benchmarking current for request-backpressure...
    Testing local:
      | Implementation          | Size     |       Throughput |    Bandwidth |  Lat.Mean(ms) |   Lat.P95(ms) |   Lat.P99(ms) | Client CPU | Client Mem | Server CPU | Server Mem |
      |-------------------------|----------|------------------|--------------|--------------|--------------|--------------|------------|------------|------------|------------|
      | grpc-dotnet             | 1024B    |       10.00 KOPS |   10.24 MB/s |     1.000 ms |     2.000 ms |     3.000 ms |      10.0% |  100.0 MB |      10.0% |  100.0 MB |
      | zlink-dotnet            | 1024B    |       30.00 KOPS |   30.72 MB/s |     0.300 ms |     0.600 ms |     0.900 ms |      10.0% |  100.0 MB |      10.0% |  100.0 MB |
      | zlink-framework-dotnet  | 1024B    |       20.00 KOPS |   20.48 MB/s |     0.500 ms |     0.900 ms |     1.200 ms |      10.0% |  100.0 MB |      10.0% |  100.0 MB |

보고서와 콘솔 출력은 perf runner에서 다루기 쉽게 metric별 RESULT,current,... 형식도 같이 남긴다. 한 행의 필드는 아래 순서다.

RESULT,current,<scenario>,local,<payload_size>,<metric>,<value>

예시는 아래와 같다.

RESULT,current,grpc-dotnet-request-backpressure,local,1024,throughput,10000.000
RESULT,current,zlink-dotnet-request-backpressure,local,1024,latency,0.300
RESULT,current,zlink-framework-dotnet-request-backpressure,local,1024,latency_p99,1.200

<scenario><구현 이름>-<패턴 이름>이므로 언어가 이름에 포함된다. 예를 들어 Node의 같은 셀은 zlink-framework-node-request-backpressure다.

metric 값은 throughput, bandwidth, latency, latency_p95, latency_p99, client_cpu_percent, client_memory_mb, server_cpu_percent, server_memory_mb를 사용한다. throughput의 raw 값은 초당 완료 수이며, 표에서는 KOPS 또는 KMSG/s로 나누어 표시한다.

server-driven 모델(§10)에서 client_*source process A, server_*target process B의 값이다. metric 이름은 집계기·과거 원본과의 호환을 위해 그대로 두고, 표 머리에는 Source CPU· Target CPU로 표시한다. 셀 원본 JSON에는 다음이 추가로 들어간다.

필드 의미
role source 또는 target. A와 B가 각자 원본을 쓰고 runner가 셀 하나로 합친다
trigger A가 받은 trigger 요청을 그대로 둔 객체. 필드는 runId, cellId, pattern, payloadBytes, durationMs, warmup(warmup 길이; 언어 harness의 단위를 §8.2대로 기록), endpoint(A의 trigger URL, §7.1의 동반 정보), receivedAtUnixMs(A가 trigger를 받은 wall-clock 시각). 여덟 필드 모두 필수이며 집계기는 별칭이나 기본값을 두지 않는다
streams A의 logical stream 수와 stream당 in-flight 상한(§10.3)
target_stats settle 뒤 runner가 B의 stats endpoint에서 읽은 수신 수·오류 수·drain 시간

5. 메트릭

필수 출력은 아래 메트릭이다. 처리량 단위는 패턴 성격에 맞춰 분리한다.

메트릭 의미
Throughput measured 구간 처리량. 표에서는 10.000 KOPS, 183.618 KMSG/s처럼 값과 단위를 한 칸에 함께 표시
Bandwidth payload 크기와 처리량으로 계산한 전송량. MB/s로 표시
Lat.Mean(ms) 평균 latency. request/reply는 A의 outbound call 직전부터 reply 완료까지, send는 B가 header로 계산한 수신 latency
Lat.P95(ms) p95 latency
Lat.P99(ms) p99 latency
Source CPU (client_cpu_percent) source process A가 active 구간 동안 사용한 CPU 비율
Source Mem (client_memory_mb) source process A의 working set
Target CPU (server_cpu_percent) target process B가 active 구간 동안 사용한 CPU 비율
Target Mem (server_memory_mb) target process B의 working set

request-serialrequest-backpressure는 echo reply가 돌아온 완료 수를 기준으로 KOPS를 계산한다. 여기서 1 KOPS는 초당 1,000건의 request/reply 완료를 뜻한다.

send-saturation은 server가 active phase에서 받은 메시지 수를 기준으로 KMSG/s를 계산한다. 여기서 1 KMSG/s는 초당 1,000개 메시지를 뜻한다. ZLink send는 reply를 기다리지 않으므로 client의 제출 호출 수만으로 처리량을 계산하지 않는다.

5.1 source 포화 규칙

이 절의 "client"는 부하를 만드는 process, 곧 server-driven 모델의 source process A를 뜻한다. Source CPU는 모든 셀에서 기록한다. 선택 항목이 아니다. 다만 백분율 하나로는 포화를 판정할 수 없으므로 사용한 core 수를 백분율과 함께 기록한다. 백분율은 머신의 논리 core 전체에 대한 값이다. 논리 core가 20개인 머신에서 단일 스레드 client가 core 하나를 완전히 사용해도 그 값은 5%이고, 어떤 고정 백분율 기준으로도 그 포화를 잡아낼 수 없다.

포화는 언어별 harness가 선언한 계측기와 상한에 대해 판정한다. 선언한 계측기의 값이 선언한 상한의 0.95배에 이른 셀을 포화 셀로 표시하고 처리량 우열 판정에서 제외한다. 그 셀에서 상한을 정한 것은 transport가 아니라 client 런타임이기 때문이다. 포화 셀의 처리량은 "이 client 구성에서 관찰된 값"으로만 기록한다.

harness는 상한만이 아니라 무엇을 재는지도 함께 선언한다. 맞는 계측기가 언어마다 다르기 때문이다. 포화 판정의 목적은 "transport가 아니라 client 런타임이 상한이었다"를 잡는 것이므로, 계측기는 user 코드가 도는 실행 자원을 재야 한다.

언어 선언 계측기 상한
dotnet 프로세스 사용 core 수 harness가 선언한 병렬도
node perf_hooksperformance.eventLoopUtilization() 1.0
java·kotlin 제출 스레드의 CPU (ThreadMXBean, jvm_thread_cores) harness가 선언한 제출 병렬도
cpp 제출과 완료 드레인을 실행하는 application thread의 CPU (CLOCK_THREAD_CPUTIME_ID, submit_thread_cores) 1

프로세스 사용 core 수를 쓰는 것은 dotnet 하나뿐이다.

Node에 프로세스 core 수를 쓰면 안 되는 이유는 실측이 보여준다. ZLink binding은 native I/O thread를 돌리므로 프로세스 CPU ÷ 경과 시간이 1.3~1.4 코어로 읽히는데, 그 thread들은 user 코드를 실행하지 않는다. 상한 1에 이 값을 대면 JS thread가 한가해도 모든 셀이 포화로 표시되고 표시가 정보를 잃는다. 이 client를 실제로 제한하는 것은 user 코드가 도는 JS thread 하나이고 event loop 사용률이 바로 그것을 잰다.

프로세스 CPU는 ZLink client의 포화 계측기로 쓸 수 없다. 이것이 이 절이 계측기를 언어별 선언으로 두는 이유다. 세 언어가 서로 다른 이유로 같은 결론에 도달했다. Node에서는 프로세스 CPU가 binding의 native I/O thread를 함께 셌고, Java에서는 GC와 JIT thread를 셌고, C++에서는 다시 binding의 I/O thread를 셌다. 다섯 언어 중 넷이 프로세스 CPU가 아닌 것을 선언했다. 문제는 임계값이 아니라 판정식이 나누는 두 행 사이의 비교 가능성이다. C++ 실측에서 zlink-cpp request-backpressure는 선언 계측기로 0.950인데 프로세스 core로는 1.90이고, 같은 run의 grpc-cpp는 0.700과 0.70으로 두 값이 사실상 같다. 프로세스 core로 판정하면 그 차이는 client 포화가 아니라 어느 행이 Core를 링크하는지를 보고하게 된다.

선언한 계측기와 상한은 언어마다 다르므로 셀 원본과 보고서에 둘 다 기록한다. 계측기나 상한을 선언하지 않은 결과는 포화 여부를 판정할 수 없으며, 판정하지 못했다는 사실을 결과에 남긴다. 계측기를 선언하지 않은 옛 결과는 프로세스 core 수를 선언한 것으로 읽는다.

5.2 미완료 깊이는 셀마다 기록한다

request 계열 셀은 아래 세 값을 셀마다 함께 기록한다. 세 값은 선택 항목이 아니다.

지표 의미
peak_in_flight active 구간에서 관측한 미완료 request 수의 최댓값
깊이 처리량 x 평균 latency로 계산한 평균 미완료 수(Little's law)
abandoned drain 상한에 이를 때까지 완료되지 않은 request 수

request-backpressure에서 이 세 값은 결과 자체다. 깊이를 설정하지 않으므로 스택이 도달한 깊이는 그 스택의 설계가 정한 결과이고, 처리량과 같은 자격으로 표에 싣는다. 도달 깊이를 빼고 처리량만 인용하면 서로 다른 깊이에서 얻은 값을 같은 조건의 값처럼 읽게 된다.

abandoned가 0이 아닌 셀과 오류가 0이 아닌 셀은 처리량 비교에 쓰지 않는다. 그 셀이 보고하는 것은 속도가 아니라 완료되지 않은 request가 있었다는 사실이다. 이 사실은 성능 항목이 아니라 정확성 항목으로 기록한다.

6. 측정 payload header

측정 payload 앞에는 bindings/c/perf의 metric header와 같은 의미의 29-byte header를 넣는다. 이 header는 payload 본문 일부이며, protobuf bytes body 또는 raw ZLink message body의 앞부분에 들어간다.

offset 크기
0 4 magic 0x5A4C4E4B (ZLNK)
4 4 run id
8 1 phase (0 warmup, 1 active)
9 4 payload size
13 8 sequence
21 8 send timestamp ns

request-serialrequest-backpressure는 reply payload에 돌아온 header를 client가 검증하고, active phase reply 수로 KOPS를 계산한다. send-saturation은 server가 header를 읽어 active phase message 수와 server-side 수신 latency를 계산한다. 이 방식은 payload 안에 측정 값을 넣어, client stopwatch만으로 단방향 처리량을 과장하지 않기 위한 기준이다.

이 header layout은 모든 언어에서 동일하다. 언어마다 다른 layout을 사용하면 셀을 나란히 놓을 수 없다.

7. 결과 해석

이 bench는 작은 로컬 비교 도구다.

7.1 결과와 함께 남기는 정보

결과 문서나 guide에 성능 우위 문장을 넣으려면 아래 정보를 함께 남긴다.

  • CPU, OS
  • 해당 언어의 런타임 version과 gRPC 라이브러리 version
  • commit hash
  • payload size
  • warmup과 active duration 설정
  • gRPC와 ZLink endpoint
  • 도달 깊이(request-backpressure 패턴, §5.2)
  • A의 logical stream 수, stream당 in-flight 상한, trigger endpoint(§10)
  • 셀마다의 peak_in_flight, 깊이, abandoned(§5.2)
  • send concurrency 값
  • source(A) CPU와 포화 여부(§5.1), target(B) CPU
  • 결과 JSON 원본

7.2 계층 간 판정

성능 판정은 gRPC가 아니라 같은 ZLink 계층끼리 비교한다. raw binding은 같은 조건의 zlink-c request-backpressure 결과를 기준으로 본다. raw binding이 C 결과의 80% 이상이면 binding 계층의 기본 성능은 통과로 판단한다. framework는 같은 언어의 raw binding 결과를 기준으로 본다. framework가 raw binding 결과의 80% 이상이면 framework 추가 비용은 통과로 판단한다.

payload 크기 1024와 4096 각각에서:
zlink-<lang> / zlink-c                     >= 0.80   binding 계층 통과
zlink-framework-<lang> / zlink-<lang>      >= 0.80   framework 추가 비용 통과

두 식은 payload 크기마다 따로 계산한다. 한 언어는 10244096 두 크기에서 모두 기준을 만족할 때에만 통과다. 한 크기만 만족한 결과는 통과가 아니며, payload별 값은 항상 그대로 기록한다. 1024에서 기준을 유지하다가 4096에서 떨어지는 스택에는 실제 문제가 있고, 보고서는 어차피 두 크기를 모두 표시하므로 payload별 판정은 추가 비용 없이 그 문제를 드러낸다.

이 기준은 ZLink가 reply를 기다리지 않고 다음 request를 보낼 수 있는 패턴에 적용한다. 그런 패턴이 판정에 적합한 이유는 gRPC unary Echo와 ZLink request가 같은 보장, 곧 서버가 처리했다는 확인을 주기 때문이다. request-serial은 한 번에 하나만 처리하는 사용 패턴의 왕복 지연을 보기 위한 보조 지표로 남긴다.

판정의 기준 패턴은 request-backpressure다. 언어 통과 여부는 이 패턴에서 두 payload 크기 모두 기준을 만족할 때 결정한다.

기준 패턴을 이렇게 두는 근거는 아래와 같다.

  • 두 식은 모두 같은 조건에서 잰 두 행의 비율이므로, 그 조건은 분자와 분모가 실제로 도달할 수 있는 조건이어야 한다. 도달하지 못하는 조건에서 계산한 비율은 계층 비용이 아니라 그 조건을 보고한다.
  • 고정 window는 어떤 서비스도 자기 자신에게 부과하지 않는 조건이다. 그 조건에서 나온 낮은 비율은 "요청당 비용이 크다"가 아니라 "그 깊이에 도달하지 못했다"를 뜻할 수 있고, 두 원인은 같은 숫자로 나타나므로 비율만으로는 구분되지 않는다.
  • request-backpressure는 각 스택이 자기 설계가 허용하는 깊이에 도달한 상태에서 비율을 계산하므로, 정상적으로 제출하는 호출자가 실제로 얻는 값을 비교한다. 이것이 "binding 계층의 기본 성능"이 뜻해야 하는 것이다.

이 선택이 해결하지 않는 것을 함께 적는다. request-backpressure는 깊이 고정을 없앨 뿐 깊이 차이를 없애지 않는다. 자기 backpressure로 낮은 깊이에 머무는 스택은 이 패턴에서도 낮은 비율을 내고, 비율만으로는 요청당 비용과 도달 깊이를 여전히 가르지 못한다. 그래서 두 패턴 어느 쪽이든 §5.2의 깊이 세 값을 함께 싣지 않은 비율은 게재하지 않는다.

고정 window 패턴은 이 bench에 없다. 외부에서 정한 깊이에서 스택이 어떻게 동작하는지는 ZLink 모델 비교 bench의 항목이며, 거기서 관측한 유실과 미완료는 정확성 항목으로 따로 기록한다.

두 번째 식은 §1.3의 소켓 구성을 지켰을 때에만 framework 계층 비용을 나타낸다.

첫 번째 식의 분모 zlink-c는 client-driven bench(§1.2)의 값이고 분자는 server-driven 모델(§10)의 값이다. 두 모델은 부하 생성 위치가 다르므로, 이 비율은 "같은 요청당 비용을 다른 모델에서 잰 값" 으로 읽고 보고서에 그 사실을 함께 적는다. 공개 비교 보고서의 중심은 이 비율이 아니라 같은 언어 안의 grpc · zlink · zlink-framework 직접 비교이며, 비율은 부록에 둔다.

7.3 언어를 가로지른 읽기 규칙

절대 처리량은 언어끼리 비교하지 않는다. grpc-nodegrpc-java를 나란히 놓은 값은 런타임 비교이지 ZLink 비교가 아니다. 표에서 두 언어의 처리량이 다르면 그 차이의 원인은 대부분 런타임과 gRPC 라이브러리이고, 이 bench는 그 원인을 분리하지 않는다.

언어를 가로질러 읽을 수 있는 값은 비율이다. zlink-framework-<lang> / zlink-<lang>는 각 언어가 자기 자신을 기준으로 계산한 값이므로, "어느 언어의 framework 계층이 가장 비싼가"에 답할 수 있다. 언어 비교를 담은 표는 이 비율을 주 항목으로 둔다.

7.4 단위 정렬

처리량 단위는 §4가 초당 완료 수로 고정한다. runner가 다른 배율로 값을 남기더라도 공용 집계기 (framework/bench/grpc/tools/)가 bandwidth(§5가 MB/s로 고정한다)로부터 배율을 역산해 정규화하므로, 비교와 판정은 언제나 집계기의 출력으로 한다. 언어 client가 스스로 출력하는 표는 run 하나를 바로 확인하기 위한 편의이며 판정의 근거가 아니다. runner의 report를 직접 읽어 비율을 계산하지 않는다.

결과는 "이 조건에서 더 빠르다/느리다"로만 해석한다. 일반적인 운영 성능이나 모든 payload에서의 우위를 주장하지 않는다.

8. 언어별 구성

8.1 언어별 사용 모듈

언어 gRPC 라이브러리와 server framework 모듈 raw binding protobuf codec
dotnet ASP.NET Core gRPC framework/languages/dotnet/src/Zlink.Framework bindings/dotnet Zlink.Framework.Codecs.Protobuf
node @grpc/grpc-js framework/languages/node/packages/framework bindings/node packages/framework-codec-protobuf
java grpc-java zlink-framework-core bindings/java zlink-framework-codec-protobuf
kotlin grpc-kotlin coroutine stub zlink-framework-kotlin bindings/kotlin Java와 같은 codec을 사용
cpp 시스템 libgrpc++grpc_cpp_plugin framework/languages/cpp/framework bindings/cpp zlink::framework_codec_protobuf

A의 HTTP trigger listener는 각 언어의 표준 HTTP 서버(ASP.NET Core minimal API, Node http, JDK HttpServer, C++ framework HTTP hosting)를 쓴다. 이 listener는 측정 경로 밖이며 어떤 셀의 비용에도 들어가지 않는다.

Kotlin은 전체 matrix에서 제외하고 보조 셀만 잰다(§10.5). Kotlin은 ZLink 쪽에서 suspend 인터페이스를 사용하므로 gRPC 쪽도 grpc-kotlin coroutine stub을 사용한다. coroutine stub을 사용할 수 없을 때에만 grpc-java blocking stub을 사용하고, 그 사유를 결과에 기록한다.

C++는 시스템에 설치된 libgrpc++를 사용한다. vcpkg로 gRPC를 빌드하지 않는다. 이 머신의 version은 1.51.1이며, 오래된 version이므로 결과에 반드시 기록한다.

8.1.1 메시지당 payload 작업 대조

각 지원 행의 클라이언트는 request/send 제출마다 29바이트 측정 header를 포함한 body와 BenchPayload 스키마의 메시지 객체를 만들고, 해당 언어의 protobuf 런타임으로 직렬화한다. raw의 envelope header part는 wire 규격에 따른 상수를 사용한다. 수신한 payload는 protobuf로 역직렬화하고, request 응답도 typed payload를 직렬화한다. 응답의 측정 header와 timestamp는 요청의 값을 보존한다. send는 payload 응답이 없으며, gRPC Command만 기존 계약대로 Empty 응답을 직렬화한다.

언어 raw 요청·응답 직렬화 / 수신 역직렬화 gRPC framework
Java 매번 생성한 BenchPayloadtoByteArray / parseFrom 같은 generated 타입을 grpc-java가 직렬화·역직렬화 같은 generated 타입을 ZLinkProtobufCodec이 직렬화·역직렬화
.NET 매번 생성한 BenchPayloadWriteTo / Parser.ParseFrom 같은 generated 타입을 Google.Protobuf 기반 gRPC가 직렬화·역직렬화 같은 generated 타입을 protobuf codec이 직렬화·역직렬화
C++ 매번 생성한 BenchPayload의 protobuf 직렬화 / ParseFromArray 같은 generated 타입을 libgrpc++가 직렬화·역직렬화 같은 generated 타입을 protobuf codec이 직렬화·역직렬화
Node 매번 생성한 BenchPayload DTO를 기존 proto-loader serializer / deserializer로 처리 같은 proto-loader의 protobuf serializer / deserializer 사용 같은 proto-loader serializer / deserializer를 schema envelope codec에 연결
C binding C++ 드라이버에서 매번 generated BenchPayload를 생성해 기존 libprotobuf로 직렬화·역직렬화 같은 generated 타입과 libprotobuf 사용 C framework 행 없음

C binding bench는 .cpp 드라이버이며 protobuf가 이미 빌드 의존성이다. raw도 이를 공유하므로 수동 protobuf 프레이밍 예외를 사용하지 않는다. 표의 동등성은 payload 작업을 뜻하며, transport 호출·envelope 처리·buffer 복사 횟수가 같다는 뜻은 아니다. Java raw와 gRPC는 ByteString.copyFrom을, framework는 기존 UnsafeByteOperations.unsafeWrap을 사용한다.

wire 동일성은 tools/test_raw_wire.sh로 확인한다. 고정 29바이트 payload의 기존 dump와 protobuf 결과를 비교하며, 길이 varint 경계와 request 응답의 body 보존도 검증한다.

raw 직렬화 비용을 포함한 결과를 새 기준으로 사용한다. 직렬화를 우회한 raw 기준의 framework/raw 비율과 성능 추세를 비교하지 않는다. raw 처리량이 낮아져 비율이 높아지는 것은 측정 조건의 정정이며 framework 성능 개선을 뜻하지 않는다.

8.2 언어마다 다르게 두되 반드시 기록하는 값

아래 세 값은 언어마다 다르게 설정한다. 같게 맞추는 것이 목적이 아니라, 사용한 값을 결과에 남기는 것이 목적이다.

항목 이유
warmup 길이 JVM은 JIT 예열이 끝난 뒤에 정상 상태가 된다. .NET과 같은 warmup을 강요하면 예열되지 않은 런타임을 측정하게 된다
gRPC server 구성 언어마다 기본 server 구현이 다르다. gRPC 쪽은 각 언어의 기본 구성으로 두고 그 구성을 결과에 남긴다
런타임과 gRPC 라이브러리 version SDK version, 런타임 version, gRPC 라이브러리 version을 셀 원본과 보고서에 함께 남긴다

9. 포트 대역

구현 하나에 A trigger, A stats, B endpoint, B stats 네 포트가 필요하고(raw binding은 B의 request· command endpoint가 분리되어 다섯), 언어당 세 구현이므로 언어마다 20개 대역을 잡는다. 같은 대역 안의 offset 의미는 모든 언어에서 같다.

언어 대역 grpc A trigger/stats, B endpoint/stats zlink raw A trigger/stats, B request/command/stats framework A trigger/stats, B endpoint/stats
dotnet 5200-5219 5200/5201, 5202/5203 5205/5206, 5207/5208/5209 5212/5213, 5214/5215
node 5220-5239 5220/5221, 5222/5223 5225/5226, 5227/5228/5229 5232/5233, 5234/5235
java 5240-5259 5240/5241, 5242/5243 5245/5246, 5247/5248/5249 5252/5253, 5254/5255
kotlin(보조, §10.5) 5260-5279 5260/5261, B는 java 대역 5242/5243 없음 5272/5273, B는 java 대역 5254/5255
cpp 5280-5299 5280/5281, 5282/5283 5285/5286, 5287/5288/5289 5292/5293, 5294/5295
C 기준(client-driven) 6200-6219 6200/6201, 6202/6203 6205/6206, 6207/6208/6209 없음

각 대역의 +16~+19는 예비다. runner는 측정 시작 전에 자기 대역의 포트가 비어 있는지 확인한다. 사용 중이면 다른 포트로 옮기지 않고 중단한다. 포트를 옮기면 결과에 기록된 endpoint와 실제 endpoint가 어긋난다. Kotlin 보조 셀은 Java의 B를 그대로 쓰므로 Java 측정과 같은 시간에 돌리지 않는다(한 번에 한 언어).

10. server-driven 실행 모델

10.1 역할

역할 process 수 하는 일
trigger client 1 (runner) A의 trigger endpoint에 HTTP POST /bench/start를 보낸다. 부하를 만들지 않고 결과도 세지 않는다
source A 구현마다 1 trigger를 받으면 §10.3의 logical stream으로 B에 request 또는 send를 반복하고, 완료·지연·오류를 자기가 집계해 셀 원본 JSON을 쓴다. stats endpoint로 진행 상태를 노출한다
target B 구현마다 1 request는 같은 payload로 echo하고, send는 header를 읽어 수신 수와 수신 latency를 센다. stats endpoint로 값을 노출한다

세 구현의 A·B는 서로 독립된 process 쌍이다. 같은 언어 안에서도 한 번에 한 쌍만 측정한다.

10.2 trigger 계약

공통 perf 규격(framework/doc/framework/common/perf/README.ko.md §4.2, §5.1, §16)의 trigger· admin 계약을 그대로 쓴다. 요청과 응답은 JSON이고 다섯 언어가 같은 필드를 쓴다.

POST http://127.0.0.1:<A trigger>/bench/start
{ "runId": "...", "cellId": "...", "pattern": "request-backpressure",
  "payloadBytes": 1024, "phase": "warmup" | "active",
  "durationMs": 5000, "requestWindow": 100, "sendConcurrency": 8 }
→ 200 { "accepted": true, "runId": "...", "cellId": "...", "phase": "active", "startedAt": <monotonic ns> }
  • 한 phase는 한 번만 시작한다. 같은 runId·cellId·phase의 중복 trigger는 같은 시작 acknowledgement를 돌려주고 두 번째 부하를 만들지 않는다.
  • trigger는 pattern·payload·duration·window·concurrency를 전달만 하고 규격 값을 바꾸지 않는다. 기본값은 §3이 정한 값이다.
  • GET http://127.0.0.1:<A stats>/bench/statsGET http://127.0.0.1:<B stats>/bench/stats는 phase, 제출·완료·오류 수, 수신 수, 현재 in-flight를 돌려준다. settle(§3)의 폴링 대상이다.
  • trigger·stats의 HTTP 왕복은 측정 operation이 아니며 throughput·latency에 들어가지 않는다.

10.3 logical stream과 패턴 대응

A는 B로 향하는 독립된 부하 흐름을 logical stream으로 돌린다. 패턴은 stream 수와 stream당 in-flight로 표현한다.

패턴 stream 수 stream당 in-flight 의미
request-serial 1 1 요청 하나를 보내고 reply 뒤 다음 요청
request-backpressure 1 상한 없음 admission backpressure를 만날 때까지 제출
send-saturation send_concurrency(기본 8) 1 (send는 완료 통지까지) reply 없는 command 경로

stream 수와 stream당 in-flight는 셀 원본에 기록한다(§4). 언어 harness가 stream을 어떻게 구현하는지(thread, task, coroutine, event loop)는 언어마다 다르며 §8.2처럼 결과에 남긴다.

10.4 셀 순서

  1. runner가 자기 언어 대역(§9)이 비어 있는지 확인한다.
  2. 구현의 B를 띄우고 stats endpoint가 응답할 때까지 기다린다(상한 30초).
  3. 구현의 A를 띄우고 A가 B에 연결해 route ready를 보고할 때까지 기다린다(상한 30초). A의 stats가 ready=false면 셀을 시작하지 않고 그 사실을 기록한다.
  4. phase=warmup trigger → A가 warmup을 끝내고 stats에 phase=idle을 보고할 때까지 기다린다.
  5. phase=active trigger → durationMs 뒤 A가 measured 구간을 닫는다.
  6. settle: B(그리고 A)의 stats를 폴링해 수신·완료 수가 더는 늘지 않을 때까지 기다린다(상한 30초, §3의 오염 규칙 그대로).
  7. A가 셀 원본 JSON을 log/<lang>/<stamp>/에 쓰고 RESULT 라인을 낸다. runner가 B의 stats를 같은 JSON의 target_stats에 합친다.
  8. A·B를 종료한다. 다음 셀은 새 process 쌍으로 시작한다(같은 process를 여러 셀에 재사용하지 않는다 — 앞 셀의 잔여 상태가 다음 셀에 들어가는 것을 막기 위해).

gRPC 구현의 A는 같은 trigger listener를 갖고, B로 향하는 unary stub을 stream 수만큼 돌린다. gRPC server 구성은 언어 기본값을 두고 결과에 기록한다(§8.2).

10.5 Kotlin 보조 셀

Kotlin은 Java와 같은 binding·server·codec을 쓰므로 전체 matrix에서 제외한다. 대신 Kotlin 호출 층의 비용을 보여 주는 보조 셀 둘 — grpc-kotlin(coroutine stub)과 zlink-framework-kotlin (suspend 호출)의 request-backpressure @1024 — 을 Java 행 옆에 싣는다. A만 Kotlin이고 B는 Java 바이너리를 §9의 Java 대역에서 그대로 쓴다.