Files
homelab/k8s/apps/llm-serving/qwen3-moe.yaml
T
rock 41dcda819e feat(llm-serving): manage clef-serve in git, generated from app.py
The Clef runtime was running from hand-applied objects that existed only in the cluster.
A rebuild would have lost it.

Three things move into git:
  - clef-serve.yaml: the Deployment and the Service the gateway routes to
  - configMapGenerator for clef-serve/app.py, so an edit to the code rolls the pod
    instead of leaving a stale copy mounted
  - clef-flash.yaml is deleted: it described an SGLang predictor that cannot run on a
    V100, and its slot is now clef-serve.yaml

Recorded in the manifest, because both cost hours: SGLang needs CUDA 13 for Clef and
CUDA 13 has no sm_70 kernels, and device_map over two cards breaks the forward pass with
'indices should be either on cpu or on the same device as the indexed tensor'.

Still open and written down in the file: the image cannot be pushed, because Cloudflare
answers 413 for layers above 100 MB. So each start pip-installs and needs PyPI.
2026-10-29 23:23:03 -07:00

152 lines
6.3 KiB
YAML

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
annotations:
serving.kserve.io/deploymentMode: RawDeployment
# KServe injects the prometheus.io/{scrape,port,path} annotations into the
# generated pod only when this flag is on. Without it the predictor carries
# no scrape annotations at all, which is why no vLLM/SGLang metrics have
# ever reached Prometheus from this namespace.
serving.kserve.io/enable-prometheus-scraping: "true"
labels:
app.kubernetes.io/name: llm-reasoning
app.kubernetes.io/part-of: llm-serving
name: reasoning
namespace: llm-serving
spec:
predictor:
containers:
- args:
# Replaces the dense Qwen3-32B. Same serving name (`reasoning`), so every
# consumer keeps its model string and no client changes.
#
# Why a MoE: Qwen3-30B-A3B has 30.5B total but only 3.3B active per token.
# On sm70 the decode path is bandwidth- and kernel-launch-bound, not
# FLOP-bound, so activating 3.3B instead of 32B is the single biggest
# throughput win available on this hardware without changing GPUs.
#
# Quantization: GPTQ-Int4, pinned with --quantization=gptq. This repeats
# the finding recorded on the previous checkpoint: bnb-4bit works poorly
# on sm70 because there are no int4 tensor cores, and vLLM auto-upgrades
# AWQ/GPTQ to the Marlin kernel which requires sm80+. Pinning `gptq`
# forces the plain kernel, which is verified Volta-compatible here.
# Do not remove this flag.
#
# Context: 131072 via YaRN. The base model card documents 32,768 native
# and 131,072 with YaRN (factor 4.0, original_max_position_embeddings
# 32768). That is Qwen's own validated ceiling, so unlike a stretched
# window this is a documented capability.
#
# KV cache math, from config.json: 48 layers, 4 KV heads, head_dim 128,
# fp16 -> 2 * 4 * 128 * 2 = 2048 B/token/layer -> 96 KB/token.
# At 131072 tokens that is 12 GiB total. With TP=2 the KV heads split
# 2 per GPU, so each GPU carries ~6 GiB of KV. Weights are ~17 GB total,
# so ~8.5 GB per GPU. At gpu-memory-utilization=0.95 (about 30.4 GB
# usable per V100-32GB) that leaves roughly 21 GB per GPU for KV plus
# activations: comfortable for one full 128K request and a few shorter
# ones. Re-read vllm:cache_config_info after rollout for the real
# num_gpu_blocks value rather than trusting this projection.
#
# TP=2, not PP=2: the model has 4 KV heads, so TP must divide 4. TP=2
# splits cleanly; TP=3 would not. TP also shortens prefill at long
# context, which is the actual bottleneck at 128K on V100.
- --model=Qwen/Qwen3-30B-A3B-GPTQ-Int4
- --served-model-name=reasoning
- --quantization=gptq
- --dtype=float16
- --kv-cache-dtype=auto
- --rope-scaling={"rope_type":"yarn","factor":4.0,"original_max_position_embeddings":32768}
- --tensor-parallel-size=2
- --max-model-len=131072
- --gpu-memory-utilization=0.95
- --max-num-seqs=4
# Prefill scheduling. The same reasoning as the previous checkpoint
# applies: vLLM V1 has no separate prefill phase, so a single long
# prompt can consume the whole step budget and starve every other
# request. The threshold must be strictly below the budget or it never
# binds, hence 8192 budget with a 2048 threshold.
- --max-num-batched-tokens=8192
- --long-prefill-token-threshold=2048
- --enable-chunked-prefill
- --enable-prefix-caching
# qwen3 is vLLM's reasoning parser for this family's <think> blocks.
- --reasoning-parser=qwen3
# hermes is the documented tool-call parser for general Qwen3 models.
- --enable-auto-tool-choice
- --tool-call-parser=hermes
- --host=0.0.0.0
- --port=8080
env:
# FlashInfer is sm80+. TRITON_ATTN is the Volta-compatible backend and is
# already the proven setting on this node. Clef-flash runs on SGLang
# instead, but Qwen stays on vLLM here because this exact combination is
# tested on this hardware; moving it to SGLang would add risk for no
# measured gain.
- name: VLLM_USE_FLASHINFER_SAMPLER
value: '0'
- name: VLLM_ATTENTION_BACKEND
value: TRITON_ATTN
- name: HF_HOME
value: /mnt/models
image: vllm/vllm-openai:v0.11.0@sha256:014a95f21c9edf6abe0aea6b07353f96baa4ec291c427bb1176dc7c93a85845c
name: kserve-container
ports:
- containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
resources:
limits:
cpu: '16'
memory: 36Gi
nvidia.com/gpu: '2'
requests:
cpu: '8'
memory: 12Gi
nvidia.com/gpu: '2'
startupProbe:
# 30.5B of int4 weights plus a 128K rope config: allow a generous
# first-boot window. failureThreshold 120 * 15s = 30 min ceiling.
failureThreshold: 120
httpGet:
path: /health
port: 8080
periodSeconds: 15
volumeMounts:
- mountPath: /mnt/models
name: models
- mountPath: /dev/shm
name: shm
# podMetadata is NOT a valid field on this KServe version. The CRD exposes
# annotations and labels directly under predictor. The older manifests in
# this directory still carry podMetadata and ArgoCD silently drops it on
# server-side apply; do not copy that pattern.
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
labels:
app.kubernetes.io/name: llm-reasoning
app.kubernetes.io/part-of: llm-serving
deploymentStrategy:
# Recreate, not RollingUpdate: GPU memory is a hard resource. A second
# pod cannot schedule alongside the first on a full 4-GPU node, so a
# rolling update would wedge with the new pod Pending forever.
type: Recreate
maxReplicas: 1
minReplicas: 1
nodeSelector:
kubernetes.io/hostname: worker-1
runtimeClassName: nvidia
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
- emptyDir:
medium: Memory
sizeLimit: 2Gi
name: shm