Skip to content

Latest commit

 

History

History
207 lines (149 loc) · 14.7 KB

File metadata and controls

207 lines (149 loc) · 14.7 KB

bundis 성능 지표

bundis 0.1.0 + 핫 캐시 + TIER1 하드닝 (commit 1b62336 + 미커밋) · 측정일 2026-06-10 주: A–E·G·I 수치는 캐시 레이어 시점 측정이고, F3·H는 TIER1 리퍼/SPOP 픽스 후 재측정값이다. write-through 버그 수정(plain SET이 이제 캐시 채움) 이후 SET 경로에 fill 비용 ~1%가 추가됐으나 GET 적중 수치(read-fill 경로와 동일)에는 영향 없다. 원시 데이터: bench/results.json · 계획/방법론: bench/PLAN.md · 재현: bun run bench/run.ts

요약 (헤드라인)

측정 구성 = 실제 제품 기본 구성: 파일 기반 SQLite + WAL, 핫 캐시 ON(64MB = 메모리 예산 256MB의 25%, write-through + 적응형 TTI + LRU), 별도 서버 프로세스, 순정 Bun.RedisClient, loopback TCP, 만료 리퍼 100ms.

지표
GET 처리량 (핫 워킹셋, 캐시 적중) 321,824 ops/s (B2) · 동일 케이스 on/off 차분 2.67× (I1: 332,261 vs 124,600)
SET 처리량 (덮어쓰기, write-through) 69,105 ops/s (캐시 off: 69,562 — 오버헤드 ≈ 0)
GET service time (단건, p50 / p99) 29.5µs / 39.1µs (I1, n=20k — §I 표 참조. §A의 n=50k 측정은 p99 65.5µs로 1회성 스파이크 포함)
SET service time (단건, p50 / p99) 33.4µs / 54.6µs
16KB 값 GET 대역폭 (캐시 적중) 3,345 MB/s
256KB 값 GET 대역폭 (워킹셋 > 캐시 → 비적중 경로) 826 MB/s
MSET 일괄 쓰기 (100키/호출) 291,370 keys/s
Pub/Sub 팬아웃 전달량 (구독자 50) 462,192 msgs/s
콜드 스타트 (100k키 DB → 첫 응답) 약 13ms (기동 11.3ms + 첫 GET 2.1ms)
지속 삽입 (200k키, WAL 체크포인트 포함) 39.6k–45.3k ops/s, 톱니 없음

환경: Apple M5 (10코어) · 24GB RAM · macOS (Darwin 25.5.0) · Bun 1.3.14


방법론 (수치를 읽기 전에)

설계 단계 3-렌즈 방법론 비판 패널, 하니스 적대적 코드 리뷰(확정 결함 17건 수정), 캐시 레이어 적대적 리뷰(확정 6건 수정 — 캐시 포이즈닝 critical 포함)를 거친 측정. 핵심 규칙:

  • 서버는 항상 별도 프로세스(spawnServer) — 측정 클라이언트와 이벤트루프 비공유. embed 비교(G1)만 예외.
  • 파일+WAL + 핫 캐시 ON이 기본 (실제 기본 설정). 캐시 기여분은 I1의 on/off 차분으로 분리. :memory: 수치는 E1 비교 레그에만.
  • 케이스마다 새 서버+새 DB. 워밍업은 측정과 동일 워크로드 — 단, 캐시 적중 경로의 콜드 구간(~10만 op)이 기본 워밍업(1만 op)보다 길어 처리량 케이스의 첫 반복에 잔존하며, 중앙값이 이를 걸러낸다(한계 1). 타이밍 루프 내 할당 0.
  • 일부 비교에 캐시 도입 런의 수치를 인용함 — 그 수치들은 현재 results.json에 없고 본문에 "(캐시 전 런)"으로 표기.
  • "service time"은 동시성 1 닫힌 루프 수치 — 부하 시 지연시간 아님(coordinated omission). 부하 중 꼬리지연은 F3 open-loop로만.
  • 처리량 = sliding window, 3회 반복 중앙값(반복별 수치 results.json 공개).
  • 대표 처리량 케이스(B2, C1, D1, I1)에서 bench/서버 %CPU 샘플링. 나머지는 미샘플.

알려진 한계:

  1. 캐시 적중 경로의 콜드 구간: 프로세스 기동 후 첫 ~10만 op 동안 ~90k ops/s, 이후 320k+로 전환 (B2 반복값 [89.8k, 325.6k, 321.8k], I1도 동일 패턴 [91.9k, 336.5k, 332.3k]). 원인 미확정 — 캐시 off 런에는 콜드 반복이 없으므로([123.5k, 124.6k, 124.9k]) 캐시 경로 특유의 JIT/자료구조 워밍업으로 추정만 함. 중앙값이 이를 흡수하지만, 짧게 살고 죽는 프로세스는 워밍업 후 수치를 못 본다.
  2. F3 open-loop 절대치는 스케줄러 타이머 해상도로 1ms 수준 부풀려짐 — 베이스라인 레그(p50 871µs, p99 2.6ms)는 상당 부분이 이 아티팩트다. 처닝 레그의 ms수백ms 꼬리에선 상대적으로 작아지므로, 레그 간 배율은 보수적 하한으로 읽을 것.
  3. D1 다중 클라이언트는 단일 bench 프로세스 구동(서버 CPU 89–104%로 서버 병목 확인됨), F2 구독자 동일 프로세스.
  4. max는 일회성 이벤트 복권 — 진단용 (예: A2 max 19.8ms는 5만 샘플 중 1회짜리 스파이크).
  5. 실제 Redis 차분 비교 없음 (redis-server 미설치).
  6. INCR "회귀" 규명 완료 (bench/incr-probe.ts로 변수 분리, 각 3회 중앙값): 캐시 도입 탓이 아니다.
    • 캐시 레이어 비용: ~1% (100k 프리로드에서 on 57.0k vs off 57.6k; 카운터 키가 캐시돼 매 INCR이 무효화를 적중해도 동일; 400k 행에서도 on/off 동일).
    • PRAGMA cache_size 변경(2MB→128MB) 영향: 0.
    • 키스페이스 효과가 지배: 빈 DB 74.6k vs 100k행 56k (−25%, keys B-tree upsert 깊이) — 캐시 전·후 런이 똑같이 지불한 비용이라 회귀가 아님. 100k→400k 행은 추가 영향 없음.
    • 캐시 전 런 61.9k와의 잔여 격차: 동일 조건(캐시 off + 구 pragma)을 오늘 재측정해도 56–57k → 런 간 환경 드리프트. 풀 스위트의 50.6k는 스위트 문맥(직전 케이스들이 남긴 서버 힙 상태) 추가분.
    • 부수 수정: incrBy/incrByFloat/hIncrBy 핫루프의 호출별 TextEncoder 할당 제거 (+2.3%, 55.7k→57.0k).

A. 단건 service time (동시성 1, n=50,000, 캐시 ON)

명령 p50 p95 p99 mean
GET (hit, 캐시 적중) 29.5µs 36.3µs 65.5µs 32.3µs
GET (miss) 22.5µs 26.6µs 30.3µs 22.9µs
SET (64B 덮어쓰기) 33.4µs 39.3µs 54.6µs 34.3µs
INCR 36.5µs 42.5µs 50.7µs 38.0µs
DEL 44.8µs 53.5µs 75.0µs 47.8µs
HSET (신규 필드) 37.1µs 48.0µs 62.0µs 40.1µs
HGET 25.5µs 29.3µs 33.3µs 25.9µs
SADD 34.0µs 42.6µs 52.4µs 36.4µs
SISMEMBER 25.5µs 29.0µs 32.3µs 25.6µs
EXPIRE 35.2µs 45.0µs 59.4µs 38.6µs

단건 수치는 loopback RTT가 지배해 캐시 유무 영향이 작다 (I1: on 29.5µs vs off 26.4µs — 캐시 관리 비용 +3µs 수준). 캐시의 가치는 처리량에서 나온다(B·I).

B. 파이프라인 처리량 (sliding window, 100k ops, 3회 중앙값)

케이스 ops/s 비고
GET (캐시 적중) 321,824 서버 CPU 91% (bench 42%)
SET (덮어쓰기) 69,105 write-through, 캐시 off와 동일
SET (신규 삽입) 37,074 두 B-tree 실제 INSERT 경로
INCR (단일 키) 50,611 회귀 아님 — 한계 6의 규명 결과 참고 (단독 측정 시 57k)
MGET(10키) 139,411 호출/s = 1,394,110 keys/s 캐시 적중 다건 조회

파이프라인 깊이 스케일링 (GET, 캐시 적중):

depth 1 10 100 1000
ops/s 51,494 265,053 324,360 325,856

C. 페이로드 크기 (SET/GET)

크기 SET ops/s GET ops/s GET MB/s 비고
1KB 50,163 327,703 336 워킹셋 1MB — 전부 캐시 적중
16KB 20,561 204,149 3,345 워킹셋 16MB < 64MB 캐시 — 적중
256KB 1,854 3,152 826 워킹셋 256MB > 64MB 캐시 — LRU 순환으로 사실상 전부 미스 (캐시 전 런의 821MB/s와 일치)

256KB 행이 캐시 경계 효과를 그대로 보여준다: 워킹셋이 --cache-mb를 넘으면 순차 순환 접근에서 적중률 ~0이 되고 SQLite 경로 성능(이전 측정과 동일한 826MB/s)으로 떨어진다. 워킹셋 크기에 맞춰 --cache-mb를 설정하는 것이 가장 큰 성능 레버다.

D. 동시성

D1 — GET 처리량 vs 클라이언트 수 (총 인플라이트 ≈128, 캐시 적중):

클라이언트 1 4 16 64
ops/s 320,281 329,737 312,045 241,472
서버 CPU 104% 89% 90% 94%

~320–330k에서 플랫 — 단일 스레드 설계의 코어 1개 천장 (캐시로 천장 자체가 125k→330k로 올라감). 64 클라이언트에서 소켓당 오버헤드로 하락.

D2 — 혼합, 16 클라이언트: 80/20 = 166,316 · 50/50 = 100,788 · 20/80 = 67,363 ops/s. 읽기 비중이 높을수록 캐시 이득 (80/20은 94k(캐시 전 런) → 166k).

E. 영속성 / 키스페이스

E1 — 파일 WAL vs :memory::

SET ops/s GET ops/s
파일 + WAL 68,364 343,849
:memory: 122,811 338,755

읽기에서 파일/메모리 차이가 소멸 — 핫 캐시가 양쪽 모두의 읽기를 흡수하기 때문. 영속성의 비용은 이제 쓰기에만 남는다 (1.8×).

E2 — 키스페이스 크기: 1k 키 368,713 vs 100k 키 344,398 ops/s — 둘 다 캐시에 들어가므로 차이 거의 소멸 (174k vs 111k, 캐시 전 런).

E3 — 콜드 스타트 (100k키 파일 DB, 반복마다 새 파일 복사본, 5회): 기동 11.3ms + 첫 GET 2.1ms ≈ 13ms. 페이지 지연 로드 구조라 기동 시 데이터 재생 없음 (100k키 단일 지점 측정). 단, 캐시는 재시작 시 비어 있으므로 한계 1의 JIT 워밍업 + 캐시 재충전 전까지는 비적중 성능(~125k).

E4 — 지속 삽입 200k키: 청크별 39,625–45,254 ops/s, 체크포인트 톱니 없음. 종료 시 DB 25MB / WAL 2.4MB.

F. 기능 경로

F1 — 100-SET 묶음 전략 (20,000 SET): MSET 291,370 > MULTI/EXEC 136,293 > 파이프라인 107,044 > 순차 37,082 SETs/s. MULTI/EXEC가 개별 파이프라인보다 빠른 이유: EXEC가 100개 쓰기를 SQLite 트랜잭션 1개로 커밋 (개별 SET은 건마다 BEGIN/COMMIT).

F2 — Pub/Sub 팬아웃 (전달 msgs/s): 구독자 1 = 155,738 · 10 = 412,301 · 50 = 462,192.

F3 — 부하 중 꼬리지연 (open-loop GET @5k/s, TTL 처닝) — 캐시 도입 후에도 동일한 최중요 운영 지표:

리퍼 배치 상한(틱당 LIMIT 1000) 적용 후 측정:

레그 p50 p95 p99 max (상한 도입 전 p99)
베이스라인 803µs 1.4ms 2.6ms 7.7ms 2.6ms
50k키 분산 만료 (~4초 창, PEXPIREAT) 891µs 15.6ms 21.4ms 30.0ms 24.1ms
50k키 일괄 만료 (한 시점) 833µs 9.0ms 14.1ms 23.4ms 377ms

일괄 만료 p99가 377ms → 14.1ms (27배 개선). 리퍼가 틱(100ms)당 최대 1,000키만 회수하므로 5만 키 동시 만료가 한 틱에 몰리지 않고 여러 틱에 분산된다 — 한 번의 동기 스윕이 서버를 수백 ms 점유하던 문제 해소. (베이스라인 레그 절대치는 스케줄러 아티팩트 ~1ms — 한계 2.) 분산 만료 꼬리가 일괄보다 약간 긴 것은 만료가 측정 창 전체에 퍼져 매 틱 회수가 GET과 계속 경합하기 때문이고, 둘 다 이제 두 자릿수 ms로 안전 영역. 남은 한계: 단일 키 CASCADE가 대형 컬렉션을 끌고 가는 경우는 행이 아닌 키 단위라 여전히 상한 밖 — TIER 3의 O(1) DEL로 해결 예정.

F5 — 만료 키 GET (lazy-expiry): p50 40.1µs vs 일반 miss 22.5µs — DELETE-on-read 비용 +78%, 1회성.

F6 — 에러 경로: 정상 GET(단일 키, 캐시 적중) 384,755 vs WRONGTYPE GET 192,816 vs INCR 비숫자 140,324 ops/s. 캐시 도입으로 역전 — 에러 경로는 캐시를 타지 못해 이제 정상 경로의 절반. 여전히 병목은 아님. (단일 키 반복이라 B2와 절대치 비교 불가.)

F7 — APPEND 누적 (1KB×2,000): 첫 윈도 194µs → 마지막 윈도 3,473µs (17.9배) — BLOB 전체 재기록. 반복 APPEND 패턴 회피.

G. 기동 모드

spawn embed
GET ops/s (캐시 적중) 347,906 282,095 (−19%)
SET ops/s 67,903 64,349 (−5%)
GET service time p50 29.4µs 29.3µs

embed = 호스트 앱과 이벤트루프 공유 비용. 처리량 중요하면 spawn.

H. 컬렉션 카디널리티 (캐시 미적용 영역 — 1차 캐시는 string 전용)

카디널리티 HGETALL SMEMBERS
1k 0.4ms 0.2ms
10k 3.5ms 1.5ms
100k 57.1ms 17.2ms

SQL 랜덤 선택 적용 후 SRANDMEMBER(1) = 1.7ms / SPOP(1) = 1.7ms @100k 셋 (도입 전 4.6ms / 5.8ms — 약 3.4배 개선). 전체 멤버를 JS로 끌어오던 것을 단건 경로는 COUNT(*) + LIMIT 1 OFFSET random(균등 보장)으로 교체. 컬렉션 통째 DEL: 10k 5ms · 100k 45–55ms — 여전히 동기 정지(TIER 3 O(1) DEL 대상). HGETALL/SMEMBERS 자체는 전체 조회라 카디널리티 비례 유지.

I. 핫 캐시 on/off 차분 (동일 워크로드, 100k키 워킹셋)

캐시 ON (64MB) 캐시 OFF 배율
GET 처리량 332,261 124,600 2.67×
SET 처리량 69,016 69,562 0.99× (오버헤드 없음)
GET service time p50 / p99 29.5µs / 39.1µs 26.4µs / 47.3µs p50 +3.1µs (Map 관리 비용)
서버 CPU (GET 중) 93% 93% 같은 포화, 2.67배 일

캐시 정책: write-through(쓴 즉시 적중) + 적응형 TTI(히트 누적 시 idle 허용 로그 성장, 8× 캡, 스위프마다 반감) + LRU 바이트 상한. 데이터 정합성: SQLite가 항상 진실 — 캐시는 순수 읽기 가속이라 재시작/영속성 의미론 무변화 (전체 테스트 141개 통과, 이 중 계약 테스트 73개는 순정 클라이언트가 캐시 ON 기본 서버를 경유).


발견된 성능 특성 정리

# 특성 영향 회피/개선
1 핫 캐시 적중 = 2.67× 읽기, 쓰기 무비용 (I1) 워킹셋 ≤ cache-mb일 때 워킹셋에 맞춰 --cache-mb 설정
2 워킹셋 > 캐시면 적중률 붕괴 (C1 256KB) 비적중 = 기존 SQLite 성능 캐시 증설 또는 기대치 조정
3 리퍼 일괄 만료 스톨 (해결) 배치 상한으로 p99 377ms→14ms (F3) TTL 지터 권장은 유지
4 캐시 적중 경로 콜드 구간 ~10만 op (B2/I1, 원인 미확정) 기동 직후 ~90k ops/s 장수 프로세스 전제, 워밍업 트래픽
5 SPOP·SRANDMEMBER 전체 로드 (해결) SQL 랜덤 선택으로 5.8ms→1.7ms (H)
6 APPEND 전체 재기록 (F7) 194µs→3.5ms @2MB 반복 APPEND 회피
7 대량 쓰기 MSET ≫ MULTI > 파이프라인 (F1) 최대 2.7× 클라이언트 패턴 선택
8 단일 코어 천장: 캐시로 125k→330k GET/s 상승 (D1) 코어 추가 무효 설계상 의도
9 콜드 스타트 13ms, 단 캐시 재충전 필요 (E3) 재시작 직후 읽기 ~125k

재현

bun run bench/run.ts             # 전체 (~3분)
bun run bench/run.ts --quick     # 축소 스모크
bun run bench/run.ts --only I1   # 캐시 on/off 차분만

결과는 bench/results.json (메타데이터: 커밋, Bun 버전, 하드웨어, 측정 구성 포함).