Skip to content

Commit 98cf181

Browse files
committed
plans: NVMe->VRAM demand paging design (5 phases)
1 parent 24352d3 commit 98cf181

1 file changed

Lines changed: 312 additions & 0 deletions

File tree

plans/nvme-vram-weight-paging.md

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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

Comments
 (0)