|
| 1 | +# MoE Resident/Paged Split + Multi-Device + Expert Cache — Implementation Plan |
| 2 | + |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Execution model for this plan: Codex implements each task; Claude reviews each task before it is marked complete.** |
| 4 | +
|
| 5 | +**Goal:** Keep the small dense weight set VRAM-resident and stream only the sparse routed experts, so frontier fine-grained MoE models run fast on a dual-GPU rig — lifting DeepSeek V4 Flash Q8 paged decode from 0.02 t/s toward 0.5–3 t/s. |
| 6 | + |
| 7 | +**Architecture:** Split model tensors by class — dense (`n_experts==1` at load / `!is_expert` in the catalog: MLA attention, shared expert, embeddings, output, norms) load as normal resident VRAM buffers; only routed-expert (`_exps`) tensors register with the pager and demand-stream from NVMe. Phase 1 does this single-card; Phase 2 places dense on a second GPU via per-tensor buft overrides + per-device pager pools; Phase 3 adds frequency-biased expert-cache retention. |
| 8 | + |
| 9 | +**Tech Stack:** C++17, ggml/ggml-hip (ROCm), llama.cpp weight pager (`src/weight-pager/`), `tests/test-weight-pager.cpp` (bespoke no-framework unit harness). Build+run on mad-lab-main `~/llama-wp/build-hip`. |
| 10 | + |
| 11 | +## Global Constraints |
| 12 | + |
| 13 | +- Feature gate: `WP_RESIDENT_DENSE=1` enables the dense-resident split; **default off**. Gate off ⇒ byte-for-byte today's all-paged behavior. |
| 14 | +- Always pass `--no-mmap` to llama.cpp tools. Never run inference without the user's go-ahead. |
| 15 | +- Remote GPU box shell is fish: wrap as `ssh mad-lab-main bash -s <<'EOF' … EOF`. |
| 16 | +- Do NOT touch pre-existing WIP: `examples/pagedattn-*`, `src/llama-graph.cpp` (unrelated edits), `examples/CMakeLists.txt`. Do NOT clobber mad-lab-main's `feat/dsws-phaseb-conversion` — use the `~/llama-wp` worktree. |
| 17 | +- Commit only the files a task names. Co-author trailer + Claude-Session trailer on every commit (see existing history). |
| 18 | +- Expert classification is name-based and already computed at `src/llama-model.cpp:1774-1798` (`is_consolidated` from `ffn_up_exps.`/`ffn_gate_exps.`/`ffn_down_exps.`). Reuse it; do NOT invent new name patterns. Ground every name/pattern table in a real GGUF dump. |
| 19 | +- The pager catalog already exposes classification: `PageMeta.is_expert`, `PageCatalog::has_experts()`, `n_expert_pages()` (`src/weight-pager/wp-page-catalog.h:39,118,121`). `add_pinned` (MAD-236, `:101`) is the existing resident-page API. |
| 20 | +- Unit suite must stay green (37/37 today): `./build-hip/bin/test-weight-pager`. |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Phase 1 — Single-card dense-resident split |
| 25 | + |
| 26 | +Foundational and independently valuable. Proves the thesis on the model already loaded, no cross-device work. |
| 27 | + |
| 28 | +### File structure (Phase 1) |
| 29 | + |
| 30 | +- `src/llama-model.cpp:1762-1804` — the per-tensor `weight_page_info` push. **Filter point:** under the gate, page only expert tensors; dense tensors skip paging and load via the normal allocator. |
| 31 | +- `src/llama-model-loader.h:33-44` — `llama_weight_page_info`. Add an `is_expert` bool (derived from the existing `is_consolidated` detection) so the filter reads one flag, not a re-parse. |
| 32 | +- `src/llama.cpp:96-211` — `init_weight_pager`: slot-budget accounting must reserve dense-resident VRAM; add the fail-loud placement guard + a resident/paged telemetry line. |
| 33 | +- `tests/test-weight-pager.cpp` — unit tests for the classifier flag + a catalog-level "only experts paged" assertion. |
| 34 | +- `src/weight-pager/wp-eval-cb.cpp` — no change expected in Phase 1 (dense tensors are simply absent from the catalog; the callback already no-ops on non-catalog tensors). Verify, don't edit. |
| 35 | + |
| 36 | +### Task 1: Add `is_expert` to `llama_weight_page_info` and set it at load |
| 37 | + |
| 38 | +**Files:** |
| 39 | +- Modify: `src/llama-model-loader.h:33-44` |
| 40 | +- Modify: `src/llama-model.cpp:1762-1799` |
| 41 | + |
| 42 | +**Interfaces:** |
| 43 | +- Produces: `llama_weight_page_info::is_expert` (bool, default false) — true iff the tensor is a routed-expert consolidated tensor. Consumed by Task 2 (filter) and Task 3 (budget/guard). |
| 44 | + |
| 45 | +- [ ] **Step 1: Add the field.** In `src/llama-model-loader.h` inside `struct llama_weight_page_info` (after `int n_experts = 1;`): |
| 46 | + |
| 47 | +```cpp |
| 48 | + // True iff this is a routed-expert (consolidated ffn_*_exps) tensor. |
| 49 | + // Set at load from the same name detection that computes n_experts. |
| 50 | + // Dense tensors (attention, shared expert, embeddings, norms) are false. |
| 51 | + bool is_expert = false; |
| 52 | +``` |
| 53 | + |
| 54 | +- [ ] **Step 2: Set it where `is_consolidated` is already computed.** In `src/llama-model.cpp`, the block at `:1774-1797` already computes `bool is_consolidated`. Immediately before `ml.weight_page_infos.push_back(info);` (`:1799`), add: |
| 55 | + |
| 56 | +```cpp |
| 57 | + info.is_expert = is_consolidated; |
| 58 | +``` |
| 59 | + |
| 60 | +Note: `is_consolidated` is declared inside the inner `{ … }` scope at `:1777`. Move the `info.is_expert = is_consolidated;` assignment to the last line **inside** that inner block (right after the `if (n_exp > 1) { info.n_experts = n_exp; }`), so it is in scope. |
| 61 | + |
| 62 | +- [ ] **Step 3: Build the loader TU only** (fast compile check), on mad-lab-main: |
| 63 | + |
| 64 | +``` |
| 65 | +ssh mad-lab-main bash -s <<'EOF' |
| 66 | +cd ~/llama-wp/build-hip && cmake --build . --target llama -j4 2>&1 | tail -3 |
| 67 | +EOF |
| 68 | +``` |
| 69 | +Expected: `Built target llama`, no errors. |
| 70 | + |
| 71 | +- [ ] **Step 4: Commit.** |
| 72 | + |
| 73 | +``` |
| 74 | +git add src/llama-model-loader.h src/llama-model.cpp |
| 75 | +git commit -m "feat(wp): tag routed-expert tensors with is_expert at load" |
| 76 | +``` |
| 77 | + |
| 78 | +### Task 2: Gate the paging filter on `WP_RESIDENT_DENSE` |
| 79 | + |
| 80 | +**Files:** |
| 81 | +- Modify: `src/llama-model.cpp:1799-1803` (the push into `weight_page_infos` + `weight_tensor_ptrs`) |
| 82 | + |
| 83 | +**Interfaces:** |
| 84 | +- Consumes: `info.is_expert` (Task 1). |
| 85 | +- Produces: when `WP_RESIDENT_DENSE=1`, `ml.weight_page_infos` and `weight_pager->weight_tensor_ptrs` contain **only** expert tensors; dense tensors are left to the normal allocator. |
| 86 | + |
| 87 | +- [ ] **Step 1: Add a gate helper near the top of `src/llama-model.cpp`** (next to other `getenv` helpers; if none, add a file-local static): |
| 88 | + |
| 89 | +```cpp |
| 90 | +static bool wp_resident_dense_enabled() { |
| 91 | + const char * v = std::getenv("WP_RESIDENT_DENSE"); |
| 92 | + return v != nullptr && v[0] == '1'; |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +- [ ] **Step 2: Filter the push.** Replace `:1799-1803`: |
| 97 | + |
| 98 | +```cpp |
| 99 | + ml.weight_page_infos.push_back(info); |
| 100 | + // Collect the actual model tensor pointer for the weight pager |
| 101 | + if (weight_pager) { |
| 102 | + weight_pager->weight_tensor_ptrs.push_back(t); |
| 103 | + } |
| 104 | +``` |
| 105 | +
|
| 106 | +with: |
| 107 | +
|
| 108 | +```cpp |
| 109 | + // WP_RESIDENT_DENSE: page ONLY routed-expert tensors; dense |
| 110 | + // tensors (attention, shared expert, embeddings, norms) fall |
| 111 | + // through to the normal buffer allocator and stay VRAM-resident. |
| 112 | + const bool page_this = !wp_resident_dense_enabled() || info.is_expert; |
| 113 | + if (page_this) { |
| 114 | + ml.weight_page_infos.push_back(info); |
| 115 | + if (weight_pager) { |
| 116 | + weight_pager->weight_tensor_ptrs.push_back(t); |
| 117 | + } |
| 118 | + } |
| 119 | +``` |
| 120 | + |
| 121 | +- [ ] **Step 3: Build `llama` target** (as Task 1 Step 3). Expected: clean. |
| 122 | + |
| 123 | +- [ ] **Step 4: Sanity-log check (no run yet).** Grep confirms the gate compiles into the catalog-population path — verify by reading back the diff; expected: dense tensors excluded when gate on. |
| 124 | + |
| 125 | +- [ ] **Step 5: Commit.** |
| 126 | + |
| 127 | +``` |
| 128 | +git add src/llama-model.cpp |
| 129 | +git commit -m "feat(wp): WP_RESIDENT_DENSE gates paging to expert tensors only" |
| 130 | +``` |
| 131 | + |
| 132 | +### Task 3: Fail-loud placement guard + resident/paged telemetry in init_weight_pager |
| 133 | + |
| 134 | +**Files:** |
| 135 | +- Modify: `src/llama.cpp:96-211` (`init_weight_pager`) |
| 136 | + |
| 137 | +**Interfaces:** |
| 138 | +- Consumes: `ml.weight_page_infos` (expert-only when gated), the catalog's `n_expert_pages()`/`size()`. |
| 139 | +- Produces: an init log line `resident_dense=<on|off> paged_pages=<N> (experts=<E>) dense_resident=<skipped>`, and a hard abort if the gate is on but any non-expert page slipped into the catalog. |
| 140 | + |
| 141 | +- [ ] **Step 1: After the catalog is populated (`src/llama.cpp:169-173`), add the guard + telemetry:** |
| 142 | + |
| 143 | +```cpp |
| 144 | + if (wp_resident_dense_enabled_llama()) { |
| 145 | + // Under the resident-dense split the catalog must contain ONLY expert |
| 146 | + // pages. A dense page here means the filter (llama-model.cpp) missed a |
| 147 | + // tensor — fail loud rather than silently thrash it. |
| 148 | + const int n_pages = model.wp_pager->n_pages(); |
| 149 | + const int n_experts = model.wp_pager->catalog_n_expert_pages(); |
| 150 | + if (n_pages != n_experts) { |
| 151 | + throw std::runtime_error(format( |
| 152 | + "weight pager: WP_RESIDENT_DENSE on but catalog has %d non-expert " |
| 153 | + "pages (n_pages=%d, expert_pages=%d) — dense filter missed tensors", |
| 154 | + n_pages - n_experts, n_pages, n_experts)); |
| 155 | + } |
| 156 | + LLAMA_LOG_WARN("%s: resident_dense=ON paged_pages=%d (all experts) " |
| 157 | + "dense tensors left resident to normal allocator\n", |
| 158 | + __func__, n_pages); |
| 159 | + } |
| 160 | +``` |
| 161 | +
|
| 162 | +- [ ] **Step 2: Add the two accessors this needs.** `wp_resident_dense_enabled_llama()` — a file-local `getenv("WP_RESIDENT_DENSE")=="1"` in `src/llama.cpp` (mirror of Task 2's helper; DRY note: both are one-liners over the same env, acceptable per file locality). `WeightPager::catalog_n_expert_pages()` — add to `src/weight-pager/wp-pager.h` a `int catalog_n_expert_pages() const { return catalog_.n_expert_pages(); }` inline accessor (the catalog method already exists at `wp-page-catalog.h:121`). |
| 163 | +
|
| 164 | +- [ ] **Step 3: Build `llama` target.** Expected: clean. |
| 165 | +
|
| 166 | +- [ ] **Step 4: Commit.** |
| 167 | +
|
| 168 | +``` |
| 169 | +git add src/llama.cpp src/weight-pager/wp-pager.h |
| 170 | +git commit -m "feat(wp): fail-loud guard + telemetry for resident-dense split" |
| 171 | +``` |
| 172 | +
|
| 173 | +### Task 4: Unit test — only experts paged when gated |
| 174 | +
|
| 175 | +**Files:** |
| 176 | +- Modify: `tests/test-weight-pager.cpp` (add one test + register in `main`) |
| 177 | +
|
| 178 | +**Interfaces:** |
| 179 | +- Consumes: `wp::PageCatalog` (`add`, `add_consolidated_experts`, `has_experts`, `n_expert_pages`, `at`). |
| 180 | +
|
| 181 | +- [ ] **Step 1: Write the failing test.** Mirror the existing catalog tests (e.g. `test_page_catalog_moe_classification`). The test asserts the classifier invariant the loader filter relies on: a consolidated expert tensor is `is_expert`, a dense tensor is not. |
| 182 | +
|
| 183 | +```cpp |
| 184 | +static int test_catalog_is_expert_classification() { |
| 185 | + int fails = 0; |
| 186 | + wp::PageCatalog cat; |
| 187 | + // Dense tensors — must NOT be experts. |
| 188 | + int p_attn = cat.add("blk.0.attn_q.weight", 0, 0, 4096); |
| 189 | + int p_emb = cat.add("token_embd.weight", 0, 0, 100000); |
| 190 | + int p_shex = cat.add("blk.0.ffn_down_shexp.weight", 0, 0, 8192); |
| 191 | + // Consolidated routed experts — MUST be experts. |
| 192 | + int first_sub = cat.add_consolidated_experts("blk.0.ffn_gate_exps.weight", |
| 193 | + 0, 0, 256*8192, 256); |
| 194 | + EXPECT(!cat.at(p_attn).is_expert, "attn_q is dense"); |
| 195 | + EXPECT(!cat.at(p_emb).is_expert, "token_embd is dense"); |
| 196 | + EXPECT(!cat.at(p_shex).is_expert, "ffn_down_shexp is dense (shared expert)"); |
| 197 | + EXPECT(cat.at(first_sub).is_expert, "ffn_gate_exps sub-page is expert"); |
| 198 | + EXPECT(cat.has_experts(), "catalog reports experts present"); |
| 199 | + return fails; |
| 200 | +} |
| 201 | +``` |
| 202 | + |
| 203 | +Note: confirm `ffn_down_shexp` (shared expert) is classified dense by the loader detection at `llama-model.cpp:1780-1782` — it matches `ffn_down_` but NOT `ffn_down_exps.`, so `is_consolidated` is false ⇒ dense. This test locks that in. If the catalog's own `add()` name-parse disagrees with the loader detection, **that discrepancy is a real bug to surface**, not to paper over. |
| 204 | + |
| 205 | +- [ ] **Step 2: Register in `main`** (`tests/test-weight-pager.cpp` `named_test tests[]`): |
| 206 | + |
| 207 | +```cpp |
| 208 | + { "catalog_is_expert_classification", test_catalog_is_expert_classification }, |
| 209 | +``` |
| 210 | +
|
| 211 | +- [ ] **Step 3: Run — expect FAIL if `is_expert` isn't set on sub-pages yet, else PASS.** |
| 212 | +
|
| 213 | +``` |
| 214 | +ssh mad-lab-main bash -s <<'EOF' |
| 215 | +cd ~/llama-wp/build-hip && cmake --build . --target test-weight-pager -j4 >/dev/null 2>&1 && ./bin/test-weight-pager 2>&1 | grep -E "is_expert_classification|total failures" |
| 216 | +EOF |
| 217 | +``` |
| 218 | +Expected: `PASS test_catalog_is_expert_classification`. |
| 219 | +
|
| 220 | +- [ ] **Step 4: Commit.** |
| 221 | +
|
| 222 | +``` |
| 223 | +git add tests/test-weight-pager.cpp |
| 224 | +git commit -m "test(wp): catalog is_expert classification (dense vs routed)" |
| 225 | +``` |
| 226 | +
|
| 227 | +### Task 5: VRAM budget — reserve dense-resident before sizing the expert pool |
| 228 | +
|
| 229 | +**Files:** |
| 230 | +- Modify: `src/llama.cpp:184-211` (the auto slot-budget block) |
| 231 | +
|
| 232 | +**Interfaces:** |
| 233 | +- Consumes: the gate; free-VRAM query already present at `:195-196`. |
| 234 | +
|
| 235 | +- [ ] **Step 1: The problem.** With the gate on, dense tensors load to VRAM via the normal allocator. If `init_weight_pager` runs its `hipMemGetInfo` **after** dense is allocated, `free_vram` already excludes dense — good. If it runs **before**, the pool would over-allocate. Verify ordering by reading the call site of `init_weight_pager` relative to `load_tensors`. **First implementation step is to confirm ordering (add a one-line `LLAMA_LOG_INFO("%s: free_vram=%zu MiB at pool sizing\n", __func__, free_vram/1048576)` and read it in the Phase-1 gate run).** |
| 236 | +
|
| 237 | +- [ ] **Step 2: If pool sizing runs before dense load** (free_vram too high): subtract the known dense-resident bytes. The dense byte total = sum of `ggml_nbytes` over non-expert weight tensors; expose it from the loader as `ml.dense_resident_bytes` (populate in the same loop at `llama-model.cpp:1799` when `!page_this`). Then in `src/llama.cpp` before `n_slots_fit`: |
| 238 | +
|
| 239 | +```cpp |
| 240 | + size_t dense_reserve = wp_resident_dense_enabled_llama() ? ml.dense_resident_bytes : 0; |
| 241 | + size_t usable2 = (usable > dense_reserve) ? (usable - dense_reserve) : 0; |
| 242 | + const int n_slots_fit = (max_page_size > 0) ? (int)(usable2 / max_page_size) : 0; |
| 243 | +``` |
| 244 | + |
| 245 | +- [ ] **Step 3: If ordering already excludes dense** (free_vram already net): no subtraction needed; document it with a comment and skip the reserve. **Pick exactly one path based on Step 1's evidence; do not guess.** |
| 246 | + |
| 247 | +- [ ] **Step 4: Build + commit.** |
| 248 | + |
| 249 | +``` |
| 250 | +git add src/llama.cpp src/llama-model.cpp src/llama-model-loader.h |
| 251 | +git commit -m "feat(wp): reserve dense-resident VRAM before sizing expert pool" |
| 252 | +``` |
| 253 | + |
| 254 | +### Task 6: Phase-1 integration gate (measured on mad-lab-main) |
| 255 | + |
| 256 | +**This task is a measurement + acceptance gate, not code. Requires the user's go-ahead to run inference.** |
| 257 | + |
| 258 | +- [ ] **Step 1: Build the server.** `cmake --build ~/llama-wp/build-hip --target llama-server -j4`. |
| 259 | +- [ ] **Step 2: Baseline (gate OFF).** Run the existing recipe (systemd-run, DeepSeek V4 Flash, `WP_ENSURE_BATCH=1`, depth 8), capture pager stats at clean SIGINT. Expect ~1013 page-ins/token, ~0.02 t/s (confirms no regression to baseline). |
| 260 | +- [ ] **Step 3: Gate ON.** Same recipe + `--setenv=WP_RESIDENT_DENSE=1`. Capture stats. |
| 261 | +- [ ] **Step 4: Acceptance gate.** PASS requires: page-ins/token drops to ≈ the routed-expert count (≈258 for DeepSeek), `sync_fallbacks` falls sharply, coherent output, 0 GPU faults, decode ≥ ~0.4 t/s. Record numbers in `docs/dev/weight-paging-batch-eval-continuation.md`. |
| 262 | +- [ ] **Step 5: Commit the doc update.** (No code.) |
| 263 | + |
| 264 | +--- |
| 265 | + |
| 266 | +## Phase 2 — Multi-device via Thunderbolt 3 (scoped; detailed after Phase 1) |
| 267 | + |
| 268 | +> These tasks are scoped with interfaces + anchors + gates. They are expanded into bite-sized TDD steps **after Phase 1 lands**, because the exact code depends on Phase 1's resident-load path. Listed here so the whole design is visible. |
| 269 | +
|
| 270 | +- **Task 7 — `--weight-paging-resident-device <dev|auto>` CLI flag.** Add to `common/arg.cpp` + `llama_model_params`. `auto` = the non-paging GPU if present else the paging card. Anchor: existing `--device`/`--weight-paging*` flags. **Gate:** flag parses, threads to `init_weight_pager`. |
| 271 | +- **Task 8 — Emit `tensor_buft_overrides` for dense→resident-device, expert→paging-device.** Under gate + resident-device set, synthesize override entries (dense tensor-name regex → resident buft; expert `_exps` → paging buft) reusing `src/llama-model-loader.cpp:1226-1251`. **Gate:** dense tensors land on the resident device buffer, experts on the paging device (assert at load); scheduler auto-inserts activation copies (`ggml-backend.cpp:1331-1360`) — verified by a coherent multi-device forward. |
| 272 | +- **Task 9 — Lift pager to per-device pools.** `PoolAllocator`/`WeightPager` keyed by `ggml_backend_buffer_type_t`; relax the `devices_used.size() > 1` guard (`wp-pager.cpp:182-188`); `pool_buf()`/`ensure()` select the pool for the paged tensor's device. Anchor: `wp-pool.h:5-14` ("per-device pools are a drop-in extension"). **Gate:** unit test for two-pool allocation; multi-device paged run coherent. |
| 273 | +- **Task 10 — Phase-2 integration gate.** Dense on 6900XT (ROCm1), full 32 GB R9700 (ROCm0) as expert cache; coherent output; decode ≥ Phase-1. |
| 274 | + |
| 275 | +## Phase 3 — Expert-locality cache (scoped; detailed after Phase 2) |
| 276 | + |
| 277 | +- **Task 11 — Hit-rate instrumentation** for the expert-only pool (per-generation reuse counter). Anchor: `wp-pool.*` LRU + hot-count, existing `prefetch_hit_rate` telemetry. |
| 278 | +- **Task 12 — Frequency/hotness-biased retention.** Bias eviction so generation-hot experts survive, layered on the existing LRU + hot-count. Keep MAD-88 sister prefetch (`wp-eval-cb.cpp:792-824`) and MAD-233 speculative reuse (`:826-910`) as-is. **Gate:** A/B hit-rate vs plain LRU on a fixed prompt shows improvement. |
| 279 | +- **Task 13 — Phase-3 integration gate.** Expert-cache hit-rate materially above ~5% baseline; decode climbs toward the model's per-token-bytes ceiling (1–3 t/s for the small-per-token models). |
| 280 | + |
| 281 | +## Non-goals (from spec) |
| 282 | + |
| 283 | +Kimi-K2.7-Code (dense too large); true cross-layer routing prediction (data-dependent, impossible); changing the GGUF quant. |
0 commit comments