終於來到整個凌晨四點系列的最後一天,今天我們在昨天建立的整個 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。
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 扣。這是它們唯一會互相影響的地方,卡上的爭用則是另一回事。
四個階段的第二租戶都用這一份,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
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 的額度;算力那一半在沒有人搶的時候不起作用,整張卡都是它的。
第二租戶是一個 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 自己的數字或者客戶端的延遲。
第二租戶改成 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。配額擋住的是新來的那一組,原本的服務維持在基準線。
前面那兩道 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。
這一組和下一組是單獨量的,不在上面那四個階段裡。
同一個模型、三種 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 掉到六分之一。
只改 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 我對自己廣學的期許,但這些內容可能會在若干年後成為落伍的技術,因此我認為最重要的還是對技術的熱忱,感謝這三十天大家的陪伴與堅持下來的自己,未來也會持續學習。