Skip to content

ARM/CPU inference tier: vLLM on Graviton (PRD-67) - #92

Merged
jicowan merged 10 commits into
mainfrom
feature/cpu-arm-inference-tier
Sep 2, 2026
Merged

ARM/CPU inference tier: vLLM on Graviton (PRD-67)#92
jicowan merged 10 commits into
mainfrom
feature/cpu-arm-inference-tier

Conversation

@jicowan

@jicowan jicowan commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a CPU (ARM/Graviton) accelerator tier alongside GPU and Neuron (PRD-67):

  • vllm-cpu runtime (internal/runtime/vllm_cpu.go) targeting the vllm/vllm-openai-cpu:*-arm64 images, with NUMA-aware TP and VLLM_CPU_KVCACHE_SPACE sizing.
  • Seeded Graviton instance types (c/m/r 7g–9g), a dedicated cpu Karpenter NodePool with the accelbench.io/cpu taint, and a settable vllm_cpu_version in tool versions.
  • CPU-aware recommender (host-memory bound, unquantized-size ceiling) and a metrics guard for GPU-only scrapers.
  • Server guards + UI gating for GPU-only knobs on CPU runs; CPU-vs-GPU compare report degrades gracefully.
  • Live-validation fixes: image tag (arm64 images start at v0.18.0), Run:ai streamer fallback to HF download (CPU image lacks the streamer), loadgen model-name derivation.

Live-validated on accelbench-eks: Qwen2.5-1.5B-Instruct on r8g.16xlarge, 600/600 requests (PRD-67 §1–§12).

Test plan

  • go test ./... && go vet ./...
  • cd frontend && npx tsc --noEmit && npm run build
  • Live run on r8g.16xlarge green

🤖 Generated with Claude Code

jicowan and others added 10 commits August 10, 2026 20:13
New vllm-cpu Runtime, the third accelerator tier alongside gpu + neuron,
mirroring VLLMneuron. Single-node, quantization-forward, no discrete
accelerator device.

- DefaultImage → vllm/vllm-openai-cpu:v<ver>-arm64 (the -arm64 suffix is
  load-bearing), routed through the EXISTING Docker Hub ECR pull-through
  cache like vllm-gpu — no new Terraform. VLLM_CPU_IMAGE override (mirrors
  VLLM_IMAGE / PRD-49).
- ResolveVersion reads a DISTINCT tool_versions field (VLLMCPUVersion, added
  to the runtime ToolVersions projection), NOT FrameworkVersion — the CPU
  image is a different repo with its own arm64 cadence. DefaultVLLMCPUVersion
  fallback.
- BuildArgs forces --dtype bfloat16 (float16 unstable on torch CPU), TP =
  NUMA-node count (caller-supplied), and NEVER emits --gpu-memory-utilization
  or --kv-cache-dtype (GPU-only). Reuses the shared S3 + Run:ai streamer path.
- MapQuantization → compressed-tensors for int8/int4 (W8A8/W4A8, Arm KleidiAI),
  not the GPU's bitsandbytes; suppressed when streaming.
- streamerExtraConfig now gates distributed:true off for cpu (new
  ContainerParams.Accelerator field): distributed streaming is a CUDA/ROCm
  torch-distributed feature, meaningless for single-node NUMA "ranks" (§5b).
  GPU/D-P paths unchanged (Accelerator empty ≠ "cpu").
- Registered in registry; ForAccelerator("cpu") → vllm-cpu.

Grounding caveat (verified this session): ARM W8A8/W4A8 is documented in the
vLLM Arm blog but not yet in the stable CPU support matrix — validate on the
real arm64 image before the §11c guard hard-rejects unquantized.

Tests: registration, ForAccelerator, DefaultImage (+ pull-through), version
resolution, forced bfloat16, GPU-only knobs absent, and the key §5b assertion
that CPU streaming at TP>1 omits distributed:true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeremy Cowan <jicowan@hotmail.com>
…checks (PRD-67 §2)

Migration 043: add the 'cpu' accelerator tier and seed Graviton3/4/5 rows.

- Expand instance_types.accelerator_type CHECK to include 'cpu' and
  benchmark_runs.framework CHECK to include 'vllm-cpu' (drop-and-recreate,
  mirroring migration 034's llm-d addition). Both constraints rejected the
  new values.
- Seed 17 rows: r8g/m8g/c8g (Graviton4, primary), r7g/m7g/c7g (Graviton3,
  fallback), m9g/c9g (Graviton5, GA in us-east-2). Graviton2 EXCLUDED (no
  SVE/I8MM/BF16). accelerator_count=0 + accelerator_memory_gib=0 (no discrete
  accelerator; host DRAM in memory_gib is the serving memory). accelerator_name
  = Graviton generation. vCPU/memory verified against EC2 spec pages.

accelerator_count=0 audit (recorded in PRD-67 Risks):
- report/compare.go perGPU() already guards count<=0 → nil (no ÷0); raw CSV
  column prints 0. Safe.
- recommend.go:688 (AcceleratorMemoryGiB/AcceleratorCount) IS ÷0-reachable for
  CPU because the dispatch (handlers.go:1338, handlers_estimate.go:215,
  seeder.go:221) is `if neuron else GPU` — a CPU instance falls into the GPU
  recommender. MUST be fixed by §4 (add a cpu→RecommendCPU branch) before CPU
  rows are usable; §4 is next.
- catalog.go accelerator_type filter is user-param only, doesn't drop cpu rows.

Applied + verified live: 17 rows, idempotent (ON CONFLICT), r8g.16xlarge =
64 vCPU/512 GiB/count 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeremy Cowan <jicowan@hotmail.com>
…RD-67 §4)

New RecommendCPU (internal/recommend/cpu.go), a SEPARATE path from the GPU
recommender because the memory model differs: CPU has no VRAM and no
gpu_memory_utilization; host DRAM IS the serving memory and the KV cache is a
fixed absolute allocation (VLLM_CPU_KVCACHE_SPACE). Fit is recomposed on the
host-memory map:

    weights(quantized) + kv_cache_space + activation <= node_RAM * hostAllocatableFrac

- Reuses modelMemoryBytes / kvCachePerTokenBytes / hostAllocatableFrac /
  inferenceOverheadBytes / isUnsupportedModelKind / roundDownContext — recomposition,
  not new physics.
- TP = NUMA-node count via cpuNumaNodes(), which returns 1: Graviton is
  single-socket, so TP=1 and intra-node parallelism comes from OMP thread
  binding, NOT tensor-parallel ranks. NOT derived from AcceleratorCount (0).
- dtype always bfloat16 (float16 unstable on torch CPU); no fp8 KV.
- §11c: refuses DENSE UNQUANTIZED models above ~13B, steering to W8A8/W4A8 with
  a quantization alternative; warns 8-13B. Pre-quantized checkpoints keep their
  format. Loud memory-bandwidth advisory on every rec.
- CPUKVCacheSpaceGiB exported so the manifest (§3) sets VLLM_CPU_KVCACHE_SPACE to
  the exact value the recommender budgeted.

Fixes the ÷0 the §2 audit flagged: the recommender dispatch (handlers.go:1338,
handlers_estimate.go:215, seeder.go:221) was `if neuron else GPU`, so a CPU
instance fell into the GPU Recommend and divided AcceleratorMemoryGiB by
AcceleratorCount=0. All three now have a `cpu` branch → RecommendCPU. Also guard
handleMemoryBreakdown (the GPU-VRAM breakdown, which ÷0'd at line 125) to 400 for
CPU instances — the VRAM breakdown is a GPU-shaped framing that doesn't apply.

Tests: fit on r8g.16xlarge, oversized-unquantized refusal + quant alternative,
large INT4 MoE fits the 1.5 TiB box, doesn't-fit small host, embedding rejection,
Graviton generation mapping, KV-cache-space clamping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeremy Cowan <jicowan@hotmail.com>
…§3, §8)

Add a third accelerator branch (cpu) to BOTH the orchestrator's live template
(model-deployment.yaml.tmpl) AND the export template (handlers_export.go), so a
CPU run deploys — and re-exports — correctly instead of falling into the neuron
branch (wrong device resource, neuron toleration, 540 startup threshold).

CPU branch in both:
- tolerations: accelbench.io/cpu (no device-plugin taint); nodeSelector adds
  kubernetes.io/arch: arm64.
- env: VLLM_CPU_KVCACHE_SPACE (absolute GiB, from recommend.CPUKVCacheSpaceGiB
  sized off host RAM) + VLLM_CPU_OMP_THREADS_BIND=auto.
- resources: request/limit cpu + memory only, NO nvidia.com/gpu or
  aws.amazon.com/neuron device resource; shm volume for the streamer/TP staging.
- securityContext: SYS_NICE cap + seccomp Unconfined (NUMA thread pinning, per
  vLLM CPU docs).
- startupProbe: GPU-style threshold 120 (weight-load dominated, no neuron
  540 compile wait). Refactored the probe conditional to `if neuron 540 else
  120` so cpu joins gpu — gpu=120 and neuron=540 unchanged.
- serve args: reuse VLLMcpu.BuildArgs via the RuntimeArgs path (orchestrator
  already; export now routes cpu through BuildArgs like sglang) so the exact
  deployed flags (forced bfloat16, TP, W8A8/W4A8, streamer w/ distributed gated
  off) are reproduced, not re-encoded in the template.

Plumbing: ModelDeploymentParams.CPUKVCacheSpaceGiB + manifestData.RuntimeImage/
CPUKVCacheSpaceGiB; orchestrator cpuKVCacheSpaceGiB() helper (0 for non-cpu);
export resolves the vllm-openai-cpu image (VLLM_CPU_IMAGE override → composed
from VLLMCPUVersion+dockerhub PTC) and adds RunExportDetails.VLLMCPUVersion
(empty until §10 wires the tool_versions column; runtime falls back to
DefaultVLLMCPUVersion).

Tests: CPU render (arm64/cpu-resources/kvcache-env/sys_nice/bfloat16, and NO
device resource / NCCL / gpu-mem-util / 540) + CPU export (same, NOT neuron
branch); gpu/neuron render + single-node export regression tests unchanged.

NOTE: version source — lifecycle.go still passes FrameworkVersion to the image
resolver; the CPU-specific VLLMCPUVersion source is wired in §10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeremy Cowan <jicowan@hotmail.com>
Gated behind install_cpu_nodepool (default off, root + module vars). When on:

- data.aws_ssm_parameter.cpu_arm64_ami: the standard EKS-optimized AL2023
  ARM64 AMI (no accelerator variant), pinned via SSM like the gpu AMI. Ships
  containerd 2.2 + soci-snapshotter, so the FastImagePull userData works.
- cpu EC2NodeClass: arm64 AMI, 500Gi gp3 root (large quantized MoE + streamer
  cache + image layers), and the shared soci_user_data (FastImagePull gate).
- cpu NodePool: Graviton3/4/5 families (r8g/m8g/c8g, r7g/m7g/c7g, m9g/c9g/r9g),
  arch arm64, reserved+on-demand, WhenEmpty/1m disruption. NO device plugin.

Taints (accelbench.io/cpu + accelbench.io/dedicated) are load-bearing beyond
isolation: they keep the GPU/Neuron DaemonSets OFF arm64 nodes. Verified against
the live cluster — nvidia-device-plugin, dcgm-exporter, neuron-device-plugin
tolerate nvidia.com/gpu / aws.amazon.com/neuron + accelbench.io/dedicated but NOT
accelbench.io/cpu, and their nodeAffinity requires accelerator labels a CPU node
lacks, so they're double-blocked. The critical system DaemonSets (aws-node,
kube-proxy, eks-pod-identity-agent, ebs-csi-node) tolerate all taints and are
AWS multi-arch, so they run on arm64. Net: no NVIDIA driver / DCGM on CPU nodes.

Applied live (install_cpu_nodepool=true): cpu EC2NodeClass Ready=True (arm64 AMI
resolved for k8s 1.36), cpu NodePool Ready (0 nodes, provisions on demand),
terraform plan clean. (The DCGM *scraper* orchestrator path is skipped for CPU
separately in §7.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeremy Cowan <jicowan@hotmail.com>
Metrics: the DCGM/GPU scraper is ALREADY gpu-gated in the orchestrator
(lifecycle.go: `if AcceleratorType == "gpu"`), so a CPU run skips it by
construction — no change needed. The accelerator-agnostic host-memory scraper
+ vLLM /metrics (throughput/TTFT/TPOT) already cover the CPU-relevant signal.
Added TestDeployModel_CPUInstance asserting a CPU run deploys the arm64
Deployment (VLLM_CPU_KVCACHE_SPACE env, arm64 nodeSelector, NO
nvidia.com/gpu / aws.amazon.com/neuron device resource) and thus skips DCGM.

Frontend:
- FilterBar: add "cpu" to the accelerator filter chips.
- Run.tsx: add a "CPU (ARM/Graviton)" optgroup to the instance dropdown
  (shows vCPU/memory, not count×name, since count=0); force framework
  vllm-cpu when a cpu instance is selected — derived from the instance's
  accelerator_type (robust across Graviton families) rather than a name regex,
  and reset off vllm-cpu/vllm-neuron when switching back to GPU.
- Estimate.tsx: add "CPU (ARM) only" filter option.
- ResultDetail.tsx: acceleratorNoun renders "CPU" for cpu runs; per-accelerator
  throughput already guards count>0 → blank (no ÷0), consistent with the
  backend compare.go guard.

go build/vet/test + frontend tsc + build all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeremy Cowan <jicowan@hotmail.com>
…ce (PRD-67 §10)

Add vllm_cpu_version to tool_versions — the ARM/CPU image tag
(vllm/vllm-openai-cpu:<ver>-arm64), DISTINCT from framework_version (different
repo, own arm64 cadence), mirroring PRD-66's llmd_version/pd_vllm_version.

- Migration 044: ADD COLUMN vllm_cpu_version DEFAULT 'v0.11.2' (= DefaultVLLMCPUVersion).
- DB: ToolVersions.VLLMCPUVersion + GetToolVersions/PutToolVersions SQL + mock default.
- Config API: GET/PUT round-trip the field (+ VLLM_CPU_IMAGE env-override status,
  which does NOT fall back to VLLM_IMAGE — the CPU runtime reads only VLLM_CPU_IMAGE),
  "keep existing" on empty, and audit line.
- Configuration.tsx: "vLLM CPU Version (ARM/Graviton)" input in the Tool Versions
  panel; types.ts + api.ts request/response types.

Wire the version SOURCE (the loose end from §3): CreateRun's version resolution
(handlers.go) now passes the FULL tool_versions projection into rt.ResolveVersion,
so vllm-cpu resolves tv.VLLMCPUVersion (and llm-d resolves LLMDVersion) instead of
only framework_version/sglang_version. A CPU run's Request.FrameworkVersion is thus
the vllm-openai-cpu tag, which lifecycle.go's DefaultImage composes correctly —
no lifecycle change needed. Export path: injectMultinodeImageVersions also injects
VLLMCPUVersion so exports name the configured CPU image.

Applied migration 044 live (column defaults v0.11.2). go build/vet/test + frontend
tsc/build green. Live API round-trip validated after the §10 redeploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeremy Cowan <jicowan@hotmail.com>
…en model name (PRD-67)

First end-to-end live r8g CPU run surfaced three integration bugs (all green
after these fixes: 600/600 requests, 586 tps, TTFT p50 217ms on r8g.16xlarge):

1. Image tag didn't exist. DefaultVLLMCPUVersion was v0.11.2 (the prebuilt-Arm-
   *wheels* "since" figure, misapplied to the image) → ImagePullBackOff "not
   found". Docker Hub vllm/vllm-openai-cpu arm64 images start at v0.18.0; pin
   v0.27.0. Updated migration 044 default + mock + tests.

2. Streamer not bundled in the CPU image. The prebuilt
   vllm/vllm-openai-cpu:*-arm64 image does NOT ship runai-model-streamer (crash:
   ModuleNotFoundError), unlike the GPU image — resolving PRD-67 §5b's open item.
   Gate the streamer off for cpu in resolveS3Model → HF-download fallback (forgoes
   S3 cache until a streamer-inclusive CPU image / init step; future work).

3. Loadgen model-name drift → 404 on every request. launchLoadgen re-derived the
   served model name from the model cache independently and set it to the S3 URI,
   but the CPU server (HF-download) registered the HF id → 404s. Now derives from
   the same resolveS3Model source of truth (honors the CPU gate). Eliminates the
   deploy/loadgen drift for all accelerators.

Live-verified: vLLM V1 on device_config=cpu, forced bfloat16, VLLM_CPU_OMP_THREADS_
BIND=auto binding threads, NUMA MEMBIND, TP=1 — exactly as designed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeremy Cowan <jicowan@hotmail.com>
Two complementary mechanisms so a user (UI) or API caller can't set a GPU-only
knob on a CPU run.

§11b/c server guards (CreateRun, handlers.go) — when instance is cpu:
- kv_cache_dtype=fp8 → 400 (fp8 KV is CUDA/ROCm only, confirmed).
- deployment_mode distributed/disaggregated → 400 (CPU is single-node).
- tensor_parallel_degree > NUMA-node count → 400 (recommend.CPUNumaNodes, =1 on
  single-socket Graviton; TP maps to NUMA nodes, parallelism is OMP thread-bind).
- §11c: oversized DENSE UNQUANTIZED model → 400 steering to W8A8/W4A8, using the
  SAME ceiling as RecommendCPU (recommend.CPUUnquantizedCeilingParams, ~13B).
  Fetches model config only for CPU runs (no HF call added to the GPU hot path);
  best-effort. Small bf16 models still allowed.
(gpu_memory_utilization is not a request field — nothing to reject.)

§11a UI gating (Run.tsx): derive isCPU from the selected instance's
accelerator_type; hide the KV Cache Dtype (fp8) field on CPU AND clear it from
the payload on switch-to-CPU so a stale value can't trip the server guard. A CPU
fit failure is treated as host-mem/bandwidth (overridable), not a GPU-VRAM hard
block. (The distributed/multi-node section lives on Distributed.tsx, not the Run
page, so nothing to hide there.)

Tests: table-driven CreateRun guards (fp8→400, distributed/disaggregated→400,
TP>NUMA→400) + the mirror proving the SAME knobs still pass on a GPU instance
(CPU-scoped, not a global regression). go vet + full backend suite + frontend
tsc/build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeremy Cowan <jicowan@hotmail.com>
Reports & cross-tier comparison need no code change — the machinery was already
CPU-safe by construction:
- perGPU() guards AcceleratorCount<=0 → blank throughput_per_gpu_tps (no ÷0).
- DCGM columns (sm/tensor/dram active, accel memory) are *float64 pointers,
  nil on a CPU run (never scraped) → fmtPtr renders them BLANK, not 0.
- cost + aggregate throughput + TTFT depend only on hourly price + vLLM
  /metrics (accelerator-agnostic) → populate for CPU.
- ResultDetail acceleratorNoun already renders "CPU" (§7).

Added TestGenerateCompareCSV_CPUvsGPU: a CPU row (count 0, nil DCGM) vs a GPU
row — asserts throughput_tps/ttft/cost_per_1m_tokens populate for CPU while
throughput_per_gpu_tps + sm_active render blank, and accelerator_count=0.

Pricing: cmd/pricingrefresh enumerates ALL instance types (ListInstanceTypes,
now incl. the seeded Graviton rows) and queries the AWS Pricing API by name with
NO architecture filter — so Graviton prices populate once the pricing CronJob
runs. The live "no pricing row" was just the CronJob not having run since seeding,
not a code gap; triggered a one-off refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Jeremy Cowan <jicowan@hotmail.com>
@jicowan
jicowan merged commit f308152 into main Sep 2, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant