Compare commits

..
Author SHA1 Message Date
rock 42617fbd7c feat(coder): serve 256K context on TP=2, disable custom all-reduce
Coder-30B-A3B is natively 262,144 tokens with rope_scaling null. One V100
could not hold it: the measured KV pool was 132,672 tokens. TP=2 halves the
weights per card to 7.85 GiB each and frees room for the larger pool.

Measured after boot, both cards worker-1:

  Available KV cache memory per GPU : 19.96 GiB
  GPU KV cache size                 : 436,112 tokens
  Max concurrency at 262,144        : 1.66x
  max_model_len (both served names) : 262144
  POST /v1/chat/completions         : 200 in 2.83s
  Weights, per worker               : 7.8516 GiB
  torch.compile                     : 78 s
  Graph capture                     : 6 s

One fatal detail, measured twice. With vLLM's default custom all-reduce the
engine reaches "Graph capturing finished in 6 secs" and then hangs. The only
output afterwards was "No available shared memory broadcast block found in
60 seconds", repeated for 14 minutes, and the front-end never received the
ready message. The first attempt died on its startup probe at 20 min.

`--disable-custom-all-reduce` fixes it. Custom all-reduce registers its own
CUDA graph addresses and deadlocks on this Volta/PCIe pair. NCCL is the
fallback and costs nothing here, because TP=2 across two PCIe V100s gains
nothing from the custom kernel. Both workers now register cleanly and the
API binds at 17:18.

Two supporting changes, both required:
  - memory request 12Gi. worker-1 exposes only 63,495 MiB allocatable, so the
    earlier 24Gi request could never schedule.
  - /dev/shm 8Gi. NCCL all-reduce moves through it, and the 64Mi default
    shows up as an init hang.

`reasoning` remains a served name at the same 262,144 so the client needs no
model-name change.
2026-10-31 19:11:29 -07:00
rock dc5ec40c7d feat(llm-serving): add coder, serving Qwen3-Coder-30B-A3B on one V100
The name now matches the model. `coder` serves
Qwen3-Coder-30B-A3B-Instruct, a 30.5B MoE with 3.3B active, quantized to
4-bit GPTQ. It is measured working on this node, not assumed.

Measured, on worker-1, driver 580.159.04, compute capability 7.0:

  GET  /v1/models        -> id=coder, root=ModelCloud/Qwen3-Coder-30B-A3B-Instruct-GPTQMODEL-W4A16-A
  POST /v1/chat/completions model=coder -> 200 in 3.18s,
       "fn main() { println!(\"hi\"); }"
  POST /v1/chat/completions model=reasoning -> 200 in 0.21s

Weights loaded in 15.61 GiB. torch.compile took 78s. The pod reached
1/1 Ready. `kubectl diff -f coder.yaml` returns empty, so the manifest
matches live state.

Two names are served on purpose: `coder` and `reasoning`. The rename
therefore needs no change to poimen-memory's LLM_MODEL, only its host.
That keeps the client working through the cutover.

Why not Qwen3-Coder-Next, the model in the original plan. It cannot run
here, and that is measured too. A v0.15.0 image on this free V100 gave:

  TORCH 2.9.1+cu129
  ARCH_LIST []                       <- empty
  RuntimeError: cudaGetDeviceCount() ... Error 803
  fla: ModuleNotFoundError           <- no GatedDeltaNet kernels

vLLM 0.15.0 requires compute capability 7.5 or higher and fails here
before it loads a model. Qwen3-Next's hybrid attention needs the `fla`
module, which the image does not ship. So this stays on the pinned
v0.11.0 image, which reports sm_70..sm_120 and works.

Qwen publishes this model as BF16 (61.1 GB) and FP8 only. V100 has no
FP8, and 61.1 GB does not fit 32 GB, so 4-bit GPTQ is the only form that
fits one card. The checkpoint is a community quant; the manifest says so
and names the reason.

reasoning.yaml stays listed until poimen-memory's LLM_CHAT_BASE moves
from reasoning-predictor to coder-predictor. Two GPUs are free, so coder
rolled out beside reasoning with no downtime.
2026-10-31 19:11:29 -07:00
rock a234451548 fix(llm-serving): allow poimen namespace ingress so memory ingest works
poimen-memory could not reach any inference pod. The egress side was
already correct: NetworkPolicy poimen/poimen-memory-llm-egress permits
port 8080 to the pods labelled llm-reasoning, llm-embeddings and
llm-reranker. This policy had no matching ingress rule, so Cilium
allowed the packet out and dropped it at the destination.

The observable was a bare timeout. The request never reached vLLM, so
the predictor log stayed empty, and /health still answered because a
probe from inside the pod does not cross the policy. That made the
failure look like a hung inference engine.

Evidence, measured on 2026-10-31 against the running predictors:

  1. A probe pod in llm-serving WITH label part-of=llm-serving:
     /v1/chat/completions -> 200 in 3.1s, 8 completion tokens.
  2. The same probe pod WITHOUT that label: 8s timeout, all services.
  3. From inside poimen-memory-bfc8k: embeddings /health -> 12s
     timeout; 10.96.0.1:443 -> 8s timeout. Same-namespace traffic
     still worked, which limits the fault to this policy.
  4. memory logged "readiness: embedder probe timed out
     elapsed_ms=10000" every 60s.

All five services already serve inference correctly. They are
reasoning (3.1s), reasoning-short (2.5s), embeddings (0.5s, real
768-dim vector), reranker (0.09s, 0.982 vs 0.00004) and qwen-cpu
(4.1s, qwen2.5:3b-instruct).

This adds the missing ingress rule. It mirrors the existing paperless
rule, which bypasses the gateway for the same reason: poimen-memory
has no service-account JWT yet.

Refs: #TBD
2026-10-31 19:11:29 -07:00
4 changed files with 156 additions and 22 deletions
+134
View File
@@ -0,0 +1,134 @@
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
+4
View File
@@ -14,6 +14,10 @@ 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,
+15
View File
@@ -66,6 +66,21 @@ 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:
@@ -134,31 +134,12 @@ 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 rises to 750m because a request below real usage earns the pod
# little scheduling weight during contention. limits.cpu rises to 3000m
# because diff computation is bursty and needs room to run without throttling.
#
# Caution: this pod runs on talos-cp-3, a control-plane node. 3000m is 25% of
# that node's 11950m allocatable CPU. If Gitea sustains near this limit, move
# the workload off the control plane rather than raising the limit again.
resources: resources:
requests: requests:
cpu: 750m cpu: 200m
memory: 768Mi memory: 512Mi
limits: limits:
cpu: 3000m cpu: 1000m
memory: 2Gi memory: 2Gi
# Tolerations for control-plane # Tolerations for control-plane