0. Contents


1. Overview

g6e.xlarge 1대 · 들어오는 규칙 없음관리자 노트북upload.shRun Command복사 · 실행측정 컨테이너measure.sh서버 컨테이너127.0.0.1:8000L40S46GB · 864GB/s① 스크립트 보냄복사 · 실행동시 1, 2, 4 … 64 마다② /v1/completions가중치 + KV 캐시토큰 · TTFT / ITLc<N>.json · metrics 기록③ 결과 → summarize.py 표STOP · 부팅마다 자동 중지 타이머 · CPU 30분 유휴 알람
위에서 아래로 시간 순서 — 회색 점선 상자를 동시 요청 단계마다 되풀이한다. 측정 컨테이너는 서버와 같은 네트워크라 127.0.0.1 로 닿고, 들어오는 포트는 없다

방향 변경 (2026-11-03)

  • 이 글의 외부 개발자용 API 계획은 이후 접었다
  • 측정 결과와 방법은 그대로 유효하고, 사용자 클러스터 애드온의 측정과 429 기준선의 원형이 된다

측정 목적: GPU 한 장의 동시 처리량

  • 외부 개발자에게 LLM API 를 열기 전에 쟀다
  • 잰 것: GPU 한 장이 약속한 속도를 지키며 동시에 몇 요청까지 처리하는지
  • 클러스터를 만들기 전에 이 숫자가 있어야 정할 수 있는 것
    • GPU 를 몇 장 둘지
    • 한 달에 얼마인지
    • 몇 요청부터 429 로 돌려보낼지

측정 환경

모델SLA 초안 안 최대 동시 요청
gpt-oss-20b32
Qwen3-30B-A3B (FP8)16 (촘촘히 재면 20, KV 캐시 FP8 이면 24)
Qwen3-8B16
Qwen3.8-27B (FP8)4

결과 요약

  • 두 MoE 모델이 dense 모델보다 빨랐다
  • 27B dense 모델은 메모리 대역폭에 막혀 한 장으로는 서빙용이 아니었다(7장)
  • KV 캐시를 FP8 로 저장하니 같은 GPU 로 20% 를 더 받았다(8장)
  • 품질은 이 글에서 재지 않았다

2. Background — 외부 개발자용 LLM API

1단계GPU 1대 실측모델 · 엔진 · SLA2단계전용 EKS게이트웨이 · 4293단계자동 확장Karpenter · KEDA · 베타4단계분리 서빙prefill / decode이 글1단계의 숫자 → 429 기준선 · 노드 수 · 월 비용
1단계 숫자가 2단계 게이트웨이의 429 기준선과 3단계 자동 확장 기준이 된다

계획: 오픈소스 LLM API

  • 오픈소스 LLM 을 우리 GPU 에 올린다
  • 외부 개발자가 API 키로 OpenAI 처럼 쓰게 한다

단계 네 개

  1. 실측
  2. 전용 EKS 와 게이트웨이(키·한도·429)
  3. 자동 확장과 베타
  4. prefill·decode 분리 서빙 실험

순서: 클러스터보다 실측 먼저

  • 2단계 이후의 설정값이 전부 1단계 숫자에서 나온다
    • 게이트웨이가 몇 번째 요청부터 429 를 낼지
    • 워커를 언제 늘릴지
    • GPU 노드를 최대 몇 대까지 둘지
  • 토스의 서빙 발표(SlashLive, 2026-10-14)도 배포 전에 “SLA 를 지키는 데 필요한 GPU 수와 비용” 을 먼저 계산한다고 했다
  • 우리는 그 계산을 실제 GPU 한 대로 한다

GPU 선택: L40S(48GB)

  • 7B~30B 급 모델이 한 장에 들어간다
  • 서울 리전에 1장 단위 인스턴스(g6e.xlarge, 시간당 $2.29)가 있다
  • H100 같은 P 계열은 8장 묶음뿐이라 1단계에는 맞지 않는다

3. SLA 초안 — 첫 토큰과 토큰 간격

요청 하나대기입력 처리 (prefill)토큰토큰토큰토큰토큰토큰토큰토큰토큰TTFT p95 ≤ 1초ITL p95 ≤ 50ms동시 요청 = 같은 순간 처리 중인 요청 수 (사용자 수가 아니다)답 200토큰 ≈ 5초. 1분에 한 번 묻는 사람은 1분 중 5초만 GPU 를 쓴다→ 동시 16 요청 ≈ 16 × (60 ÷ 5) ≈ 190명이 쉬지 않고 쓰는 정도
약속은 둘 — 글자가 나오기 시작하는 시간, 그리고 글자가 나오는 속도

채팅형 API 의 약속: 두 숫자

  • 채팅형 API 의 약속을 아래 두 숫자로 잡았다
지표초안뜻
TTFT p95≤ 1초요청 후 첫 토큰까지. 1초 안에 글자가 나오기 시작하면 바로 반응한다고 느낀다
ITL p95≤ 50ms토큰과 토큰 사이. 초당 20토큰 이상이면 읽는 속도보다 빠르다

판정 기준: 두 조건 동시 충족

  • 두 조건을 동시에 지키는 가장 높은 동시 요청 수를 “GPU 1장의 실력” 으로 본다
  • p95 를 쓰는 이유: 평균이 아니라 대부분의 사용자가 겪는 쪽을 보기 위해서다

동시 요청 수와 사용자 수의 차이

  • 동시 요청 수는 사용자 수가 아니다 — 같은 순간 답을 만들고 있는 요청의 수다
  • 답 하나에 5초쯤 걸리고 한 사람이 1분에 한 번 묻는다면
    • 동시 16 요청은 쉬지 않고 쓰는 사람 약 190명에 해당한다
  • 몰리는 시간을 생각하면 여유를 둬야 한다
  • 그래도 한 장이 수십~백 명대를 감당한다는 감은 이 계산에서 나온다

4. 측정 서버 — GPU 한 대와 비용 장치

정의 위치와 적용 방식

  • 측정 서버는 별도 레포(kukie-gpu-infra)의 Terraform root terraform/llm-bench 로 만들었다
  • 잠깐 쓰고 지울 실험 서버라 관리자 로컬 적용으로 두었다
항목값
인스턴스g6e.xlarge 온디맨드, Deep Learning Base AMI (AL2023, NVIDIA 드라이버·Docker 포함)
디스크gp3 200GB — 모델과 이미지를 남겨 다시 켤 때 1~2분
네트워크전용 VPC, 들어오는 규칙 없음, 나가는 방향 TCP 443 만
접속SSM Session Manager
권한SSM 관리형 정책이 여는 Parameter Store 읽기를 인라인 Deny 로 막음 — 서드파티 모델 코드가 돌기 때문

비용 장치: 두 겹

  • GPU 는 켜 둔 시간만큼 돈이 나간다
  • 끄는 걸 잊는 일을 막으려고 장치를 두 겹 두었다
① 부팅마다 타이머user_data 는 첫 부팅에만 — 유닛은 매번TAGAutoStopMinutesTerraform 변수 → 태그IMDS태그 읽기토큰 필수 · hop 1UNITsystemd 유닛shutdown -h +240EC2중지 (stop)shutdown 동작 = stop② 유휴 알람CWCPUUtilization평균 < 3% · 5분 × 6STOPec2:stop끝난 뒤 켜 둔 경우
중지는 GPU 요금이 멈추고 디스크(모델·이미지)는 남는다. 종료하면 디스크까지 사라진다

리뷰 수정: 타이머 값을 인스턴스 태그로

  • 처음에는 타이머 분 값을 user_data 에 넣었다
  • 리뷰 지적: “user_data 는 첫 부팅에만 실행되니 값을 바꿔도 서버에 반영되지 않는다”
  • 지금은 분 값을 인스턴스 태그로 넘긴다
    • 부팅마다 systemd 유닛이 IMDS에서 태그를 읽는다
    • 값을 바꾸면 태그만 바뀌고 서버는 다시 만들어지지 않는다

첫 apply 중단

  • 첫 apply 는 중간에 멈췄다
apply ①IAM 4개 생성VPCVpcLimitExceeded정리dns-lab-tw 삭제한도5 → 10 요청 · 승인apply ②나머지 9개서울 리전 VPC 한도 기본값은 5개 — dev, 호스팅, CI 러너, 실습 클러스터, 다른 팀원 VPC 로 이미 다 차 있었다
Terraform 은 이미 만든 IAM 네 개를 state 에 남겨, 두 번째 apply 는 나머지 아홉 개만 만들었다

원인: 서울 리전 VPC 한도

  • 서울 리전의 VPC 한도는 기본 5개였고 이미 다 차 있었다
  • 기한이 지난 실습 클러스터를 지우고 한도 증가를 요청했다 → 같은 날 승인됐다
  • Terraform 은 실패 전에 만든 IAM 네 개를 state 에 남겨 둔다 → 두 번째 apply 는 나머지만 만들었다

5. 측정 방법 — 동시 요청 단계와 지표 기록

measure.sh — 단계마다for c in 1 2 4 8 16 32 64BG지표 기록2초마다 /metrics대기 줄 · KV 사용률RUNvllm bench serve요청 max(40, 8c)개입력 1,000 / 출력 200JSONc<N>.jsonTTFT · ITL p50/p95/p99초당 출력 토큰TXTmetrics-c<N>.txt단계 동안 최고값→ 429 기준선 후보누가 부르나EC2bench.shvLLM 컨테이너 띄우기→ measure.sh(1단계)K8SJobBASE_URL = vLLM Service→ measure.sh(2단계 · GPU 화면)
measure.sh 는 서버를 띄우지 않는다 — EC2 는 bench.sh, EKS 는 Job 이 같은 스크립트를 부른다

부하 생성: vllm bench serve

  • 부하는 vLLM 에 들어 있는 vllm bench serve 로 만들었다
  • 요청은 무작위 문장이다
    • 입력 약 1,000토큰
    • 출력은 --ignore-eos 로 정확히 200토큰
  • 단계마다 요청을 max(40, 동시 수 × 8) 개 보내 p95 를 낼 표본을 확보했다
vllm bench serve --model "$MODEL" --base-url "$BASE_URL" \
  --dataset-name random --random-input-len 1000 --random-output-len 200 --ignore-eos \
  --num-prompts "$prompts" --max-concurrency "$c" \
  --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,95,99

서버 지표 기록

  • 같은 시간 동안 서버의 /metrics 에서 2초마다 기록했다
    • 대기 요청 수
    • KV 캐시 사용률
  • SLA 가 깨지기 직전의 이 두 값이 429 기준선 후보다

클라이언트 위치: 서버와 같은 호스트

  • 측정 클라이언트는 서버와 같은 호스트에서 돌렸다
  • 집에서 인터넷을 거쳐 재면 네트워크 지연이 TTFT 에 섞인다 → 엔진 실력과 구분되지 않는다

측정 스크립트와 서버의 분리

  • 측정 스크립트(measure.sh)는 서버를 띄우지 않고 BASE_URL 만 받는다
  • EC2: bench.sh 가 vLLM 컨테이너를 띄운 뒤 이 스크립트를 부른다
  • EKS: 같은 스크립트를 Job 으로 vLLM Service 에 돌린다(아직 구현 전)

6. 결과 — 모델 네 개

gpt-oss-20bQwen3-30B-A3BQwen3-8BQwen3.8-27B30ms100ms300ms1s3s10s30s1248163264동시 요청 수TTFT p95SLA 1초
가로·세로 모두 로그 눈금. 빨간 점선이 SLA 초안 1초
gpt-oss-20bQwen3-30B-A3BQwen3-8BQwen3.8-27B5ms10ms20ms50ms100ms200ms500ms1248163264동시 요청 수ITL p95SLA 50ms
토큰 간격은 완만하게 늘다가 한계에서 꺾인다. Qwen3.8-27B 는 출발점부터 높다
gpt-oss-20b32Qwen3-30B-A3B→ 20 (촘촘히) · 24 (KV FP8)16Qwen3-8B16Qwen3.8-27B4같은 L40S 1장 · 입력 약 1,000 / 출력 200토큰 · TTFT p95 ≤ 1초 & ITL p95 ≤ 50ms
Qwen3-30B-A3B 의 연한 막대는 16~32 를 4씩 다시 잰 값(20)과 KV FP8 (24)
모델구조 · 정밀도크기혼자일 때 ITL최대 동시무엇에서 깨지나
gpt-oss-20bMoE (활성 3.6B) · MXFP4약 13GB6.8ms3264 에서 ITL
Qwen3-30B-A3B-Instruct-2507MoE (활성 3.3B) · FP8약 31GB8.1ms16 → 2032 에서 ITL
Qwen3-8Bdense · BF16약 16GB22.5ms1632 에서 TTFT
Qwen3.8-27Bdense (Mamba 하이브리드) · FP8약 31GB40.3ms48 에서 TTFT

깨지는 모양 두 가지

  • Qwen3-8B 와 Qwen3.8-27B: 첫 토큰이 늦어지며 깨졌다
    • 요청이 줄을 선다
  • 두 MoE 모델: 토큰 간격이 튀며 깨졌다
    • 새 요청의 입력 처리가 진행 중인 요청의 토큰 생성을 밀어낸다

Qwen3.8-27B 기동 실패: Mamba 캐시 블록 부족

  • Qwen3.8-27B 는 처음에 아예 뜨지 않았다
  • 하이브리드(Mamba) 구조라 동시 요청마다 Mamba 캐시 블록이 하나씩 필요하다
  • vLLM 기본 최대 동시 수 256 이 이 GPU 에 남은 185 블록을 넘었다
  • --max-num-seqs 128 로 다시 쟀다

7. 해석 — 메모리 대역폭과 KV 캐시

Qwen3.8-27B27.8GB · dense FP8실측 40ms하한 32.2msQwen3-8B16.4GB · dense BF16실측 22.2ms하한 19msQwen3-30B-A3B3.3GB · MoE 활성 3.3B · FP8실측 7.9ms하한 3.8msgpt-oss-20b≈1.9GB · MoE 활성 3.6B · MXFP4실측 6.6ms하한 2.2ms토큰 1개 = 가중치를 한 번 읽기 → 혼자 쓸 때 속도는 '읽는 양 ÷ 대역폭'에 묶인다
진한 막대 = 실측(동시 1, ITL p50). 연한 막대 = 읽는 바이트 ÷ 864GB/s 로 계산한 하한

동시 1 의 토큰 간격: 가중치 ÷ 대역폭

  • 혼자 쓸 때(동시 1)의 토큰 간격은 “토큰 하나에 읽어야 하는 가중치 ÷ GPU 메모리 대역폭” 으로 거의 설명된다
  • 다음 토큰을 만들려면 가중치를 처음부터 끝까지 한 번 읽어야 한다
  • L40S 는 초당 864GB 를 읽는다

모델별 읽는 양과 하한

  • Qwen3.8-27B
    • 270억 개를 FP8(1바이트)로 전부 읽는다
    • 27.8GB ÷ 864GB/s ≈ 32ms 가 하한이고 실측은 40ms 였다
    • 기준 50ms 와의 여유가 처음부터 10ms 뿐이다 → 동시 요청을 거의 받지 못한다
  • Qwen3-30B-A3B 와 gpt-oss-20b
    • 전체는 크지만 토큰마다 일부 전문가(expert)만 읽는다
    • 읽는 양이 3.3GB, 약 1.9GB 로 작아 하한이 몇 ms 다
    • 실측이 하한의 2~3배인 것은 라우팅, 어텐션, KV 캐시 읽기 같은 고정 비용 때문이다

동시 요청이 늘 때의 한계: KV 캐시 자리

  • 동시 요청이 늘면 다른 자원이 한계가 된다
  • 요청마다 KV 캐시가 GPU 메모리를 차지한다
  • Qwen3.8-27B: 가중치가 31GB 라 KV 캐시에 남은 자리가 약 9.3GB(11만 8천 토큰)뿐이었다
    • 동시 64 에서 KV 사용률이 99% 에 닿았다
  • gpt-oss-20b: 가중치가 13GB 라 같은 동시 64 에서도 8% 였다

정리: L40S 1장에 유리한 모델

  • L40S 1장으로 많은 동시 요청을 받으려면 토큰마다 읽는 양이 작고(MoE·낮은 정밀도) 가중치가 작아 KV 캐시 자리가 넉넉한 모델이 유리하다
  • 27B dense 모델은 품질이 확실히 좋다고 확인되면 GPU 두 장 구성(텐서 병렬)으로 따로 잰다

8. 엔진 설정 — KV 캐시 FP8

동시 16동시 20동시 24동시 28동시 32기본값583685723787858chunk 2048573678722779853chunk 2048 + KV FP8619803880856914KV 캐시를 FP8 로 저장하면 같은 메모리에 두 배의 토큰 → 대기 없이 받는 요청이 는다
칸 안 숫자는 초당 출력 토큰. 초록 테두리 = SLA 안. KV FP8 만 24 를 지켰다

재측정 대상과 범위

  • 모델: 가장 유력한 후보인 Qwen3-30B-A3B
  • 기본값이 깨지던 구간(동시 16~32)을 4씩 다시 쟀다
설정최대 동시동시 24 초당 출력 토큰
기본값20723
--max-num-batched-tokens 204820722
위 + --kv-cache-dtype fp824880

prefill 분할: 차이 없음

  • prefill 을 작게 나누는 설정은 차이가 없었다
  • vLLM 이 이미 비슷한 값을 쓰는 것으로 보인다

KV 캐시 FP8: 20 → 24

  • KV 캐시를 FP8 로 저장하니 같은 메모리에 두 배의 토큰이 들어갔다
    • 대기 없이 받는 요청이 20 에서 24 로 늘었다
    • 처리량은 22% 올랐다
  • 설정마다 한 번씩만 쟀다 → 몇 % 차이는 오차일 수 있다
  • 그래도 20 과 24 의 차이는 분명했다 → 배포 인자 후보로 둔다

9. 리뷰에서 고친 것 — 네트워크 네임스페이스와 pipefail

측정 스크립트 PR 리뷰

  • 측정 스크립트 PR 의 리뷰에서 두 가지를 고쳤다
처음 — --network hostEC2호스트 네트워크CLI측정 컨테이너 (호스트와 같은 홉)IMDS169.254.169.254 → 역할 자격증명 ✕고친 뒤 — container:vllmEC2호스트 네트워크도커 브리지 뒤 네임스페이스 하나vLLM 서버 + 측정 컨테이너 · 127.0.0.1:8000IMDS토큰 응답 hop 1 → 닿지 않음 ✓
hop limit 1 은 브리지를 한 번 더 지나는 컨테이너만 막는다 — host 네트워크는 그 장치를 비껴간다

측정 컨테이너의 네트워크

  • 처음에는 측정 컨테이너를 --network host 로 띄웠다
    • 서버 주소(127.0.0.1:8000)에 바로 닿게 하려는 것이었다
  • 문제: host 네트워크 컨테이너는 호스트와 같은 홉에 있어 IMDS hop limit 1 장치를 비껴간다
    • 서드파티 이미지 안의 코드가 인스턴스 역할을 읽을 수 있는 구성이었다
  • 수정: 서버 컨테이너의 네트워크 네임스페이스를 공유하는 --network container:vllm 으로 바꿨다
    • 같은 주소를 쓰면서 브리지 뒤에 남는다

지표 기록이 조용히 멈추는 문제

  • 지표를 2초마다 기록하는 루프가 스크립트의 set -euo pipefail 을 물려받았다
  • curl 이 한 번 실패하거나 grep 이 한 줄도 못 찾으면 → 그 단계 동안 기록이 끊겼다
  • 오류 없이 표에 “-” 만 찍힌다
  • 이 값이 429 기준선의 근거라 루프 안의 파이프라인 끝에 || true 를 붙였다
curl -s "$BASE_URL/metrics" |
  grep -E '^vllm:(num_requests_waiting|kv_cache_usage_perc)' |
  sed "s/^/$(date +%s) /" || true

10. 다음 단계

EVAL품질 평가한국어 문제집 · AI 채점PICK첫 모델속도 × 품질로 선택EKSkukie-gpuGPU 노드 0~2대JOBJobmeasure.sh · 429 기준선
속도 다음은 품질 — 그 뒤 같은 측정을 클러스터 안 Job 으로

이후 진행 (2026-11-03 기준)

항목상태
품질 비교공개 지수로 먼저 비교했다. 격차가 커서 방향을 바꿨다. 한국어 질문 세트는 아직 없다
첫 모델gpt-oss-20b 로 Kukie GPU 화면 데모와 오퍼레이터 샘플을 구성했다. 카탈로그 선정은 아직이다
전용 EKS kukie-gpu적용됐다(GPU 노드 0~2대, 평소 0대)
measure.sh 의 Job 전환구현 전. 애드온 2층의 측정 Job 이 된다
429 기준선구현 전. 게이트웨이가 아니라 애드온 어드미션에서 쓴다

측정 비용

  • 측정 서버를 켜 둔 시간: 약 2시간 7분
  • GPU 비용: 약 $4.9

관련 문서

  • TTFT — 첫 토큰까지의 시간
  • ITL — 토큰 사이 간격
  • Concurrency — 동시 요청과 지연이 꺾이는 지점
  • Goodput — SLA 를 지킨 요청만 센 처리량
  • Percentile — p95 로 읽는 법
  • KV 캐시 — 요청마다 차지하는 메모리와 FP8
  • MoE — 전체 파라미터와 활성 파라미터
  • IMDS — hop limit 과 컨테이너

출처