|
| 1 | +# NVMe→VRAM Demand Paging for Oversized Models |
| 2 | + |
| 3 | +## Goal |
| 4 | + |
| 5 | +Enable running models larger than available VRAM by treating NVMe SSD as the |
| 6 | +backing store for model weights. Layers not currently needed live on NVMe. |
| 7 | +Before each layer is computed, its weights are paged in NVMe→VRAM directly |
| 8 | +via SAM/ReBAR (bypassing RAM entirely). When VRAM is full, the LRU layer is |
| 9 | +evicted (weights are read-only so no write-back needed — just mark the slot |
| 10 | +free). RAM is never involved. |
| 11 | + |
| 12 | +**Hardware context:** AMD Radeon AI Pro R9700 (gfx1201, 32GB VRAM) + NVMe SSD |
| 13 | +with SAM/ReBAR enabled. Measured NVMe→VRAM bandwidth via SAM: ~5.7 GB/s. |
| 14 | + |
| 15 | +## Architecture Overview |
| 16 | + |
| 17 | +``` |
| 18 | +NVMe (GGUF file) |
| 19 | + | |
| 20 | + | io_uring O_DIRECT reads |
| 21 | + | into SAM-mapped BAR1 addresses |
| 22 | + v |
| 23 | +VRAM Slot Pool ←→ Weight Page Table ←→ LRU Eviction |
| 24 | + | |
| 25 | + | ggml eval callback hook |
| 26 | + v |
| 27 | +Compute Graph (forward pass) |
| 28 | +``` |
| 29 | + |
| 30 | +Key insight: model weights are **read-only**. Eviction = mark slot free. |
| 31 | +No write-back. This makes the pager much simpler than a general VM system. |
| 32 | + |
| 33 | +## Phases |
| 34 | + |
| 35 | +--- |
| 36 | + |
| 37 | +### Phase 1: Weight Page Table + VRAM Slot Pool |
| 38 | +**File:** `src/llama-weight-pager.h` / `src/llama-weight-pager.cpp` |
| 39 | +**Depends on:** nothing (pure data structures + hipMalloc) |
| 40 | +**Testable standalone:** yes — unit test alloc/evict/LRU without IO |
| 41 | + |
| 42 | +#### Data structures |
| 43 | + |
| 44 | +```cpp |
| 45 | +struct llama_weight_page { |
| 46 | + std::string tensor_name; // ggml tensor name (e.g. "blk.0.attn_q.weight") |
| 47 | + size_t file_offset; // byte offset in GGUF file |
| 48 | + size_t size; // tensor size in bytes |
| 49 | + int slot_idx; // VRAM slot index, -1 = not in VRAM |
| 50 | + uint64_t last_used; // monotonic counter for LRU |
| 51 | + void * vram_ptr; // pointer into slot pool, null if not loaded |
| 52 | +}; |
| 53 | + |
| 54 | +struct llama_vram_pool { |
| 55 | + void * base; // hipMalloc'd contiguous VRAM block |
| 56 | + size_t slot_size; // bytes per slot (= max single-layer weight size) |
| 57 | + int n_slots; // total slots = floor(pool_bytes / slot_size) |
| 58 | + std::vector<bool> used; |
| 59 | + std::vector<uint64_t> lru_tick; // per-slot last-used tick |
| 60 | + |
| 61 | + int alloc_slot(); // returns slot idx, evicts LRU if full |
| 62 | + void free_slot(int idx); |
| 63 | + void * slot_ptr(int idx) { return (uint8_t*)base + (size_t)idx * slot_size; } |
| 64 | +}; |
| 65 | + |
| 66 | +struct llama_weight_pager { |
| 67 | + std::string model_path; |
| 68 | + int fd; // open fd for io_uring reads |
| 69 | + llama_vram_pool pool; |
| 70 | + std::vector<llama_weight_page> pages; // one per weight tensor |
| 71 | + std::unordered_map<std::string, int> name_to_page; |
| 72 | + uint64_t tick = 0; |
| 73 | + |
| 74 | + // Ensure tensor is in VRAM. Returns VRAM pointer. |
| 75 | + void * ensure(const std::string & name); |
| 76 | + void evict_lru(); |
| 77 | + int find_page(const std::string & name); |
| 78 | +}; |
| 79 | +``` |
| 80 | +
|
| 81 | +#### What to implement |
| 82 | +- `llama_vram_pool::alloc_slot()` — scan `used[]`, if all full find min `lru_tick` and evict |
| 83 | +- `llama_weight_pager::ensure()` — if already in VRAM update tick and return; else page_in() |
| 84 | +- `llama_weight_pager::evict_lru()` — free slot, set page.slot_idx = -1, page.vram_ptr = null |
| 85 | +
|
| 86 | +**No IO in this phase.** `page_in()` is a stub that returns nullptr. Pool is |
| 87 | +allocated with `hipMalloc`. Slot size is set externally (Phase 2 sets it). |
| 88 | +
|
| 89 | +--- |
| 90 | +
|
| 91 | +### Phase 2: GGUF Offset Extraction + SAM Page-In |
| 92 | +**File:** `src/llama-weight-pager.cpp` (page_in impl) + changes to `src/llama-model-loader.cpp` |
| 93 | +**Depends on:** Phase 1, liburing, HIP |
| 94 | +
|
| 95 | +#### 2a: Extract tensor file offsets during model load |
| 96 | +
|
| 97 | +In `llama_model_loader::load_all_data`, before the main load loop, for each |
| 98 | +weight tensor record its `(name, file_offset, size)` into a |
| 99 | +`std::vector<llama_weight_page_info>` that gets stored on the model. |
| 100 | +
|
| 101 | +Key: `weight->offs` is already the byte offset into the GGUF file for each |
| 102 | +tensor. This just needs to be saved instead of discarded after loading. |
| 103 | +
|
| 104 | +Add to `llama_model` (in `llama-model.h`): |
| 105 | +```cpp |
| 106 | +std::unique_ptr<llama_weight_pager> weight_pager; // null if paging disabled |
| 107 | +``` |
| 108 | + |
| 109 | +Add to `common_params` (in `common.h`): |
| 110 | +```cpp |
| 111 | +bool weight_paging = false; // --weight-paging flag |
| 112 | +int weight_paging_slots = -1; // VRAM slots (-1 = auto from -ngl) |
| 113 | +``` |
| 114 | + |
| 115 | +CLI flag: `--weight-paging` (enables the system) |
| 116 | + |
| 117 | +#### 2b: SAM page_in implementation |
| 118 | + |
| 119 | +```cpp |
| 120 | +void llama_weight_pager::page_in(llama_weight_page & page) { |
| 121 | + int slot = pool.alloc_slot(); // may evict LRU |
| 122 | + void * dst = pool.slot_ptr(slot); // fine-grained VRAM address via SAM |
| 123 | + |
| 124 | + // O_DIRECT aligned read directly into VRAM via BAR1 |
| 125 | + // Use pread with O_DIRECT — simpler than io_uring for synchronous case |
| 126 | + size_t aligned_off = page.file_offset & ~(size_t)(DIRECT_IO_ALIGN - 1); |
| 127 | + size_t prefix = page.file_offset - aligned_off; |
| 128 | + // read into staging, copy to slot (fallback path) |
| 129 | + // OR: pread directly into dst (SAM path — dst is BAR1-mapped VRAM) |
| 130 | + ssize_t n = pread(fd, dst, page.size, page.file_offset); |
| 131 | + |
| 132 | + page.slot_idx = slot; |
| 133 | + page.vram_ptr = (uint8_t*)dst; |
| 134 | + pool.used[slot] = true; |
| 135 | + pool.lru_tick[slot] = ++tick; |
| 136 | +} |
| 137 | +``` |
| 138 | +
|
| 139 | +**Important:** For `pread` into a `hipExtMallocWithFlags(hipDeviceMallocFinegrained)` |
| 140 | +address to work, the write must reach VRAM via BAR1. Test this with a small |
| 141 | +synthetic benchmark before wiring into inference. |
| 142 | +
|
| 143 | +If `pread` into fine-grained VRAM works → full SAM path. |
| 144 | +If not → use `hipHostMalloc` staging + `hipMemcpyAsync` as fallback. Still |
| 145 | +avoids persistent RAM residency (staging buffer is temporary, reused). |
| 146 | +
|
| 147 | +#### Slot sizing |
| 148 | +`slot_size = max weight tensor size across all layers`. Compute during offset |
| 149 | +extraction pass. Typical value for 100B Q3 model: ~500MB per layer. |
| 150 | +
|
| 151 | +--- |
| 152 | +
|
| 153 | +### Phase 3: ggml Eval Callback Hook |
| 154 | +**File:** `src/llama.cpp` (context init) + `tools/server/server-context.cpp` |
| 155 | +**Depends on:** Phase 1 + 2 |
| 156 | +
|
| 157 | +ggml provides `ggml_backend_sched_set_eval_callback(sched, cb, userdata)`. |
| 158 | +The callback fires **before** each graph node is executed. |
| 159 | +
|
| 160 | +```cpp |
| 161 | +static bool weight_pager_eval_cb(struct ggml_tensor * t, bool ask, void * user_data) { |
| 162 | + if (ask) return true; // yes we want the callback |
| 163 | + auto * pager = (llama_weight_pager *)user_data; |
| 164 | +
|
| 165 | + // For each src tensor of t that is a weight tensor (has a page entry): |
| 166 | + for (int i = 0; i < GGML_MAX_SRC; i++) { |
| 167 | + if (!t->src[i]) break; |
| 168 | + auto it = pager->name_to_page.find(ggml_get_name(t->src[i])); |
| 169 | + if (it != pager->name_to_page.end()) { |
| 170 | + auto & page = pager->pages[it->second]; |
| 171 | + void * vram = pager->ensure(page); |
| 172 | + // Redirect tensor data pointer to current VRAM slot |
| 173 | + t->src[i]->data = vram; |
| 174 | + } |
| 175 | + } |
| 176 | + return true; |
| 177 | +} |
| 178 | +``` |
| 179 | + |
| 180 | +Wire up in `llama_new_context_with_model` when `weight_pager` is set: |
| 181 | +```cpp |
| 182 | +if (model.weight_pager) { |
| 183 | + ggml_backend_sched_set_eval_callback(ctx->sched, |
| 184 | + weight_pager_eval_cb, model.weight_pager.get()); |
| 185 | +} |
| 186 | +``` |
| 187 | + |
| 188 | +**Critical:** `t->src[i]->data` redirection must be safe to do mid-graph. |
| 189 | +ggml backends read `data` at op execution time, not at graph build time, |
| 190 | +so this is safe as long as the VRAM slot isn't evicted before the op runs. |
| 191 | +Eviction is prevented because `ensure()` updates `last_used` and eviction |
| 192 | +only touches the LRU slot. |
| 193 | + |
| 194 | +--- |
| 195 | + |
| 196 | +### Phase 4: Async Prefetch via io_uring |
| 197 | +**File:** `src/llama-weight-pager.cpp` |
| 198 | +**Depends on:** Phase 1-3 |
| 199 | + |
| 200 | +The forward pass visits layers in order 0, 1, 2, ... N. While layer K is |
| 201 | +being computed on GPU, prefetch layer K+1's weights from NVMe. |
| 202 | + |
| 203 | +Replace synchronous `pread` in `page_in` with io_uring async submission: |
| 204 | + |
| 205 | +```cpp |
| 206 | +struct prefetch_req { |
| 207 | + int page_idx; |
| 208 | + int slot_idx; |
| 209 | + void * dst; |
| 210 | +}; |
| 211 | + |
| 212 | +void llama_weight_pager::submit_prefetch(int page_idx) { |
| 213 | + auto & page = pages[page_idx]; |
| 214 | + if (page.slot_idx >= 0) return; // already in VRAM |
| 215 | + int slot = pool.alloc_slot(); |
| 216 | + void * dst = pool.slot_ptr(slot); |
| 217 | + io_uring_submit_read(page.file_offset, dst, page.size, page_idx); |
| 218 | + in_flight[page_idx] = { page_idx, slot, dst }; |
| 219 | +} |
| 220 | + |
| 221 | +void llama_weight_pager::complete_prefetch(int page_idx) { |
| 222 | + // wait for io_uring completion for page_idx |
| 223 | + // mark page as loaded |
| 224 | +} |
| 225 | +``` |
| 226 | +
|
| 227 | +The eval callback becomes two-phase: |
| 228 | +1. On layer K entry: `complete_prefetch(K)` (wait if still in flight) |
| 229 | +2. After layer K dispatch: `submit_prefetch(K+1)` |
| 230 | +
|
| 231 | +This pipelines NVMe reads with GPU compute, hiding the ~5.7 GB/s transfer |
| 232 | +latency behind computation. |
| 233 | +
|
| 234 | +--- |
| 235 | +
|
| 236 | +### Phase 5: Integration, Flags, and Metrics |
| 237 | +**File:** `common/arg.cpp`, `tools/server/server-context.cpp`, `tools/server/server-http.cpp` |
| 238 | +**Depends on:** Phase 1-4 |
| 239 | +
|
| 240 | +#### New CLI flags |
| 241 | +``` |
| 242 | +--weight-paging Enable NVMe→VRAM demand paging for model weights |
| 243 | +--weight-paging-slots N Number of VRAM layer slots (default: -ngl value) |
| 244 | +--weight-paging-prefetch Enable async prefetch of next layer (default: on) |
| 245 | +``` |
| 246 | +
|
| 247 | +#### Metrics endpoint additions (`/metrics`) |
| 248 | +``` |
| 249 | +llama_weight_pager_page_ins_total |
| 250 | +llama_weight_pager_evictions_total |
| 251 | +llama_weight_pager_prefetch_hits_total (layer was ready before needed) |
| 252 | +llama_weight_pager_prefetch_misses_total (had to wait for IO) |
| 253 | +llama_weight_pager_io_bytes_total |
| 254 | +llama_weight_pager_io_seconds_total (compute bandwidth: bytes/seconds) |
| 255 | +``` |
| 256 | +
|
| 257 | +#### Interaction with tiered KV cache |
| 258 | +Weight paging and KV tiering are independent systems on the same hardware: |
| 259 | +- Weight pager uses VRAM for layer weights, NVMe as backing |
| 260 | +- KV tiered cache uses VRAM for hot KV, RAM for warm, NVMe for cold |
| 261 | +- They share NVMe bandwidth — io_uring submission queues should be separate |
| 262 | + fds (model file vs KV slab files) to avoid head-of-line blocking |
| 263 | +
|
| 264 | +--- |
| 265 | +
|
| 266 | +## File List (new/modified) |
| 267 | +
|
| 268 | +| File | Change | |
| 269 | +|------|--------| |
| 270 | +| `src/llama-weight-pager.h` | NEW — page table + pool declarations | |
| 271 | +| `src/llama-weight-pager.cpp` | NEW — implementation | |
| 272 | +| `src/llama-model-loader.cpp` | save tensor file offsets, init pager | |
| 273 | +| `src/llama-model.h` | add `weight_pager` field | |
| 274 | +| `src/llama.cpp` | register eval callback when pager present | |
| 275 | +| `common/common.h` | add `weight_paging` params | |
| 276 | +| `common/arg.cpp` | add `--weight-paging` flags | |
| 277 | +| `tools/server/server-context.cpp` | pass pager config to init | |
| 278 | +| `src/CMakeLists.txt` | add llama-weight-pager.cpp | |
| 279 | +
|
| 280 | +--- |
| 281 | +
|
| 282 | +## Implementation Order for Handoff |
| 283 | +
|
| 284 | +Each phase can be handed to a separate model session: |
| 285 | +
|
| 286 | +1. **Session A** → Phase 1 only. Deliverable: `llama-weight-pager.h/cpp` with |
| 287 | + pool alloc/evict/LRU working, unit-testable without IO or HIP. |
| 288 | +
|
| 289 | +2. **Session B** → Phase 2. Deliverable: offset extraction wired into model |
| 290 | + loader + `page_in()` with SAM pread. Requires reading Phase 1 output. |
| 291 | + Test: load a model with `--weight-paging`, confirm pread hits VRAM slot. |
| 292 | +
|
| 293 | +3. **Session C** → Phase 3. Deliverable: eval callback hook wired, inference |
| 294 | + works correctly (outputs match non-paged run). Requires Phase 1+2 output. |
| 295 | +
|
| 296 | +4. **Session D** → Phase 4. Deliverable: async io_uring prefetch, pipeline |
| 297 | + bench showing >80% prefetch hit rate. Requires Phase 1-3 output. |
| 298 | +
|
| 299 | +5. **Session E** → Phase 5. Deliverable: flags, metrics, integration tests. |
| 300 | +
|
| 301 | +--- |
| 302 | +
|
| 303 | +## Key Risks and Mitigations |
| 304 | +
|
| 305 | +| Risk | Mitigation | |
| 306 | +|------|------------| |
| 307 | +| `pread` into fine-grained VRAM fails on kernel | Benchmark first (Phase 2); fall back to staging+hipMemcpyAsync | |
| 308 | +| Slot eviction races with GPU compute | LRU never evicts a slot whose `last_used == tick` (current tick); GPU runs synchronously per-layer | |
| 309 | +| VRAM pool too large, OOM | `--weight-paging-slots` cap; default to `-ngl` value | |
| 310 | +| io_uring and KV tier contend on NVMe | Separate submission queues; weight pager gets priority | |
| 311 | +| Tensor data pointer redirect unsafe | Test with small model first; ggml reads `data` at execution not graph build | |
| 312 | +
|
0 commit comments