목표와 현재 상태

동일한 single-node k3s와 RTX 4090에서 vLLM의 cache와 memory 설정을 한 번에 하나씩 변경해 안정적인 운영 기준을 찾는다.

구분상태확인 내용
최적화 engine완료vLLM과 Google Gemma 4 12B W4A16 checkpoint 선택
baseline 저장완료image, args, env, resource request·limit 기록
cold cache완료빈 PVC에서 Pod Ready까지 270초
warm cache완료같은 PVC 재사용 시 Pod Ready까지 171초
GPU memory budget측정 진행 중0.70, 0.75, 0.80, 0.85 API 5회 성공, metric 비교 대기
0.85 후보완료실제 patch 값 0.85, warm 평균 1.642079초
context length완료4096, 8192, 16384 비교 후 8192로 복구
scheduler 기준선완료max-num-seqs 1, 2, 4에서 순차 API 각 5회 성공
concurrency latency완료max-num-seqs 1, 2, 4에서 concurrency 1, 2, 4, 8 모두 HTTP 200
scheduler metric완료세 후보의 지속 부하에서 running·waiting·KV cache·preemption·GPU metric 기록
최종 후보 API완료prompt 398, 1550, 3086 token에서 총 15/15 HTTP 200
baseline 원복완료context 8192, max-num-seqs 4, GPU memory utilization 0.80, 원래 PVC로 복구

1. 변경 전 baseline 저장

최적화 이전 설정을 YAML로 보존하고 실제 container 설정을 조회했다.

sudo k3s kubectl -n gemmaforge get deployment gemma4-12b \
  -o yaml \
  > ~/gemma4-12b-before-step07.yaml
sudo k3s kubectl -n gemmaforge get deployment gemma4-12b \
  -o json \
  | jq '.spec.template.spec.containers[]
    | select(.name == "vllm")
    | {image, args, env, resources}'
항목baseline 값의미
imagevllm/vllm-openai:v0.24.0최적화 비교에 사용할 engine version
checkpointgoogle/gemma-4-12B-it-qat-w4a16-ctW4A16 QAT model artifact
context8192prompt와 output을 합친 최대 길이
max sequences4iteration당 최대 sequence 수
GPU memory utilization0.75변경 전 실제 baseline 값
Hugging Face cache/models/huggingfacePVC mount와 일치해야 하는 cache 경로
GPU limit1RTX 4090 한 장을 Pod에 할당

현재 설치된 vLLM이 실험 대상 인자를 지원하는지 container 내부 help로 확인했다.

sudo k3s kubectl -n gemmaforge exec \
  deployment/gemma4-12b \
  -- vllm serve --help=gpu-memory-utilization
sudo k3s kubectl -n gemmaforge exec \
  deployment/gemma4-12b \
  -- vllm serve --help=max-model-len
sudo k3s kubectl -n gemmaforge exec \
  deployment/gemma4-12b \
  -- vllm serve --help=max-num-seqs
sudo k3s kubectl -n gemmaforge exec \
  deployment/gemma4-12b \
  -- vllm serve --help=max-num-batched-tokens

2. cold cache와 warm cache 비교

model을 PVC에 저장했을 때 최초 준비와 재기동 시간이 얼마나 달라지는지 비교했다. 기존 정상 PVC는 보존하고 별도의 빈 40Gi PVC를 만들었다.

# cache-cold-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: gemma4-model-cache-cold
  namespace: gemmaforge
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 40Gi
k3s kubectl apply -f cache-cold-pvc.yaml

빈 PVC를 vLLM의 model cache volume으로 연결했다.

SECONDS=0
sudo k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=strategic \
  -p '{
    "spec": {
      "template": {
        "spec": {
          "volumes": [
            {
              "name": "model-cache",
              "persistentVolumeClaim": {
                "claimName": "gemma4-model-cache-cold"
              }
            }
          ]
        }
      }
    }
  }'

빈 PVC에서 model을 준비하고 Pod가 Ready가 될 때까지 측정했다.

sudo k3s kubectl -n gemmaforge rollout status \
  deployment/gemma4-12b \
  --timeout=60m
printf 'vLLM cold PVC patch 시작부터 Pod Ready까지 %s초\n' "$SECONDS"

Pod 안에서 실제 PVC mount 경로를 확인했다.

k3s kubectl -n gemmaforge get deployment gemma4-12b \
  -o jsonpath='{range .spec.template.spec.containers[?(@.name=="vllm")].volumeMounts[*]}{.name}{"\t"}{.mountPath}{"\n"}{end}'
k3s kubectl -n gemmaforge exec \
  deployment/gemma4-12b \
  -- du -sh /models/huggingface
k3s kubectl -n gemmaforge exec \
  deployment/gemma4-12b \
  -- find /models/huggingface \
    -maxdepth 4 \
    -type d

같은 PVC를 유지한 채 rollout restart하여 저장된 model cache를 재사용했다.

SECONDS=0
sudo k3s kubectl -n gemmaforge rollout restart deployment/gemma4-12b
sudo k3s kubectl -n gemmaforge rollout status \
  deployment/gemma4-12b \
  --timeout=40m
printf 'vLLM warm cache Pod Ready까지 %s초\n' "$SECONDS"
구분Pod Ready차이해석
cold cache270초기준빈 PVC에 model을 내려받고 cache를 생성하는 시간이 포함됨
warm cache171초99초 단축같은 PVC의 9.6GiB cache를 재사용
개선율36.7%cold 대비PVC cache가 재기동 준비 시간을 유의미하게 줄임

cold PVC에는 checkpoint revision 1d2c2d7f2466070e69d6fb3fd5ce9a7d75f2f6eeblobs, refs, snapshots가 생성됐다. 따라서 warm 재기동의 단축은 실제 Hugging Face cache 재사용 결과로 판단한다. 기존 gemma4-model-cache PVC 복구 결과는 아직 전달되지 않았으므로 완료로 기록하지 않는다.

3. GPU memory budget

현재 Deployment 인자 위치와 baseline 값을 확인했다.

sudo k3s kubectl -n gemmaforge get deployment gemma4-12b \
  -o jsonpath='{.spec.template.spec.containers[0].args}{"\n"}'

이 단계는 quantization 비교가 아니라 --gpu-memory-utilization 후보 비교다. model artifact와 나머지 args를 유지하고 memory budget 값만 변경했다. vLLM에서 사용할 동일 조건의 공식 Gemma 4 12B Q8 artifact는 확인되지 않아 quantization 비교는 실행하지 않았다.

0.70

sudo k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=json \
  -p='[
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/name",
      "value": "vllm"
    },
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/9",
      "value": "--gpu-memory-utilization"
    },
    {
      "op": "replace",
      "path": "/spec/template/spec/containers/0/args/10",
      "value": "0.70"
    }
  ]'
for i in $(seq 1 5); do
  curl -sS \
    -o "/tmp/gemmaforge-07-vllm-memory-${i}.json" \
    -w "run=${i} http=%{http_code} total=%{time_total}s\n" \
    http://127.0.0.1:8100/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d '{
      "model": "gemma-4-12b-q4",
      "messages": [
        {
          "role": "user",
          "content": "Kubernetes의 장점을 세 가지로 요약해줘."
        }
      ],
      "max_tokens": 128,
      "temperature": 0
    }'
done

0.75

sudo k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=json \
  -p='[
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/name",
      "value": "vllm"
    },
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/9",
      "value": "--gpu-memory-utilization"
    },
    {
      "op": "replace",
      "path": "/spec/template/spec/containers/0/args/10",
      "value": "0.75"
    }
  ]'
for i in $(seq 1 5); do
  curl -sS \
    -o "/tmp/gemmaforge-07-vllm-memory-${i}.json" \
    -w "run=${i} http=%{http_code} total=%{time_total}s\n" \
    http://127.0.0.1:8100/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d '{
      "model": "gemma-4-12b-q4",
      "messages": [
        {
          "role": "user",
          "content": "Kubernetes의 장점을 세 가지로 요약해줘."
        }
      ],
      "max_tokens": 128,
      "temperature": 0
    }'
done

0.80

sudo k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=json \
  -p='[
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/name",
      "value": "vllm"
    },
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/9",
      "value": "--gpu-memory-utilization"
    },
    {
      "op": "replace",
      "path": "/spec/template/spec/containers/0/args/10",
      "value": "0.80"
    }
  ]'
for i in $(seq 1 5); do
  curl -sS \
    -o "/tmp/gemmaforge-07-vllm-memory-${i}.json" \
    -w "run=${i} http=%{http_code} total=%{time_total}s\n" \
    http://127.0.0.1:8100/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d '{
      "model": "gemma-4-12b-q4",
      "messages": [
        {
          "role": "user",
          "content": "Kubernetes의 장점을 세 가지로 요약해줘."
        }
      ],
      "max_tokens": 128,
      "temperature": 0
    }'
done

0.85

sudo k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=json \
  -p='[
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/name",
      "value": "vllm"
    },
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/9",
      "value": "--gpu-memory-utilization"
    },
    {
      "op": "replace",
      "path": "/spec/template/spec/containers/0/args/10",
      "value": "0.85"
    }
  ]'
for i in $(seq 1 5); do
  curl -sS \
    -o "/tmp/gemmaforge-07-vllm-memory-${i}.json" \
    -w "run=${i} http=%{http_code} total=%{time_total}s\n" \
    http://127.0.0.1:8100/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d '{
      "model": "gemma-4-12b-q4",
      "messages": [
        {
          "role": "user",
          "content": "Kubernetes의 장점을 세 가지로 요약해줘."
        }
      ],
      "max_tokens": 128,
      "temperature": 0
    }'
done
실제 적용값HTTP 성공run 1run 2~5 평균판단
0.705/52.919088초1.677039초단일 요청 기능 검증 통과
0.755/52.956636초1.702058초변경 전 baseline, 기능 검증 통과
0.805/52.768130초1.653303초0.70, 0.75보다 warm 평균이 짧음
0.855/52.818173초1.642079초네 후보 중 warm 평균이 가장 짧음

네 번의 요청 세트는 모두 HTTP 200이었고 실제로 적용된 0.70~0.85 범위에서는 단일 요청 API가 정상 동작했다. 0.85의 warm 평균이 1.642079초로 가장 짧았지만 후보 간 차이가 작고 VRAM 최고값, KV cache 최고값, queue, restart·OOM 및 rollout 결과가 함께 기록되지 않았으므로 아직 최종 최적값으로 확정하지 않는다.

4. context length와 scheduler

같은 model과 GPU memory budget을 유지한 상태에서 max-model-len을 변경해 실제 API context 경계를 확인했다. 이후 8192로 복구하고 max-num-seqs만 변경해 순차 단일 요청 기준선을 측정했다.

공통 context payload 준비

아래 payload를 한 번 생성하고 모든 context에서 동일하게 사용했다.

for repeat in 64 256 512 768 1024; do
  jq -n \
    --arg content "$(for i in $(seq 1 "$repeat"); do
      printf 'Kubernetes GPU serving reliability test. '
    done)" \
    '{
      "model": "gemma-4-12b-q4",
      "messages": [
        {
          "role": "user",
          "content": $content
        }
      ],
      "max_tokens": 128,
      "temperature": 0
    }' > "/tmp/gemmaforge-vllm-context-${repeat}.json"
done

context 4096

sudo k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=json \
  -p='[
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/5",
      "value": "--max-model-len"
    },
    {
      "op": "replace",
      "path": "/spec/template/spec/containers/0/args/6",
      "value": "4096"
    }
  ]'
sudo k3s kubectl -n gemmaforge rollout status \
  deployment/gemma4-12b \
  --timeout=40m
for repeat in 64 256 512 768 1024; do
  jq '{model, messages}' \
    "/tmp/gemmaforge-vllm-context-${repeat}.json" \
    | curl -fsS http://127.0.0.1:8100/tokenize \
        -H 'Content-Type: application/json' \
        -d @- \
    | jq --arg repeat "$repeat" \
        '{
          repeat: $repeat,
          prompt_tokens: .count,
          max_model_len
        }'
done
for repeat in 64 256 512 768 1024; do
  curl -sS \
    -o "/tmp/gemmaforge-vllm-context-4096-result-${repeat}.json" \
    -w "context=4096 repeat=${repeat} http=%{http_code} total=%{time_total}s\n" \
    http://127.0.0.1:8100/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d @"/tmp/gemmaforge-vllm-context-${repeat}.json"
done
for repeat in 64 256 512 768 1024; do
  jq --arg repeat "$repeat" '{
    repeat: $repeat,
    error: (.error.message // null),
    finish_reason: (.choices[0].finish_reason // null),
    prompt_tokens: (.usage.prompt_tokens // null),
    completion_tokens: (.usage.completion_tokens // null)
  }' "/tmp/gemmaforge-vllm-context-4096-result-${repeat}.json"
done

repeat=64·256·512는 HTTP 200으로 128 token을 생성했다. repeat=768·1024는 prompt와 output 합계가 4096을 넘어서 HTTP 400으로 거절됐다. 이는 장애가 아니라 설정한 context 경계가 정상 적용된 결과다.

context 8192

k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=json \
  -p='[
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/5",
      "value": "--max-model-len"
    },
    {
      "op": "replace",
      "path": "/spec/template/spec/containers/0/args/6",
      "value": "8192"
    }
  ]'
sudo k3s kubectl -n gemmaforge rollout status \
  deployment/gemma4-12b \
  --timeout=40m
curl -fsS http://127.0.0.1:8100/v1/models \
  | jq '.data[] | {id, root, max_model_len}'
 
for repeat in 64 256 512 768 1024; do
  jq '{model, messages}' \
    "/tmp/gemmaforge-vllm-context-${repeat}.json" \
    | curl -fsS http://127.0.0.1:8100/tokenize \
        -H 'Content-Type: application/json' \
        -d @- \
    | jq --arg repeat "$repeat" \
        '{
          repeat: $repeat,
          prompt_tokens: .count,
          max_model_len
        }'
done
for repeat in 64 256 512 768 1024; do
  curl -sS \
    -o "/tmp/gemmaforge-vllm-context-8192-result-${repeat}.json" \
    -w "context=8192 repeat=${repeat} http=%{http_code} total=%{time_total}s\n" \
    http://127.0.0.1:8100/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d @"/tmp/gemmaforge-vllm-context-${repeat}.json"
done
for repeat in 64 256 512 768 1024; do
  jq --arg repeat "$repeat" '{
    repeat: $repeat,
    error: (.error.message // null),
    finish_reason: (.choices[0].finish_reason // null),
    prompt_tokens: (.usage.prompt_tokens // null),
    completion_tokens: (.usage.completion_tokens // null)
  }' "/tmp/gemmaforge-vllm-context-8192-result-${repeat}.json"
done

모든 payload가 HTTP 200으로 처리됐고 최대 6,158 prompt token과 128 completion token을 정상 생성했다.

context 16384

sudo k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=json \
  -p='[
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/5",
      "value": "--max-model-len"
    },
    {
      "op": "replace",
      "path": "/spec/template/spec/containers/0/args/6",
      "value": "16384"
    }
  ]'
sudo k3s kubectl -n gemmaforge rollout status \
  deployment/gemma4-12b \
  --timeout=40m
curl -fsS http://127.0.0.1:8100/v1/models \
  | jq '.data[] | {id, root, max_model_len}'
 
for repeat in 64 256 512 768 1024; do
  jq '{model, messages}' \
    "/tmp/gemmaforge-vllm-context-${repeat}.json" \
    | curl -fsS http://127.0.0.1:8100/tokenize \
        -H 'Content-Type: application/json' \
        -d @- \
    | jq --arg repeat "$repeat" \
        '{
          repeat: $repeat,
          prompt_tokens: .count,
          max_model_len
        }'
done
for repeat in 64 256 512 768 1024; do
  curl -sS \
    -o "/tmp/gemmaforge-vllm-context-16384-result-${repeat}.json" \
    -w "context=16384 repeat=${repeat} http=%{http_code} total=%{time_total}s\n" \
    http://127.0.0.1:8100/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d @"/tmp/gemmaforge-vllm-context-${repeat}.json"
done
for repeat in 64 256 512 768 1024; do
  jq --arg repeat "$repeat" '{
    repeat: $repeat,
    error: (.error.message // null),
    finish_reason: (.choices[0].finish_reason // null),
    prompt_tokens: (.usage.prompt_tokens // null),
    completion_tokens: (.usage.completion_tokens // null)
  }' "/tmp/gemmaforge-vllm-context-16384-result-${repeat}.json"
done

이번 payload의 최대 prompt는 6,158 token이므로 16384의 상한까지 검증한 것은 아니다. 다만 rollout과 모든 API 요청이 성공해 해당 설정에서 engine이 정상 기동하는 것은 확인했다.

context성공 범위HTTP 결과최대 성공 요청해석
4096repeat 64·256·512200 3회, 400 2회prompt 3,086 + output 128설정한 context 경계가 정상 적용
8192repeat 64~1024200 5회prompt 6,158 + output 128현재 payload 전체 처리
16384repeat 64~1024200 5회prompt 6,158 + output 128기동 성공, 16384 상한 자체는 미검증

같은 긴 payload에서 819216384의 응답 시간 차이는 작았다. 현재 payload가 8192 안에 모두 들어가기 때문에 더 큰 context 설정의 이점이 나타나지 않은 것으로 해석한다.

context 8192 복구

sudo k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=json \
  -p='[
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/5",
      "value": "--max-model-len"
    },
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/6",
      "value": "16384"
    },
    {
      "op": "replace",
      "path": "/spec/template/spec/containers/0/args/6",
      "value": "8192"
    }
  ]'
sudo k3s kubectl -n gemmaforge rollout status \
  deployment/gemma4-12b \
  --timeout=40m
sudo k3s kubectl -n gemmaforge get deployment/gemma4-12b \
  -o json \
  | jq -r '
      .spec.template.spec.containers[]
      | select(.name == "vllm")
      | .args
      | to_entries[]
      | "\(.key)\t\(.value)"
    '

args index 출력에는 값이 남지 않았지만 다음 API 결과에서 max_model_len=8192를 확인했다.

curl -fsS http://127.0.0.1:8100/health
curl -fsS http://127.0.0.1:8100/v1/models \
  | jq '.data[] | {id, root, max_model_len}'

scheduler max-num-seqs

context를 8192로 고정하고 max-num-seqs1·2·4로 변경했다. 이 요청은 순차 실행이므로 concurrency 한계가 아니라 각 설정의 단일 요청 기능 기준선만 확인한다.

max-num-seqs 1

k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=json \
  -p='[
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/7",
      "value": "--max-num-seqs"
    },
    {
      "op": "replace",
      "path": "/spec/template/spec/containers/0/args/8",
      "value": "1"
    }
  ]'
k3s kubectl -n gemmaforge rollout status \
  deployment/gemma4-12b \
  --timeout=40m
sudo k3s kubectl -n gemmaforge get pods -o wide
curl -fsS http://127.0.0.1:8100/health
for i in $(seq 1 5); do
  curl -sS \
    -o "/tmp/gemmaforge-07-vllm-seqs-${i}.json" \
    -w "run=${i} http=%{http_code} total=%{time_total}s\n" \
    http://127.0.0.1:8100/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d '{
      "model": "gemma-4-12b-q4",
      "messages": [
        {
          "role": "user",
          "content": "Kubernetes의 장점을 세 가지로 요약해줘."
        }
      ],
      "max_tokens": 128,
      "temperature": 0
    }'
done

max-num-seqs 2

k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=json \
  -p='[
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/7",
      "value": "--max-num-seqs"
    },
    {
      "op": "replace",
      "path": "/spec/template/spec/containers/0/args/8",
      "value": "2"
    }
  ]'
k3s kubectl -n gemmaforge rollout status \
  deployment/gemma4-12b \
  --timeout=40m
sudo k3s kubectl -n gemmaforge get pods -o wide
for i in $(seq 1 5); do
  curl -sS \
    -o "/tmp/gemmaforge-07-vllm-seqs-${i}.json" \
    -w "run=${i} http=%{http_code} total=%{time_total}s\n" \
    http://127.0.0.1:8100/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d '{
      "model": "gemma-4-12b-q4",
      "messages": [
        {
          "role": "user",
          "content": "Kubernetes의 장점을 세 가지로 요약해줘."
        }
      ],
      "max_tokens": 128,
      "temperature": 0
    }'
done

max-num-seqs 4

k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=json \
  -p='[
    {
      "op": "test",
      "path": "/spec/template/spec/containers/0/args/7",
      "value": "--max-num-seqs"
    },
    {
      "op": "replace",
      "path": "/spec/template/spec/containers/0/args/8",
      "value": "4"
    }
  ]'
k3s kubectl -n gemmaforge rollout status \
  deployment/gemma4-12b \
  --timeout=40m
sudo k3s kubectl -n gemmaforge get pods -o wide
for i in $(seq 1 5); do
  curl -sS \
    -o "/tmp/gemmaforge-07-vllm-seqs-${i}.json" \
    -w "run=${i} http=%{http_code} total=%{time_total}s\n" \
    http://127.0.0.1:8100/v1/chat/completions \
    -H 'Content-Type: application/json' \
    -d '{
      "model": "gemma-4-12b-q4",
      "messages": [
        {
          "role": "user",
          "content": "Kubernetes의 장점을 세 가지로 요약해줘."
        }
      ],
      "max_tokens": 128,
      "temperature": 0
    }'
done
max-num-seqsPodrestartHTTP 성공run 1run 2~5 평균해석
1Running 1/105/52.885444초1.590685초순차 단일 요청에서 가장 짧은 warm 평균
2Running 1/105/52.721084초1.598357초seqs 1과 거의 동일한 범위
4Running 1/105/52.791049초1.643155초정상 동작하지만 단일 요청 warm 평균은 약간 김

세 설정 모두 restart 없이 API 요청을 처리했다. 차이는 최대 약 52ms로 작고 순차 요청만 사용했으므로 max-num-seqs=1이 더 우수하다고 결론 내리지 않는다. scheduler 설정은 동시 요청 1·2·4와 queue·KV cache metric을 함께 측정해야 한다.

5. 입력 payload 확인

5절에서 생성한 /tmp/gemmaforge-vllm-context-256.json을 세 scheduler 후보가 공통으로 사용했다. context는 8192, 출력 상한은 128 token으로 고정해 max-num-seqs와 concurrency만 비교했다.

실행 전에 다음 명령으로 payload와 API 연결을 확인했다.

jq '{model, max_tokens, temperature, prompt_chars: (.messages[0].content | length)}' \
  /tmp/gemmaforge-vllm-context-256.json
 
curl -fsS http://127.0.0.1:8100/v1/models | jq

6. max-num-seqs별 동시 요청 1차 단기 측정

Areas 런북의 공통 함수와 후보별 patch 명령을 준비한 뒤, 각 후보에서 아래 입력을 실행했다.

run_vllm_load
check_vllm_idle

run_vllm_load는 concurrency 1, 2, 4, 8을 차례로 실행하고 HTTP 상태·요청 시간을 출력한다. check_vllm_idle은 모든 부하가 끝난 뒤 running·waiting과 Pod restart를 확인한다. 함수 전문과 후보별 설정 명령은 GemmaForge 최적화 런북 10절을 따른다.

아래 세 callout은 1차 단기 측정 원문이다

HTTP 상태와 요청 시간은 유효하지만 metric은 후보 판정에서 제외한다. 같은 제목의 값이 중복되거나 이전 후보 값이 섞인 이유는 [5m] 범위에 rollout 전·후 Pod가 함께 포함됐고, 수 초짜리 부하 일부가 Prometheus scrape 사이에서 끝났기 때문이다. 최종 판정은 8절의 90초 지속 부하 결과를 사용한다. 원래 등호 3개로 시작하던 실행 구분선은 Dataview inline query와 충돌하므로 측정값을 유지한 채 CASE ...로 표시한다.

max-num-seqsconcurrencyHTTP 성공평균 요청 시간마지막 완료추정 처리량완료 형태
111/12.79초2.79초0.36 req/s1개 실행
122/22.82초3.69초0.54 req/s거의 직렬
144/44.39초6.92초0.58 req/s약 4개 wave
188/87.74초13.81초0.58 req/s약 8개 wave
211/12.82초2.82초0.35 req/s1개 실행
222/22.63초2.63초0.76 req/s2개 동시 완료
244/42.91초3.77초1.06 req/s약 2개 wave
288/84.49초7.05초1.14 req/s약 4개 wave
411/12.84초2.84초0.35 req/s1개 실행
422/22.66초2.66초0.75 req/s2개 동시 완료
444/42.00초2.00초2.00 req/s4개 동시 완료
488/83.06초3.95초2.02 req/s약 2개 wave

요청 결과만 보면 max-num-seqs1 → 2 → 4로 증가할수록 동시에 처리되는 요청 수가 늘었다. concurrency 8의 마지막 완료 시간은 13.81초 → 7.05초 → 3.95초로 줄었고, 모든 요청은 HTTP 200이었다. 각 후보가 끝난 뒤 running과 waiting은 0, Pod restart는 0이었다.

7. 실험 구간 metric 판정

이번 실행의 latency와 HTTP 결과는 scheduler 동시성 차이를 보여주는 유효한 기록이다. 그러나 저장된 Prometheus 최고값은 후보 간 정량 비교에 바로 사용하지 않는다.

관찰 내용원인현재 판정
같은 metric 제목에 값이 2개 출력됨rollout 전·후 Pod 시계열이 [5m] 범위에 함께 존재하고 출력에서 pod, instance 라벨을 숨김어느 값이 현재 Pod인지 구분할 수 없음
다음 후보의 첫 측정에 이전 후보의 높은 값이 남음5분 범위가 직전 실험 구간과 겹침후보별 최고값 비교 불가
max-num-seqs=4, concurrency 8에서 running·KV cache·GPU utilization이 0부하가 약 4초 만에 끝나 Prometheus scrape 사이에서 실행됨실제 무부하가 아니라 sampling 누락 가능성이 큼
concurrency가 뒤로 갈수록 일부 시간이 짧아짐rollout 직후 첫 요청의 warm-up 비용과 고정된 실행 순서가 섞임1회 결과만으로 미세한 latency 우열을 확정하지 않음

따라서 scheduler의 방향성은 확인했지만 waiting, KV cache, GPU utilization, VRAM 최고값은 재측정이 필요하다. 보완 절차는 Areas 런북의 10-10. 짧은 부하에서 누락된 metric 다시 측정에 추가했다.

max-num-seqs 1 지속 부하 최종 결과

refresh_current_vllm_pod
collect_vllm_metrics
run_vllm_case 2 90
run_vllm_case 4 90
run_vllm_case 8 90
concurrency요청 성공측정 구간처리량평균 요청 시간최대 요청 시간running 최고waiting 최고KV cache 최고preemptionGPU memory 최고GPU util 최고
154/5491초0.593 req/s1.665719초1.950031초100.037852020531 MiB86%
254/5492초0.587 req/s2.549072초3.656425초110.037852020545 MiB83%
456/5695초0.589 req/s4.269395초7.410069초130.037852020589 MiB84%
856/5695초0.589 req/s7.652564초9.102962초170.037852020558 MiB85%

running 최고값이 모든 조건에서 1이고 waiting 최고값이 각각 concurrency - 1과 일치했다. max-num-seqs=1이 동시에 실행하는 sequence를 하나로 제한하고, 초과 요청을 실패시키지 않고 queue에서 순서대로 처리한 것이다.

concurrency를 1 → 8로 늘려도 처리량은 0.587~0.593 req/s로 거의 변하지 않았다. 반면 평균 요청 시간은 1.67초 → 7.65초, 최대 요청 시간은 1.95초 → 9.10초로 증가했다. 실행 slot이 하나뿐이므로 동시 요청을 늘린 효과가 처리량 증가가 아니라 queue 대기시간 증가로 나타났다.

KV cache 최고값은 모든 조건에서 약 3.79%, GPU memory는 20531~20589 MiB, GPU utilization은 83~86%로 안정적이었다. 동시에 실행되는 sequence가 항상 하나라 concurrency 증가가 KV cache나 VRAM 증가로 이어지지 않았다. preemption은 전 구간 0이어서 memory pressure로 실행 중 요청이 밀려난 흔적도 없다.

각 실행 후 running=0, waiting=0, Pod restart 0으로 복구됐다. 따라서 max-num-seqs=1은 안정적으로 동작하지만, 여러 요청이 들어오는 환경에서는 GPU 처리량을 확장하지 못하고 tail latency를 키우는 설정으로 판단한다.

max-num-seqs 2 지속 부하 최종 결과

run_vllm_case 1 90

나머지 concurrency는 수정된 run_vllm_case를 한 줄씩 실행했다.

run_vllm_case 2 90
run_vllm_case 4 90
run_vllm_case 8 90
concurrency요청 성공측정 구간처리량평균 요청 시간최대 요청 시간running 최고waiting 최고KV cache 최고preemptionGPU memory 최고GPU util 최고
151/5190초0.567 req/s1.739537초3.094595초100.037925020650 MiB85%
2104/10491초1.143 req/s1.735728초2.119637초200.043220020688 MiB86%
4104/10490초1.156 req/s2.608149초3.758840초220.043220020681 MiB86%
8104/10492초1.130 req/s4.436486초7.455550초260.043220020681 MiB85%

concurrency 1 → 2에서 처리량은 0.567 → 1.143 req/s로 거의 두 배가 됐다. concurrency가 2를 넘으면 running은 계속 2에 머물고 waiting이 2, 6으로 증가했다. 이때 처리량은 약 1.14 req/s에서 더 늘지 않고 평균 요청 시간만 2.61초, 4.44초로 증가했다.

KV cache 최고값은 concurrency 2부터 약 4.32%로 유지됐고 GPU memory는 20650~20688 MiB, GPU utilization은 85~86%였다. preemption과 restart가 없으므로 max-num-seqs=2는 두 요청까지 안정적으로 병렬 처리하지만 그 이상의 요청은 queue에서 기다리는 설정이다.

max-num-seqs 4 지속 부하 최종 결과

max-num-seqs=4를 적용한 같은 Pod에서 concurrency를 하나씩 올리며 실행했다.

run_vllm_case 1 90
run_vllm_case 2 90
run_vllm_case 4 90
run_vllm_case 8 90
concurrency요청 성공측정 구간처리량평균 요청 시간최대 요청 시간running 최고waiting 최고KV cache 최고preemptionGPU memory 최고GPU util 최고
154/5490초0.600 req/s1.660883초2.819826초100.038091020579 MiB87%
2104/10491초1.143 req/s1.740539초2.697617초200.041987020579 MiB85%
4200/20091초2.198 req/s1.799211초2.035951초400.048371020650 MiB86%
8200/20090초2.222 req/s2.714282초3.802103초440.053674020646 MiB85%

concurrency 1 → 2 → 4에서 running 최고값도 1 → 2 → 4로 증가했고, 처리량은 0.600 → 1.143 → 2.198 req/s로 확장됐다. 이 범위에서는 waiting이 모두 0이고 평균 요청 시간도 1.66~1.80초에 머물렀다.

concurrency 8에서는 running 4, waiting 4가 되어 scheduler 상한이 명확히 드러났다. 처리량은 2.222 req/s로 concurrency 4와 거의 같고 평균 요청 시간은 2.71초로 증가했다. KV cache 최고값은 5.37%, GPU memory는 최고 20650 MiB, GPU utilization은 최고 87%였으며 preemption과 restart는 없었다.

max-num-seqs 후보 비교와 선택

max-num-seqs처리량 상한상한까지 waiting초과 부하의 waitingKV cache 최고GPU memory 범위preemption·restart판정
10.59 req/sconcurrency 1까지 0c2=1, c4=3, c8=70.03785220531~20589 MiB0·0단일 요청 전용에 가까워 동시 요청 처리량을 확장하지 못함
21.15 req/sconcurrency 2까지 0c4=2, c8=60.04322020650~20688 MiB0·0두 요청까지 안정적으로 병렬 처리
42.22 req/sconcurrency 4까지 0c8=40.05367420579~20650 MiB0·0네 요청까지 latency 증가 없이 처리량이 가장 크게 확장됨

세 후보 모두 HTTP 오류, preemption, restart 없이 안정적이었다. 처리량은 대략 max-num-seqs에 비례해 0.59 → 1.15 → 2.22 req/s로 증가했고, 각 후보에서 concurrency가 scheduler 상한을 넘을 때만 waiting이 생겼다. 이는 --max-num-seqs가 실제 동시 실행 slot 상한으로 동작했다는 직접적인 결과다.

GPU memory 차이는 전체 후보에서 약 157 MiB 이내이고 GPU utilization도 83~87% 범위라, 이번 짧은 출력 workload에서는 seqs 증가에 따른 VRAM·GPU 부담보다 처리량 이점이 훨씬 컸다. 따라서 RTX 4090 단일 GPU의 현재 workload에서는 max-num-seqs=4를 scheduler 기본값으로 선택한다. 다만 concurrency 8을 기본 운영값으로 삼는다는 뜻은 아니며, queue가 생기지 않는 운영 동시성 기준은 4다.

8. 최종 후보 입력 길이별 재측정

최종 후보 상태에서 기존 context payload의 실제 token 수를 다시 확인했다.

for repeat in 64 256 512; do
  jq '{model, messages}' "/tmp/gemmaforge-vllm-context-${repeat}.json" \
    | curl -fsS http://127.0.0.1:8100/tokenize \
        -H 'Content-Type: application/json' \
        -d @- \
    | jq --arg repeat "$repeat" \
        '{repeat: $repeat, count, max_model_len}'
done

각 입력 길이에서 동일 요청을 5회 실행했다.

for repeat in 64 256 512; do
  for run in $(seq 1 5); do
    curl -sS \
      -o "/tmp/gemmaforge-vllm-final-${repeat}-${run}.json" \
      -w "repeat=${repeat} run=${run} http=%{http_code} total=%{time_total}s\n" \
      http://127.0.0.1:8100/v1/chat/completions \
      -H 'Content-Type: application/json' \
      -d @"/tmp/gemmaforge-vllm-context-${repeat}.json"
  done
done
반복prompt token성공run 1run 2~5 평균판정
643985/52.038277초1.665700초정상
25615505/51.765761초1.728527초정상
51230865/51.927471초1.765378초정상

세 payload는 모두 context 8192 안에 있고 총 15/15 요청이 HTTP 200으로 성공했다. prompt가 398 → 3086 token으로 약 7.8배 증가했지만 warm 평균은 1.665700 → 1.765378초로 약 6.0% 증가하는 데 그쳤다. 이번 128-token 출력 workload에서는 입력 길이 증가가 latency에 미치는 영향이 비교적 작았다.

run 1이 항상 가장 느린 것은 아니므로 단일 cold 요청만으로 후보를 비교하지 않는다. 최종 상태의 기능 검증은 통과했지만, GPU memory utilization 최적값은 후보별 동일 구간 metric 비교가 없으므로 별도 확정하지 않는다.

9. baseline 원복과 확인

전체 실험 후 vLLM args와 model cache PVC를 Runbook의 baseline으로 원복했다.

sudo k3s kubectl -n gemmaforge patch \
  deployment/gemma4-12b \
  --type=strategic \
  -p '{
    "spec": {
      "template": {
        "spec": {
          "containers": [
            {
              "name": "vllm",
              "args": [
                "google/gemma-4-12B-it-qat-w4a16-ct",
                "--served-model-name",
                "gemma-4-12b-q4",
                "--dtype",
                "auto",
                "--max-model-len",
                "8192",
                "--max-num-seqs",
                "4",
                "--gpu-memory-utilization",
                "0.80",
                "--limit-mm-per-prompt",
                "{\"image\": 0, \"audio\": 0}"
              ]
            }
          ],
          "volumes": [
            {
              "name": "model-cache",
              "persistentVolumeClaim": {
                "claimName": "gemma4-model-cache"
              }
            }
          ]
        }
      }
    }
  }'
sudo k3s kubectl -n gemmaforge rollout status \
  deployment/gemma4-12b \
  --timeout=40m

원복된 API의 health와 model identity를 확인했다.

curl -fsS http://127.0.0.1:8100/health
curl -fsS http://127.0.0.1:8100/v1/models | jq
항목원복 값확인 결과
checkpointgoogle/gemma-4-12B-it-qat-w4a16-ct/v1/modelsroot 일치
served modelgemma-4-12b-q4/v1/modelsid 일치
max model length8192/v1/models에서 확인
max sequences4baseline patch 적용 후 rollout 성공
GPU memory utilization0.80baseline patch 적용 후 rollout 성공
model cache PVCgemma4-model-cachebaseline patch 적용 후 rollout 성공
healthHTTP 성공curl -fsS 오류 없음

원복 rollout이 성공했고 API health, model identity, context 상한까지 정상으로 확인됐다. 따라서 Step 7 실험은 원래 운영 baseline으로 돌아온 상태에서 종료했다.

현재까지의 결론

  • vLLM을 Step 7 최적화 engine으로 선택했다.
  • 빈 PVC에서 cold start는 270초, 같은 PVC를 재사용한 warm start는 171초였다.
  • Hugging Face cache 9.6G와 정확한 checkpoint revision이 cold PVC에 생성됐다.
  • GPU memory utilization 0.70, 0.75, 0.80, 0.85는 단일 요청 5회를 모두 처리했다.
  • 현재 API 시간만 보면 0.85의 warm 평균이 가장 짧지만 metric 검증 전에는 최적값으로 확정하지 않는다.
  • context 4096에서는 prompt 4622, 6158 token 요청이 HTTP 400으로 거부되어 설정 상한이 실제로 적용됨을 확인했다.
  • context 819216384는 준비한 최대 6158 token prompt와 128 output token 요청을 모두 처리했다.
  • 준비한 payload가 모두 8192 안에 들어가므로 이번 결과만으로 16384의 추가 효용이나 실제 상한을 판단할 수 없다.
  • context는 8192로 복구했고 /v1/models에서 max_model_len=8192를 확인했다.
  • max-num-seqs 1, 2, 4는 순차 단일 요청에서 모두 5/5 성공했고, 지속 부하 metric까지 완료했다.
  • 동시 요청에서도 세 후보가 모두 HTTP 200, restart 0으로 끝났다. concurrency 8의 마지막 완료 시간은 13.81초, 7.05초, 3.95초로 scheduler 용량 증가가 확인됐다.
  • 초기 단기 측정의 Prometheus metric은 이전 Pod 시계열 혼입과 sampling 누락이 있어 후보 판정에서 제외한다.
  • 보완 측정에서 max-num-seqs=1은 running 최고 1, waiting 최고 concurrency - 1, 처리량 약 0.59 req/s로 확인됐다.
  • seqs 1은 restart·preemption 없이 안정적이지만 concurrency 증가를 처리량으로 전환하지 못해 대기시간과 tail latency가 증가했다.
  • seqs 2는 concurrency 2까지 waiting 없이 약 1.15 req/s, seqs 4는 concurrency 4까지 waiting 없이 약 2.22 req/s를 처리했다.
  • 세 후보 모두 상한을 넘는 동시 요청만 queue로 보냈고 preemption·restart는 없었다.
  • VRAM 차이는 후보 전체에서 약 157 MiB 이내였으므로 처리량과 queue 결과를 기준으로 max-num-seqs=4, 운영 concurrency 4를 scheduler 기본값으로 선택했다.
  • 최종 후보에서 prompt 398, 1550, 3086 token 요청을 각 5회 실행해 총 15/15 HTTP 200을 확인했다.
  • 전체 실험 후 context 8192, max-num-seqs 4, GPU memory utilization 0.80, 원래 model cache PVC의 baseline으로 원복했다.