AI · HPC · Storage

Rustycage

A Rust LLM serving platform with block-level KV cache tiering across GPU, RAM, and disk

Workspace crates
6
API surfaces served
3
Quantization targets
26
KV cache tiers
3

Rust 78% JavaScript 8% Shell 7% Python 3% CSS 2% HTML 1%

01Why this exists

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.

02Architecture

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.

03Development log

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."

04Roadmap

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.

Now

  • LRU eviction with sequence-aware grouping
  • Async block promotion ahead of attention
  • Tier hit-rate and promotion-latency metrics
  • Real smoke tests in cohort rollout, beyond --version

Next

  • Evaluate ggml_backend_tiered against the metadata-first path
  • Copy-on-write prefix sharing for multi-tenant serving
  • Tagged releases and a pinned llama.cpp merge cadence

Later

  • Physical block redesign, vLLM-style PagedAttention
  • Incremental upstream PRs to llama.cpp

05Pitfalls

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.

06Lessons learned

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.

07What's next

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