iT邦幫忙

2026 iThome 鐵人賽

DAY 30
0
Kubernetes

凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜系列 第 30 篇

【Day 30】最後一夜的凌晨四點:迎來破曉的 GPU 叢集,與女友煮的牛肉麵

  • 分享至 

  • xImage
  •  

終於來到整個凌晨四點系列的最後一天,今天我們在昨天建立的整個 GPU 叢集上跑一個實驗,做一個完美的收尾。

叢集已經在跑:兩張 L40S,兩台 vLLM 各拿半張,請求從 Envoy 進來、由 EPP 挑一台。這套服務自己占一個 namespace、一個 LocalQueue,ClusterQueue 的四份額度被它用掉兩份,它就是第一個租戶。

今天加第二個租戶:另一個 namespace、另一個 LocalQueue、同一個 ClusterQueue,兩個 Pod 一組,每個也要半張卡。先量一次基準線,再把三種情況各跑一次:Volcano 放行、什麼都不裝、Kueue 擋住。

其中三段送同一份負載進去,同時讀四個地方:負載產生器的吞吐和延遲、Kueue 的 admission 狀態、兩個節點的 nvidia-smi、DCGM 的四個欄位。中間那一段只看 gang 收不收,沒有送請求。


實驗環境

兩個節點 ip-172-31-26-39 和 ip-172-31-30-5,各一張 L40S 46068 MiB,GPU 全部走 DRA。

原本在跑的是兩台 vLLM,一個節點一台。它們要的不是整張卡,而是走 HAMi 的 DeviceClass 要一份,容量寫 cores: "50" 加 memory: 23000Mi。HAMi 的 device 帶 allowMultipleAllocations: true,同一張卡可以被配出去多次,所以每張 L40S 上現在只有 23000 MiB 被占住,剩下的 23068 MiB 還配得出去。第二租戶能不能落在同一張卡上,問的就是這 23068 MiB。

kubectl -n llm-d get pods -o wide --no-headers | grep decode
optimized-baseline-nvidia-gpu-vllm-decode-7b5fd98694-7whxb   1/1   Running   0   24m   10.42.1.32   ip-172-31-30-5
optimized-baseline-nvidia-gpu-vllm-decode-7b5fd98694-xg87m   1/1   Running   0   24m   10.42.0.35   ip-172-31-26-39
kubectl get resourceclaim -A --no-headers | awk '{print $1,$3}' | sort | uniq -c
      4 gpu-operator allocated,reserved
      2 llm-d allocated,reserved
kubectl get clusterqueue cq -o json | python3 -c "
import sys,json
s=json.load(sys.stdin)['status']
print('admitted:', s.get('admittedWorkloads'),' pending:', s.get('pendingWorkloads'))
for f in s.get('flavorsUsage',[]):
    for r in f['resources']: print(f\"  {r['name']:<16} {r['total']}\")"
admitted: 2  pending: 0
  cpu              4
  hami.io/gpu      2
  memory           32Gi

status.flavorsUsage 整塊都是已用量,三個數字都是那兩台 vLLM 吃掉的。配額要讀 spec:

kubectl get clusterqueue cq -o json | python3 -c "
import sys,json
for g in json.load(sys.stdin)['spec']['resourceGroups']:
  for f in g['flavors']:
    for r in f['resources']: print(f['name'], r['name'], r['nominalQuota'])"
l40s cpu 14
l40s memory 110Gi
l40s hami.io/gpu 4

hami.io/gpu 的配額是 4,用掉 2。

第二租戶的 namespace 和佇列

apiVersion: v1
kind: Namespace
metadata:
  name: tenant2
---
apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: lq
  namespace: tenant2
spec:
  clusterQueue: cq

兩個租戶各有自己的 namespace 和 LocalQueue,而配額從同一個 ClusterQueue 扣。這是它們唯一會互相影響的地方,卡上的爭用則是另一回事。

第二租戶的 ResourceClaimTemplate

四個階段的第二租戶都用這一份,t2-over.yaml 裡也帶著它。要的容量跟那兩台 vLLM 完全一樣:

apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata:
  name: half
  namespace: tenant2
spec:
  spec:
    devices:
      requests:
      - name: gpu
        exactly:
          deviceClassName: hami-core-gpu.project-hami.io
          count: 1
          capacity:
            requests:
              cores: "50"
              memory: 23000Mi

負載產生器

64 條執行緒打 120 秒,每一筆的 prompt 前面加一段 24 個字元的隨機前綴,ignore_eos 讓每一筆都吐滿 128 個 token:

apiVersion: v1
kind: Pod
metadata:
  name: load64
  namespace: llm-d
spec:
  restartPolicy: Never
  containers:
  - name: load
    image: vllm/vllm-openai:v0.30.0
    env:
      - { name: C,    value: "64" }
      - { name: SECS, value: "120" }
    command: ["python3","-u","-c"]
    args:
    - |
      import threading, time, json, os, urllib.request, random, string
      URL="http://llm-d-inference-gateway.llm-d.svc.cluster.local/v1/completions"
      C=int(os.environ["C"]); SECS=float(os.environ["SECS"])
      def mkbody():
          tag="".join(random.choices(string.ascii_lowercase, k=24))
          return json.dumps({"model":"Qwen/Qwen2.5-7B-Instruct",
                       "prompt":f"[{tag}] Explain in detail how a transformer works.",
                       "max_tokens":128,"ignore_eos":True,"temperature":0.8}).encode()
      lat=[]; toks=0; errs=0; lk=threading.Lock(); end=time.time()+SECS
      def worker():
          global toks,errs
          while time.time()<end:
              t=time.time()
              try:
                  r=urllib.request.Request(URL,data=mkbody(),
                        headers={"Content-Type":"application/json"})
                  d=json.loads(urllib.request.urlopen(r,timeout=300).read())
                  n=d["usage"]["completion_tokens"]
                  with lk: lat.append(time.time()-t); toks+=n
              except Exception:
                  with lk: errs+=1
      ts=[threading.Thread(target=worker) for _ in range(C)]
      t0=time.time()
      for t in ts: t.start()
      for t in ts: t.join()
      el=time.time()-t0; lat.sort()
      def p(q): return lat[min(int(len(lat)*q),len(lat)-1)] if lat else float("nan")
      print(f"RESULT concurrency={C} seconds={el:.1f}")
      print(f"RESULT requests={len(lat)} errors={errs}")
      print(f"RESULT output_tokens={toks} tok_per_s={toks/el:.1f}")
      print(f"RESULT e2e_p50={p(.5):.3f} p90={p(.9):.3f} p99={p(.99):.3f}")

隨機前綴的理由在 EPP 那一節。

每個階段的兩道快照

一道從主機看,一道從 DCGM 看。這兩道在後面每個階段各跑一次,輸出就附在那個階段裡:

for H in 172.31.26.39 172.31.30.5; do
  printf '%-16s ' "$H"
  ssh ubuntu@$H 'nvidia-smi --query-gpu=memory.used,utilization.gpu,power.draw --format=csv,noheader'
done
for N in ip-172-31-26-39 ip-172-31-30-5; do
  P=$(kubectl -n gpu-operator get pod -l app=nvidia-dcgm-exporter-dra \
        --field-selector spec.nodeName=$N -o jsonpath='{.items[0].metadata.name}')
  kubectl -n gpu-operator port-forward "$P" 9400:9400 >/tmp/pfd.log 2>&1 & echo $! >/tmp/pfd.pid
  sleep 5
  printf '%-16s ' "$N"
  curl -s localhost:9400/metrics | awk -F'[{ ]' '
    /^DCGM_FI_DEV_GPU_UTIL/{u=$NF} /^DCGM_FI_PROF_GR_ENGINE_ACTIVE/{g=$NF}
    /^DCGM_FI_PROF_DRAM_ACTIVE/{d=$NF} /^DCGM_FI_PROF_PIPE_TENSOR_ACTIVE/{t=$NF}
    END{printf "GPU_UTIL=%s  GR_ENGINE=%s  DRAM=%s  TENSOR=%s\n",u,g,d,t}'
  kill $(cat /tmp/pfd.pid); sleep 1
done

一、基準線:卡上只有那兩台 vLLM

kubectl apply -f load64.yaml
sleep 60
172.31.26.39     20933 MiB, 100 %, 349.67 W
172.31.30.5      20933 MiB, 100 %, 331.01 W
ip-172-31-26-39  GPU_UTIL=100  GR_ENGINE=0.999523  DRAM=0.83585   TENSOR=0.162758
ip-172-31-30-5   GPU_UTIL=100  GR_ENGINE=0.932222  DRAM=0.779882  TENSOR=0.151744
kubectl -n llm-d logs load64 | grep RESULT
RESULT concurrency=64 seconds=123.4
RESULT requests=2310 errors=0
RESULT output_tokens=295680 tok_per_s=2395.3
RESULT e2e_p50=3.175 p90=3.861 p99=3.958

claim 裡寫的是半張卡,而這兩台在只有自己的時候跑到 350 W 和 331 W,DRAM_ACTIVE 0.84 和 0.78。記憶體那一半守得住,20933 MiB 沒有超過 23000 MiB 的額度;算力那一半在沒有人搶的時候不起作用,整張卡都是它的。


二、Volcano 的門:一組兩個 Pod 一起放行

第二租戶是一個 LeaderWorkerSet,size: 2,leader 和 worker 各要一份 half。容器裡先握住 8 GB 記憶體,再跑連續的 8192 平方矩陣相乘。Pod 指定 schedulerName: volcano,兩個模板都帶 scheduling.k8s.io/group-name 指到同一個 PodGroup:

apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
  name: tenant2
  namespace: tenant2
spec:
  minMember: 2
  queue: default
---
apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
  name: burn
  namespace: tenant2
  labels:
    kueue.x-k8s.io/queue-name: lq
spec:
  replicas: 1
  leaderWorkerTemplate:
    size: 2
    leaderTemplate:
      metadata:
        labels:
          app: burn
        annotations:
          scheduling.k8s.io/group-name: tenant2
      spec:
        schedulerName: volcano
        containers:
        - name: burn
          image: vllm/vllm-openai:v0.30.0
          env:
          - name: MODE
            value: "burn"
          command: ["python3","-u","-c"]
          args:
          - |
            import torch, time, os
            print("total MiB:", torch.cuda.get_device_properties(0).total_memory//1024**2, flush=True)
            hold = torch.empty(int(8e9//2), dtype=torch.float16, device="cuda")
            print("held 8GB, mode=", os.environ["MODE"], flush=True)
            if os.environ["MODE"] == "idle":
                while True: time.sleep(30)
            a = torch.randn(8192, 8192, device="cuda", dtype=torch.float16)
            b = torch.randn(8192, 8192, device="cuda", dtype=torch.float16)
            while True:
                for _ in range(50): c = a @ b
                torch.cuda.synchronize()
          resources:
            limits:
              cpu: "2"
              memory: 8Gi
            requests:
              cpu: "1"
              memory: 4Gi
            claims:
            - name: gpu
        resourceClaims:
        - name: gpu
          resourceClaimTemplateName: half
    workerTemplate:
      # 內容與 leaderTemplate 相同,這裡省略,完整版在 repo

workerTemplate 是必填的,照上面抄會生出不合法的 manifest。

kubectl apply -f t2-volcano.yaml
sleep 45
kubectl -n tenant2 get pods --no-headers
burn-0       1/1   Running   0   45s
burn-0-1     1/1   Running   0   45s
kubectl -n tenant2 get podgroup tenant2 -o json | python3 -c "
import sys,json
d=json.load(sys.stdin)['status']
print('phase =',d.get('phase'),' running =',d.get('running'))
for c in d.get('conditions',[]): print(' ',c.get('reason'),'::',c.get('message',''))"
phase = Running  running = 2
  tasks in gang are ready to be scheduled ::
172.31.26.39     29544 MiB, 100 %, 349.84 W
172.31.30.5      29544 MiB, 100 %, 349.27 W

兩個一起上。每張卡原本被 vLLM 的 claim 拿掉 23000 MiB,剩下 23068 MiB,第二租戶那份 23000 MiB 剛好放得進去,所以 gang 的條件成立。這一段沒有送請求,gang 要回答的問題到放行為止。


三、兩個租戶搶同一張卡:吞吐砍半,而卡的使用率沒有變

schedulerName 寫在 Pod spec 裡,既有的 Pod 改不動,PodGroup 也要一起撤掉,所以刪掉重建:

kubectl -n tenant2 delete lws burn
kubectl -n tenant2 delete podgroup tenant2

重新套用一份一樣的 LeaderWorkerSet,差別只有拿掉 schedulerName: volcano 和那個 annotation。

kubectl apply -f t2-default.yaml
kubectl -n tenant2 get pods -o wide --no-headers
burn-0       1/1   Running   0   50s   10.42.0.38   ip-172-31-26-39
burn-0-1     1/1   Running   0   50s   10.42.1.35   ip-172-31-30-5
kubectl -n tenant2 logs burn-0 | head -2
total MiB: 23000
held 8GB, mode= burn

容器裡看到的記憶體上限是 23000 MiB,不是整張卡的 46068 MiB。兩個租戶拿到的額度一樣:

kubectl -n llm-d  exec optimized-baseline-nvidia-gpu-vllm-decode-7b5fd98694-xg87m -- env | grep CUDA_DEVICE
CUDA_DEVICE_SM_LIMIT_0=50
CUDA_DEVICE_MEMORY_LIMIT_0=23000m
kubectl -n tenant2 exec burn-0 -- env | grep CUDA_DEVICE
CUDA_DEVICE_SM_LIMIT_0=50
CUDA_DEVICE_MEMORY_LIMIT_0=23000m

同一份負載再送一次:

172.31.26.39     29544 MiB, 100 %, 330.29 W
172.31.30.5      29544 MiB, 100 %, 334.86 W
ip-172-31-26-39  GPU_UTIL=100  GR_ENGINE=0.999588  DRAM=0.581654  TENSOR=0.475464
ip-172-31-30-5   GPU_UTIL=100  GR_ENGINE=0.999615  DRAM=0.567754  TENSOR=0.496109
RESULT concurrency=64 seconds=121.5
RESULT requests=1024 errors=0
RESULT output_tokens=131072 tok_per_s=1079.2
RESULT e2e_p50=7.473 p90=7.949 p99=7.979

吞吐從 2395.3 掉到 1079.2,p50 從 3.175 秒變成 7.473 秒。而硬體那一側三個「卡忙不忙」的數字都沒有跟著動:GPU_UTIL 兩邊都還是 100,GR_ENGINE_ACTIVE 從 0.9995/0.9322 變成 0.9996/0.9996,功耗 330 W 和 335 W 對基準線的 350 W 和 331 W。

動的只有兩個欄位。DRAM_ACTIVE 從 0.836/0.780 掉到 0.582/0.568,PIPE_TENSOR_ACTIVE 從 0.163/0.152 漲到 0.475/0.496。一個掉三成,一個漲三倍。

矩陣相乘是 tensor 指令密集、記憶體頻寬需求低的工作,decode 相反。卡上的工作組成換了。

GPU_UTIL 的定義是過去一段取樣期間內有一個以上 kernel 在執行的時間百分比,所以它兩邊都是 100。要看出服務變慢,得去讀 vLLM 自己的數字或者客戶端的延遲。


四、Kueue 的門:整組擋在 admission

第二租戶改成 size: 4,一份 leader 加三份 worker。容器和 claim 都沒變,t2-over.yaml 跟第二段那份只差三處:size 是 4、沒有 PodGroup、沒有 schedulerName。

size 改不動,所以第三段那一組得先刪掉,為什麼改不動在這一節最後。

kubectl -n tenant2 delete lws burn
kubectl -n tenant2 delete resourceclaimtemplate half

這一段是隔天在同一套平台上單獨重跑的,那兩台 vLLM 沒有重啟,AGE 是 23h,claim 也沒動過。

起點是只有那兩台 vLLM 的狀態:

kubectl get clusterqueue cq -o jsonpath='{.status.admittedWorkloads} admitted / {.status.pendingWorkloads} pending{"\n"}'
2 admitted / 0 pending
kubectl apply -f t2-over.yaml
resourceclaimtemplate.resource.k8s.io/half created
leaderworkerset.leaderworkerset.x-k8s.io/burn created
kubectl -n tenant2 get pods --no-headers
burn-0     0/1   SchedulingGated   0     50s
burn-0-1   0/1   SchedulingGated   0     50s
burn-0-2   0/1   SchedulingGated   0     50s
burn-0-3   0/1   SchedulingGated   0     50s
kubectl -n tenant2 get workloads -o json | python3 -c "
import sys,json
for w in json.load(sys.stdin)['items']:
    ps=','.join(f\"{p['name']}={p['count']}\" for p in w['spec']['podSets'])
    print(w['metadata']['name'],'['+ps+']')
    for c in w.get('status',{}).get('conditions',[]):
        if c['type'] in ('QuotaReserved','Admitted'):
            print('  ',c['type'],'=',c['status'],'::',c.get('message',''))"
leaderworkerset-burn-0-a8e98 [leader=1,worker=3]
   QuotaReserved = False :: couldn't assign flavors to pod set worker: insufficient unused quota for hami.io/gpu in flavor l40s, 2 more needed
kubectl get clusterqueue cq -o jsonpath='{.status.admittedWorkloads} admitted / {.status.pendingWorkloads} pending{"\n"}'
2 admitted / 1 pending
kubectl get clusterqueue cq -o json | python3 -c "
import sys,json
s=json.load(sys.stdin)['status']
print('admitted:', s.get('admittedWorkloads'),' pending:', s.get('pendingWorkloads'))
for f in s.get('flavorsUsage',[]):
    for r in f['resources']: print(f\"  {r['name']:<16} {r['total']}\")"
admitted: 2  pending: 1
  cpu              4
  hami.io/gpu      2
  memory           32Gi

配額 4 用掉 2,剩兩份。訊息只提 worker 這個 podSet,所以 leader 那一份是配出去了,剩一份;worker 要三份,差 2。而四個 Pod 全部停在 SchedulingGated,連那個配得出去的 leader 也沒排。Kueue 擋的單位是整個 Workload。

第二租戶擋在門外的時候,同一份負載第三次:

172.31.26.39     20933 MiB, 100 %, 216.24 W
172.31.30.5      20933 MiB, 100 %, 326.71 W
ip-172-31-26-39  GPU_UTIL=100  GR_ENGINE=0.977347  DRAM=0.818606  TENSOR=0.154887
ip-172-31-30-5   GPU_UTIL=100  GR_ENGINE=0.999539  DRAM=0.834998  TENSOR=0.163246

負載跑到一半,那四個 Pod 還擋在門外:

kubectl -n tenant2 get pods --no-headers
burn-0     0/1   SchedulingGated   0     2m4s
burn-0-1   0/1   SchedulingGated   0     2m4s
burn-0-2   0/1   SchedulingGated   0     2m4s
burn-0-3   0/1   SchedulingGated   0     2m4s
kubectl -n llm-d logs load64 | grep RESULT
RESULT concurrency=64 seconds=123.0
RESULT requests=2304 errors=0
RESULT output_tokens=294912 tok_per_s=2398.6
RESULT e2e_p50=3.175 p90=3.909 p99=4.324

2398.6 tok/s、p50 3.175 秒,卡上的用量回到 20933 MiB。配額擋住的是新來的那一組,原本的服務維持在基準線。

size 改不動,而擋下來的不是 LeaderWorkerSet

前面那兩道 delete 是因為 size 這個欄位改不動。錯誤訊息裡的 webhook 叫 vleaderworkerset.kb.io,而叢集上有兩個同名的,所以單看訊息分不出是誰擋的:

kubectl get validatingwebhookconfiguration -o json | python3 -c "
import sys,json
for c in json.load(sys.stdin)['items']:
    for w in c.get('webhooks',[]):
        if any('leaderworkersets' in r.get('resources',[]) for r in w.get('rules',[])):
            print(c['metadata']['name'], w['name'])
            print('  namespaceSelector:', json.dumps(w.get('namespaceSelector')))
            print('  objectSelector:   ', json.dumps(w.get('objectSelector')))"
kueue-validating-webhook-configuration vleaderworkerset.kb.io
  namespaceSelector: {"matchExpressions": [{"key": "kubernetes.io/metadata.name", "operator": "NotIn", "values": ["kube-system", "kueue-system"]}]}
  objectSelector:    {}
lws-validating-webhook-configuration vleaderworkerset.kb.io
  namespaceSelector: {}
  objectSelector:    {}

兩個的 namespaceSelector 都蓋得到 tenant2,兩個都沒有 objectSelector。要分辨就得做一組對照:兩個 LeaderWorkerSet 放在同一個 namespace,模板一模一樣,唯一的差別是其中一個帶 kueue.x-k8s.io/queue-name。

apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
  name: ctl-nolabel
  namespace: tenant2
spec:
  replicas: 1
  leaderWorkerTemplate:
    size: 2
    leaderTemplate:
      spec:
        containers:
        - name: c
          image: registry.k8s.io/pause:3.9
          resources:
            requests: { cpu: 10m, memory: 16Mi }
    workerTemplate:
      spec:
        containers:
        - name: c
          image: registry.k8s.io/pause:3.9
          resources:
            requests: { cpu: 10m, memory: 16Mi }
---
apiVersion: leaderworkerset.x-k8s.io/v1
kind: LeaderWorkerSet
metadata:
  name: ctl-label
  namespace: tenant2
  labels:
    kueue.x-k8s.io/queue-name: lq
spec:
  replicas: 1
  leaderWorkerTemplate:
    size: 2
    leaderTemplate:
      spec:
        containers:
        - name: c
          image: registry.k8s.io/pause:3.9
          resources:
            requests: { cpu: 10m, memory: 16Mi }
    workerTemplate:
      spec:
        containers:
        - name: c
          image: registry.k8s.io/pause:3.9
          resources:
            requests: { cpu: 10m, memory: 16Mi }

同一個 patch 各打一次,用 --dry-run=server 走完 webhook 但不真的改:

kubectl -n tenant2 patch lws ctl-nolabel --type=merge --dry-run=server \
  -p '{"spec":{"leaderWorkerTemplate":{"size":4}}}'
leaderworkerset.leaderworkerset.x-k8s.io/ctl-nolabel patched
kubectl -n tenant2 patch lws ctl-label --type=merge --dry-run=server \
  -p '{"spec":{"leaderWorkerTemplate":{"size":4}}}'
Error from server (Forbidden): admission webhook "vleaderworkerset.kb.io" denied the request: spec.leaderWorkerTemplate.size: Invalid value: 4: field is immutable

沒掛標籤的那個過了,掛了的那個被擋。凍住 size 的是 Kueue,不是 LeaderWorkerSet。LWS 自己的 API reference 裡 scheduling 和 subGroupSize 都明寫不可變,size 沒有這一句,CRD 裡也沒有相關的 x-kubernetes-validations。

排著隊的 Workload 不能一邊等一邊改要幾個 Pod。kubectl apply 走的是同一個 webhook,一樣過不去,所以前面那兩道 delete 不能省。


四個階段並排

階段 tok/s p50 p90 p99 DRAM_ACTIVE TENSOR 功耗
1 基準線 2395.3 3.175 3.861 3.958 0.836 / 0.780 0.163 / 0.152 350 / 331 W
2 Volcano 放行 — — — — — — 350 / 349 W
3 爭用 1079.2 7.473 7.949 7.979 0.582 / 0.568 0.475 / 0.496 330 / 335 W
4 Kueue 擋住 2398.6 3.175 3.909 4.324 0.819 / 0.835 0.155 / 0.163 216 / 327 W

送過請求的那三段,errors 都是 0。

同樣四個階段跑了兩輪,第一輪入口走手寫的 HTTPRoute,第二輪走 AIGatewayRoute:

階段 第一輪 第二輪
1 2418.5 / p50 3.172 2395.3 / p50 3.175
2 放行,running=2 放行,running=2
3 1048.2 / p50 7.450 1079.2 / p50 7.473
4 2405.7 / p50 3.195 2406.7 / p50 3.167

第四段後來又單獨重跑了一次,2398.6 / p50 3.175,上面那張並排表用的是這一次。吞吐和 DRAM_ACTIVE、PIPE_TENSOR_ACTIVE 都對得上,而 ip-172-31-26-39 的功耗從第二輪的 344.50 W 變成 216.24 W。


切一半的代價落在 KV cache

這一組和下一組是單獨量的,不在上面那四個階段裡。

同一個模型、三種 claim:

整卡 40000Mi 23000Mi(本實驗)
容器裡 nvidia-smi 46068 MiB 40000 MiB 23000 MiB
Available KV cache memory 25.14 GiB 19.25 GiB 3.97 GiB
GPU KV cache size 470,656 tokens 360,368 tokens 74,384 tokens
主機 nvidia-smi 用量 42093 MiB 36571 MiB 20931 MiB

模型權重那 15.48 GiB 不會因為切卡變小,所以減掉的全部從 KV cache 扣。記憶體砍一半,能放的 token 掉到六分之一。


EPP 把相同前綴送到同一台

只改 prompt,其他都不動:

負載 EPP 分流 兩張卡 吞吐
16 併發、每筆相同 prompt 16 / 0 70% / 0% 695.7 tok/s
16 併發、每筆不同 prompt 8 / 8 100% / 100% 710.6 tok/s

相同前綴的請求會全部落到同一台,另一台閒置。前面那份負載在每一筆的 prompt 前面加隨機前綴,就是為了避開這件事。

兩張卡都滿載的那一組,只比單張卡 70% 的多 2% 吞吐,這一段的瓶頸在客戶端,不在卡。


小結

今天我們用 Day 29 建好的那套叢集跑了四個階段,變數只有一個:第二租戶怎麼進來。Volcano 問的是這一組排不排得進去,容量上放得下就放行,而放行之後原本的服務慢一倍,這件事 gang 不管。Kueue 問的是這個租戶還有沒有額度,沒有就整組擋住,原本的服務維持在基準線。

卡上的兩道額度只有一道守得住。記憶體誰都越不過 23000 MiB;算力的 50 在沒有人搶的時候等於整張卡,有人搶的時候吞吐掉到基準線的 45%。記憶體是硬牆,算力是份額。

而吞吐砍半的那一刻,GPU_UTIL 還是 100、功耗也沒動,動的只有 DRAM_ACTIVE 和 PIPE_TENSOR_ACTIVE,它們說的是卡上在跑什麼,不是服務快不快。


完賽感言

凌晨四點,女友帶著 GPU 來我家學習 Kubernetes,學而有成,滿載而歸。

從 Day1 一路走過來真的蠻累的,尤其這三十天有某幾天我在東京遊玩,那幾天有時候傍晚就回飯店趕文章,趕完後才又出去吃個拉麵然後淺草晃晃。那時候心裡都會想:X,我在日本誒,我幹嘛做這種事,但現在很慶幸我能堅持下來。

除了在日本以外,還有其他大大小小的事,除了 Day1 以外幾乎每天都下班趕文章,所以這三十天的內容應該會留下蠻多 AI 的痕跡,這個以後有機會再慢慢修。這三十天主要都著重在個別的技術學習上,算是有符合 Day 1 我對自己廣學的期許,但這些內容可能會在若干年後成為落伍的技術,因此我認為最重要的還是對技術的熱忱,感謝這三十天大家的陪伴與堅持下來的自己,未來也會持續學習。


文章回顧


上一篇
【Day 29】夢回 Day 1:打造理想中的 Kubernetes AI 基礎設施
系列文
凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言