AI · HPC · Storage
A Rust LLM serving platform with block-level KV cache tiering across GPU, RAM, and disk
Rust 78% JavaScript 8% Shell 7% Python 3% CSS 2% HTML 1%
Long conversations do not run out of model weights, they run out of KV cache. The attention state for prior tokens routinely exceeds the size of the weights themselves, and on a 8 GB consumer card that ceiling arrives early and hard — as an out-of-memory crash rather than a graceful slowdown.
vLLM solved this with PagedAttention and CPU swap. That solution is Python, and CUDA-only, and carries an infrastructure footprint that does not fit a lab of mixed older GPUs. llama.cpp, which does fit, has had PagedAttention sitting open as issue #1955 since June 2023 with no implementation merged and no active PR. Nobody had shipped block-level KV tiering for llama.cpp.
Rustycage is the attempt: keep llama.cpp's portability and hardware tolerance, add the tiering that makes long context survivable, and wrap the whole thing in an API surface that existing Ollama and OpenAI clients can talk to without modification.
A six-crate Cargo workspace over llama.cpp via llama-cpp-2:
| Crate | Responsibility |
|---|---|
rustycage-core |
Types, config, GPU detection, HuggingFace client, quantization engine |
rustycage-engine |
Inference, multi-GPU tensor split, streaming generation |
rustycage-api |
Axum server — OpenAI, Ollama, NIM routes, SSE, model autoload |
rustycage-cli |
Clap CLI, 12 commands |
rustycage-bench |
Benchmark runner, composite scoring, ranking |
rustycage-web |
Embedded vanilla-JS WebUI via rust-embed |
Three API surfaces from one process: OpenAI v1 on 8080, Ollama on 11434, and NIM-style health and Prometheus metrics. A model requested by name that is not loaded gets loaded automatically, which is what makes it a drop-in rather than a migration.
The tiering. The current implementation is state-based and coarse: tier 1 is
KV cache on GPU with offload_kqv=true, tier 2 is host RAM with the flag off,
tier 3 is a full state file on disk via llama.cpp's state_save_file(). It
operates at conversation granularity — an entire context saves and restores as a
unit, in seconds, and it cannot swap mid-generation.
That is a real limitation and it is written down as one. The forked path adds a
tier field to llama_kv_cell and eviction logic in slot allocation, which
moves granularity from conversation to block without touching the attention
kernels. The assessment behind that decision is in
docs/kv-cache-fork-assessment.md, including the two heavier paths that were
costed and deliberately not taken yet.
Roughly five months, in three distinct bursts rather than a steady line.
March 2026 — the shape. Initial commit on 24 March as Nollama, renamed to Rustycage the same day. The full README with API tables landed in that first sitting, before most of the code did. By 30 March the fork existed: "Add KV cache tiering: fork llama.cpp with GPU→CPU→Disk eviction," plus an Ubuntu installer with a curated model set. Eight commits in a week.
April to June — the long tail. Two commits a month. HuggingFace search fixes, quantization parsing, prompt formatting, embedding server context. The one structural piece was 7 June: unifying the OpenAI and Ollama schemas so both ports serve the same shapes, which arrived as the repo's only pull request.
Between those, on 2 May, two build scripts that turned out to matter more than they look: a native CUDA rebuild for Wintermute's older glibc and nvcc, and a separate build-and-publish path from mrwick. Those exist because of the deploy problems in Pitfalls.
August 2026 — the fleet. Ten commits in two days, 12–13 August, and the project stopped being a single-host program. Soundgarden — a resident attendant running across all phases with peer heartbeats. Model quarantine with post-download load testing. Incidents made inspectable, with LLM-driven failure triage that files GitHub issues automatically. Negotiated cohort upgrade for multi-host GPU rollouts. Then three consecutive commits hardening that rollout: stdin and version verification, cmake and bindgen checks, a pinned CUDA toolkit path.
One commit message from 12 August is the honest summary of that week: "Require curl verification against a running node before calling work done."
The near work is finishing what the fork started. The metadata-first path is in place but the eviction policy is naive — LRU with sequence-aware grouping is the next correctness step, and async promotion is what turns cold-block access from a stall into a prefetch. Neither is useful without tier hit-rate and promotion-latency metrics to show whether the tiering is helping or just moving bytes around.
After that comes a decision point that was deliberately deferred: whether the
custom ggml_backend_tiered wrapper earns its roughly 3,000 lines over the
current approach. That is a measurement, not a preference, and it should not be
made before the Path 1 metrics exist.
Physical block redesign — true PagedAttention with sub-4% memory waste — is costed at 5,000+ lines and two to three months, and requires modifying CUDA flash attention kernels. It stays in Later until something forces it.
One binary per host, and I learned that the expensive way. A CUDA binary
built on mrwick fails on Wintermute and Molly with GLIBC_2.38 not found. The
obvious optimization — build once, copy everywhere — is wrong for a fleet of
mixed-vintage Linux installs. The rollout scripts now build natively on every
host and the docs say, in bold, not to copy one host's binary to another.
Ubuntu's packaged nvcc is a trap. CUDA 11.5 with GCC 11 fails compiling
ggml-cuda with std_function.h: parameter packs not expanded. The build scripts
now prefer /usr/local/cuda/bin/nvcc whenever it exists and treat
/usr/bin/nvcc as a fallback of last resort.
CMake guesses the wrong GPU architecture. llama.cpp's default CUDA arch list
made CMake attempt sm_89 on a Maxwell-era Quadro M6000, which fails in a way
that does not immediately point at the cause. CMAKE_CUDA_ARCHITECTURES='52;61'
pins it to the cards that actually exist — 52 for the M6000, 61 for the GTX 1070.
Full CUDA builds do not fit on edge hosts. The tree with deps/llama.cpp,
bindgen, and the CUDA toolkit is too large for disk-starved machines like Molly.
The workaround is a single published binary on NFS at
/mnt/wintermute/bin/rustycage, symlinked into place locally, with ~/.rustycage
kept on local disk.
A staggered fleet upgrade is worse than no upgrade. Hosts drifting onto
different SHAs mid-rollout produced version mismatches that were hard to reason
about. The fix was a barrier: stage the same SHA everywhere, wait for unanimous
ready, then cut over as a cohort, keeping rustycage-prev for automatic
rollback on post-cutover mismatch.
"Staged successfully" was a lie for a while. The original smoke test ran
--version on the new binary and called it good. A binary that answers
--version can still be entirely unable to serve. Hence the 12 August commit
requiring curl verification against a running node before work counts as done.
The smoke test is still thinner than it should be, and that is on the Now list.
Wrong-file model loads look like model bugs. Quarantine issue #2 caught an mmproj vision projector being loaded as a standalone LLM — the sidecar file mistaken for the model. Post-download load testing now quarantines models that fail to load rather than letting them fail at inference time, where the error points somewhere unhelpful.
Coarse tiering is honest but limited. Conversation-granularity state
save/restore takes seconds and cannot swap mid-generation. It is genuinely useful
for session persistence across restarts and genuinely useless for keeping a long
agent context hot. Both things are true and the comparison table in
docs/kv-cache-tiering.md says so rather than claiming parity with Dynamo.
Cost the paths before writing any of them. The fork assessment laid out three implementations at roughly 1,500, 3,000, and 5,000+ lines, with pros, cons, and explicit phase gates. Picking the smallest one that touched no CUDA kernels was only possible because the alternatives had been priced first. The document took an afternoon and has saved considerably more than that.
Portability constraints are the architecture. Nearly every hard-won lesson — native per-host builds, pinned CUDA arch, NFS-published binaries, cohort barriers — comes from running a fleet of different GPUs and glibc versions rather than uniform cloud instances. Choosing llama.cpp for hardware tolerance meant inheriting the whole build matrix. That was the right trade, and it was not free.
Compatibility is worth more than novelty. Speaking OpenAI and Ollama on their own ports means existing clients switch with a URL change. Almost no adoption friction, and it cost one schema-unification PR to keep both surfaces honest.
Deployment tooling is the product once there is more than one host. The March burst built the engine; the August burst built the ability to safely change it in place across a fleet. The second turned out to be the harder problem, and underinvesting in it early is why three consecutive hardening commits were needed after the first real rollout.
Automate the triage, not the fix. Soundgarden files GitHub issues with an LLM analysis of the failure, a confidence level, and suggested remediation — but a human decides what to do. Issue #2 is the system reporting on itself, correctly, without acting on it. That division has held up.
Write down what you did not build. The Dynamo comparison table catalogues everything Rustycage does worse: no mid-generation swap, no prefix sharing, seconds instead of sub-millisecond promotion. Having the gap written down turns it into a roadmap instead of a surprise.
Finish Path 1 properly — LRU eviction, async promotion, and the tier metrics that
make the Path 2 decision a measurement rather than an argument. Replace the
--version smoke test with a real one that loads a model and serves a completion
before a cohort cutover is allowed to proceed. Cut a first tagged release, since
five months and 24 commits without one makes the rollout scripts do work that
version tags should be doing.
Generated from databloom/rustycage@9b983d8 · synced 2026-08-19 00:13:42 UTC