Serving Stack¶
vLLM, LMCache and NIXL, built from source against this repository's torch rather than installed from published wheels.
They have to be: their native extensions link against torch's C++ ABI, so a wheel built for AMD's ROCm 7.x torch and one built here cannot share a process.
What Gets Built¶
| Target | What It Is |
|---|---|
nixl-wheel |
NIXL plus UCX, compiled from source with --with-rocm |
cupy-wheel |
CuPy for ROCm; LMCache's MP server imports it at module scope |
torchvision-wheel |
torchvision for ROCm; timm pulls it in and transformers imports it at module scope |
vllm / vllm-wheel |
The serving image and its wheel |
lmcache / lmcache-wheel |
The multi-process cache runtime and its wheel |
make push-serving # nixl + cupy + torchvision + vllm + lmcache
make build-vllm # just vLLM, single builder, no push
make fetch-nixl-wheel # pull a wheel artifact into ./dist
make push-serving VLLM_REF=v0.31.0 NIXL_REF=v1.5.0
Every ref here is a release tag, never a branch: a moving ref would be served from the layer cache indefinitely once built, so an "update" would silently be a no-op.
Ubuntu's libucx is compiled without ROCm memory-type support, so
NIXL bundles its own: Dockerfile.nixl compiles UCX with
--with-rocm and auditwheel bundles it. Without that, every
device-memory transfer bounces through host staging.
Running It¶
serving/docker-compose.yml runs vLLM and the LMCache MP server as a
pair over DOCKER_HOST=ssh://$GPU_HOST, so the file stays under version
control here while the engine runs on the machine with the GPU.
make serve-stack # vLLM + LMCache MP
make serve-vllm # vLLM alone — the no-offload baseline
make serve-wait # block until /health answers
make serve-test # prove the cache is actually working
make serve-test-l2 # prove the disk tier serves blocks
make serve-logs # both containers, merged, live
make serve-status # what is running on GPU_HOST
make serve-down # stop and remove
The compose file owns the runtime defaults the way rocm-bake.hcl owns
the build ones, and the Makefile reads ${NAME:-default} out of it.
| Variable | Default | What It Does |
|---|---|---|
KV_TIER |
cpu |
cpu = L1 only, file = L1 plus an L2 on disk |
TRANSFER_MODE |
lmcache_driven |
auto, engine_driven or lmcache_driven |
HSA_IPC_LEGACY |
1 |
Required for cross-container HIP IPC |
VLLM_DEV_MODE |
0 |
1 exposes /reset_prefix_cache, which serve-test needs |
L1_SIZE_GB |
8 |
LMCache's host-RAM tier |
CHUNK_SIZE |
256 |
Tokens per cached chunk |
MAX_MODEL_LEN |
8192 |
|
GPU_UTIL |
0.90 |
|
SERVE_MODEL |
Qwen/Qwen2.5-3B-Instruct |
|
VLLM_BIND |
0.0.0.0 from the Makefile, 127.0.0.1 in compose |
A variable is passed only when actually set — an exported empty variable
is still set, so VAR= would override the compose default with "".
VLLM_BIND differs deliberately: 127.0.0.1 is right locally, but
driving it from another machine needs the port off loopback. Narrow it
again, or tunnel, if the host is not on a trusted network.
HSA_ENABLE_IPC_MODE_LEGACY decides whether this works at all
It governs whether the two containers can share KV memory. When it
is wrong the stack fails at startup, and no amount of
TRANSFER_MODE tuning will fix it.
The Tags Come From The GPU Host¶
SERVE_ARCH asks GPU_HOST which GPU it has, via rocminfo over SSH.
The build default is gfx950 while the serving host here is a gfx1201
RX 9070 XT, so deriving the serving tag from the build architecture
asked Zot for an image nobody had built — the only symptom being a pull
failure naming a plausible-looking tag. It falls back to the build
architecture when the host is unreachable.
/health Is Not Evidence¶
LMCache on ROCm has a failure mode where the endpoint serves every request correctly and caches nothing — in LMCache#4419 a reporter ran eight days at a zero hit rate with no error anywhere.
make serve-test sends the same long prefix twice, requires the second
prefill to be measurably cheaper, then reads LMCache's own log for the
store and the retrieve — vLLM's own prefix caching can make the timing
alone look fine. serve-test-l2 goes further, emptying LMCache's L1 as
well as vLLM's VRAM between requests so a hit can only have come off
disk; it needs KV_TIER=file.
make serve-logs merges both streams tagged by container rather than
using compose logs. The useful lines alternate — vLLM says what it
asked for, LMCache whether it had it — and reading them apart once hid
LMCache dying on a missing CuPy while vLLM waited five minutes before
reporting a generic engine error.
Running The Images Directly¶
# vLLM. AITER is installed but off by default.
docker run --rm --device /dev/kfd --device /dev/dri -p 8000:8000 \
-e VLLM_ROCM_USE_AITER=1 \
snoc-strix.fold-leaffish.ts.net:8080/rocm-pytorch/vllm:latest \
serve Qwen/Qwen3-8B
# LMCache's MP deployment: a controller holding the cluster-wide index,
# and one or more cache servers holding the blocks. The image has no
# default role — name the CLI you want.
docker run --rm -p 9000:9000 \
snoc-strix.fold-leaffish.ts.net:8080/rocm-pytorch/lmcache:latest \
lmcache_controller --host 0.0.0.0 --port 9000
docker run --rm --device /dev/kfd --device /dev/dri -p 5555:5555 \
snoc-strix.fold-leaffish.ts.net:8080/rocm-pytorch/lmcache:latest \
lmcache server --host 0.0.0.0 --port 5555
The cache server lost its console script in v0.5.7
LMCache v0.5.7 restructured the package: the single-process engine
moved to lmcache.inprocess.v1.*, the multiprocess stack took over
lmcache.v1.*, and the standalone lmcache_server entry point was
dropped in favour of the lmcache server subcommand. Both roles are
argparse and take flags; --transport defaults to zmq, so pass
--transport grpc for the gRPC path.
Two Upstream Constraints¶
Both projects' requirement files carry CUDA-only or x86-wheel-only
pins — cufile-python, nvtx, tilelang, amd-quark and others.
These are stripped before the build, which also keeps them out of the
wheel's install_requires. The lists are the VLLM_DROP_REQS and
LMCACHE_DROP_REQS build args, so a dependency can be let back in once
it has a usable wheel.
Upstream publishes torchvision only as a CUDA wheel, so this
pipeline builds it, the way it builds torch and triton. It used to take
PyPI's: timm pulls torchvision in through vLLM's build
requirements, its _C_stable.so links libtorch_cuda and
libc10_cuda, and import torchvision failed at load time. Nothing in
the text-model path touches it, so the build and the import checks
stayed green while vision and video models would have failed at serve
time — and serving papered over it by swapping in the CPU wheel at
container start.
torchvision-wheel replaces all of that. Dockerfile.torchvision
builds it FROM the PyTorch image with FORCE_CUDA=1 (a BuildKit
RUN has no /dev/kfd, so torch.cuda.is_available() is false and
without it the wheel ships no device code at all) and asserts the
result carries code objects for the target architecture before
publishing. /etc/pip-constraints.txt now pins torchvision alongside
torch and triton, to a +rocm<version> local version PyPI cannot
offer, so any later resolution that reaches for the CUDA build fails
loudly. The proof it worked is in Dockerfile.vllm: the LMCache
connector import check, which could not run while torchvision was
broken, is back.