Skip to content

Commit e5089c2

Browse files
kmbandyclaude
andcommitted
feat(wp): ensure_batch — concurrent QD=N expert page-in (Colibri pattern)
Decode paged the MoE active-expert set through a per-expert prefetch+ensure loop whose reserved slots stayed UNPINNED until harvest, so under decode eviction a later expert's read could evict an earlier one's not-yet-loaded slot, forcing a serial page_in_sync_ re-read — collapsing effective io_uring queue depth to ~1 (raising queue depth did nothing; the eviction window, not the depth, was the bottleneck). ensure_batch (opt-in WP_ENSURE_BATCH=1) reserves AND pins every cold-miss slot up front (alloc_slot skips pinned, so no sibling read can evict it), then on the P2P/direct-to-device path submits all misses in one io_uring batch (true QD=N) straight into VRAM, harvests, and on any read failure syncs into the SAME pinned slot. Returns the pinned pages for the caller to release on the next callback (existing per-op/per-range pin lifecycle). page_in_sync_ gains a reuse_slot arg so the failure fallback reads into the pinned slot without releasing it. Default off; existing prefetch+ensure path preserved in the else branch for A/B + rollback. Build-gated (HIP), pending R9700 decode A/B validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ZRfPpL8XFzk1hep9MMg9P
1 parent 752b445 commit e5089c2

3 files changed

Lines changed: 197 additions & 15 deletions

File tree

src/weight-pager/wp-eval-cb.cpp

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,56 @@ bool weight_pager_eval_cb(struct ggml_tensor * t, bool ask, void * user_data) {
595595
active.insert((int) idx);
596596
}
597597

598+
// WP_ENSURE_BATCH (opt-in): Colibri-style
599+
// concurrent batch page-in. Reserve+PIN every
600+
// active-expert slot up front, then issue all
601+
// cold-miss reads in ONE io_uring batch (true
602+
// QD=N). Closes the eviction window that let a
603+
// later expert's read evict an earlier one's
604+
// not-yet-harvested slot, collapsing effective
605+
// queue depth to ~1 under decode. Default off;
606+
// the else branch keeps the current path (A/B).
607+
static int s_ensure_batch_env = -1;
608+
if (s_ensure_batch_env < 0) {
609+
const char * eb = std::getenv("WP_ENSURE_BATCH");
610+
s_ensure_batch_env = (eb != nullptr && eb[0] == '1') ? 1 : 0;
611+
}
612+
int n_ensures = 0;
613+
void * first_active_slot = nullptr;
614+
if (s_ensure_batch_env == 1) {
615+
std::vector<int> active_pages;
616+
active_pages.reserve(active.size());
617+
for (int e : active) {
618+
active_pages.push_back(weight_page + 1 + e);
619+
}
620+
std::vector<void *> active_ptrs;
621+
std::vector<int> active_pinned;
622+
pager->ensure_batch(active_pages, active_ptrs, active_pinned);
623+
std::size_t ap = 0;
624+
for (int e : active) {
625+
void * slot = active_ptrs[ap++];
626+
if (slot != nullptr) {
627+
slot = capture_ptr_for_page(weight_page + 1 + e, slot);
628+
host_ptrs[(size_t) e] = slot;
629+
if (first_active_slot == nullptr) {
630+
first_active_slot = slot;
631+
}
632+
++n_ensures;
633+
#if defined(GGML_USE_HIP)
634+
enqueue_async_wait_for_page(weight_page + 1 + e, s_async_events_prev_op);
635+
#endif
636+
}
637+
}
638+
// Record the pins ensure_batch took so the
639+
// per-op / per-range lifecycle releases them.
640+
for (int p : active_pinned) {
641+
(paged_batch ? s_range_pins : s_pinned_pages_prev_op).push_back(p);
642+
if (paged_batch) { s_range_pinned_bytes += pager->page_meta(p).size; }
643+
}
644+
#if defined(GGML_USE_HIP)
645+
if (!active_pinned.empty()) { s_prev_op_pager = pager; }
646+
#endif
647+
} else {
598648
// Pass 1: fire async prefetch for every
599649
// active expert. With io_uring (depth 4),
600650
// multiple preads can be in flight at
@@ -643,8 +693,6 @@ bool weight_pager_eval_cb(struct ggml_tensor * t, bool ask, void * user_data) {
643693
// hit; for in-flight pages it waits on
644694
// the async completion; for unloaded
645695
// pages it falls back to sync.
646-
int n_ensures = 0;
647-
void * first_active_slot = nullptr;
648696
for (int e : active) {
649697
const int sub_page_idx = weight_page + 1 + e;
650698
void * slot = pager->ensure(sub_page_idx);
@@ -669,6 +717,8 @@ bool weight_pager_eval_cb(struct ggml_tensor * t, bool ask, void * user_data) {
669717
#endif
670718
}
671719
}
720+
} // end WP_ENSURE_BATCH else (existing prefetch+ensure path)
721+
672722
// Safety: fill INACTIVE expert slots with a non-null
673723
// sentinel (first active slot) so a kernel that reads
674724
// expert_ptrs[inactive_idx] gets a valid (wrong) pointer

src/weight-pager/wp-pager.cpp

Lines changed: 126 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,114 @@ void * WeightPager::ensure(int page_idx) {
873873
return slot_ptr_(slot);
874874
}
875875

876+
void WeightPager::ensure_batch(const std::vector<int> & page_indices,
877+
std::vector<void *> & out_ptrs,
878+
std::vector<int> & out_pinned) {
879+
out_ptrs.assign(page_indices.size(), nullptr);
880+
out_pinned.clear();
881+
if (!initialized_) return;
882+
883+
// Pass 1 — resolve hits/pinned inline; for every cold miss reserve AND PIN
884+
// a slot up front. Pinning before any sibling read means alloc_slot for a
885+
// later miss can never evict an earlier miss's in-flight slot (alloc_slot
886+
// skips pinned), which is what collapsed effective QD to ~1 under decode
887+
// eviction. Each pinned page is reported in out_pinned for the caller to
888+
// release next callback.
889+
struct Miss { int page; int slot; std::size_t out_i; };
890+
std::vector<Miss> misses;
891+
misses.reserve(page_indices.size());
892+
for (std::size_t i = 0; i < page_indices.size(); ++i) {
893+
const int p = page_indices[i];
894+
if (p < 0 || p >= catalog_.size()) continue;
895+
const PageMeta & m = catalog_.at(p);
896+
if (m.is_pinned) { // MAD-236: always-resident, no slot
897+
out_ptrs[i] = m.resident_ptr;
898+
continue;
899+
}
900+
if (page_loaded_[p]) { // hit — bump LRU, pin, harvest
901+
const int s = page_to_slot_[p];
902+
pool_.mark_used(s);
903+
pool_.pin_slot(s);
904+
out_pinned.push_back(p);
905+
out_ptrs[i] = slot_ptr_(s);
906+
continue;
907+
}
908+
if (page_to_slot_[p] >= 0) {
909+
// Reserved by an in-flight (e.g. cross-layer) prefetch. Harvest via
910+
// the tested ensure() path (waits on the prefetch, or syncs), then
911+
// pin the result. Not part of the concurrent batch.
912+
void * ptr = ensure(p);
913+
if (ptr != nullptr) {
914+
pool_.pin_slot(page_to_slot_[p]);
915+
out_pinned.push_back(p);
916+
out_ptrs[i] = ptr;
917+
}
918+
continue;
919+
}
920+
const int s = pool_.alloc_slot(m.size);
921+
if (s < 0) { ++stats_.sync_fallbacks; continue; } // pool exhausted (rare)
922+
ensure_slot_map_(s);
923+
page_to_slot_[p] = s;
924+
slot_to_page_[s] = p;
925+
pool_.pin_slot(s); // PIN before the next alloc
926+
out_pinned.push_back(p);
927+
misses.push_back({ p, s, i });
928+
}
929+
if (misses.empty()) return;
930+
931+
// Pass 2 — issue all cold-miss reads. On the P2P/direct-to-device path the
932+
// reads land straight in the VRAM slots, so one io_uring batch keeps N reads
933+
// in flight at once (true QD=N). Off P2P the shared staging buffer can't
934+
// hold N pages, so fall back to serial sync into each pinned slot (still
935+
// correct; the throughput case that matters for decode is P2P).
936+
if (file_io_->direct_to_device()) {
937+
std::vector<FileIOBatchRequest> reqs;
938+
reqs.reserve(misses.size());
939+
for (std::size_t k = 0; k < misses.size(); ++k) {
940+
const PageMeta & m = catalog_.at(misses[k].page);
941+
reqs.push_back({ (uint64_t) k, (int) m.file_idx, m.file_offset,
942+
m.size, slot_ptr_(misses[k].slot) });
943+
}
944+
const int n_sub = file_io_->submit_batch(reqs);
945+
file_io_->flush();
946+
std::vector<bool> ok(misses.size(), false);
947+
int reaped = 0;
948+
while (reaped < n_sub) {
949+
IoResult r = file_io_->wait_any(/*timeout_ms=*/-1);
950+
if (r.req_id < misses.size()) {
951+
ok[(std::size_t) r.req_id] =
952+
(r.status == IoStatus::Ok &&
953+
r.bytes_read == (int) catalog_.at(misses[(std::size_t) r.req_id].page).size);
954+
++reaped;
955+
}
956+
// Unknown req_id (stale prefetch completion): drop, keep waiting.
957+
}
958+
const auto io_t0 = std::chrono::steady_clock::now();
959+
for (std::size_t k = 0; k < misses.size(); ++k) {
960+
const Miss & mm = misses[k];
961+
const PageMeta & m = catalog_.at(mm.page);
962+
if (ok[k] && zero_device_padding(slot_ptr_(mm.slot), m.size, pool_.slot_size(mm.slot))) {
963+
page_to_slot_[mm.page] = mm.slot;
964+
page_loaded_[mm.page] = true;
965+
slot_to_page_[mm.slot] = mm.page;
966+
pool_.mark_used(mm.slot);
967+
record_page_in_(m.size, seconds_since(io_t0));
968+
out_ptrs[mm.out_i] = slot_ptr_(mm.slot);
969+
} else {
970+
// read (or padding) failed — sync-fallback into the SAME pinned
971+
// slot so the up-front pin/out_pinned bookkeeping stays valid.
972+
const int s = page_in_sync_(mm.page, /*reuse_slot=*/mm.slot);
973+
out_ptrs[mm.out_i] = (s < 0) ? nullptr : slot_ptr_(s);
974+
}
975+
}
976+
} else {
977+
for (const Miss & mm : misses) {
978+
const int s = page_in_sync_(mm.page, /*reuse_slot=*/mm.slot);
979+
out_ptrs[mm.out_i] = (s < 0) ? nullptr : slot_ptr_(s);
980+
}
981+
}
982+
}
983+
876984
int WeightPager::take_async_transfer_event(int page_idx) {
877985
if (!initialized_) return -1;
878986
if (page_idx < 0 || page_idx >= (int) page_async_event_.size()) return -1;
@@ -1119,10 +1227,13 @@ void WeightPager::mark_cross_layer_prefetch_candidates(const std::vector<int> &
11191227
}
11201228
}
11211229

1122-
int WeightPager::page_in_sync_(int page_idx) {
1123-
// Synchronous read into a fresh slot, bypassing the prefetch pipeline.
1124-
// Used by ensure() on miss. Host transports read into staging and then
1125-
// hand off to GpuTransport; P2P transports read into the VRAM slot.
1230+
int WeightPager::page_in_sync_(int page_idx, int reuse_slot) {
1231+
// Synchronous read into a slot, bypassing the prefetch pipeline. Used by
1232+
// ensure() on miss (reuse_slot < 0: alloc a fresh slot) and by ensure_batch
1233+
// as its per-page failure fallback (reuse_slot >= 0: read into that already-
1234+
// reserved+pinned slot; never release it here). Host transports read into
1235+
// staging and then hand off to GpuTransport; P2P transports read into VRAM.
1236+
const bool owns_slot = (reuse_slot < 0);
11261237

11271238
static int s_diag_count = 0;
11281239
const bool diag = (s_diag_count < 5);
@@ -1137,9 +1248,12 @@ int WeightPager::page_in_sync_(int page_idx) {
11371248

11381249
const PageMeta & m = catalog_.at(page_idx);
11391250

1140-
const int slot = pool_.alloc_slot(m.size);
1141-
if (slot < 0) return -1;
1142-
ensure_slot_map_(slot);
1251+
int slot = reuse_slot;
1252+
if (slot < 0) {
1253+
slot = pool_.alloc_slot(m.size);
1254+
if (slot < 0) return -1;
1255+
ensure_slot_map_(slot);
1256+
}
11431257
void * dst = slot_ptr_(slot);
11441258
if (diag) LLAMA_LOG_ERROR("[DIAG] page_in_sync_[%d]: alloc_slot ok, slot=%d dst=%p\n", s_diag_count, slot, dst);
11451259

@@ -1151,7 +1265,7 @@ int WeightPager::page_in_sync_(int page_idx) {
11511265
if (staging == nullptr || m.size > sync_staging_size_) {
11521266
LLAMA_LOG_ERROR("wp::WeightPager::page_in_sync_: page %d size %zu exceeds shared staging size %zu\n",
11531267
page_idx, m.size, sync_staging_size_);
1154-
pool_.release_slot(slot);
1268+
if (owns_slot) pool_.release_slot(slot);
11551269
return -1;
11561270
}
11571271

@@ -1163,7 +1277,7 @@ int WeightPager::page_in_sync_(int page_idx) {
11631277
if (evt < 0) {
11641278
LLAMA_LOG_WARN("wp::WeightPager::page_in_sync_: host tier gpu stage_in failed for page %d\n",
11651279
page_idx);
1166-
pool_.release_slot(slot);
1280+
if (owns_slot) pool_.release_slot(slot);
11671281
return -1;
11681282
}
11691283
transport_.release_event(evt);
@@ -1212,13 +1326,13 @@ int WeightPager::page_in_sync_(int page_idx) {
12121326
if (diag) LLAMA_LOG_ERROR("[DIAG] page_in_sync_[%d]: stage1 done ok=%d\n", s_diag_count, (int)ok);
12131327
if (!ok) {
12141328
LLAMA_LOG_WARN("wp::WeightPager::page_in_sync_: file IO failed for page %d\n", page_idx);
1215-
pool_.release_slot(slot);
1329+
if (owns_slot) pool_.release_slot(slot);
12161330
return -1;
12171331
}
12181332

12191333
if (direct_to_device) {
12201334
if (!zero_device_padding(dst, m.size, pool_.slot_size(slot))) {
1221-
pool_.release_slot(slot);
1335+
if (owns_slot) pool_.release_slot(slot);
12221336
return -1;
12231337
}
12241338
page_to_slot_[page_idx] = slot;
@@ -1241,7 +1355,7 @@ int WeightPager::page_in_sync_(int page_idx) {
12411355
if (diag) LLAMA_LOG_ERROR("[DIAG] page_in_sync_[%d]: stage_in returned evt=%d\n", s_diag_count, evt);
12421356
if (evt < 0) {
12431357
LLAMA_LOG_WARN("wp::WeightPager::page_in_sync_: gpu stage_in failed for page %d\n", page_idx);
1244-
pool_.release_slot(slot);
1358+
if (owns_slot) pool_.release_slot(slot);
12451359
return -1;
12461360
}
12471361
transport_.release_event(evt);

src/weight-pager/wp-pager.h

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,20 @@ class WeightPager {
168168
// page_idx is out of range or any underlying op fails.
169169
void * ensure(int page_idx);
170170

171+
// Batch-ensure a set of pages with all cold-miss reads issued CONCURRENTLY
172+
// (Colibri pattern). Each miss's slot is reserved AND pinned up front so no
173+
// read in the batch can evict a sibling's not-yet-loaded slot — the eviction
174+
// window that collapsed effective io_uring queue depth to ~1 under decode
175+
// pressure. On the P2P (direct-to-device) IO path the misses are submitted in
176+
// one io_uring batch (true QD=N) reading straight into the VRAM slots.
177+
// Fills out_ptrs[i] with the slot pointer for pages[i] (nullptr on failure /
178+
// pool exhaustion), and out_pinned with every page this call pinned — the
179+
// CALLER must record those and unpin them in the next eval callback (matches
180+
// the per-op pin lifecycle used by ensure()+pin_page).
181+
void ensure_batch(const std::vector<int> & page_indices,
182+
std::vector<void *> & out_ptrs,
183+
std::vector<int> & out_pinned);
184+
171185
// WP_ASYNC_ENSURE handoff. ensure() stashes the transfer event here when
172186
// it returns before stage 2 has completed; the eval callback takes it,
173187
// queues a compute-stream wait, and releases it after that op completes.
@@ -254,7 +268,11 @@ class WeightPager {
254268
// Internal helper: synchronous page-in (used by ensure() on miss).
255269
// Reads the page's bytes via FileIOLayer (sync path), copies to VRAM,
256270
// and zeros the padding. Returns the slot index or -1 on failure.
257-
int page_in_sync_(int page_idx);
271+
// reuse_slot >= 0: read into that caller-owned (typically pinned) slot
272+
// instead of allocating a fresh one, and do NOT release it on failure — the
273+
// caller owns its lifecycle. reuse_slot < 0 keeps the original behavior
274+
// (alloc a slot, release it on any error). Returns the slot index or -1.
275+
int page_in_sync_(int page_idx, int reuse_slot = -1);
258276

259277
// Resolve a slot index to a VRAM pointer.
260278
void * slot_ptr_(int slot_idx) const { return pool_.slot_ptr(slot_idx); }

0 commit comments

Comments
 (0)