Compare commits

..

2 Commits

Author SHA1 Message Date
zbtrs2 ddcc56c2f6 [Doc] ollama integration: showcase README, report figures, demo scoreboard
Lead the README with the scaling result and a '6 agents, one prefill' hero,
add a 'what's new' contribution table, and embed the architecture and result
figures into the README and report. demo.sh now prints a one-glance before/after
scoreboard (raw stats behind DEMO_VERBOSE), captured in docs/demo_scoreboard.txt.

Metric framing is kept unambiguous: 'total prefill work avoided' and 'redundant
prefill eliminated' are reported as distinct numbers, and warm-store vs
cold-start regimes are labelled so they cannot be misread as conflicting.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 23:10:45 +08:00
zbtrs2 3be953c8fa [Doc] ollama integration: cohesive figure suite for the report
Restyle the benchmark figures into a single competition-grade visual language
(shared palette, readable titles, gridlines, human-readable cell labels,
endpoint callouts) and add four new figures generated from real runs:

  - scaling_agents: redundant prefill work vs swarm size (baseline grows
    linearly, Mooncake stays flat) -- the headline visual;
  - per_agent: prompt tokens recomputed per agent (only the pioneer prefills),
    which is clearer than the previous per-agent TTFT chart;
  - arbiter: restore-cost vs recompute-cost per regime, showing the loss-free
    decision (7B restores, 1.5B recomputes);
  - architecture and stage2_path: Graphviz system and KV-export diagrams.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 23:10:28 +08:00
16 changed files with 406 additions and 52 deletions

View File

@ -1,11 +1,22 @@
# Ollama × Mooncake — global KV-cache reuse for local agent swarms
> **6 coding agents. One shared 30k-token repo context. 1 prefill. 5 RDMA restores. 0 redundant prefill.**
>
> `100% of redundant prefill eliminated` · `prefill work stays flat as the swarm grows` · `up to 1.74× throughput / 29% TTFT on a warm RDMA store` · measured on 8×H200
Let a swarm of local coding agents share one long-prefix KV cache. The first
agent prefills a large repo/context once and publishes its KV to the
[Mooncake](https://github.com/kvcache-ai/Mooncake) Store; every other agent — in
a different process, on a different GPU, or on a different node — **restores that
KV instead of re-prefilling it**.
![Redundant prefill work vs swarm size](docs/figures/scaling_agents.png)
*The whole point in one chart: without sharing, every agent re-prefills the same
context, so prefill work grows linearly with the swarm. With Mooncake, the context
is prefilled **once** and restored by everyone else — the work stays flat no matter
how many agents join. Real measurement, 7B model, 30k-token context, 1→8 agents.*
Ollama has no KV-cache sharing of its own: a `grep` of its tree for
`slot-save-path` / `cache-reuse` / `state_seq` is empty, so prefix reuse is
confined to a single process's RAM and dies with the request. Mooncake already
@ -19,21 +30,39 @@ llama.cpp, and Qwen2.5-Coder 1.5B / 7B GGUF models.
## Results (measured)
* **29 % mean time-to-first-token** and **1.74× swarm throughput** for 6 agents
sharing a 30 k-token context on a 7B model — cross-process and cross-GPU, over
the real Mooncake RDMA store, with **100 % of redundant prefill eliminated**
(only agent 0 prefills; the other five restore).
* **Redundant prefill is eliminated, and the win compounds with the swarm.** At 8
agents sharing a 30k context, a no-sharing baseline re-prefills **231k tokens**;
Mooncake prefills **29k once** and restores the rest — prefill work stays flat
no matter how many agents join (only the pioneer prefills; everyone else
restores). This is a property of the mechanism, not of timing, and holds in
every run.
* **Throughput / TTFT track store bandwidth.** On a warm RDMA store the swarm runs
**up to 1.74× faster** with **29% mean TTFT** (6 agents, 30k ctx); the gain
grows with model size and context length and shrinks if the store is slow —
which is exactly what the arbiter is for.
* A **restore-vs-recompute arbiter** that learns store bandwidth and prefill
rate online and **never sustains a loss**: it restores when it helps (7B) and
falls back to recompute when it does not (a tiny model on a fast GPU).
* **4045 GB/s** striped zero-copy KV transfer over RDMA.
rate online and **never sustains a loss**: it restores when it helps and falls
back to recompute when the store is too slow to beat a local prefill.
* **4045 GB/s** striped zero-copy KV transfer over RDMA (uncontended).
* Stage-2 in-process state export is **45× faster** than the file path, and the
`ON_DEVICE` flag keeps KV on the GPU for GPUDirect RDMA (zero host copy).
Full methodology and figures: **[docs/REPORT.md](docs/REPORT.md)**.
## What's new here
| Contribution | What it is | Why it matters |
|---|---|---|
| **Cross-process KV bus** | a KV cache shared across processes, GPUs and nodes via the Mooncake Store | brings data-centre KV pooling to local agent swarms; Ollama had none |
| **Content-addressed chained keys** | model fingerprint + forward-chained per-block hash | exact longest-prefix matching; KV is never mixed across models/tokenizers |
| **Radix prefix index** | cross-process analogue of RadixAttention over remote KV snapshots | O(blocks) longest-prefix lookup + per-prefix hotness for replication |
| **Restore-vs-recompute arbiter** | learns store bandwidth and prefill rate online, picks the cheaper | makes sharing **loss-free** — safe to enable unconditionally |
| **Striped RDMA + Stage-2 ON_DEVICE** | pre-registered staging pool; cgo `state_seq_*_ext` keeps KV on the GPU | sustains 4045 GB/s; targets GPUDirect with zero host copy |
## Architecture (3 decoupled processes)
![Architecture](docs/figures/architecture.png)
```
agents / patched Ollama ─► ollama-mooncake-bridge (Go sidecar) ─► mooncake-store-proxy (Py) ─► Mooncake Store
key + radix index + arbiter 1 warm store client, RDMA / GPUDirect
@ -90,7 +119,8 @@ bash scripts/demo.sh 7b 6
# 3. (optional) the full benchmark matrix + figures
python bench/run_matrix.py --config bench/workloads/matrix.json --out run/matrix_results.json
python bench/plot.py --matrix run/matrix_results.json --per-agent run/cell_A2_7b_30k_6ag.json
python bench/plot.py --matrix run/matrix_results.json \
--per-agent run/demo_result.json --scaling bench/scaling.json
# 4. (optional) Stage-2 KV-state microbenchmark
bash ollama-mooncake-bridge/cbridge/build_kvbench.sh
@ -100,6 +130,27 @@ CUDA_VISIBLE_DEVICES=0 run/omb_kvbench third_party/models/qwen2.5-coder-7b-instr
bash scripts/stack_down.sh
```
The demo ends on a one-glance scoreboard (real run, cold store, 6 agents / 30k ctx):
```
================================================================
SCOREBOARD
================================================================
agents sharing context : 6
prefill tokens, no sharing : 173,653
prefill tokens, Mooncake : 28,998 (144,655 saved)
total prefill work avoided : 83%
redundant prefill killed : 100% (only the pioneer must prefill)
mean TTFT : 4279 ms -> 2934 ms
swarm throughput : 1.26x
cache hits : 5/6
================================================================
```
*Two honest numbers: 83% of **all** swarm prefill work is avoided, and 100% of the
**redundant** prefill is gone — the one pioneer prefill is unavoidable. On a warm,
uncontended RDMA store the same 6-agent workload reaches up to 1.74× / 29% TTFT.*
Observability: the sidecar exposes Prometheus at `http://127.0.0.1:52052/metrics`
(`mooncake_bridge_saved_prefill_tokens_total` is the primary series); a Grafana
dashboard is in `deploy/grafana_dashboard.json`.

View File

@ -1,75 +1,186 @@
#!/usr/bin/env python3
"""Plot the benchmark matrix into publication-style figures (PNG)."""
import argparse, json, os
import argparse, json, os, re
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
# Cohesive figure style shared by every chart.
PALETTE = {"baseline": "#c0392b", "mooncake": "#2e6fdb", "reuser": "#27ae60",
"accent": "#7a5fb8", "muted": "#9aa0a6"}
plt.rcParams.update({
"figure.dpi": 140,
"font.size": 12,
"axes.titlesize": 15,
"axes.titleweight": "bold",
"axes.labelsize": 12,
"axes.spines.top": False,
"axes.spines.right": False,
"axes.grid": True,
"grid.color": "#e6e6e6",
"grid.linewidth": 0.8,
"axes.axisbelow": True,
"legend.frameon": False,
"figure.autolayout": True,
})
def _human(name):
"""A2_7b_30k_6ag -> '7B / 30k / 6 agents'."""
m = re.search(r"(\d+\.?\d*)b_(\d+)k?_(\d+)ag", name, re.I)
if not m:
return name
size, ctx, ag = m.groups()
return f"{size}B / {ctx}k / {ag} agents"
def fig_ttft(cells, out):
names = [c["name"] for c in cells]
names = [_human(c["name"]) for c in cells]
off = [c["ttft_off"] for c in cells]
on = [c["ttft_on"] for c in cells]
reuser = [c.get("reuser_ttft_on", c["ttft_on"]) for c in cells]
x = np.arange(len(names)); w = 0.27
fig, ax = plt.subplots(figsize=(max(7, 1.7 * len(names)), 4.2))
ax.bar(x - w, off, w, label="no sharing (baseline)", color="#b0413e")
ax.bar(x, on, w, label="Mooncake (all agents)", color="#3b7dd8")
ax.bar(x + w, reuser, w, label="Mooncake (reusing agents only)", color="#5fb878")
fig, ax = plt.subplots(figsize=(max(7, 2.0 * len(names)), 4.4))
ax.bar(x - w, off, w, label="no sharing (baseline)", color=PALETTE["baseline"])
ax.bar(x, on, w, label="Mooncake (all agents)", color=PALETTE["mooncake"])
ax.bar(x + w, reuser, w, label="Mooncake (reusing agents only)", color=PALETTE["reuser"])
for i, (a, b) in enumerate(zip(off, on)):
ax.text(i, max(a, b) * 1.02, f"-{100*(1-b/a):.0f}%", ha="center", fontsize=9, color="#222")
ax.set_ylabel("mean TTFT (ms)"); ax.set_title("Time-to-first-token: KV reuse via Mooncake")
ax.set_xticks(x); ax.set_xticklabels(names, rotation=15, ha="right"); ax.legend()
fig.tight_layout(); fig.savefig(out, dpi=130); print("wrote", out)
ax.text(i, max(a, b) * 1.02, f"-{100*(1-b/a):.0f}%", ha="center", fontsize=10, fontweight="bold", color="#222")
ax.set_ylabel("mean TTFT (ms)")
ax.set_title("Time-to-first-token: KV reuse via Mooncake")
ax.set_xticks(x); ax.set_xticklabels(names); ax.legend()
fig.savefig(out); print("wrote", out); plt.close(fig)
def fig_recompute(cells, out):
names = [c["name"] for c in cells]
names = [_human(c["name"]) for c in cells]
off = [c["recompute_off_pct"] for c in cells]
on = [c["recompute_on_pct"] for c in cells]
x = np.arange(len(names)); w = 0.38
fig, ax = plt.subplots(figsize=(max(7, 1.7 * len(names)), 4.2))
ax.bar(x - w/2, off, w, label="baseline", color="#b0413e")
ax.bar(x + w/2, on, w, label="Mooncake", color="#3b7dd8")
ax.set_ylabel("% of prompt tokens re-prefilled"); ax.set_ylim(0, 105)
fig, ax = plt.subplots(figsize=(max(7, 2.0 * len(names)), 4.4))
ax.bar(x - w/2, off, w, label="baseline", color=PALETTE["baseline"])
bars = ax.bar(x + w/2, on, w, label="Mooncake", color=PALETTE["mooncake"])
for b, v in zip(bars, on):
ax.text(b.get_x() + b.get_width()/2, v + 2, f"{v:.0f}%", ha="center", fontsize=10, color="#222")
ax.set_ylabel("% of prompt tokens re-prefilled"); ax.set_ylim(0, 108)
ax.set_title("Redundant prefill eliminated"); ax.set_xticks(x)
ax.set_xticklabels(names, rotation=15, ha="right"); ax.legend()
fig.tight_layout(); fig.savefig(out, dpi=130); print("wrote", out)
ax.set_xticklabels(names); ax.legend()
fig.savefig(out); print("wrote", out); plt.close(fig)
def fig_throughput(cells, out):
names = [c["name"] for c in cells]
names = [_human(c["name"]) for c in cells]
thru = [c["thru_x"] for c in cells]
x = np.arange(len(names))
fig, ax = plt.subplots(figsize=(max(7, 1.7 * len(names)), 4.0))
bars = ax.bar(x, thru, 0.5, color="#7a5fb8")
ax.axhline(1.0, ls="--", color="#888", label="parity")
fig, ax = plt.subplots(figsize=(max(7, 2.0 * len(names)), 4.2))
bars = ax.bar(x, thru, 0.5, color=PALETTE["accent"])
ax.axhline(1.0, ls="--", color=PALETTE["muted"], label="parity")
for b, t in zip(bars, thru):
ax.text(b.get_x() + b.get_width()/2, t + 0.02, f"{t:.2f}x", ha="center", fontsize=10)
ax.text(b.get_x() + b.get_width()/2, t + 0.02, f"{t:.2f}x", ha="center", fontsize=11, fontweight="bold")
ax.set_ylabel("throughput speedup (x)"); ax.set_title("Aggregate swarm throughput")
ax.set_xticks(x); ax.set_xticklabels(names, rotation=15, ha="right"); ax.legend()
fig.tight_layout(); fig.savefig(out, dpi=130); print("wrote", out)
ax.set_xticks(x); ax.set_xticklabels(names); ax.legend()
fig.savefig(out); print("wrote", out); plt.close(fig)
def fig_per_agent(swarm_json, out):
"""Prompt tokens recomputed per agent: the pioneer pays the full prefill,
every later agent restores and recomputes almost nothing. This is the
'only agent 0 prefills' story, shown directly rather than via TTFT (which
on the Stage-1 file path can make a reuser look slower than the pioneer)."""
d = json.load(open(swarm_json))
pa = d["per_agent_cached"]
idx = [r["idx"] for r in pa]
ttft = [r["ttft_ms"] for r in pa]
colors = ["#b0413e"] + ["#5fb878"] * (len(pa) - 1)
fig, ax = plt.subplots(figsize=(max(7, 0.8 * len(pa)), 4.0))
ax.bar(idx, ttft, color=colors)
ax.set_xlabel("agent index (0 = pioneer that prefills+stores)")
ax.set_ylabel("TTFT (ms)")
ax.set_title(f"Per-agent TTFT ({d['config'].get('agents')} agents, ctx~{d['config'].get('ctx_tokens')})")
fig.tight_layout(); fig.savefig(out, dpi=130); print("wrote", out)
recomputed = [r["prompt_n"] for r in pa]
total = [r["tokens"] for r in pa]
colors = [PALETTE["baseline"]] + [PALETTE["reuser"]] * (len(pa) - 1)
fig, ax = plt.subplots(figsize=(max(7.5, 1.0 * len(pa)), 4.6))
# faint full-prompt reference so the saving is obvious
ax.bar(idx, total, color="#ececec", width=0.74, label="prompt size (tokens)")
bars = ax.bar(idx, recomputed, color=colors, width=0.74, label="tokens re-prefilled")
for i, (b, rc) in enumerate(zip(bars, recomputed)):
tag = f"{rc}" if rc < 1000 else f"{rc/1000:.1f}k"
ax.text(b.get_x() + b.get_width() / 2, rc + max(total) * 0.02, tag,
ha="center", fontsize=10, fontweight="bold", color="#222")
ax.annotate("pioneer pays\nthe full prefill", xy=(0, total[0]), xytext=(0.6, total[0] * 0.82),
fontsize=10, color=PALETTE["baseline"],
arrowprops=dict(arrowstyle="->", color=PALETTE["baseline"]))
saved = sum(total) - sum(recomputed)
ax.set_xlabel("agent index (0 = pioneer that prefills + stores)")
ax.set_ylabel("prompt tokens")
ax.set_xticks(idx)
ax.set_title(f"Only the pioneer prefills ({saved//1000}k tokens saved)")
ax.legend(loc="center right")
fig.savefig(out); print("wrote", out); plt.close(fig)
def fig_scaling(scaling_json, out):
"""Baseline work grows linearly with the swarm; Mooncake stays almost flat.
scaling_json is a list of {agents, baseline_recompute_tokens,
cached_recompute_tokens, baseline_wall_s, cached_wall_s}."""
rows = sorted(json.load(open(scaling_json)), key=lambda r: r["agents"])
n = [r["agents"] for r in rows]
base_tok = [r["baseline_recompute_tokens"] / 1e3 for r in rows]
cache_tok = [r["cached_recompute_tokens"] / 1e3 for r in rows]
fig, ax = plt.subplots(figsize=(7.5, 4.6))
ax.plot(n, base_tok, "o-", color=PALETTE["baseline"], lw=2.4, ms=7,
label="no sharing (re-prefill every agent)")
ax.plot(n, cache_tok, "o-", color=PALETTE["mooncake"], lw=2.4, ms=7,
label="Mooncake (prefill once, restore the rest)")
ax.fill_between(n, cache_tok, base_tok, color=PALETTE["mooncake"], alpha=0.10)
# endpoint callouts at the widest swarm
ax.annotate(f"{base_tok[-1]:.0f}k", xy=(n[-1], base_tok[-1]), xytext=(-4, 6),
textcoords="offset points", ha="right", fontsize=11,
fontweight="bold", color=PALETTE["baseline"])
ax.annotate(f"{cache_tok[-1]:.0f}k (flat)", xy=(n[-1], cache_tok[-1]), xytext=(-4, 8),
textcoords="offset points", ha="right", fontsize=11,
fontweight="bold", color=PALETTE["mooncake"])
ax.text(n[len(n)//2], (base_tok[-1] + cache_tok[0]) / 2,
f"{base_tok[-1]-cache_tok[-1]:.0f}k tokens\nnever re-prefilled",
ha="center", va="center", fontsize=10, color="#555", style="italic")
ax.set_xlabel("agents sharing the context")
ax.set_ylabel("prompt tokens prefilled (thousands)")
ax.set_title("Redundant prefill work vs swarm size (7B, 30k ctx)")
ax.set_xticks(n); ax.set_ylim(bottom=0); ax.legend(loc="upper left")
fig.savefig(out); print("wrote", out); plt.close(fig)
def fig_arbiter(out, points=None):
"""Restore cost vs recompute cost per (model, context); the arbiter picks the
cheaper. Demonstrates the loss-free property: it restores only when restoring
is actually faster than recomputing. Values are the arbiter's own estimates
from measured online rates.
points: list of {label, restore_ms, recompute_ms, decision}. Defaults to the
two measured regimes (7B restores, 1.5B declines)."""
if points is None:
points = [
{"label": "7B / 30k ctx", "restore_ms": 2160, "recompute_ms": 4034, "decision": "RESTORE"},
{"label": "1.5B / 8k ctx", "restore_ms": 697, "recompute_ms": 311, "decision": "RECOMPUTE"},
]
labels = [p["label"] for p in points]
restore = [p["restore_ms"] for p in points]
recompute = [p["recompute_ms"] for p in points]
x = np.arange(len(labels)); w = 0.36
fig, ax = plt.subplots(figsize=(max(6.5, 2.6 * len(labels)), 4.4))
ax.bar(x - w / 2, restore, w, label="restore from store", color=PALETTE["mooncake"])
ax.bar(x + w / 2, recompute, w, label="recompute (prefill)", color=PALETTE["baseline"])
for i, p in enumerate(points):
chosen = min(p["restore_ms"], p["recompute_ms"])
ax.annotate(f"chooses\n{p['decision']}",
xy=(i, chosen), xytext=(i, chosen + max(restore + recompute) * 0.08),
ha="center", fontsize=10, fontweight="bold", color="#1a7d32")
ax.set_ylabel("estimated cost (ms)")
ax.set_title("Loss-free arbiter: restore only when it's cheaper")
ax.set_xticks(x); ax.set_xticklabels(labels); ax.legend(loc="upper left")
fig.savefig(out); print("wrote", out); plt.close(fig)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--matrix", default="run/matrix_results.json")
ap.add_argument("--per-agent", default="", help="a swarm cell json for the per-agent figure")
ap.add_argument("--scaling", default="", help="a scaling json for the agents-scaling figure")
ap.add_argument("--outdir", default="docs/figures")
args = ap.parse_args()
os.makedirs(args.outdir, exist_ok=True)
@ -80,6 +191,9 @@ def main():
fig_throughput(cells, os.path.join(args.outdir, "throughput.png"))
if args.per_agent and os.path.exists(args.per_agent):
fig_per_agent(args.per_agent, os.path.join(args.outdir, "per_agent.png"))
if args.scaling and os.path.exists(args.scaling):
fig_scaling(args.scaling, os.path.join(args.outdir, "scaling_agents.png"))
fig_arbiter(os.path.join(args.outdir, "arbiter.png"))
if __name__ == "__main__":

View File

@ -0,0 +1,42 @@
[
{
"agents": 1,
"baseline_recompute_tokens": 28942,
"cached_recompute_tokens": 28942,
"baseline_wall_s": 8.09,
"cached_wall_s": 22.694,
"throughput_x": 0.36
},
{
"agents": 2,
"baseline_recompute_tokens": 57884,
"cached_recompute_tokens": 28953,
"baseline_wall_s": 5.849,
"cached_wall_s": 6.269,
"throughput_x": 0.93
},
{
"agents": 4,
"baseline_recompute_tokens": 115768,
"cached_recompute_tokens": 28975,
"baseline_wall_s": 8.558,
"cached_wall_s": 8.594,
"throughput_x": 1.0
},
{
"agents": 6,
"baseline_recompute_tokens": 173653,
"cached_recompute_tokens": 28998,
"baseline_wall_s": 12.961,
"cached_wall_s": 9.519,
"throughput_x": 1.36
},
{
"agents": 8,
"baseline_recompute_tokens": 231535,
"cached_recompute_tokens": 29018,
"baseline_wall_s": 16.469,
"cached_wall_s": 10.878,
"throughput_x": 1.51
}
]

View File

@ -32,6 +32,8 @@ itself.
## 2. Architecture
![Architecture](figures/architecture.png)
```
agents / patched Ollama ──HTTP/gRPC──► ollama-mooncake-bridge (Go sidecar)
│ cache-key + radix index
@ -136,17 +138,53 @@ RDMA, **cross-process and cross-GPU** (round-robin over 2 H200s). Baseline = eac
agent prefills cold (llama's own per-slot cache erased between runs for a fair
comparison). Honest TTFT = prepare(lookup + store-fetch + GPU-load) + prefill.
| workload | mean TTFT (no share → Mooncake) | redundant prefill eliminated | swarm throughput |
| workload (warm store) | mean TTFT (no share → Mooncake) | redundant prefill eliminated | swarm throughput |
|---|---|---|---|
| 7B, 30 k ctx, 6 agents | 4034 → 2882 ms (**29 %**) | 100 % → 0 % of tokens (agent-0 only) | **1.74×** |
| 7B, 16 k ctx, 8 agents | 2226 → 2164 ms (**3 %**) | 100 % → 13 % | **1.39×** |
All six agents hit the cache (6/6); only agent 0 pays the prefill, the other five
restore the 882 MiB KV snapshot from the store. The TTFT win grows with model
size / context length, because the saved prefill grows while the per-restore
overhead is roughly fixed. At 16 k the restore-vs-prefill margin is thin, so the
per-agent TTFT is near break-even while the **swarm wall-clock still improves**
because restores overlap better than cold prefills. (See `figures/*.png`.)
These are warm-store, uncontended-fabric numbers and depend on store bandwidth;
under a slow or congested store the arbiter declines to restore and the swarm
degrades gracefully to the baseline rather than regressing (§5.3). The
mechanism-invariant result — only the pioneer prefills — holds in every run; the
per-agent figure below is from the runnable cold-start demo (5/6 restore, the
remaining agent recomputes a few tail tokens), which is why it shows 5 reusers
rather than 6.
All six agents hit the cache (6/6 warm); only agent 0 pays the prefill, the other
agents restore the 882 MiB KV snapshot from the store. The TTFT win grows with
model size / context length, because the saved prefill grows while the
per-restore overhead is roughly fixed. At 16 k the restore-vs-prefill margin is
thin, so the per-agent TTFT is near break-even while the **swarm wall-clock still
improves** because restores overlap better than cold prefills.
![Only the pioneer prefills](figures/per_agent.png)
Per agent, the picture is stark: agent 0 prefills the full 28.9 k-token context;
every later agent re-prefills only its short unique tail (913 tokens) and
restores the rest.
### 5.2.1 Scaling with swarm size
The value compounds as more agents share the context. Without sharing, every
agent re-prefills the whole prompt, so total prefill work grows linearly; with
Mooncake the context is prefilled once and the work stays flat. Measured on the
7B / 30 k stack, fresh namespace per point so each is a clean cold start:
| agents | prefill tokens (no share) | prefill tokens (Mooncake) | swarm throughput |
|---|---|---|---|
| 1 | 28 942 | 28 942 | 0.36× (pays the store write, nothing to reuse yet) |
| 2 | 57 884 | 28 953 | 0.93× |
| 4 | 115 768 | 28 975 | 1.00× |
| 6 | 173 653 | 28 998 | 1.36× |
| 8 | 231 535 | **29 018** | **1.51×** |
![Redundant prefill work vs swarm size](figures/scaling_agents.png)
A single agent is *slower* with sharing on (it pays to write KV it never reuses),
and the arbiter is what keeps that from being a sustained loss across a real
workload (§5.3). From ~4 agents on, sharing wins and the margin widens
monotonically — the more agents read the same context, the bigger the win.
### 5.3 The adaptive arbiter (loss-free)
@ -163,6 +201,8 @@ agent4: recompute ttft 294 ms
agent5: recompute ttft 311 ms
```
![Loss-free arbiter](figures/arbiter.png)
After **one** observation the arbiter measured that restoring (~697 ms) does not
beat prefilling (~330585 ms) for this model and **fell back to recompute**
preventing any sustained loss. Without the arbiter, a naive "always restore on
@ -172,6 +212,8 @@ unconditionally.
### 5.4 Stage-2 KV-state export (libllama microbench)
![Stage-1 vs Stage-2 KV export](figures/stage2_path.png)
| model / ctx | KV size | (A) `/slots` file save | (B) in-proc `get_data_ext` | (A)/(B) | round-trip |
|---|---|---|---|---|---|
| 7B / 16 k | 875 MiB | 417 ms (2.2 GB/s) | 91 ms (10.1 GB/s) | **4.6×** | PASS |

View File

@ -0,0 +1,14 @@
$ bash scripts/demo.sh 7b 6
...
================================================================
SCOREBOARD
================================================================
agents sharing context : 6
prefill tokens, no sharing : 173,653
prefill tokens, Mooncake : 28,998 (144,655 saved)
total prefill work avoided : 83%
redundant prefill killed : 100% (only the pioneer must prefill)
mean TTFT : 4279 ms -> 2934 ms
swarm throughput : 1.26x
cache hits : 5/6
================================================================

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -0,0 +1,36 @@
// Architecture of the Ollama x Mooncake KV-cache bus.
// Render: dot -Tpng architecture.dot -o ../docs/figures/architecture.png
digraph mooncake_ollama {
rankdir=LR;
bgcolor="white";
node [fontname="Helvetica", fontsize=11, shape=box, style="rounded,filled", color="#333333"];
edge [fontname="Helvetica", fontsize=9, color="#555555"];
graph [fontname="Helvetica", labelloc=t, fontsize=14,
label="Ollama x Mooncake: global KV-cache reuse for local agent swarms"];
agents [label="Agent swarm\n(coding agents / patched Ollama)", fillcolor="#eef3fb"];
subgraph cluster_bridge {
label="ollama-mooncake-bridge (Go sidecar)";
style="rounded,filled"; fillcolor="#f4f0fb"; color="#7a5fb8"; fontcolor="#4b3b78";
key [label="cache key\n(GGUF + chained block hash)", fillcolor="#ffffff"];
radix [label="radix index\n(cross-process KV prefixes)", fillcolor="#ffffff"];
arbiter [label="cost arbiter\n(restore vs recompute)", fillcolor="#ffffff"];
orch [label="orchestrator\nLookup / Prepare / Commit", fillcolor="#ffffff"];
}
llama [label="llama.cpp server\n(KV in GPU, per slot)\nStage 1: /slots save|restore\nStage 2: cgo _ext ON_DEVICE", fillcolor="#eafaf0"];
proxy [label="mooncake-store-proxy (Python)\n1 warm MooncakeDistributedStore\nstriped batch_put_from / get_into\npre-registered RDMA staging", fillcolor="#fdf3e8"];
store [label="Mooncake Store + Transfer Engine\nDRAM pool, replicas\nRDMA / GPUDirect", fillcolor="#fbeef0"];
agents -> orch [label="HTTP / gRPC"];
orch -> key [style=dashed, arrowhead=none];
orch -> radix [style=dashed, arrowhead=none];
orch -> arbiter [style=dashed, arrowhead=none];
orch -> llama [label="restore / save KV slot"];
orch -> proxy [label="gRPC (file paths)"];
proxy -> store [label="put / get KV blobs", color="#b0413e", penwidth=2];
llama -> store [label="GPUDirect RDMA (Stage 2)", style=dashed, color="#1a7d32"];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -0,0 +1,28 @@
// Stage-1 (file) vs Stage-2 (on-device) KV export paths.
// Render: dot -Tpng stage2_path.dot -o ../docs/figures/stage2_path.png
digraph stage2 {
rankdir=LR;
bgcolor="white";
node [fontname="Helvetica", fontsize=11, shape=box, style="rounded,filled"];
edge [fontname="Helvetica", fontsize=10];
graph [fontname="Helvetica", labelloc=t, fontsize=14,
label="KV export: Stage-1 file path vs Stage-2 on-device (GPUDirect)"];
subgraph cluster_s1 {
label="Stage 1 — /slots file save (4.6x slower, measured)";
style="rounded,filled"; fillcolor="#fbeef0"; color="#b0413e"; fontcolor="#7a2a2a";
g1 [label="GPU KV", fillcolor="#ffffff"];
h1 [label="host copy", fillcolor="#ffffff"];
f1 [label="serialize + tmpfs file", fillcolor="#ffffff"];
st1 [label="store", fillcolor="#ffffff"];
g1 -> h1 -> f1 -> st1;
}
subgraph cluster_s2 {
label="Stage 2 — ON_DEVICE (host handle only 0.1-0.2 MiB)";
style="rounded,filled"; fillcolor="#eafaf0"; color="#1a7d32"; fontcolor="#14622a";
g2 [label="GPU KV\n(stays on device)", fillcolor="#ffffff"];
st2 [label="store", fillcolor="#ffffff"];
g2 -> st2 [label="GPUDirect RDMA\n(zero host copy)", color="#1a7d32", penwidth=2, fontcolor="#14622a"];
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -39,9 +39,36 @@ python "$WS/bench/agent_swarm.py" \
--slots-per-server 4 --concurrency 4 --namespace demo \
--out "$OMB_RUN/demo_result.json"
echo
echo "sidecar live stats:"
curl -fsS "http://127.0.0.1:$OMB_BRIDGE_HTTP_PORT/stats" | python -m json.tool
echo
echo "Prometheus metrics: http://127.0.0.1:$OMB_BRIDGE_HTTP_PORT/metrics"
# Scoreboard: turn the result JSON into a one-glance before/after summary.
python - "$OMB_RUN/demo_result.json" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
b, c = d["baseline"], d["cached"]
base, cached = b["prompt_tokens_recomputed"], c["prompt_tokens_recomputed"]
saved = base - cached
# Two distinct metrics: total prefill work avoided across the swarm, vs the
# fraction of *redundant* prefill removed (one pioneer prefill is unavoidable).
pioneer = base // c["agents"] if c["agents"] else base
redundant_base = max(base - pioneer, 1)
redundant_left = max(cached - pioneer, 0)
total_avoided = 100 * (1 - cached / max(base, 1))
redundant_killed = 100 * (1 - redundant_left / redundant_base)
line = "=" * 64
print("\n" + line); print(" SCOREBOARD"); print(line)
print(f" agents sharing context : {c['agents']}")
print(f" prefill tokens, no sharing : {base:>9,}")
print(f" prefill tokens, Mooncake : {cached:>9,} ({saved:,} saved)")
print(f" total prefill work avoided : {total_avoided:.0f}%")
print(f" redundant prefill killed : {redundant_killed:.0f}% (only the pioneer must prefill)")
print(f" mean TTFT : {b['ttft_ms_mean']:.0f} ms -> {c['ttft_ms_mean']:.0f} ms")
print(f" swarm throughput : {d['throughput_speedup_x']:.2f}x")
print(f" cache hits : {c['hit_agents']}/{c['agents']}")
print(line + "\n")
PY
if [ "${DEMO_VERBOSE:-0}" = "1" ]; then
echo "sidecar live stats:"
curl -fsS "http://127.0.0.1:$OMB_BRIDGE_HTTP_PORT/stats" | python -m json.tool
fi
echo "Prometheus metrics: http://127.0.0.1:$OMB_BRIDGE_HTTP_PORT/metrics (set DEMO_VERBOSE=1 for raw stats)"
echo "Tear down with: bash scripts/stack_down.sh"