격리 벤치마크
이 benchmark는 실제 uvicorn ASGI process에서 aerospike-py와 공식 aerospike Python client를 비교합니다. 각 endpoint는 found, pred_sum, subset_sum 중 하나의 scalar만 반환해 response serialization 비용을 제외합니다. 따라서 측정 latency에는 batch_read, materialization, 선택적 inference가 포함되고 JSON encoding은 포함되지 않습니다.
Production Benchmark는 반대 범위를 측정합니다. HTTP parsing, 전체 record marshaling, DLRM, network output처럼 serving pod가 실제로 부담하는 비용을 모두 포함합니다. Client library 자체의 차이를 확인하려면 이 문서를, capacity planning에는 production benchmark를 사용하세요.
Source: 본 repo 의
benchmark/. podman/docker 로 Aerospike CE 8.1.2.1_3 띄워서 로컬 실행.
실행 방법
cd benchmark
make up # podman compose → aerospike:ce-8.1.2.1_3 on 127.0.0.1:18710
make seed # 결정론적 10k records, 5개 메타 bin + 64개 float feature f0..f63
make bench-all CONCURRENCY=10 DURATION=30s BATCH_SIZE=50
make report # → 시나리오별 markdown 비교 표
make bench는 client마다 uvicorn process를 시작하고 /healthz를 기다립니다. 지정한 시간 동안 oha를 실행한 뒤 process를 종료하고 다음 client로 넘어갑니다. 각 run은 oha.json 옆에 meta.json을 저장하므로 loadtest/report.py가 결과를 (scenario, batch_size, concurrency, python) 단위로 묶을 수 있습니다.
Aerospike CE container는 scripts/aerospike.template.conf를 mount하고 access-address를 127.0.0.1로 고정합니다. aerospike-py는 connect() 중에 전체 cluster를 찾습니다. 이 설정이 없으면 Podman 내부 IP로 다시 연결하다 실패합니다. 이 benchmark에서 공식 client는 seed host만 사용합니다.
아래 수치는 warm cache에서 실행한 짧은 개발용 matrix를 사용합니다. 조건은 Python 3.11, concurrency 4, batch size 30, run당 3초입니다. 어느 시나리오에서 차이가 커지거나 작아지는지 비교할 때만 사용하고 절대 성능 수치로 인용하지 마세요. Production 수준의 matrix에는 seed record 10,000–100,000개, 60초 run, concurrency 10–50이 필요합니다.
시나리오
- s1 — read-only
- s2 — dict-path infer
- s3 — numpy zero-copy 🔥
- s4 — gather vs single
- s5 — lazy subset
s1 — 순수 batch_read 라운드트립
가장 얇은 측정: {offset, batch_size} JSON body 를 받아 키를 만들고 batch_read 호출, found count 만 반환. 다운스트림 bin materialisation 없음, 추론 없음, full-record 직렬화 없음.
코드 경로 차이
| client | _materialise |
|---|---|
| aerospike-py | result.found_count() — pure-Rust filter+count, PyDict build 없음 |
| official | sum(1 for br in result.batch_records if br.result == 0) — BatchRecord 리스트 Python 순회 |
공식 client는 batch_read를 호출할 때마다 asyncio.to_thread 전환 비용을 부담합니다. s1은 이 비용을 가장 직접적으로 측정합니다.
Smoke (py3.11, c=4, batch=30, 3s)
| client | RPS | avg ms | p95 |
|---|---|---|---|
| official | 2383.2 | 1.68 | 2.14 |
| aerospike-py | 2466.8 | 1.62 | 2.39 |
| speedup | 1.04× | 1.04× | — |
5개 시나리오 중 가장 작은 격차 — request shape 이 HTTP framing + 단일 round trip 으로 dominate 되고 둘 다 공통 비용. concurrency 가 커지면 격차 벌어짐 (production-benchmark Env A 는 ASGI 레이어 없이 동일 DB shape 에서 4.8× 관찰).
3.14t 예상
두 client 모두 이 시나리오에서 절대 latency가 가장 크게 줄어들 것으로 예상합니다. 짧은 request에서 spawn_blocking_delay와 event_loop_resume_delay가 큰 비중을 차지하기 때문입니다. Client 간 비율은 줄어들 수 있습니다. Production Benchmark의 같은 workload를 사용한 Python 3.14t run에서는 42%였던 차이가 약 2 ms로 줄었습니다.
s2 — batch_read → dict → tensor → DLRM
레코드를 per-row Python list 로 materialise → torch.tensor 빌드 → 2-layer MLP (64→256→1) forward. 업스트림 코드가 dict-of-dicts 결과를 기대하는 흔한 패턴.
코드 경로 차이
| client | materialise step |
|---|---|
| aerospike-py | result.to_dict() → request 순서대로 bins_by_key.get(key) |
| official | result.batch_records 순회 (aerospike 19.x 가 request 순서 보존, 실측 확인) |
양 path 모두 Python list-of-lists 를 빌드한 뒤 torch.tensor(rows, dtype=torch.float32) 를 정확히 1회 호출 — cell-by-cell tensor 할당으로 한 쪽을 불공정하게 punish 하는 패턴은 회피.
Smoke
| client | RPS | avg ms | p95 |
|---|---|---|---|
| official | 1986.0 | 2.01 | 2.48 |
| aerospike-py | 2206.6 | 1.81 | 2.56 |
| speedup | 1.11× | 1.11× | — |
1.11×는 s2와 s3를 비교하는 기준입니다. 두 client가 record별 dict를 순회할 때는 차이가 작습니다. s3에서는 이 차이가 1.33×로 커집니다. aerospike-py는 Rust-side zero-copy 경로를 사용하지만 공식 client에는 같은 기능이 없기 때문입니다.
3.14t 예상
두 client 모두 개선됩니다. torch가 GIL을 해제하므로 DLRM CPU inference도 빨라집니다. Production Benchmark에서는 Python 3.14t에서 inference가 43.5 ms에서 20.7 ms로 52% 줄었습니다. Client 간 비율은 작아지지만 dict iteration 비용의 차이는 남습니다.
s3 — batch_read → numpy structured array → torch → DLRM
aerospike-py path 는 LazyBatchRecords.to_numpy(dtype) 로 GIL release 된 채 연속 메모리의 structured array 를 채운 후, copy 없이 (batch, N_FEATURES) float32 매트릭스로 view:
np_batch = result.to_numpy(_DTYPE) # structured (batch,) array
matrix_np = np_batch.batch_records.view(np.float32) \
.reshape(-1, N_FEATURES) # zero-copy view
tensor = torch.from_numpy(matrix_np) # zero-copy
preds = infer(tensor)
zero-copy 검증: numpy 2.x + 64-<f4 packed dtype 에서 np_batch.batch_records.view(np.float32).reshape(-1, 64).base is np_batch.batch_records 와 np.shares_memory(matrix_np, np_batch.batch_records) 둘 다 True.
공식 client 엔 equivalent 없음 — np.zeros((batch, N), dtype=np.float32) 를 할당하고 field-by-field Python loop 로 복사.
Smoke
| client | RPS | avg ms | p95 |
|---|---|---|---|
| official | 1854.0 | 2.16 | 2.75 |
| aerospike-py | 2466.2 | 1.62 | 2.36 |
| speedup | 1.33× | 1.33× | 1.16× |
s2에서 s3로 증가한 차이(1.11× → 1.33×)가 to_numpy()만의 효과입니다. DLRM과 DB shape는 같고 materialisation 경로만 다릅니다. PyTorch, JAX, scikit-learn처럼 NumPy를 지원하는 downstream framework를 사용하면 aerospike-py는 Python loop를 거치지 않고 데이터를 materialise합니다.
3.14t 예상
이 차이는 구조에서 비롯됩니다. aerospike-py는 GIL을 해제한 상태에서 Rust가 NumPy array를 한 번에 채우지만, 공식 client는 Python loop를 사용합니다. s2도 GIL 제거의 이점을 얻지만 s3는 이미 GIL을 대부분 우회하므로, Python 3.14t에서는 s3와 s2의 비율이 더 벌어질 가능성이 있습니다.
s4 — asyncio.gather(N batch_reads) vs 단일 batch_read(mixed keys)
두 endpoint는 같은 수의 key를 처리합니다. **/s4/gather**는 n_groups개의 batch_read를 병렬로 실행하고, **/s4/single**은 모든 key를 한 번의 호출로 처리합니다. DB wire payload는 같고 고정 비용을 나누는 방식만 다릅니다.
aerospike-py 가 gather 에서 더 크게 이기는 이유
공식 client는 각 sub-batch를 asyncio.to_thread를 통해 thread pool에 전달합니다. aerospike-py는 Tokio 안에서 sub-batch를 동시에 실행합니다. 따라서 공식 client는 gather의 호출별 overhead를 N번 부담하지만, aerospike-py는 대략 한 번만 부담합니다.
Smoke (n_groups=4, per_group=30 → total 120 keys)
| endpoint | client | RPS | avg ms | p95 |
|---|---|---|---|---|
/s4/gather | official | 902.8 | 4.43 | 4.80 |
/s4/gather | aerospike-py | 1130.3 | 3.54 | 4.95 |
/s4/gather | speedup | 1.25× | 1.25× | — |
/s4/single | official | 996.1 | 4.02 | 4.70 |
/s4/single | aerospike-py | 1385.4 | 2.89 | 4.24 |
/s4/single | speedup | 1.39× | 1.39× | 1.11× |
두 client 모두 /s4/single이 /s4/gather보다 빠릅니다. Round trip이 줄어 고정 비용을 더 잘 분산하기 때문입니다. Client 간 차이는 /s4/single에서 더 큽니다(1.39× 대 1.25×). Gather path에서는 실제 병렬 실행이 thread 전환 비용을 일부 상쇄하지만 single path에는 그런 효과가 없습니다.
권장 방식: key를 미리 알고 있다면 gather(N batch_reads) 대신 batch_read(mixed_keys) 한 번을 사용하세요. Production Benchmark의 Environment C에서 이 변경으로 p95가 추가로 33% 줄었습니다.
3.14t 예상
Python 3.14t에서는 공식 client의 asyncio.to_thread 전환 비용이 줄어 gather와 single의 차이도 작아질 것입니다. aerospike-py가 단일 호출로 Rust와 Python의 경계를 한 번만 통과하는 이점은 그대로 유지됩니다.
s5 — LazyBatchRecords 의 부분 materialisation
Wire상의 record에는 64개 bin(f0..f63)이 있고 endpoint는 그중 8개만 사용합니다. 두 client 모두 전체 payload를 받습니다. Server-side 최적화인 bin projection(bins=[…])은 의도적으로 사용하지 않았으므로, 이 시나리오는 같은 wire size에서 client-side materialisation 비용을 비교합니다.
코드 경로 차이
| client | materialise step |
|---|---|
| aerospike-py | result.to_numpy(_SUBSET_DTYPE) — dtype 에 지정된 8개 field 만 structured array 에 기록; 나머지 56개는 Python 에 절대 노출 안 됨 |
| official | 각 BatchRecord.record[2] 는 C extension 이 Python 반환 전에 빌드한 fully-materialised 64-key Python dict — subset 접근 (bins[name]) 은 무료지만 up-front dict build 는 회피 불가 |
Smoke
| client | RPS | avg ms | p95 |
|---|---|---|---|
| official | 2421.4 | 1.65 | 2.09 |
| aerospike-py | 2722.0 | 1.47 | 2.16 |
| speedup | 1.12× | 1.12× | — |
s3보다 차이가 작습니다. Wire payload가 같고 공식 client의 C extension이 dict를 빠르게 생성하기 때문입니다. aerospike-py의 이점은 사용하지 않는 bin 수가 늘수록 커집니다. 64개 중 8개를 사용할 때는 차이가 작지만, 128개 중 1개만 사용하면 더 커집니다.
3.14t 예상
aerospike-py의 to_numpy(subset_dtype)는 각 record를 채우는 동안 GIL을 해제하므로, Python 3.14t에서는 여러 s5 request를 병렬로 materialise할 수 있습니다. 공식 client도 C extension에서 request별 dict를 만드는 동안 GIL을 해제하지만, 완성된 dict의 소유권을 Python에 넘겨야 합니다. 따라서 비율은 비슷하게 유지될 가능성이 있습니다.
의도적으로 측정 안 한 것
| 제외 | 이유 |
|---|---|
| Response body 직렬화 | 각 endpoint는 record에서 계산한 scalar 하나, 즉 {found: N} 또는 pred_sum/subset_sum을 추가한 값만 반환합니다. Full-record JSON encoding은 두 client 모두 부담하지만 key와 digest shape가 달라 공식 client의 비용을 비대칭으로 키우고 client 간 차이를 가릴 수 있습니다. |
| OTel / Prometheus / nelo | 측정 결과에 변동을 더합니다. Client library는 이 기능을 제공하지만 benchmark suite에서는 비활성화했습니다. |
| Docker image / k8s manifests | 이 benchmark는 client library만 격리합니다. Deployment, registry, manifest 작업은 관련 없는 변동을 더합니다. |
Bin projection (bins=[…] on the wire) | Client 비교와 별개인 server-side 최적화입니다. s5는 같은 wire payload에서 client-side materialisation 비용을 측정합니다. |
모든 production 비용을 포함한 결과는 Production Benchmark를 참고하세요.
Python 3.14t — 같은 스위트 위 런타임 swap
Python 3.14t는 GIL을 비활성화한 free-threaded CPython 빌드입니다. Production Benchmark의 3.14t 섹션에서 spawn_blocking_delay는 234 ms에서 0.12 ms로, event_loop_resume_delay는 39.7 ms에서 거의 0으로 줄었습니다. 여기서도 같은 현상이 나타날 가능성이 있습니다. 다만 isolated suite는 batch_read 이후의 GIL-bound stage를 이미 대부분 제외했으므로 절대 차이는 더 작을 것입니다.
benchmark/ 프로젝트는 인자 하나로 runtime을 바꿀 수 있습니다.
uv python install 3.14t # 일회성, ~30 MB 다운로드 (uv 관리)
PYTHON=3.14t uv sync # 별도의 3.14t venv 생성
PYTHON=3.14t make bench-all CONCURRENCY=10 DURATION=30s BATCH_SIZE=50
make report # report.py 가 python 버전별 그룹화
시나리오별 예상 효과는 각 탭의 "3.14t 예상" 절에 정리했습니다.
각 시나리오의 "3.14t 예상"은 Production Benchmark의 stage breakdown을 바탕으로 추정한 값이며 측정값이 아닙니다. Harness는 runtime 변경을 지원합니다. 공개할 matrix 측정 작업은 Bottleneck Analysis에서 추적합니다.
관련 파일
benchmark/
├── pyproject.toml # uv project, editable parent aerospike-py
├── compose.yaml # aerospike:ce-8.1.2.1_3
├── scripts/aerospike.template.conf
├── Makefile # up · seed · serve-{py,official} · bench[-all] · report
├── src/app/
│ ├── main.py · lifespan.py · config.py · model.py · seed.py
│ ├── clients/{py_async,official_async}.py
│ └── routes/s{1..5}*.py
└── loadtest/
├── run_oha.sh · bench_matrix.sh
├── scenarios.py # id → 한 줄 description
└── report.py