Skip to content

Commit 3ca01d7

Browse files
authored
Merge pull request Luce-Org#227 from howard0su/disk_cache
feat: add ondisk prefix cache (DiskPrefixCache)
2 parents d06efb9 + ffba863 commit 3ca01d7

14 files changed

Lines changed: 1759 additions & 2 deletions

.copilot/gem.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Laguna + Gemma4 Unimplemented Stubs & Fix Plan
2+
3+
---
4+
5+
## Laguna (`dflash/src/laguna/`)
6+
7+
**Status:** Phase 2 complete (forward graph, loader, cache, snapshots, daemon, PFlash compress).
8+
Phase 3/4 work (sparse prefill, spec decode) not yet started.
9+
10+
### L1. MoE Shared-Only Stub (debug — intentional, no fix needed)
11+
- **File:** `laguna_target_graph.cpp:287-303`
12+
- **What:** Env `DFLASH_LAGUNA_MOE_STUB=1` → shared expert only. Full MoE IS the default.
13+
14+
### L2. Dead Code: `build_laguna_moe_block_legacy` (cleanup)
15+
- **File:** `laguna_target_graph.cpp:386-468`
16+
- **What:** An alternative MoE implementation never called from anywhere.
17+
- **Fix:** Delete or gate behind compile flag.
18+
19+
### L3. SWA Ring-Buffer KV Optimization (deferred)
20+
- **File:** `laguna_target_graph.cpp:544`
21+
- **What:** All 40 layers allocate full `max_ctx` KV cache. SWA layers only need
22+
`sliding_window=512` tokens. Wastes ~2.5 GiB at 32K context.
23+
- **Fix:** Ring-buffer for SWA layers with capacity = 512.
24+
25+
### L4. PFlash Sparse Prefill (Phase 3 — not started)
26+
- **File:** Not yet created
27+
- **What:** Laguna uses `ggml_flash_attn_ext` (dense) for all attention. Missing
28+
block-sparse FA via `flash_prefill_forward_bf16`/`f16`/`q8` on full-attention layers.
29+
- **Impact:** 2-4× TTFT regression vs what PFlash would give on long contexts.
30+
- **Fix:** Layer-segmented prefill path (pattern in `qwen3_graph.cpp:488-558`).
31+
32+
### L5. DFlash Speculative Decode (Phase 4 — not started)
33+
- **File:** `laguna_backend.h` — does NOT override `supports_dflash_spec_decode()`
34+
- **What:** No `LagunaDFlashTarget` class. No spec decode acceleration.
35+
- **Fix:** Implement `DFlashTarget` interface (`verify_batch`, `snapshot_kv`,
36+
`restore_kv`, `is_eos`, `embed_tokens`, `project_hidden_to_tokens`, etc.).
37+
38+
### L6. C++ Cross-Tokenizer Mapping (Phase 4 — Python only)
39+
- **File:** `bench_laguna_pflash.cpp:88` — uses `fake_l = 1972` placeholder
40+
- **What:** Qwen3→Laguna vocab mapping only in Python (`laguna_pflash_niah.py:394`).
41+
- **Fix:** Port `cross_tok_compressed` logic to C++.
42+
43+
---
44+
45+
## Gemma4 (`dflash/src/gemma4/`)
46+
47+
**Status:** Early integration. Loader, cache, graph, backend, daemon all exist and compile.
48+
Forward graph is functional but several features are stubbed or missing.
49+
50+
### G1. Per-Layer Embedding Injection NOT Wired (functional gap)
51+
- **File:** `gemma4_graph.cpp:352-365`
52+
- **What:** Two explicit TODOs: `gemma4_step()` does not pass token IDs needed for
53+
per-layer embeddings. `per_layer_input` is always `nullptr` in the layer loop.
54+
The architecture uses per-layer token embeddings gated into the residual stream —
55+
this is skipped entirely during forward.
56+
- **Impact:** Numerically wrong output on models that rely on per-layer embeddings
57+
(architecture defines `per_layer_tok_embd`, `per_layer_inp_gate`, `per_layer_proj`).
58+
- **Fix:** Add a `const int32_t * token_ids` parameter to `gemma4_step()`, look up
59+
per-layer embeddings via the global `per_layer_tok_embd` tensor (sliced per layer),
60+
project through `per_layer_model_proj` + `per_layer_proj_norm`, and pass result
61+
to each layer's `build_gemma4_layer()`.
62+
63+
### G2. Park/Unpark Are No-Ops (functional gap)
64+
- **File:** `gemma4_backend.cpp:70-81`
65+
- **What:** `park()` sets a flag but does NOT free weights/cache. `unpark()` clears
66+
the flag but does NOT reload. Unlike Laguna which frees/reloads VRAM.
67+
- **Impact:** Park command doesn't actually release GPU memory for pflash drafter.
68+
- **Fix:** Mirror Laguna's pattern: `park()``free_gemma4_cache` + `free_gemma4_weights`;
69+
`unpark()``load_gemma4_gguf` + `create_gemma4_cache`.
70+
71+
### G3. PFlash Compress Not Supported (stub)
72+
- **File:** `gemma4_backend.cpp:342-349`
73+
- **What:** `handle_compress()` prints "not supported" and returns. No drafter integration.
74+
- **Impact:** No PFlash compression for Gemma4 targets (longer TTFT on long contexts).
75+
- **Fix:** Port Laguna's compress path (lazy-load Qwen3-0.6B drafter, score+compress,
76+
emit surviving tokens). Needs cross-tokenizer mapping if Gemma4 vocab differs.
77+
78+
### G4. DFlash Speculative Decode Not Supported
79+
- **File:** `gemma4_backend.h` — does NOT override `supports_dflash_spec_decode()`
80+
- **What:** No `Gemma4DFlashTarget`. Same gap as Laguna.
81+
- **Fix:** Implement `DFlashTarget` interface for Gemma4.
82+
83+
### G5. SWA Ring-Buffer KV Optimization (same as Laguna)
84+
- **File:** `gemma4_loader.cpp:334` — SWA layers allocate full `max_ctx` KV
85+
- **What:** KV cache for SWA layers is full-sized. Only need `sliding_window` tokens.
86+
- **Fix:** Ring-buffer for SWA layers.
87+
88+
### G6. PFlash Sparse Prefill Not Implemented
89+
- **File:** `gemma4_graph.cpp` — only uses `ggml_flash_attn_ext`
90+
- **What:** Same as Laguna — no block-sparse FA for full-attention layers.
91+
- **Fix:** Layer-segmented prefill with `flash_prefill_forward_*`.
92+
93+
### G7. `restore_and_generate` Resets Cache Position (logic issue)
94+
- **File:** `gemma4_backend.cpp:245-267`
95+
- **What:** After restoring snapshot, calls `generate()` which sets `cache_.cur_pos = 0`
96+
at line 178, discarding the restored position. This means the diff-prefill
97+
optimization (only re-prefilling tokens after the snapshot) is NOT happening.
98+
- **Impact:** Every restore+generate does a full re-prefill from token 0.
99+
- **Fix:** Don't reset `cur_pos` in the restore path; only prefill the diff
100+
(tokens from `snap.cur_pos` onward). Follow Laguna's `restore_and_generate` pattern.
101+
102+
---
103+
104+
## Combined Summary Table
105+
106+
| ID | Backend | Stub | File | Severity | Effort |
107+
|----|---------|------|------|----------|--------|
108+
| G1 | Gemma4 | Per-layer embedding injection not wired (always nullptr) | `gemma4_graph.cpp:352-365` | **Critical** | 1-2d |
109+
| G7 | Gemma4 | `restore_and_generate` resets cache to 0 (no diff-prefill) | `gemma4_backend.cpp:178,245` | **High** | 0.5d |
110+
| G2 | Gemma4 | Park/unpark are no-ops (don't free/reload VRAM) | `gemma4_backend.cpp:70-81` | **High** | 0.5d |
111+
| G3 | Gemma4 | `handle_compress()` prints "not supported" | `gemma4_backend.cpp:342-349` | High | 1d |
112+
| G6 | Gemma4 | No sparse prefill (dense ggml FA only) | `gemma4_graph.cpp` | High | 2-3d |
113+
| G4 | Gemma4 | No DFlash speculative decode | `gemma4_backend.h` | High | 3-5d |
114+
| G5 | Gemma4 | SWA layers allocate full max_ctx KV (no ring buffer) | `gemma4_loader.cpp:334` | Medium | 1-2d |
115+
| L4 | Laguna | No sparse prefill (dense ggml FA only) | not yet created | **High** | 2-3d |
116+
| L5 | Laguna | No DFlash speculative decode | `laguna_backend.h` | High | 3-5d |
117+
| L6 | Laguna | C++ cross-tokenizer mapping missing (Python only) | `bench_laguna_pflash.cpp:88` | Medium | 1-2d |
118+
| L3 | Laguna | SWA layers allocate full max_ctx KV (no ring buffer) | `laguna_target_graph.cpp:544` | Medium | 1-2d |
119+
| L2 | Laguna | Dead `build_laguna_moe_block_legacy` never called | `laguna_target_graph.cpp:386-468` | Low | 1h |
120+
| L1 | Laguna | MoE shared-only stub (env debug switch) | `laguna_target_graph.cpp:287` | None ||
121+
122+
---
123+
124+
## Recommended Fix Order
125+
126+
1. **G1** — Per-layer embeddings (Gemma4 produces wrong output without this)
127+
2. **G7** — restore_and_generate cache reset bug (correctness)
128+
3. **G2** — Park/unpark no-ops (VRAM management)
129+
4. **L4 + G6** — PFlash sparse prefill (both archs, biggest perf win)
130+
5. **G3** — PFlash compress for Gemma4
131+
6. **L5 + G4** — DFlash spec decode (both archs)
132+
7. **L6** — C++ cross-tokenizer
133+
8. **L3 + G5** — SWA ring-buffer (VRAM optimization)
134+
9. **L2** — Dead code cleanup

dflash/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ add_library(dflash27b STATIC
263263
src/server/tool_memory.cpp
264264
src/server/sse_emitter.cpp
265265
src/server/prefix_cache.cpp
266+
src/server/disk_prefix_cache.cpp
266267
)
267268
# BSA (Block-Sparse Attention) backs the speculative-prefill drafter scoring
268269
# path. Default ON so prefill is fast out of the box. Turn OFF if you don't

dflash/src/common/model_backend.h

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
#include <string>
1616
#include <vector>
1717

18+
#include "ggml.h"
19+
#include "ggml-backend.h"
1820
#include "sampler.h"
1921

2022
namespace dflash27b {
@@ -105,6 +107,30 @@ struct ModelBackend {
105107
const GenerateRequest & req,
106108
const DaemonIO & io) = 0;
107109

110+
// ── Snapshot serialization (for ondisk prefix cache) ─────────────
111+
// Read-only reference to a snapshot's ggml tensors for serialization.
112+
struct SnapshotRef {
113+
ggml_context * ctx = nullptr;
114+
ggml_backend_buffer_t buf = nullptr;
115+
int cur_pos = 0;
116+
int32_t last_tok = -1; // last prefill token (for decode seeding)
117+
};
118+
119+
// Export a snapshot's tensor context + buffer for read-only access.
120+
// Ownership is NOT transferred — caller must only read tensor data.
121+
// Returns empty ref (ctx==nullptr) if slot is invalid or unused.
122+
virtual SnapshotRef snapshot_ref(int slot) const { (void)slot; return {}; }
123+
124+
// Import a deserialized snapshot into the given slot. Backend takes
125+
// ownership of ctx and buf on success. On failure (returns false),
126+
// the caller is responsible for freeing ctx and buf.
127+
virtual bool snapshot_adopt(int slot, ggml_context * ctx,
128+
ggml_backend_buffer_t buf, int cur_pos,
129+
int32_t last_tok = -1) {
130+
(void)slot; (void)ctx; (void)buf; (void)cur_pos; (void)last_tok;
131+
return false;
132+
}
133+
108134
// ── Compress (pflash) ────────────────────────────────────────────
109135
// Backend owns the DrafterContext lifecycle and park/unpark policy.
110136

dflash/src/qwen3/qwen3_backend.cpp

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,11 @@ bool Qwen3Backend::snapshot_save(int slot) {
855855
for (int il = 0; il < n_layer; ++il) {
856856
snap.k_snap[il] = ggml_dup_tensor(snap.ctx, cache_.k[il]);
857857
snap.v_snap[il] = ggml_dup_tensor(snap.ctx, cache_.v[il]);
858+
char name[64];
859+
std::snprintf(name, sizeof(name), "snap_k_%d", il);
860+
ggml_set_name(snap.k_snap[il], name);
861+
std::snprintf(name, sizeof(name), "snap_v_%d", il);
862+
ggml_set_name(snap.v_snap[il], name);
858863
}
859864

860865
snap.buf = ggml_backend_alloc_ctx_tensors(snap.ctx, backend_);
@@ -890,6 +895,56 @@ int Qwen3Backend::snapshot_cur_pos(int slot) const {
890895
return snapshots_[slot].cur_pos;
891896
}
892897

898+
ModelBackend::SnapshotRef Qwen3Backend::snapshot_ref(int slot) const {
899+
SnapshotRef ref;
900+
if (slot < 0 || slot >= PREFIX_SLOTS) return ref;
901+
const auto & snap = snapshots_[slot];
902+
if (!snap.ctx) return ref;
903+
ref.ctx = snap.ctx;
904+
ref.buf = snap.buf;
905+
ref.cur_pos = snap.cur_pos;
906+
return ref;
907+
}
908+
909+
bool Qwen3Backend::snapshot_adopt(int slot, ggml_context * ctx,
910+
ggml_backend_buffer_t buf, int cur_pos,
911+
int32_t /*last_tok*/) {
912+
if (slot < 0 || slot >= PREFIX_SLOTS) return false;
913+
snapshot_free(slot);
914+
915+
auto & snap = snapshots_[slot];
916+
const int n_layer = cache_.n_layer;
917+
918+
snap.k_snap.resize(n_layer, nullptr);
919+
snap.v_snap.resize(n_layer, nullptr);
920+
921+
// Rebind tensors by name.
922+
for (ggml_tensor * t = ggml_get_first_tensor(ctx); t; t = ggml_get_next_tensor(ctx, t)) {
923+
if (!t->name[0]) continue;
924+
int il = -1;
925+
if (std::sscanf(t->name, "snap_k_%d", &il) == 1 && il >= 0 && il < n_layer) {
926+
snap.k_snap[il] = t;
927+
} else if (std::sscanf(t->name, "snap_v_%d", &il) == 1 && il >= 0 && il < n_layer) {
928+
snap.v_snap[il] = t;
929+
}
930+
}
931+
932+
// Validate all layers bound.
933+
for (int il = 0; il < n_layer; ++il) {
934+
if (!snap.k_snap[il] || !snap.v_snap[il]) {
935+
snap.k_snap.clear();
936+
snap.v_snap.clear();
937+
return false;
938+
}
939+
}
940+
941+
snap.ctx = ctx;
942+
snap.buf = buf;
943+
snap.cur_pos = cur_pos;
944+
std::fprintf(stderr, "[qwen3] snapshot adopted slot=%d pos=%d\n", slot, cur_pos);
945+
return true;
946+
}
947+
893948
// ── Compress ───────────────────────────────────────────────────────────
894949

895950
ModelBackend::CompressResult Qwen3Backend::compress(const CompressRequest & req) {

dflash/src/qwen3/qwen3_backend.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ class Qwen3Backend : public ModelBackend {
9292
const GenerateRequest & req,
9393
const DaemonIO & io) override;
9494

95+
SnapshotRef snapshot_ref(int slot) const override;
96+
bool snapshot_adopt(int slot, ggml_context * ctx,
97+
ggml_backend_buffer_t buf, int cur_pos,
98+
int32_t last_tok = -1) override;
99+
95100
CompressResult compress(const CompressRequest & req) override;
96101
bool handle_compress(const std::string & line,
97102
const DaemonIO & io) override;

dflash/src/qwen35/qwen35_backend.cpp

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,87 @@ int Qwen35Backend::snapshot_cur_pos(int slot) const {
199199
return prefix_snapshots_[slot].cur_pos;
200200
}
201201

202+
ModelBackend::SnapshotRef Qwen35Backend::snapshot_ref(int slot) const {
203+
SnapshotRef ref;
204+
if (slot < 0 || slot >= PREFIX_SLOTS) return ref;
205+
const auto & snap = prefix_snapshots_[slot];
206+
if (!snap.ctx) return ref;
207+
ref.ctx = snap.ctx;
208+
ref.buf = snap.buf;
209+
ref.cur_pos = snap.cur_pos;
210+
ref.last_tok = snap.last_tok;
211+
return ref;
212+
}
213+
214+
bool Qwen35Backend::snapshot_adopt(int slot, ggml_context * ctx,
215+
ggml_backend_buffer_t buf, int cur_pos,
216+
int32_t last_tok) {
217+
if (slot < 0 || slot >= PREFIX_SLOTS) return false;
218+
snapshot_free(slot);
219+
220+
auto & snap = prefix_snapshots_[slot];
221+
222+
// Count expected tensor layout from weights.
223+
const int n_full_attn = w_.n_layer / w_.full_attention_interval;
224+
const int n_delta = w_.n_layer - n_full_attn;
225+
226+
snap.attn_k_snap.assign(n_full_attn, nullptr);
227+
snap.attn_v_snap.assign(n_full_attn, nullptr);
228+
snap.ssm_state_snap.assign(n_delta, nullptr);
229+
snap.conv_state_snap.assign(n_delta, nullptr);
230+
snap.target_feat_snap = nullptr;
231+
232+
// Rebind tensors by name.
233+
for (ggml_tensor * t = ggml_get_first_tensor(ctx); t; t = ggml_get_next_tensor(ctx, t)) {
234+
if (!t->name[0]) continue;
235+
int idx = -1;
236+
if (std::sscanf(t->name, "snap_cache_k_%d", &idx) == 1 && idx >= 0 && idx < n_full_attn) {
237+
snap.attn_k_snap[idx] = t;
238+
} else if (std::sscanf(t->name, "snap_cache_v_%d", &idx) == 1 && idx >= 0 && idx < n_full_attn) {
239+
snap.attn_v_snap[idx] = t;
240+
} else if (std::sscanf(t->name, "snap_ssm_state_%d", &idx) == 1 && idx >= 0 && idx < n_delta) {
241+
snap.ssm_state_snap[idx] = t;
242+
} else if (std::sscanf(t->name, "snap_conv_state_%d", &idx) == 1 && idx >= 0 && idx < n_delta) {
243+
snap.conv_state_snap[idx] = t;
244+
} else if (std::strcmp(t->name, "snap_target_feat") == 0) {
245+
snap.target_feat_snap = t;
246+
}
247+
}
248+
249+
// Validate all required tensors are present.
250+
for (int i = 0; i < n_full_attn; ++i) {
251+
if (!snap.attn_k_snap[i] || !snap.attn_v_snap[i]) {
252+
snap.attn_k_snap.clear(); snap.attn_v_snap.clear();
253+
snap.ssm_state_snap.clear(); snap.conv_state_snap.clear();
254+
snap.target_feat_snap = nullptr;
255+
return false;
256+
}
257+
}
258+
for (int i = 0; i < n_delta; ++i) {
259+
if (!snap.ssm_state_snap[i] || !snap.conv_state_snap[i]) {
260+
snap.attn_k_snap.clear(); snap.attn_v_snap.clear();
261+
snap.ssm_state_snap.clear(); snap.conv_state_snap.clear();
262+
snap.target_feat_snap = nullptr;
263+
return false;
264+
}
265+
}
266+
if (!snap.target_feat_snap) {
267+
snap.attn_k_snap.clear(); snap.attn_v_snap.clear();
268+
snap.ssm_state_snap.clear(); snap.conv_state_snap.clear();
269+
return false;
270+
}
271+
272+
snap.ctx = ctx;
273+
snap.buf = buf;
274+
snap.cur_pos = cur_pos;
275+
snap.last_tok = last_tok;
276+
snap.kv_k_type = cache_.kv_k_type;
277+
snap.max_ctx = cache_.max_ctx;
278+
snap.target_feat_cap = cache_.target_feat_cap;
279+
std::fprintf(stderr, "[qwen35] snapshot adopted slot=%d pos=%d last_tok=%d\n", slot, cur_pos, last_tok);
280+
return true;
281+
}
282+
202283
// ── Compress (pflash) ───────────────────────────────────────────────────
203284

204285
bool Qwen35Backend::handle_compress(const std::string & line, const DaemonIO & io) {

dflash/src/qwen35/qwen35_backend.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ class Qwen35Backend : public ModelBackend {
9292
const GenerateRequest & req,
9393
const DaemonIO & io) override;
9494

95+
SnapshotRef snapshot_ref(int slot) const override;
96+
bool snapshot_adopt(int slot, ggml_context * ctx,
97+
ggml_backend_buffer_t buf, int cur_pos,
98+
int32_t last_tok = -1) override;
99+
95100
bool handle_compress(const std::string & line,
96101
const DaemonIO & io) override;
97102
void free_drafter() override;

0 commit comments

Comments
 (0)