Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7de85e01e | ||
|
|
aa992872cb | ||
|
|
1fd5f25932 | ||
|
|
8f9bf6ee8d | ||
|
|
ce4434286c | ||
|
|
f81db119f4 |
@@ -1,134 +0,0 @@
|
|||||||
apiVersion: serving.kserve.io/v1beta1
|
|
||||||
kind: InferenceService
|
|
||||||
metadata:
|
|
||||||
name: coder
|
|
||||||
namespace: llm-serving
|
|
||||||
labels:
|
|
||||||
app.kubernetes.io/name: llm-coder
|
|
||||||
app.kubernetes.io/part-of: llm-serving
|
|
||||||
annotations:
|
|
||||||
serving.kserve.io/deploymentMode: RawDeployment
|
|
||||||
spec:
|
|
||||||
predictor:
|
|
||||||
minReplicas: 1
|
|
||||||
maxReplicas: 1
|
|
||||||
nodeSelector:
|
|
||||||
kubernetes.io/hostname: worker-1
|
|
||||||
# Two GPUs are free, so this rolls out beside `reasoning` instead of over it.
|
|
||||||
# Recreate still matters for the later swap: a rolling update needs a card the
|
|
||||||
# old pod holds and would sit Pending forever.
|
|
||||||
deploymentStrategy:
|
|
||||||
type: Recreate
|
|
||||||
containers:
|
|
||||||
- name: kserve-container
|
|
||||||
# Pinned to v0.11.0 on purpose. This is the last image measured to work on this
|
|
||||||
# node: torch 2.8.0+cu128, arch list sm_70..sm_120, driver 580.159.04.
|
|
||||||
# v0.15.0 was measured and FAILED here: arch list empty, and
|
|
||||||
# `cudaGetDeviceCount()` raised Error 803 (unsupported driver combination)
|
|
||||||
# before any model load. Do not raise this tag without re-running that probe.
|
|
||||||
image: vllm/vllm-openai:v0.11.0@sha256:014a95f21c9edf6abe0aea6b07353f96baa4ec291c427bb1176dc7c93a85845c
|
|
||||||
args:
|
|
||||||
# Qwen3-Coder-30B-A3B-Instruct: 30.5B total, 3.3B active. Architecture is
|
|
||||||
# Qwen3MoeForCausalLM, the same class as the Qwen3-30B-A3B already serving
|
|
||||||
# here, so it takes the proven code path on sm70.
|
|
||||||
#
|
|
||||||
# Qwen ships this model only as BF16 (61.1 GB) and FP8 (no FP8 on Volta).
|
|
||||||
# 4-bit GPTQ is the only form that fits one 32 GB card. This checkpoint is a
|
|
||||||
# community quant: ModelCloud maintains GPTQModel, and the repo is complete
|
|
||||||
# (config + tokenizer + 4 shards, 16.9 GB). Verified by loading, not by trust.
|
|
||||||
#
|
|
||||||
# Qwen3-Coder-Next (80B/3B, the hybrid-attention model) is NOT an option:
|
|
||||||
# its GatedDeltaNet kernels need compute capability 7.5+ and the `fla` module,
|
|
||||||
# and sm70 has neither. Measured, not assumed.
|
|
||||||
- --model=ModelCloud/Qwen3-Coder-30B-A3B-Instruct-GPTQMODEL-W4A16-A
|
|
||||||
# `reasoning` stays a served name so an existing client that sends
|
|
||||||
# model=reasoning keeps working after the DNS rename. vLLM takes the names
|
|
||||||
# as a space-separated list, so they go in as separate argv items.
|
|
||||||
- --served-model-name
|
|
||||||
- coder
|
|
||||||
- reasoning
|
|
||||||
- --quantization=gptq
|
|
||||||
- --dtype=float16
|
|
||||||
- --kv-cache-dtype=auto
|
|
||||||
# Native window is 262144 and rope_scaling is null, so 256K needs no YaRN.
|
|
||||||
# One card cannot hold it: measured KV pool on a single V100 was 132,672
|
|
||||||
# tokens. TP=2 halves the weights per card (7.8 GiB each), which frees room
|
|
||||||
# for the larger pool. Measured after boot: see the KV line in the log.
|
|
||||||
- --max-model-len=262144
|
|
||||||
- --tensor-parallel-size=2
|
|
||||||
- --pipeline-parallel-size=1
|
|
||||||
- --gpu-memory-utilization=0.90
|
|
||||||
# A single 262,144-token request consumes the whole pool, so keep few slots.
|
|
||||||
- --max-num-seqs=4
|
|
||||||
- --max-num-batched-tokens=8192
|
|
||||||
- --long-prefill-token-threshold=2048
|
|
||||||
- --enable-chunked-prefill
|
|
||||||
- --enable-prefix-caching
|
|
||||||
# Measured: with the default (custom all-reduce ON) the engine finished CUDA graph
|
|
||||||
# capture in 6s at 17:01:56 and then went silent for 14+ minutes, emitting only
|
|
||||||
# "No available shared memory broadcast block found in 60 seconds". The front-end
|
|
||||||
# never received the ready message. vLLM's custom all-reduce registers its own
|
|
||||||
# CUDA graph addresses and deadlocks on this Volta/PCIe pair. NCCL is the fallback
|
|
||||||
# and is correct here, because TP=2 across two PCIe V100s gains nothing from the
|
|
||||||
# custom kernel anyway.
|
|
||||||
- --disable-custom-all-reduce
|
|
||||||
- --reasoning-parser=qwen3
|
|
||||||
- --enable-auto-tool-choice
|
|
||||||
- --tool-call-parser=hermes
|
|
||||||
- --host=0.0.0.0
|
|
||||||
- --port=8080
|
|
||||||
env:
|
|
||||||
# Required on this hardware, not tuning:
|
|
||||||
# - flashinfer's check_cuda_arch raises on compute capability 7.0,
|
|
||||||
# so the sampler must stay off
|
|
||||||
# - FlashInfer and FlashAttention-2 need sm75+, so Triton serves attention
|
|
||||||
- name: VLLM_USE_FLASHINFER_SAMPLER
|
|
||||||
value: "0"
|
|
||||||
- name: VLLM_ATTENTION_BACKEND
|
|
||||||
value: TRITON_ATTN
|
|
||||||
- name: HF_HOME
|
|
||||||
value: /mnt/models
|
|
||||||
ports:
|
|
||||||
- containerPort: 8080
|
|
||||||
protocol: TCP
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8080
|
|
||||||
periodSeconds: 10
|
|
||||||
startupProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /health
|
|
||||||
port: 8080
|
|
||||||
# TP=2 at 256K needs ~10 min before the API binds: 22s weights, 80s torch.compile,
|
|
||||||
# 85s KV profiling, then graph capture. 120 x 15s = 30 min of headroom.
|
|
||||||
failureThreshold: 120
|
|
||||||
periodSeconds: 15
|
|
||||||
resources:
|
|
||||||
# worker-1 exposes only 63,495 MiB of allocatable memory, so a 24Gi request
|
|
||||||
# never schedules. Two vLLM workers keep the weights on the GPU and need
|
|
||||||
# little host RAM, so the request stays small and the limit allows burst.
|
|
||||||
requests:
|
|
||||||
cpu: "8"
|
|
||||||
memory: 12Gi
|
|
||||||
nvidia.com/gpu: "2"
|
|
||||||
limits:
|
|
||||||
cpu: "16"
|
|
||||||
memory: 40Gi
|
|
||||||
nvidia.com/gpu: "2"
|
|
||||||
volumeMounts:
|
|
||||||
- mountPath: /mnt/models
|
|
||||||
name: models
|
|
||||||
- mountPath: /dev/shm
|
|
||||||
name: shm
|
|
||||||
runtimeClassName: nvidia
|
|
||||||
volumes:
|
|
||||||
- name: models
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: llm-models
|
|
||||||
- name: shm
|
|
||||||
emptyDir:
|
|
||||||
medium: Memory
|
|
||||||
# NCCL all-reduce over TP=2 moves through /dev/shm. The 64Mi default is
|
|
||||||
# too small and shows up as an init hang or a bus error.
|
|
||||||
sizeLimit: 8Gi
|
|
||||||
@@ -14,10 +14,6 @@ resources:
|
|||||||
# v0.5.22+, published only on CUDA 13, and CUDA 13 dropped Volta kernels. The model
|
# v0.5.22+, published only on CUDA 13, and CUDA 13 dropped Volta kernels. The model
|
||||||
# instead runs the implementation it ships (joint_schema_model.py) in fp16 on one V100.
|
# instead runs the implementation it ships (joint_schema_model.py) in fp16 on one V100.
|
||||||
- clef-serve.yaml
|
- clef-serve.yaml
|
||||||
# coder = Qwen3-Coder-30B-A3B-Instruct, 4-bit GPTQ, TP=2 at 262,144 tokens. It serves
|
|
||||||
# both `coder` and `reasoning`, so poimen-memory needs a host change only, never a
|
|
||||||
# model-name change. reasoning.yaml below can be dropped once that host change lands.
|
|
||||||
- coder.yaml
|
|
||||||
- reasoning.yaml
|
- reasoning.yaml
|
||||||
# ornith is retired: its InferenceService still reports Ready=True while running zero
|
# ornith is retired: its InferenceService still reports Ready=True while running zero
|
||||||
# replicas, because desired equals available at 0. Its manifest was never committed,
|
# replicas, because desired equals available at 0. Its manifest was never committed,
|
||||||
|
|||||||
@@ -66,21 +66,6 @@ spec:
|
|||||||
port: 80
|
port: 80
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: 8000
|
port: 8000
|
||||||
# Allow from poimen namespace (poimen-memory ingest + query).
|
|
||||||
# The client-side egress rule is the other half and must stay in step:
|
|
||||||
# NetworkPolicy poimen/poimen-memory-llm-egress permits port 8080 to pods
|
|
||||||
# labelled part-of=llm-serving, which is the same selector used here. This
|
|
||||||
# policy had no matching ingress rule at all, so the packet was allowed out
|
|
||||||
# and dropped here. The client then saw a bare timeout and the predictor log
|
|
||||||
# stayed empty, which is why this failure looked like a hung inference engine
|
|
||||||
# for 34 days.
|
|
||||||
- from:
|
|
||||||
- namespaceSelector:
|
|
||||||
matchLabels:
|
|
||||||
kubernetes.io/metadata.name: poimen
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 8080
|
|
||||||
# Allow intra-namespace (pod-to-pod within llm-serving)
|
# Allow intra-namespace (pod-to-pod within llm-serving)
|
||||||
- from:
|
- from:
|
||||||
- podSelector:
|
- podSelector:
|
||||||
|
|||||||
@@ -97,10 +97,25 @@ gitea:
|
|||||||
# USER and PASSWD injected via env vars below (GITEA__MAILER__USER, GITEA__MAILER__PASSWD)
|
# USER and PASSWD injected via env vars below (GITEA__MAILER__USER, GITEA__MAILER__PASSWD)
|
||||||
|
|
||||||
# Persistence (shared storage for repos)
|
# Persistence (shared storage for repos)
|
||||||
|
#
|
||||||
|
# 60Gi, not the original 20Gi. The container registry stores its blobs here,
|
||||||
|
# and a single candle-vllm sm_70 image is 14GB uncompressed. At 20Gi the volume
|
||||||
|
# filled, and every blob upload failed with:
|
||||||
|
# Package registry API internal error: 500 write /data/tmp/package-upload/...:
|
||||||
|
# no space left on device
|
||||||
|
# The push client surfaces that as a bare `received unexpected HTTP status: 500`
|
||||||
|
# after retrying every layer, which looks like a network fault and is not one.
|
||||||
|
#
|
||||||
|
# Note for whoever expands this again: Longhorn's admission webhook checks the
|
||||||
|
# PHYSICAL free space of the disk holding each replica, not the thin volume
|
||||||
|
# size. A request for 100Gi was rejected while the disk had 23.5Gi free:
|
||||||
|
# disk ... does not have sufficient physical space for expansion:
|
||||||
|
# physical free space would drop below minimal: left=14596177920 < minimal=49931236556
|
||||||
|
# 60Gi fit. Raise it in steps and watch the webhook message.
|
||||||
persistence:
|
persistence:
|
||||||
enabled: true
|
enabled: true
|
||||||
storageClass: longhorn
|
storageClass: longhorn
|
||||||
size: 20Gi
|
size: 60Gi
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
|
|
||||||
@@ -134,10 +149,29 @@ ingress:
|
|||||||
# wildcard. The wildcard covers this host.
|
# wildcard. The wildcard covers this host.
|
||||||
|
|
||||||
# Resources
|
# Resources
|
||||||
|
#
|
||||||
|
# The 1000m ceiling throttled Gitea badly. Measured on a running pod:
|
||||||
|
# nr_throttled 189528 of 1169014 CFS periods (16.2%)
|
||||||
|
# throttled_usec 4424898602 us (~74 minutes lost)
|
||||||
|
# actual usage 951m against the 1000m limit
|
||||||
|
# The visible symptom was slow Actions traffic: FetchTask took 3469.9ms and
|
||||||
|
# repository compare endpoints took 3091-3811ms, so runners timed out and
|
||||||
|
# retried, which added more load to an already saturated process.
|
||||||
|
#
|
||||||
|
# The database was never the bottleneck. A real action_runner UPDATE measured
|
||||||
|
# 2.133ms with zero lock waits, so this is CPU starvation in Gitea itself.
|
||||||
|
#
|
||||||
|
# requests.cpu stays 200m and limits.cpu stays 1000m. The 3000m limit was a
|
||||||
|
# temporary measure while the Actions stall was diagnosed; the real fix landed
|
||||||
|
# elsewhere, so the limit is back to the original.
|
||||||
|
#
|
||||||
|
# Caution: this pod runs on talos-cp-3, a control-plane node. If Gitea
|
||||||
|
# throttles again at 1000m, move the workload off the control plane rather
|
||||||
|
# than raising the limit.
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 200m
|
cpu: 200m
|
||||||
memory: 512Mi
|
memory: 768Mi
|
||||||
limits:
|
limits:
|
||||||
cpu: 1000m
|
cpu: 1000m
|
||||||
memory: 2Gi
|
memory: 2Gi
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# One-shot destructive health scan of a candidate Longhorn disk.
|
||||||
|
#
|
||||||
|
# WHY THIS IS A MANUAL JOB AND NOT AN ARGOCD HOOK
|
||||||
|
# It writes over the entire raw device. An ArgoCD PostSync hook would run it
|
||||||
|
# on every sync and destroy whatever the volume holds. Apply it by hand,
|
||||||
|
# once, on a disk that has no partitions and no mounts.
|
||||||
|
#
|
||||||
|
# WHY NOT TALOS
|
||||||
|
# Talos declares disks by mounting them (machine.disks in the machine
|
||||||
|
# config). It has no primitive for "run this command on the host", so a scan
|
||||||
|
# cannot live in the Talos config. This Job is the IaC-correct home.
|
||||||
|
#
|
||||||
|
# ORDER OF OPERATIONS
|
||||||
|
# 1. Scan the raw device with this Job <- the disk must be UNMOUNTED
|
||||||
|
# 2. If it passes, add the disk to terraform.tfvars.local `longhorn_disks`
|
||||||
|
# with device /dev/disk/by-id/<wwid> and run `make apply-cp`
|
||||||
|
# 3. Register it with Longhorn via the pattern in longhorn-add-disks-job.yaml
|
||||||
|
#
|
||||||
|
# What it does
|
||||||
|
# fio writes a CRC32C-verified pattern across the whole device, then reads
|
||||||
|
# every block back and compares. Any unreadable or mismatched sector fails
|
||||||
|
# the Job. This is a full write and read pass, not a sample.
|
||||||
|
#
|
||||||
|
# Expected duration
|
||||||
|
# 2 TB on a 5400 rpm 2.5" drive at roughly 90 MB/s is about 6 h to write and
|
||||||
|
# 6 h to verify. Budget 12 hours. Check progress with:
|
||||||
|
# kubectl -n llm-io logs job/disk-scan-cp1-sdb -f
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: disk-scan-cp1-sdb
|
||||||
|
namespace: llm-io
|
||||||
|
labels:
|
||||||
|
app: disk-scan
|
||||||
|
spec:
|
||||||
|
backoffLimit: 0
|
||||||
|
# Do not let a stuck scan retry silently.
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: disk-scan
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
nodeSelector:
|
||||||
|
kubernetes.io/hostname: talos-cp-1
|
||||||
|
containers:
|
||||||
|
- name: scan
|
||||||
|
image: ubuntu:22.04
|
||||||
|
securityContext:
|
||||||
|
privileged: true
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq smartmontools fio
|
||||||
|
|
||||||
|
echo "=== device identity ==="
|
||||||
|
smartctl -i /dev/sdb | tail -12
|
||||||
|
|
||||||
|
echo "=== SMART before ==="
|
||||||
|
smartctl -H /dev/sdb | tail -2
|
||||||
|
|
||||||
|
echo "=== full write + verify (destructive) ==="
|
||||||
|
fio --name=devverify \
|
||||||
|
--filename=/dev/sdb \
|
||||||
|
--rw=write \
|
||||||
|
--bs=1M \
|
||||||
|
--iodepth=4 \
|
||||||
|
--ioengine=libaio \
|
||||||
|
--direct=1 \
|
||||||
|
--verify=crc32c \
|
||||||
|
--do_verify=1 \
|
||||||
|
--verify_fatal=1 \
|
||||||
|
--eta=always \
|
||||||
|
--output-format=normal
|
||||||
|
|
||||||
|
echo "=== SMART after ==="
|
||||||
|
smartctl -H /dev/sdb | tail -2
|
||||||
|
smartctl -A /dev/sdb | awk '/Reallocated_Sector|Current_Pending|Offline_Uncorrect|UDMA_CRC/{print " "$0}'
|
||||||
|
|
||||||
|
echo "=== SCAN PASSED ==="
|
||||||
|
volumeMounts:
|
||||||
|
- name: dev
|
||||||
|
mountPath: /dev
|
||||||
|
volumes:
|
||||||
|
- name: dev
|
||||||
|
hostPath:
|
||||||
|
path: /dev
|
||||||
Reference in New Issue
Block a user