Lab: Serve SmolLM2 on CPU vLLM
Goal: Build a patched CPU vLLM image, serve SmolLM2-135M behind the OpenAI-compatible /v1 API, prove it's the same contract your M2 client speaks by pointing that client at it with a one-line change, then tune the CPU knobs.
Time: ~30 minutes (plus a one-time multi-GB image pull + model download on first run)
Prerequisites: Rancher Desktop (or Docker Desktop) running; docker and docker compose on your PATH; the M2 lab's client.py handy. Lab assets live in labs/m3/.
Unlike M1/M2 (where the model ran natively), vLLM runs inside a container, so it needs real CPU and RAM allocated to your container runtime's VM. Give your runtime at least 4 CPUs and 6 GB of memory before this lab:
- Rancher Desktop: Preferences → Virtual Machine → set CPUs = 4, Memory = 6 GB, then apply (it restarts the VM).
- Colima:
colima start --cpu 4 --memory 6 - Docker Desktop / OrbStack: Settings → Resources.
The compose.yaml caps the container at cpus: 4.0 / memory: 5G. A cap must not exceed what the VM has — if it does, the container refuses to start (see Troubleshooting).
The first docker compose build pulls a multi-GB base image, and the first docker compose up downloads the model from Hugging Face. Both are cached afterward. On top of that, CPU inference is slow — a chat completion can take tens of seconds. Nothing is hung; the machine is just doing real work without a GPU. Throughput isn't the point of this lab — understanding the engine is.
vLLM and Docker are chatty and versioned: your docker compose logs will have more lines than shown here (route listings, a Triton not installed warning on CPU — both harmless), the JSON responses carry extra null fields (vLLM extends the OpenAI schema), and docker compose progress lines vary by Docker version. Match the shape of each expected output (the key fields, the model's answer), not every character.
Step 1 — The image and the NUMA patch
Change into the lab directory and look at the Dockerfile.
cd labs/m3
cat Dockerfile
Expected output:
FROM openeuler/vllm-cpu:0.9.1-oe2403lts
RUN sed -i 's/cpu_count_per_numa = cpu_count \/\/ numa_size/cpu_count_per_numa = cpu_count \/\/ numa_size if numa_size > 0 else cpu_count/g' \
/workspace/vllm/vllm/worker/cpu_worker.py
ENV VLLM_TARGET_DEVICE=cpu \
VLLM_CPU_KVCACHE_SPACE=1 \
OMP_NUM_THREADS=2 \
OPENBLAS_NUM_THREADS=1 \
MKL_NUM_THREADS=1
That single sed line is the whole reason we build a custom image. Inside a container the kernel reports 0 NUMA nodes, so vLLM's cpu_count // numa_size becomes a division by zero and the worker crashes on startup. The patch guards the division: if there are no NUMA nodes, use the full CPU count. (See the lesson's CPU track for the full story.)
Build the image:
docker compose build
Expected output:
[+] Building 41.2s (8/8) FINISHED
=> [internal] load build definition from Dockerfile
=> => transferring dockerfile: 512B
=> [internal] load metadata for docker.io/openeuler/vllm-cpu:0.9.1-oe2403lts
=> [1/2] FROM docker.io/openeuler/vllm-cpu:0.9.1-oe2403lts
=> [2/2] RUN sed -i 's/cpu_count_per_numa = .../g' /workspace/vllm/vllm/worker/cpu_worker.py
=> exporting to image
=> => naming to docker.io/library/vllm-cpu-optimized:latest
Step 2 — Serve SmolLM2 on CPU vLLM
Look at compose.yaml. The vllm-cpu service runs the patched image, passes the model and serving args, caps resources (cpus 4 / mem 5G) so it can't eat the laptop, mounts an hf-cache volume so weights survive restarts, and maps host port 8009 to the container's 8000. Four of its settings are exactly what it takes to make CPU vLLM run in a container — each one fixes a real startup/inference failure:
cat compose.yaml
Expected output (excerpt):
services:
vllm-cpu:
build: {context: ., dockerfile: Dockerfile}
image: vllm-cpu-optimized:latest
container_name: vllm-smollm2
# (2) SYS_NICE + unconfined seccomp let vLLM migrate NUMA pages; without them
# startup dies with "numa_migrate_pages failed. errno: 1".
cap_add: [SYS_NICE]
security_opt: ["seccomp:unconfined"]
command:
- --model
- ${MODEL_NAME:-HuggingFaceTB/SmolLM2-135M-Instruct}
- --host
- "0.0.0.0"
- --port
- "8000"
- --dtype
- ${DTYPE:-float32} # (3) CPU has no bf16 kernel — float32 or inference 500s
- --swap-space
- "${SWAP_SPACE:-1}" # (4) default 4 GiB > container RAM; keep it small
- --max-model-len
- "${MAX_MODEL_LEN:-1024}" # smaller context => smaller KV cache => fits RAM
ports:
- "8009:8000"
deploy:
resources:
limits: {cpus: "4.0", memory: 5G} # (1) must be <= your runtime VM's allocation
Those four numbered settings are the difference between "vLLM CPU works" and a wall of tracebacks. They're covered in Troubleshooting below and in the lesson's CPU-track section.
Start it:
docker compose up -d
Expected output:
[+] Running 2/2
✔ Volume "m3_hf-cache" Created
✔ Container vllm-smollm2 Started
Watch it load — on first run this includes the model download:
docker compose logs -f vllm-cpu
Expected output (tail):
INFO ... Starting vLLM API server on http://0.0.0.0:8000
INFO ... Available routes: /health, /v1/models, /v1/chat/completions, /v1/completions
INFO ... Application startup complete.
INFO ... Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
Press Ctrl+C to stop following the logs (the container keeps running). Now wait for health to go green:
curl http://localhost:8009/health
Expected output:
/health returns HTTP 200 with an empty body once the model is loaded and the server is ready. If you get a connection refused, the model is still loading — give it a minute and retry.
Step 3 — It's the same OpenAI contract
3a — List models
curl -s http://localhost:8009/v1/models | python3 -m json.tool
Expected output:
{
"object": "list",
"data": [
{
"id": "HuggingFaceTB/SmolLM2-135M-Instruct",
"object": "model",
"created": 1783253768,
"owned_by": "vllm",
"max_model_len": 1024
}
]
}
Same shape as the M2 GET /v1/models you hit against Ollama — only the id and owned_by differ.
3b — Chat completion
curl -s http://localhost:8009/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "HuggingFaceTB/SmolLM2-135M-Instruct",
"messages": [{"role": "user", "content": "In one sentence, what is a Linux container?"}],
"max_tokens": 48
}' | python3 -m json.tool
Expected output (this is a real capture — a 135M model is tiny, so the wording will be rough but the shape is a standard OpenAI response):
{
"id": "chatcmpl-1c1ec4302bee44089339020eb932ba36",
"object": "chat.completion",
"created": 1783253771,
"model": "HuggingFaceTB/SmolLM2-135M-Instruct",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Linux containers are virtualized environments that run applications and services without requiring network connections or additional hardware, allowing for efficient application running on minimal infrastructure."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 40,
"completion_tokens": 29,
"total_tokens": 69
}
}
On CPU this call can take tens of seconds — the first token is the slowest. That's the machine without a GPU, not a bug. The response shape is what matters: choices[0].message.content, identical to what your M2 client already parses.
3c — Point your M2 client at vLLM (one-line change)
This is the through-line of the whole course. Your M2 client reads OPENAI_BASE_URL and MODEL from the environment. Run the exact same M2 client container — no rebuild, no code change — but point it at vLLM instead of Ollama by overriding just those two environment variables. (The client's dependencies live in its image, so you run it as a container, not with host Python.)
From the labs/m3/ directory:
docker compose -f ../m2/compose.yaml run --rm \
-e OPENAI_BASE_URL=http://host.docker.internal:8009/v1 \
-e MODEL=HuggingFaceTB/SmolLM2-135M-Instruct \
client python client.py "In one sentence, what is a Linux container?"
Expected output (135M is tiny — expect rough wording; the point is that the same client got an answer from a different engine):
Linux containers are lightweight, portable software environments that package an
application with its dependencies so it runs the same anywhere.
No code changed. No SDK changed. No image rebuilt. Two environment variables swapped the engine behind the /v1 wall socket — exactly the point M2 made, now proven against a real production engine. (This reuses the client service you built in M2 via its compose.yaml.)
Step 4 — Tuning knobs
Copy the example env file and experiment. These map straight to the lesson's CPU knobs.
cp .env.example .env
Expected output:
Try the lightest model (135M) for faster startup on constrained machines, and adjust threading and concurrency. Edit .env:
# in .env
MODEL_NAME=HuggingFaceTB/SmolLM2-135M-Instruct
OMP_THREADS=4 # more OpenMP threads (try up to ~75% of your cores)
MAX_NUM_SEQS=4 # fewer concurrent sequences = less memory
Recreate the service to apply:
docker compose up -d
Expected output:
[+] Running 1/1
✔ Container vllm-smollm2 Recreated
Re-run the Step 3b curl. With the 135M model you'll notice faster startup and first-token latency. Watch live resource use while a request runs:
docker stats vllm-smollm2 --no-stream
Expected output:
CONTAINER CPU % MEM USAGE / LIMIT MEM %
vllm-smollm2 180.4% 2.1GiB / 8GiB 26.3%
OMP_THREADS is the main dial: raising it uses more cores (higher CPU %) and can lower latency up to a point, then thermal throttling and cache thrashing take over. Find the value with the best latency, not the highest CPU.
Step 5 — The GPU track (read-only)
You won't run this on the Mac — Apple Silicon exposes no GPU to containers — but this is exactly what you'd run on an NVIDIA Linux box or cloud VM. Do not execute these here.
# Requires the NVIDIA Container Toolkit installed on the host.
docker run --gpus all --ipc=host -p 8000:8000 \
vllm/vllm-openai:latest \
--model mistralai/Mistral-7B-Instruct-v0.3
vllm/vllm-openai:latestis the official CUDA image (not the CPU one).--gpus allexposes the GPU (needs the NVIDIA Container Toolkit).--ipc=hostgives vLLM the large shared memory its multi-process attention needs.
The served endpoint is the same /v1 contract — your M2 client would point at http://<gpu-host>:8000/v1 and work unchanged. To fit a 7B model on a smaller card, add a quantized checkpoint (e.g. an AWQ 4-bit model); vLLM detects the method automatically.
Troubleshooting
range of CPUs is from 0.01 to 2.00, as there are only 2 CPUs available(or similar) — your composecpus/memorycap is larger than what your runtime VM has. Raise the VM's allocation (4 CPUs / 6 GB — see the setup note at the top), or lowerCPU_LIMIT/MEMORY_LIMITin.envto fit.numa_migrate_pages failed. errno: 1on startup — the container lacks permission to migrate memory across NUMA nodes. The compose already grantscap_add: [SYS_NICE]andsecurity_opt: ["seccomp:unconfined"]; if you stripped those, add them back.Too large swap space. 4.00 GiB out of the ... total CPU memory— vLLM's--swap-spacedefaults to 4 GiB, bigger than this container's RAM. The compose sets--swap-space 1; lower it further if needed.- HTTP 500 with
rms_norm_impl not implemented for 'BFloat16'—--dtype autopicked the model's bf16, which CPU kernels (especially arm64) can't run. The compose forces--dtype float32. Don't set it back toautoon CPU. - Connection refused on
/health— the model is still downloading/loading on first run. Followdocker compose logs -f vllm-cpuand wait forApplication startup complete. - Slow first token — expected on CPU. The first token is slowest; subsequent tokens stream faster. Not a bug.
- Container killed / out of memory — lower
MAX_MODEL_LEN(e.g. 1024), lowerMAX_NUM_SEQS, or switch toSmolLM2-135M-Instructin.env, thendocker compose up -d. - Model download stalls — first download can take several minutes;
docker compose restart vllm-cputo resume. Check your internet connection. ZeroDivisionError/ worker crash on startup — the NUMA patch is missing or the base image tag changed. Confirm thesedline in theDockerfileand rebuild withdocker compose build --no-cache.
Step 6 — Tear down
Free the RAM before the next module.
docker compose down
Expected output:
[+] Running 1/1
✔ Container vllm-smollm2 Removed
To also clear the cached model weights (frees disk, but the next run re-downloads):
docker compose down -v
Expected output:
[+] Running 2/2
✔ Container vllm-smollm2 Removed
✔ Volume m3_hf-cache Removed
What's next: In M4 you'll package models and their configuration as OCI artifacts — shipping a model the same way you ship a container image.